diff --git a/CLAUDE.md b/CLAUDE.md index 44c854c88..3d0188b55 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,8 @@ npm run dev # Start daemon + Expo in Tmux npm run cli -- ls -a -g # List all agents npm run cli -- daemon status # Check daemon status npm run typecheck # Always run after changes +npm run db:query # Show DB table row counts +npm run db:query -- "SELECT ..." # Run arbitrary SQL against SQLite ``` See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 30f617ccc..9fa18db0d 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -35,6 +35,25 @@ In worktrees or with `npm run dev`, ports may differ. Never assume defaults. Check `$PASEO_HOME/daemon.log` for trace-level logs. +### Database queries + +Run arbitrary SQL against the SQLite database: + +```bash +# Show table row counts +npm run db:query + +# Run any SQL +npm run db:query -- "SELECT agent_id, title, last_status FROM agent_snapshots" +npm run db:query -- "SELECT agent_id, seq, item_kind FROM agent_timeline_rows ORDER BY committed_at DESC LIMIT 10" + +# Point at a specific DB directory +npm run db:query -- --db /path/to/db "SELECT ..." +``` + +Auto-detects the running dev daemon's database from `/tmp/paseo-dev.*`, `PASEO_HOME`, or `~/.paseo/db`. +Pass either a DB directory or a `paseo.sqlite` file to `--db`. The script opens the database directly in read-only mode. + ## Build sync gotchas ### Relay → Daemon diff --git a/docs/STORAGE_REVAMP_PLAN.md b/docs/STORAGE_REVAMP_PLAN.md new file mode 100644 index 000000000..48090faeb --- /dev/null +++ b/docs/STORAGE_REVAMP_PLAN.md @@ -0,0 +1,217 @@ +# Storage Revamp Plan + +Status: active rollout, phases 1 and 2 complete + +This document now tracks the storage revamp as it exists today, not as a speculative design exercise. +The DB foundation and the project/workspace identity cutover have landed. What remains is the explicit +creation/archive surface cleanup, timeline durability cutover, and final removal of legacy paths. + +## Goals + +- make structured records durable in Drizzle + SQLite +- make projects and workspaces explicit first-class records +- stop deriving project/workspace identity from agent `cwd` +- keep agent snapshot persistence behind clear ownership +- move committed timeline history to storage-owned rows +- remove legacy JSON and in-memory authority once the DB path is proven + +## Out of scope + +- moving config, keypairs, push tokens, or server identity into the DB +- persisting raw provider deltas or transport-only chunk streams +- designing a hosted/remote database story beyond keeping the schema portable +- durable reasoning history unless product explicitly asks for it later + +## Current state + +The storage revamp is no longer hypothetical. + +Completed: + +- Drizzle + SQLite database bootstrap is in place +- `projects`, `workspaces`, and `agent_snapshots` use integer primary keys +- `workspaces.project_id` and `agent_snapshots.workspace_id` cascade on delete +- `agent_snapshots.workspace_id` is `NOT NULL` +- legacy JSON import feeds the DB-backed structured records +- project/workspace records use explicit `directory` fields instead of path-as-identity +- session read paths now use persisted workspace/project rows instead of cwd/git derivation +- `workspace-reconciliation-service.ts` is deleted +- `workspace-registry-bootstrap.ts` is deleted +- `workspace-registry-model.ts` is reduced to `normalizeWorkspaceId` + +Still pending: + +- explicit `create_project` / `create_workspace` API cleanup +- final archive cascade behavior for descendants and live agents +- committed timeline storage cutover +- removal of remaining legacy JSON and in-memory committed-history authority + +## Converged decisions + +### Structured record authority + +Projects, workspaces, and agent snapshots are DB-backed structured records. +The server should not recreate project/workspace identity from: + +- git remotes +- worktree main-repo roots +- normalized cwd strings + +Temporary exception: + +- agent creation may still find-or-create a workspace by directory if the UI has not yet provided + `workspaceId` explicitly + +That fallback is transitional and should be deleted once the client always sends the workspace id. + +### Storage seams + +The useful seams remain concrete and domain-shaped: + +- `ProjectRegistry` +- `WorkspaceRegistry` +- `AgentSnapshotStore` +- `AgentTimelineStore` + +There is no reason to reintroduce a reconciliation service layer for project/workspace identity. + +### Timeline contract + +The long-term timeline contract remains: + +- committed rows are durable, canonical history +- provisional live updates are transient subscription state +- committed history is fetched by seq +- provider history replay is not the durability mechanism + +The structured-record cutover is complete before the timeline cutover so timeline rows can rely on +stable DB-backed agent and workspace identity. + +## Remaining phases + +### Phase 3: Explicit creation and archive cleanup + +Goal: +Remove the last transitional write paths that still infer state from directories. + +Required work: + +- add explicit `create_project` handling +- add explicit `create_workspace` handling +- make agent creation require `workspaceId` once the UI is ready +- finish archive semantics for workspaces/projects and any descendant agent state +- remove the temporary find-or-create-by-directory fallback from agent creation + +Exit gate: + +- project/workspace creation is explicit end to end +- no normal creation path infers identity from cwd or git metadata +- archive flows behave consistently for structured records and live runtime state + +### Phase 4: Timeline storage cutover + +Goal: +Make committed history durable and storage-owned. + +Required work: + +- make `AgentTimelineStore` authoritative for committed history +- write one committed row per finalized logical item +- support tail, before-seq, and after-seq queries from storage +- stop treating provider history hydration as the normal refresh/load path +- keep provisional live updates in memory only + +Exit gate: + +- committed history survives daemon restart +- reconnect uses committed catch-up plus future live events without gaps or duplicates +- unloaded agents can serve committed history from storage alone + +### Phase 5: Legacy cleanup + +Goal: +Remove compatibility paths after the DB-backed model is fully authoritative. + +Required work: + +- remove legacy JSON authority for structured records +- remove in-memory committed-history ownership +- remove provider-history rehydrate compatibility paths +- trim dead protocol and reducer logic from the pre-storage model +- update architecture docs to match the final model + +Exit gate: + +- there is one durable storage path for structured records +- there is one durable storage path for committed timeline history +- the runtime no longer depends on the removed JSON/in-memory model + +## Data model summary + +### Projects + +- integer primary key +- `directory` is unique +- `display_name` +- `kind`: `git | directory` +- optional `git_remote` +- timestamps and archive state + +### Workspaces + +- integer primary key +- belongs to a project by `project_id` +- `directory` is unique +- `display_name` +- `kind`: `checkout | worktree` +- timestamps and archive state + +### Agent snapshots + +- `agent_id` remains the primary key +- belongs to a workspace by integer `workspace_id` +- `workspace_id` is required +- timestamps, lifecycle state, persistence metadata, attention metadata, archive state + +### Timeline rows + +Target shape once Phase 4 lands: + +- `agent_id` +- committed `seq` +- committed timestamp +- canonical finalized item payload + +Not part of durable history: + +- raw streaming chunks +- provisional assistant text +- provisional reasoning text + +## Verification requirements + +Every remaining phase should keep the same bar: + +- `npm run typecheck` +- targeted tests for the touched storage/session/runtime paths +- migration/import coverage when storage authority changes +- reconnect and catch-up scenario coverage when timeline behavior changes + +At minimum, timeline cutover must explicitly prove: + +- `fetch-after-seq` +- `fetch-before-seq` +- restart durability +- no-gap/no-duplicate reconnect behavior + +## Main risks + +- timeline work reintroduces provider-history replay as hidden authority +- archive behavior diverges between stored records and live in-memory agents +- explicit creation work leaves the transitional cwd fallback in place too long +- cleanup stalls after compatibility paths stop being exercised + +## Rule of thumb + +If a new change needs to ask "what can we infer from this cwd?" for project or workspace identity, +it is probably moving in the wrong direction. diff --git a/docs/TERMINAL-MODE.md b/docs/TERMINAL-MODE.md new file mode 100644 index 000000000..03b6cdcb6 --- /dev/null +++ b/docs/TERMINAL-MODE.md @@ -0,0 +1,506 @@ +# Terminal Mode — Implementation Plan + +## Concept + +Terminal mode wraps an agent TUI (Claude Code, Codex, OpenCode, Gemini, etc.) in a Paseo agent entity. The agent is tracked in sessions, has a provider/icon/title, and can be archived — but instead of rendering a structured chat view, it renders a terminal running the agent's CLI. + +**Key principle:** `agent.terminal` is a boolean flag on the agent entity. If `true`, the panel renders a terminal. If `false` (default), it renders the current structured AgentStreamView. + +## What Changes + +### Phase 1: Server — Data Model & Provider Interface + +#### 1.1 Add `terminal` flag to `ManagedAgentBase` + +**File:** `packages/server/src/server/agent/agent-manager.ts` + +```typescript +type ManagedAgentBase = { + // ...existing fields... + terminal: boolean; // NEW — if true, this agent renders as a terminal TUI +}; +``` + +This flag is set at creation time and never changes. A terminal agent is always a terminal agent. + +#### 1.2 Add `terminal` to `AgentSessionConfig` + +**File:** `packages/server/src/server/agent/agent-sdk-types.ts` + +```typescript +export type AgentSessionConfig = { + // ...existing fields... + terminal?: boolean; // NEW — create as terminal agent +}; +``` + +#### 1.3 Add `terminal` to the Zod schema + +**File:** `packages/server/src/shared/messages.ts` + +Add to `AgentSessionConfigSchema`: +```typescript +terminal: z.boolean().optional(), +``` + +Add to the `AgentStateSchema` (the wire format sent to clients): +```typescript +terminal: z.boolean().optional(), +``` + +#### 1.4 Add terminal command builders to `AgentClient` + +**File:** `packages/server/src/server/agent/agent-sdk-types.ts` + +```typescript +export type TerminalCommand = { + command: string; + args: string[]; + env?: Record; +}; + +export interface AgentClient { + // ...existing methods... + + /** + * Build the shell command to launch this agent's TUI for a new session. + * Only available if capabilities.supportsTerminalMode is true. + */ + buildTerminalCreateCommand?(config: AgentSessionConfig): TerminalCommand; + + /** + * Build the shell command to resume an existing session in the agent's TUI. + * Only available if capabilities.supportsTerminalMode is true. + */ + buildTerminalResumeCommand?(handle: AgentPersistenceHandle): TerminalCommand; +} +``` + +#### 1.5 Add `supportsTerminalMode` capability + +**File:** `packages/server/src/server/agent/agent-sdk-types.ts` + +```typescript +export type AgentCapabilityFlags = { + // ...existing flags... + supportsTerminalMode: boolean; // NEW +}; +``` + +Also add to the Zod schema in `messages.ts`: +```typescript +supportsTerminalMode: z.boolean(), +``` + +#### 1.6 Implement terminal command builders in providers + +**Claude** (`packages/server/src/server/agent/providers/claude-agent.ts`): +```typescript +buildTerminalCreateCommand(config: AgentSessionConfig): TerminalCommand { + const args: string[] = []; + if (config.modeId === "bypassPermissions") { + args.push("--dangerously-skip-permissions"); + } + if (config.model) args.push("--model", config.model); + // mode mapping: default → nothing, plan → --plan, etc. + return { command: "claude", args, env: {} }; +} + +buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand { + return { + command: "claude", + args: ["--resume", handle.sessionId], + env: {}, + }; +} +``` + +**Codex** (`packages/server/src/server/agent/providers/codex-app-server-agent.ts`): +```typescript +buildTerminalCreateCommand(config: AgentSessionConfig): TerminalCommand { + const args: string[] = []; + if (config.model) args.push("--model", config.model); + if (config.modeId) args.push("--approval-mode", config.modeId); + return { command: "codex", args, env: {} }; +} + +buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand { + return { + command: "codex", + args: ["--resume", handle.nativeHandle ?? handle.sessionId], + env: {}, + }; +} +``` + +**OpenCode** (`packages/server/src/server/agent/providers/opencode-agent.ts`): +```typescript +buildTerminalCreateCommand(config: AgentSessionConfig): TerminalCommand { + return { command: "opencode", args: [], env: {} }; +} +// No resume support for OpenCode initially +``` + +Capabilities for each provider: +- Claude: `supportsTerminalMode: true` +- Codex: `supportsTerminalMode: true` +- OpenCode: `supportsTerminalMode: true` + +#### 1.7 Handle terminal agent creation in `AgentManager.createAgent()` + +**File:** `packages/server/src/server/agent/agent-manager.ts` + +When `config.terminal === true`: +1. Do NOT call `client.createSession()` — there is no managed session +2. Call `client.buildTerminalCreateCommand(config)` to get the command +3. Create a `TerminalSession` via `terminalManager.createTerminal()` with the command +4. Register the agent with `terminal: true`, `lifecycle: "idle"`, `session: null` +5. Store the terminal ID in the agent's metadata or a new field +6. The agent's persistence handle can be populated later (the CLI will create its own session file) + +```typescript +async createAgent(config: AgentSessionConfig, agentId?: string, options?: { labels?: Record }): Promise { + const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent"); + const normalizedConfig = await this.normalizeConfig(config); + const client = this.requireClient(normalizedConfig.provider); + + if (normalizedConfig.terminal) { + // Terminal mode — no managed session, just build the command + const buildCmd = client.buildTerminalCreateCommand; + if (!buildCmd) { + throw new Error(`Provider '${normalizedConfig.provider}' does not support terminal mode`); + } + const cmd = buildCmd.call(client, normalizedConfig); + return this.registerTerminalAgent(resolvedAgentId, normalizedConfig, cmd, { + labels: options?.labels, + }); + } + + // ...existing managed agent flow... +} +``` + +New method `registerTerminalAgent()`: +- Creates a ManagedAgent with `terminal: true` +- Stores the `TerminalCommand` in agent metadata for later use (resume, reconnect) +- Sets lifecycle to `"idle"` (the terminal itself manages the agent's internal state) +- Does NOT have an `AgentSession` — the `session` field is `null` (like closed agents) +- Broadcasts `agent_state` event so clients know about it + +#### 1.8 New message: create terminal for agent + +The client needs a way to request a terminal for a terminal agent. Options: + +**Option A:** Extend `createTerminal` to accept an agent ID. When provided, the server looks up the agent, gets the command, and creates a terminal pre-configured with that command. + +**Option B:** New message type `create_terminal_agent_request` that combines agent creation + terminal creation in one step. + +**Recommendation: Option A.** Add optional `agentId` to `CreateTerminalRequestMessage`. If provided: +- Look up the agent (must be a terminal agent) +- Use the agent's stored command to create the terminal +- Associate the terminal with the agent + +**File:** `packages/server/src/shared/messages.ts` + +```typescript +const CreateTerminalRequestMessageSchema = z.object({ + type: z.literal("create_terminal_request"), + cwd: z.string(), + name: z.string().optional(), + agentId: z.string().optional(), // NEW — if provided, create terminal for this terminal agent + requestId: z.string(), +}); +``` + +#### 1.9 Terminal → Agent lifecycle binding + +When a terminal associated with a terminal agent exits: +- Set agent lifecycle to `"closed"` +- Attempt to detect the agent's session file for persistence handle +- Broadcast state update + +When a terminal agent is opened from the sessions page: +- Server calls `buildTerminalResumeCommand(handle)` if persistence handle exists +- Otherwise calls `buildTerminalCreateCommand(config)` +- Creates a new terminal with that command + +#### 1.10 Extend `createTerminal()` to support command + args + +**File:** `packages/server/src/terminal/terminal.ts` + +```typescript +export interface CreateTerminalOptions { + cwd: string; + shell?: string; + env?: Record; + rows?: number; + cols?: number; + name?: string; + command?: string; // NEW — if provided, run this instead of shell + args?: string[]; // NEW — arguments for command +} +``` + +In `createTerminal()`: +```typescript +const spawnCommand = options.command ?? shell; +const spawnArgs = options.command ? (options.args ?? []) : []; + +const ptyProcess = pty.spawn(spawnCommand, spawnArgs, { + name: "xterm-256color", + cols, rows, cwd, + env: { ...process.env, ...env, TERM: "xterm-256color" }, +}); +``` + +--- + +### Phase 2: App — Draft UI & Terminal Toggle + +#### 2.1 Add terminal toggle to draft tab + +**File:** `packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx` + +Add a toggle switch in the draft UI: **"Chat" / "Terminal"** + +State: +```typescript +const [isTerminalMode, setIsTerminalMode] = useState(false); +``` + +The toggle should be persistent per draft (stored in the draft store or as a preference). + +When terminal mode is selected: +- The provider/model pickers still work (same UI) +- The mode picker still works +- The "send" button label changes to "Launch" or "Start" +- The initial prompt input may be hidden or optional (terminal agents don't need an initial prompt — the user types directly into the TUI) + +#### 2.2 Modify agent creation to pass `terminal: true` + +When the user submits a draft in terminal mode: + +```typescript +const config: AgentSessionConfig = { + provider: selectedProvider, + cwd: workspaceId, + model: selectedModel, + modeId: selectedMode, + terminal: true, // NEW +}; +``` + +The `CreateAgentRequestMessage` already carries `config`, so no new wire message needed. + +#### 2.3 Terminal mode in `AgentStatusBar` + +**File:** `packages/app/src/components/agent-status-bar.tsx` + +When rendering a draft's status bar, filter the capability: +- If `supportsTerminalMode` is false for a provider, disable the terminal toggle when that provider is selected +- The terminal toggle can live next to the provider selector or as a segmented control above the input area + +--- + +### Phase 3: App — Agent Panel Rendering + +#### 3.1 Branch rendering in `AgentPanel` + +**File:** `packages/app/src/panels/agent-panel.tsx` + +```typescript +function AgentPanelContent({ agentId, ... }) { + const agent = useAgentState(agentId); + + if (agent?.terminal) { + return ; + } + + return ; +} +``` + +#### 3.2 New component: `TerminalAgentPanel` + +**File:** `packages/app/src/panels/terminal-agent-panel.tsx` (new file) + +This component: +1. Gets the terminal ID associated with the agent (from agent metadata or a new field) +2. Renders a `TerminalPane` connected to that terminal session +3. If no terminal exists yet (agent from sessions page), requests terminal creation via `createTerminal({ agentId })` +4. Handles terminal exit → agent close lifecycle + +Essentially: it's the existing `TerminalPane` component, but associated with an agent entity instead of a standalone terminal. + +#### 3.3 Tab descriptor for terminal agents + +**File:** `packages/app/src/panels/agent-panel.tsx` → `useAgentPanelDescriptor` + +The tab descriptor (icon, label) already comes from the agent's provider. Terminal agents get the same icon/label as managed agents — that's the whole point. No changes needed here unless we want a "terminal" badge. + +Optional: add a small terminal icon badge to distinguish terminal agents from managed agents in the tab bar. + +--- + +### Phase 4: Sessions Page + +#### 4.1 Terminal agents appear in sessions list + +No changes needed for listing — terminal agents are real agents, they already show up via `AgentManager.getAgents()`. + +#### 4.2 Opening a terminal agent from sessions + +**File:** `packages/app/src/screens/sessions/` (sessions screen) + +When the user clicks a closed terminal agent: +1. Server calls `buildTerminalResumeCommand(handle)` if persistence exists +2. Creates a new terminal with that command +3. Opens agent tab in workspace + +If no persistence handle (session was ephemeral), show "Start new session" which calls `buildTerminalCreateCommand(config)`. + +--- + +### Phase 5: CLI Gating + +#### 5.1 `paseo send` — error for terminal agents + +**File:** `packages/cli/src/commands/send.ts` + +```typescript +if (agent.terminal) { + throw new Error("Cannot send messages to terminal agents. Open the terminal in the UI instead."); +} +``` + +#### 5.2 `paseo run` — could support `--terminal` flag (future) + +Not in v1. For now, `paseo run` always creates managed agents. Terminal mode is UI-only. + +#### 5.3 `paseo ls` — show terminal flag + +Add a `terminal` column or badge to `paseo ls` output so users can distinguish terminal agents. + +--- + +## Wire Format Changes Summary + +### AgentSessionConfig (create request) +```diff + { + provider: string; + cwd: string; + model?: string; + modeId?: string; ++ terminal?: boolean; + ... + } +``` + +### AgentState (server → client) +```diff + { + id: string; + provider: string; + lifecycle: string; ++ terminal?: boolean; + ... + } +``` + +### AgentCapabilityFlags +```diff + { + supportsStreaming: boolean; + supportsSessionPersistence: boolean; ++ supportsTerminalMode: boolean; + ... + } +``` + +### CreateTerminalRequest +```diff + { + type: "create_terminal_request"; + cwd: string; + name?: string; ++ agentId?: string; + requestId: string; + } +``` + +### TerminalCommand (new type) +```typescript +{ + command: string; + args: string[]; + env?: Record; +} +``` + +--- + +## Implementation Phases & Agent Assignments + +### Phase 1: Server data model (1 agent) +- Add `terminal` to types, schemas, and agent manager +- Add `TerminalCommand` type and `buildTerminalCreateCommand`/`buildTerminalResumeCommand` to `AgentClient` +- Add `supportsTerminalMode` capability flag +- Extend `createTerminal()` to support command+args +- Implement terminal agent creation flow in `AgentManager` +- Wire terminal exit → agent close lifecycle +- Implement command builders in Claude, Codex, OpenCode providers +- Typecheck must pass + +### Phase 2: App draft UI + terminal toggle (1 agent) +- Add terminal mode toggle to `workspace-draft-agent-tab.tsx` +- Pass `terminal: true` in config when toggle is on +- Filter toggle based on `supportsTerminalMode` capability +- Persist toggle preference +- Typecheck must pass + +### Phase 3: App panel rendering (1 agent) +- Branch `AgentPanelContent` on `agent.terminal` +- Create `TerminalAgentPanel` component +- Handle terminal creation for agent on open +- Handle terminal exit lifecycle +- Typecheck must pass + +### Phase 4: Sessions page + CLI gating (1 agent) +- Terminal agents show in sessions with badge +- Opening from sessions resumes or creates terminal +- `paseo send` errors for terminal agents +- `paseo ls` shows terminal badge +- Typecheck must pass + +--- + +## Feature Interaction Guards + +Terminal agents are explicitly excluded from automated dispatch paths: + +- **LoopService**: `buildWorkerConfig` and `buildVerifierConfig` set `terminal: false` +- **ScheduleService**: `executeSchedule` rejects terminal agents with a clear error for agent-targeted schedules; new-agent schedules set `terminal: false` +- **Voice mode / `handleSendAgentMessage`**: Guarded by `getStructuredSendRejection()` before send +- **CLI `paseo send`**: Returns error for terminal agents +- **MCP agent creation**: Programmatic paths don't pass `terminal: true` + +All session-specific operations (`runAgent`, `streamAgent`, `setMode`, `cancelAgentRun`, etc.) are guarded by the centralized `requireSessionAgent()` which rejects terminal agents. + +## What This Does NOT Change + +- The existing managed agent flow is untouched +- Terminal sessions (non-agent) still work as before +- The `AgentSession` interface is unchanged +- Mobile experience is unchanged (terminal mode is web/desktop only for now) +- No new providers are added (existing providers gain terminal command builders) +- No hooks, no env injection, no process tree detection (v1 keeps it simple) + +## Future Work (Not In This Plan) + +- Auto-detect agent type from PTY process tree (for standalone terminals) +- "Convert to chat" / "Convert to terminal" actions +- Terminal title/icon from OSC sequences +- `paseo run --terminal` CLI support +- Mobile terminal mode (if xterm.js works well enough on mobile web) +- Gemini / Aider / Goose provider definitions (terminal-only providers) diff --git a/nix/package.nix b/nix/package.nix index 2923280c1..13b6c4151 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -42,7 +42,7 @@ buildNpmPackage rec { # To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # CI auto-updates this when package-lock.json changes (see .github/workflows/). - npmDepsHash = "sha256-RqH1tJ6+pS0XJ4yxxfQ6BRQJfSdsPcxXwCpy+BJ/dhY="; + npmDepsHash = "sha256-r9y8rUyT/56wHFUp8D/yA7mjy715jjezSYaEuj1D4TQ="; # Prevent onnxruntime-node's install script from running during automatic # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). diff --git a/package-lock.json b/package-lock.json index f1a43fb17..8c90ef10f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3060,6 +3060,13 @@ "react": ">=16.8.0" } }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@egjs/hammerjs": { "version": "2.0.17", "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", @@ -3072,6 +3079,14 @@ "node": ">=0.8.0" } }, + "node_modules/@electric-sql/pglite": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", + "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", + "license": "Apache-2.0", + "optional": true, + "peer": true + }, "node_modules/@electron/asar": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", @@ -3510,6 +3525,442 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -11285,6 +11736,16 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -13991,6 +14452,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/better-sqlite3": { + "version": "12.8.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", + "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -14012,6 +14487,64 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -15811,7 +16344,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" @@ -15827,7 +16359,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -16358,6 +16889,631 @@ "url": "https://dotenvx.com" } }, + "node_modules/drizzle-kit": { + "version": "0.31.10", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", + "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "tsx": "^4.21.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz", + "integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, "node_modules/dtrace-provider": { "version": "0.8.8", "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.8.8.tgz", @@ -18540,6 +19696,15 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", @@ -21160,6 +22325,12 @@ "node": ">=16.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -21603,6 +22774,12 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -21886,6 +23063,12 @@ "node": ">=6" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -26786,6 +27969,12 @@ "node": ">=10" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/mnemonic-id": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/mnemonic-id/-/mnemonic-id-3.2.7.tgz", @@ -26975,6 +28164,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -28568,6 +29763,45 @@ "node": "^12.20.0 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -31302,6 +32536,51 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/simple-plist": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz", @@ -31795,7 +33074,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -31805,7 +33083,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, "license": "MIT" }, "node_modules/string-length": { @@ -32288,6 +33565,54 @@ "node": ">=10" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-fs/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tar-fs/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tar-stream": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", @@ -33063,7 +34388,6 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dev": true, "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" @@ -33735,7 +35059,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/utils-merge": { @@ -35058,14 +36381,14 @@ }, "packages/app/node_modules/expo-clipboard": { "version": "8.0.7", - "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.7.tgz", - "integrity": "sha512-zvlfFV+wB2QQrQnHWlo0EKHAkdi2tycLtE+EXFUWTPZYkgu1XcH+aiKfd4ul7Z0SDF+1IuwoiW9AA9eO35aj3Q==", "license": "MIT", "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" - } + }, + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.7.tgz", + "integrity": "sha512-zvlfFV+wB2QQrQnHWlo0EKHAkdi2tycLtE+EXFUWTPZYkgu1XcH+aiKfd4ul7Z0SDF+1IuwoiW9AA9eO35aj3Q==" }, "packages/app/node_modules/react-native-nitro-modules": { "version": "0.33.8", @@ -35079,12 +36402,12 @@ }, "packages/app/node_modules/zod": { "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" - } + }, + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" }, "packages/cli": { "name": "@getpaseo/cli", @@ -35112,24 +36435,24 @@ }, "packages/cli/node_modules/chalk": { "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" }, "funding": { "url": "https://github.com/chalk/chalk?sponsor=1" - } + }, + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==" }, "packages/cli/node_modules/commander": { "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "license": "MIT", "engines": { "node": ">=18" - } + }, + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==" }, "packages/desktop": { "name": "@getpaseo/desktop", @@ -35428,7 +36751,9 @@ "@xterm/headless": "^6.0.0", "ai": "5.0.78", "ajv": "^8.17.1", + "better-sqlite3": "^12.8.0", "dotenv": "^17.2.3", + "drizzle-orm": "^0.45.1", "express": "^4.18.2", "express-basic-auth": "^1.2.1", "fast-uri": "^3.1.0", @@ -35452,12 +36777,14 @@ }, "devDependencies": { "@playwright/test": "^1.56.1", + "@types/better-sqlite3": "^7.6.13", "@types/express": "^4.17.20", "@types/node": "^20.9.0", "@types/qrcode": "^1.5.6", "@types/uuid": "^9.0.7", "@types/ws": "^8.5.8", "@vitest/ui": "^3.2.4", + "drizzle-kit": "^0.31.10", "playwright": "^1.56.1", "tsx": "^4.6.0", "typescript": "^5.2.2", @@ -35475,8 +36802,6 @@ }, "packages/server/node_modules/@modelcontextprotocol/sdk": { "version": "1.20.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.1.tgz", - "integrity": "sha512-j/P+yuxXfgxb+mW7OEoRCM3G47zCTDqUPivJo/VzpjbG8I9csTXtOprCf5FfOfHK4whOJny0aHuBEON+kS7CCA==", "license": "MIT", "dependencies": { "ajv": "^6.12.6", @@ -35494,12 +36819,12 @@ }, "engines": { "node": ">=18" - } + }, + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.1.tgz", + "integrity": "sha512-j/P+yuxXfgxb+mW7OEoRCM3G47zCTDqUPivJo/VzpjbG8I9csTXtOprCf5FfOfHK4whOJny0aHuBEON+kS7CCA==" }, "packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -35510,12 +36835,12 @@ "funding": { "type": "github", "url": "https://github.com/sponsors/epoberezkin" - } + }, + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==" }, "packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/express": { "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", "dependencies": { "accepts": "^2.0.0", @@ -35552,7 +36877,9 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/express" - } + }, + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==" }, "packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { "version": "0.4.1", @@ -35562,8 +36889,6 @@ }, "packages/server/node_modules/accepts": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", "dependencies": { "mime-types": "^3.0.0", @@ -35571,12 +36896,12 @@ }, "engines": { "node": ">= 0.6" - } + }, + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==" }, "packages/server/node_modules/ajv": { "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -35587,24 +36912,24 @@ "funding": { "type": "github", "url": "https://github.com/sponsors/epoberezkin" - } + }, + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==" }, "packages/server/node_modules/ansi-regex": { "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" }, "funding": { "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } + }, + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==" }, "packages/server/node_modules/body-parser": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", @@ -35619,33 +36944,33 @@ }, "engines": { "node": ">=18" - } + }, + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==" }, "packages/server/node_modules/content-disposition": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", "license": "MIT", "dependencies": { "safe-buffer": "5.2.1" }, "engines": { "node": ">= 0.6" - } + }, + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==" }, "packages/server/node_modules/cookie-signature": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", "license": "MIT", "engines": { "node": ">=6.6.0" - } + }, + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==" }, "packages/server/node_modules/finalhandler": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -35657,58 +36982,60 @@ }, "engines": { "node": ">= 0.8" - } + }, + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==" }, "packages/server/node_modules/fresh": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", "engines": { "node": ">= 0.8" - } + }, + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==" }, "packages/server/node_modules/media-typer": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", "engines": { "node": ">= 0.8" - } + }, + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==" }, "packages/server/node_modules/merge-descriptors": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", "engines": { "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==" }, "packages/server/node_modules/mime-types": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", "license": "MIT", "dependencies": { "mime-db": "^1.54.0" }, "engines": { "node": ">= 0.6" - } + }, + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==" }, "packages/server/node_modules/negotiator": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", "engines": { "node": ">= 0.6" - } + }, + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==" }, "packages/server/node_modules/raw-body": { "version": "3.0.2", @@ -35743,8 +37070,6 @@ }, "packages/server/node_modules/send": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", "license": "MIT", "dependencies": { "debug": "^4.3.5", @@ -35761,12 +37086,12 @@ }, "engines": { "node": ">= 18" - } + }, + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==" }, "packages/server/node_modules/serve-static": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", @@ -35776,12 +37101,12 @@ }, "engines": { "node": ">= 18" - } + }, + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==" }, "packages/server/node_modules/strip-ansi": { "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -35791,12 +37116,12 @@ }, "funding": { "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } + }, + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==" }, "packages/server/node_modules/type-is": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "license": "MIT", "dependencies": { "content-type": "^1.0.5", @@ -35805,16 +37130,18 @@ }, "engines": { "node": ">= 0.6" - } + }, + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==" }, "packages/server/node_modules/zod": { "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" - } + }, + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" }, "packages/website": { "name": "@getpaseo/website", @@ -35845,13 +37172,13 @@ }, "packages/website/node_modules/@types/node": { "version": "22.19.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.6.tgz", - "integrity": "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ==", "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" - } + }, + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.6.tgz", + "integrity": "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ==" }, "packages/website/node_modules/react": { "version": "19.2.4", diff --git a/package.json b/package.json index 869cf02ff..571940c13 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "web": "npm run web --workspace=@getpaseo/app", "dev:desktop": "npm run dev --workspace=@getpaseo/desktop", "build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop", + "db:query": "npm run db:query --workspace=@getpaseo/server --", "cli": "npx tsx packages/cli/src/index.js", "version": "npm run version:sync-internal && npm run release:prepare && git add -A", "version:sync-internal": "node scripts/sync-workspace-versions.mjs", diff --git a/packages/app/e2e/helpers/launcher.ts b/packages/app/e2e/helpers/launcher.ts new file mode 100644 index 000000000..798201137 --- /dev/null +++ b/packages/app/e2e/helpers/launcher.ts @@ -0,0 +1,206 @@ +import { expect, type Page } from "@playwright/test"; +import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; +import { createTempGitRepo } from "./workspace"; + +// ─── Navigation ──────────────────────────────────────────────────────────── + +function getServerId(): string { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); + } + return serverId; +} + +/** Navigate to a workspace and wait for the tab bar to appear. */ +export async function gotoWorkspace(page: Page, cwd: string): Promise { + const route = buildHostWorkspaceRoute(getServerId(), cwd); + await page.goto(route); + await waitForTabBar(page); +} + +// ─── Tab bar queries ─────────────────────────────────────────────────────── + +/** Wait for the workspace tab bar to be visible. */ +export async function waitForTabBar(page: Page): Promise { + await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({ + timeout: 30_000, + }); +} + +/** Return all tab test IDs currently in the tab bar. */ +export async function getTabTestIds(page: Page): Promise { + const tabs = page.locator( + '[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])', + ); + const count = await tabs.count(); + const ids: string[] = []; + for (let i = 0; i < count; i++) { + const testId = await tabs.nth(i).getAttribute("data-testid"); + if (testId) ids.push(testId); + } + return ids; +} + +/** Return the number of tabs matching a kind prefix (e.g. "launcher", "draft", "terminal", "agent"). */ +export async function countTabsOfKind(page: Page, kind: string): Promise { + const ids = await getTabTestIds(page); + return ids.filter((id) => id.includes(kind)).length; +} + +/** Return the currently active tab's test ID (the one with aria-selected or focus styling). */ +export async function getActiveTabTestId(page: Page): Promise { + // Active tab has the focused highlight — check for the aria-selected or data-active attribute + const activeTab = page + .locator( + '[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])[aria-selected="true"]', + ) + .first(); + if (await activeTab.isVisible().catch(() => false)) { + return activeTab.getAttribute("data-testid"); + } + // Fallback: the tab with focused styling + return null; +} + +// ─── Tab actions ─────────────────────────────────────────────────────────── + +/** Click the '+' button in the tab bar to open a new launcher tab. */ +export async function clickNewTabButton(page: Page): Promise { + const button = page.getByTestId("workspace-new-tab"); + await expect(button).toBeVisible({ timeout: 10_000 }); + await button.click(); +} + +/** Press Cmd+T (macOS) to open a new tab. */ +export async function pressNewTabShortcut(page: Page): Promise { + await page.keyboard.press("Meta+t"); +} + +// ─── Launcher panel assertions ───────────────────────────────────────────── + +/** Wait for the launcher panel to render with its primary tiles. */ +export async function waitForLauncherPanel(page: Page): Promise { + await expect(page.getByRole("button", { name: "New Chat" }).first()).toBeVisible({ + timeout: 15_000, + }); + await expect(page.getByRole("button", { name: "Terminal" }).first()).toBeVisible({ + timeout: 15_000, + }); +} + +/** Assert that the launcher panel shows provider tiles under "Terminal Agents". */ +export async function assertProviderTilesVisible(page: Page): Promise { + await expect(page.getByText("Terminal Agents", { exact: true }).first()).toBeVisible({ + timeout: 10_000, + }); +} + +/** Assert the launcher panel has a "New Chat" tile. */ +export async function assertNewChatTileVisible(page: Page): Promise { + await expect(page.getByRole("button", { name: "New Chat" }).first()).toBeVisible(); +} + +/** Assert the launcher panel has a "Terminal" tile. */ +export async function assertTerminalTileVisible(page: Page): Promise { + await expect(page.getByRole("button", { name: "Terminal" }).first()).toBeVisible(); +} + +// ─── Launcher tile clicks ────────────────────────────────────────────────── + +/** Click the "New Chat" tile on the launcher panel. */ +export async function clickNewChat(page: Page): Promise { + const button = page.getByRole("button", { name: "New Chat" }).first(); + await expect(button).toBeVisible({ timeout: 10_000 }); + await button.click(); +} + +/** Click the "Terminal" tile on the launcher panel. */ +export async function clickTerminal(page: Page): Promise { + const button = page.getByRole("button", { name: "Terminal", exact: true }).first(); + await expect(button).toBeVisible({ timeout: 10_000 }); + await button.click(); +} + +/** Click a provider tile by label (e.g. "Claude Code", "Codex"). */ +export async function clickProviderTile(page: Page, providerLabel: string): Promise { + const tile = page.getByRole("button", { name: providerLabel }).first(); + await expect(tile).toBeVisible({ timeout: 10_000 }); + await tile.click(); +} + +// ─── Tab title assertions ────────────────────────────────────────────────── + +/** Wait for any tab in the bar to display the given title text. */ +export async function waitForTabWithTitle( + page: Page, + title: string | RegExp, + timeout = 30_000, +): Promise { + const matcher = typeof title === "string" ? new RegExp(title, "i") : title; + await expect( + page + .locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])') + .filter({ hasText: matcher }) + .first(), + ).toBeVisible({ timeout }); +} + +/** Assert the new-tab '+' button is visible and there is only one. */ +export async function assertSingleNewTabButton(page: Page): Promise { + const buttons = page.getByTestId("workspace-new-tab"); + // There might be multiple panes, each with a "+" button + // But within a single pane there should only be one + const count = await buttons.count(); + expect(count).toBeGreaterThanOrEqual(1); +} + +// ─── No-flash measurement ────────────────────────────────────────────────── + +/** + * Measure the time between clicking a launcher tile and the replacement panel becoming visible. + * Returns elapsed milliseconds. + */ +export async function measureTileTransition( + page: Page, + clickAction: () => Promise, + successLocator: ReturnType, + timeout = 5_000, +): Promise { + const start = Date.now(); + await clickAction(); + await expect(successLocator).toBeVisible({ timeout }); + return Date.now() - start; +} + +/** + * Sample tab IDs at high frequency across a transition to detect blank/intermediate states. + * Returns all unique snapshots observed. + */ +export async function sampleTabsDuringTransition( + page: Page, + action: () => Promise, + durationMs = 2_000, + intervalMs = 30, +): Promise { + const snapshots: string[][] = []; + const startSampling = async () => { + const start = Date.now(); + while (Date.now() - start < durationMs) { + snapshots.push(await getTabTestIds(page)); + await page.waitForTimeout(intervalMs); + } + }; + + const samplingPromise = startSampling(); + await action(); + await samplingPromise; + return snapshots; +} + +// ─── Workspace setup ─────────────────────────────────────────────────────── + +/** Create a temp git repo and return its path with a cleanup function. */ +export async function createWorkspace(prefix = "launcher-e2e-"): ReturnType { + return createTempGitRepo(prefix); +} diff --git a/packages/app/e2e/helpers/terminal-perf.ts b/packages/app/e2e/helpers/terminal-perf.ts index 18dd79bd1..8ac45d251 100644 --- a/packages/app/e2e/helpers/terminal-perf.ts +++ b/packages/app/e2e/helpers/terminal-perf.ts @@ -7,6 +7,12 @@ import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; export type TerminalPerfDaemonClient = { connect(): Promise; close(): Promise; + openProject( + cwd: string, + ): Promise<{ + workspace: { id: number; name: string; projectRootPath: string } | null; + error: string | null; + }>; createTerminal( cwd: string, name?: string, diff --git a/packages/app/e2e/helpers/workspace-lifecycle.ts b/packages/app/e2e/helpers/workspace-lifecycle.ts new file mode 100644 index 000000000..e0aa30dec --- /dev/null +++ b/packages/app/e2e/helpers/workspace-lifecycle.ts @@ -0,0 +1,51 @@ +import { expect, type Page } from "@playwright/test"; +import { + clickNewChat, + clickProviderTile, + clickTerminal, + countTabsOfKind, + getTabTestIds, + waitForTabWithTitle, +} from "./launcher"; +import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf"; + +function terminalSurface(page: Page) { + return page.locator('[data-testid="terminal-surface"]').first(); +} + +function composerInput(page: Page) { + return page.getByRole("textbox", { name: "Message agent..." }).first(); +} + +export async function expectTerminalCwd(page: Page, expectedPath: string): Promise { + const terminal = terminalSurface(page); + await expect(terminal).toBeVisible({ timeout: 20_000 }); + await terminal.click(); + await setupDeterministicPrompt(page, `SENTINEL_${Date.now()}`); + await terminal.pressSequentially("pwd\n", { delay: 0 }); + await waitForTerminalContent(page, (text) => text.includes(expectedPath), 10_000); +} + +export async function createStandaloneTerminalFromLauncher(page: Page): Promise { + const tabIdsBefore = await getTabTestIds(page); + const launcherCountBefore = await countTabsOfKind(page, "launcher"); + await clickTerminal(page); + await expect(terminalSurface(page)).toBeVisible({ timeout: 20_000 }); + await expect.poll(() => countTabsOfKind(page, "launcher")).toBe(launcherCountBefore - 1); + await expect.poll(async () => (await getTabTestIds(page)).length).toBe(tabIdsBefore.length); +} + +export async function createTerminalAgentFromLauncher(page: Page, providerLabel: string): Promise { + await clickProviderTile(page, providerLabel); + await expect(page.getByTestId("terminal-agent-loading")).toHaveCount(0, { timeout: 30_000 }); + await expect(terminalSurface(page)).toBeVisible({ timeout: 30_000 }); + await waitForTabWithTitle(page, /new agent/i); +} + +export async function createAgentChatFromLauncher(page: Page): Promise { + await clickNewChat(page); + await expect(composerInput(page)).toBeVisible({ timeout: 15_000 }); + await expect(composerInput(page)).toBeEditable({ timeout: 15_000 }); + await expect(page.getByTestId("agent-loading")).toHaveCount(0); + await expect(page.getByRole("button", { name: "New Chat" })).toHaveCount(0); +} diff --git a/packages/app/e2e/helpers/workspace-setup.ts b/packages/app/e2e/helpers/workspace-setup.ts new file mode 100644 index 000000000..0123ab634 --- /dev/null +++ b/packages/app/e2e/helpers/workspace-setup.ts @@ -0,0 +1,348 @@ +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { pathToFileURL } from "node:url"; +import { expect, type Page } from "@playwright/test"; +import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes"; +import { gotoAppShell } from "./app"; + +type WorkspaceSetupProgressPayload = { + status: "running" | "completed" | "failed"; + detail: { commands: string[]; log: string }; + error: string | null; +}; + +type WorkspaceSetupRawMessage = { + type: string; + payload?: WorkspaceSetupProgressPayload; +}; + +type WorkspaceSetupDaemonClient = { + connect(): Promise; + close(): Promise; + openProject( + cwd: string, + ): Promise<{ + workspace: { + id: number; + name: string; + workspaceDirectory: string; + projectRootPath: string; + } | null; + error: string | null; + }>; + createPaseoWorktree( + input: { cwd: string; worktreeSlug?: string }, + ): Promise<{ + workspace: { + id: number; + name: string; + workspaceDirectory: string; + projectRootPath: string; + } | null; + error: string | null; + }>; + fetchWorkspaces(): Promise<{ + entries: Array<{ + id: number; + name: string; + workspaceDirectory: string; + projectRootPath: string; + }>; + }>; + fetchAgents(): Promise<{ + entries: Array<{ + agent: { id: string; cwd: string; workspaceId?: string | null }; + }>; + }>; + fetchAgent( + agentId: string, + ): Promise<{ + agent: { id: string; cwd: string } | null; + project: unknown | null; + } | null>; + listTerminals( + cwd: string, + ): Promise<{ + cwd?: string; + terminals: Array<{ id: string; cwd: string; name: string }>; + error?: string | null; + }>; + subscribeRawMessages(handler: (message: WorkspaceSetupRawMessage) => void): () => void; +}; +export type { WorkspaceSetupDaemonClient, WorkspaceSetupProgressPayload }; + +function getDaemonWsUrl(): string { + const daemonPort = process.env.E2E_DAEMON_PORT; + if (!daemonPort) { + throw new Error("E2E_DAEMON_PORT is not set."); + } + return `ws://127.0.0.1:${daemonPort}/ws`; +} + +async function loadDaemonClientConstructor(): Promise< + new (config: { url: string; clientId: string; clientType: "cli" }) => WorkspaceSetupDaemonClient +> { + const repoRoot = path.resolve(process.cwd(), "../.."); + const moduleUrl = pathToFileURL( + path.join(repoRoot, "packages/server/dist/server/server/exports.js"), + ).href; + const mod = (await import(moduleUrl)) as { + DaemonClient: new (config: { + url: string; + clientId: string; + clientType: "cli"; + }) => WorkspaceSetupDaemonClient; + }; + return mod.DaemonClient; +} + +export async function connectWorkspaceSetupClient(): Promise { + const DaemonClient = await loadDaemonClientConstructor(); + const client = new DaemonClient({ + url: getDaemonWsUrl(), + clientId: `workspace-setup-${randomUUID()}`, + clientType: "cli", + }); + await client.connect(); + return client; +} + +export async function seedProjectForWorkspaceSetup( + client: WorkspaceSetupDaemonClient, + repoPath: string, +): Promise { + const result = await client.openProject(repoPath); + if (!result.workspace || result.error) { + throw new Error(result.error ?? `Failed to open project ${repoPath}`); + } +} + +export function projectNameFromPath(repoPath: string): string { + return repoPath.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? repoPath; +} + +export async function openHomeWithProject(page: Page, repoPath: string): Promise { + await gotoAppShell(page); + await expect( + page + .locator('[data-testid^="sidebar-project-row-"]') + .filter({ hasText: projectNameFromPath(repoPath) }) + .first(), + ).toBeVisible({ timeout: 30_000 }); +} + +function createWorkspaceButton(page: Page, repoPath: string) { + return page.getByRole("button", { + name: `Create a new workspace for ${projectNameFromPath(repoPath)}`, + }); +} + +async function revealWorkspaceButton(page: Page, repoPath: string): Promise { + await page + .locator('[data-testid^="sidebar-project-row-"]') + .filter({ hasText: projectNameFromPath(repoPath) }) + .first() + .hover(); +} + +export async function createWorkspaceFromSidebar(page: Page, repoPath: string): Promise { + const button = createWorkspaceButton(page, repoPath); + await revealWorkspaceButton(page, repoPath); + await expect(button).toBeVisible({ timeout: 30_000 }); + await expect(button).toBeEnabled({ timeout: 30_000 }); + await button.click(); + await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 }); + await expect(page.getByTestId("workspace-setup-dialog")).toBeVisible({ timeout: 30_000 }); +} + +export async function getCurrentWorkspaceIdFromRoute(page: Page): Promise { + await expect + .poll( + () => parseHostWorkspaceRouteFromPathname(new URL(page.url()).pathname)?.workspaceId ?? null, + { timeout: 30_000 }, + ) + .not.toBeNull(); + + const workspaceId = + parseHostWorkspaceRouteFromPathname(new URL(page.url()).pathname)?.workspaceId ?? null; + if (!workspaceId) { + throw new Error(`Expected a workspace route but found ${page.url()}`); + } + + return workspaceId; +} + +function workspaceSetupDialog(page: Page) { + return page.getByTestId("workspace-setup-dialog"); +} + +export async function createChatAgentFromWorkspaceSetup( + page: Page, + input: { message: string }, +): Promise { + const dialog = workspaceSetupDialog(page); + await dialog.getByRole("button", { name: /Chat Agent/i }).click(); + + const messageInput = dialog.getByRole("textbox", { name: "Message agent..." }).first(); + await expect(messageInput).toBeVisible({ timeout: 15_000 }); + await messageInput.fill(input.message); + + await dialog.getByRole("button", { name: "Send message" }).click(); +} + +export async function createTerminalAgentFromWorkspaceSetup( + page: Page, + input: { providerLabel: string; prompt?: string }, +): Promise { + const dialog = workspaceSetupDialog(page); + await dialog.getByRole("button", { name: /Terminal Agent/i }).click(); + + const providerButton = dialog.getByRole("button", { name: new RegExp(`^${input.providerLabel}$`, "i") }).first(); + await expect(providerButton).toBeVisible({ timeout: 15_000 }); + await providerButton.click(); + + if (input.prompt) { + const promptInput = dialog.getByPlaceholder("Optional").first(); + await expect(promptInput).toBeVisible({ timeout: 15_000 }); + await promptInput.fill(input.prompt); + } + + await dialog.getByRole("button", { name: "Launch" }).click(); +} + +export async function createStandaloneTerminalFromWorkspaceSetup(page: Page): Promise { + await workspaceSetupDialog(page) + .getByRole("button", { name: /^Terminal Create the workspace/i }) + .click(); +} + +export async function waitForWorkspaceSetupDialogToClose(page: Page, timeoutMs = 45_000): Promise { + const dialog = workspaceSetupDialog(page); + + try { + await expect(dialog).toHaveCount(0, { timeout: timeoutMs }); + } catch (error) { + const dialogText = (await dialog.textContent().catch(() => null))?.replace(/\s+/g, " ").trim(); + throw new Error( + dialogText + ? `Workspace setup dialog stayed open. Visible text: ${dialogText}` + : `Workspace setup dialog did not close within ${timeoutMs}ms`, + { cause: error }, + ); + } +} + +export async function expectSetupPanel(page: Page): Promise { + await expect(page.getByText("Workspace setup", { exact: true })).toBeVisible({ timeout: 30_000 }); +} + +export async function expectSetupStatus( + page: Page, + status: "Running" | "Completed" | "Failed", +): Promise { + await expect(page.getByTestId("workspace-setup-status")).toContainText(status, { + timeout: 30_000, + }); +} + +export async function expectSetupLogContains(page: Page, text: string): Promise { + await expect(page.getByTestId("workspace-setup-log")).toContainText(text, { + timeout: 30_000, + }); +} + +export async function expectNoSetupMessage(page: Page): Promise { + await expect(page.getByText("No setup commands ran for this workspace.", { exact: true })).toBeVisible({ + timeout: 30_000, + }); +} + +export async function createWorkspaceThroughDaemon( + client: WorkspaceSetupDaemonClient, + input: { cwd: string; worktreeSlug: string }, +): Promise<{ id: string; name: string }> { + const result = await client.createPaseoWorktree(input); + if (!result.workspace || result.error) { + throw new Error(result.error ?? `Failed to create workspace for ${input.cwd}`); + } + return { + id: String(result.workspace.id), + name: result.workspace.name, + }; +} + +export async function findWorktreeWorkspaceForProject( + client: WorkspaceSetupDaemonClient, + repoPath: string, +): Promise<{ + id: string; + name: string; + projectRootPath: string; + workspaceDirectory: string; +}> { + const payload = await client.fetchWorkspaces(); + const workspace = + payload.entries.find( + (entry) => + entry.projectRootPath === repoPath && entry.workspaceDirectory !== repoPath, + ) ?? null; + if (!workspace) { + throw new Error(`Failed to find created worktree workspace for ${repoPath}`); + } + return { + id: String(workspace.id), + name: workspace.name, + projectRootPath: workspace.projectRootPath, + workspaceDirectory: workspace.workspaceDirectory, + }; +} + +export async function fetchWorkspaceById( + client: WorkspaceSetupDaemonClient, + workspaceId: string, +): Promise<{ + id: number; + name: string; + workspaceDirectory: string; + projectRootPath: string; +}> { + const parsedWorkspaceId = Number(workspaceId); + if (!Number.isInteger(parsedWorkspaceId)) { + throw new Error(`Workspace id is not numeric: ${workspaceId}`); + } + + const payload = await client.fetchWorkspaces(); + const workspace = payload.entries.find((entry) => entry.id === parsedWorkspaceId) ?? null; + if (!workspace) { + throw new Error(`Workspace not found: ${workspaceId}`); + } + return workspace; +} + +export async function waitForWorkspaceSetupProgress( + client: WorkspaceSetupDaemonClient, + predicate: (payload: WorkspaceSetupProgressPayload) => boolean, + timeoutMs = 30_000, +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + unsubscribe(); + reject(new Error(`Timed out waiting for workspace_setup_progress after ${timeoutMs}ms`)); + }, timeoutMs); + + const unsubscribe = client.subscribeRawMessages((message) => { + if (message.type !== "workspace_setup_progress") { + return; + } + if (!message.payload) { + return; + } + if (!predicate(message.payload)) { + return; + } + clearTimeout(timeout); + unsubscribe(); + resolve(message.payload); + }); + }); +} diff --git a/packages/app/e2e/helpers/workspace.ts b/packages/app/e2e/helpers/workspace.ts index d49d67a87..e5305c414 100644 --- a/packages/app/e2e/helpers/workspace.ts +++ b/packages/app/e2e/helpers/workspace.ts @@ -10,7 +10,11 @@ type TempRepo = { export const createTempGitRepo = async ( prefix = "paseo-e2e-", - options?: { withRemote?: boolean }, + options?: { + withRemote?: boolean; + paseoConfig?: Record; + files?: Array<{ path: string; content: string }>; + }, ): Promise => { // Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping. const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp"; @@ -22,7 +26,24 @@ export const createTempGitRepo = async ( execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" }); execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" }); await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n"); + if (options?.paseoConfig) { + await writeFile( + path.join(repoPath, "paseo.json"), + JSON.stringify(options.paseoConfig, null, 2), + ); + } + for (const file of options?.files ?? []) { + const filePath = path.join(repoPath, file.path); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, file.content); + } execSync("git add README.md", { cwd: repoPath, stdio: "ignore" }); + if (options?.paseoConfig) { + execSync("git add paseo.json", { cwd: repoPath, stdio: "ignore" }); + } + for (const file of options?.files ?? []) { + execSync(`git add ${JSON.stringify(file.path)}`, { cwd: repoPath, stdio: "ignore" }); + } execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" }); if (withRemote) { diff --git a/packages/app/e2e/launcher-tab.spec.ts b/packages/app/e2e/launcher-tab.spec.ts new file mode 100644 index 000000000..343d5e7cd --- /dev/null +++ b/packages/app/e2e/launcher-tab.spec.ts @@ -0,0 +1,350 @@ +import { test, expect } from "./fixtures"; +import { createTempGitRepo } from "./helpers/workspace"; +import { + gotoWorkspace, + waitForLauncherPanel, + assertProviderTilesVisible, + assertNewChatTileVisible, + assertTerminalTileVisible, + assertSingleNewTabButton, + clickNewTabButton, + pressNewTabShortcut, + clickNewChat, + clickTerminal, + clickProviderTile, + countTabsOfKind, + getTabTestIds, + waitForTabWithTitle, + measureTileTransition, + sampleTabsDuringTransition, +} from "./helpers/launcher"; +import { + connectTerminalClient, + waitForTerminalContent, + setupDeterministicPrompt, + type TerminalPerfDaemonClient, +} from "./helpers/terminal-perf"; + +// ─── Shared state ────────────────────────────────────────────────────────── + +let tempRepo: { path: string; cleanup: () => Promise }; +let workspaceId: string; +let seedClient: TerminalPerfDaemonClient; + +test.beforeAll(async () => { + tempRepo = await createTempGitRepo("launcher-e2e-"); + seedClient = await connectTerminalClient(); + const result = await seedClient.openProject(tempRepo.path); + if (!result.workspace) throw new Error(result.error ?? "Failed to seed workspace"); + workspaceId = String(result.workspace.id); +}); + +test.afterAll(async () => { + if (seedClient) await seedClient.close(); + if (tempRepo) await tempRepo.cleanup(); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Launcher Tab Tests +// ═══════════════════════════════════════════════════════════════════════════ + +test.describe("Launcher tab", () => { + test("Cmd+T opens launcher panel with New Chat, Terminal, and provider tiles", async ({ + page, + }) => { + await gotoWorkspace(page, workspaceId); + + await pressNewTabShortcut(page); + + await waitForLauncherPanel(page); + await assertNewChatTileVisible(page); + await assertTerminalTileVisible(page); + await assertProviderTilesVisible(page); + }); + + test("opening two new tabs creates two launcher tabs", async ({ page }) => { + await gotoWorkspace(page, workspaceId); + + await pressNewTabShortcut(page); + await waitForLauncherPanel(page); + const countAfterFirst = await countTabsOfKind(page, "launcher"); + + await pressNewTabShortcut(page); + await waitForLauncherPanel(page); + const countAfterSecond = await countTabsOfKind(page, "launcher"); + + expect(countAfterSecond).toBe(countAfterFirst + 1); + }); + + test("clicking New Chat replaces launcher in-place with draft tab", async ({ page }) => { + await gotoWorkspace(page, workspaceId); + + await clickNewTabButton(page); + await waitForLauncherPanel(page); + + const tabsBefore = await getTabTestIds(page); + const launcherCountBefore = tabsBefore.filter((id) => id.includes("launcher")).length; + + await clickNewChat(page); + + // Draft composer should appear (the agent message input) + const composer = page.getByRole("textbox", { name: "Message agent..." }); + await expect(composer.first()).toBeVisible({ timeout: 15_000 }); + + // Launcher tab should have been replaced (not added alongside) + const tabsAfter = await getTabTestIds(page); + const launcherCountAfter = tabsAfter.filter((id) => id.includes("launcher")).length; + const draftCountAfter = tabsAfter.filter((id) => id.includes("draft")).length; + + expect(launcherCountAfter).toBe(launcherCountBefore - 1); + expect(draftCountAfter).toBeGreaterThanOrEqual(1); + // Total tab count should stay the same (replaced, not added) + expect(tabsAfter.length).toBe(tabsBefore.length); + }); + + test("clicking Terminal replaces launcher with standalone terminal", async ({ page }) => { + test.setTimeout(45_000); + await gotoWorkspace(page, workspaceId); + + await clickNewTabButton(page); + await waitForLauncherPanel(page); + + const tabsBefore = await getTabTestIds(page); + + await clickTerminal(page); + + // Terminal surface should appear + const terminal = page.locator('[data-testid="terminal-surface"]'); + await expect(terminal.first()).toBeVisible({ timeout: 20_000 }); + + // Tab count stays the same (in-place replacement) + const tabsAfter = await getTabTestIds(page); + expect(tabsAfter.length).toBe(tabsBefore.length); + + // The launcher tab is gone, a terminal tab exists + const terminalTabs = tabsAfter.filter((id) => id.includes("terminal")); + expect(terminalTabs.length).toBeGreaterThanOrEqual(1); + }); + + test("clicking a provider tile replaces launcher with terminal agent tab", async ({ page }) => { + test.setTimeout(45_000); + await gotoWorkspace(page, workspaceId); + + await clickNewTabButton(page); + await waitForLauncherPanel(page); + + const tabsBefore = await getTabTestIds(page); + + // Click the first visible provider tile under "Terminal Agents" + const providerTiles = page.locator('[role="button"]').filter({ + has: page.locator("text=Terminal Agents").locator("..").locator(".."), + }); + + // Try clicking any provider tile — find the first one after the "Terminal Agents" label + const terminalAgentsLabel = page.getByText("Terminal Agents", { exact: true }).first(); + await expect(terminalAgentsLabel).toBeVisible({ timeout: 10_000 }); + + // The provider grid follows the label. Click the first provider tile. + const providerGrid = terminalAgentsLabel.locator("~ *").first(); + const firstProvider = providerGrid.getByRole("button").first(); + if (await firstProvider.isVisible().catch(() => false)) { + await firstProvider.click(); + } else { + // Fallback: look for any provider button after the section label + const allButtons = page.getByRole("button"); + const count = await allButtons.count(); + let clicked = false; + for (let i = 0; i < count; i++) { + const btn = allButtons.nth(i); + const text = await btn.innerText().catch(() => ""); + // Skip known non-provider buttons + if (["New Chat", "Terminal", "More", "+"].includes(text.trim())) continue; + if (!text.trim()) continue; + await btn.click(); + clicked = true; + break; + } + if (!clicked) { + test.skip(true, "No provider tiles available"); + return; + } + } + + // Should see an agent panel (terminal surface or agent stream) + const agentOrTerminal = page.locator( + '[data-testid="terminal-surface"], [data-testid^="agent-"]', + ); + await expect(agentOrTerminal.first()).toBeVisible({ timeout: 30_000 }); + + // Tab count stays the same (replaced, not added) + const tabsAfter = await getTabTestIds(page); + expect(tabsAfter.length).toBe(tabsBefore.length); + }); + + test("tab bar shows a single + button per pane", async ({ page }) => { + await gotoWorkspace(page, workspaceId); + await assertSingleNewTabButton(page); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Terminal Title Tests +// ═══════════════════════════════════════════════════════════════════════════ + +test.describe("Terminal title propagation", () => { + let client: TerminalPerfDaemonClient; + + test.beforeAll(async () => { + client = await connectTerminalClient(); + }); + + test.afterAll(async () => { + if (client) await client.close(); + }); + + test("terminal tab title updates from OSC title escape sequence", async ({ page }) => { + test.setTimeout(60_000); + + const result = await client.createTerminal(tempRepo.path, "title-test"); + if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`); + const terminalId = result.terminal.id; + + try { + // Navigate to workspace and open the terminal + await gotoWorkspace(page, workspaceId); + await clickNewTabButton(page); + await waitForLauncherPanel(page); + await clickTerminal(page); + + const terminal = page.locator('[data-testid="terminal-surface"]'); + await expect(terminal.first()).toBeVisible({ timeout: 20_000 }); + await terminal.first().click(); + + await setupDeterministicPrompt(page); + + // Send OSC 0 (set window title) escape sequence + const testTitle = `E2E-Title-${Date.now()}`; + await terminal + .first() + .pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, { delay: 0 }); + + // Wait for the tab to reflect the new title + await waitForTabWithTitle(page, testTitle, 15_000); + } finally { + await client.killTerminal(terminalId).catch(() => {}); + } + }); + + test("title debouncing coalesces rapid changes", async ({ page }) => { + test.setTimeout(60_000); + + const result = await client.createTerminal(tempRepo.path, "debounce-test"); + if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`); + const terminalId = result.terminal.id; + + try { + await gotoWorkspace(page, workspaceId); + await clickNewTabButton(page); + await waitForLauncherPanel(page); + await clickTerminal(page); + + const terminal = page.locator('[data-testid="terminal-surface"]'); + await expect(terminal.first()).toBeVisible({ timeout: 20_000 }); + await terminal.first().click(); + + await setupDeterministicPrompt(page); + + // Fire many rapid title changes — only the last should stick + const finalTitle = `Final-${Date.now()}`; + for (let i = 0; i < 5; i++) { + await terminal + .first() + .pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, { delay: 0 }); + } + await terminal + .first() + .pressSequentially(`printf '\\033]0;${finalTitle}\\007'\n`, { delay: 0 }); + + // The tab should eventually settle on the final title + await waitForTabWithTitle(page, finalTitle, 15_000); + } finally { + await client.killTerminal(terminalId).catch(() => {}); + } + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// No-Flash Transition Tests +// ═══════════════════════════════════════════════════════════════════════════ + +test.describe("Launcher transitions (no flash)", () => { + test("New Chat transition has no blank intermediate tab state", async ({ page }) => { + await gotoWorkspace(page, workspaceId); + + await clickNewTabButton(page); + await waitForLauncherPanel(page); + + // Sample tabs at high frequency across the transition + const snapshots = await sampleTabsDuringTransition( + page, + () => clickNewChat(page), + 2_000, + 30, + ); + + // Every snapshot should have at least one tab — no blank/zero-tab frames + for (const snapshot of snapshots) { + expect(snapshot.length).toBeGreaterThanOrEqual(1); + } + + // Tab count should never increase (no duplicate flash from add-then-remove) + const counts = snapshots.map((s) => s.length); + const maxCount = Math.max(...counts); + const initialCount = counts[0] ?? 0; + + // Allow at most +1 transient tab (tolerance for React render batching) + expect(maxCount).toBeLessThanOrEqual(initialCount + 1); + }); + + test("Terminal transition completes within visual budget", async ({ page }) => { + test.setTimeout(30_000); + await gotoWorkspace(page, workspaceId); + + await clickNewTabButton(page); + await waitForLauncherPanel(page); + + const terminal = page.locator('[data-testid="terminal-surface"]'); + const elapsed = await measureTileTransition( + page, + () => clickTerminal(page), + terminal.first(), + 20_000, + ); + + // Terminal surface should appear within a reasonable budget. + // Note: terminal creation involves a server round-trip, so we allow more time + // than a pure in-memory transition, but it should still be well under 5 seconds. + expect(elapsed).toBeLessThan(5_000); + }); + + test("New Chat click → composer appears without launcher flash", async ({ page }) => { + await gotoWorkspace(page, workspaceId); + + await clickNewTabButton(page); + await waitForLauncherPanel(page); + + const composer = page.getByRole("textbox", { name: "Message agent..." }).first(); + + const elapsed = await measureTileTransition( + page, + () => clickNewChat(page), + composer, + 10_000, + ); + + // Draft replacement is fully in-memory — should be fast + // We use a generous budget here because CI can be slow, but the key assertion + // is that no blank/flash frame appears (tested above). + expect(elapsed).toBeLessThan(3_000); + }); +}); diff --git a/packages/app/e2e/sidebar-workspace.spec.ts b/packages/app/e2e/sidebar-workspace.spec.ts new file mode 100644 index 000000000..4d7b285b7 --- /dev/null +++ b/packages/app/e2e/sidebar-workspace.spec.ts @@ -0,0 +1,181 @@ +import { execSync } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test, expect } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { createTempGitRepo } from "./helpers/workspace"; +import { expectWorkspaceHeader } from "./helpers/workspace-ui"; +import { connectWorkspaceSetupClient } from "./helpers/workspace-setup"; + +function getServerId(): string { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); + } + return serverId; +} + +function getWorkspaceRowTestId(workspaceId: string): string { + return `sidebar-workspace-row-${getServerId()}:${workspaceId}`; +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function setGitHubRemote(repoPath: string): void { + execSync("git remote set-url origin https://github.com/test-owner/test-repo.git", { + cwd: repoPath, + stdio: "ignore", + }); +} + +async function createTempDirectory(prefix = "paseo-e2e-dir-") { + const dirPath = await mkdtemp(path.join(process.platform === "win32" ? tmpdir() : "/tmp", prefix)); + await writeFile(path.join(dirPath, "README.md"), "# Temp Directory\n"); + return { + path: dirPath, + cleanup: async () => { + await rm(dirPath, { recursive: true, force: true }); + }, + }; +} + +async function openProjectViaDaemon( + client: Awaited>, + cwd: string, +): Promise<{ id: string; name: string }> { + const result = await client.openProject(cwd); + if (!result.workspace || result.error) { + throw new Error(result.error ?? `Failed to open project ${cwd}`); + } + return { + id: String(result.workspace.id), + name: result.workspace.name, + }; +} + +async function openWorkspaceFromSidebar(page: import("@playwright/test").Page, workspaceId: string) { + const row = page.getByTestId(getWorkspaceRowTestId(workspaceId)); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.click(); + await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 }); + return row; +} + +async function waitForSidebarProject( + page: import("@playwright/test").Page, + projectName: string, +) { + const row = page + .getByRole("button", { + name: new RegExp(escapeRegex(projectName), "i"), + }) + .first(); + await expect(row).toBeVisible({ timeout: 30_000 }); + return row; +} + +async function waitForSidebarWorkspace(page: import("@playwright/test").Page, workspaceId: string) { + const row = page.getByTestId(getWorkspaceRowTestId(workspaceId)); + await expect(row).toBeVisible({ timeout: 30_000 }); + return row; +} + +test.describe("Sidebar workspace list", () => { + test("project with GitHub remote shows owner/repo name in sidebar", async ({ page }) => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("sidebar-remote-", { withRemote: true }); + + try { + setGitHubRemote(repo.path); + const workspace = await openProjectViaDaemon(client, repo.path); + await gotoAppShell(page); + await waitForSidebarProject(page, "test-owner/test-repo"); + await waitForSidebarWorkspace(page, workspace.id); + + const projectRow = page + .locator('[data-testid^="sidebar-project-row-"]') + .filter({ hasText: "test-owner/test-repo" }) + .first(); + + await expect(projectRow).toBeVisible({ timeout: 30_000 }); + await expect(projectRow).not.toContainText(path.basename(repo.path)); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + + test("project shows workspace under it", async ({ page }) => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("sidebar-workspace-under-project-"); + + try { + const workspace = await openProjectViaDaemon(client, repo.path); + await gotoAppShell(page); + + await waitForSidebarProject(page, path.basename(repo.path)); + await waitForSidebarWorkspace(page, workspace.id); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + + test("non-git project shows directory name", async ({ page }) => { + const client = await connectWorkspaceSetupClient(); + const project = await createTempDirectory("sidebar-directory-"); + + try { + await openProjectViaDaemon(client, project.path); + await gotoAppShell(page); + + const projectRow = await waitForSidebarProject(page, path.basename(project.path)); + await expect(projectRow).toContainText(path.basename(project.path)); + } finally { + await client.close(); + await project.cleanup(); + } + }); + + test("workspace header shows correct title and subtitle", async ({ page }) => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("sidebar-header-", { withRemote: true }); + + try { + setGitHubRemote(repo.path); + const workspace = await openProjectViaDaemon(client, repo.path); + await gotoAppShell(page); + await waitForSidebarProject(page, "test-owner/test-repo"); + await waitForSidebarWorkspace(page, workspace.id); + await openWorkspaceFromSidebar(page, workspace.id); + + await expectWorkspaceHeader(page, { + title: workspace.name, + subtitle: "test-owner/test-repo", + }); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + + test("git project shows branch name in workspace row", async ({ page }) => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("sidebar-branch-"); + + try { + const workspace = await openProjectViaDaemon(client, repo.path); + await gotoAppShell(page); + await waitForSidebarProject(page, path.basename(repo.path)); + + expect(workspace.name).toBe("main"); + await expect(await waitForSidebarWorkspace(page, workspace.id)).toContainText("main"); + } finally { + await client.close(); + await repo.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/terminal-performance.spec.ts b/packages/app/e2e/terminal-performance.spec.ts index da03858a4..9490343cb 100644 --- a/packages/app/e2e/terminal-performance.spec.ts +++ b/packages/app/e2e/terminal-performance.spec.ts @@ -24,6 +24,9 @@ test.describe("Terminal wire performance", () => { test.beforeAll(async () => { tempRepo = await createTempGitRepo("perf-"); client = await connectTerminalClient(); + // Seed the workspace in the daemon so the app can resolve the path + const seedResult = await client.openProject(tempRepo.path); + if (!seedResult.workspace) throw new Error(seedResult.error ?? "Failed to seed workspace"); }); test.afterAll(async () => { diff --git a/packages/app/e2e/workspace-cwd.spec.ts b/packages/app/e2e/workspace-cwd.spec.ts new file mode 100644 index 000000000..34769ac73 --- /dev/null +++ b/packages/app/e2e/workspace-cwd.spec.ts @@ -0,0 +1,107 @@ +import { execSync } from "node:child_process"; +import path from "node:path"; +import { expect, test } from "./fixtures"; +import { + clickNewTabButton, + clickTerminal, + gotoWorkspace, + waitForLauncherPanel, +} from "./helpers/launcher"; +import { + setupDeterministicPrompt, + waitForTerminalContent, +} from "./helpers/terminal-perf"; +import { createTempGitRepo } from "./helpers/workspace"; +import { connectWorkspaceSetupClient, seedProjectForWorkspaceSetup } from "./helpers/workspace-setup"; + +test.describe("Workspace cwd correctness", () => { + test("main checkout workspace opens terminals in the project root", async ({ page }) => { + test.setTimeout(60_000); + + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("workspace-cwd-main-"); + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + + const workspaceResult = await client.openProject(repo.path); + if (!workspaceResult.workspace) { + throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`); + } + const workspaceId = String(workspaceResult.workspace.id); + + await gotoWorkspace(page, workspaceId); + await clickNewTabButton(page); + await waitForLauncherPanel(page); + await clickTerminal(page); + + const terminal = page.locator('[data-testid="terminal-surface"]'); + await expect(terminal.first()).toBeVisible({ timeout: 20_000 }); + await terminal.first().click(); + + await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`); + await terminal.first().pressSequentially("pwd\n", { delay: 0 }); + + await waitForTerminalContent(page, (text) => text.includes(repo.path), 10_000); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + + test("worktree workspace opens terminals in the worktree directory", async ({ page }) => { + test.setTimeout(90_000); + + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("workspace-cwd-worktree-"); + const worktreePath = path.join( + "/tmp", + `paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + const branchName = `workspace-cwd-${Date.now()}`; + let worktreeCreated = false; + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + + execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, { + cwd: repo.path, + stdio: "ignore", + }); + worktreeCreated = true; + + const workspaceResult = await client.openProject(worktreePath); + if (!workspaceResult.workspace) { + throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`); + } + const workspaceId = String(workspaceResult.workspace.id); + + await gotoWorkspace(page, workspaceId); + await clickNewTabButton(page); + await waitForLauncherPanel(page); + await clickTerminal(page); + + const terminal = page.locator('[data-testid="terminal-surface"]'); + await expect(terminal.first()).toBeVisible({ timeout: 20_000 }); + await terminal.first().click(); + + await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`); + await terminal.first().pressSequentially("pwd\n", { delay: 0 }); + await waitForTerminalContent(page, (text) => text.includes(worktreePath), 10_000); + } finally { + if (worktreeCreated) { + try { + execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, { + cwd: repo.path, + stdio: "ignore", + }); + } catch { + // Best-effort cleanup so test failures preserve the original error. + } + } + await client.close(); + await repo.cleanup(); + } + }); + +}); diff --git a/packages/app/e2e/workspace-lifecycle.spec.ts b/packages/app/e2e/workspace-lifecycle.spec.ts new file mode 100644 index 000000000..e7c183f6d --- /dev/null +++ b/packages/app/e2e/workspace-lifecycle.spec.ts @@ -0,0 +1,242 @@ +import { execSync } from "node:child_process"; +import path from "node:path"; +import { test } from "./fixtures"; +import { + clickNewTabButton, + gotoWorkspace, + waitForLauncherPanel, +} from "./helpers/launcher"; +import { createTempGitRepo } from "./helpers/workspace"; +import { + createAgentChatFromLauncher, + createStandaloneTerminalFromLauncher, + createTerminalAgentFromLauncher, + expectTerminalCwd, +} from "./helpers/workspace-lifecycle"; +import { connectWorkspaceSetupClient, seedProjectForWorkspaceSetup } from "./helpers/workspace-setup"; + +test.describe("Workspace lifecycle", () => { + // The first test after a spec-file switch can intermittently fail because + // the shared daemon still holds stale sessions from the previous spec. + // One retry is enough for the daemon to stabilize. + test.describe.configure({ retries: 1 }); + + test.describe("Main checkout", () => { + test("creates a terminal agent via provider tile", async ({ page }) => { + test.setTimeout(60_000); + + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("lifecycle-main-agent-"); + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + const workspaceResult = await client.openProject(repo.path); + if (!workspaceResult.workspace) { + throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`); + } + const workspaceId = String(workspaceResult.workspace.id); + + await gotoWorkspace(page, workspaceId); + await clickNewTabButton(page); + await waitForLauncherPanel(page); + await createTerminalAgentFromLauncher(page, "Claude"); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + + test("creates an agent chat via New Chat", async ({ page }) => { + test.setTimeout(60_000); + + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("lifecycle-main-chat-"); + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + const workspaceResult = await client.openProject(repo.path); + if (!workspaceResult.workspace) { + throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`); + } + const workspaceId = String(workspaceResult.workspace.id); + + await gotoWorkspace(page, workspaceId); + await clickNewTabButton(page); + await waitForLauncherPanel(page); + await createAgentChatFromLauncher(page); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + + test("creates a terminal with correct CWD", async ({ page }) => { + test.setTimeout(60_000); + + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("lifecycle-main-shell-"); + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + const workspaceResult = await client.openProject(repo.path); + if (!workspaceResult.workspace) { + throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`); + } + const workspaceId = String(workspaceResult.workspace.id); + + await gotoWorkspace(page, workspaceId); + await clickNewTabButton(page); + await waitForLauncherPanel(page); + await createStandaloneTerminalFromLauncher(page); + await expectTerminalCwd(page, repo.path); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + }); + + test.describe("Worktree workspace", () => { + test("creates a terminal agent via provider tile", async ({ page }) => { + test.setTimeout(90_000); + + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("lifecycle-wt-agent-"); + const worktreePath = path.join( + "/tmp", + `paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + const branchName = `lifecycle-wt-agent-${Date.now()}`; + let worktreeCreated = false; + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + + execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, { + cwd: repo.path, + stdio: "ignore", + }); + worktreeCreated = true; + + const workspaceResult = await client.openProject(worktreePath); + if (!workspaceResult.workspace) { + throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`); + } + const workspaceId = String(workspaceResult.workspace.id); + + await gotoWorkspace(page, workspaceId); + await clickNewTabButton(page); + await waitForLauncherPanel(page); + await createTerminalAgentFromLauncher(page, "Claude"); + } finally { + if (worktreeCreated) { + try { + execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, { + cwd: repo.path, + stdio: "ignore", + }); + } catch { + // Best-effort cleanup so test failures preserve the original error. + } + } + await client.close(); + await repo.cleanup(); + } + }); + + test("creates an agent chat via New Chat", async ({ page }) => { + test.setTimeout(90_000); + + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("lifecycle-wt-chat-"); + const worktreePath = path.join( + "/tmp", + `paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + const branchName = `lifecycle-wt-chat-${Date.now()}`; + let worktreeCreated = false; + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + + execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, { + cwd: repo.path, + stdio: "ignore", + }); + worktreeCreated = true; + + const workspaceResult = await client.openProject(worktreePath); + if (!workspaceResult.workspace) { + throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`); + } + const workspaceId = String(workspaceResult.workspace.id); + + await gotoWorkspace(page, workspaceId); + await clickNewTabButton(page); + await waitForLauncherPanel(page); + await createAgentChatFromLauncher(page); + } finally { + if (worktreeCreated) { + try { + execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, { + cwd: repo.path, + stdio: "ignore", + }); + } catch { + // Best-effort cleanup so test failures preserve the original error. + } + } + await client.close(); + await repo.cleanup(); + } + }); + + test("creates a terminal with correct CWD", async ({ page }) => { + test.setTimeout(90_000); + + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("lifecycle-wt-shell-"); + const worktreePath = path.join( + "/tmp", + `paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + const branchName = `lifecycle-wt-shell-${Date.now()}`; + let worktreeCreated = false; + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + + execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, { + cwd: repo.path, + stdio: "ignore", + }); + worktreeCreated = true; + + const workspaceResult = await client.openProject(worktreePath); + if (!workspaceResult.workspace) { + throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`); + } + const workspaceId = String(workspaceResult.workspace.id); + + await gotoWorkspace(page, workspaceId); + await clickNewTabButton(page); + await waitForLauncherPanel(page); + await createStandaloneTerminalFromLauncher(page); + await expectTerminalCwd(page, worktreePath); + } finally { + if (worktreeCreated) { + try { + execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, { + cwd: repo.path, + stdio: "ignore", + }); + } catch { + // Best-effort cleanup so test failures preserve the original error. + } + } + await client.close(); + await repo.cleanup(); + } + }); + }); +}); diff --git a/packages/app/e2e/workspace-setup-runtime.spec.ts b/packages/app/e2e/workspace-setup-runtime.spec.ts new file mode 100644 index 000000000..ca5a2ab79 --- /dev/null +++ b/packages/app/e2e/workspace-setup-runtime.spec.ts @@ -0,0 +1,171 @@ +import { existsSync } from "node:fs"; +import { expect, test } from "./fixtures"; +import { createTempGitRepo } from "./helpers/workspace"; +import { + connectWorkspaceSetupClient, + createChatAgentFromWorkspaceSetup, + createStandaloneTerminalFromWorkspaceSetup, + createTerminalAgentFromWorkspaceSetup, + createWorkspaceFromSidebar, + findWorktreeWorkspaceForProject, + openHomeWithProject, + type WorkspaceSetupDaemonClient, +} from "./helpers/workspace-setup"; + +async function openWorkspaceSetupDialogFromSidebar( + page: import("@playwright/test").Page, + repoPath: string, +): Promise { + await openHomeWithProject(page, repoPath); + await createWorkspaceFromSidebar(page, repoPath); +} + +async function expectCreatedWorkspaceRoute( + client: WorkspaceSetupDaemonClient, + originalProjectPath: string, +) { + await expect + .poll( + async () => { + try { + return await findWorktreeWorkspaceForProject(client, originalProjectPath); + } catch { + return null; + } + }, + { timeout: 30_000 }, + ) + .not.toBeNull(); + + const workspace = await findWorktreeWorkspaceForProject(client, originalProjectPath); + + expect(workspace.workspaceDirectory).not.toBe(originalProjectPath); + expect(existsSync(workspace.workspaceDirectory)).toBe(true); + return workspace; +} + +async function waitForNewWorkspaceAgent( + client: WorkspaceSetupDaemonClient, + expectedWorkspaceDirectory: string, + agentIdsBefore: Set, +) { + await expect + .poll( + async () => { + const payload = await client.fetchAgents(); + return ( + payload.entries.find( + (entry) => + !agentIdsBefore.has(entry.agent.id) && + entry.agent.cwd === expectedWorkspaceDirectory, + )?.agent ?? null + ); + }, + { timeout: 30_000 }, + ) + .not.toBeNull(); + + const payload = await client.fetchAgents(); + const agent = + payload.entries.find( + (entry) => + !agentIdsBefore.has(entry.agent.id) && entry.agent.cwd === expectedWorkspaceDirectory, + )?.agent ?? null; + if (!agent) { + throw new Error(`Expected a new agent for workspace ${expectedWorkspaceDirectory}`); + } + return agent; +} + +test.describe("Workspace setup runtime authority", () => { + test.describe.configure({ retries: 1 }); + + test("first chat agent attaches to the created workspace", async ({ page }) => { + test.setTimeout(90_000); + + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("workspace-setup-chat-"); + + try { + await client.openProject(repo.path); + await openWorkspaceSetupDialogFromSidebar(page, repo.path); + const agentIdsBefore = new Set((await client.fetchAgents()).entries.map((entry) => entry.agent.id)); + + await createChatAgentFromWorkspaceSetup(page, { + message: `workspace-setup-chat-${Date.now()}`, + }); + + const workspace = await expectCreatedWorkspaceRoute(client, repo.path); + const agent = await waitForNewWorkspaceAgent( + client, + workspace.workspaceDirectory, + agentIdsBefore, + ); + expect(agent.cwd).toBe(workspace.workspaceDirectory); + expect(agent.cwd).not.toBe(repo.path); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + + test("first terminal agent attaches to the created workspace", async ({ page }) => { + test.setTimeout(90_000); + + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("workspace-setup-terminal-agent-"); + + try { + await client.openProject(repo.path); + await openWorkspaceSetupDialogFromSidebar(page, repo.path); + const agentIdsBefore = new Set((await client.fetchAgents()).entries.map((entry) => entry.agent.id)); + + await createTerminalAgentFromWorkspaceSetup(page, { + providerLabel: "Claude", + prompt: `workspace-setup-terminal-agent-${Date.now()}`, + }); + + const workspace = await expectCreatedWorkspaceRoute(client, repo.path); + const agent = await waitForNewWorkspaceAgent( + client, + workspace.workspaceDirectory, + agentIdsBefore, + ); + expect(agent.cwd).toBe(workspace.workspaceDirectory); + expect(agent.cwd).not.toBe(repo.path); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + + test("first terminal attaches to the created workspace", async ({ page }) => { + test.setTimeout(90_000); + + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("workspace-setup-terminal-"); + + try { + await client.openProject(repo.path); + await openWorkspaceSetupDialogFromSidebar(page, repo.path); + + await createStandaloneTerminalFromWorkspaceSetup(page); + + const workspace = await expectCreatedWorkspaceRoute(client, repo.path); + + await expect + .poll( + async () => + (await client.listTerminals(workspace.workspaceDirectory)).terminals.length > 0, + { timeout: 30_000 }, + ) + .toBe(true); + expect( + (await client.listTerminals(repo.path)).terminals.length, + ).toBe(0); + } finally { + await client.close(); + await repo.cleanup(); + } + }); +}); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index d75ad1550..af7d45145 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -60,6 +60,7 @@ import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout"; import { CommandCenter } from "@/components/command-center"; import { ProjectPickerModal } from "@/components/project-picker-modal"; import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; +import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { queryClient } from "@/query/query-client"; import { @@ -76,6 +77,7 @@ import { parseServerIdFromPathname, parseHostAgentRouteFromPathname, parseWorkspaceOpenIntent, + decodeWorkspaceIdFromPathSegment, } from "@/utils/host-routes"; import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store"; @@ -372,6 +374,7 @@ function AppContainer({ + ); @@ -607,7 +610,6 @@ function FaviconStatusSync() { function RootStack() { const storeReady = useStoreReady(); const { theme } = useUnistyles(); - return ( - + { + const serverValue = Array.isArray(params?.serverId) + ? params.serverId[0] + : params?.serverId; + const workspaceValue = Array.isArray(params?.workspaceId) + ? params.workspaceId[0] + : params?.workspaceId; + const serverId = typeof serverValue === "string" ? serverValue.trim() : ""; + const workspaceId = + typeof workspaceValue === "string" + ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? workspaceValue.trim()) + : ""; + return `${serverId}:${workspaceId}`; + }} + /> + serverId ? state.sessions[serverId]?.workspaces : undefined, + ); + const hasHydratedWorkspaces = useSessionStore((state) => + serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false, + ); + const resolvedWorkspaceId = useSessionStore((state) => { + if (!serverId || !agentId) { + return null; + } + return resolveWorkspaceIdByExecutionDirectory({ + workspaces: state.sessions[serverId]?.workspaces?.values(), + workspaceDirectory: state.sessions[serverId]?.agents?.get(agentId)?.cwd, + }); + }); useEffect(() => { if (redirectedRef.current) { @@ -33,18 +49,17 @@ export default function HostAgentReadyRoute() { return; } - const normalizedCwd = agentCwd?.trim(); - if (normalizedCwd) { + if (resolvedWorkspaceId) { redirectedRef.current = true; router.replace( prepareWorkspaceTab({ serverId, - workspaceId: normalizedCwd, + workspaceId: resolvedWorkspaceId, target: { kind: "agent", agentId }, }) as any, ); } - }, [agentCwd, agentId, router, serverId]); + }, [agentId, resolvedWorkspaceId, router, serverId]); useEffect(() => { if (redirectedRef.current) { @@ -53,14 +68,14 @@ export default function HostAgentReadyRoute() { if (!serverId || !agentId) { return; } - if (agentCwd?.trim()) { + if (agentCwd?.trim() && !hasHydratedWorkspaces) { return; } if (!client || !isConnected) { redirectedRef.current = true; router.replace(buildHostRootRoute(serverId) as any); } - }, [agentCwd, agentId, client, isConnected, router, serverId]); + }, [agentCwd, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]); useEffect(() => { if (redirectedRef.current) { @@ -78,12 +93,19 @@ export default function HostAgentReadyRoute() { return; } const cwd = result?.agent?.cwd?.trim(); + const workspaceId = resolveWorkspaceIdByExecutionDirectory({ + workspaces: sessionWorkspaces?.values(), + workspaceDirectory: cwd, + }); + if (!workspaceId && !hasHydratedWorkspaces) { + return; + } redirectedRef.current = true; - if (cwd) { + if (workspaceId) { router.replace( prepareWorkspaceTab({ serverId, - workspaceId: cwd, + workspaceId, target: { kind: "agent", agentId }, }) as any, ); @@ -102,7 +124,7 @@ export default function HostAgentReadyRoute() { return () => { cancelled = true; }; - }, [agentId, client, isConnected, router, serverId]); + }, [agentId, client, hasHydratedWorkspaces, isConnected, router, serverId, sessionWorkspaces]); return null; } diff --git a/packages/app/src/app/h/[serverId]/index.tsx b/packages/app/src/app/h/[serverId]/index.tsx index 0d275ecd1..973570cdf 100644 --- a/packages/app/src/app/h/[serverId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/index.tsx @@ -3,14 +3,23 @@ import { useLocalSearchParams, usePathname, useRouter } from "expo-router"; import { useSessionStore } from "@/stores/session-store"; import { useFormPreferences } from "@/hooks/use-form-preferences"; import { + buildHostAgentDetailRoute, buildHostOpenProjectRoute, buildHostRootRoute, - buildHostWorkspaceRoute, + buildHostWorkspaceOpenRoute, } from "@/utils/host-routes"; +import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; const HOST_ROOT_REDIRECT_DELAY_MS = 300; +function getCurrentPathname(fallbackPathname: string): string { + if (typeof window === "undefined") { + return fallbackPathname; + } + return window.location.pathname || fallbackPathname; +} + export default function HostIndexRoute() { const router = useRouter(); const pathname = usePathname(); @@ -32,11 +41,13 @@ export default function HostIndexRoute() { return; } const rootRoute = buildHostRootRoute(serverId); - if (pathname !== rootRoute && pathname !== `${rootRoute}/`) { + const currentPathname = getCurrentPathname(pathname); + if (currentPathname !== rootRoute && currentPathname !== `${rootRoute}/`) { return; } const timer = setTimeout(() => { - if (pathname !== rootRoute && pathname !== `${rootRoute}/`) { + const latestPathname = getCurrentPathname(pathname); + if (latestPathname !== rootRoute && latestPathname !== `${rootRoute}/`) { return; } @@ -55,20 +66,30 @@ export default function HostIndexRoute() { }); const primaryAgent = visibleAgents[0]; - if (primaryAgent?.cwd?.trim()) { + const primaryAgentWorkspaceId = resolveWorkspaceIdByExecutionDirectory({ + workspaces: sessionWorkspaces?.values(), + workspaceDirectory: primaryAgent?.cwd, + }); + if (primaryAgent && primaryAgentWorkspaceId) { router.replace( prepareWorkspaceTab({ serverId, - workspaceId: primaryAgent.cwd.trim(), + workspaceId: primaryAgentWorkspaceId, target: { kind: "agent", agentId: primaryAgent.id }, }) as any, ); return; } + if (primaryAgent) { + router.replace(buildHostAgentDetailRoute(serverId, primaryAgent.id) as any); + return; + } const primaryWorkspace = visibleWorkspaces[0]; if (primaryWorkspace?.id?.trim()) { - router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()) as any); + router.replace( + buildHostWorkspaceOpenRoute(serverId, primaryWorkspace.id.trim(), "draft:new") as any, + ); return; } diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx index 2afc57223..399830a1c 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx @@ -1,10 +1,10 @@ import { useEffect, useRef } from "react"; -import { useGlobalSearchParams, useLocalSearchParams, useRouter } from "expo-router"; +import { useGlobalSearchParams, usePathname, useRouter } from "expo-router"; import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; import { buildHostWorkspaceRoute, - decodeWorkspaceIdFromPathSegment, + parseHostWorkspaceRouteFromPathname, parseWorkspaceOpenIntent, type WorkspaceOpenIntent, } from "@/utils/host-routes"; @@ -37,18 +37,13 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge export default function HostWorkspaceLayout() { const router = useRouter(); const consumedIntentRef = useRef(null); - const params = useLocalSearchParams<{ - serverId?: string | string[]; - workspaceId?: string | string[]; - }>(); + const pathname = usePathname(); const globalParams = useGlobalSearchParams<{ open?: string | string[]; }>(); - const serverId = getParamValue(params.serverId); - const workspaceValue = getParamValue(params.workspaceId); - const workspaceId = workspaceValue - ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "") - : ""; + const parsedWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname); + const serverId = parsedWorkspaceRoute?.serverId ?? ""; + const workspaceId = parsedWorkspaceRoute?.workspaceId ?? ""; const openValue = getParamValue(globalParams.open); useEffect(() => { diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index c65cc841d..33e18d87a 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -14,6 +14,13 @@ import { buildHostRootRoute } from "@/utils/host-routes"; const WELCOME_ROUTE = "/welcome"; +function getCurrentPathname(fallbackPathname: string): string { + if (typeof window === "undefined") { + return fallbackPathname; + } + return window.location.pathname || fallbackPathname; +} + function useAnyOnlineHostServerId(serverIds: string[]): string | null { const runtime = getHostRuntimeStore(); @@ -51,7 +58,8 @@ export default function Index() { if (!storeReady) { return; } - if (pathname !== "/" && pathname !== "") { + const currentPathname = getCurrentPathname(pathname); + if (currentPathname !== "/" && currentPathname !== "") { return; } diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 82c7611b4..07457105c 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -15,7 +15,10 @@ import { formatTimeAgo } from "@/utils/time"; import { shortenPath } from "@/utils/shorten-path"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useSessionStore } from "@/stores/session-store"; -import { Archive } from "lucide-react-native"; +import { Archive, SquareTerminal } from "lucide-react-native"; +import { getProviderIcon } from "@/components/provider-icons"; +import { buildHostAgentDetailRoute } from "@/utils/host-routes"; +import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; interface AgentListProps { @@ -130,6 +133,7 @@ function SessionRow({ const isSelected = selectedAgentId === agentKey; const statusLabel = formatStatusLabel(agent.status); const projectPath = shortenPath(agent.cwd); + const ProviderIcon = getProviderIcon(agent.provider); return ( + + + {agent.title || "New session"} + {agent.terminal ? ( + } + /> + ) : null} {agent.archivedAt ? ( ({ flexWrap: "wrap", gap: theme.spacing[2], }, + providerIconWrap: { + width: theme.iconSize.md, + alignItems: "center", + justifyContent: "center", + }, rowMetaRow: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index a4bf19ddf..686cbf3e1 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -63,6 +63,7 @@ import { createMarkdownStyles } from "@/styles/markdown-styles"; import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { getMarkdownListMarker } from "@/utils/markdown-list"; import { normalizeInlinePathTarget } from "@/utils/inline-path"; +import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { useStableEvent } from "@/hooks/use-stable-event"; import { @@ -132,10 +133,13 @@ const AgentStreamViewComponent = forwardRef { it("uses ready mode when no controlled status controls are provided", () => { diff --git a/packages/app/src/components/agent-input-area.status-controls.ts b/packages/app/src/components/composer.status-controls.ts similarity index 100% rename from packages/app/src/components/agent-input-area.status-controls.ts rename to packages/app/src/components/composer.status-controls.ts diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/composer.tsx similarity index 98% rename from packages/app/src/components/agent-input-area.tsx rename to packages/app/src/components/composer.tsx index 4f4d6c441..f8a703c9f 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/composer.tsx @@ -41,7 +41,7 @@ import { persistAttachmentFromBlob, persistAttachmentFromFileUri, } from "@/attachments/service"; -import { resolveStatusControlMode } from "@/components/agent-input-area.status-controls"; +import { resolveStatusControlMode } from "@/components/composer.status-controls"; import { markScrollInvestigationRender } from "@/utils/scroll-jank-investigation"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; @@ -56,11 +56,12 @@ type QueuedMessage = { type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageAttachment[]); -interface AgentInputAreaProps { +interface ComposerProps { agentId: string; serverId: string; isInputActive: boolean; onSubmitMessage?: (payload: MessagePayload) => Promise; + allowEmptySubmit?: boolean; /** Externally controlled loading state. When true, disables the submit button. */ isSubmitLoading?: boolean; /** When true, blurs the input immediately when submitting. */ @@ -89,11 +90,12 @@ const EMPTY_ARRAY: readonly QueuedMessage[] = []; const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills"; const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands"; -export function AgentInputArea({ +export function Composer({ agentId, serverId, isInputActive, onSubmitMessage, + allowEmptySubmit = false, isSubmitLoading = false, blurOnSubmit = false, value, @@ -109,8 +111,8 @@ export function AgentInputArea({ onAttentionInputFocus, onAttentionPromptSend, statusControls, -}: AgentInputAreaProps) { - markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`); +}: ComposerProps) { + markScrollInvestigationRender(`Composer:${serverId}:${agentId}`); const { theme } = useUnistyles(); const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg; const insets = useSafeAreaInsets(); @@ -480,7 +482,7 @@ export function AgentInputArea({ return; } void voice.startVoice(serverId, agentId).catch((error) => { - console.error("[AgentInputArea] Failed to start voice mode", error); + console.error("[Composer] Failed to start voice mode", error); const message = error instanceof Error ? error.message : typeof error === "string" ? error : null; if (message && message.trim().length > 0) { @@ -677,6 +679,7 @@ export function AgentInputArea({ value={userInput} onChangeText={setUserInput} onSubmit={handleSubmit} + allowEmptySubmit={allowEmptySubmit} isSubmitDisabled={isProcessing || isSubmitLoading} isSubmitLoading={isProcessing || isSubmitLoading} images={selectedImages} diff --git a/packages/app/src/components/icons/aider-icon.tsx b/packages/app/src/components/icons/aider-icon.tsx new file mode 100644 index 000000000..1deb4a218 --- /dev/null +++ b/packages/app/src/components/icons/aider-icon.tsx @@ -0,0 +1,16 @@ +import Svg, { Path } from "react-native-svg"; + +interface AiderIconProps { + size?: number; + color?: string; +} + +export function AiderIcon({ size = 16, color = "currentColor" }: AiderIconProps) { + return ( + + + + ); +} diff --git a/packages/app/src/components/icons/amp-icon.tsx b/packages/app/src/components/icons/amp-icon.tsx new file mode 100644 index 000000000..5b855867d --- /dev/null +++ b/packages/app/src/components/icons/amp-icon.tsx @@ -0,0 +1,29 @@ +import Svg, { Path } from "react-native-svg"; + +interface AmpIconProps { + size?: number; + color?: string; +} + +export function AmpIcon({ size = 16, color = "currentColor" }: AmpIconProps) { + return ( + + + + + + + ); +} diff --git a/packages/app/src/components/icons/gemini-icon.tsx b/packages/app/src/components/icons/gemini-icon.tsx new file mode 100644 index 000000000..05d3822ce --- /dev/null +++ b/packages/app/src/components/icons/gemini-icon.tsx @@ -0,0 +1,17 @@ +import Svg, { Path } from "react-native-svg"; + +interface GeminiIconProps { + size?: number; + color?: string; +} + +export function GeminiIcon({ size = 16, color = "currentColor" }: GeminiIconProps) { + return ( + + + + ); +} diff --git a/packages/app/src/components/icons/opencode-icon.tsx b/packages/app/src/components/icons/opencode-icon.tsx new file mode 100644 index 000000000..e5ded34f2 --- /dev/null +++ b/packages/app/src/components/icons/opencode-icon.tsx @@ -0,0 +1,19 @@ +import Svg, { Path } from "react-native-svg"; + +interface OpenCodeIconProps { + size?: number; + color?: string; +} + +export function OpenCodeIcon({ size = 16, color = "currentColor" }: OpenCodeIconProps) { + return ( + + + + + ); +} diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index cd500c1d7..7cbaa9e79 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -53,6 +53,7 @@ export interface MessageInputProps { value: string; onChangeText: (text: string) => void; onSubmit: (payload: MessagePayload) => void; + allowEmptySubmit?: boolean; isSubmitDisabled?: boolean; isSubmitLoading?: boolean; images?: ImageAttachment[]; @@ -178,6 +179,7 @@ export const MessageInput = forwardRef(funct value, onChangeText, onSubmit, + allowEmptySubmit = false, isSubmitDisabled = false, isSubmitLoading = false, images = [], @@ -851,7 +853,7 @@ export const MessageInput = forwardRef(funct } const hasImages = images.length > 0; - const hasSendableContent = value.trim().length > 0 || hasImages; + const hasSendableContent = value.trim().length > 0 || hasImages || allowEmptySubmit; const shouldShowSendButton = hasSendableContent || isSubmitLoading; const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function"; const isSendButtonDisabled = diff --git a/packages/app/src/components/project-picker-modal.tsx b/packages/app/src/components/project-picker-modal.tsx index 0556f2aec..785a7add1 100644 --- a/packages/app/src/components/project-picker-modal.tsx +++ b/packages/app/src/components/project-picker-modal.tsx @@ -40,9 +40,9 @@ export function ProjectPickerModal() { const recommendedPaths = useMemo(() => { if (!workspaces) return []; - return Array.from(workspaces.values()).map( - (workspace) => workspace.projectRootPath || workspace.id, - ); + return Array.from(workspaces.values()) + .map((workspace) => workspace.projectRootPath) + .filter((path) => path.length > 0); }, [workspaces]); const directorySuggestionsQuery = useQuery({ diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts index 0089dac56..e02a35cdb 100644 --- a/packages/app/src/components/provider-icons.ts +++ b/packages/app/src/components/provider-icons.ts @@ -1,10 +1,18 @@ import { Bot } from "lucide-react-native"; +import { AiderIcon } from "@/components/icons/aider-icon"; +import { AmpIcon } from "@/components/icons/amp-icon"; import { ClaudeIcon } from "@/components/icons/claude-icon"; import { CodexIcon } from "@/components/icons/codex-icon"; +import { GeminiIcon } from "@/components/icons/gemini-icon"; +import { OpenCodeIcon } from "@/components/icons/opencode-icon"; const PROVIDER_ICONS: Record = { claude: ClaudeIcon as unknown as typeof Bot, codex: CodexIcon as unknown as typeof Bot, + gemini: GeminiIcon as unknown as typeof Bot, + amp: AmpIcon as unknown as typeof Bot, + aider: AiderIcon as unknown as typeof Bot, + opencode: OpenCodeIcon as unknown as typeof Bot, }; export function getProviderIcon(provider: string): typeof Bot { diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 1e2727387..8e1dc43c6 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -10,7 +10,7 @@ import { type GestureResponderEvent, } from "react-native"; import * as Haptics from "expo-haptics"; -import { useMutation, useQueries } from "@tanstack/react-query"; +import { useQueries } from "@tanstack/react-query"; import { useCallback, useMemo, @@ -45,7 +45,6 @@ import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runt import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout"; import { projectIconQueryKey } from "@/hooks/use-project-icon-query"; import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes"; -import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { type SidebarProjectEntry, type SidebarWorkspaceEntry, @@ -83,10 +82,14 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query"; import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model"; import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; -import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; -import { createNameId } from "mnemonic-id"; +import { useSessionStore } from "@/stores/session-store"; +import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; import { openExternalUrl } from "@/utils/open-external-url"; +import { + requireWorkspaceExecutionDirectory, + resolveWorkspaceExecutionDirectory, +} from "@/utils/workspace-execution"; function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { if (!icon) { @@ -239,7 +242,7 @@ function WorkspaceStatusIndicator({ } const KindIcon = - workspaceKind === "local_checkout" + workspaceKind === "checkout" ? Monitor : workspaceKind === "worktree" ? FolderGit2 @@ -654,7 +657,6 @@ function ProjectHeaderRow({ canCreateWorktree, isProjectActive = false, onWorkspacePress, - onWorktreeCreated, shortcutNumber = null, showShortcutBadge = false, drag, @@ -665,50 +667,30 @@ function ProjectHeaderRow({ }: ProjectHeaderRowProps) { const [isHovered, setIsHovered] = useState(false); const isMobileBreakpoint = isCompactFormFactor(); - const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); - const toast = useToast(); + const beginWorkspaceSetup = useWorkspaceSetupStore((state) => state.beginWorkspaceSetup); + + const handleBeginWorkspaceSetup = useCallback(() => { + if (!serverId) { + return; + } + + onWorkspacePress?.(); + beginWorkspaceSetup({ + serverId, + sourceDirectory: project.iconWorkingDir, + displayName, + creationMethod: "create_worktree", + navigationMethod: "navigate", + }); + }, [beginWorkspaceSetup, displayName, onWorkspacePress, project.iconWorkingDir, serverId]); - const createWorktreeMutation = useMutation({ - mutationFn: async () => { - if (!serverId) { - throw new Error("No server"); - } - const client = getHostRuntimeStore().getClient(serverId); - if (!client || !isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(serverId))) { - throw new Error("Host is not connected"); - } - const payload = await client.createPaseoWorktree({ - cwd: project.iconWorkingDir, - worktreeSlug: createNameId(), - }); - if (payload.error || !payload.workspace) { - throw new Error(payload.error ?? "Failed to create worktree"); - } - return payload.workspace; - }, - onSuccess: (workspace) => { - mergeWorkspaces(serverId!, [normalizeWorkspaceDescriptor(workspace)]); - onWorktreeCreated?.(workspace.id); - onWorkspacePress?.(); - router.navigate( - prepareWorkspaceTab({ - serverId: serverId!, - workspaceId: workspace.id, - target: { kind: "draft", draftId: "new" }, - }) as any, - ); - }, - onError: (error) => { - toast.error(error instanceof Error ? error.message : String(error)); - }, - }); useKeyboardActionHandler({ handlerId: `worktree-new-${project.projectKey}`, actions: ["worktree.new"], - enabled: isProjectActive && canCreateWorktree && !createWorktreeMutation.isPending, + enabled: isProjectActive && canCreateWorktree, priority: 0, handle: () => { - createWorktreeMutation.mutate(); + handleBeginWorkspaceSetup(); return true; }, }); @@ -752,9 +734,9 @@ function ProjectHeaderRow({ {canCreateWorktree ? ( createWorktreeMutation.mutate()} + onPress={handleBeginWorkspaceSetup} visible={isHovered || isMobileBreakpoint} - loading={createWorktreeMutation.isPending} + loading={false} showShortcutHint={isProjectActive} testID={`sidebar-project-new-worktree-${project.projectKey}`} /> @@ -836,10 +818,13 @@ function WorkspaceRowInner({ const { theme } = useUnistyles(); const [isHovered, setIsHovered] = useState(false); const isTouchPlatform = Platform.OS !== "web"; + const workspaceDirectory = resolveWorkspaceExecutionDirectory({ + workspaceDirectory: workspace.workspaceDirectory, + }); const prHint = useWorkspacePrHint({ serverId: workspace.serverId, - cwd: workspace.workspaceId, - enabled: workspace.workspaceKind !== "directory", + cwd: workspaceDirectory ?? "", + enabled: workspace.projectKind === "git" && Boolean(workspaceDirectory), }); const interaction = useLongPressDragInteraction({ drag, @@ -1003,12 +988,17 @@ function WorkspaceRowWithMenu({ (state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES, ); const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); + const workspaceDirectory = resolveWorkspaceExecutionDirectory({ + workspaceDirectory: workspace.workspaceDirectory, + }); const archiveStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ - serverId: workspace.serverId, - cwd: workspace.workspaceId, - actionId: "archive-worktree", - }), + workspaceDirectory + ? state.getStatus({ + serverId: workspace.serverId, + cwd: workspaceDirectory, + actionId: "archive-worktree", + }) + : "idle", ); const isWorktree = workspace.workspaceKind === "worktree"; const isArchiving = isWorktree ? archiveStatus === "pending" : isArchivingWorkspace; @@ -1046,11 +1036,26 @@ function WorkspaceRowWithMenu({ if (!confirmed) { return; } + let workspaceDirectory: string; + try { + workspaceDirectory = requireWorkspaceExecutionDirectory({ + workspaceId: workspace.workspaceId, + workspaceDirectory: workspace.workspaceDirectory, + }); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Workspace path not available"); + return; + } + + if (!workspaceDirectory) { + toast.error("Workspace path not available"); + return; + } void archiveWorktree({ serverId: workspace.serverId, - cwd: workspace.workspaceId, - worktreePath: workspace.workspaceId, + cwd: workspaceDirectory, + worktreePath: workspaceDirectory, }) .then(() => { redirectAfterArchive(); @@ -1066,6 +1071,7 @@ function WorkspaceRowWithMenu({ redirectAfterArchive, toast, workspace.name, + workspace.workspaceDirectory, workspace.serverId, workspace.workspaceId, ]); @@ -1095,7 +1101,7 @@ function WorkspaceRowWithMenu({ setIsArchivingWorkspace(true); try { - const payload = await client.archiveWorkspace(workspace.workspaceId); + const payload = await client.archiveWorkspace(Number(workspace.workspaceId)); if (payload.error) { throw new Error(payload.error); } @@ -1116,9 +1122,19 @@ function WorkspaceRowWithMenu({ ]); const handleCopyPath = useCallback(() => { - void Clipboard.setStringAsync(workspace.workspaceId); + let workspaceDirectory: string; + try { + workspaceDirectory = requireWorkspaceExecutionDirectory({ + workspaceId: workspace.workspaceId, + workspaceDirectory: workspace.workspaceDirectory, + }); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Workspace path not available"); + return; + } + void Clipboard.setStringAsync(workspaceDirectory); toast.copied("Path copied"); - }, [toast, workspace.workspaceId]); + }, [toast, workspace.workspaceDirectory, workspace.workspaceId]); const handleCopyBranchName = useCallback(() => { void Clipboard.setStringAsync(workspace.name); @@ -1240,7 +1256,7 @@ function NonGitProjectRowWithMenuContent({ setIsArchivingWorkspace(true); try { - const payload = await client.archiveWorkspace(workspace.workspaceId); + const payload = await client.archiveWorkspace(Number(workspace.workspaceId)); if (payload.error) { throw new Error(payload.error); } @@ -1351,7 +1367,7 @@ function FlattenedProjectRow({ dragHandleProps?: DraggableListDragHandleProps; isProjectActive?: boolean; }) { - if (project.projectKind === "non_git") { + if (project.projectKind === "directory") { return ( Promise | void; onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; - onSelectNewTabOption: (selection: { - optionId: "__new_tab_agent__" | "__new_tab_terminal__"; - paneId?: string; - }) => void; - onNewTerminalTab: (input: { paneId?: string }) => void; - newTabAgentOptionId?: "__new_tab_agent__" | "__new_tab_terminal__"; + onCreateLauncherTab: (input: { paneId?: string }) => void; buildPaneContentModel: (input: { paneId: string; isPaneFocused: boolean; @@ -268,9 +263,7 @@ export function SplitContainer({ onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, - onSelectNewTabOption, - onNewTerminalTab, - newTabAgentOptionId = "__new_tab_agent__", + onCreateLauncherTab, buildPaneContentModel, onFocusPane, onSplitPane, @@ -537,9 +530,7 @@ export function SplitContainer({ onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToRight={onCloseTabsToRight} onCloseOtherTabs={onCloseOtherTabs} - onSelectNewTabOption={onSelectNewTabOption} - onNewTerminalTab={onNewTerminalTab} - newTabAgentOptionId={newTabAgentOptionId} + onCreateLauncherTab={onCreateLauncherTab} buildPaneContentModel={buildPaneContentModel} onFocusPane={onFocusPane} onSplitPane={onSplitPane} @@ -659,9 +650,7 @@ function SplitNodeView({ onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, - onSelectNewTabOption, - onNewTerminalTab, - newTabAgentOptionId, + onCreateLauncherTab, buildPaneContentModel, onFocusPane, onSplitPane, @@ -694,9 +683,7 @@ function SplitNodeView({ onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToRight={onCloseTabsToRight} onCloseOtherTabs={onCloseOtherTabs} - onSelectNewTabOption={onSelectNewTabOption} - onNewTerminalTab={onNewTerminalTab} - newTabAgentOptionId={newTabAgentOptionId} + onCreateLauncherTab={onCreateLauncherTab} buildPaneContentModel={buildPaneContentModel} onFocusPane={onFocusPane} onSplitPane={onSplitPane} @@ -744,9 +731,7 @@ function SplitNodeView({ onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToRight={onCloseTabsToRight} onCloseOtherTabs={onCloseOtherTabs} - onSelectNewTabOption={onSelectNewTabOption} - onNewTerminalTab={onNewTerminalTab} - newTabAgentOptionId={newTabAgentOptionId} + onCreateLauncherTab={onCreateLauncherTab} buildPaneContentModel={buildPaneContentModel} onFocusPane={onFocusPane} onSplitPane={onSplitPane} @@ -793,9 +778,7 @@ function SplitPaneView({ onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, - onSelectNewTabOption, - onNewTerminalTab, - newTabAgentOptionId, + onCreateLauncherTab, buildPaneContentModel, onFocusPane, onSplitPane, @@ -904,9 +887,7 @@ function SplitPaneView({ onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)} onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)} onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)} - onSelectNewTabOption={onSelectNewTabOption} - onNewTerminalTab={onNewTerminalTab} - newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"} + onCreateLauncherTab={onCreateLauncherTab} onReorderTabs={(nextTabs) => { onReorderTabsInPane( pane.id, diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx index 19f7a5310..de352a0e3 100644 --- a/packages/app/src/components/welcome-screen.tsx +++ b/packages/app/src/components/welcome-screen.tsx @@ -244,6 +244,11 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { ); useEffect(() => { + const currentPathname = + typeof window === "undefined" ? null : (window.location.pathname || null); + if (currentPathname && currentPathname !== "/welcome") { + return; + } if (!anyOnlineServerId) { return; } diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx new file mode 100644 index 000000000..00c71d02d --- /dev/null +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -0,0 +1,726 @@ +import { useCallback, useEffect, useMemo, useState, type ComponentType } from "react"; +import { ActivityIndicator, Pressable, Text, View } from "react-native"; +import { Bot, ChevronLeft, MessagesSquare, SquareTerminal } from "lucide-react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; +import { createNameId } from "mnemonic-id"; +import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; +import { Composer } from "@/components/composer"; +import { getProviderIcon } from "@/components/provider-icons"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/contexts/toast-context"; +import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useProviderRecency } from "@/stores/provider-recency-store"; +import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; +import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; +import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { encodeImages } from "@/utils/encode-images"; +import { toErrorMessage } from "@/utils/error-messages"; +import { + requireWorkspaceExecutionAuthority, + requireWorkspaceRecordId, +} from "@/utils/workspace-execution"; +import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; +import type { MessagePayload } from "./message-input"; + +type SetupStep = "choose" | "chat" | "terminal-agent"; + +export function WorkspaceSetupDialog() { + const { theme } = useUnistyles(); + const toast = useToast(); + const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup); + const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup); + const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); + const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); + const setAgents = useSessionStore((state) => state.setAgents); + const [step, setStep] = useState("choose"); + const [terminalPrompt, setTerminalPrompt] = useState(""); + const [errorMessage, setErrorMessage] = useState(null); + const [createdWorkspace, setCreatedWorkspace] = useState | null>(null); + const [pendingAction, setPendingAction] = useState<"chat" | "terminal-agent" | "terminal" | null>( + null, + ); + + const serverId = pendingWorkspaceSetup?.serverId ?? ""; + const sourceDirectory = pendingWorkspaceSetup?.sourceDirectory ?? ""; + const displayName = pendingWorkspaceSetup?.displayName?.trim() ?? ""; + const workspace = createdWorkspace; + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); + const chatDraft = useAgentInputDraft({ + draftKey: `workspace-setup:${serverId}:${sourceDirectory}`, + composer: { + initialServerId: serverId || null, + initialValues: workspace?.workspaceDirectory + ? { workingDir: workspace.workspaceDirectory } + : undefined, + isVisible: pendingWorkspaceSetup !== null, + onlineServerIds: isConnected && serverId ? [serverId] : [], + lockedWorkingDir: workspace?.workspaceDirectory || undefined, + }, + }); + const composerState = chatDraft.composerState; + if (!composerState && pendingWorkspaceSetup) { + throw new Error("Workspace setup composer state is required"); + } + const { providers: sortedProviders, recordUsage } = useProviderRecency( + composerState?.providerDefinitions ?? [], + ); + + useEffect(() => { + setStep("choose"); + setTerminalPrompt(""); + setErrorMessage(null); + setCreatedWorkspace(null); + setPendingAction(null); + }, [pendingWorkspaceSetup?.creationMethod, serverId, sourceDirectory]); + + const handleClose = useCallback(() => { + clearWorkspaceSetup(); + }, [clearWorkspaceSetup]); + + const navigateAfterCreation = useCallback( + ( + workspaceId: string, + target: { kind: "agent"; agentId: string } | { kind: "terminal"; terminalId: string }, + ) => { + if (!pendingWorkspaceSetup) { + return; + } + + clearWorkspaceSetup(); + navigateToPreparedWorkspaceTab({ + serverId: pendingWorkspaceSetup.serverId, + workspaceId, + target, + navigationMethod: pendingWorkspaceSetup.navigationMethod, + }); + }, + [clearWorkspaceSetup, pendingWorkspaceSetup], + ); + + const withConnectedClient = useCallback(() => { + if (!client || !isConnected) { + throw new Error("Host is not connected"); + } + return client; + }, [client, isConnected]); + + const ensureWorkspace = useCallback(async () => { + if (!pendingWorkspaceSetup) { + throw new Error("No workspace setup is pending"); + } + + if (createdWorkspace) { + return createdWorkspace; + } + + const connectedClient = withConnectedClient(); + const payload = + pendingWorkspaceSetup.creationMethod === "create_worktree" + ? await connectedClient.createPaseoWorktree({ + cwd: pendingWorkspaceSetup.sourceDirectory, + worktreeSlug: createNameId(), + }) + : await connectedClient.openProject(pendingWorkspaceSetup.sourceDirectory); + + if (payload.error || !payload.workspace) { + throw new Error( + payload.error ?? + (pendingWorkspaceSetup.creationMethod === "create_worktree" + ? "Failed to create worktree" + : "Failed to open project"), + ); + } + + const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace); + mergeWorkspaces(pendingWorkspaceSetup.serverId, [normalizedWorkspace]); + if (pendingWorkspaceSetup.creationMethod === "open_project") { + setHasHydratedWorkspaces(pendingWorkspaceSetup.serverId, true); + } + setCreatedWorkspace(normalizedWorkspace); + return normalizedWorkspace; + }, [ + createdWorkspace, + mergeWorkspaces, + pendingWorkspaceSetup, + setHasHydratedWorkspaces, + withConnectedClient, + ]); + + const getIsStillActive = useCallback(() => { + const current = useWorkspaceSetupStore.getState().pendingWorkspaceSetup; + return ( + current?.serverId === pendingWorkspaceSetup?.serverId && + current?.sourceDirectory === pendingWorkspaceSetup?.sourceDirectory && + current?.creationMethod === pendingWorkspaceSetup?.creationMethod + ); + }, [ + pendingWorkspaceSetup?.creationMethod, + pendingWorkspaceSetup?.serverId, + pendingWorkspaceSetup?.sourceDirectory, + ]); + + const handleCreateChatAgent = useCallback( + async ({ text, images }: MessagePayload) => { + try { + setPendingAction("chat"); + setErrorMessage(null); + const workspace = await ensureWorkspace(); + const connectedClient = withConnectedClient(); + if (!composerState) { + throw new Error("Workspace setup composer state is required"); + } + + const encodedImages = await encodeImages(images); + const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory; + const agent = await connectedClient.createAgent({ + provider: composerState.selectedProvider, + cwd: workspaceDirectory, + workspaceId: requireWorkspaceRecordId(workspace.id), + ...(composerState.modeOptions.length > 0 && composerState.selectedMode !== "" + ? { modeId: composerState.selectedMode } + : {}), + ...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}), + ...(composerState.effectiveThinkingOptionId + ? { thinkingOptionId: composerState.effectiveThinkingOptionId } + : {}), + ...(text.trim() ? { initialPrompt: text.trim() } : {}), + ...(encodedImages && encodedImages.length > 0 ? { images: encodedImages } : {}), + }); + + if (!getIsStillActive()) { + return; + } + + setAgents(serverId, (previous) => { + const next = new Map(previous); + next.set(agent.id, normalizeAgentSnapshot(agent, serverId)); + return next; + }); + navigateAfterCreation(workspace.id, { kind: "agent", agentId: agent.id }); + } catch (error) { + const message = toErrorMessage(error); + setErrorMessage(message); + toast.error(message); + } finally { + if (getIsStillActive()) { + setPendingAction(null); + } + } + }, + [ + composerState, + getIsStillActive, + navigateAfterCreation, + serverId, + setAgents, + ensureWorkspace, + toast, + withConnectedClient, + ], + ); + + const handleCreateTerminalAgent = useCallback(async () => { + try { + setPendingAction("terminal-agent"); + setErrorMessage(null); + const workspace = await ensureWorkspace(); + const connectedClient = withConnectedClient(); + if (!composerState) { + throw new Error("Workspace setup composer state is required"); + } + + const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory; + const agent = await connectedClient.createAgent({ + provider: composerState.selectedProvider, + cwd: workspaceDirectory, + workspaceId: requireWorkspaceRecordId(workspace.id), + terminal: true, + ...(terminalPrompt.trim() ? { initialPrompt: terminalPrompt.trim() } : {}), + }); + + if (!getIsStillActive()) { + return; + } + + recordUsage(composerState.selectedProvider); + setAgents(serverId, (previous) => { + const next = new Map(previous); + next.set(agent.id, normalizeAgentSnapshot(agent, serverId)); + return next; + }); + navigateAfterCreation(workspace.id, { kind: "agent", agentId: agent.id }); + } catch (error) { + const message = toErrorMessage(error); + setErrorMessage(message); + toast.error(message); + } finally { + if (getIsStillActive()) { + setPendingAction(null); + } + } + }, [ + composerState, + getIsStillActive, + navigateAfterCreation, + recordUsage, + serverId, + setAgents, + ensureWorkspace, + terminalPrompt, + toast, + withConnectedClient, + ]); + + const handleCreateTerminal = useCallback(async () => { + try { + setPendingAction("terminal"); + setErrorMessage(null); + const workspace = await ensureWorkspace(); + const connectedClient = withConnectedClient(); + const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory; + + const payload = await connectedClient.createTerminal(workspaceDirectory); + if (payload.error || !payload.terminal) { + throw new Error(payload.error ?? "Failed to open terminal"); + } + + if (!getIsStillActive()) { + return; + } + + navigateAfterCreation(workspace.id, { kind: "terminal", terminalId: payload.terminal.id }); + } catch (error) { + const message = toErrorMessage(error); + setErrorMessage(message); + toast.error(message); + } finally { + if (getIsStillActive()) { + setPendingAction(null); + } + } + }, [ensureWorkspace, getIsStillActive, navigateAfterCreation, toast, withConnectedClient]); + + const workspaceTitle = + workspace?.name || + workspace?.projectDisplayName || + displayName || + sourceDirectory.split(/[\\/]/).filter(Boolean).pop() || + sourceDirectory; + const workspacePath = workspace?.workspaceDirectory || "Workspace will be created before launch."; + + if (!pendingWorkspaceSetup || !sourceDirectory) { + return null; + } + + return ( + + + {workspaceTitle} + {workspacePath} + + + {step === "choose" ? ( + + What do you want to open? + + { + setErrorMessage(null); + setStep("chat"); + }} + /> + { + setErrorMessage(null); + setStep("terminal-agent"); + }} + /> + { + void handleCreateTerminal(); + }} + /> + + + ) : null} + + {step === "chat" ? ( + + { + setErrorMessage(null); + setStep("choose"); + }} + /> + + Start with a prompt and optional images. The workspace is created first, then the agent launches, then navigation happens. + + + + + + ) : null} + + {step === "terminal-agent" ? ( + + { + setErrorMessage(null); + setStep("choose"); + }} + /> + + Choose a provider and optionally send an initial prompt. The workspace is created before the terminal agent launches. + + + + {sortedProviders.map((provider) => ( + composerState?.setProviderFromUser(provider.id)} + /> + ))} + + + + Initial prompt + + + + + + + + + ) : null} + + {errorMessage ? {errorMessage} : null} + + ); +} + +function StepHeader({ title, onBack }: { title: string; onBack: () => void }) { + const { theme } = useUnistyles(); + + return ( + + + + + {title} + + ); +} + +function ChoiceCard({ + title, + description, + Icon, + disabled, + pending = false, + onPress, +}: { + title: string; + description: string; + Icon: ComponentType<{ size: number; color: string }>; + disabled: boolean; + pending?: boolean; + onPress: () => void; +}) { + const { theme } = useUnistyles(); + + return ( + [ + styles.choiceCard, + (hovered || pressed) && !disabled ? styles.choiceCardHovered : null, + disabled ? styles.cardDisabled : null, + ]} + > + + {pending ? ( + + ) : ( + + )} + + + {title} + {description} + + + ); +} + +function ProviderOption({ + provider, + selected, + disabled, + onPress, +}: { + provider: { id: AgentProvider; label: string; description: string }; + selected: boolean; + disabled: boolean; + onPress: () => void; +}) { + const { theme } = useUnistyles(); + const Icon = getProviderIcon(provider.id); + + return ( + [ + styles.providerCard, + selected ? styles.providerCardSelected : null, + (hovered || pressed) && !disabled ? styles.choiceCardHovered : null, + disabled ? styles.cardDisabled : null, + ]} + > + + + + + {provider.label} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + header: { + gap: theme.spacing[1], + }, + workspaceTitle: { + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.semibold, + color: theme.colors.foreground, + }, + workspacePath: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + section: { + gap: theme.spacing[3], + }, + sectionTitle: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foregroundMuted, + }, + helper: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + lineHeight: 20, + }, + choiceGrid: { + gap: theme.spacing[2], + }, + choiceCard: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.surface1, + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[3], + }, + choiceCardHovered: { + backgroundColor: theme.colors.surface2, + }, + cardDisabled: { + opacity: theme.opacity[50], + }, + choiceIconWrap: { + width: 32, + height: 32, + borderRadius: theme.borderRadius.md, + alignItems: "center", + justifyContent: "center", + backgroundColor: theme.colors.surface2, + }, + choiceBody: { + flex: 1, + gap: 2, + }, + choiceTitle: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foreground, + }, + choiceDescription: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + composerCard: { + minHeight: 180, + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.surface0, + overflow: "hidden", + }, + stepHeader: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + backButton: { + width: 28, + height: 28, + borderRadius: theme.borderRadius.md, + alignItems: "center", + justifyContent: "center", + backgroundColor: theme.colors.surface2, + }, + providerGrid: { + flexDirection: "row", + flexWrap: "wrap", + gap: theme.spacing[2], + }, + providerCard: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.surface1, + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + }, + providerCardSelected: { + borderColor: theme.colors.accent, + backgroundColor: theme.colors.surface2, + }, + providerIconWrap: { + width: 28, + height: 28, + borderRadius: theme.borderRadius.md, + alignItems: "center", + justifyContent: "center", + backgroundColor: theme.colors.surface2, + }, + providerBody: { + flex: 1, + }, + providerTitle: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foreground, + }, + field: { + gap: theme.spacing[2], + }, + fieldLabel: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foreground, + }, + input: { + minHeight: 80, + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.surface1, + color: theme.colors.foreground, + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[3], + textAlignVertical: "top", + fontSize: theme.fontSize.sm, + }, + actions: { + flexDirection: "row", + gap: theme.spacing[2], + }, + actionButton: { + flex: 1, + }, + errorText: { + fontSize: theme.fontSize.sm, + color: theme.colors.destructive, + lineHeight: 20, + }, +})); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 96f2993fc..887d597b2 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -521,9 +521,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider void client .fetchAgentTimeline(agentId, { direction: "after", - cursor: { epoch: cursor.epoch, seq: cursor.endSeq }, + cursor: { seq: cursor.endSeq }, limit: 0, - projection: "canonical", }) .catch((error) => { console.warn("[Session] failed to fetch catch-up timeline on resume", agentId, error); @@ -749,13 +748,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider ); const requestCanonicalCatchUp = useCallback( - (agentId: string, cursor: { epoch: string; endSeq: number }) => { + (agentId: string, cursor: { endSeq: number }) => { void client .fetchAgentTimeline(agentId, { direction: "after", - cursor: { epoch: cursor.epoch, seq: cursor.endSeq }, + cursor: { seq: cursor.endSeq }, limit: 0, - projection: "canonical", }) .catch((error) => { console.warn("[Session] failed to fetch canonical catch-up timeline", agentId, error); @@ -858,7 +856,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider } if ( current && - current.epoch === result.cursor.epoch && current.startSeq === result.cursor.startSeq && current.endSeq === result.cursor.endSeq ) { @@ -963,7 +960,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const unsubAgentStream = client.on("agent_stream", (message) => { if (message.type !== "agent_stream") return; - const { agentId, event, timestamp, seq, epoch } = message.payload; + const { agentId, event, timestamp, seq } = message.payload; const parsedTimestamp = new Date(timestamp); const streamEvent = event as AgentStreamEventPayload; if ( @@ -1005,7 +1002,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const result = processAgentStreamEvent({ event: streamEvent, seq, - epoch, currentTail, currentHead, currentCursor, @@ -1029,8 +1025,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider if ( current && typeof seq === "number" && - typeof epoch === "string" && - current.epoch === epoch && seq >= current.startSeq && seq <= current.endSeq ) { @@ -1039,7 +1033,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider } if ( current && - current.epoch === nextCursor.epoch && current.startSeq === nextCursor.startSeq && current.endSeq === nextCursor.endSeq ) { @@ -1090,7 +1083,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const unsubWorkspaceUpdate = client.on("workspace_update", (message) => { if (message.type !== "workspace_update") return; if (message.payload.kind === "remove") { - removeWorkspace(serverId, message.payload.id); + removeWorkspace(serverId, String(message.payload.id)); return; } mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]); diff --git a/packages/app/src/contexts/session-status-tracking.test.ts b/packages/app/src/contexts/session-status-tracking.test.ts index d5e98ed3a..bce83d25f 100644 --- a/packages/app/src/contexts/session-status-tracking.test.ts +++ b/packages/app/src/contexts/session-status-tracking.test.ts @@ -7,6 +7,7 @@ function createAgent(status: Agent["status"]): Agent { serverId: "server-1", id: "agent-1", provider: "codex", + terminal: false, status, createdAt: new Date(0), updatedAt: new Date(0), @@ -19,6 +20,7 @@ function createAgent(status: Agent["status"]): Agent { supportsMcpServers: true, supportsReasoningStream: true, supportsToolInvocations: true, + supportsTerminalMode: false, }, currentModeId: null, availableModes: [], diff --git a/packages/app/src/contexts/session-stream-reducers.test.ts b/packages/app/src/contexts/session-stream-reducers.test.ts index 580189da3..138baf8a8 100644 --- a/packages/app/src/contexts/session-stream-reducers.test.ts +++ b/packages/app/src/contexts/session-stream-reducers.test.ts @@ -9,13 +9,9 @@ import { type TimelineCursor, } from "./session-stream-reducers"; -// --------------------------------------------------------------------------- -// Test helpers -// --------------------------------------------------------------------------- - function makeTimelineEntry(seq: number, text: string, type: string = "assistant_message") { return { - seqStart: seq, + seq, provider: "claude", item: { type, text }, timestamp: new Date(1000 + seq).toISOString(), @@ -33,22 +29,30 @@ function makeTimelineEvent( } as AgentStreamEventPayload; } -function makeUserTimelineEvent(text: string): AgentStreamEventPayload { +function makeToolCallEvent(status: "running" | "completed"): AgentStreamEventPayload { return { type: "timeline", provider: "claude", - item: { type: "user_message", text }, - } as AgentStreamEventPayload; + item: { + type: "tool_call", + callId: "call-1", + name: "shell", + status, + detail: { + type: "shell", + command: "pwd", + }, + error: null, + }, + }; } const baseTimelineInput: ProcessTimelineResponseInput = { payload: { agentId: "agent-1", direction: "after", - reset: false, - epoch: "epoch-1", - startCursor: null, - endCursor: null, + startSeq: null, + endSeq: null, entries: [], error: null, }, @@ -63,7 +67,6 @@ const baseTimelineInput: ProcessTimelineResponseInput = { const baseStreamInput: ProcessAgentStreamEventInput = { event: makeTimelineEvent("hello"), seq: undefined, - epoch: undefined, currentTail: [], currentHead: [], currentCursor: undefined, @@ -71,10 +74,6 @@ const baseStreamInput: ProcessAgentStreamEventInput = { timestamp: new Date(2000), }; -// --------------------------------------------------------------------------- -// processTimelineResponse -// --------------------------------------------------------------------------- - describe("processTimelineResponse", () => { it("returns error path when payload.error is set", () => { const result = processTimelineResponse({ @@ -93,35 +92,10 @@ describe("processTimelineResponse", () => { expect(result.tail).toBe(baseTimelineInput.currentTail); expect(result.head).toBe(baseTimelineInput.currentHead); expect(result.cursorChanged).toBe(false); - expect(result.sideEffects).toEqual([]); }); - it("returns error with no init resolution when no deferred exists", () => { - const result = processTimelineResponse({ - ...baseTimelineInput, - isInitializing: true, - hasActiveInitDeferred: false, - payload: { - ...baseTimelineInput.payload, - error: "timeout", - }, - }); - - expect(result.error).toBe("timeout"); - expect(result.initResolution).toBe(null); - expect(result.clearInitializing).toBe(true); - }); - - it("replaces tail and clears head when reset=true", () => { - const existingTail: StreamItem[] = [ - { - kind: "user_message", - id: "old", - text: "old message", - timestamp: new Date(500), - }, - ]; - const existingHead: StreamItem[] = [ + it("replaces tail during bootstrap tail init and schedules committed catch-up", () => { + const provisionalHead: StreamItem[] = [ { kind: "assistant_message", id: "head-1", @@ -132,518 +106,280 @@ describe("processTimelineResponse", () => { const result = processTimelineResponse({ ...baseTimelineInput, - currentTail: existingTail, - currentHead: existingHead, - payload: { - ...baseTimelineInput.payload, - reset: true, - startCursor: { seq: 1 }, - endCursor: { seq: 3 }, - entries: [ - makeTimelineEntry(1, "first"), - makeTimelineEntry(2, "second"), - makeTimelineEntry(3, "third"), - ], - }, - }); - - expect(result.tail).not.toBe(existingTail); - expect(result.tail.length).toBeGreaterThan(0); - expect(result.head).toEqual([]); - expect(result.cursorChanged).toBe(true); - expect(result.cursor).toEqual({ - epoch: "epoch-1", - startSeq: 1, - endSeq: 3, - }); - expect(result.error).toBe(null); - expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true); - }); - - it("sets cursor to null when reset=true but no cursors in payload", () => { - const result = processTimelineResponse({ - ...baseTimelineInput, - currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 5 }, - payload: { - ...baseTimelineInput.payload, - reset: true, - entries: [], - }, - }); - - expect(result.cursor).toBe(null); - expect(result.cursorChanged).toBe(true); - }); - - it("performs bootstrap tail init with catch-up side effect", () => { - const result = processTimelineResponse({ - ...baseTimelineInput, + currentHead: provisionalHead, isInitializing: true, hasActiveInitDeferred: true, initRequestDirection: "tail", payload: { ...baseTimelineInput.payload, direction: "tail", - epoch: "epoch-1", - startCursor: { seq: 1 }, - endCursor: { seq: 5 }, + startSeq: 1, + endSeq: 5, entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(5, "last")], }, }); - // Bootstrap tail replaces expect(result.tail.length).toBeGreaterThan(0); expect(result.head).toEqual([]); expect(result.cursorChanged).toBe(true); expect(result.cursor).toEqual({ - epoch: "epoch-1", startSeq: 1, endSeq: 5, }); - // Should have catch-up side effect - const catchUp = result.sideEffects.find((e) => e.type === "catch_up"); - expect(catchUp).toBeDefined(); - expect(catchUp!.type === "catch_up" && catchUp!.cursor).toEqual({ - epoch: "epoch-1", - endSeq: 5, + const catchUp = result.sideEffects.find((effect) => effect.type === "catch_up"); + expect(catchUp).toEqual({ + type: "catch_up", + cursor: { endSeq: 5 }, }); }); - it("appends incrementally for contiguous seqs", () => { - const existingCursor: TimelineCursor = { - epoch: "epoch-1", - startSeq: 1, - endSeq: 3, - }; - - const result = processTimelineResponse({ - ...baseTimelineInput, - currentCursor: existingCursor, - payload: { - ...baseTimelineInput.payload, - epoch: "epoch-1", - entries: [makeTimelineEntry(4, "next-1"), makeTimelineEntry(5, "next-2")], + it("prepends older committed history for before pagination", () => { + const currentTail: StreamItem[] = [ + { + kind: "assistant_message", + id: "tail-3", + text: "newer", + timestamp: new Date(3000), }, - }); - - expect(result.tail.length).toBeGreaterThan(0); - expect(result.cursorChanged).toBe(true); - expect(result.cursor).toEqual({ - epoch: "epoch-1", - startSeq: 1, - endSeq: 5, - }); - expect(result.error).toBe(null); - }); - - it("detects gap and emits catch-up side effect", () => { - const existingCursor: TimelineCursor = { - epoch: "epoch-1", - startSeq: 1, - endSeq: 3, - }; + ]; + const currentCursor: TimelineCursor = { startSeq: 3, endSeq: 4 }; const result = processTimelineResponse({ ...baseTimelineInput, - currentCursor: existingCursor, - payload: { - ...baseTimelineInput.payload, - epoch: "epoch-1", - entries: [makeTimelineEntry(10, "far ahead")], - }, - }); - - // Gap should trigger catch-up - const catchUp = result.sideEffects.find((e) => e.type === "catch_up"); - expect(catchUp).toBeDefined(); - expect(catchUp!.type === "catch_up" && catchUp!.cursor).toEqual({ - epoch: "epoch-1", - endSeq: 3, - }); - }); - - it("drops stale entries silently", () => { - const existingCursor: TimelineCursor = { - epoch: "epoch-1", - startSeq: 1, - endSeq: 8, - }; - - const result = processTimelineResponse({ - ...baseTimelineInput, - currentCursor: existingCursor, - payload: { - ...baseTimelineInput.payload, - epoch: "epoch-1", - entries: [makeTimelineEntry(5, "old"), makeTimelineEntry(7, "also old")], - }, - }); - - // No new items appended (all dropped as stale) - expect(result.tail).toBe(baseTimelineInput.currentTail); - expect(result.cursorChanged).toBe(false); - }); - - it("drops entries with epoch mismatch", () => { - const existingCursor: TimelineCursor = { - epoch: "epoch-1", - startSeq: 1, - endSeq: 5, - }; - - const result = processTimelineResponse({ - ...baseTimelineInput, - currentCursor: existingCursor, - payload: { - ...baseTimelineInput.payload, - epoch: "epoch-2", - entries: [makeTimelineEntry(6, "different epoch")], - }, - }); - - expect(result.tail).toBe(baseTimelineInput.currentTail); - expect(result.cursorChanged).toBe(false); - }); - - it("resolves init when deferred matches direction", () => { - const result = processTimelineResponse({ - ...baseTimelineInput, - isInitializing: true, - hasActiveInitDeferred: true, - initRequestDirection: "after", - payload: { - ...baseTimelineInput.payload, - direction: "after", - entries: [], - }, - }); - - expect(result.initResolution).toBe("resolve"); - expect(result.clearInitializing).toBe(true); - }); - - it("does not resolve init when directions differ (before vs after)", () => { - const result = processTimelineResponse({ - ...baseTimelineInput, - isInitializing: true, - hasActiveInitDeferred: true, - initRequestDirection: "after", + currentTail, + currentCursor, payload: { ...baseTimelineInput.payload, direction: "before", - entries: [], + startSeq: 1, + endSeq: 2, + entries: [ + makeTimelineEntry(1, "hello", "user_message"), + makeTimelineEntry(2, "older"), + ], }, }); - // "before" direction doesn't match "after" initRequestDirection, - // and "before" is not a bootstrap tail path, so init should NOT resolve - expect(result.initResolution).toBe(null); - expect(result.clearInitializing).toBe(false); + expect(result.cursorChanged).toBe(true); + expect(result.cursor).toEqual({ + startSeq: 1, + endSeq: 4, + }); + expect(result.tail).toHaveLength(3); + expect(result.tail[0]?.kind).toBe("user_message"); + expect(result.tail[1]?.kind).toBe("assistant_message"); + expect(result.tail[2]).toBe(currentTail[0]); }); - it("clears initializing even without deferred", () => { + it("replaces stale provisional assistant UI when fetch-after returns committed row 121", () => { + const currentHead: StreamItem[] = [ + { + kind: "assistant_message", + id: "head-assistant", + text: "partial", + timestamp: new Date(120000), + }, + ]; + const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 }; + const result = processTimelineResponse({ ...baseTimelineInput, - isInitializing: true, - hasActiveInitDeferred: false, + currentHead, + currentCursor, payload: { ...baseTimelineInput.payload, direction: "after", - entries: [], - }, - }); - - expect(result.clearInitializing).toBe(true); - expect(result.initResolution).toBe(null); - }); - - it("always includes flush_pending_updates side effect on success", () => { - const result = processTimelineResponse({ - ...baseTimelineInput, - payload: { - ...baseTimelineInput.payload, - entries: [], - }, - }); - - expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true); - }); - - it("initializes cursor when no existing cursor on first entries", () => { - const result = processTimelineResponse({ - ...baseTimelineInput, - currentCursor: undefined, - payload: { - ...baseTimelineInput.payload, - epoch: "epoch-1", - entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(2, "second")], + startSeq: 121, + endSeq: 121, + entries: [makeTimelineEntry(121, "finalized reply")], }, }); + expect(result.head).toEqual([]); expect(result.cursorChanged).toBe(true); expect(result.cursor).toEqual({ - epoch: "epoch-1", startSeq: 1, - endSeq: 2, + endSeq: 121, + }); + expect(result.tail[result.tail.length - 1]).toMatchObject({ + kind: "assistant_message", + text: "finalized reply", + }); + }); + + it("keeps provisional head when reconnect catch-up has no new committed rows yet", () => { + const currentHead: StreamItem[] = [ + { + kind: "assistant_message", + id: "head-assistant", + text: "still streaming", + timestamp: new Date(120000), + }, + ]; + const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 }; + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentHead, + currentCursor, + payload: { + ...baseTimelineInput.payload, + direction: "after", + startSeq: null, + endSeq: null, + entries: [], + }, + }); + + expect(result.head).toBe(currentHead); + expect(result.cursorChanged).toBe(false); + expect(result.tail).toBe(baseTimelineInput.currentTail); + }); + + it("requests catch-up when committed rows arrive with a forward gap", () => { + const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 }; + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentCursor, + payload: { + ...baseTimelineInput.payload, + direction: "after", + startSeq: 125, + endSeq: 125, + entries: [makeTimelineEntry(125, "far ahead")], + }, + }); + + expect(result.cursorChanged).toBe(false); + expect(result.tail).toBe(baseTimelineInput.currentTail); + expect(result.sideEffects).toContainEqual({ + type: "catch_up", + cursor: { endSeq: 120 }, }); }); }); -// --------------------------------------------------------------------------- -// processAgentStreamEvent -// --------------------------------------------------------------------------- - describe("processAgentStreamEvent", () => { - it("passes through non-timeline events without cursor changes", () => { - const turnEvent: AgentStreamEventPayload = { - type: "turn_completed", - provider: "claude", - }; - + it("treats seq-less timeline events as provisional head updates", () => { const result = processAgentStreamEvent({ ...baseStreamInput, - event: turnEvent, + event: makeTimelineEvent("partial"), seq: undefined, - epoch: undefined, }); + expect(result.changedHead).toBe(true); + expect(result.changedTail).toBe(false); + expect(result.head).toHaveLength(1); + expect(result.head[0]).toMatchObject({ + kind: "assistant_message", + text: "partial", + }); expect(result.cursorChanged).toBe(false); - expect(result.cursor).toBe(null); - expect(result.sideEffects).toEqual([]); }); - it("accepts timeline event with cursor advance", () => { - const existingCursor: TimelineCursor = { - epoch: "epoch-1", - startSeq: 1, - endSeq: 4, - }; + it("appends committed live rows to tail and clears superseded provisional assistant state", () => { + const currentHead: StreamItem[] = [ + { + kind: "assistant_message", + id: "head-assistant", + text: "partial", + timestamp: new Date(1000), + }, + ]; + const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 }; const result = processAgentStreamEvent({ ...baseStreamInput, - event: makeTimelineEvent("new chunk"), - seq: 5, - epoch: "epoch-1", - currentCursor: existingCursor, + event: makeTimelineEvent("finalized reply"), + seq: 121, + currentHead, + currentCursor, }); + expect(result.changedTail).toBe(true); + expect(result.changedHead).toBe(true); + expect(result.head).toEqual([]); expect(result.cursorChanged).toBe(true); expect(result.cursor).toEqual({ - epoch: "epoch-1", startSeq: 1, - endSeq: 5, + endSeq: 121, + }); + expect(result.tail[result.tail.length - 1]).toMatchObject({ + kind: "assistant_message", + text: "finalized reply", }); - expect(result.sideEffects).toEqual([]); }); - it("detects gap and emits catch-up side effect", () => { - const existingCursor: TimelineCursor = { - epoch: "epoch-1", - startSeq: 1, - endSeq: 4, - }; + it("replaces provisional tool progress when the committed tool row arrives", () => { + const provisional = processAgentStreamEvent({ + ...baseStreamInput, + event: makeToolCallEvent("running"), + seq: undefined, + }); + const committed = processAgentStreamEvent({ + ...baseStreamInput, + event: makeToolCallEvent("completed"), + seq: 8, + currentHead: provisional.head, + currentTail: provisional.tail, + currentCursor: { startSeq: 1, endSeq: 7 }, + }); + + expect(committed.head).toEqual([]); + expect(committed.tail).toHaveLength(1); + expect(committed.tail[0]).toMatchObject({ + kind: "tool_call", + payload: { + source: "agent", + data: { + callId: "call-1", + status: "completed", + }, + }, + }); + }); + + it("requests catch-up when a committed live row skips ahead", () => { const result = processAgentStreamEvent({ ...baseStreamInput, event: makeTimelineEvent("far ahead"), - seq: 10, - epoch: "epoch-1", - currentCursor: existingCursor, + seq: 125, + currentCursor: { startSeq: 1, endSeq: 120 }, }); - expect(result.cursorChanged).toBe(false); expect(result.changedTail).toBe(false); expect(result.changedHead).toBe(false); - - const catchUp = result.sideEffects.find((e) => e.type === "catch_up"); - expect(catchUp).toBeDefined(); - expect(catchUp!.cursor).toEqual({ - epoch: "epoch-1", - endSeq: 4, - }); - }); - - it("drops stale timeline event", () => { - const existingCursor: TimelineCursor = { - epoch: "epoch-1", - startSeq: 1, - endSeq: 8, - }; - - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: makeTimelineEvent("old"), - seq: 5, - epoch: "epoch-1", - currentCursor: existingCursor, - }); - expect(result.cursorChanged).toBe(false); + expect(result.sideEffects).toContainEqual({ + type: "catch_up", + cursor: { endSeq: 120 }, + }); + }); + + it("clears provisional head on terminal turn events without committing it to tail", () => { + const result = processAgentStreamEvent({ + ...baseStreamInput, + event: { + type: "turn_completed", + provider: "claude", + }, + currentHead: [ + { + kind: "thought", + id: "reasoning-1", + text: "thinking", + timestamp: new Date(1000), + status: "loading", + }, + ], + }); + + expect(result.changedHead).toBe(true); expect(result.changedTail).toBe(false); - expect(result.changedHead).toBe(false); - expect(result.sideEffects).toEqual([]); - }); - - it("drops timeline event with epoch mismatch", () => { - const existingCursor: TimelineCursor = { - epoch: "epoch-1", - startSeq: 1, - endSeq: 5, - }; - - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: makeTimelineEvent("wrong epoch"), - seq: 6, - epoch: "epoch-2", - currentCursor: existingCursor, - }); - - expect(result.cursorChanged).toBe(false); - expect(result.changedTail).toBe(false); - expect(result.changedHead).toBe(false); - expect(result.sideEffects).toEqual([]); - }); - - it("initializes cursor when none exists", () => { - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: makeTimelineEvent("first"), - seq: 1, - epoch: "epoch-1", - currentCursor: undefined, - }); - - expect(result.cursorChanged).toBe(true); - expect(result.cursor).toEqual({ - epoch: "epoch-1", - startSeq: 1, - endSeq: 1, - }); - }); - - it("derives optimistic idle status on turn_completed for running agent", () => { - const turnCompletedEvent: AgentStreamEventPayload = { - type: "turn_completed", - provider: "claude", - }; - - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: turnCompletedEvent, - currentAgent: { - status: "running", - updatedAt: new Date(1000), - lastActivityAt: new Date(1000), - }, - timestamp: new Date(2000), - }); - - expect(result.agentChanged).toBe(true); - expect(result.agent).not.toBe(null); - expect(result.agent!.status).toBe("idle"); - expect(result.agent!.updatedAt.getTime()).toBe(2000); - expect(result.agent!.lastActivityAt.getTime()).toBe(2000); - }); - - it("derives optimistic error status on turn_failed for running agent", () => { - const turnFailedEvent: AgentStreamEventPayload = { - type: "turn_failed", - provider: "claude", - error: "something broke", - }; - - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: turnFailedEvent, - currentAgent: { - status: "running", - updatedAt: new Date(1000), - lastActivityAt: new Date(1000), - }, - timestamp: new Date(2000), - }); - - expect(result.agentChanged).toBe(true); - expect(result.agent!.status).toBe("error"); - }); - - it("does not change agent when status is not running", () => { - const turnCompletedEvent: AgentStreamEventPayload = { - type: "turn_completed", - provider: "claude", - }; - - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: turnCompletedEvent, - currentAgent: { - status: "idle", - updatedAt: new Date(1000), - lastActivityAt: new Date(1000), - }, - timestamp: new Date(2000), - }); - - expect(result.agentChanged).toBe(false); - expect(result.agent).toBe(null); - }); - - it("does not change agent when no agent is provided", () => { - const turnCompletedEvent: AgentStreamEventPayload = { - type: "turn_completed", - provider: "claude", - }; - - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: turnCompletedEvent, - currentAgent: null, - timestamp: new Date(2000), - }); - - expect(result.agentChanged).toBe(false); - expect(result.agent).toBe(null); - }); - - it("preserves updatedAt when agent timestamp is newer than event", () => { - const turnCompletedEvent: AgentStreamEventPayload = { - type: "turn_completed", - provider: "claude", - }; - - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: turnCompletedEvent, - currentAgent: { - status: "running", - updatedAt: new Date(5000), - lastActivityAt: new Date(5000), - }, - timestamp: new Date(2000), - }); - - expect(result.agentChanged).toBe(true); - expect(result.agent!.updatedAt.getTime()).toBe(5000); - expect(result.agent!.lastActivityAt.getTime()).toBe(5000); - }); - - it("does not produce agent patch for non-terminal events", () => { - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: makeTimelineEvent("just text"), - currentAgent: { - status: "running", - updatedAt: new Date(1000), - lastActivityAt: new Date(1000), - }, - seq: 1, - epoch: "epoch-1", - timestamp: new Date(2000), - }); - - expect(result.agentChanged).toBe(false); - expect(result.agent).toBe(null); + expect(result.head).toEqual([]); + expect(result.tail).toEqual([]); }); }); diff --git a/packages/app/src/contexts/session-stream-reducers.ts b/packages/app/src/contexts/session-stream-reducers.ts index 6623fba94..5600d57ac 100644 --- a/packages/app/src/contexts/session-stream-reducers.ts +++ b/packages/app/src/contexts/session-stream-reducers.ts @@ -1,7 +1,7 @@ import type { AgentStreamEventPayload } from "@server/shared/messages"; import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle"; import type { StreamItem } from "@/types/stream"; -import { applyStreamEvent, hydrateStreamState, reduceStreamUpdate } from "@/types/stream"; +import { hydrateStreamState, reduceStreamUpdate } from "@/types/stream"; import { classifySessionTimelineSeq, type SessionTimelineSeqDecision, @@ -12,38 +12,25 @@ import { } from "@/contexts/session-timeline-bootstrap-policy"; import { deriveOptimisticLifecycleStatus } from "@/contexts/session-stream-lifecycle"; -// --------------------------------------------------------------------------- -// Shared cursor type -// --------------------------------------------------------------------------- - export type TimelineCursor = { - epoch: string; startSeq: number; endSeq: number; }; -// --------------------------------------------------------------------------- -// Side-effect discriminated unions -// --------------------------------------------------------------------------- - export type TimelineReducerSideEffect = - | { type: "catch_up"; cursor: { epoch: string; endSeq: number } } + | { type: "catch_up"; cursor: { endSeq: number } } | { type: "flush_pending_updates" }; export type AgentStreamReducerSideEffect = { type: "catch_up"; - cursor: { epoch: string; endSeq: number }; + cursor: { endSeq: number }; }; -// --------------------------------------------------------------------------- -// processTimelineResponse -// --------------------------------------------------------------------------- - type TimelineDirection = "tail" | "before" | "after"; type InitRequestDirection = "tail" | "after"; type TimelineResponseEntry = { - seqStart: number; + seq: number; provider: string; item: Record; timestamp: string; @@ -53,10 +40,8 @@ export interface ProcessTimelineResponseInput { payload: { agentId: string; direction: TimelineDirection; - reset: boolean; - epoch: string; - startCursor: { seq: number } | null; - endCursor: { seq: number } | null; + startSeq: number | null; + endSeq: number | null; entries: TimelineResponseEntry[]; error: string | null; }; @@ -79,205 +64,9 @@ export interface ProcessTimelineResponseOutput { sideEffects: TimelineReducerSideEffect[]; } -export function processTimelineResponse( - input: ProcessTimelineResponseInput, -): ProcessTimelineResponseOutput { - const { - payload, - currentTail, - currentHead, - currentCursor, - isInitializing, - hasActiveInitDeferred, - initRequestDirection, - } = input; - - // ------------------------------------------------------------------ - // Error path: reject init and leave stream state unchanged - // ------------------------------------------------------------------ - if (payload.error) { - return { - tail: currentTail, - head: currentHead, - cursor: currentCursor, - cursorChanged: false, - initResolution: hasActiveInitDeferred ? "reject" : null, - clearInitializing: isInitializing, - error: payload.error, - sideEffects: [], - }; - } - - // ------------------------------------------------------------------ - // Convert entries to timeline units - // ------------------------------------------------------------------ - const timelineUnits = payload.entries.map((entry) => ({ - seq: entry.seqStart, - event: { - type: "timeline", - provider: entry.provider, - item: entry.item, - } as AgentStreamEventPayload, - timestamp: new Date(entry.timestamp), - })); - - const toHydratedEvents = ( - units: typeof timelineUnits, - ): Array<{ event: AgentStreamEventPayload; timestamp: Date }> => - units.map(({ event, timestamp }) => ({ event, timestamp })); - - // ------------------------------------------------------------------ - // Derive bootstrap policy (replace vs incremental) - // ------------------------------------------------------------------ - const bootstrapPolicy = deriveBootstrapTailTimelinePolicy({ - direction: payload.direction, - reset: payload.reset, - epoch: payload.epoch, - endCursor: payload.endCursor, - isInitializing, - hasActiveInitDeferred, - }); - const replace = bootstrapPolicy.replace; - - let nextTail = currentTail; - let nextHead = currentHead; - let nextCursor: TimelineCursor | null | undefined = currentCursor; - let cursorChanged = false; - const sideEffects: TimelineReducerSideEffect[] = []; - - if (replace) { - // ---------------------------------------------------------------- - // Replace path: full hydration from scratch - // ---------------------------------------------------------------- - nextTail = hydrateStreamState(toHydratedEvents(timelineUnits), { - source: "canonical", - }); - nextHead = []; - - if (payload.startCursor && payload.endCursor) { - nextCursor = { - epoch: payload.epoch, - startSeq: payload.startCursor.seq, - endSeq: payload.endCursor.seq, - }; - cursorChanged = true; - } else { - nextCursor = null; - cursorChanged = true; - } - - if (bootstrapPolicy.catchUpCursor) { - sideEffects.push({ - type: "catch_up", - cursor: bootstrapPolicy.catchUpCursor, - }); - } - } else if (timelineUnits.length > 0) { - // ---------------------------------------------------------------- - // Incremental append path - // ---------------------------------------------------------------- - const acceptedUnits: typeof timelineUnits = []; - let cursor = currentCursor; - let gapCursor: { epoch: string; endSeq: number } | null = null; - - for (const unit of timelineUnits) { - const decision: SessionTimelineSeqDecision = classifySessionTimelineSeq({ - cursor: cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null, - epoch: payload.epoch, - seq: unit.seq, - }); - - if (decision === "gap") { - gapCursor = cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null; - break; - } - if (decision === "drop_stale" || decision === "drop_epoch") { - continue; - } - - acceptedUnits.push(unit); - if (decision === "init") { - cursor = { - epoch: payload.epoch, - startSeq: unit.seq, - endSeq: unit.seq, - }; - continue; - } - if (!cursor) { - continue; - } - cursor = { - ...cursor, - endSeq: unit.seq, - }; - } - - if (acceptedUnits.length > 0) { - nextTail = acceptedUnits.reduce( - (state, { event, timestamp }) => - reduceStreamUpdate(state, event, timestamp, { - source: "canonical", - }), - currentTail, - ); - } - - if ( - cursor && - (!currentCursor || - currentCursor.epoch !== cursor.epoch || - currentCursor.startSeq !== cursor.startSeq || - currentCursor.endSeq !== cursor.endSeq) - ) { - nextCursor = cursor; - cursorChanged = true; - } - - if (gapCursor) { - sideEffects.push({ type: "catch_up", cursor: gapCursor }); - } - } - - // ------------------------------------------------------------------ - // Flush pending agent updates side effect - // ------------------------------------------------------------------ - sideEffects.push({ type: "flush_pending_updates" }); - - // ------------------------------------------------------------------ - // Init resolution - // ------------------------------------------------------------------ - const shouldResolveDeferredInit = shouldResolveTimelineInit({ - hasActiveInitDeferred, - isInitializing, - initRequestDirection, - responseDirection: payload.direction, - reset: payload.reset, - }); - const clearInitializing = shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred); - - const initResolution: "resolve" | "reject" | null = shouldResolveDeferredInit ? "resolve" : null; - - return { - tail: nextTail, - head: nextHead, - cursor: nextCursor, - cursorChanged, - initResolution, - clearInitializing, - error: null, - sideEffects, - }; -} - -// --------------------------------------------------------------------------- -// processAgentStreamEvent -// --------------------------------------------------------------------------- - export interface ProcessAgentStreamEventInput { event: AgentStreamEventPayload; seq: number | undefined; - epoch: string | undefined; currentTail: StreamItem[]; currentHead: StreamItem[]; currentCursor: TimelineCursor | undefined; @@ -307,75 +96,250 @@ export interface ProcessAgentStreamEventOutput { sideEffects: AgentStreamReducerSideEffect[]; } +function cursorsEqual( + left: TimelineCursor | null | undefined, + right: TimelineCursor | null | undefined, +): boolean { + if (!left || !right) { + return left === right; + } + return left.startSeq === right.startSeq && left.endSeq === right.endSeq; +} + +function removeSupersededProvisionalItems( + head: StreamItem[], + event: AgentStreamEventPayload, +): StreamItem[] { + if (head.length === 0 || event.type !== "timeline") { + return head; + } + + let nextHead = head; + if (event.item.type === "assistant_message") { + nextHead = head.filter((item) => item.kind !== "assistant_message"); + } else if (event.item.type === "tool_call") { + const committedToolCall = event.item; + nextHead = head.filter( + (item) => + item.kind !== "tool_call" || + item.payload.source !== "agent" || + item.payload.data.callId !== committedToolCall.callId, + ); + } + + return nextHead.length === head.length ? head : nextHead; +} + +export function processTimelineResponse( + input: ProcessTimelineResponseInput, +): ProcessTimelineResponseOutput { + const { + payload, + currentTail, + currentHead, + currentCursor, + isInitializing, + hasActiveInitDeferred, + initRequestDirection, + } = input; + + if (payload.error) { + return { + tail: currentTail, + head: currentHead, + cursor: currentCursor, + cursorChanged: false, + initResolution: hasActiveInitDeferred ? "reject" : null, + clearInitializing: isInitializing, + error: payload.error, + sideEffects: [], + }; + } + + const timelineUnits = payload.entries.map((entry) => ({ + seq: entry.seq, + event: { + type: "timeline", + provider: entry.provider, + item: entry.item, + } as AgentStreamEventPayload, + timestamp: new Date(entry.timestamp), + })); + + const bootstrapPolicy = deriveBootstrapTailTimelinePolicy({ + direction: payload.direction, + endSeq: payload.endSeq, + isInitializing, + hasActiveInitDeferred, + }); + + let nextTail = currentTail; + let nextHead = currentHead; + let nextCursor: TimelineCursor | null | undefined = currentCursor; + let cursorChanged = false; + const sideEffects: TimelineReducerSideEffect[] = []; + + if (bootstrapPolicy.replace) { + nextTail = hydrateStreamState( + timelineUnits.map(({ event, timestamp }) => ({ event, timestamp })), + { source: "canonical" }, + ); + nextHead = []; + nextCursor = + typeof payload.startSeq === "number" && typeof payload.endSeq === "number" + ? { + startSeq: payload.startSeq, + endSeq: payload.endSeq, + } + : null; + cursorChanged = !cursorsEqual(currentCursor, nextCursor); + + if (bootstrapPolicy.catchUpCursor) { + sideEffects.push({ + type: "catch_up", + cursor: bootstrapPolicy.catchUpCursor, + }); + } + } else if (payload.direction === "before") { + const prepended = hydrateStreamState( + timelineUnits.map(({ event, timestamp }) => ({ event, timestamp })), + { source: "canonical" }, + ); + nextTail = prepended.length > 0 ? [...prepended, ...currentTail] : currentTail; + const derivedCursor = + typeof payload.startSeq === "number" + ? { + startSeq: payload.startSeq, + endSeq: currentCursor?.endSeq ?? payload.endSeq ?? payload.startSeq, + } + : currentCursor; + nextCursor = derivedCursor; + cursorChanged = !cursorsEqual(currentCursor, derivedCursor); + } else if (timelineUnits.length > 0) { + const acceptedUnits: typeof timelineUnits = []; + let cursor = currentCursor; + let gapCursor: { endSeq: number } | null = null; + + for (const unit of timelineUnits) { + const decision: SessionTimelineSeqDecision = classifySessionTimelineSeq({ + cursor: cursor ? { endSeq: cursor.endSeq } : null, + seq: unit.seq, + }); + + if (decision === "gap") { + gapCursor = cursor ? { endSeq: cursor.endSeq } : null; + break; + } + if (decision === "drop_stale") { + continue; + } + + acceptedUnits.push(unit); + cursor = + decision === "init" + ? { startSeq: unit.seq, endSeq: unit.seq } + : { ...(cursor ?? { startSeq: unit.seq, endSeq: unit.seq }), endSeq: unit.seq }; + nextHead = removeSupersededProvisionalItems(nextHead, unit.event); + } + + if (acceptedUnits.length > 0) { + nextTail = acceptedUnits.reduce( + (state, { event, timestamp }) => + reduceStreamUpdate(state, event, timestamp, { + source: "canonical", + }), + currentTail, + ); + } + + if (cursor && !cursorsEqual(currentCursor, cursor)) { + nextCursor = cursor; + cursorChanged = true; + } + + if (gapCursor) { + sideEffects.push({ type: "catch_up", cursor: gapCursor }); + } + } + + sideEffects.push({ type: "flush_pending_updates" }); + + const shouldResolveDeferredInit = shouldResolveTimelineInit({ + hasActiveInitDeferred, + isInitializing, + initRequestDirection, + responseDirection: payload.direction, + }); + const clearInitializing = shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred); + + return { + tail: nextTail, + head: nextHead, + cursor: nextCursor, + cursorChanged, + initResolution: shouldResolveDeferredInit ? "resolve" : null, + clearInitializing, + error: null, + sideEffects, + }; +} + export function processAgentStreamEvent( input: ProcessAgentStreamEventInput, ): ProcessAgentStreamEventOutput { - const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } = - input; + const { event, seq, currentTail, currentHead, currentCursor, currentAgent, timestamp } = input; - let shouldApplyStreamEvent = true; + let nextTail = currentTail; + let nextHead = currentHead; + let changedTail = false; + let changedHead = false; let nextTimelineCursor: TimelineCursor | null = null; let cursorChanged = false; const sideEffects: AgentStreamReducerSideEffect[] = []; - // ------------------------------------------------------------------ - // Timeline sequencing gate - // ------------------------------------------------------------------ - if (event.type === "timeline" && typeof seq === "number" && typeof epoch === "string") { + if (event.type === "timeline" && typeof seq === "number") { const decision = classifySessionTimelineSeq({ - cursor: currentCursor ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } : null, - epoch, + cursor: currentCursor ? { endSeq: currentCursor.endSeq } : null, seq, }); - if (decision === "init") { - nextTimelineCursor = { epoch, startSeq: seq, endSeq: seq }; - cursorChanged = true; - } else if (decision === "accept") { - nextTimelineCursor = { - ...(currentCursor ?? { epoch, startSeq: seq, endSeq: seq }), - epoch, - endSeq: seq, - }; - cursorChanged = true; - } else if (decision === "gap") { - shouldApplyStreamEvent = false; + if (decision === "gap") { if (currentCursor) { sideEffects.push({ type: "catch_up", - cursor: { - epoch: currentCursor.epoch, - endSeq: currentCursor.endSeq, - }, + cursor: { endSeq: currentCursor.endSeq }, }); } - } else { - // drop_stale or drop_epoch - shouldApplyStreamEvent = false; + } else if (decision !== "drop_stale") { + nextTail = reduceStreamUpdate(currentTail, event, timestamp, { + source: "canonical", + }); + changedTail = nextTail !== currentTail; + + nextHead = removeSupersededProvisionalItems(currentHead, event); + changedHead = nextHead !== currentHead; + + nextTimelineCursor = + decision === "init" + ? { startSeq: seq, endSeq: seq } + : { ...(currentCursor ?? { startSeq: seq, endSeq: seq }), endSeq: seq }; + cursorChanged = !cursorsEqual(currentCursor, nextTimelineCursor); } + } else if (event.type === "timeline") { + nextHead = reduceStreamUpdate(currentHead, event, timestamp, { + source: "live", + }); + changedHead = nextHead !== currentHead; + } else if ( + (event.type === "turn_completed" || + event.type === "turn_canceled" || + event.type === "turn_failed") && + currentHead.length > 0 + ) { + nextHead = []; + changedHead = true; } - // ------------------------------------------------------------------ - // Apply stream event to tail/head - // ------------------------------------------------------------------ - const { tail, head, changedTail, changedHead } = shouldApplyStreamEvent - ? applyStreamEvent({ - tail: currentTail, - head: currentHead, - event, - timestamp, - source: "live", - }) - : { - tail: currentTail, - head: currentHead, - changedTail: false, - changedHead: false, - }; - - // ------------------------------------------------------------------ - // Optimistic lifecycle status - // ------------------------------------------------------------------ let agentPatch: AgentPatch | null = null; let agentChanged = false; @@ -402,8 +366,8 @@ export function processAgentStreamEvent( } return { - tail, - head, + tail: nextTail, + head: nextHead, changedTail, changedHead, cursor: nextTimelineCursor, diff --git a/packages/app/src/contexts/session-timeline-bootstrap-policy.test.ts b/packages/app/src/contexts/session-timeline-bootstrap-policy.test.ts index e233b8742..cc8b98ce0 100644 --- a/packages/app/src/contexts/session-timeline-bootstrap-policy.test.ts +++ b/packages/app/src/contexts/session-timeline-bootstrap-policy.test.ts @@ -2,26 +2,42 @@ import { describe, expect, it } from "vitest"; import { classifySessionTimelineSeq } from "./session-timeline-seq-gate"; import { deriveBootstrapTailTimelinePolicy, + deriveInitialTimelineRequest, shouldResolveTimelineInit, } from "./session-timeline-bootstrap-policy"; -describe("deriveBootstrapTailTimelinePolicy", () => { - it("always replaces on explicit reset without catch-up cursor", () => { - const policy = deriveBootstrapTailTimelinePolicy({ - direction: "after", - reset: true, - epoch: "epoch-1", - endCursor: { seq: 200 }, - isInitializing: false, - hasActiveInitDeferred: false, +describe("deriveInitialTimelineRequest", () => { + it("uses tail bootstrap when history has not synced yet", () => { + expect( + deriveInitialTimelineRequest({ + cursor: { seq: 42 }, + hasAuthoritativeHistory: false, + initialTimelineLimit: 200, + }), + ).toEqual({ + direction: "tail", + limit: 200, }); - - expect(policy.replace).toBe(true); - expect(policy.catchUpCursor).toBeNull(); }); + it("uses catch-up after the committed cursor once history is synced", () => { + expect( + deriveInitialTimelineRequest({ + cursor: { seq: 42 }, + hasAuthoritativeHistory: true, + initialTimelineLimit: 200, + }), + ).toEqual({ + direction: "after", + cursor: { seq: 42 }, + limit: 0, + }); + }); +}); + +describe("deriveBootstrapTailTimelinePolicy", () => { it("forces baseline replace and canonical catch-up for init tail race", () => { - const advancedCursor = { epoch: "epoch-1", endSeq: 205 }; + const advancedCursor = { endSeq: 205 }; const tailSeqStart = 101; const tailSeqEnd = 200; @@ -29,7 +45,6 @@ describe("deriveBootstrapTailTimelinePolicy", () => { for (let seq = tailSeqStart; seq <= tailSeqEnd; seq += 1) { const decision = classifySessionTimelineSeq({ cursor: advancedCursor, - epoch: "epoch-1", seq, }); if (decision === "accept" || decision === "init") { @@ -40,16 +55,13 @@ describe("deriveBootstrapTailTimelinePolicy", () => { const policy = deriveBootstrapTailTimelinePolicy({ direction: "tail", - reset: false, - epoch: "epoch-1", - endCursor: { seq: 200 }, + endSeq: 200, isInitializing: true, hasActiveInitDeferred: true, }); expect(policy.replace).toBe(true); expect(policy.catchUpCursor).toEqual({ - epoch: "epoch-1", endSeq: 200, }); }); @@ -57,9 +69,7 @@ describe("deriveBootstrapTailTimelinePolicy", () => { it("does not replace non-bootstrap, non-reset responses", () => { const policy = deriveBootstrapTailTimelinePolicy({ direction: "tail", - reset: false, - epoch: "epoch-1", - endCursor: { seq: 200 }, + endSeq: 200, isInitializing: false, hasActiveInitDeferred: false, }); @@ -77,7 +87,6 @@ describe("shouldResolveTimelineInit", () => { isInitializing: true, initRequestDirection: "tail", responseDirection: "tail", - reset: false, }), ).toBe(true); }); @@ -89,7 +98,6 @@ describe("shouldResolveTimelineInit", () => { isInitializing: true, initRequestDirection: "tail", responseDirection: "after", - reset: false, }), ).toBe(false); }); @@ -101,7 +109,6 @@ describe("shouldResolveTimelineInit", () => { isInitializing: true, initRequestDirection: "after", responseDirection: "after", - reset: false, }), ).toBe(true); }); diff --git a/packages/app/src/contexts/session-timeline-bootstrap-policy.ts b/packages/app/src/contexts/session-timeline-bootstrap-policy.ts index 706ab5eee..c7ab9ded8 100644 --- a/packages/app/src/contexts/session-timeline-bootstrap-policy.ts +++ b/packages/app/src/contexts/session-timeline-bootstrap-policy.ts @@ -6,7 +6,6 @@ type BootstrapTailCursor = { } | null; type InitialTimelineCursor = { - epoch: string; seq: number; } | null; @@ -20,48 +19,37 @@ export function deriveInitialTimelineRequest({ initialTimelineLimit: number; }): { direction: "tail" | "after"; - cursor?: { epoch: string; seq: number }; + cursor?: { seq: number }; limit: number; - projection: "canonical"; } { if (!hasAuthoritativeHistory || !cursor) { return { direction: "tail", limit: initialTimelineLimit, - projection: "canonical", }; } return { direction: "after", - cursor: { epoch: cursor.epoch, seq: cursor.seq }, + cursor: { seq: cursor.seq }, limit: 0, - projection: "canonical", }; } export function deriveBootstrapTailTimelinePolicy({ direction, - reset, - epoch, - endCursor, + endSeq, isInitializing, hasActiveInitDeferred, }: { direction: TimelineDirection; - reset: boolean; - epoch: string; - endCursor: BootstrapTailCursor; + endSeq: number | null; isInitializing: boolean; hasActiveInitDeferred: boolean; }): { replace: boolean; - catchUpCursor: { epoch: string; endSeq: number } | null; + catchUpCursor: { endSeq: number } | null; } { - if (reset) { - return { replace: true, catchUpCursor: null }; - } - const isBootstrapTailInit = direction === "tail" && isInitializing && hasActiveInitDeferred; if (!isBootstrapTailInit) { return { replace: false, catchUpCursor: null }; @@ -69,7 +57,7 @@ export function deriveBootstrapTailTimelinePolicy({ return { replace: true, - catchUpCursor: endCursor ? { epoch, endSeq: endCursor.seq } : null, + catchUpCursor: typeof endSeq === "number" ? { endSeq } : null, }; } @@ -78,19 +66,14 @@ export function shouldResolveTimelineInit({ isInitializing, initRequestDirection, responseDirection, - reset, }: { hasActiveInitDeferred: boolean; isInitializing: boolean; initRequestDirection: InitRequestDirection; responseDirection: TimelineDirection; - reset: boolean; }): boolean { if (!hasActiveInitDeferred || !isInitializing) { return false; } - if (reset) { - return true; - } return responseDirection === initRequestDirection; } diff --git a/packages/app/src/contexts/session-timeline-seq-gate.test.ts b/packages/app/src/contexts/session-timeline-seq-gate.test.ts index d18af910a..3f827dce8 100644 --- a/packages/app/src/contexts/session-timeline-seq-gate.test.ts +++ b/packages/app/src/contexts/session-timeline-seq-gate.test.ts @@ -5,8 +5,7 @@ describe("classifySessionTimelineSeq", () => { it("accepts contiguous forward seq", () => { expect( classifySessionTimelineSeq({ - cursor: { epoch: "epoch-1", endSeq: 4 }, - epoch: "epoch-1", + cursor: { endSeq: 4 }, seq: 5, }), ).toBe("accept"); @@ -15,8 +14,7 @@ describe("classifySessionTimelineSeq", () => { it("drops stale seq older than the current end", () => { expect( classifySessionTimelineSeq({ - cursor: { epoch: "epoch-1", endSeq: 8 }, - epoch: "epoch-1", + cursor: { endSeq: 8 }, seq: 7, }), ).toBe("drop_stale"); @@ -25,28 +23,16 @@ describe("classifySessionTimelineSeq", () => { it("drops duplicate replay seq equal to the current end", () => { expect( classifySessionTimelineSeq({ - cursor: { epoch: "epoch-1", endSeq: 8 }, - epoch: "epoch-1", + cursor: { endSeq: 8 }, seq: 8, }), ).toBe("drop_stale"); }); - it("drops epoch mismatch", () => { - expect( - classifySessionTimelineSeq({ - cursor: { epoch: "epoch-1", endSeq: 4 }, - epoch: "epoch-2", - seq: 5, - }), - ).toBe("drop_epoch"); - }); - it("initializes when cursor is null", () => { expect( classifySessionTimelineSeq({ cursor: null, - epoch: "epoch-1", seq: 1, }), ).toBe("init"); @@ -55,8 +41,7 @@ describe("classifySessionTimelineSeq", () => { it("classifies forward gaps", () => { expect( classifySessionTimelineSeq({ - cursor: { epoch: "epoch-1", endSeq: 4 }, - epoch: "epoch-1", + cursor: { endSeq: 4 }, seq: 9, }), ).toBe("gap"); diff --git a/packages/app/src/contexts/session-timeline-seq-gate.ts b/packages/app/src/contexts/session-timeline-seq-gate.ts index e7e35b567..1fe15597e 100644 --- a/packages/app/src/contexts/session-timeline-seq-gate.ts +++ b/packages/app/src/contexts/session-timeline-seq-gate.ts @@ -1,28 +1,22 @@ export type SessionTimelineSeqCursor = | { - epoch: string; endSeq: number; } | null | undefined; -export type SessionTimelineSeqDecision = "accept" | "drop_stale" | "drop_epoch" | "gap" | "init"; +export type SessionTimelineSeqDecision = "accept" | "drop_stale" | "gap" | "init"; export function classifySessionTimelineSeq({ cursor, - epoch, seq, }: { cursor: SessionTimelineSeqCursor; - epoch: string; seq: number; }): SessionTimelineSeqDecision { if (!cursor) { return "init"; } - if (cursor.epoch !== epoch) { - return "drop_epoch"; - } if (seq <= cursor.endSeq) { return "drop_stale"; } diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index bec3f0392..25a78f49d 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -66,7 +66,7 @@ type UseAgentFormStateOptions = { onlineServerIds?: string[]; }; -type UseAgentFormStateResult = { +export type UseAgentFormStateResult = { selectedServerId: string | null; setSelectedServerId: (value: string | null) => void; setSelectedServerIdFromUser: (value: string | null) => void; diff --git a/packages/app/src/hooks/use-agent-initialization.test.ts b/packages/app/src/hooks/use-agent-initialization.test.ts index dd32a15d6..43da108e3 100644 --- a/packages/app/src/hooks/use-agent-initialization.test.ts +++ b/packages/app/src/hooks/use-agent-initialization.test.ts @@ -2,11 +2,10 @@ import { describe, expect, it } from "vitest"; import { __private__ } from "./use-agent-initialization"; describe("useAgentInitialization timeline request policy", () => { - it("uses canonical tail bootstrap when history has not synced yet", () => { + it("uses committed tail bootstrap when history has not synced yet", () => { expect( __private__.deriveInitialTimelineRequest({ cursor: { - epoch: "epoch-1", seq: 42, }, hasAuthoritativeHistory: false, @@ -15,11 +14,10 @@ describe("useAgentInitialization timeline request policy", () => { ).toEqual({ direction: "tail", limit: 200, - projection: "canonical", }); }); - it("uses canonical tail bootstrap when cursor is missing", () => { + it("uses committed tail bootstrap when cursor is missing", () => { expect( __private__.deriveInitialTimelineRequest({ cursor: null, @@ -29,15 +27,13 @@ describe("useAgentInitialization timeline request policy", () => { ).toEqual({ direction: "tail", limit: 200, - projection: "canonical", }); }); - it("uses canonical catch-up after the current cursor once history is synced", () => { + it("uses committed catch-up after the current cursor once history is synced", () => { expect( __private__.deriveInitialTimelineRequest({ cursor: { - epoch: "epoch-1", seq: 42, }, hasAuthoritativeHistory: true, @@ -45,9 +41,8 @@ describe("useAgentInitialization timeline request policy", () => { }), ).toEqual({ direction: "after", - cursor: { epoch: "epoch-1", seq: 42 }, + cursor: { seq: 42 }, limit: 0, - projection: "canonical", }); }); @@ -61,7 +56,6 @@ describe("useAgentInitialization timeline request policy", () => { ).toEqual({ direction: "tail", limit: 0, - projection: "canonical", }); }); diff --git a/packages/app/src/hooks/use-agent-initialization.ts b/packages/app/src/hooks/use-agent-initialization.ts index 62ffa9a9d..20df64bcd 100644 --- a/packages/app/src/hooks/use-agent-initialization.ts +++ b/packages/app/src/hooks/use-agent-initialization.ts @@ -60,7 +60,7 @@ export function useAgentInitialization({ const hasAuthoritativeHistory = session?.agentAuthoritativeHistoryApplied.get(agentId) === true; const timelineRequest = deriveInitialTimelineRequest({ - cursor: cursor ? { epoch: cursor.epoch, seq: cursor.endSeq } : null, + cursor: cursor ? { seq: cursor.endSeq } : null, hasAuthoritativeHistory, initialTimelineLimit, }); @@ -107,7 +107,6 @@ export function useAgentInitialization({ await client.fetchAgentTimeline(agentId, { direction: "tail", limit: initialTimelineLimit, - projection: "canonical", }); } catch (error) { setAgentInitializing(agentId, false); diff --git a/packages/app/src/hooks/use-agent-input-draft.live.test.tsx b/packages/app/src/hooks/use-agent-input-draft.live.test.tsx new file mode 100644 index 000000000..a0b6d455a --- /dev/null +++ b/packages/app/src/hooks/use-agent-input-draft.live.test.tsx @@ -0,0 +1,270 @@ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { useDraftStore } from "@/stores/draft-store"; +import type { AttachmentMetadata } from "@/attachments/types"; + +const { asyncStorage } = vi.hoisted(() => ({ + asyncStorage: new Map(), +})); + +vi.mock("@react-native-async-storage/async-storage", () => ({ + default: { + getItem: async (key: string) => asyncStorage.get(key) ?? null, + setItem: async (key: string, value: string) => { + asyncStorage.set(key, value); + }, + removeItem: async (key: string) => { + asyncStorage.delete(key); + }, + }, +})); + +vi.mock("@/attachments/service", () => ({ + garbageCollectAttachments: async () => undefined, +})); + +vi.mock("./use-agent-form-state", () => ({ + useAgentFormState: () => ({ + selectedServerId: "host-1", + setSelectedServerId: () => undefined, + setSelectedServerIdFromUser: () => undefined, + selectedProvider: "codex", + setProviderFromUser: () => undefined, + selectedMode: "auto", + setModeFromUser: () => undefined, + selectedModel: "", + setModelFromUser: () => undefined, + selectedThinkingOptionId: "", + setThinkingOptionFromUser: () => undefined, + workingDir: "/repo", + setWorkingDir: () => undefined, + setWorkingDirFromUser: () => undefined, + providerDefinitions: [{ id: "codex", label: "Codex", modes: [{ id: "auto", label: "Auto" }] }], + providerDefinitionMap: new Map(), + agentDefinition: undefined, + modeOptions: [{ id: "auto", label: "Auto" }], + availableModels: [ + { + provider: "codex", + id: "gpt-5.4", + label: "gpt-5.4", + isDefault: true, + defaultThinkingOptionId: "high", + thinkingOptions: [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + ], + }, + ], + allProviderModels: new Map([ + [ + "codex", + [ + { + provider: "codex", + id: "gpt-5.4", + label: "gpt-5.4", + isDefault: true, + defaultThinkingOptionId: "high", + thinkingOptions: [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + ], + }, + ], + ], + ]), + isAllModelsLoading: false, + availableThinkingOptions: [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + ], + isModelLoading: false, + modelError: null, + refreshProviderModels: () => undefined, + setProviderAndModelFromUser: () => undefined, + workingDirIsEmpty: false, + persistFormPreferences: async () => undefined, + }), +})); + +let useAgentInputDraft: typeof import("./use-agent-input-draft").useAgentInputDraft; + +beforeAll(async () => { + const storage = new Map(); + + Object.defineProperty(globalThis, "window", { + value: { + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + removeItem: (key: string) => { + storage.delete(key); + }, + }, + }, + configurable: true, + }); + Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { + value: true, + configurable: true, + }); + + ({ useAgentInputDraft } = await import("./use-agent-input-draft")); +}); + +describe("useAgentInputDraft live contract", () => { + beforeEach(() => { + asyncStorage.clear(); + const dom = new JSDOM("
", { + url: "http://localhost", + }); + + Object.defineProperty(globalThis, "document", { + value: dom.window.document, + configurable: true, + }); + Object.defineProperty(globalThis, "navigator", { + value: dom.window.navigator, + configurable: true, + }); + + useDraftStore.setState({ drafts: {}, createModalDraft: null }); + }); + + it("hydrates persisted text and images and returns draft-mode composer state for a caller-provided key", async () => { + let latest: ReturnType | null = null; + const image: AttachmentMetadata = { + id: "attachment-1", + mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "attachments/1", + createdAt: 1, + fileName: "image.png", + byteSize: 128, + }; + + function getLatest(): ReturnType { + if (!latest) { + throw new Error("Expected hook result"); + } + return latest; + } + + function Probe({ draftKey }: { draftKey: string }) { + latest = useAgentInputDraft({ + draftKey, + composer: { + initialServerId: "host-1", + initialValues: { workingDir: "/repo" }, + isVisible: true, + onlineServerIds: ["host-1"], + lockedWorkingDir: "/repo", + }, + }); + return null; + } + + const container = document.getElementById("root"); + if (!container) { + throw new Error("Missing root container"); + } + + let root: Root | null = createRoot(container); + await act(async () => { + root!.render(); + }); + + expect(getLatest().composerState?.statusControls.selectedProvider).toBe("codex"); + expect(getLatest().composerState?.commandDraftConfig).toEqual({ + provider: "codex", + cwd: "/repo", + modeId: "auto", + model: "gpt-5.4", + thinkingOptionId: "high", + }); + + await act(async () => { + getLatest().setText("hello world"); + getLatest().setImages([image]); + }); + + await act(async () => { + root!.unmount(); + }); + + root = createRoot(container); + await act(async () => { + root.render(); + }); + + expect(getLatest().text).toBe("hello world"); + expect(getLatest().images).toEqual([image]); + }); + + it("clears drafts with sent and abandoned lifecycle tombstones", async () => { + let latest: ReturnType | null = null; + const sentImage: AttachmentMetadata = { + id: "attachment-sent", + mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "attachments/sent", + createdAt: 2, + }; + + function getLatest(): ReturnType { + if (!latest) { + throw new Error("Expected hook result"); + } + return latest; + } + + function Probe() { + latest = useAgentInputDraft({ draftKey: "draft:lifecycle" }); + return null; + } + + const container = document.getElementById("root"); + if (!container) { + throw new Error("Missing root container"); + } + + const root = createRoot(container); + await act(async () => { + root.render(); + }); + + await act(async () => { + getLatest().setText("queued message"); + getLatest().setImages([sentImage]); + }); + + await act(async () => { + getLatest().clear("sent"); + }); + + expect(getLatest().text).toBe(""); + expect(getLatest().images).toEqual([]); + expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({ + lifecycle: "sent", + input: { text: "", images: [] }, + }); + + await act(async () => { + getLatest().setText("draft again"); + }); + + await act(async () => { + getLatest().clear("abandoned"); + }); + + expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({ + lifecycle: "abandoned", + input: { text: "", images: [] }, + }); + }); +}); diff --git a/packages/app/src/hooks/use-agent-input-draft.test.ts b/packages/app/src/hooks/use-agent-input-draft.test.ts new file mode 100644 index 000000000..a7590bf2f --- /dev/null +++ b/packages/app/src/hooks/use-agent-input-draft.test.ts @@ -0,0 +1,149 @@ +import { beforeAll, describe, expect, it } from "vitest"; + +let __private__: typeof import("./use-agent-input-draft").__private__; + +beforeAll(async () => { + const storage = new Map(); + Object.defineProperty(globalThis, "window", { + value: { + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + removeItem: (key: string) => { + storage.delete(key); + }, + }, + }, + configurable: true, + }); + + ({ __private__ } = await import("./use-agent-input-draft")); +}); + +describe("useAgentInputDraft", () => { + describe("__private__.resolveDraftKey", () => { + it("returns an object draft key string unchanged", () => { + expect( + __private__.resolveDraftKey({ + draftKey: "draft:key", + selectedServerId: "host-1", + }), + ).toBe("draft:key"); + }); + + it("resolves a computed draft key from the selected server", () => { + expect( + __private__.resolveDraftKey({ + draftKey: ({ selectedServerId }) => `draft:${selectedServerId ?? "none"}`, + selectedServerId: "host-1", + }), + ).toBe("draft:host-1"); + }); + }); + + describe("__private__.resolveEffectiveComposerModelId", () => { + const models = [ + { + provider: "codex", + id: "gpt-5.4", + label: "gpt-5.4", + isDefault: true, + }, + { + provider: "codex", + id: "gpt-5.4-mini", + label: "gpt-5.4-mini", + }, + ]; + + it("prefers the selected model when present", () => { + expect( + __private__.resolveEffectiveComposerModelId({ + selectedModel: "gpt-5.4-mini", + availableModels: models, + }), + ).toBe("gpt-5.4-mini"); + }); + + it("falls back to the provider default model", () => { + expect( + __private__.resolveEffectiveComposerModelId({ + selectedModel: "", + availableModels: models, + }), + ).toBe("gpt-5.4"); + }); + }); + + describe("__private__.resolveEffectiveComposerThinkingOptionId", () => { + const models = [ + { + provider: "codex", + id: "gpt-5.4", + label: "gpt-5.4", + isDefault: true, + defaultThinkingOptionId: "high", + thinkingOptions: [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + ], + }, + ]; + + it("prefers the selected thinking option when present", () => { + expect( + __private__.resolveEffectiveComposerThinkingOptionId({ + selectedThinkingOptionId: "medium", + availableModels: models, + effectiveModelId: "gpt-5.4", + }), + ).toBe("medium"); + }); + + it("falls back to the model default thinking option", () => { + expect( + __private__.resolveEffectiveComposerThinkingOptionId({ + selectedThinkingOptionId: "", + availableModels: models, + effectiveModelId: "gpt-5.4", + }), + ).toBe("high"); + }); + }); + + describe("__private__.buildDraftComposerCommandConfig", () => { + it("returns undefined when cwd is empty", () => { + expect( + __private__.buildDraftComposerCommandConfig({ + provider: "codex", + cwd: " ", + modeOptions: [], + selectedMode: "", + effectiveModelId: "gpt-5.4", + effectiveThinkingOptionId: "high", + }), + ).toBeUndefined(); + }); + + it("builds the draft command config from derived composer state", () => { + expect( + __private__.buildDraftComposerCommandConfig({ + provider: "codex", + cwd: "/repo", + modeOptions: [{ id: "auto", label: "Auto" }], + selectedMode: "auto", + effectiveModelId: "gpt-5.4", + effectiveThinkingOptionId: "high", + }), + ).toEqual({ + provider: "codex", + cwd: "/repo", + modeId: "auto", + model: "gpt-5.4", + thinkingOptionId: "high", + }); + }); + }); +}); diff --git a/packages/app/src/hooks/use-agent-input-draft.ts b/packages/app/src/hooks/use-agent-input-draft.ts index 200474970..94d12d02d 100644 --- a/packages/app/src/hooks/use-agent-input-draft.ts +++ b/packages/app/src/hooks/use-agent-input-draft.ts @@ -1,9 +1,44 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { AttachmentMetadata } from "@/attachments/types"; +import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar"; +import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; +import { + useAgentFormState, + type CreateAgentInitialValues, + type UseAgentFormStateResult, +} from "@/hooks/use-agent-form-state"; import { useDraftStore } from "@/stores/draft-store"; +import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types"; type ImageUpdater = AttachmentMetadata[] | ((prev: AttachmentMetadata[]) => AttachmentMetadata[]); +type AgentInputDraftComposerOptions = { + initialServerId: string | null; + initialValues?: CreateAgentInitialValues; + isVisible?: boolean; + onlineServerIds?: string[]; + lockedWorkingDir?: string; +}; + +type DraftKeyContext = { + selectedServerId: string | null; +}; + +type DraftKeyInput = string | ((context: DraftKeyContext) => string); + +type UseAgentInputDraftInput = { + draftKey: DraftKeyInput; + composer?: AgentInputDraftComposerOptions; +}; + +type DraftComposerState = UseAgentFormStateResult & { + workingDir: string; + effectiveModelId: string; + effectiveThinkingOptionId: string; + statusControls: DraftAgentStatusBarProps; + commandDraftConfig: DraftCommandConfig | undefined; +}; + interface AgentInputDraft { text: string; setText: (text: string) => void; @@ -11,6 +46,7 @@ interface AgentInputDraft { setImages: (updater: ImageUpdater) => void; clear: (lifecycle: "sent" | "abandoned") => void; isHydrated: boolean; + composerState: DraftComposerState | null; } function hasDraftContent(input: { text: string; images: AttachmentMetadata[] }): boolean { @@ -36,7 +72,108 @@ function areImagesEqual(input: { }); } -export function useAgentInputDraft(draftKey: string): AgentInputDraft { +function resolveDraftKey(input: { + draftKey: DraftKeyInput; + selectedServerId: string | null; +}): string { + if (typeof input.draftKey === "function") { + return input.draftKey({ selectedServerId: input.selectedServerId }); + } + return input.draftKey; +} + +function resolveEffectiveComposerModelId(input: { + selectedModel: string; + availableModels: AgentModelDefinition[]; +}): string { + const selectedModel = input.selectedModel.trim(); + if (selectedModel) { + return selectedModel; + } + + return input.availableModels.find((model) => model.isDefault)?.id ?? input.availableModels[0]?.id ?? ""; +} + +function resolveEffectiveComposerThinkingOptionId(input: { + selectedThinkingOptionId: string; + availableModels: AgentModelDefinition[]; + effectiveModelId: string; +}): string { + const selectedThinkingOptionId = input.selectedThinkingOptionId.trim(); + if (selectedThinkingOptionId) { + return selectedThinkingOptionId; + } + + const selectedModelDefinition = + input.availableModels.find((model) => model.id === input.effectiveModelId) ?? null; + return selectedModelDefinition?.defaultThinkingOptionId ?? ""; +} + +function buildDraftComposerCommandConfig(input: { + provider: DraftAgentStatusBarProps["selectedProvider"]; + cwd: string; + modeOptions: DraftAgentStatusBarProps["modeOptions"]; + selectedMode: string; + effectiveModelId: string; + effectiveThinkingOptionId: string; +}): DraftCommandConfig | undefined { + const cwd = input.cwd.trim(); + if (!cwd) { + return undefined; + } + + return { + provider: input.provider, + cwd, + ...(input.modeOptions.length > 0 && input.selectedMode !== "" ? { modeId: input.selectedMode } : {}), + ...(input.effectiveModelId ? { model: input.effectiveModelId } : {}), + ...(input.effectiveThinkingOptionId + ? { thinkingOptionId: input.effectiveThinkingOptionId } + : {}), + }; +} + +function buildDraftStatusControls(input: { + formState: UseAgentFormStateResult; +}): DraftAgentStatusBarProps { + const { formState } = input; + return { + providerDefinitions: formState.providerDefinitions, + selectedProvider: formState.selectedProvider, + onSelectProvider: formState.setProviderFromUser, + modeOptions: formState.modeOptions, + selectedMode: formState.selectedMode, + onSelectMode: formState.setModeFromUser, + models: formState.availableModels, + selectedModel: formState.selectedModel, + onSelectModel: formState.setModelFromUser, + isModelLoading: formState.isModelLoading, + allProviderModels: formState.allProviderModels, + isAllModelsLoading: formState.isAllModelsLoading, + onSelectProviderAndModel: formState.setProviderAndModelFromUser, + thinkingOptions: formState.availableThinkingOptions, + selectedThinkingOptionId: formState.selectedThinkingOptionId, + onSelectThinkingOption: formState.setThinkingOptionFromUser, + }; +} + +export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft { + const composerOptions = input.composer ?? null; + const formState = useAgentFormState({ + initialServerId: composerOptions?.initialServerId ?? null, + initialValues: composerOptions?.initialValues, + isVisible: composerOptions?.isVisible ?? false, + isCreateFlow: true, + onlineServerIds: composerOptions?.onlineServerIds ?? [], + }); + const draftKey = useMemo( + () => + resolveDraftKey({ + draftKey: input.draftKey, + selectedServerId: formState.selectedServerId, + }), + [formState.selectedServerId, input.draftKey], + ); const [text, setText] = useState(""); const [images, setImagesState] = useState([]); const [isHydrated, setIsHydrated] = useState(false); @@ -148,6 +285,83 @@ export function useAgentInputDraft(draftKey: string): AgentInputDraft { }); }, [draftKey, images, text]); + const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? ""; + useEffect(() => { + if (!composerOptions || !lockedWorkingDir) { + return; + } + if (formState.workingDir.trim() === lockedWorkingDir) { + return; + } + formState.setWorkingDir(lockedWorkingDir); + }, [composerOptions, formState, lockedWorkingDir]); + + const effectiveModelId = useMemo( + () => + resolveEffectiveComposerModelId({ + selectedModel: formState.selectedModel, + availableModels: formState.availableModels, + }), + [formState.availableModels, formState.selectedModel], + ); + + const effectiveThinkingOptionId = useMemo( + () => + resolveEffectiveComposerThinkingOptionId({ + selectedThinkingOptionId: formState.selectedThinkingOptionId, + availableModels: formState.availableModels, + effectiveModelId, + }), + [effectiveModelId, formState.availableModels, formState.selectedThinkingOptionId], + ); + + const workingDir = lockedWorkingDir || formState.workingDir; + + const commandDraftConfig = useMemo( + () => + composerOptions + ? buildDraftComposerCommandConfig({ + provider: formState.selectedProvider, + cwd: workingDir, + modeOptions: formState.modeOptions, + selectedMode: formState.selectedMode, + effectiveModelId, + effectiveThinkingOptionId, + }) + : undefined, + [ + composerOptions, + effectiveModelId, + effectiveThinkingOptionId, + workingDir, + formState.modeOptions, + formState.selectedMode, + formState.selectedProvider, + ], + ); + + const composerState = useMemo(() => { + if (!composerOptions) { + return null; + } + + return { + ...formState, + workingDir, + effectiveModelId, + effectiveThinkingOptionId, + statusControls: buildDraftStatusControls({ formState }), + commandDraftConfig, + }; + }, [ + commandDraftConfig, + composerOptions, + effectiveModelId, + effectiveThinkingOptionId, + formState, + workingDir, + ]); + return { text, setText, @@ -155,5 +369,14 @@ export function useAgentInputDraft(draftKey: string): AgentInputDraft { setImages, clear, isHydrated, + composerState, }; } + +export const __private__ = { + resolveDraftKey, + resolveEffectiveComposerModelId, + resolveEffectiveComposerThinkingOptionId, + buildDraftComposerCommandConfig, + buildDraftStatusControls, +}; diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts index 137985743..fd189b3ff 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts @@ -17,6 +17,7 @@ function createAgent(id: string): Agent { serverId: "server-1", id, provider: "claude", + terminal: false, status: "running", createdAt: now, updatedAt: now, @@ -29,6 +30,7 @@ function createAgent(id: string): Agent { supportsMcpServers: true, supportsReasoningStream: true, supportsToolInvocations: true, + supportsTerminalMode: false, }, currentModeId: null, availableModes: [], diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.ts b/packages/app/src/hooks/use-agent-screen-state-machine.ts index 2c37b70a6..612dbbd85 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.ts @@ -5,6 +5,14 @@ export interface AgentScreenAgent { id: string; status: "initializing" | "idle" | "running" | "error" | "closed"; cwd: string; + lastError?: string | null; + terminalExit?: { + command: string; + message: string; + exitCode: number | null; + signal: number | null; + outputLines: string[]; + } | null; projectPlacement?: { checkout?: { cwd?: string; diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index f77736e85..403ac1d20 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -65,6 +65,7 @@ export function useAggregatedAgents(options?: { serverId, serverLabel, title: agent.title ?? null, + terminal: agent.terminal, status: agent.status, lastActivityAt: agent.lastActivityAt, cwd: agent.cwd, diff --git a/packages/app/src/hooks/use-all-agents-list.test.ts b/packages/app/src/hooks/use-all-agents-list.test.ts index 1353dcd62..cc9ebda42 100644 --- a/packages/app/src/hooks/use-all-agents-list.test.ts +++ b/packages/app/src/hooks/use-all-agents-list.test.ts @@ -8,6 +8,7 @@ function makeAgent(input?: Partial): Agent { serverId: "server-1", id: input?.id ?? "agent-1", provider: input?.provider ?? "codex", + terminal: input?.terminal ?? false, status: input?.status ?? "idle", createdAt: input?.createdAt ?? timestamp, updatedAt: input?.updatedAt ?? timestamp, @@ -20,6 +21,7 @@ function makeAgent(input?: Partial): Agent { supportsMcpServers: true, supportsReasoningStream: true, supportsToolInvocations: true, + supportsTerminalMode: false, }, currentModeId: input?.currentModeId ?? null, availableModes: input?.availableModes ?? [], diff --git a/packages/app/src/hooks/use-all-agents-list.ts b/packages/app/src/hooks/use-all-agents-list.ts index d7c24e681..6bf0c0f27 100644 --- a/packages/app/src/hooks/use-all-agents-list.ts +++ b/packages/app/src/hooks/use-all-agents-list.ts @@ -19,6 +19,7 @@ function toAggregatedAgent(params: { serverId: params.serverId, serverLabel: params.serverLabel, title: source.title ?? null, + terminal: source.terminal, status: source.status, lastActivityAt: source.lastActivityAt, cwd: source.cwd, diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index ceecac46b..15e2f02a2 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { TextInput } from "react-native"; import { router, usePathname, type Href } from "expo-router"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; +import { useSessionStore } from "@/stores/session-store"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { useHosts } from "@/runtime/host-runtime"; import { useAllAgentsList } from "@/hooks/use-all-agents-list"; @@ -11,13 +12,18 @@ import { clearCommandCenterFocusRestoreElement, takeCommandCenterFocusRestoreElement, } from "@/utils/command-center-focus-restore"; -import { buildHostSettingsRoute, parseServerIdFromPathname } from "@/utils/host-routes"; +import { + buildHostAgentDetailRoute, + buildHostSettingsRoute, + parseServerIdFromPathname, +} from "@/utils/host-routes"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string"; import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { getShortcutOs } from "@/utils/shortcut-platform"; import { getIsElectronRuntime } from "@/constants/layout"; +import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { focusWithRetries } from "@/utils/web-focus"; @@ -215,9 +221,17 @@ export function useCommandCenter() { // Don't restore focus back to the prior element after we navigate. clearCommandCenterFocusRestoreElement(); setOpen(false); + const workspaceId = resolveWorkspaceIdByExecutionDirectory({ + workspaces: useSessionStore.getState().sessions[agent.serverId]?.workspaces?.values(), + workspaceDirectory: agent.cwd, + }); + if (!workspaceId) { + router.navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as any); + return; + } const route = prepareWorkspaceTab({ serverId: agent.serverId, - workspaceId: agent.cwd, + workspaceId, target: { kind: "agent", agentId: agent.id }, }); router.navigate(route as any); diff --git a/packages/app/src/hooks/use-draft-agent-create-flow.ts b/packages/app/src/hooks/use-draft-agent-create-flow.ts index 98d55b74f..190d92e41 100644 --- a/packages/app/src/hooks/use-draft-agent-create-flow.ts +++ b/packages/app/src/hooks/use-draft-agent-create-flow.ts @@ -72,6 +72,7 @@ type CreateRequestContext = { interface UseDraftAgentCreateFlowOptions { draftId: string; getPendingServerId: () => string | null; + allowEmptyText?: boolean; validateBeforeSubmit?: (ctx: SubmitContext) => string | null; onBeforeSubmit?: (ctx: CreateRequestContext) => void; onCreateStart?: () => void; @@ -84,6 +85,7 @@ interface UseDraftAgentCreateFlowOptions { export function useDraftAgentCreateFlow({ draftId, getPendingServerId, + allowEmptyText = false, validateBeforeSubmit, onBeforeSubmit, onCreateStart, @@ -110,6 +112,10 @@ export function useDraftAgentCreateFlow({ return EMPTY_STREAM_ITEMS; } + if (!machine.attempt.text && (!machine.attempt.images || machine.attempt.images.length === 0)) { + return EMPTY_STREAM_ITEMS; + } + return [ { kind: "user_message", @@ -139,7 +145,7 @@ export function useDraftAgentCreateFlow({ dispatch({ type: "DRAFT_SET_ERROR", message: "" }); const trimmedPrompt = text.trim(); - if (!trimmedPrompt) { + if (!trimmedPrompt && !allowEmptyText) { const error = new Error("Initial prompt is required"); dispatch({ type: "DRAFT_SET_ERROR", message: error.message }); throw error; @@ -215,6 +221,7 @@ export function useDraftAgentCreateFlow({ setPendingCreateAttempt, updatePendingAgentId, validateBeforeSubmit, + allowEmptyText, ], ); diff --git a/packages/app/src/hooks/use-open-project.test.ts b/packages/app/src/hooks/use-open-project.test.ts new file mode 100644 index 000000000..a39789998 --- /dev/null +++ b/packages/app/src/hooks/use-open-project.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@react-native-async-storage/async-storage", () => { + const storage = new Map(); + return { + default: { + getItem: vi.fn(async (key: string) => storage.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + storage.set(key, value); + }), + removeItem: vi.fn(async (key: string) => { + storage.delete(key); + }), + }, + }; +}); + +const { replaceRoute } = vi.hoisted(() => ({ + replaceRoute: vi.fn(), +})); + +vi.mock("expo-router", () => ({ + router: { + replace: replaceRoute, + }, +})); + +import { openProjectDirectly } from "@/hooks/use-open-project"; +import { useSessionStore } from "@/stores/session-store"; +import { + buildWorkspaceTabPersistenceKey, + collectAllTabs, + useWorkspaceLayoutStore, +} from "@/stores/workspace-layout-store"; + +const SERVER_ID = "server-1"; +const WORKSPACE_ID = "/repo/project"; + +describe("openProjectDirectly", () => { + beforeEach(() => { + replaceRoute.mockReset(); + useSessionStore.setState({ + sessions: {}, + }); + useSessionStore.getState().initializeSession(SERVER_ID, {} as never); + useWorkspaceLayoutStore.setState({ + layoutByWorkspace: {}, + splitSizesByWorkspace: {}, + pinnedAgentIdsByWorkspace: {}, + }); + vi.restoreAllMocks(); + }); + + it("opens the workspace directly, marks workspaces hydrated, and seeds a launcher tab", async () => { + vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( + "11111111-1111-1111-1111-111111111111", + ); + + const result = await openProjectDirectly({ + serverId: SERVER_ID, + projectPath: WORKSPACE_ID, + isConnected: true, + client: { + openProject: vi.fn(async () => ({ + requestId: "request-1", + error: null, + workspace: { + id: 1, + projectId: 1, + projectDisplayName: "project", + projectRootPath: WORKSPACE_ID, + workspaceDirectory: WORKSPACE_ID, + projectKind: "git" as const, + workspaceKind: "checkout" as const, + name: "project", + status: "done" as const, + activityAt: null, + diffStat: null, + }, + })), + }, + mergeWorkspaces: useSessionStore.getState().mergeWorkspaces, + setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces, + openLauncherTab: useWorkspaceLayoutStore.getState().openLauncherTab, + replaceRoute, + }); + + expect(result).toBe(true); + expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(true); + expect(Array.from(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.values() ?? [])).toEqual([ + expect.objectContaining({ + id: "1", + projectId: "1", + projectRootPath: WORKSPACE_ID, + workspaceDirectory: WORKSPACE_ID, + }), + ]); + + const workspaceKey = buildWorkspaceTabPersistenceKey({ + serverId: SERVER_ID, + workspaceId: "1", + }); + expect(workspaceKey).toBeTruthy(); + const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey as string]; + expect(layout.root.kind).toBe("pane"); + const tabs = collectAllTabs(layout.root); + expect(tabs).toHaveLength(1); + expect(tabs[0]?.target).toEqual({ + kind: "launcher", + launcherId: "11111111-1111-1111-1111-111111111111", + }); + expect(replaceRoute).toHaveBeenCalledWith("/h/server-1/workspace/MQ"); + }); + + it("does not navigate or seed tabs when openProject fails", async () => { + const result = await openProjectDirectly({ + serverId: SERVER_ID, + projectPath: WORKSPACE_ID, + isConnected: true, + client: { + openProject: vi.fn(async () => ({ + requestId: "request-2", + error: "Failed to open project", + workspace: null, + })), + }, + mergeWorkspaces: useSessionStore.getState().mergeWorkspaces, + setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces, + openLauncherTab: useWorkspaceLayoutStore.getState().openLauncherTab, + replaceRoute, + }); + + expect(result).toBe(false); + expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(false); + expect(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.size).toBe(0); + expect(useWorkspaceLayoutStore.getState().layoutByWorkspace).toEqual({}); + expect(replaceRoute).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/hooks/use-open-project.ts b/packages/app/src/hooks/use-open-project.ts index ed353dccf..a90ed420e 100644 --- a/packages/app/src/hooks/use-open-project.ts +++ b/packages/app/src/hooks/use-open-project.ts @@ -1,45 +1,76 @@ -import { router } from "expo-router"; import { useCallback } from "react"; -import { useToast } from "@/contexts/toast-context"; -import { useHostRuntimeClient } from "@/runtime/host-runtime"; -import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; -import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; +import { router } from "expo-router"; +import type { DaemonClient } from "@server/client/daemon-client"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; +import { + buildWorkspaceTabPersistenceKey, + useWorkspaceLayoutStore, +} from "@/stores/workspace-layout-store"; +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; + +interface OpenProjectDirectlyInput { + serverId: string; + projectPath: string; + isConnected: boolean; + client: Pick | null; + mergeWorkspaces: (serverId: string, workspaces: Iterable) => void; + setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void; + openLauncherTab: (workspaceKey: string) => string | null; + replaceRoute: (route: string) => void; +} + +export async function openProjectDirectly(input: OpenProjectDirectlyInput): Promise { + const normalizedServerId = input.serverId.trim(); + const trimmedPath = input.projectPath.trim(); + if (!normalizedServerId || !trimmedPath || !input.client || !input.isConnected) { + return false; + } + + const payload = await input.client.openProject(trimmedPath); + if (payload.error || !payload.workspace) { + return false; + } + + const workspace = normalizeWorkspaceDescriptor(payload.workspace); + input.mergeWorkspaces(normalizedServerId, [workspace]); + input.setHasHydratedWorkspaces(normalizedServerId, true); + + const workspaceKey = buildWorkspaceTabPersistenceKey({ + serverId: normalizedServerId, + workspaceId: workspace.id, + }); + if (!workspaceKey) { + return false; + } + + input.openLauncherTab(workspaceKey); + input.replaceRoute(buildHostWorkspaceRoute(normalizedServerId, workspace.id)); + return true; +} export function useOpenProject(serverId: string | null): (path: string) => Promise { const normalizedServerId = serverId?.trim() ?? ""; - const toast = useToast(); const client = useHostRuntimeClient(normalizedServerId); + const isConnected = useHostRuntimeIsConnected(normalizedServerId); const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); return useCallback( async (path: string) => { - const trimmedPath = path.trim(); - if (!trimmedPath || !client || !normalizedServerId) { - return false; - } - - try { - const payload = await client.openProject(trimmedPath); - if (payload.error || !payload.workspace) { - throw new Error(payload.error || "Failed to open project"); - } - - mergeWorkspaces(normalizedServerId, [normalizeWorkspaceDescriptor(payload.workspace)]); - setHasHydratedWorkspaces(normalizedServerId, true); - router.replace( - prepareWorkspaceTab({ - serverId: normalizedServerId, - workspaceId: payload.workspace.id, - target: { kind: "draft", draftId: "new" }, - }) as any, - ); - return true; - } catch (error) { - toast.error(error instanceof Error ? error.message : "Failed to open project"); - return false; - } + return openProjectDirectly({ + serverId: normalizedServerId, + projectPath: path, + isConnected, + client, + mergeWorkspaces, + setHasHydratedWorkspaces, + openLauncherTab: useWorkspaceLayoutStore.getState().openLauncherTab, + replaceRoute: (route) => { + router.replace(route as any); + }, + }); }, - [client, mergeWorkspaces, normalizedServerId, setHasHydratedWorkspaces, toast], + [client, isConnected, mergeWorkspaces, normalizedServerId, setHasHydratedWorkspaces], ); } diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts index 88f4925c6..e87f2443d 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts @@ -19,7 +19,11 @@ function workspace( Partial< Pick< WorkspaceDescriptor, - "projectDisplayName" | "projectRootPath" | "projectKind" | "workspaceKind" + | "projectDisplayName" + | "projectRootPath" + | "workspaceDirectory" + | "projectKind" + | "workspaceKind" > >, ): WorkspaceDescriptor { @@ -28,8 +32,9 @@ function workspace( projectId: input.projectId, projectDisplayName: input.projectDisplayName ?? input.projectId, projectRootPath: input.projectRootPath ?? input.id, + workspaceDirectory: input.workspaceDirectory ?? input.projectRootPath ?? input.id, projectKind: input.projectKind ?? "git", - workspaceKind: input.workspaceKind ?? "local_checkout", + workspaceKind: input.workspaceKind ?? "checkout", name: input.name, status: input.status, activityAt: input.activityAt, diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 205deb6a4..0678b78ee 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -15,6 +15,9 @@ export interface SidebarWorkspaceEntry { workspaceKey: string; serverId: string; workspaceId: string; + projectRootPath?: string; + workspaceDirectory?: string; + projectKind: WorkspaceDescriptor["projectKind"]; workspaceKind: WorkspaceDescriptor["workspaceKind"]; name: string; activityAt: Date | null; @@ -118,7 +121,7 @@ export function buildSidebarProjectsFromWorkspaces(input: { projectName: workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId), projectKind: workspace.projectKind, - iconWorkingDir: workspace.projectRootPath || workspace.id, + iconWorkingDir: workspace.projectRootPath, statusBucket: "done", activeCount: 0, totalWorkspaces: 0, @@ -130,6 +133,9 @@ export function buildSidebarProjectsFromWorkspaces(input: { workspaceKey: `${input.serverId}:${workspace.id}`, serverId: input.serverId, workspaceId: workspace.id, + projectRootPath: workspace.projectRootPath, + workspaceDirectory: workspace.workspaceDirectory, + projectKind: workspace.projectKind, workspaceKind: workspace.workspaceKind, name: workspace.name, activityAt: workspace.activityAt, @@ -248,10 +254,11 @@ function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string } function toWorkspaceDescriptor(payload: { - id: string; - projectId: string; + id: number; + projectId: number; projectDisplayName: string; projectRootPath: string; + workspaceDirectory: string; projectKind: WorkspaceDescriptor["projectKind"]; workspaceKind: WorkspaceDescriptor["workspaceKind"]; name: string; diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 2540e8549..cb880cc6e 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -204,7 +204,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ help: { id: "workspace-tab-new", section: "tabs-panes", - label: "New agent tab", + label: "New tab", keys: ["mod", "T"], }, }, @@ -216,7 +216,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ help: { id: "workspace-tab-new", section: "tabs-panes", - label: "New agent tab", + label: "New tab", keys: ["mod", "T"], }, }, diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 880c8a4b9..0f0110442 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -4,16 +4,14 @@ import ReanimatedAnimated from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; import { useStoreWithEqualityFn } from "zustand/traditional"; -import { Bot } from "lucide-react-native"; import invariant from "tiny-invariant"; import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view"; -import { AgentInputArea } from "@/components/agent-input-area"; +import { Composer } from "@/components/composer"; import { ArchivedAgentCallout } from "@/components/archived-agent-callout"; import { FileDropZone } from "@/components/file-drop-zone"; +import { getProviderIcon } from "@/components/provider-icons"; import type { ImageAttachment } from "@/components/message-input"; import { ToastViewport, useToastHost } from "@/components/toast-host"; -import { ClaudeIcon } from "@/components/icons/claude-icon"; -import { CodexIcon } from "@/components/icons/codex-icon"; import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear"; import { useAgentInitialization } from "@/hooks/use-agent-initialization"; import { @@ -28,6 +26,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useStableEvent } from "@/hooks/use-stable-event"; import { usePaneContext } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; +import { TerminalAgentPanel } from "@/panels/terminal-agent-panel"; import { useHostRuntimeClient, useHostRuntimeConnectionStatus, @@ -96,7 +95,7 @@ function useAgentPanelDescriptor( ); const provider = descriptorState.provider; const label = resolveWorkspaceAgentTabLabel(descriptorState.title); - const icon = provider === "claude" ? ClaudeIcon : provider === "codex" ? CodexIcon : Bot; + const icon = getProviderIcon(provider); return { label: label ?? "", @@ -155,6 +154,12 @@ function isNotFoundErrorMessage(message: string): boolean { return /agent not found|not found/i.test(message); } +type AgentLookupState = + | { tag: "idle" } + | { tag: "loading" } + | { tag: "not_found"; message: string } + | { tag: "error"; message: string }; + function AgentPanelContent({ serverId, agentId, @@ -227,6 +232,219 @@ function AgentPanelBody({ isConnected: boolean; connectionStatus: HostRuntimeConnectionStatus; onOpenWorkspaceFile?: (input: { filePath: string }) => void; +}) { + const { theme } = useUnistyles(); + const { isArchivingAgent } = useArchiveAgent(); + const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); + const setAgents = useSessionStore((state) => state.setAgents); + const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions); + const projectPlacement = useStoreWithEqualityFn( + useSessionStore, + (state) => + agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null, + (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b), + ); + const agentState = useSessionStore( + useShallow((state) => { + const agent = agentId ? state.sessions[serverId]?.agents?.get(agentId) ?? null : null; + return { + serverId: agent?.serverId ?? null, + id: agent?.id ?? null, + terminal: agent?.terminal ?? false, + status: agent?.status ?? null, + cwd: agent?.cwd ?? null, + lastError: agent?.lastError ?? null, + terminalExit: agent?.terminalExit ?? null, + archivedAt: agent?.archivedAt ?? null, + }; + }), + ); + const [lookupState, setLookupState] = useState({ tag: "idle" }); + const lookupAttemptTokenRef = useRef(0); + + useEffect(() => { + lookupAttemptTokenRef.current += 1; + setLookupState({ tag: "idle" }); + }, [agentId, serverId]); + + useEffect(() => { + if (!agentId) { + return; + } + if (agentState.id) { + if (lookupState.tag !== "idle") { + setLookupState({ tag: "idle" }); + } + return; + } + if (!isConnected || !hasSession) { + return; + } + if (lookupState.tag === "loading" || lookupState.tag === "not_found") { + return; + } + + setLookupState({ tag: "loading" }); + const attemptToken = ++lookupAttemptTokenRef.current; + + client + .fetchAgent(agentId) + .then((result) => { + if (attemptToken !== lookupAttemptTokenRef.current) { + return; + } + if (!result) { + setLookupState({ + tag: "not_found", + message: `Agent not found: ${agentId}`, + }); + return; + } + + const normalized = normalizeAgentSnapshot(result.agent, serverId); + const hydrated = { + ...normalized, + projectPlacement: result.project, + }; + setAgents(serverId, (previous) => { + const next = new Map(previous); + next.set(hydrated.id, hydrated); + return next; + }); + setPendingPermissions(serverId, (previous) => { + const next = new Map(previous); + for (const [key, pending] of next.entries()) { + if (pending.agentId === hydrated.id) { + next.delete(key); + } + } + for (const request of hydrated.pendingPermissions) { + const key = derivePendingPermissionKey(hydrated.id, request); + next.set(key, { key, agentId: hydrated.id, request }); + } + return next; + }); + setLookupState({ tag: "idle" }); + }) + .catch((error) => { + if (attemptToken !== lookupAttemptTokenRef.current) { + return; + } + const message = toErrorMessage(error); + if (isNotFoundErrorMessage(message)) { + setLookupState({ tag: "not_found", message }); + return; + } + setLookupState({ tag: "error", message }); + }); + }, [ + agentId, + agentState.id, + client, + hasSession, + isConnected, + lookupState.tag, + serverId, + setAgents, + setPendingPermissions, + ]); + + if (lookupState.tag === "not_found") { + return ( + + + Agent not found + + + ); + } + + if (lookupState.tag === "error") { + return ( + + + Failed to load agent + {lookupState.message} + + + ); + } + + const agent: AgentScreenAgent | null = + agentState.serverId && agentState.id && agentState.status && agentState.cwd + ? { + serverId: agentState.serverId, + id: agentState.id, + status: agentState.status, + cwd: agentState.cwd, + lastError: agentState.lastError ?? null, + terminalExit: agentState.terminalExit ?? null, + projectPlacement, + } + : null; + + if (!agent) { + return ( + + + + + + ); + } + + const isArchivingCurrentAgent = Boolean(agentId && isArchivingAgent({ serverId, agentId })); + + if (agentState.terminal) { + return ( + + + + {isArchivingCurrentAgent ? ( + + + Archiving agent... + Please wait while we archive this agent. + + ) : null} + + ); + } + + return ( + + ); +} + +function ChatAgentContent({ + serverId, + agentId, + isPaneFocused, + client, + isConnected, + connectionStatus, + onOpenWorkspaceFile, +}: { + serverId: string; + agentId?: string; + isPaneFocused: boolean; + client: NonNullable>; + isConnected: boolean; + connectionStatus: HostRuntimeConnectionStatus; + onOpenWorkspaceFile?: (input: { filePath: string }) => void; }) { const { theme } = useUnistyles(); const panelToast = useToastHost(); @@ -241,12 +459,12 @@ function AgentPanelBody({ routeKey: string; reason: "initial-entry" | "resume"; } | null>(null); - const agentInputDraft = useAgentInputDraft( - buildDraftStoreKey({ + const agentInputDraft = useAgentInputDraft({ + draftKey: buildDraftStoreKey({ serverId, agentId: agentId ?? "__pending__", }), - ); + }); const handleFilesDropped = useCallback((files: ImageAttachment[]) => { addImagesRef.current?.(files); @@ -262,8 +480,11 @@ function AgentPanelBody({ return { serverId: agent?.serverId ?? null, id: agent?.id ?? null, + terminal: agent?.terminal ?? false, status: agent?.status ?? null, cwd: agent?.cwd ?? null, + lastError: agent?.lastError ?? null, + terminalExit: agent?.terminalExit ?? null, archivedAt: agent?.archivedAt ?? null, requiresAttention: agent?.requiresAttention ?? false, attentionReason: agent?.attentionReason ?? null, @@ -494,6 +715,8 @@ function AgentPanelBody({ id: agentState.id, status: agentState.status, cwd: agentState.cwd, + lastError: agentState.lastError ?? null, + terminalExit: agentState.terminalExit ?? null, projectPlacement, } : null; @@ -615,98 +838,6 @@ function AgentPanelBody({ setMissingAgentState({ kind: "idle" }); }, [agentId, serverId]); - useEffect(() => { - if (!agentId) { - return; - } - if (agentState.id || shouldUseOptimisticStream) { - if (missingAgentState.kind !== "idle") { - setMissingAgentState({ kind: "idle" }); - } - return; - } - if (!isConnected || !hasSession) { - return; - } - if (missingAgentState.kind === "resolving" || missingAgentState.kind === "not_found") { - return; - } - - setMissingAgentState({ kind: "resolving" }); - const attemptToken = ++initAttemptTokenRef.current; - - ensureAgentIsInitialized(agentId) - .then(async () => { - if (attemptToken !== initAttemptTokenRef.current) { - return; - } - const currentAgent = useSessionStore.getState().sessions[serverId]?.agents.get(agentId); - if (!currentAgent) { - const result = await client.fetchAgent(agentId); - if (attemptToken !== initAttemptTokenRef.current) { - return; - } - if (!result) { - setMissingAgentState({ - kind: "not_found", - message: `Agent not found: ${agentId}`, - }); - return; - } - const normalized = normalizeAgentSnapshot(result.agent, serverId); - const hydrated = { - ...normalized, - projectPlacement: result.project, - }; - setAgents(serverId, (previous) => { - const next = new Map(previous); - next.set(hydrated.id, hydrated); - return next; - }); - setPendingPermissions(serverId, (previous) => { - const next = new Map(previous); - for (const [key, pending] of next.entries()) { - if (pending.agentId === hydrated.id) { - next.delete(key); - } - } - for (const request of hydrated.pendingPermissions) { - const key = derivePendingPermissionKey(hydrated.id, request); - next.set(key, { key, agentId: hydrated.id, request }); - } - return next; - }); - } - if (attemptToken !== initAttemptTokenRef.current) { - return; - } - setMissingAgentState({ kind: "idle" }); - }) - .catch((error) => { - if (attemptToken !== initAttemptTokenRef.current) { - return; - } - const message = toErrorMessage(error); - if (isNotFoundErrorMessage(message)) { - setMissingAgentState({ kind: "not_found", message }); - return; - } - setMissingAgentState({ kind: "error", message }); - }); - }, [ - agentState.id, - agentId, - client, - ensureAgentIsInitialized, - hasSession, - isConnected, - missingAgentState.kind, - serverId, - setAgents, - setPendingPermissions, - shouldUseOptimisticStream, - ]); - const isHistoryRefreshCatchingUp = viewState.tag === "ready" && viewState.sync.status === "catching_up" && @@ -781,7 +912,7 @@ function AgentPanelBody({
{agentId && !isArchivingCurrentAgent && !agentState.archivedAt ? ( - + resolveWorkspaceExecutionAuthority({ + workspaces: state.sessions[serverId]?.workspaces, + workspaceId, + }), + ); invariant(target.kind === "file", "FilePanel requires file target"); - return ; + if (!authority) { + return ( + + Workspace execution directory not found. + + ); + } + return ( + + ); } export const filePanelRegistration: PanelRegistration<"file"> = { diff --git a/packages/app/src/panels/launcher-panel.tsx b/packages/app/src/panels/launcher-panel.tsx new file mode 100644 index 000000000..a5dddb937 --- /dev/null +++ b/packages/app/src/panels/launcher-panel.tsx @@ -0,0 +1,485 @@ +import { useCallback, useMemo, useState, type ComponentType } from "react"; +import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; +import { Bot, ChevronDown, Plus, SquarePen, SquareTerminal } from "lucide-react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import invariant from "tiny-invariant"; +import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; +import { getProviderIcon } from "@/components/provider-icons"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { usePaneContext } from "@/panels/pane-context"; +import type { PanelRegistration } from "@/panels/panel-registry"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { generateDraftId } from "@/stores/draft-keys"; +import { useProviderRecency } from "@/stores/provider-recency-store"; +import { useSessionStore } from "@/stores/session-store"; +import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { toErrorMessage } from "@/utils/error-messages"; +import { + getWorkspaceExecutionAuthority, + requireWorkspaceRecordId, +} from "@/utils/workspace-execution"; + +const MAX_VISIBLE_PROVIDER_TILES = 4; + +function useLauncherPanelDescriptor() { + return { + label: "New Tab", + subtitle: "New Tab", + titleState: "ready" as const, + icon: Plus, + statusBucket: null, + }; +} + +function LauncherPanel() { + const { serverId, workspaceId, target, retargetCurrentTab, isPaneFocused } = usePaneContext(); + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); + const workspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces); + const workspaceAuthority = getWorkspaceExecutionAuthority({ workspaces, workspaceId }); + const workspaceDirectory = workspaceAuthority.ok + ? workspaceAuthority.authority.workspaceDirectory + : null; + const { providers, recordUsage } = useProviderRecency(); + const setAgents = useSessionStore((state) => state.setAgents); + const [pendingAction, setPendingAction] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + invariant(target.kind === "launcher", "LauncherPanel requires launcher target"); + + const visibleProviders = useMemo( + () => providers.slice(0, MAX_VISIBLE_PROVIDER_TILES), + [providers], + ); + const overflowProviders = useMemo( + () => providers.slice(MAX_VISIBLE_PROVIDER_TILES), + [providers], + ); + + const launchTerminalAgent = useCallback( + async (providerId: AgentProvider) => { + if (!client || !isConnected || !workspaceDirectory) { + setErrorMessage(!workspaceDirectory ? "Workspace directory not found" : "Host is not connected"); + return; + } + if (!workspaceAuthority.ok) { + setErrorMessage(workspaceAuthority.message); + return; + } + const persistedWorkspaceId = requireWorkspaceRecordId(workspaceAuthority.authority.workspaceId); + + setPendingAction(providerId); + setErrorMessage(null); + + try { + const agent = await client.createAgent({ + provider: providerId, + cwd: workspaceDirectory, + workspaceId: persistedWorkspaceId, + terminal: true, + }); + recordUsage(providerId); + // Retarget first so the launcher converts in place before session reconciliation + // can materialize the new agent as a separate tab. + retargetCurrentTab({ kind: "agent", agentId: agent.id }); + setAgents(serverId, (previous) => { + const next = new Map(previous); + next.set(agent.id, normalizeAgentSnapshot(agent, serverId)); + return next; + }); + } catch (error) { + setErrorMessage(toErrorMessage(error)); + } finally { + setPendingAction((current) => (current === providerId ? null : current)); + } + }, + [ + client, + isConnected, + recordUsage, + retargetCurrentTab, + serverId, + setAgents, + workspaceAuthority, + workspaceDirectory, + ], + ); + + const openDraftTab = useCallback(() => { + setErrorMessage(null); + setPendingAction("draft"); + retargetCurrentTab({ + kind: "draft", + draftId: generateDraftId(), + }); + setPendingAction(null); + }, [retargetCurrentTab]); + + const openTerminalTab = useCallback(async () => { + if (!client || !isConnected || !workspaceDirectory) { + setErrorMessage(!workspaceDirectory ? "Workspace directory not found" : "Host is not connected"); + return; + } + + setPendingAction("terminal"); + setErrorMessage(null); + + try { + const payload = await client.createTerminal(workspaceDirectory); + if (payload.error || !payload.terminal) { + throw new Error(payload.error ?? "Failed to open terminal"); + } + retargetCurrentTab({ + kind: "terminal", + terminalId: payload.terminal.id, + }); + } catch (error) { + setErrorMessage(toErrorMessage(error)); + } finally { + setPendingAction((current) => (current === "terminal" ? null : current)); + } + }, [client, isConnected, retargetCurrentTab, workspaceDirectory]); + + const actionsDisabled = pendingAction !== null; + + if (!workspaceDirectory) { + return ( + + + + {workspaceAuthority.ok ? "Workspace execution directory not found." : workspaceAuthority.message} + + + + ); + } + + return ( + + + + + + { + void openTerminalTab(); + }} + /> + + + Terminal Agents + + + {visibleProviders.map((provider) => ( + { + void launchTerminalAgent(provider.id); + }} + /> + ))} + + {overflowProviders.length > 0 ? ( + { + void launchTerminalAgent(providerId); + }} + /> + ) : null} + + + {errorMessage ? {errorMessage} : null} + + + + ); +} + +function LauncherTile({ + title, + Icon, + accent = false, + disabled, + pending, + onPress, +}: { + title: string; + Icon: ComponentType<{ size: number; color: string }>; + accent?: boolean; + disabled: boolean; + pending: boolean; + onPress: () => void; +}) { + const { theme } = useUnistyles(); + const iconColor = accent ? theme.colors.accentForeground : theme.colors.foreground; + const titleColor = accent ? theme.colors.accentForeground : theme.colors.foreground; + + return ( + [ + styles.primaryTile, + accent ? styles.primaryTileAccent : null, + (hovered || pressed) && !disabled + ? accent + ? styles.primaryTileAccentInteractive + : styles.tileInteractive + : null, + disabled ? styles.tileDisabled : null, + ]} + > + + {pending ? ( + + ) : ( + + )} + + {title} + + ); +} + +function ProviderTile({ + provider, + disabled, + pending, + onPress, +}: { + provider: { id: string; label: string; description: string }; + disabled: boolean; + pending: boolean; + onPress: () => void; +}) { + const { theme } = useUnistyles(); + const Icon = getProviderIcon(provider.id); + + return ( + [ + styles.providerTile, + (hovered || pressed) && !disabled ? styles.tileInteractive : null, + disabled ? styles.tileDisabled : null, + ]} + > + + {pending ? ( + + ) : ( + + )} + + {provider.label} + + ); +} + +function ViewAllProvidersTile({ + providers, + disabled, + pendingProviderId, + onSelectProvider, +}: { + providers: Array<{ id: string; label: string; description: string }>; + disabled: boolean; + pendingProviderId: string | null; + onSelectProvider: (providerId: AgentProvider) => void; +}) { + const { theme } = useUnistyles(); + + return ( + + + {({ open }) => ( + <> + + + + More + + {open ? : null} + + )} + + + {providers.map((provider) => { + const Icon = getProviderIcon(provider.id); + return ( + onSelectProvider(provider.id as AgentProvider)} + leading={} + status={pendingProviderId === provider.id ? "pending" : "idle"} + pendingLabel={`Launching ${provider.label}...`} + > + {provider.label} + + ); + })} + + + ); +} + +export const launcherPanelRegistration: PanelRegistration<"launcher"> = { + kind: "launcher", + component: LauncherPanel, + useDescriptor: useLauncherPanelDescriptor, +}; + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + content: { + flexGrow: 1, + justifyContent: "center", + alignItems: "center", + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[8], + }, + loadingContent: { + flex: 1, + }, + contentUnfocused: { + opacity: 0.96, + }, + inner: { + width: "100%", + maxWidth: 360, + gap: theme.spacing[4], + }, + primaryRow: { + flexDirection: "row", + gap: theme.spacing[2], + }, + tileInteractive: { + backgroundColor: theme.colors.surface2, + }, + tileDisabled: { + opacity: theme.opacity[50], + }, + primaryTile: { + flex: 1, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + borderRadius: theme.borderRadius.lg, + borderWidth: 1, + borderColor: theme.colors.borderAccent, + backgroundColor: theme.colors.surface1, + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + }, + primaryTileAccent: { + backgroundColor: theme.colors.accent, + borderColor: theme.colors.accent, + }, + primaryTileAccentInteractive: { + backgroundColor: theme.colors.accentBright, + borderColor: theme.colors.accentBright, + }, + primaryIconWrap: { + width: 28, + height: 28, + borderRadius: theme.borderRadius.md, + alignItems: "center", + justifyContent: "center", + backgroundColor: theme.colors.surface2, + }, + primaryIconWrapAccent: { + backgroundColor: "rgba(255,255,255,0.14)", + }, + primaryTileTitle: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + }, + sectionLabel: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foregroundMuted, + textTransform: "uppercase", + letterSpacing: 0.6, + }, + providerGrid: { + flexDirection: "row", + flexWrap: "wrap", + gap: theme.spacing[2], + }, + providerTile: { + position: "relative", + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + borderRadius: theme.borderRadius.lg, + borderWidth: 1, + borderColor: theme.colors.borderAccent, + backgroundColor: theme.colors.surface1, + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + }, + providerIconWrap: { + width: 28, + height: 28, + borderRadius: theme.borderRadius.md, + alignItems: "center", + justifyContent: "center", + backgroundColor: theme.colors.surface2, + }, + providerLabel: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foreground, + }, + dropdownOutline: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + borderRadius: theme.borderRadius.lg, + borderWidth: 1, + borderColor: theme.colors.accent, + }, + errorText: { + fontSize: theme.fontSize.sm, + color: theme.colors.destructive, + }, +})); diff --git a/packages/app/src/panels/register-panels.ts b/packages/app/src/panels/register-panels.ts index 760671b65..c22fd66a2 100644 --- a/packages/app/src/panels/register-panels.ts +++ b/packages/app/src/panels/register-panels.ts @@ -1,6 +1,7 @@ import { agentPanelRegistration } from "@/panels/agent-panel"; import { draftPanelRegistration } from "@/panels/draft-panel"; import { filePanelRegistration } from "@/panels/file-panel"; +import { launcherPanelRegistration } from "@/panels/launcher-panel"; import { registerPanel } from "@/panels/panel-registry"; import { terminalPanelRegistration } from "@/panels/terminal-panel"; @@ -14,5 +15,6 @@ export function ensurePanelsRegistered(): void { registerPanel(agentPanelRegistration); registerPanel(terminalPanelRegistration); registerPanel(filePanelRegistration); + registerPanel(launcherPanelRegistration); panelsRegistered = true; } diff --git a/packages/app/src/panels/terminal-agent-panel.tsx b/packages/app/src/panels/terminal-agent-panel.tsx new file mode 100644 index 000000000..f71d73ae0 --- /dev/null +++ b/packages/app/src/panels/terminal-agent-panel.tsx @@ -0,0 +1,316 @@ +import { useIsFocused } from "@react-navigation/native"; +import { useEffect, useRef, useState } from "react"; +import { ActivityIndicator, Text, View } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import type { DaemonClient } from "@server/client/daemon-client"; +import { TerminalPane } from "@/components/terminal-pane"; +import { Fonts } from "@/constants/theme"; +import { useArchiveAgent } from "@/hooks/use-archive-agent"; +import { usePaneContext } from "@/panels/pane-context"; +import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine"; +import { + buildTerminalAgentReopenKey, + useTerminalAgentReopenStore, +} from "@/stores/terminal-agent-reopen-store"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; + +type TerminalAgentPanelProps = { + serverId: string; + client: DaemonClient; + agent: AgentScreenAgent; + isPaneFocused: boolean; +}; + +function toErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +function getTerminalExitTitle(agent: AgentScreenAgent): string { + const exitCode = agent.terminalExit?.exitCode; + const signal = agent.terminalExit?.signal; + if ( + agent.status === "error" || + (exitCode != null && exitCode !== 0) || + signal != null + ) { + return "Terminal session failed"; + } + return "Terminal session ended"; +} + +function getTerminalExitMessage(agent: AgentScreenAgent): string { + const summary = agent.terminalExit?.message?.trim(); + if (summary) { + return summary; + } + const lastError = agent.lastError?.trim(); + if (lastError) { + return lastError; + } + return "Reopen the agent from the sessions list to start it again."; +} + +function isCleanTerminalExit(agent: AgentScreenAgent): boolean { + return agent.status === "closed" && agent.terminalExit?.exitCode === 0 && agent.terminalExit.signal == null; +} + +export function TerminalAgentPanel({ + serverId, + client, + agent, + isPaneFocused, +}: TerminalAgentPanelProps) { + const isScreenFocused = useIsFocused(); + const { theme } = useUnistyles(); + const { tabId, workspaceId } = usePaneContext(); + const { archiveAgent } = useArchiveAgent(); + const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); + const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent); + const [terminalId, setTerminalId] = useState(null); + const [isCreating, setIsCreating] = useState(false); + const [createError, setCreateError] = useState(null); + const [didExitInPanel, setDidExitInPanel] = useState(false); + const reopenKey = buildTerminalAgentReopenKey({ serverId, agentId: agent.id }); + const reopenIntentVersion = useTerminalAgentReopenStore((state) => + reopenKey ? (state.reopenIntentVersionByAgentKey[reopenKey] ?? 0) : 0, + ); + + // Refs for effect guards — these values gate whether the creation effect + // should run, but changes to them should NOT re-trigger the effect. + const isCreatingRef = useRef(false); + const didExitRef = useRef(false); + const lastHandledReopenIntentRef = useRef(reopenIntentVersion); + const isAutoClosingRef = useRef(false); + + useEffect(() => { + setTerminalId(null); + setIsCreating(false); + setCreateError(null); + setDidExitInPanel(false); + isCreatingRef.current = false; + didExitRef.current = false; + }, [agent.id, serverId]); + + useEffect(() => { + if (reopenIntentVersion <= lastHandledReopenIntentRef.current) { + return; + } + + lastHandledReopenIntentRef.current = reopenIntentVersion; + if (!didExitRef.current && !didExitInPanel && !createError) { + return; + } + + didExitRef.current = false; + setDidExitInPanel(false); + setCreateError(null); + }, [createError, didExitInPanel, reopenIntentVersion]); + + useEffect(() => { + if (!terminalId) { + return; + } + return client.on("terminal_stream_exit", (message) => { + if (message.type !== "terminal_stream_exit" || message.payload.terminalId !== terminalId) { + return; + } + setTerminalId((current) => (current === message.payload.terminalId ? null : current)); + setDidExitInPanel(true); + didExitRef.current = true; + }); + }, [client, terminalId]); + + useEffect(() => { + if (!isCleanTerminalExit(agent) || isAutoClosingRef.current) { + return; + } + + const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); + if (!workspaceKey) { + return; + } + + isAutoClosingRef.current = true; + void archiveAgent({ serverId, agentId: agent.id }) + .then(() => { + unpinWorkspaceAgent(workspaceKey, agent.id); + closeWorkspaceTab(workspaceKey, tabId); + }) + .finally(() => { + isAutoClosingRef.current = false; + }); + }, [ + agent, + archiveAgent, + closeWorkspaceTab, + serverId, + tabId, + unpinWorkspaceAgent, + workspaceId, + ]); + + // Create the terminal when the panel becomes visible and no terminal exists yet. + // Guards (isCreatingRef, didExitRef) are refs to avoid re-triggering the effect + // when their values change — we only want this to fire on genuine state transitions + // (focus change, terminal cleared, agent change). + useEffect(() => { + if ( + !isScreenFocused || + !isPaneFocused || + terminalId || + isCreatingRef.current || + didExitRef.current + ) { + return; + } + + let cancelled = false; + isCreatingRef.current = true; + setIsCreating(true); + setCreateError(null); + + void client + .createTerminal(agent.cwd, undefined, undefined, { agentId: agent.id }) + .then((payload) => { + if (cancelled) { + return; + } + if (payload.error || !payload.terminal) { + setCreateError(payload.error ?? "Failed to open terminal"); + return; + } + setTerminalId(payload.terminal.id); + }) + .catch((error) => { + if (cancelled) { + return; + } + setCreateError(toErrorMessage(error)); + }) + .finally(() => { + if (!cancelled) { + isCreatingRef.current = false; + setIsCreating(false); + } + }); + + return () => { + cancelled = true; + }; + }, [agent.cwd, agent.id, client, isPaneFocused, isScreenFocused, terminalId]); + + if (!isScreenFocused) { + return ; + } + + if (terminalId) { + return ( + + ); + } + + if (isCreating) { + return ( + + + Opening terminal… + + ); + } + + if (createError) { + return ( + + Failed to open terminal + {createError} + + ); + } + + if (didExitInPanel || agent.status === "closed" || agent.status === "error") { + const terminalExit = agent.terminalExit ?? null; + const exitMeta = + terminalExit?.exitCode != null + ? `Exit code ${terminalExit.exitCode}` + : terminalExit?.signal != null + ? `Signal ${terminalExit.signal}` + : null; + return ( + + {getTerminalExitTitle(agent)} + {getTerminalExitMessage(agent)} + {terminalExit ? ( + + {exitMeta ? {exitMeta} : null} + {terminalExit.outputLines.length > 0 ? ( + {terminalExit.outputLines.join("\n")} + ) : null} + + ) : null} + Reopen the agent from the sessions list to start it again. + + ); + } + + return ( + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + state: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: theme.spacing[3], + paddingHorizontal: theme.spacing[6], + backgroundColor: theme.colors.surface0, + }, + title: { + fontSize: theme.fontSize.lg, + color: theme.colors.foreground, + textAlign: "center", + }, + message: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + textAlign: "center", + }, + detailsCard: { + width: "100%", + maxWidth: 560, + padding: theme.spacing[4], + gap: theme.spacing[2], + borderRadius: theme.spacing[3], + backgroundColor: theme.colors.surface1, + borderWidth: StyleSheet.hairlineWidth, + borderColor: theme.colors.border, + }, + detailsLabel: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + textTransform: "uppercase", + letterSpacing: 0.4, + }, + output: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + fontFamily: Fonts.mono, + lineHeight: 20, + }, +})); diff --git a/packages/app/src/panels/terminal-panel.tsx b/packages/app/src/panels/terminal-panel.tsx index 2f77fec4c..b6f9a2bf5 100644 --- a/packages/app/src/panels/terminal-panel.tsx +++ b/packages/app/src/panels/terminal-panel.tsx @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { Terminal } from "lucide-react-native"; -import { View } from "react-native"; +import { Text, View } from "react-native"; import { useIsFocused } from "@react-navigation/native"; import invariant from "tiny-invariant"; import type { ListTerminalsResponse } from "@server/shared/messages"; @@ -8,6 +8,7 @@ import { TerminalPane } from "@/components/terminal-pane"; import { usePaneContext } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import { useSessionStore } from "@/stores/session-store"; +import { getWorkspaceExecutionAuthority } from "@/utils/workspace-execution"; type ListTerminalsPayload = ListTerminalsResponse["payload"]; @@ -24,14 +25,24 @@ function useTerminalPanelDescriptor( context: { serverId: string; workspaceId: string }, ): PanelDescriptor { const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null); + const workspaces = useSessionStore((state) => state.sessions[context.serverId]?.workspaces); + const workspaceAuthority = getWorkspaceExecutionAuthority({ + workspaces, + workspaceId: context.workspaceId, + }); + const workspaceDirectory = workspaceAuthority.ok + ? workspaceAuthority.authority.workspaceDirectory + : null; const terminalsQuery = useQuery({ - queryKey: ["terminals", context.serverId, context.workspaceId] as const, - enabled: Boolean(client && context.workspaceId), + queryKey: ["terminals", context.serverId, workspaceDirectory] as const, + enabled: Boolean(client && workspaceDirectory), queryFn: async (): Promise => { - if (!client) { - return { cwd: context.workspaceId, terminals: [], requestId: "missing-client" }; + if (!client || !workspaceDirectory) { + throw new Error( + workspaceAuthority.ok ? "Workspace execution directory not found" : workspaceAuthority.message, + ); } - return client.listTerminals(context.workspaceId); + return client.listTerminals(workspaceDirectory); }, staleTime: 5_000, }); @@ -39,7 +50,7 @@ function useTerminalPanelDescriptor( terminalsQuery.data?.terminals.find((entry) => entry.id === target.terminalId) ?? null; return { - label: trimNonEmpty(terminal?.name ?? null) ?? "Terminal", + label: trimNonEmpty(terminal?.title ?? terminal?.name ?? null) ?? "Terminal", subtitle: "Terminal", titleState: "ready", icon: Terminal, @@ -50,16 +61,31 @@ function useTerminalPanelDescriptor( function TerminalPanel() { const isFocused = useIsFocused(); const { serverId, workspaceId, target, isPaneFocused } = usePaneContext(); + const workspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces); + const workspaceAuthority = getWorkspaceExecutionAuthority({ workspaces, workspaceId }); + const workspaceDirectory = workspaceAuthority.ok + ? workspaceAuthority.authority.workspaceDirectory + : null; invariant(target.kind === "terminal", "TerminalPanel requires terminal target"); if (!isFocused) { return ; } + if (!workspaceDirectory) { + return ( + + + {workspaceAuthority.ok ? "Workspace execution directory not found." : workspaceAuthority.message} + + + ); + } + return ( diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index b91bef121..a1f88849e 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -134,6 +134,7 @@ function makeFetchAgentsEntry(input: { supportsMcpServers: true, supportsReasoningStream: true, supportsToolInvocations: true, + supportsTerminalMode: false, }, currentModeId: null, availableModes: [], diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 7acd471c1..60c265d55 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -11,15 +11,14 @@ import Animated from "react-native-reanimated"; import { Folder, GitBranch, PanelRight } from "lucide-react-native"; import { SidebarMenuToggle } from "@/components/headers/menu-header"; import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; -import { AgentInputArea } from "@/components/agent-input-area"; +import { Composer } from "@/components/composer"; import { AgentStreamView } from "@/components/agent-stream-view"; import { FormSelectTrigger } from "@/components/agent-form/agent-form-dropdowns"; import { ExplorerSidebar } from "@/components/explorer-sidebar"; import { Combobox } from "@/components/ui/combobox"; import { FileDropZone } from "@/components/file-drop-zone"; import { useQuery } from "@tanstack/react-query"; -import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; -import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; +import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; import { CHECKOUT_STATUS_STALE_TIME, checkoutStatusQueryKey, @@ -27,6 +26,7 @@ import { import { useAllAgentsList } from "@/hooks/use-all-agents-list"; import { useHosts } from "@/runtime/host-runtime"; import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions"; +import { buildHostAgentDetailRoute } from "@/utils/host-routes"; import { shortenPath } from "@/utils/shorten-path"; import { collectAgentWorkingDirectorySuggestions } from "@/utils/agent-working-directory-suggestions"; import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions"; @@ -50,6 +50,7 @@ import type { AgentSessionConfig, } from "@server/server/agent/agent-sdk-types"; import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest"; +import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; @@ -65,6 +66,7 @@ const DRAFT_CAPABILITIES: AgentCapabilityFlags = { supportsMcpServers: false, supportsReasoningStream: false, supportsToolInvocations: false, + supportsTerminalMode: false, }; const PROVIDER_DEFINITION_MAP = new Map( AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]), @@ -202,37 +204,45 @@ function DraftAgentScreenContent({ return values; }, [resolvedMode, resolvedModel, resolvedProvider, resolvedThinkingOptionId, resolvedWorkingDir]); + const draftIdRef = useRef(generateDraftId()); + const draftAgentIdRef = useRef(generateDraftId()); + const draftInput = useAgentInputDraft( + { + draftKey: ({ selectedServerId }) => + buildDraftStoreKey({ + serverId: selectedServerId ?? "", + agentId: draftAgentIdRef.current, + draftId: draftIdRef.current, + }), + composer: { + initialServerId: resolvedServerId ?? null, + initialValues, + isVisible, + onlineServerIds, + }, + }, + ); + const composerState = draftInput.composerState; + if (!composerState) { + throw new Error("Draft agent composer state is required"); + } + const { selectedServerId, setSelectedServerIdFromUser, - selectedProvider, - setProviderFromUser, - selectedMode, - setModeFromUser, - selectedModel, - setModelFromUser, - selectedThinkingOptionId, - setThinkingOptionFromUser, + providerDefinitions, workingDir, setWorkingDirFromUser, - providerDefinitions, modeOptions, - availableModels, - allProviderModels, - isAllModelsLoading, - availableThinkingOptions, isModelLoading, modelError, refreshProviderModels, - setProviderAndModelFromUser, persistFormPreferences, - } = useAgentFormState({ - initialServerId: resolvedServerId ?? null, - initialValues, - isVisible, - isCreateFlow: true, - onlineServerIds, - }); + effectiveModelId, + effectiveThinkingOptionId, + commandDraftConfig, + statusControls, + } = composerState; const isMobile = isCompactFormFactor(); const mobileView = usePanelStore((state) => state.mobileView); const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); @@ -244,15 +254,6 @@ function DraftAgentScreenContent({ (state) => state.activateExplorerTabForCheckout, ); const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; - const draftIdRef = useRef(generateDraftId()); - const draftAgentIdRef = useRef(generateDraftId()); - const draftInput = useAgentInputDraft( - buildDraftStoreKey({ - serverId: selectedServerId ?? "", - agentId: draftAgentIdRef.current, - draftId: draftIdRef.current, - }), - ); const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">( initialWorktreeMode, @@ -632,6 +633,16 @@ function DraftAgentScreenContent({ isGit: isAttachWorktree && selectedWorktreePath ? true : checkout?.isGit === true, }; }, [selectedServerId, explorerCwd, isAttachWorktree, selectedWorktreePath, checkout?.isGit]); + const draftExplorerWorkspaceId = useSessionStore( + useCallback( + (state) => + resolveWorkspaceIdByExecutionDirectory({ + workspaces: selectedServerId ? state.sessions[selectedServerId]?.workspaces?.values() : null, + workspaceDirectory: explorerCwd, + }), + [explorerCwd, selectedServerId], + ), + ); const canOpenExplorer = draftExplorerCheckout !== null; const openExplorerForDraftCheckout = useCallback(() => { if (!draftExplorerCheckout) { @@ -743,47 +754,6 @@ function DraftAgentScreenContent({ }, [baseBranch, branchSearchQuery, branchSuggestionsQuery.data, checkout, worktreeOptions]); const createAgentClient = sessionClient; - const effectiveDraftModelId = useMemo(() => { - if (selectedModel.trim()) { - return selectedModel.trim(); - } - return availableModels.find((model) => model.isDefault)?.id ?? availableModels[0]?.id ?? ""; - }, [availableModels, selectedModel]); - const effectiveDraftThinkingOptionId = useMemo(() => { - if (selectedThinkingOptionId.trim()) { - return selectedThinkingOptionId.trim(); - } - const selectedModelDefinition = - availableModels.find((model) => model.id === effectiveDraftModelId) ?? null; - return selectedModelDefinition?.defaultThinkingOptionId ?? ""; - }, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]); - const draftCommandConfig = useMemo(() => { - const cwd = ( - isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir - ).trim(); - if (!cwd) { - return undefined; - } - - return { - provider: selectedProvider, - cwd, - ...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}), - ...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}), - ...(effectiveDraftThinkingOptionId - ? { thinkingOptionId: effectiveDraftThinkingOptionId } - : {}), - }; - }, [ - effectiveDraftModelId, - effectiveDraftThinkingOptionId, - isAttachWorktree, - modeOptions.length, - selectedMode, - selectedProvider, - selectedWorktreePath, - workingDir, - ]); const { formErrorMessage, @@ -791,7 +761,7 @@ function DraftAgentScreenContent({ optimisticStreamItems, draftAgent, handleCreateFromInput, - } = useDraftAgentCreateFlow({ + } = useDraftAgentCreateFlow({ draftId: draftIdRef.current, getPendingServerId: () => selectedServerId, validateBeforeSubmit: ({ text }) => { @@ -817,7 +787,7 @@ function DraftAgentScreenContent({ if (isModelLoading) { return "Model defaults are still loading"; } - if (!effectiveDraftModelId) { + if (!effectiveModelId) { return "No model is available for the selected provider"; } if (isAttachWorktree && !selectedWorktreePath) { @@ -850,15 +820,19 @@ function DraftAgentScreenContent({ const cwd = (isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() || "."; - const provider = selectedProvider; - const model = effectiveDraftModelId || null; - const thinkingOptionId = effectiveDraftThinkingOptionId || null; - const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null; + const provider = composerState.selectedProvider; + const model = effectiveModelId || null; + const thinkingOptionId = effectiveThinkingOptionId || null; + const modeId = + composerState.modeOptions.length > 0 && composerState.selectedMode !== "" + ? composerState.selectedMode + : null; return { serverId, id: draftAgentIdRef.current, provider, + terminal: false, status: "running", createdAt: now, updatedAt: now, @@ -887,14 +861,17 @@ function DraftAgentScreenContent({ const resolvedWorkingDir = isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : trimmedPath; - const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined; + const modeId = + composerState.modeOptions.length > 0 && composerState.selectedMode !== "" + ? composerState.selectedMode + : undefined; const config: AgentSessionConfig = { - provider: selectedProvider, + provider: composerState.selectedProvider, cwd: resolvedWorkingDir, ...(modeId ? { modeId } : {}), - ...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}), - ...(effectiveDraftThinkingOptionId - ? { thinkingOptionId: effectiveDraftThinkingOptionId } + ...(effectiveModelId ? { model: effectiveModelId } : {}), + ...(effectiveThinkingOptionId + ? { thinkingOptionId: effectiveThinkingOptionId } : {}), }; @@ -942,20 +919,29 @@ function DraftAgentScreenContent({ const createdWorkingDir = typeof result.cwd === "string" ? result.cwd.trim() : ""; const configuredWorkingDir = config.cwd.trim(); - const workspaceId = createdWorkingDir.length > 0 ? createdWorkingDir : configuredWorkingDir; + const workspaceId = resolveWorkspaceIdByExecutionDirectory({ + workspaces: useSessionStore.getState().sessions[selectedServerId]?.workspaces?.values(), + workspaceDirectory: createdWorkingDir.length > 0 ? createdWorkingDir : configuredWorkingDir, + }); return { agentId: result.id, result: { id: result.id, - cwd: workspaceId, + workspaceId, }, }; }, onCreateSuccess: ({ result }) => { + if (!result.workspaceId) { + router.replace( + buildHostAgentDetailRoute(selectedServerId as string, result.id) as any, + ); + return; + } const route = prepareWorkspaceTab({ serverId: selectedServerId as string, - workspaceId: result.cwd, + workspaceId: result.workspaceId, target: { kind: "agent", agentId: result.id }, }); router.replace(route as any); @@ -1216,7 +1202,7 @@ function DraftAgentScreenContent({ )} - @@ -1257,7 +1228,7 @@ function DraftAgentScreenContent({ {!isMobile && isExplorerOpen && explorerServerId && draftExplorerCheckout ? ( @@ -1280,7 +1251,7 @@ function DraftAgentScreenContent({ {isMobile && explorerServerId && draftExplorerCheckout ? ( diff --git a/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts b/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts index 63229fc11..16fb2cc3b 100644 --- a/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts +++ b/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts @@ -19,6 +19,7 @@ function makeAgent(input: { serverId: "srv", id: input.id, provider: "codex", + terminal: false, status: "idle", createdAt, updatedAt: createdAt, @@ -31,6 +32,7 @@ function makeAgent(input: { supportsMcpServers: true, supportsReasoningStream: true, supportsToolInvocations: true, + supportsTerminalMode: false, }, currentModeId: null, availableModes: [], @@ -79,7 +81,7 @@ describe("workspace agent visibility", () => { const result = deriveWorkspaceAgentVisibility({ sessionAgents, - workspaceId, + workspaceDirectory: workspaceId, }); expect(result.activeAgentIds).toEqual(new Set(["visible-agent"])); @@ -151,13 +153,33 @@ describe("workspace agent visibility", () => { const result = deriveWorkspaceAgentVisibility({ sessionAgents, - workspaceId: "/Users/moboudra/.paseo/worktrees/1luy0po7/normal-squid", + workspaceDirectory: "/Users/moboudra/.paseo/worktrees/1luy0po7/normal-squid", }); expect(result.activeAgentIds).toEqual(new Set(["slash-agent"])); expect(result.knownAgentIds.has("slash-agent")).toBe(true); }); + it("matches workspace agents using the workspace directory even when the route uses a numeric workspace id", () => { + const sessionAgents = new Map([ + [ + "terminal-agent", + makeAgent({ + id: "terminal-agent", + cwd: "/tmp/workspace-lifecycle-main", + }), + ], + ]); + + const result = deriveWorkspaceAgentVisibility({ + sessionAgents, + workspaceDirectory: "/tmp/workspace-lifecycle-main", + }); + + expect(result.activeAgentIds).toEqual(new Set(["terminal-agent"])); + expect(result.knownAgentIds).toEqual(new Set(["terminal-agent"])); + }); + describe("workspaceAgentVisibilityEqual", () => { it("returns true for identical sets", () => { const a = { activeAgentIds: new Set(["a", "b"]), knownAgentIds: new Set(["a", "b", "c"]) }; diff --git a/packages/app/src/screens/workspace/workspace-agent-visibility.ts b/packages/app/src/screens/workspace/workspace-agent-visibility.ts index 783b23a93..a15a899b3 100644 --- a/packages/app/src/screens/workspace/workspace-agent-visibility.ts +++ b/packages/app/src/screens/workspace/workspace-agent-visibility.ts @@ -12,11 +12,11 @@ export interface WorkspaceAgentVisibility { export function deriveWorkspaceAgentVisibility(input: { sessionAgents: Map | undefined; - workspaceId: string; + workspaceDirectory: string | null | undefined; }): WorkspaceAgentVisibility { - const { sessionAgents, workspaceId } = input; - const normalizedWorkspaceId = normalizeWorkspaceId(workspaceId); - if (!sessionAgents || !workspaceId) { + const { sessionAgents, workspaceDirectory } = input; + const normalizedWorkspaceDirectory = normalizeWorkspaceId(workspaceDirectory); + if (!sessionAgents || !normalizedWorkspaceDirectory) { return { activeAgentIds: new Set(), knownAgentIds: new Set(), @@ -26,7 +26,7 @@ export function deriveWorkspaceAgentVisibility(input: { const activeAgentIds = new Set(); const knownAgentIds = new Set(); for (const agent of sessionAgents.values()) { - if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceId) { + if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) { continue; } knownAgentIds.add(agent.id); diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 595be3647..b6bac5ce4 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -14,6 +14,7 @@ import { ArrowRightToLine, Columns2, Copy, + Plus, Rows2, SquarePen, SquareTerminal, @@ -46,11 +47,6 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs- const DROPDOWN_WIDTH = 220; const LOADING_TAB_LABEL_SKELETON_WIDTH = 80; -type NewTabOptionId = "__new_tab_agent__" | "__new_tab_terminal__"; -type NewTabSelection = { - optionId: NewTabOptionId; - paneId?: string; -}; export interface WorkspaceDesktopTabRowItem { tab: WorkspaceTabDescriptor; @@ -74,10 +70,8 @@ type WorkspaceDesktopTabsRowProps = { onCloseTabsToLeft: (tabId: string) => Promise | void; onCloseTabsToRight: (tabId: string) => Promise | void; onCloseOtherTabs: (tabId: string) => Promise | void; - onSelectNewTabOption: (selection: NewTabSelection) => void; - newTabAgentOptionId: NewTabOptionId; + onCreateLauncherTab: (input: { paneId?: string }) => void; onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void; - onNewTerminalTab: (input: { paneId?: string }) => void; onSplitRight: () => void; onSplitDown: () => void; externalDndContext?: boolean; @@ -87,6 +81,9 @@ type WorkspaceDesktopTabsRowProps = { }; function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string { + if (tab.target.kind === "launcher") { + return "New Tab"; + } if (tab.target.kind === "draft") { return "New Agent"; } @@ -338,10 +335,8 @@ export function WorkspaceDesktopTabsRow({ onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, - onSelectNewTabOption, - newTabAgentOptionId, + onCreateLauncherTab, onReorderTabs, - onNewTerminalTab, onSplitRight, onSplitDown, externalDndContext = false, @@ -350,8 +345,7 @@ export function WorkspaceDesktopTabsRow({ showPaneSplitActions = true, }: WorkspaceDesktopTabsRowProps) { const { theme } = useUnistyles(); - const newAgentTabKeys = useShortcutKeys("workspace-tab-new"); - const newTerminalTabKeys = useShortcutKeys("workspace-terminal-new"); + const newTabKeys = useShortcutKeys("workspace-tab-new"); const splitRightKeys = useShortcutKeys("workspace-pane-split-right"); const splitDownKeys = useShortcutKeys("workspace-pane-split-down"); const [tabsContainerWidth, setTabsContainerWidth] = useState(0); @@ -478,48 +472,22 @@ export function WorkspaceDesktopTabsRow({ - onSelectNewTabOption({ - optionId: newTabAgentOptionId, - paneId, - }) - } + testID="workspace-new-tab" + onPress={() => onCreateLauncherTab({ paneId })} accessibilityRole="button" - accessibilityLabel="New agent tab" + accessibilityLabel="New tab" style={({ hovered, pressed }) => [ styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered, ]} > - + - New agent tab - {newAgentTabKeys ? ( - - ) : null} - - - - - onNewTerminalTab({ paneId })} - accessibilityRole="button" - accessibilityLabel="New terminal tab" - style={({ hovered, pressed }) => [ - styles.newTabActionButton, - (hovered || pressed) && styles.newTabActionButtonHovered, - ]} - > - - - - - New terminal tab - {newTerminalTabKeys ? ( - + New tab + {newTabKeys ? ( + ) : null} diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-config.test.ts b/packages/app/src/screens/workspace/workspace-draft-agent-config.test.ts new file mode 100644 index 000000000..0ff6fc5b7 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-draft-agent-config.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { buildWorkspaceDraftAgentConfig } from "./workspace-draft-agent-config"; + +describe("workspace-draft-agent-config", () => { + it("builds chat-only config for workspace draft agents", () => { + expect( + buildWorkspaceDraftAgentConfig({ + provider: "codex", + cwd: "/tmp/project", + modeId: "auto", + model: "gpt-5.4", + thinkingOptionId: "high", + }), + ).toEqual({ + provider: "codex", + cwd: "/tmp/project", + modeId: "auto", + model: "gpt-5.4", + thinkingOptionId: "high", + }); + }); +}); diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-config.ts b/packages/app/src/screens/workspace/workspace-draft-agent-config.ts new file mode 100644 index 000000000..d9d566137 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-draft-agent-config.ts @@ -0,0 +1,17 @@ +import type { AgentSessionConfig } from "@server/server/agent/agent-sdk-types"; + +export function buildWorkspaceDraftAgentConfig(input: { + provider: AgentSessionConfig["provider"]; + cwd: string; + modeId?: string; + model?: string; + thinkingOptionId?: string; +}): AgentSessionConfig { + return { + provider: input.provider, + cwd: input.cwd, + ...(input.modeId ? { modeId: input.modeId } : {}), + ...(input.model ? { model: input.model } : {}), + ...(input.thinkingOptionId ? { thinkingOptionId: input.thinkingOptionId } : {}), + }; +} diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index b5a41e0e8..4647480b9 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -1,22 +1,24 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useMemo, useRef } from "react"; import { Keyboard, Platform, ScrollView, Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { AgentInputArea } from "@/components/agent-input-area"; +import invariant from "tiny-invariant"; +import { Composer } from "@/components/composer"; import { FileDropZone } from "@/components/file-drop-zone"; import { AgentStreamView } from "@/components/agent-stream-view"; import type { ImageAttachment } from "@/components/message-input"; -import { useAgentFormState } from "@/hooks/use-agent-form-state"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config"; import { buildDraftStoreKey } from "@/stores/draft-keys"; -import type { Agent } from "@/stores/session-store"; +import { type Agent, useSessionStore } from "@/stores/session-store"; import { encodeImages } from "@/utils/encode-images"; +import { + getWorkspaceExecutionAuthority, + requireWorkspaceRecordId, +} from "@/utils/workspace-execution"; import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus"; -import type { - AgentCapabilityFlags, - AgentSessionConfig, -} from "@server/server/agent/agent-sdk-types"; +import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types"; import type { AgentSnapshotPayload } from "@server/shared/messages"; const EMPTY_PENDING_PERMISSIONS = new Map(); @@ -27,6 +29,7 @@ const DRAFT_CAPABILITIES: AgentCapabilityFlags = { supportsMcpServers: false, supportsReasoningStream: false, supportsToolInvocations: false, + supportsTerminalMode: false, }; type WorkspaceDraftAgentTabProps = { @@ -50,66 +53,36 @@ export function WorkspaceDraftAgentTab({ }: WorkspaceDraftAgentTabProps) { const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); + const workspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces); + const workspaceAuthority = getWorkspaceExecutionAuthority({ workspaces, workspaceId }); + const workspaceExecutionAuthority = workspaceAuthority.ok ? workspaceAuthority.authority : null; + const workspaceDirectory = workspaceExecutionAuthority?.workspaceDirectory ?? null; const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); - const draftInput = useAgentInputDraft( - buildDraftStoreKey({ - serverId, - agentId: tabId, - draftId, - }), + const draftStoreKey = useMemo( + () => + buildDraftStoreKey({ + serverId, + agentId: tabId, + draftId, + }), + [draftId, serverId, tabId], ); - - const { - selectedProvider, - setProviderFromUser, - selectedMode, - setModeFromUser, - selectedModel, - setModelFromUser, - selectedThinkingOptionId, - setThinkingOptionFromUser, - workingDir, - setWorkingDir, - providerDefinitions, - modeOptions, - availableModels, - allProviderModels, - isAllModelsLoading, - availableThinkingOptions, - isModelLoading, - setProviderAndModelFromUser, - persistFormPreferences, - } = useAgentFormState({ - initialServerId: serverId, - initialValues: { workingDir: workspaceId }, - isVisible: true, - isCreateFlow: true, - onlineServerIds: isConnected ? [serverId] : [], - }); - - // Lock working directory to workspace. - useEffect(() => { - if (workingDir.trim() === workspaceId.trim()) { - return; - } - setWorkingDir(workspaceId); - }, [setWorkingDir, workingDir, workspaceId]); - - const effectiveDraftModelId = useMemo(() => { - if (selectedModel.trim()) { - return selectedModel.trim(); - } - return availableModels.find((model) => model.isDefault)?.id ?? availableModels[0]?.id ?? ""; - }, [availableModels, selectedModel]); - - const effectiveDraftThinkingOptionId = useMemo(() => { - if (selectedThinkingOptionId.trim()) { - return selectedThinkingOptionId.trim(); - } - const selectedModelDefinition = - availableModels.find((model) => model.id === effectiveDraftModelId) ?? null; - return selectedModelDefinition?.defaultThinkingOptionId ?? ""; - }, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]); + const draftInput = useAgentInputDraft( + { + draftKey: draftStoreKey, + composer: { + initialServerId: serverId, + initialValues: workspaceDirectory ? { workingDir: workspaceDirectory } : undefined, + isVisible: true, + onlineServerIds: isConnected ? [serverId] : [], + lockedWorkingDir: workspaceDirectory ?? undefined, + }, + }, + ); + const composerState = draftInput.composerState; + if (!composerState) { + throw new Error("Workspace draft composer state is required"); + } const { formErrorMessage, @@ -124,36 +97,44 @@ export function WorkspaceDraftAgentTab({ if (!text.trim()) { return "Initial prompt is required"; } - if (providerDefinitions.length === 0) { + if (composerState.providerDefinitions.length === 0) { return "No available providers on the selected host"; } - if (isModelLoading) { + if (composerState.isModelLoading) { return "Model defaults are still loading"; } - if (!effectiveDraftModelId) { + if (!composerState.effectiveModelId) { return "No model is available for the selected provider"; } + if (!workspaceDirectory) { + return "Workspace directory not found"; + } if (!client) { return "Host is not connected"; } return null; }, onBeforeSubmit: () => { - void persistFormPreferences(); + void composerState.persistFormPreferences(); if (Platform.OS === "web") { (document.activeElement as HTMLElement | null)?.blur?.(); } Keyboard.dismiss(); }, buildDraftAgent: (attempt) => { + invariant(workspaceDirectory, "Workspace directory is required"); const now = attempt.timestamp; - const model = effectiveDraftModelId || null; - const thinkingOptionId = effectiveDraftThinkingOptionId || null; - const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null; + const model = composerState.effectiveModelId || null; + const thinkingOptionId = composerState.effectiveThinkingOptionId || null; + const modeId = + composerState.modeOptions.length > 0 && composerState.selectedMode !== "" + ? composerState.selectedMode + : null; return { serverId, id: tabId, - provider: selectedProvider, + provider: composerState.selectedProvider, + terminal: false, status: "running", createdAt: now, updatedAt: now, @@ -164,34 +145,36 @@ export function WorkspaceDraftAgentTab({ availableModes: [], pendingPermissions: [], persistence: null, - runtimeInfo: { provider: selectedProvider, sessionId: null, model, modeId }, + runtimeInfo: { provider: composerState.selectedProvider, sessionId: null, model, modeId }, title: "Agent", - cwd: workspaceId, + cwd: workspaceDirectory, model, thinkingOptionId, labels: {}, }; }, createRequest: async ({ attempt, text, images }) => { + invariant(workspaceDirectory, "Workspace directory is required"); + invariant(workspaceExecutionAuthority, "Workspace authority is required"); if (!client) { throw new Error("Host is not connected"); } - const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined; - const config: AgentSessionConfig = { - provider: selectedProvider, - cwd: workspaceId, - ...(modeId ? { modeId } : {}), - ...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}), - ...(effectiveDraftThinkingOptionId - ? { thinkingOptionId: effectiveDraftThinkingOptionId } + const config = buildWorkspaceDraftAgentConfig({ + provider: composerState.selectedProvider, + cwd: workspaceDirectory, + ...(composerState.modeOptions.length > 0 && composerState.selectedMode !== "" + ? { modeId: composerState.selectedMode } : {}), - }; + model: composerState.effectiveModelId || undefined, + thinkingOptionId: composerState.effectiveThinkingOptionId || undefined, + }); const imagesData = await encodeImages(images); const result = await client.createAgent({ config, - initialPrompt: text, + workspaceId: requireWorkspaceRecordId(workspaceExecutionAuthority.workspaceId), + ...(text ? { initialPrompt: text } : {}), clientMessageId: attempt.clientMessageId, ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), }); @@ -206,25 +189,6 @@ export function WorkspaceDraftAgentTab({ }, }); - const draftCommandConfig = useMemo(() => { - return { - provider: selectedProvider, - cwd: workspaceId, - ...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}), - ...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}), - ...(effectiveDraftThinkingOptionId - ? { thinkingOptionId: effectiveDraftThinkingOptionId } - : {}), - }; - }, [ - effectiveDraftModelId, - effectiveDraftThinkingOptionId, - modeOptions.length, - selectedMode, - selectedProvider, - workspaceId, - ]); - const handleFilesDropped = useCallback((files: ImageAttachment[]) => { addImagesRef.current?.(files); }, []); @@ -265,11 +229,12 @@ export function WorkspaceDraftAgentTab({ - diff --git a/packages/app/src/screens/workspace/workspace-header-source.ts b/packages/app/src/screens/workspace/workspace-header-source.ts index 4316f103b..3c9029110 100644 --- a/packages/app/src/screens/workspace/workspace-header-source.ts +++ b/packages/app/src/screens/workspace/workspace-header-source.ts @@ -1,5 +1,4 @@ import type { WorkspaceDescriptor } from "@/stores/session-store"; -import { projectDisplayNameFromProjectId } from "@/utils/project-display-name"; export function resolveWorkspaceHeader(input: { workspace: WorkspaceDescriptor }): { title: string; @@ -7,7 +6,7 @@ export function resolveWorkspaceHeader(input: { workspace: WorkspaceDescriptor } } { return { title: input.workspace.name, - subtitle: projectDisplayNameFromProjectId(input.workspace.projectId), + subtitle: input.workspace.projectDisplayName, }; } diff --git a/packages/app/src/screens/workspace/workspace-pane-content.tsx b/packages/app/src/screens/workspace/workspace-pane-content.tsx index 2260ae10d..230cfb061 100644 --- a/packages/app/src/screens/workspace/workspace-pane-content.tsx +++ b/packages/app/src/screens/workspace/workspace-pane-content.tsx @@ -36,7 +36,7 @@ export function buildWorkspacePaneContentModel({ const registration = getPanelRegistration(tab.kind); invariant(registration, `No panel registration for kind: ${tab.kind}`); return { - key: `${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}`, + key: `${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}:${tab.kind}`, Component: registration.component, paneContextValue: { serverId: normalizedServerId, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 3e5722964..c0667d051 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -62,8 +62,6 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes"; -import { isAbsolutePath } from "@/utils/path"; -import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity"; import { normalizeWorkspaceTabTarget, workspaceTabTargetsEqual, @@ -81,6 +79,10 @@ import { applyArchivedAgentCloseResults, useArchiveAgent } from "@/hooks/use-arc import { useStableEvent } from "@/hooks/use-stable-event"; import { buildProviderCommand } from "@/utils/provider-command-templates"; import { generateDraftId } from "@/stores/draft-keys"; +import { + resolveWorkspaceExecutionAuthority, + resolveWorkspaceRouteId, +} from "@/utils/workspace-execution"; import { WorkspaceTabPresentationResolver, WorkspaceTabIcon, @@ -98,7 +100,6 @@ import { } from "@/screens/workspace/workspace-header-source"; import { deriveWorkspaceAgentVisibility, - shouldPruneWorkspaceAgentTab, workspaceAgentVisibilityEqual, } from "@/screens/workspace/workspace-agent-visibility"; import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state"; @@ -117,11 +118,7 @@ import { findAdjacentPane } from "@/utils/split-navigation"; import { isCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout"; const TERMINALS_QUERY_STALE_TIME = 5_000; -const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__"; -const NEW_TAB_TERMINAL_OPTION_ID = "__new_tab_terminal__"; -type NewTabOptionId = typeof NEW_TAB_AGENT_OPTION_ID | typeof NEW_TAB_TERMINAL_OPTION_ID; const EMPTY_UI_TABS: WorkspaceTab[] = []; -const EMPTY_PINNED_AGENT_IDS = new Set(); const EMPTY_SET = new Set(); type WorkspaceScreenProps = { @@ -146,6 +143,9 @@ function decodeSegment(value: string): string { } function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string { + if (tab.target.kind === "launcher") { + return "New Tab"; + } if (tab.target.kind === "draft") { return "New Agent"; } @@ -159,6 +159,9 @@ function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string { } function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string { + if (tab.target.kind === "launcher") { + return "New Tab"; + } if (tab.target.kind === "draft") { return "New Agent"; } @@ -585,7 +588,13 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? ""; const normalizedWorkspaceId = - normalizeWorkspaceIdentity(decodeWorkspaceIdFromPathSegment(workspaceId)) ?? ""; + resolveWorkspaceRouteId({ + routeWorkspaceId: decodeWorkspaceIdFromPathSegment(workspaceId), + }) ?? ""; + const sessionWorkspaces = useSessionStore( + (state) => state.sessions[normalizedServerId]?.workspaces, + ); + const workspaceTerminalScopeKey = normalizedServerId && normalizedWorkspaceId ? `${normalizedServerId}:${normalizedWorkspaceId}` @@ -597,43 +606,53 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) const queryClient = useQueryClient(); const client = useHostRuntimeClient(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId); + const workspaceDescriptor = sessionWorkspaces?.get(normalizedWorkspaceId) ?? null; + const workspaceAuthority = useMemo( + () => + resolveWorkspaceExecutionAuthority({ + workspaces: sessionWorkspaces, + workspaceId: normalizedWorkspaceId, + }), + [normalizedWorkspaceId, sessionWorkspaces], + ); + const workspaceDirectory = workspaceAuthority?.workspaceDirectory ?? null; + const isMissingWorkspaceExecutionAuthority = Boolean(workspaceDescriptor && !workspaceAuthority); const workspaceAgentVisibility = useStoreWithEqualityFn( useSessionStore, (state) => deriveWorkspaceAgentVisibility({ sessionAgents: state.sessions[normalizedServerId]?.agents, - workspaceId: normalizedWorkspaceId, + workspaceDirectory, }), workspaceAgentVisibilityEqual, ); const terminalsQueryKey = useMemo( - () => ["terminals", normalizedServerId, normalizedWorkspaceId] as const, - [normalizedServerId, normalizedWorkspaceId], + () => ["terminals", normalizedServerId, workspaceDirectory] as const, + [normalizedServerId, workspaceDirectory], ); type ListTerminalsPayload = ListTerminalsResponse["payload"]; const terminalsQuery = useQuery({ queryKey: terminalsQueryKey, enabled: Boolean(client && isConnected) && - normalizedWorkspaceId.length > 0 && - isAbsolutePath(normalizedWorkspaceId), + Boolean(workspaceDirectory), queryFn: async () => { - if (!client) { + if (!client || !workspaceDirectory) { throw new Error("Host is not connected"); } - return await client.listTerminals(normalizedWorkspaceId); + return await client.listTerminals(workspaceDirectory); }, staleTime: TERMINALS_QUERY_STALE_TIME, }); const terminals = terminalsQuery.data?.terminals ?? []; const createTerminalMutation = useMutation({ mutationFn: async (input?: { paneId?: string }) => { - if (!client) { + if (!client || !workspaceDirectory) { throw new Error("Host is not connected"); } - return await client.createTerminal(normalizedWorkspaceId); + return await client.createTerminal(workspaceDirectory); }, onSuccess: (payload, input) => { const createdTerminal = payload.terminal; @@ -643,8 +662,9 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) terminals: current?.terminals ?? [], terminal: createdTerminal, }); + const cwd = current?.cwd ?? workspaceDirectory; return { - cwd: current?.cwd ?? normalizedWorkspaceId, + ...(cwd ? { cwd } : {}), terminals: nextTerminals, requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`, }; @@ -687,7 +707,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) const { archiveAgent } = useArchiveAgent(); useEffect(() => { - if (!client || !isConnected || !isAbsolutePath(normalizedWorkspaceId)) { + if (!client || !isConnected || !workspaceDirectory) { return; } @@ -695,7 +715,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) if (message.type !== "terminals_changed") { return; } - if (message.payload.cwd !== normalizedWorkspaceId) { + if (message.payload.cwd !== workspaceDirectory) { return; } @@ -706,32 +726,30 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) })); }); - client.subscribeTerminals({ cwd: normalizedWorkspaceId }); + client.subscribeTerminals({ cwd: workspaceDirectory }); return () => { unsubscribeChanged(); - client.unsubscribeTerminals({ cwd: normalizedWorkspaceId }); + client.unsubscribeTerminals({ cwd: workspaceDirectory }); }; - }, [client, isConnected, normalizedWorkspaceId, queryClient, terminalsQueryKey]); + }, [client, isConnected, queryClient, terminalsQueryKey, workspaceDirectory]); const checkoutQuery = useQuery({ - queryKey: checkoutStatusQueryKey(normalizedServerId, normalizedWorkspaceId), + queryKey: checkoutStatusQueryKey( + normalizedServerId, + workspaceDirectory ?? `missing-workspace-directory:${normalizedWorkspaceId}`, + ), enabled: Boolean(client && isConnected) && - normalizedWorkspaceId.length > 0 && - isAbsolutePath(normalizedWorkspaceId), + Boolean(workspaceDirectory), queryFn: async () => { - if (!client) { + if (!client || !workspaceDirectory) { throw new Error("Host is not connected"); } - return (await client.getCheckoutStatus(normalizedWorkspaceId)) as CheckoutStatusPayload; + return (await client.getCheckoutStatus(workspaceDirectory)) as CheckoutStatusPayload; }, staleTime: 15_000, }); - - const workspaceDescriptor = useSessionStore( - (state) => state.sessions[normalizedServerId]?.workspaces.get(normalizedWorkspaceId) ?? null, - ); const hasHydratedWorkspaces = useSessionStore( (state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false, ); @@ -761,15 +779,15 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; const activeExplorerCheckout = useMemo(() => { - if (!normalizedServerId || !isAbsolutePath(normalizedWorkspaceId)) { + if (!normalizedServerId || !workspaceDirectory) { return null; } return { serverId: normalizedServerId, - cwd: normalizedWorkspaceId, + cwd: workspaceDirectory, isGit: isGitCheckout, }; - }, [isGitCheckout, normalizedServerId, normalizedWorkspaceId]); + }, [isGitCheckout, normalizedServerId, workspaceDirectory]); useEffect(() => { setActiveExplorerCheckout(activeExplorerCheckout); @@ -829,10 +847,13 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) [workspaceLayout], ); const openWorkspaceTab = useWorkspaceLayoutStore((state) => state.openTab); + const openWorkspaceLauncherTab = useWorkspaceLayoutStore((state) => state.openLauncherTab); const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab); const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); - const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent); const retargetWorkspaceTab = useWorkspaceLayoutStore((state) => state.retargetTab); + const convertWorkspaceDraftToAgent = useWorkspaceLayoutStore((state) => state.convertDraftToAgent); + const reconcileWorkspaceTabs = useWorkspaceLayoutStore((state) => state.reconcileTabs); + const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent); const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane); const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty); const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane); @@ -840,11 +861,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) const paneFocusSuppressedRef = useRef(false); const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit); const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane); - const pinnedAgentIds = useWorkspaceLayoutStore((state) => - persistenceKey - ? (state.pinnedAgentIdsByWorkspace[persistenceKey] ?? EMPTY_PINNED_AGENT_IDS) - : EMPTY_PINNED_AGENT_IDS, - ); const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId); const { closingTabIds, closeTab } = useCloseTabs(); const closeWorkspaceTabWithCleanup = useCallback( @@ -948,7 +964,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) return; } - const terminalIds = new Set(terminals.map((terminal) => terminal.id)); const hasActivePendingDraftCreateInWorkspace = uiTabs.some((tab) => { if (tab.target.kind !== "draft") { return false; @@ -957,56 +972,19 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) return pending?.serverId === normalizedServerId && pending.lifecycle === "active"; }); - for (const agentId of workspaceAgentVisibility.activeAgentIds) { - const representedByTarget = uiTabs.some( - (tab) => tab.target.kind === "agent" && tab.target.agentId === agentId, - ); - const representedByDeterministicTabId = uiTabs.some( - (tab) => tab.tabId === `agent_${agentId}`, - ); - if ( - hasActivePendingDraftCreateInWorkspace && - !representedByTarget && - !representedByDeterministicTabId - ) { - continue; - } - ensureWorkspaceTab({ kind: "agent", agentId }); - } - for (const terminal of terminals) { - ensureWorkspaceTab({ kind: "terminal", terminalId: terminal.id }); - } - - const canPruneAgentTabs = hasHydratedAgents; - const canPruneTerminalTabs = terminalsQuery.isSuccess; - for (const tab of uiTabs) { - if ( - canPruneAgentTabs && - tab.target.kind === "agent" && - shouldPruneWorkspaceAgentTab({ - agentId: tab.target.agentId, - agentsHydrated: hasHydratedAgents, - knownAgentIds: workspaceAgentVisibility.knownAgentIds, - activeAgentIds: workspaceAgentVisibility.activeAgentIds, - }) - ) { - closeWorkspaceTabWithCleanup({ tabId: tab.tabId, target: tab.target }); - } - if ( - canPruneTerminalTabs && - tab.target.kind === "terminal" && - !terminalIds.has(tab.target.terminalId) - ) { - closeWorkspaceTabWithCleanup({ tabId: tab.tabId, target: tab.target }); - } - } + reconcileWorkspaceTabs(persistenceKey, { + agentsHydrated: hasHydratedAgents, + terminalsHydrated: terminalsQuery.isSuccess, + activeAgentIds: workspaceAgentVisibility.activeAgentIds, + knownAgentIds: workspaceAgentVisibility.knownAgentIds, + standaloneTerminalIds: terminals.map((terminal) => terminal.id), + hasActivePendingDraftCreate: hasActivePendingDraftCreateInWorkspace, + }); }, [ - closeWorkspaceTabWithCleanup, - ensureWorkspaceTab, hasHydratedAgents, pendingByDraftId, - pinnedAgentIds, persistenceKey, + reconcileWorkspaceTabs, terminals, terminalsQuery.isSuccess, uiTabs, @@ -1016,13 +994,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) const activeTabId = focusedPaneTabState.activeTabId; const activeTab = focusedPaneTabState.activeTab; - useEffect(() => { - if (!activeTabId || !persistenceKey) { - return; - } - focusWorkspaceTab(persistenceKey, activeTabId); - }, [activeTabId, focusWorkspaceTab, persistenceKey]); - const tabs = useMemo( () => focusedPaneTabState.tabs.map((tab) => tab.descriptor), [focusedPaneTabState.tabs], @@ -1134,17 +1105,36 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) openWorkspaceDraftTab(); }, [openWorkspaceDraftTab]); + const handleCreateLauncherTab = useCallback( + (input?: { paneId?: string }) => { + if (!persistenceKey) { + return null; + } + + if (input?.paneId) { + focusWorkspacePane(persistenceKey, input.paneId); + } + + const tabId = openWorkspaceLauncherTab(persistenceKey); + if (tabId) { + focusWorkspaceTab(persistenceKey, tabId); + } + return tabId; + }, + [focusWorkspacePane, focusWorkspaceTab, openWorkspaceLauncherTab, persistenceKey], + ); + const handleCreateTerminal = useCallback( (input?: { paneId?: string }) => { if (createTerminalMutation.isPending) { return; } - if (!isAbsolutePath(normalizedWorkspaceId)) { + if (!workspaceDirectory) { return; } createTerminalMutation.mutate(input); }, - [createTerminalMutation, normalizedWorkspaceId], + [createTerminalMutation, workspaceDirectory], ); const handleSelectSwitcherTab = useCallback( @@ -1154,21 +1144,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) [navigateToTabId], ); - const handleSelectNewTabOption = useCallback( - (selection: { optionId: NewTabOptionId; paneId?: string }) => { - if (selection.paneId && persistenceKey) { - focusWorkspacePane(persistenceKey, selection.paneId); - } - if (selection.optionId === NEW_TAB_AGENT_OPTION_ID) { - handleCreateDraftTab(); - } else if (selection.optionId === NEW_TAB_TERMINAL_OPTION_ID) { - handleCreateTerminal({ paneId: selection.paneId }); - } - }, - [focusWorkspacePane, handleCreateDraftTab, handleCreateTerminal, persistenceKey], - ); - - const handleCreateDraftSplit = useCallback( + const handleCreateLauncherSplit = useCallback( (input: { targetPaneId: string; position: "left" | "right" | "top" | "bottom" }) => { if (!persistenceKey) { return; @@ -1179,10 +1155,9 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) return; } - focusWorkspacePane(persistenceKey, paneId); - openWorkspaceDraftTab(); + handleCreateLauncherTab({ paneId }); }, - [focusWorkspacePane, openWorkspaceDraftTab, persistenceKey, splitWorkspacePaneEmpty], + [handleCreateLauncherTab, persistenceKey, splitWorkspacePaneEmpty], ); const killTerminalAsync = killTerminalMutation.mutateAsync; @@ -1297,29 +1272,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) [allTabDescriptorsById, handleCloseAgentTab, handleCloseDraftOrFileTab, handleCloseTerminalTab], ); - const prevCloseTabDeps = useRef({ - allTabDescriptorsById, - handleCloseAgentTab, - handleCloseDraftOrFileTab, - handleCloseTerminalTab, - }); - useEffect(() => { - const prev = prevCloseTabDeps.current; - const changed: string[] = []; - if (prev.allTabDescriptorsById !== allTabDescriptorsById) changed.push("allTabDescriptorsById"); - if (prev.handleCloseAgentTab !== handleCloseAgentTab) changed.push("handleCloseAgentTab"); - if (prev.handleCloseDraftOrFileTab !== handleCloseDraftOrFileTab) - changed.push("handleCloseDraftOrFileTab"); - if (prev.handleCloseTerminalTab !== handleCloseTerminalTab) - changed.push("handleCloseTerminalTab"); - if (changed.length > 0) console.log("[handleCloseTabById] deps changed:", changed.join(", ")); - prevCloseTabDeps.current = { - allTabDescriptorsById, - handleCloseAgentTab, - handleCloseDraftOrFileTab, - handleCloseTerminalTab, - }; - }); const handleCopyAgentId = useCallback( async (agentId: string) => { if (!agentId) return; @@ -1366,18 +1318,18 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) ); const handleCopyWorkspacePath = useCallback(async () => { - if (!isAbsolutePath(normalizedWorkspaceId)) { + if (!workspaceDirectory) { toast.error("Workspace path not available"); return; } try { - await Clipboard.setStringAsync(normalizedWorkspaceId); + await Clipboard.setStringAsync(workspaceDirectory); toast.copied("Workspace path"); } catch { toast.error("Copy failed"); } - }, [normalizedWorkspaceId, toast]); + }, [toast, workspaceDirectory]); const handleCopyBranchName = useCallback(async () => { if (!currentBranchName) { @@ -1535,7 +1487,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) (action: KeyboardActionDefinition): boolean => { switch (action.id) { case "workspace.tab.new": - handleCreateDraftTab(); + handleCreateLauncherTab(); return true; case "workspace.terminal.new": handleCreateTerminal(); @@ -1568,7 +1520,14 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) return false; } }, - [activeTabId, handleCloseTabById, handleCreateDraftTab, handleCreateTerminal, navigateToTabId, tabs], + [ + activeTabId, + handleCloseTabById, + handleCreateLauncherTab, + handleCreateTerminal, + navigateToTabId, + tabs, + ], ); const handleWorkspacePaneAction = useCallback( @@ -1583,7 +1542,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) } if (action.id === "workspace.pane.split.right") { - handleCreateDraftSplit({ + handleCreateLauncherSplit({ targetPaneId: focusedPane.id, position: "right", }); @@ -1591,7 +1550,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) } if (action.id === "workspace.pane.split.down") { - handleCreateDraftSplit({ + handleCreateLauncherSplit({ targetPaneId: focusedPane.id, position: "bottom", }); @@ -1661,7 +1620,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) allTabDescriptorsById, closeWorkspaceTabWithCleanup, focusWorkspacePane, - handleCreateDraftSplit, + handleCreateLauncherSplit, moveWorkspaceTabToPane, persistenceKey, focusedPaneTabState.activeTabId, @@ -1746,6 +1705,10 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) if (!persistenceKey) { return; } + if (input.tab.kind === "draft" && target.kind === "agent") { + convertWorkspaceDraftToAgent(persistenceKey, input.tab.tabId, target.agentId); + return; + } retargetWorkspaceTab(persistenceKey, input.tab.tabId, target); }, onOpenWorkspaceFile: (filePath) => { @@ -1764,47 +1727,10 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) normalizedWorkspaceId, openWorkspaceTab, persistenceKey, + convertWorkspaceDraftToAgent, retargetWorkspaceTab, ], ); - const prevBuildDeps = useRef({ - handleCloseTabById, - handleOpenFileFromChat, - focusWorkspacePane, - navigateToTabId, - normalizedServerId, - normalizedWorkspaceId, - openWorkspaceTab, - persistenceKey, - retargetWorkspaceTab, - }); - useEffect(() => { - const prev = prevBuildDeps.current; - const changed: string[] = []; - if (prev.handleCloseTabById !== handleCloseTabById) changed.push("handleCloseTabById"); - if (prev.handleOpenFileFromChat !== handleOpenFileFromChat) - changed.push("handleOpenFileFromChat"); - if (prev.focusWorkspacePane !== focusWorkspacePane) changed.push("focusWorkspacePane"); - if (prev.navigateToTabId !== navigateToTabId) changed.push("navigateToTabId"); - if (prev.normalizedServerId !== normalizedServerId) changed.push("normalizedServerId"); - if (prev.normalizedWorkspaceId !== normalizedWorkspaceId) changed.push("normalizedWorkspaceId"); - if (prev.openWorkspaceTab !== openWorkspaceTab) changed.push("openWorkspaceTab"); - if (prev.persistenceKey !== persistenceKey) changed.push("persistenceKey"); - if (prev.retargetWorkspaceTab !== retargetWorkspaceTab) changed.push("retargetWorkspaceTab"); - if (changed.length > 0) - console.log("[buildPaneContentModel] deps changed:", changed.join(", ")); - prevBuildDeps.current = { - handleCloseTabById, - handleOpenFileFromChat, - focusWorkspacePane, - navigateToTabId, - normalizedServerId, - normalizedWorkspaceId, - openWorkspaceTab, - persistenceKey, - retargetWorkspaceTab, - }; - }); const focusedPaneId = focusedPaneTabState.pane?.id ?? null; const focusedPaneTabIds = useMemo(() => tabs.map((tab) => tab.tabId), [tabs]); const focusedPaneTabDescriptorMap = useStableTabDescriptorMap(tabs); @@ -1839,6 +1765,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) + ) : isMissingWorkspaceExecutionAuthority ? ( + + + Workspace execution directory is missing. Reload workspace data before opening tabs. + + ) : !activeTabDescriptor ? ( !hasHydratedAgents ? ( @@ -2057,7 +1989,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) } - disabled={!isAbsolutePath(normalizedWorkspaceId)} + disabled={!workspaceDirectory} onSelect={handleCopyWorkspacePath} > Copy workspace path @@ -2080,10 +2012,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) {!isMobile && isGitCheckout ? ( <> - + {workspaceDirectory ? ( + + ) : null} {}} onSplitDown={() => {}} showPaneSplitActions={false} @@ -2269,13 +2201,11 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) onCloseTabsToLeft={handleCloseTabsToLeftInPane} onCloseTabsToRight={handleCloseTabsToRightInPane} onCloseOtherTabs={handleCloseOtherTabsInPane} - onSelectNewTabOption={handleSelectNewTabOption} - newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID} + onCreateLauncherTab={handleCreateLauncherTab} buildPaneContentModel={buildDesktopPaneContentModel} onFocusPane={handleFocusPane} - onNewTerminalTab={handleCreateTerminal} onSplitPane={handleSplitPane} - onSplitPaneEmpty={handleCreateDraftSplit} + onSplitPaneEmpty={handleCreateLauncherSplit} onMoveTabToPane={handleMoveTabToPane} onResizeSplit={handleResizePaneSplit} onReorderTabsInPane={handleReorderTabsInPane} @@ -2290,13 +2220,15 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) {(!isFocusModeEnabled || isMobile) && ( - + workspaceDirectory ? ( + + ) : null )} diff --git a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts index 84562c96d..1ca625126 100644 --- a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts +++ b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts @@ -13,8 +13,9 @@ describe("workspace source of truth consumption", () => { projectId: "remote:github.com/getpaseo/paseo", projectDisplayName: "getpaseo/paseo", projectRootPath: "/repo/main", + workspaceDirectory: "/repo/main", projectKind: "git", - workspaceKind: "local_checkout", + workspaceKind: "checkout", name: "feat/workspace-sot", status: "running", activityAt: new Date("2026-03-01T00:00:00.000Z"), diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts index fea7255f4..bfdb89713 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -78,6 +78,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "draft") { return `workspace-draft-close-${tab.target.draftId}`; } + if (tab.target.kind === "launcher") { + return `workspace-launcher-close-${tab.target.launcherId}`; + } return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`; } diff --git a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx index ba7c7dfa9..cccc95e27 100644 --- a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx +++ b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx @@ -44,7 +44,7 @@ export function WorkspaceTabPresentationResolver({ return ( { + const storage = new Map(); + return { + default: { + getItem: vi.fn(async (key: string) => storage.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + storage.set(key, value); + }), + removeItem: vi.fn(async (key: string) => { + storage.delete(key); + }), + }, + }; +}); + +import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest"; +import { + __providerRecencyStoreTestUtils, + sortProvidersByRecency, + useProviderRecencyStore, +} from "./provider-recency-store"; + +describe("provider-recency-store", () => { + beforeEach(() => { + useProviderRecencyStore.setState({ + recentProviderIds: [], + recordUsage: useProviderRecencyStore.getState().recordUsage, + }); + }); + + it("sorts used providers first and keeps unused providers in default order", () => { + const sorted = sortProvidersByRecency(AGENT_PROVIDER_DEFINITIONS, ["codex"]); + + expect(sorted.map((provider) => provider.id)).toEqual(["codex", "claude", "opencode"]); + }); + + it("moves the latest provider to the front without duplicating prior entries", () => { + useProviderRecencyStore.getState().recordUsage("codex"); + useProviderRecencyStore.getState().recordUsage("opencode"); + useProviderRecencyStore.getState().recordUsage("codex"); + + expect(useProviderRecencyStore.getState().recentProviderIds).toEqual([ + "codex", + "opencode", + ]); + }); + + it("filters invalid and duplicate providers during migration", () => { + expect( + __providerRecencyStoreTestUtils.migratePersistedState({ + recentProviderIds: ["codex", "invalid", "codex", "claude"], + }), + ).toEqual({ + recentProviderIds: ["codex", "claude"], + }); + }); +}); diff --git a/packages/app/src/stores/provider-recency-store.ts b/packages/app/src/stores/provider-recency-store.ts new file mode 100644 index 000000000..d7014deaa --- /dev/null +++ b/packages/app/src/stores/provider-recency-store.ts @@ -0,0 +1,129 @@ +import { useMemo } from "react"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; +import { + AGENT_PROVIDER_DEFINITIONS, + isValidAgentProvider, + type AgentProviderDefinition, +} from "@server/server/agent/provider-manifest"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +const PROVIDER_RECENCY_STORE_VERSION = 1; + +interface ProviderRecencyStoreState { + recentProviderIds: AgentProvider[]; + recordUsage: (providerId: AgentProvider) => void; +} + +function sanitizeRecentProviderIds(providerIds: readonly string[] | undefined): AgentProvider[] { + if (!providerIds || providerIds.length === 0) { + return []; + } + + const seen = new Set(); + const sanitized: AgentProvider[] = []; + for (const providerId of providerIds) { + if (!isValidAgentProvider(providerId)) { + continue; + } + if (seen.has(providerId)) { + continue; + } + seen.add(providerId); + sanitized.push(providerId); + } + return sanitized; +} + +export function sortProvidersByRecency( + providers: readonly T[], + recentProviderIds: readonly string[], +): T[] { + if (providers.length <= 1) { + return [...providers]; + } + + const recentRank = new Map(); + for (const providerId of recentProviderIds) { + if (recentRank.has(providerId)) { + continue; + } + recentRank.set(providerId, recentRank.size); + } + + return providers + .map((provider, defaultIndex) => ({ + provider, + defaultIndex, + recentIndex: recentRank.get(provider.id) ?? Number.POSITIVE_INFINITY, + })) + .sort((left, right) => { + if (left.recentIndex !== right.recentIndex) { + return left.recentIndex - right.recentIndex; + } + return left.defaultIndex - right.defaultIndex; + }) + .map((entry) => entry.provider); +} + +function migratePersistedState(state: unknown): Pick { + const record = state as { recentProviderIds?: string[] } | null | undefined; + return { + recentProviderIds: sanitizeRecentProviderIds(record?.recentProviderIds), + }; +} + +export const useProviderRecencyStore = create()( + persist( + (set) => ({ + recentProviderIds: [], + recordUsage: (providerId) => { + if (!isValidAgentProvider(providerId)) { + return; + } + + set((state) => ({ + recentProviderIds: [ + providerId, + ...state.recentProviderIds.filter((id) => id !== providerId), + ], + })); + }, + }), + { + name: "terminal-agent-provider-recency", + version: PROVIDER_RECENCY_STORE_VERSION, + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ + recentProviderIds: state.recentProviderIds, + }), + migrate: (persistedState) => migratePersistedState(persistedState), + }, + ), +); + +export function useProviderRecency( + availableProviders: readonly AgentProviderDefinition[] = AGENT_PROVIDER_DEFINITIONS, +): { + providers: AgentProviderDefinition[]; + recordUsage: (providerId: AgentProvider) => void; +} { + const recentProviderIds = useProviderRecencyStore((state) => state.recentProviderIds); + const recordUsage = useProviderRecencyStore((state) => state.recordUsage); + + const providers = useMemo( + () => sortProvidersByRecency(availableProviders, recentProviderIds), + [availableProviders, recentProviderIds], + ); + + return { + providers, + recordUsage, + }; +} + +export const __providerRecencyStoreTestUtils = { + migratePersistedState, + sanitizeRecentProviderIds, +}; diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 0646658cb..ecc252603 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -22,6 +22,7 @@ import type { GitSetupOptions, ProjectPlacementPayload, ServerCapabilities, + AgentSnapshotPayload, WorkspaceDescriptorPayload, } from "@server/shared/messages"; import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity"; @@ -79,10 +80,13 @@ export interface AgentRuntimeInfo { extra?: Record; } +type TerminalExitDetails = NonNullable; + export interface Agent { serverId: string; id: string; provider: AgentProvider; + terminal: boolean; status: AgentLifecycleStatus; createdAt: Date; updatedAt: Date; @@ -96,6 +100,7 @@ export interface Agent { runtimeInfo?: AgentRuntimeInfo; lastUsage?: AgentUsage; lastError?: string | null; + terminalExit?: TerminalExitDetails | null; title: string | null; cwd: string; model: string | null; @@ -113,6 +118,7 @@ export interface WorkspaceDescriptor { projectId: string; projectDisplayName: string; projectRootPath: string; + workspaceDirectory: string; projectKind: WorkspaceDescriptorPayload["projectKind"]; workspaceKind: WorkspaceDescriptorPayload["workspaceKind"]; name: string; @@ -126,10 +132,11 @@ export function normalizeWorkspaceDescriptor( ): WorkspaceDescriptor { const activityAt = payload.activityAt ? new Date(payload.activityAt) : null; return { - id: normalizeWorkspaceIdentity(payload.id) ?? payload.id, - projectId: payload.projectId, + id: normalizeWorkspaceIdentity(String(payload.id)) ?? String(payload.id), + projectId: String(payload.projectId), projectDisplayName: payload.projectDisplayName, projectRootPath: payload.projectRootPath, + workspaceDirectory: payload.workspaceDirectory, projectKind: payload.projectKind, workspaceKind: payload.workspaceKind, name: payload.name, @@ -191,7 +198,6 @@ export type DaemonServerInfo = { }; export interface AgentTimelineCursorState { - epoch: string; startSeq: number; endSeq: number; } @@ -1119,6 +1125,7 @@ export const useSessionStore = create()( id: agent.id, serverId, title: agent.title ?? null, + terminal: agent.terminal, status: agent.status, lastActivityAt, cwd: agent.cwd, diff --git a/packages/app/src/stores/terminal-agent-reopen-store.ts b/packages/app/src/stores/terminal-agent-reopen-store.ts new file mode 100644 index 000000000..63001a73b --- /dev/null +++ b/packages/app/src/stores/terminal-agent-reopen-store.ts @@ -0,0 +1,52 @@ +import { create } from "zustand"; + +interface BuildTerminalAgentReopenKeyInput { + serverId: string; + agentId: string; +} + +interface RequestTerminalAgentReopenInput { + serverId: string; + agentId: string; +} + +interface TerminalAgentReopenStore { + reopenIntentVersionByAgentKey: Record; + requestReopen: (input: RequestTerminalAgentReopenInput) => void; +} + +function trimNonEmpty(value: string | null | undefined): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function buildTerminalAgentReopenKey( + input: BuildTerminalAgentReopenKeyInput, +): string | null { + const serverId = trimNonEmpty(input.serverId); + const agentId = trimNonEmpty(input.agentId); + if (!serverId || !agentId) { + return null; + } + return `${serverId}:${agentId}`; +} + +export const useTerminalAgentReopenStore = create()((set) => ({ + reopenIntentVersionByAgentKey: {}, + requestReopen: ({ serverId, agentId }) => { + const key = buildTerminalAgentReopenKey({ serverId, agentId }); + if (!key) { + return; + } + + set((state) => ({ + reopenIntentVersionByAgentKey: { + ...state.reopenIntentVersionByAgentKey, + [key]: (state.reopenIntentVersionByAgentKey[key] ?? 0) + 1, + }, + })); + }, +})); diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index efe9db64f..8aa64b141 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -2,6 +2,7 @@ import invariant from "tiny-invariant"; import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import { buildDeterministicWorkspaceTabId, + createLauncherId, normalizeWorkspaceTabTarget, workspaceTabTargetsEqual, } from "@/utils/workspace-tab-identity"; @@ -110,6 +111,11 @@ interface OpenTabInLayoutResult { tabId: string; } +interface OpenLauncherTabInLayoutInput { + layout: WorkspaceLayout; + now: number; +} + interface RetargetTabInLayoutInput { layout: WorkspaceLayout; tabId: string; @@ -121,6 +127,17 @@ interface RetargetTabInLayoutResult { tabId: string; } +interface ConvertDraftToAgentInLayoutInput { + layout: WorkspaceLayout; + tabId: string; + agentId: string; +} + +interface ConvertDraftToAgentInLayoutResult { + layout: WorkspaceLayout; + tabId: string; +} + interface ReorderFocusedPaneTabsInLayoutInput { layout: WorkspaceLayout; tabIds: string[]; @@ -181,6 +198,20 @@ interface ReorderPaneTabsInLayoutInput { tabIds: string[]; } +export interface WorkspaceTabReconcileState { + layout: WorkspaceLayout; + pinnedAgentIds?: ReadonlySet | null; +} + +export interface WorkspaceTabSnapshot { + agentsHydrated: boolean; + terminalsHydrated: boolean; + activeAgentIds: Iterable; + knownAgentIds: Iterable; + standaloneTerminalIds: Iterable; + hasActivePendingDraftCreate?: boolean; +} + const DEFAULT_PANE_ID = "main"; const MIN_SPLIT_SIZE = 0.1; @@ -750,6 +781,37 @@ function updateTabInTree(root: SplitNodeInternal, input: UpdateTabInTreeInput): }); } +function replaceTabInTree( + root: SplitNodeInternal, + input: { + tabId: string; + nextTabId: string; + target: WorkspaceTabTarget; + }, +): SplitNodeInternal { + const panePath = findPanePathContainingTab(root, input.tabId); + invariant(panePath, `Tab not found: ${input.tabId}`); + return replaceNodeAtPath(root, panePath, (node) => { + invariant(node.kind === "pane", "Expected pane while replacing tab"); + return { + kind: "pane", + pane: normalizePaneAfterTabChange({ + ...node.pane, + tabs: node.pane.tabs.map((tab) => + tab.tabId === input.tabId + ? { + ...tab, + tabId: input.nextTabId, + target: input.target, + } + : tab, + ), + focusedTabId: node.pane.focusedTabId === input.tabId ? input.nextTabId : node.pane.focusedTabId, + }), + }; + }); +} + function updateGroupSizesInTree( root: SplitNodeInternal, input: UpdateGroupSizesInTreeInput, @@ -957,22 +1019,12 @@ export function removeTabFromTree(root: SplitNode, tabId: string): SplitNode { return detachTabFromTree(asInternalNode(root), { tabId }).root; } -export function openTabInLayout(input: OpenTabInLayoutInput): OpenTabInLayoutResult { +function insertNewTabIntoFocusedPane(input: { + layout: WorkspaceLayout; + target: WorkspaceTabTarget; + now: number; +}): OpenTabInLayoutResult { const layout = asInternalLayout(input.layout); - const existingTab = collectAllTabs(layout.root).find((tab) => - workspaceTabTargetsEqual(tab.target, input.target), - ); - if (existingTab) { - return { - tabId: existingTab.tabId, - layout: - focusTabInLayout({ - layout, - tabId: existingTab.tabId, - }) ?? input.layout, - }; - } - const focusedPane = findPaneById(layout.root, layout.focusedPaneId) ?? collectAllPanes(layout.root)[0] ?? @@ -999,6 +1051,38 @@ export function openTabInLayout(input: OpenTabInLayoutInput): OpenTabInLayoutRes }; } +export function openTabInLayout(input: OpenTabInLayoutInput): OpenTabInLayoutResult { + const layout = asInternalLayout(input.layout); + const existingTab = collectAllTabs(layout.root).find((tab) => + workspaceTabTargetsEqual(tab.target, input.target), + ); + if (existingTab) { + return { + tabId: existingTab.tabId, + layout: + focusTabInLayout({ + layout, + tabId: existingTab.tabId, + }) ?? input.layout, + }; + } + + return insertNewTabIntoFocusedPane(input); +} + +export function openLauncherTabInLayout( + input: OpenLauncherTabInLayoutInput, +): OpenTabInLayoutResult { + return insertNewTabIntoFocusedPane({ + layout: input.layout, + target: { + kind: "launcher", + launcherId: createLauncherId(), + }, + now: input.now, + }); +} + export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout | null { const internalLayout = asInternalLayout(input.layout); const pane = findPaneContainingTab(internalLayout.root, input.tabId); @@ -1054,11 +1138,34 @@ export function retargetTabInLayout( }; } + const existingTargetTab = + collectAllTabs(layout.root).find( + (tab) => tab.tabId !== input.tabId && workspaceTabTargetsEqual(tab.target, input.target), + ) ?? null; + if (existingTargetTab) { + const nextLayout = + closeTabInLayout({ + layout: input.layout, + tabId: input.tabId, + }) ?? input.layout; + return { + layout: + focusTabInLayout({ + layout: nextLayout, + tabId: existingTargetTab.tabId, + }) ?? nextLayout, + tabId: existingTargetTab.tabId, + }; + } + return { + // Preserve the existing tab id so launcher->entity transitions keep the same + // React key during the first render. Reconciliation can canonicalize later. tabId: input.tabId, layout: { - root: updateTabInTree(layout.root, { + root: replaceTabInTree(layout.root, { tabId: input.tabId, + nextTabId: input.tabId, target: input.target, }), focusedPaneId: layout.focusedPaneId, @@ -1066,6 +1173,52 @@ export function retargetTabInLayout( }; } +export function convertDraftToAgentInLayout( + input: ConvertDraftToAgentInLayoutInput, +): ConvertDraftToAgentInLayoutResult | null { + const layout = asInternalLayout(input.layout); + const currentTab = collectAllTabs(layout.root).find((tab) => tab.tabId === input.tabId) ?? null; + if (!currentTab || currentTab.target.kind !== "draft") { + return null; + } + + const target: WorkspaceTabTarget = { + kind: "agent", + agentId: input.agentId, + }; + const canonicalTabId = buildDeterministicWorkspaceTabId(target); + const existingCanonicalTab = + collectAllTabs(layout.root).find((tab) => tab.tabId === canonicalTabId) ?? null; + + if (existingCanonicalTab && existingCanonicalTab.tabId !== input.tabId) { + const nextLayout = + closeTabInLayout({ + layout: input.layout, + tabId: input.tabId, + }) ?? input.layout; + return { + layout: + focusTabInLayout({ + layout: nextLayout, + tabId: canonicalTabId, + }) ?? nextLayout, + tabId: canonicalTabId, + }; + } + + return { + tabId: canonicalTabId, + layout: { + root: replaceTabInTree(layout.root, { + tabId: input.tabId, + nextTabId: canonicalTabId, + target, + }), + focusedPaneId: layout.focusedPaneId, + }, + }; +} + export function reorderFocusedPaneTabsInLayout( input: ReorderFocusedPaneTabsInLayoutInput, ): WorkspaceLayout | null { @@ -1234,3 +1387,201 @@ export function reorderPaneTabsInLayout( focusedPaneId: layout.focusedPaneId, }; } + +function normalizeStringSet(values: Iterable): Set { + const next = new Set(); + for (const value of values) { + const normalized = trimNonEmpty(value); + if (normalized) { + next.add(normalized); + } + } + return next; +} + +function isEntityTarget( + target: WorkspaceTabTarget, +): target is Extract { + return target.kind === "agent" || target.kind === "terminal"; +} + +function isAgentTab(tab: WorkspaceTab): tab is WorkspaceTab & { target: { kind: "agent"; agentId: string } } { + return tab.target.kind === "agent"; +} + +function isTerminalTab( + tab: WorkspaceTab, +): tab is WorkspaceTab & { target: { kind: "terminal"; terminalId: string } } { + return tab.target.kind === "terminal"; +} + +function openEntityTabWithoutFocusing(layout: WorkspaceLayout, target: WorkspaceTabTarget): WorkspaceLayout { + const internalLayout = asInternalLayout(layout); + const focusedPane = + findPaneById(internalLayout.root, internalLayout.focusedPaneId) ?? + collectAllPanes(internalLayout.root)[0] ?? + findPaneById(createDefaultLayout().root, DEFAULT_PANE_ID); + invariant(focusedPane, "Workspace layout must always have a pane"); + + const tabId = buildDeterministicWorkspaceTabId(target); + return { + root: insertTabIntoPane(internalLayout.root, { + paneId: focusedPane.id, + tab: { + tabId, + target, + createdAt: Date.now(), + }, + focusTabId: focusedPane.focusedTabId ?? tabId, + }), + focusedPaneId: internalLayout.focusedPaneId, + }; +} + +export function reconcileWorkspaceTabs( + state: WorkspaceTabReconcileState, + snapshot: WorkspaceTabSnapshot, +): WorkspaceTabReconcileState { + let nextLayout = state.layout; + const originalFocusedTabId = + findPaneById(nextLayout.root, nextLayout.focusedPaneId)?.focusedTabId ?? null; + let reconciledFocusedTabId = originalFocusedTabId; + const pinnedAgentIds = new Set(state.pinnedAgentIds ?? []); + const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds); + const knownAgentIds = normalizeStringSet(snapshot.knownAgentIds); + const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds); + const visibleAgentIds = new Set(activeAgentIds); + for (const agentId of pinnedAgentIds) { + if (knownAgentIds.has(agentId)) { + visibleAgentIds.add(agentId); + } + } + + const initialTabs = collectAllTabs(nextLayout.root); + const representedAgentIds = new Set(initialTabs.filter(isAgentTab).map((tab) => tab.target.agentId)); + + const entityGroups = new Map< + string, + { + target: WorkspaceTabTarget; + tabs: WorkspaceTab[]; + } + >(); + for (const tab of initialTabs) { + if (!isEntityTarget(tab.target)) { + continue; + } + const canonicalTarget = normalizeWorkspaceTabTarget(tab.target); + if (!canonicalTarget) { + continue; + } + const canonicalTabId = buildDeterministicWorkspaceTabId(canonicalTarget); + const currentGroup = entityGroups.get(canonicalTabId); + if (currentGroup) { + currentGroup.tabs.push(tab); + continue; + } + entityGroups.set(canonicalTabId, { + target: canonicalTarget, + tabs: [tab], + }); + } + + for (const [canonicalTabId, group] of entityGroups) { + const keeper = group.tabs.find((tab) => tab.tabId === canonicalTabId) ?? group.tabs[0] ?? null; + if (!keeper) { + continue; + } + if (group.tabs.some((tab) => tab.tabId === originalFocusedTabId)) { + reconciledFocusedTabId = canonicalTabId; + } + if ( + keeper.tabId !== canonicalTabId || + !workspaceTabTargetsEqual(keeper.target, group.target) + ) { + nextLayout = { + root: replaceTabInTree(asInternalLayout(nextLayout).root, { + tabId: keeper.tabId, + nextTabId: canonicalTabId, + target: group.target, + }), + focusedPaneId: nextLayout.focusedPaneId, + }; + } + for (const tab of group.tabs) { + if (tab.tabId === keeper.tabId) { + continue; + } + nextLayout = + closeTabInLayout({ + layout: nextLayout, + tabId: tab.tabId, + }) ?? nextLayout; + } + } + + for (const tab of collectAllTabs(nextLayout.root)) { + if (isAgentTab(tab) && snapshot.agentsHydrated && !visibleAgentIds.has(tab.target.agentId)) { + nextLayout = + closeTabInLayout({ + layout: nextLayout, + tabId: tab.tabId, + }) ?? nextLayout; + } + if (isTerminalTab(tab) && snapshot.terminalsHydrated && !standaloneTerminalIds.has(tab.target.terminalId)) { + nextLayout = + closeTabInLayout({ + layout: nextLayout, + tabId: tab.tabId, + }) ?? nextLayout; + } + } + + const currentEntityTabs = collectAllTabs(nextLayout.root); + const currentAgentIds = new Set( + currentEntityTabs.filter(isAgentTab).map((tab) => tab.target.agentId), + ); + const currentTerminalIds = new Set( + currentEntityTabs.filter(isTerminalTab).map((tab) => tab.target.terminalId), + ); + + const sortedVisibleAgentIds = [...visibleAgentIds].sort(); + for (const agentId of sortedVisibleAgentIds) { + if (currentAgentIds.has(agentId)) { + continue; + } + if (snapshot.hasActivePendingDraftCreate && !representedAgentIds.has(agentId)) { + continue; + } + nextLayout = openEntityTabWithoutFocusing(nextLayout, { + kind: "agent", + agentId, + }); + currentAgentIds.add(agentId); + } + + const sortedTerminalIds = [...standaloneTerminalIds].sort(); + for (const terminalId of sortedTerminalIds) { + if (currentTerminalIds.has(terminalId)) { + continue; + } + nextLayout = openEntityTabWithoutFocusing(nextLayout, { + kind: "terminal", + terminalId, + }); + currentTerminalIds.add(terminalId); + } + + if (reconciledFocusedTabId) { + nextLayout = + focusTabInLayout({ + layout: nextLayout, + tabId: reconciledFocusedTabId, + }) ?? nextLayout; + } + + return { + ...state, + layout: nextLayout, + }; +} diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 78f0aa446..009aa8f9b 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -262,6 +262,58 @@ describe("workspace-layout-store actions", () => { ]); }); + it("openLauncherTab creates duplicate launcher tabs for repeated Cmd+T/new-tab opens", () => { + vi.spyOn(globalThis.crypto, "randomUUID") + .mockReturnValueOnce("11111111-1111-1111-1111-111111111111") + .mockReturnValueOnce("22222222-2222-2222-2222-222222222222"); + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + + const firstTabId = store.openLauncherTab(workspaceKey); + const secondTabId = store.openLauncherTab(workspaceKey); + const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; + + expect(firstTabId).toBe("launcher_11111111-1111-1111-1111-111111111111"); + expect(secondTabId).toBe("launcher_22222222-2222-2222-2222-222222222222"); + expect(firstTabId).not.toBe(secondTabId); + expect(findPaneById(layout.root, "main")?.tabIds).toEqual([firstTabId, secondTabId]); + expect(collectAllTabs(layout.root)).toEqual([ + { + tabId: firstTabId, + target: { kind: "launcher", launcherId: "11111111-1111-1111-1111-111111111111" }, + createdAt: expect.any(Number), + }, + { + tabId: secondTabId, + target: { kind: "launcher", launcherId: "22222222-2222-2222-2222-222222222222" }, + createdAt: expect.any(Number), + }, + ]); + }); + + it("splitPaneEmpty plus openLauncherTab opens a launcher tab in the new pane", () => { + vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValueOnce( + "77777777-7777-7777-7777-777777777777", + ); + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + + store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); + const newPaneId = store.splitPaneEmpty(workspaceKey, { + targetPaneId: "main", + position: "right", + }); + const launcherTabId = store.openLauncherTab(workspaceKey); + const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; + + expect(newPaneId).toBe("pane_77777777-7777-7777-7777-777777777777"); + expect(launcherTabId).toMatch(/^launcher_/); + expect(layout.focusedPaneId).toBe(newPaneId); + expect(findPaneById(layout.root, "main")?.tabIds).toEqual(["file_/repo/worktree/a.ts"]); + expect(findPaneById(layout.root, newPaneId!)?.tabIds).toEqual([launcherTabId!]); + expect(findPaneById(layout.root, newPaneId!)?.focusedTabId).toBe(launcherTabId); + }); + it("focusTab moves workspace focus to the pane containing the tab", () => { vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", @@ -288,7 +340,7 @@ describe("workspace-layout-store actions", () => { expect(findPaneById(layout.root, splitPaneId!)?.focusedTabId).toBe(terminalTabId); }); - it("retargetTab updates the existing tab target without moving it to a different pane", () => { + it("convertDraftToAgent replaces the draft tab with a canonical agent tab in the same pane", () => { vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( "12121212-1212-1212-1212-121212121212", ); @@ -296,30 +348,125 @@ describe("workspace-layout-store actions", () => { const store = useWorkspaceLayoutStore.getState(); store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); - const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" }); + const secondTabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-2" }); const splitPaneId = store.splitPane(workspaceKey, { tabId: secondTabId!, targetPaneId: "main", position: "right", }); - const nextTabId = store.retargetTab(workspaceKey, secondTabId!, { + const nextTabId = store.convertDraftToAgent(workspaceKey, secondTabId!, "agent-1"); + const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; + const splitPane = findPaneById(layout.root, splitPaneId!); + const convertedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === nextTabId); + + expect(splitPaneId).toBe("pane_12121212-1212-1212-1212-121212121212"); + expect(nextTabId).toBe("agent_agent-1"); + expect(splitPane?.tabIds).toEqual(["agent_agent-1"]); + expect(findPaneContainingTab(layout.root, "agent_agent-1")?.id).toBe(splitPaneId); + expect(convertedTab).toEqual({ + tabId: "agent_agent-1", + target: { kind: "agent", agentId: "agent-1" }, + createdAt: expect.any(Number), + }); + }); + + it("retargetTab keeps a launcher tab in place while updating its target", () => { + vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( + "33333333-3333-3333-3333-333333333333", + ); + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + + const launcherTabId = store.openLauncherTab(workspaceKey); + const nextTabId = store.retargetTab(workspaceKey, launcherTabId!, { + kind: "file", + path: "/repo/worktree/launcher.ts", + }); + const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; + + expect(launcherTabId).toBe("launcher_33333333-3333-3333-3333-333333333333"); + expect(nextTabId).toBe(launcherTabId); + expect(findPaneById(layout.root, "main")?.tabIds).toEqual([launcherTabId!]); + expect(collectAllTabs(layout.root)).toEqual([ + { + tabId: launcherTabId!, + target: { kind: "file", path: "/repo/worktree/launcher.ts" }, + createdAt: expect.any(Number), + }, + ]); + }); + + it("retargetTab closes a launcher tab and focuses the existing canonical target tab", () => { + vi.spyOn(globalThis.crypto, "randomUUID") + .mockReturnValueOnce("44444444-4444-4444-4444-444444444444") + .mockReturnValueOnce("55555555-5555-5555-5555-555555555555") + .mockReturnValueOnce("66666666-6666-6666-6666-666666666666"); + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + + const existingFileTabId = store.openTab(workspaceKey, { + kind: "file", + path: "/repo/worktree/existing.ts", + }); + const launcherTabId = store.openLauncherTab(workspaceKey); + const splitPaneId = store.splitPane(workspaceKey, { + tabId: launcherTabId!, + targetPaneId: "main", + position: "right", + }); + const secondLauncherTabId = store.openLauncherTab(workspaceKey); + + const nextTabId = store.retargetTab(workspaceKey, secondLauncherTabId!, { + kind: "file", + path: "/repo/worktree/existing.ts", + }); + const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; + + expect(existingFileTabId).toBe("file_/repo/worktree/existing.ts"); + expect(launcherTabId).toBe("launcher_44444444-4444-4444-4444-444444444444"); + expect(splitPaneId).toBe("pane_55555555-5555-5555-5555-555555555555"); + expect(secondLauncherTabId).toMatch(/^launcher_/); + expect(secondLauncherTabId).not.toBe(launcherTabId); + expect(nextTabId).toBe(existingFileTabId); + expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual([ + existingFileTabId!, + launcherTabId!, + ]); + expect(layout.focusedPaneId).toBe("main"); + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(existingFileTabId); + }); + + it("retargetTab closes a launcher tab and focuses an existing matching target tab", () => { + vi.spyOn(globalThis.crypto, "randomUUID") + .mockReturnValueOnce("77777777-7777-7777-7777-777777777777") + .mockReturnValueOnce("88888888-8888-8888-8888-888888888888"); + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + + const firstLauncherTabId = store.openLauncherTab(workspaceKey); + const firstAgentTabId = store.retargetTab(workspaceKey, firstLauncherTabId!, { + kind: "agent", + agentId: "agent-1", + }); + const secondLauncherTabId = store.openLauncherTab(workspaceKey); + + const nextTabId = store.retargetTab(workspaceKey, secondLauncherTabId!, { kind: "agent", agentId: "agent-1", }); const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; - const splitPane = findPaneById(layout.root, splitPaneId!); - const retargetedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === secondTabId); - expect(splitPaneId).toBe("pane_12121212-1212-1212-1212-121212121212"); - expect(nextTabId).toBe(secondTabId); - expect(splitPane?.tabIds).toEqual([secondTabId!]); - expect(findPaneContainingTab(layout.root, secondTabId!)?.id).toBe(splitPaneId); - expect(retargetedTab).toEqual({ - tabId: secondTabId, - target: { kind: "agent", agentId: "agent-1" }, - createdAt: expect.any(Number), - }); + expect(firstAgentTabId).toBe(firstLauncherTabId); + expect(nextTabId).toBe(firstLauncherTabId); + expect(collectAllTabs(layout.root)).toEqual([ + { + tabId: firstLauncherTabId!, + target: { kind: "agent", agentId: "agent-1" }, + createdAt: expect.any(Number), + }, + ]); + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(firstLauncherTabId); }); it("reorderTabs reorders tabs within the focused pane", () => { @@ -702,4 +849,102 @@ describe("workspace-layout-store actions", () => { splitSizesByWorkspace: {}, }); }); + + it("convertDraftToAgent removes the draft and focuses the existing canonical agent tab", () => { + vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( + "67676767-6767-6767-6767-676767676767", + ); + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + + const draftTabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-existing" }); + const agentTabId = store.openTab(workspaceKey, { kind: "agent", agentId: "agent-1" }); + const splitPaneId = store.splitPane(workspaceKey, { + tabId: agentTabId!, + targetPaneId: "main", + position: "right", + }); + + const nextTabId = store.convertDraftToAgent(workspaceKey, draftTabId!, "agent-1"); + const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; + + expect(splitPaneId).toBe("pane_67676767-6767-6767-6767-676767676767"); + expect(nextTabId).toBe("agent_agent-1"); + expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual(["agent_agent-1"]); + expect(layout.focusedPaneId).toBe(splitPaneId); + expect(findPaneContainingTab(layout.root, "agent_agent-1")?.id).toBe(splitPaneId); + }); + + it("reconcileTabs canonicalizes duplicates and prunes stale entity tabs from hydrated snapshots", () => { + const workspaceKey = createWorkspaceKey(); + + useWorkspaceLayoutStore.setState((state) => ({ + ...state, + layoutByWorkspace: { + ...state.layoutByWorkspace, + [workspaceKey]: { + root: { + kind: "pane", + pane: { + id: "main", + tabIds: ["draft_agent", "agent_agent-1", "terminal_orphan", "draft-1"], + focusedTabId: "draft_agent", + tabs: [ + { + tabId: "draft_agent", + target: { kind: "agent", agentId: "agent-1" }, + createdAt: 1, + }, + { + tabId: "agent_agent-1", + target: { kind: "agent", agentId: "agent-1" }, + createdAt: 2, + }, + { + tabId: "terminal_orphan", + target: { kind: "terminal", terminalId: "term-stale" }, + createdAt: 3, + }, + { + tabId: "draft-1", + target: { kind: "draft", draftId: "draft-1" }, + createdAt: 4, + }, + ], + } as any, + }, + focusedPaneId: "main", + }, + }, + pinnedAgentIdsByWorkspace: { + [workspaceKey]: new Set(["agent-2"]), + }, + })); + + useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: ["agent-1"], + knownAgentIds: ["agent-1", "agent-2"], + standaloneTerminalIds: ["term-1"], + hasActivePendingDraftCreate: false, + }); + + const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; + const tabs = collectAllTabs(layout.root); + + expect(tabs.map((tab) => tab.tabId)).toEqual([ + "agent_agent-1", + "draft-1", + "agent_agent-2", + "terminal_term-1", + ]); + expect(tabs.find((tab) => tab.tabId === "agent_agent-1")).toEqual({ + tabId: "agent_agent-1", + target: { kind: "agent", agentId: "agent-1" }, + createdAt: 2, + }); + expect(layout.focusedPaneId).toBe("main"); + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe("agent_agent-1"); + }); }); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index e942dae51..ef6af3889 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -11,6 +11,7 @@ import { closeTabInLayout, collectAllPanes, collectAllTabs, + convertDraftToAgentInLayout, createDefaultLayout, findPaneById, findPaneContainingTab, @@ -20,7 +21,9 @@ import { insertSplit, moveTabToPaneInLayout, normalizeLayout, + openLauncherTabInLayout, openTabInLayout, + reconcileWorkspaceTabs, removePaneFromTree, removeTabFromTree, reorderFocusedPaneTabsInLayout, @@ -31,6 +34,8 @@ import { type SplitGroup, type SplitNode, type SplitPane, + type WorkspaceTabReconcileState, + type WorkspaceTabSnapshot, type WorkspaceLayout, } from "@/stores/workspace-layout-actions"; import { normalizeWorkspaceTabTarget } from "@/utils/workspace-tab-identity"; @@ -48,16 +53,26 @@ export { removePaneFromTree, removeTabFromTree, }; -export type { SplitGroup, SplitNode, SplitPane, WorkspaceLayout }; +export type { + SplitGroup, + SplitNode, + SplitPane, + WorkspaceLayout, + WorkspaceTabReconcileState, + WorkspaceTabSnapshot, +}; interface WorkspaceLayoutStore { layoutByWorkspace: Record; splitSizesByWorkspace: Record>; pinnedAgentIdsByWorkspace: Record>; openTab: (workspaceKey: string, target: WorkspaceTabTarget) => string | null; + openLauncherTab: (workspaceKey: string) => string | null; closeTab: (workspaceKey: string, tabId: string) => void; focusTab: (workspaceKey: string, tabId: string) => void; retargetTab: (workspaceKey: string, tabId: string, target: WorkspaceTabTarget) => string | null; + convertDraftToAgent: (workspaceKey: string, tabId: string, agentId: string) => string | null; + reconcileTabs: (workspaceKey: string, snapshot: WorkspaceTabSnapshot) => void; reorderTabs: (workspaceKey: string, tabIds: string[]) => void; getWorkspaceTabs: (workspaceKey: string) => WorkspaceTab[]; splitPane: ( @@ -128,6 +143,26 @@ export const useWorkspaceLayoutStore = create()( return result.tabId; }, + openLauncherTab: (workspaceKey) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + if (!normalizedWorkspaceKey) { + return null; + } + + const result = openLauncherTabInLayout({ + layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey), + now: Date.now(), + }); + + set((state) => ({ + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: result.layout, + }, + })); + + return result.tabId; + }, closeTab: (workspaceKey, tabId) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); const normalizedTabId = trimNonEmpty(tabId); @@ -202,6 +237,59 @@ export const useWorkspaceLayoutStore = create()( return result.tabId; }, + convertDraftToAgent: (workspaceKey, tabId, agentId) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedTabId = trimNonEmpty(tabId); + const normalizedAgentId = trimNonEmpty(agentId); + if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedAgentId) { + return null; + } + + const result = convertDraftToAgentInLayout({ + layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey), + tabId: normalizedTabId, + agentId: normalizedAgentId, + }); + if (!result) { + return null; + } + + set((state) => ({ + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: result.layout, + }, + })); + + return result.tabId; + }, + reconcileTabs: (workspaceKey, snapshot) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + if (!normalizedWorkspaceKey) { + return; + } + + set((state) => { + const currentLayout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey); + const nextState = reconcileWorkspaceTabs( + { + layout: currentLayout, + pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, + }, + snapshot, + ); + if (nextState.layout === currentLayout) { + return state; + } + + return { + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: nextState.layout, + }, + }; + }); + }, reorderTabs: (workspaceKey, tabIds) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); if (!normalizedWorkspaceKey) { diff --git a/packages/app/src/stores/workspace-setup-store.test.ts b/packages/app/src/stores/workspace-setup-store.test.ts new file mode 100644 index 000000000..59f04350c --- /dev/null +++ b/packages/app/src/stores/workspace-setup-store.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useWorkspaceSetupStore } from "./workspace-setup-store"; + +describe("workspace-setup-store", () => { + beforeEach(() => { + useWorkspaceSetupStore.setState({ pendingWorkspaceSetup: null }); + }); + + it("tracks deferred workspace setup by source directory and optional workspace id", () => { + useWorkspaceSetupStore.getState().beginWorkspaceSetup({ + serverId: "server-1", + sourceDirectory: "/Users/test/project", + sourceWorkspaceId: "42", + displayName: "project", + creationMethod: "open_project", + navigationMethod: "replace", + }); + + expect(useWorkspaceSetupStore.getState().pendingWorkspaceSetup).toEqual({ + serverId: "server-1", + sourceDirectory: "/Users/test/project", + sourceWorkspaceId: "42", + displayName: "project", + creationMethod: "open_project", + navigationMethod: "replace", + }); + }); + + it("clears pending setup state", () => { + useWorkspaceSetupStore.getState().beginWorkspaceSetup({ + serverId: "server-1", + sourceDirectory: "/Users/test/project", + creationMethod: "create_worktree", + navigationMethod: "navigate", + }); + + useWorkspaceSetupStore.getState().clearWorkspaceSetup(); + + expect(useWorkspaceSetupStore.getState().pendingWorkspaceSetup).toBeNull(); + }); +}); diff --git a/packages/app/src/stores/workspace-setup-store.ts b/packages/app/src/stores/workspace-setup-store.ts new file mode 100644 index 000000000..ba24dde8d --- /dev/null +++ b/packages/app/src/stores/workspace-setup-store.ts @@ -0,0 +1,29 @@ +import { create } from "zustand"; + +export type WorkspaceSetupNavigationMethod = "navigate" | "replace"; +export type WorkspaceCreationMethod = "open_project" | "create_worktree"; + +export interface PendingWorkspaceSetup { + serverId: string; + sourceDirectory: string; + sourceWorkspaceId?: string; + displayName?: string; + creationMethod: WorkspaceCreationMethod; + navigationMethod: WorkspaceSetupNavigationMethod; +} + +interface WorkspaceSetupStoreState { + pendingWorkspaceSetup: PendingWorkspaceSetup | null; + beginWorkspaceSetup: (value: PendingWorkspaceSetup) => void; + clearWorkspaceSetup: () => void; +} + +export const useWorkspaceSetupStore = create()((set) => ({ + pendingWorkspaceSetup: null, + beginWorkspaceSetup: (value) => { + set({ pendingWorkspaceSetup: value }); + }, + clearWorkspaceSetup: () => { + set({ pendingWorkspaceSetup: null }); + }, +})); diff --git a/packages/app/src/stores/workspace-tabs-store.test.ts b/packages/app/src/stores/workspace-tabs-store.test.ts index 1b22b31d1..9459d3665 100644 --- a/packages/app/src/stores/workspace-tabs-store.test.ts +++ b/packages/app/src/stores/workspace-tabs-store.test.ts @@ -140,6 +140,41 @@ describe("workspace-tabs-store retargetTab", () => { expect(order).toEqual([draftTabId]); }); + it("openLauncherTab creates distinct launcher tabs without deduplicating", () => { + vi.spyOn(globalThis.crypto, "randomUUID") + .mockReturnValueOnce("11111111-1111-1111-1111-111111111111") + .mockReturnValueOnce("22222222-2222-2222-2222-222222222222"); + const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); + expect(key).toBeTruthy(); + const workspaceKey = key as string; + + const firstTabId = useWorkspaceTabsStore.getState().openLauncherTab({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + }); + const secondTabId = useWorkspaceTabsStore.getState().openLauncherTab({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + }); + + const state = useWorkspaceTabsStore.getState(); + expect(firstTabId).toBe("launcher_11111111-1111-1111-1111-111111111111"); + expect(secondTabId).toBe("launcher_22222222-2222-2222-2222-222222222222"); + expect(state.tabOrderByWorkspace[workspaceKey]).toEqual([firstTabId, secondTabId]); + expect(state.uiTabsByWorkspace[workspaceKey]).toEqual([ + { + tabId: "launcher_11111111-1111-1111-1111-111111111111", + target: { kind: "launcher", launcherId: "11111111-1111-1111-1111-111111111111" }, + createdAt: expect.any(Number), + }, + { + tabId: "launcher_22222222-2222-2222-2222-222222222222", + target: { kind: "launcher", launcherId: "22222222-2222-2222-2222-222222222222" }, + createdAt: expect.any(Number), + }, + ]); + }); + it("retargeting a background draft keeps the currently focused tab focused", () => { const draftTabId = "draft_background"; const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); diff --git a/packages/app/src/stores/workspace-tabs-store.ts b/packages/app/src/stores/workspace-tabs-store.ts index 23ed460e0..679e7e018 100644 --- a/packages/app/src/stores/workspace-tabs-store.ts +++ b/packages/app/src/stores/workspace-tabs-store.ts @@ -1,12 +1,19 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; +import { + buildDeterministicWorkspaceTabId, + createLauncherId, + normalizeWorkspaceTabTarget, + workspaceTabTargetsEqual, +} from "@/utils/workspace-tab-identity"; export type WorkspaceTabTarget = | { kind: "draft"; draftId: string } | { kind: "agent"; agentId: string } | { kind: "terminal"; terminalId: string } - | { kind: "file"; path: string }; + | { kind: "file"; path: string } + | { kind: "launcher"; launcherId: string }; export type WorkspaceTab = { tabId: string; @@ -38,63 +45,6 @@ export function buildWorkspaceTabPersistenceKey(input: { return `${serverId}:${normalizeWorkspaceId(workspaceId)}`; } -function normalizeTabTarget( - value: WorkspaceTabTarget | null | undefined, -): WorkspaceTabTarget | null { - if (!value || typeof value !== "object" || typeof value.kind !== "string") { - return null; - } - if (value.kind === "draft") { - const draftId = trimNonEmpty(value.draftId); - return draftId ? { kind: "draft", draftId } : null; - } - if (value.kind === "agent") { - const agentId = trimNonEmpty(value.agentId); - return agentId ? { kind: "agent", agentId } : null; - } - if (value.kind === "terminal") { - const terminalId = trimNonEmpty(value.terminalId); - return terminalId ? { kind: "terminal", terminalId } : null; - } - if (value.kind === "file") { - const path = trimNonEmpty(value.path); - return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null; - } - return null; -} - -function tabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): boolean { - if (left.kind !== right.kind) { - return false; - } - if (left.kind === "draft" && right.kind === "draft") { - return left.draftId === right.draftId; - } - if (left.kind === "agent" && right.kind === "agent") { - return left.agentId === right.agentId; - } - if (left.kind === "terminal" && right.kind === "terminal") { - return left.terminalId === right.terminalId; - } - if (left.kind === "file" && right.kind === "file") { - return left.path === right.path; - } - return false; -} - -function buildDeterministicTabId(target: WorkspaceTabTarget): string { - if (target.kind === "draft") { - return target.draftId; - } - if (target.kind === "agent") { - return `agent_${target.agentId}`; - } - if (target.kind === "terminal") { - return `terminal_${target.terminalId}`; - } - return `file_${target.path}`; -} - function normalizeTabOrder(list: unknown): string[] { if (!Array.isArray(list)) { return []; @@ -133,6 +83,7 @@ type WorkspaceTabsState = { workspaceId: string; target: WorkspaceTabTarget; }) => string | null; + openLauncherTab: (input: { serverId: string; workspaceId: string }) => string | null; openOrFocusTab: (input: { serverId: string; workspaceId: string; @@ -169,19 +120,20 @@ export const useWorkspaceTabsStore = create()( }, ensureTab: ({ serverId, workspaceId, target }) => { const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); - const normalizedTarget = normalizeTabTarget(target); + const normalizedTarget = normalizeWorkspaceTabTarget(target); if (!key || !normalizedTarget) { return null; } - const deterministicTabId = buildDeterministicTabId(normalizedTarget); + const deterministicTabId = buildDeterministicWorkspaceTabId(normalizedTarget); let resolvedTabId = deterministicTabId; const now = Date.now(); set((state) => { const currentTabs = state.uiTabsByWorkspace[key] ?? []; const tabWithSameTarget = - currentTabs.find((tab) => tabTargetsEqual(tab.target, normalizedTarget)) ?? null; + currentTabs.find((tab) => workspaceTabTargetsEqual(tab.target, normalizedTarget)) ?? + null; const effectiveTabId = tabWithSameTarget?.tabId ?? deterministicTabId; resolvedTabId = effectiveTabId; @@ -196,7 +148,7 @@ export const useWorkspaceTabsStore = create()( ]; } const existing = currentTabs[existingIndex]; - if (existing && tabTargetsEqual(existing.target, normalizedTarget)) { + if (existing && workspaceTabTargetsEqual(existing.target, normalizedTarget)) { return currentTabs; } return currentTabs.map((tab, index) => @@ -218,6 +170,13 @@ export const useWorkspaceTabsStore = create()( return resolvedTabId; }, + openLauncherTab: ({ serverId, workspaceId }) => { + return get().openOrFocusTab({ + serverId, + workspaceId, + target: { kind: "launcher", launcherId: createLauncherId() }, + }); + }, openOrFocusTab: ({ serverId, workspaceId, target }) => { const tabId = get().ensureTab({ serverId, workspaceId, target }); if (!tabId) { @@ -310,7 +269,7 @@ export const useWorkspaceTabsStore = create()( retargetTab: ({ serverId, workspaceId, tabId, target }) => { const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); const normalizedTabId = trimNonEmpty(tabId); - const normalizedTarget = normalizeTabTarget(target); + const normalizedTarget = normalizeWorkspaceTabTarget(target); if (!key || !normalizedTabId || !normalizedTarget) { return null; } @@ -325,7 +284,7 @@ export const useWorkspaceTabsStore = create()( } const currentTarget = currentTabs[index]?.target; - if (currentTarget && tabTargetsEqual(currentTarget, normalizedTarget)) { + if (currentTarget && workspaceTabTargetsEqual(currentTarget, normalizedTarget)) { return state; } @@ -390,7 +349,7 @@ export const useWorkspaceTabsStore = create()( for (const key in state.uiTabsByWorkspace) { const tabs = (state.uiTabsByWorkspace[key] ?? []) .map((tab) => { - const normalizedTarget = normalizeTabTarget(tab.target); + const normalizedTarget = normalizeWorkspaceTabTarget(tab.target); const normalizedTabId = trimNonEmpty(tab.tabId); if (!normalizedTarget || !normalizedTabId) { return null; @@ -479,13 +438,13 @@ export const useWorkspaceTabsStore = create()( continue; } - const normalizedTarget = normalizeTabTarget((rawTab as WorkspaceTab).target); + const normalizedTarget = normalizeWorkspaceTabTarget((rawTab as WorkspaceTab).target); const rawTabId = trimNonEmpty((rawTab as WorkspaceTab).tabId); if (!normalizedTarget) { continue; } - const tabId = rawTabId ?? buildDeterministicTabId(normalizedTarget); + const tabId = rawTabId ?? buildDeterministicWorkspaceTabId(normalizedTarget); if (!usedOrder.has(tabId)) { usedOrder.add(tabId); orderFromTabs.push(tabId); diff --git a/packages/app/src/types/agent-directory.ts b/packages/app/src/types/agent-directory.ts index b7d9be9b3..a395596ec 100644 --- a/packages/app/src/types/agent-directory.ts +++ b/packages/app/src/types/agent-directory.ts @@ -5,6 +5,7 @@ export type AgentDirectoryEntry = Pick< | "id" | "serverId" | "title" + | "terminal" | "status" | "lastActivityAt" | "cwd" diff --git a/packages/app/src/utils/agent-snapshots.ts b/packages/app/src/utils/agent-snapshots.ts index 223c2437a..2361eba85 100644 --- a/packages/app/src/utils/agent-snapshots.ts +++ b/packages/app/src/utils/agent-snapshots.ts @@ -31,6 +31,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: serverId, id: snapshot.id, provider: snapshot.provider, + terminal: snapshot.terminal === true, status: snapshot.status as AgentLifecycleStatus, createdAt, updatedAt, @@ -44,6 +45,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: runtimeInfo: snapshot.runtimeInfo, lastUsage: snapshot.lastUsage, lastError: snapshot.lastError ?? null, + terminalExit: snapshot.terminalExit ?? null, title: snapshot.title ?? null, cwd: snapshot.cwd, model: snapshot.model ?? null, diff --git a/packages/app/src/utils/error-messages.ts b/packages/app/src/utils/error-messages.ts new file mode 100644 index 000000000..4ab0cf343 --- /dev/null +++ b/packages/app/src/utils/error-messages.ts @@ -0,0 +1,6 @@ +export function toErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index 5b34e4e95..85ede68bf 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildHostAgentDetailRoute, buildHostRootRoute, + buildHostWorkspaceOpenRoute, buildHostWorkspaceRoute, decodeFilePathFromPathSegment, decodeWorkspaceIdFromPathSegment, @@ -89,4 +90,10 @@ describe("workspace route parsing", () => { "/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1", ); }); + + it("builds workspace routes with a one-shot open intent", () => { + expect(buildHostWorkspaceOpenRoute("local", "/tmp/repo", "draft:new")).toBe( + "/h/local/workspace/L3RtcC9yZXBv?open=draft%3Anew", + ); + }); }); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 5afe9f059..fab334d7c 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -281,6 +281,19 @@ export function buildHostWorkspaceRoute(serverId: string, workspaceId: string): return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(encodedWorkspaceId)}`; } +export function buildHostWorkspaceOpenRoute( + serverId: string, + workspaceId: string, + openIntent: string, +): string { + const base = buildHostWorkspaceRoute(serverId, workspaceId); + const normalizedOpenIntent = trimNonEmpty(openIntent); + if (base === "/" || !normalizedOpenIntent) { + return base; + } + return `${base}?open=${encodeURIComponent(normalizedOpenIntent)}`; +} + export function buildHostAgentDetailRoute( serverId: string, agentId: string, @@ -292,11 +305,7 @@ export function buildHostAgentDetailRoute( if (!normalizedAgentId) { return "/"; } - const base = buildHostWorkspaceRoute(serverId, normalizedWorkspaceId); - if (base === "/") { - return "/"; - } - return `${base}?open=${encodeURIComponent(`agent:${normalizedAgentId}`)}`; + return buildHostWorkspaceOpenRoute(serverId, normalizedWorkspaceId, `agent:${normalizedAgentId}`); } const normalizedServerId = trimNonEmpty(serverId); const normalizedAgentId = trimNonEmpty(agentId); diff --git a/packages/app/src/utils/notification-routing.test.ts b/packages/app/src/utils/notification-routing.test.ts index ecd044a06..099b20ef1 100644 --- a/packages/app/src/utils/notification-routing.test.ts +++ b/packages/app/src/utils/notification-routing.test.ts @@ -28,6 +28,20 @@ describe("resolveNotificationTarget", () => { workspaceId: null, }); }); + + it("does not treat cwd as a workspace id alias", () => { + expect( + resolveNotificationTarget({ + serverId: "srv-1", + agentId: "agent-1", + cwd: "/tmp/repo", + }), + ).toEqual({ + serverId: "srv-1", + agentId: "agent-1", + workspaceId: null, + }); + }); }); describe("buildNotificationRoute", () => { diff --git a/packages/app/src/utils/notification-routing.ts b/packages/app/src/utils/notification-routing.ts index 6b0f1b6b0..d1de8e444 100644 --- a/packages/app/src/utils/notification-routing.ts +++ b/packages/app/src/utils/notification-routing.ts @@ -23,7 +23,7 @@ export function resolveNotificationTarget(data: NotificationData): { return { serverId: readNonEmptyString(data, "serverId"), agentId: readNonEmptyString(data, "agentId"), - workspaceId: readNonEmptyString(data, "workspaceId") ?? readNonEmptyString(data, "cwd"), + workspaceId: readNonEmptyString(data, "workspaceId"), }; } diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index 04c8a3f10..a554cde01 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -13,7 +13,8 @@ function workspace(overrides: Partial = {}): SidebarWorks workspaceKey: "srv:/repo", serverId: "srv", workspaceId: "/repo", - workspaceKind: "directory", + projectKind: "git", + workspaceKind: "checkout", name: "paseo", activityAt: null, statusBucket: "done", @@ -41,13 +42,13 @@ describe("buildSidebarProjectRowModel", () => { it("flattens non-git projects with one workspace into a direct workspace row model", () => { const flattenedWorkspace = workspace({ workspaceId: "/repo/non-git", - workspaceKind: "directory", + workspaceKind: "checkout", statusBucket: "running", }); const result = buildSidebarProjectRowModel({ project: project({ - projectKind: "non_git", + projectKind: "directory", workspaces: [flattenedWorkspace], }), collapsed: false, @@ -70,7 +71,7 @@ describe("buildSidebarProjectRowModel", () => { const result = buildSidebarProjectRowModel({ project: project({ - projectKind: "non_git", + projectKind: "directory", workspaces: [flattenedWorkspace], }), collapsed: false, @@ -87,25 +88,23 @@ describe("buildSidebarProjectRowModel", () => { }); }); - it("flattens git projects with a single workspace and keeps the new worktree action", () => { - const flattenedWorkspace = workspace({ + it("keeps single-workspace git projects as sections with the new worktree action", () => { + const onlyWorkspace = workspace({ workspaceId: "/repo/main", - workspaceKind: "local_checkout", + workspaceKind: "checkout", }); const result = buildSidebarProjectRowModel({ project: project({ projectKind: "git", - workspaces: [flattenedWorkspace], + workspaces: [onlyWorkspace], }), collapsed: true, }); expect(result).toEqual({ - kind: "workspace_link", - workspace: flattenedWorkspace, - selected: false, - chevron: null, + kind: "project_section", + chevron: "expand", trailingAction: "new_worktree", }); }); @@ -115,7 +114,7 @@ describe("buildSidebarProjectRowModel", () => { project: project({ projectKind: "git", workspaces: [ - workspace({ workspaceId: "/repo/main", workspaceKind: "local_checkout" }), + workspace({ workspaceId: "/repo/main", workspaceKind: "checkout" }), workspace({ workspaceId: "/repo/feature", workspaceKind: "worktree" }), ], }), @@ -131,12 +130,12 @@ describe("buildSidebarProjectRowModel", () => { }); describe("isSidebarProjectFlattened", () => { - it("returns true for single-workspace projects regardless of kind", () => { + it("returns true only for single-workspace directory projects", () => { expect( isSidebarProjectFlattened(project({ projectKind: "git", workspaces: [workspace()] })), - ).toBe(true); + ).toBe(false); expect( - isSidebarProjectFlattened(project({ projectKind: "non_git", workspaces: [workspace()] })), + isSidebarProjectFlattened(project({ projectKind: "directory", workspaces: [workspace()] })), ).toBe(true); }); diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index 113927177..dc8625481 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -11,7 +11,8 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry { workspaceKey: `${serverId}:${cwd}`, serverId, workspaceId: cwd, - workspaceKind: "local_checkout", + projectKind: "git", + workspaceKind: "checkout", name: cwd, activityAt: null, statusBucket: "done", @@ -76,7 +77,7 @@ describe("buildSidebarShortcutModel", () => { expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "/repo/w9" }); }); - it("ignores collapsed state for flattened single-workspace projects", () => { + it("respects collapsed state for single-workspace git projects", () => { const projects = [project("p1", [workspace("s1", "/repo/main")])]; const model = buildSidebarShortcutModel({ @@ -84,7 +85,7 @@ describe("buildSidebarShortcutModel", () => { collapsedProjectKeys: new Set(["p1"]), }); - expect(model.visibleTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]); - expect(model.shortcutTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]); + expect(model.visibleTargets).toEqual([]); + expect(model.shortcutTargets).toEqual([]); }); }); diff --git a/packages/app/src/utils/terminal-list.test.ts b/packages/app/src/utils/terminal-list.test.ts index b7b38e39f..6fe3b971f 100644 --- a/packages/app/src/utils/terminal-list.test.ts +++ b/packages/app/src/utils/terminal-list.test.ts @@ -50,4 +50,18 @@ describe("terminal-list", () => { { id: "term-2", name: "Renamed Terminal" }, ]); }); + + it("preserves terminal titles from create responses", () => { + const result = upsertTerminalListEntry({ + terminals: [], + terminal: { + id: "term-3", + name: "Terminal 3", + title: "Build Output", + cwd: "/tmp/project", + }, + }); + + expect(result).toEqual([{ id: "term-3", name: "Terminal 3", title: "Build Output" }]); + }); }); diff --git a/packages/app/src/utils/terminal-list.ts b/packages/app/src/utils/terminal-list.ts index eb7699ce4..99f7ad41a 100644 --- a/packages/app/src/utils/terminal-list.ts +++ b/packages/app/src/utils/terminal-list.ts @@ -7,6 +7,7 @@ function toTerminalListEntry(input: { terminal: CreatedTerminal }): TerminalList return { id: input.terminal.id, name: input.terminal.name, + ...(input.terminal.title ? { title: input.terminal.title } : {}), }; } diff --git a/packages/app/src/utils/workspace-archive-navigation.test.ts b/packages/app/src/utils/workspace-archive-navigation.test.ts index eeaba5bca..266a9fd14 100644 --- a/packages/app/src/utils/workspace-archive-navigation.test.ts +++ b/packages/app/src/utils/workspace-archive-navigation.test.ts @@ -13,10 +13,11 @@ function workspace( projectId: input.projectId ?? "project-1", projectDisplayName: input.projectDisplayName ?? "Project", projectRootPath: input.projectRootPath ?? "/repo", + workspaceDirectory: input.workspaceDirectory ?? input.projectRootPath ?? "/repo", projectKind: input.projectKind ?? "git", workspaceKind: input.workspaceKind ?? "worktree", name: input.name ?? input.id, - status: input.status ?? "done", + status: input.status ?? "running", activityAt: input.activityAt ?? null, diffStat: input.diffStat ?? null, }; @@ -25,7 +26,7 @@ function workspace( describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => { it("redirects an archived worktree to the visible local checkout for the same project", () => { const workspaces = [ - workspace({ id: "/repo", workspaceKind: "local_checkout", name: "main" }), + workspace({ id: "/repo", workspaceKind: "checkout", name: "main" }), workspace({ id: "/repo/.paseo/worktrees/feature", name: "feature" }), ]; @@ -37,7 +38,7 @@ describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => { ).toBe("/repo"); }); - it("falls back to the project root path when the root checkout is not in the visible workspace list", () => { + it("falls back to the host root route when no sibling workspace target exists", () => { const workspaces = [ workspace({ id: "/repo/.paseo/worktrees/feature", @@ -47,11 +48,12 @@ describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => { ]; expect( - resolveWorkspaceArchiveRedirectWorkspaceId({ + buildWorkspaceArchiveRedirectRoute({ + serverId: "server-1", archivedWorkspaceId: "/repo/.paseo/worktrees/feature", workspaces, }), - ).toBe("/repo"); + ).toBe("/h/server-1"); }); it("falls back to the host root route when no alternate workspace target exists", () => { @@ -60,8 +62,8 @@ describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => { id: "/notes", projectId: "notes", projectRootPath: "/notes", - projectKind: "non_git", - workspaceKind: "directory", + projectKind: "directory", + workspaceKind: "checkout", }), ]; diff --git a/packages/app/src/utils/workspace-archive-navigation.ts b/packages/app/src/utils/workspace-archive-navigation.ts index df31aab74..a1ef64a0f 100644 --- a/packages/app/src/utils/workspace-archive-navigation.ts +++ b/packages/app/src/utils/workspace-archive-navigation.ts @@ -1,20 +1,14 @@ import type { WorkspaceDescriptor } from "@/stores/session-store"; import { buildHostRootRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; - -function trimNonEmpty(value: string | null | undefined): string | null { - if (typeof value !== "string") { - return null; - } - - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} +import { resolveWorkspaceRouteId } from "@/utils/workspace-execution"; export function resolveWorkspaceArchiveRedirectWorkspaceId(input: { archivedWorkspaceId: string; workspaces: Iterable; }): string | null { - const archivedWorkspaceId = trimNonEmpty(input.archivedWorkspaceId); + const archivedWorkspaceId = resolveWorkspaceRouteId({ + routeWorkspaceId: input.archivedWorkspaceId, + }); if (!archivedWorkspaceId) { return null; } @@ -32,17 +26,12 @@ export function resolveWorkspaceArchiveRedirectWorkspaceId(input: { const rootCheckoutWorkspace = sameProjectWorkspaces.find( (workspace) => - workspace.workspaceKind === "local_checkout" && workspace.id !== archivedWorkspace.id, + workspace.workspaceKind === "checkout" && workspace.id !== archivedWorkspace.id, ) ?? null; if (rootCheckoutWorkspace) { return rootCheckoutWorkspace.id; } - const fallbackProjectRootPath = trimNonEmpty(archivedWorkspace.projectRootPath); - if (fallbackProjectRootPath && fallbackProjectRootPath !== archivedWorkspace.id) { - return fallbackProjectRootPath; - } - const siblingWorkspace = sameProjectWorkspaces.find((workspace) => workspace.id !== archivedWorkspace.id) ?? null; return siblingWorkspace?.id ?? null; diff --git a/packages/app/src/utils/workspace-execution.test.ts b/packages/app/src/utils/workspace-execution.test.ts new file mode 100644 index 000000000..17b9a78cc --- /dev/null +++ b/packages/app/src/utils/workspace-execution.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { + getWorkspaceExecutionAuthority, + requireWorkspaceExecutionAuthority, + resolveWorkspaceIdByExecutionDirectory, + resolveWorkspaceRouteId, +} from "./workspace-execution"; + +function createWorkspace( + input: Partial & Pick, +): WorkspaceDescriptor { + return { + id: input.id, + projectId: input.projectId ?? "project-1", + projectDisplayName: input.projectDisplayName ?? "Project", + projectRootPath: input.projectRootPath ?? "/repo", + workspaceDirectory: input.workspaceDirectory ?? "/repo", + projectKind: input.projectKind ?? "git", + workspaceKind: input.workspaceKind ?? "checkout", + name: input.name ?? "main", + status: input.status ?? "running", + activityAt: input.activityAt ?? null, + diffStat: input.diffStat ?? null, + }; +} + +describe("resolveWorkspaceRouteId", () => { + it("normalizes route workspace ids", () => { + expect(resolveWorkspaceRouteId({ routeWorkspaceId: " /tmp/repo/ " })).toBe("/tmp/repo"); + }); + + it("returns null for empty values", () => { + expect(resolveWorkspaceRouteId({ routeWorkspaceId: " " })).toBeNull(); + }); +}); + +describe("resolveWorkspaceIdByExecutionDirectory", () => { + it("matches workspace directories", () => { + const workspaces = [ + createWorkspace({ + id: "workspace-1", + projectRootPath: "/repo", + workspaceDirectory: "/repo/.paseo/worktrees/feature", + }), + ]; + + expect( + resolveWorkspaceIdByExecutionDirectory({ + workspaces, + workspaceDirectory: "/repo/.paseo/worktrees/feature", + }), + ).toBe("workspace-1"); + }); + + it("does not match project root metadata", () => { + const workspaces = [ + createWorkspace({ + id: "workspace-1", + projectRootPath: "/repo", + workspaceDirectory: "/repo/.paseo/worktrees/feature", + }), + ]; + + expect( + resolveWorkspaceIdByExecutionDirectory({ + workspaces, + workspaceDirectory: "/repo", + }), + ).toBeNull(); + }); +}); + +describe("workspace execution authority", () => { + it("returns an explicit failure when workspace id is missing", () => { + expect( + getWorkspaceExecutionAuthority({ + workspaces: new Map(), + workspaceId: null, + }), + ).toEqual({ + ok: false, + reason: "workspace_id_missing", + message: "Workspace id is required.", + }); + }); + + it("returns an explicit failure when workspace directory is missing", () => { + const workspaces = new Map([ + [ + "workspace-1", + createWorkspace({ + id: "workspace-1", + workspaceDirectory: " ", + projectRootPath: "/repo", + }), + ], + ]); + + expect( + getWorkspaceExecutionAuthority({ + workspaces, + workspaceId: "workspace-1", + }), + ).toEqual({ + ok: false, + reason: "workspace_directory_missing", + message: "Workspace directory is missing for workspace workspace-1", + }); + }); + + it("never falls back to project root metadata", () => { + const workspaces = new Map([ + [ + "workspace-1", + createWorkspace({ + id: "workspace-1", + projectRootPath: "/repo", + workspaceDirectory: "/repo/.paseo/worktrees/feature", + }), + ], + ]); + + expect( + requireWorkspaceExecutionAuthority({ + workspaces, + workspaceId: "workspace-1", + }), + ).toEqual({ + workspaceId: "workspace-1", + workspaceDirectory: "/repo/.paseo/worktrees/feature", + workspace: workspaces.get("workspace-1"), + }); + }); +}); diff --git a/packages/app/src/utils/workspace-execution.ts b/packages/app/src/utils/workspace-execution.ts new file mode 100644 index 000000000..2d47e02a0 --- /dev/null +++ b/packages/app/src/utils/workspace-execution.ts @@ -0,0 +1,177 @@ +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity"; + +export type WorkspaceAuthorityResult = { + workspaceId: string; + workspaceDirectory: string; + workspace: WorkspaceDescriptor; +}; + +export type WorkspaceExecutionAuthorityFailureReason = + | "workspace_id_missing" + | "workspace_missing" + | "workspace_directory_missing"; + +export type WorkspaceExecutionAuthorityResult = + | { ok: true; authority: WorkspaceAuthorityResult } + | { + ok: false; + reason: WorkspaceExecutionAuthorityFailureReason; + message: string; + }; + +export function resolveWorkspaceRouteId(input: { + routeWorkspaceId: string | null | undefined; +}): string | null { + return normalizeWorkspaceIdentity(input.routeWorkspaceId); +} + +export function resolveWorkspaceIdByExecutionDirectory(input: { + workspaces: Iterable | null | undefined; + workspaceDirectory: string | null | undefined; +}): string | null { + const normalizedWorkspaceDirectory = normalizeWorkspaceIdentity(input.workspaceDirectory); + if (!normalizedWorkspaceDirectory) { + return null; + } + + for (const workspace of input.workspaces ?? []) { + if (normalizeWorkspaceIdentity(workspace.workspaceDirectory) === normalizedWorkspaceDirectory) { + return workspace.id; + } + } + + return null; +} + +export function getWorkspaceExecutionAuthority( + input: + | { + workspace: WorkspaceDescriptor | null | undefined; + } + | { + workspaces: Map | undefined; + workspaceId: string | null | undefined; + }, +): WorkspaceExecutionAuthorityResult { + const workspace = + "workspace" in input + ? input.workspace + : (() => { + const normalizedWorkspaceId = normalizeWorkspaceIdentity(input.workspaceId); + if (!normalizedWorkspaceId) { + return null; + } + return input.workspaces?.get(normalizedWorkspaceId) ?? null; + })(); + + if ("workspaces" in input) { + const normalizedWorkspaceId = normalizeWorkspaceIdentity(input.workspaceId); + if (!normalizedWorkspaceId) { + return { + ok: false, + reason: "workspace_id_missing", + message: "Workspace id is required.", + }; + } + } + + if (!workspace) { + return { + ok: false, + reason: "workspace_missing", + message: + "workspaces" in input + ? `Workspace not found: ${String(input.workspaceId ?? "")}` + : "Workspace not found.", + }; + } + + const workspaceDirectory = normalizeWorkspaceIdentity(workspace.workspaceDirectory); + if (!workspaceDirectory) { + return { + ok: false, + reason: "workspace_directory_missing", + message: `Workspace directory is missing for workspace ${workspace.id}`, + }; + } + + return { + ok: true, + authority: { + workspaceId: workspace.id, + workspaceDirectory, + workspace, + }, + }; +} + +export function requireWorkspaceExecutionAuthority( + input: + | { + workspace: WorkspaceDescriptor | null | undefined; + } + | { + workspaces: Map | undefined; + workspaceId: string | null | undefined; + }, +): WorkspaceAuthorityResult { + const result = getWorkspaceExecutionAuthority(input); + if (!result.ok) { + throw new Error(result.message); + } + return result.authority; +} + +export function requireWorkspaceRecordId(workspaceId: string): number { + const normalizedWorkspaceId = normalizeWorkspaceIdentity(workspaceId); + if (!normalizedWorkspaceId) { + throw new Error("Workspace ID is required"); + } + + const parsedWorkspaceId = Number(normalizedWorkspaceId); + if (!Number.isInteger(parsedWorkspaceId)) { + throw new Error(`Workspace ID is not a persisted record ID: ${workspaceId}`); + } + + return parsedWorkspaceId; +} + +export function resolveWorkspaceExecutionDirectory(input: { + workspaceDirectory: string | null | undefined; +}): string | null { + return normalizeWorkspaceIdentity(input.workspaceDirectory); +} + +export function requireWorkspaceExecutionDirectory(input: { + workspaceId?: string; + workspaceDirectory: string | null | undefined; +}): string { + const workspaceDirectory = resolveWorkspaceExecutionDirectory({ + workspaceDirectory: input.workspaceDirectory, + }); + if (!workspaceDirectory) { + throw new Error( + input.workspaceId + ? `Workspace directory is missing for workspace ${input.workspaceId}` + : "Workspace directory is missing.", + ); + } + return workspaceDirectory; +} + +export function resolveWorkspaceExecutionAuthority( + input: + | { + workspace: WorkspaceDescriptor | null | undefined; + } + | { + workspaces: Map | undefined; + workspaceId: string | null | undefined; + }, +): WorkspaceAuthorityResult | null { + const result = getWorkspaceExecutionAuthority(input); + return result.ok ? result.authority : null; +} + +export const parseWorkspaceRecordId = requireWorkspaceRecordId; diff --git a/packages/app/src/utils/workspace-navigation.test.ts b/packages/app/src/utils/workspace-navigation.test.ts new file mode 100644 index 000000000..a1ea1722d --- /dev/null +++ b/packages/app/src/utils/workspace-navigation.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@react-native-async-storage/async-storage", () => { + const storage = new Map(); + return { + default: { + getItem: vi.fn(async (key: string) => storage.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + storage.set(key, value); + }), + removeItem: vi.fn(async (key: string) => { + storage.delete(key); + }), + }, + }; +}); + +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { + buildTerminalAgentReopenKey, + useTerminalAgentReopenStore, +} from "@/stores/terminal-agent-reopen-store"; +import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; + +const SERVER_ID = "server-1"; +const WORKSPACE_ID = "/repo/worktree"; +const AGENT_ID = "agent-1"; + +describe("prepareWorkspaceTab", () => { + beforeEach(() => { + useWorkspaceLayoutStore.setState({ + layoutByWorkspace: {}, + splitSizesByWorkspace: {}, + pinnedAgentIdsByWorkspace: {}, + }); + useTerminalAgentReopenStore.setState({ + reopenIntentVersionByAgentKey: {}, + requestReopen: useTerminalAgentReopenStore.getState().requestReopen, + }); + }); + + it("publishes a reopen intent when requested for an agent tab", () => { + const route = prepareWorkspaceTab({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: AGENT_ID }, + requestReopen: true, + }); + + const reopenKey = buildTerminalAgentReopenKey({ serverId: SERVER_ID, agentId: AGENT_ID }); + expect(reopenKey).toBeTruthy(); + expect(route).toBe("/h/server-1/workspace/L3JlcG8vd29ya3RyZWU"); + expect( + useTerminalAgentReopenStore.getState().reopenIntentVersionByAgentKey[reopenKey as string], + ).toBe(1); + }); + + it("does not publish a reopen intent unless explicitly requested", () => { + prepareWorkspaceTab({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: AGENT_ID }, + }); + + const reopenKey = buildTerminalAgentReopenKey({ serverId: SERVER_ID, agentId: AGENT_ID }); + expect(reopenKey).toBeTruthy(); + expect( + useTerminalAgentReopenStore.getState().reopenIntentVersionByAgentKey[reopenKey as string], + ).toBeUndefined(); + }); +}); diff --git a/packages/app/src/utils/workspace-navigation.ts b/packages/app/src/utils/workspace-navigation.ts index 6eb564f9d..a232288f5 100644 --- a/packages/app/src/utils/workspace-navigation.ts +++ b/packages/app/src/utils/workspace-navigation.ts @@ -1,5 +1,7 @@ +import { router } from "expo-router"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { generateDraftId } from "@/stores/draft-keys"; +import { useTerminalAgentReopenStore } from "@/stores/terminal-agent-reopen-store"; import { buildWorkspaceTabPersistenceKey, type WorkspaceTabTarget, @@ -11,6 +13,11 @@ interface PrepareWorkspaceTabInput { workspaceId: string; target: WorkspaceTabTarget; pin?: boolean; + requestReopen?: boolean; +} + +interface NavigateToPreparedWorkspaceTabInput extends PrepareWorkspaceTabInput { + navigationMethod?: "navigate" | "replace"; } function getPreparedTarget(target: WorkspaceTabTarget): WorkspaceTabTarget { @@ -38,5 +45,22 @@ export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput): string { useWorkspaceLayoutStore.getState().pinAgent(key, target.agentId); } + if (input.requestReopen && target.kind === "agent") { + useTerminalAgentReopenStore.getState().requestReopen({ + serverId: input.serverId, + agentId: target.agentId, + }); + } + return buildHostWorkspaceRoute(input.serverId, input.workspaceId); } + +export function navigateToPreparedWorkspaceTab(input: NavigateToPreparedWorkspaceTabInput): string { + const route = prepareWorkspaceTab(input); + if (input.navigationMethod === "replace") { + router.replace(route as any); + } else { + router.navigate(route as any); + } + return route; +} diff --git a/packages/app/src/utils/workspace-tab-identity.ts b/packages/app/src/utils/workspace-tab-identity.ts index 9ea9ffd7f..771c3d42c 100644 --- a/packages/app/src/utils/workspace-tab-identity.ts +++ b/packages/app/src/utils/workspace-tab-identity.ts @@ -22,6 +22,10 @@ export function normalizeWorkspaceTabTarget( const path = trimNonEmpty(value.path); return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null; } + if (value.kind === "launcher") { + const launcherId = trimNonEmpty(value.launcherId); + return launcherId ? { kind: "launcher", launcherId } : null; + } return null; } @@ -44,6 +48,10 @@ export function workspaceTabTargetsEqual( if (left.kind === "file" && right.kind === "file") { return left.path === right.path; } + if (left.kind === "launcher" && right.kind === "launcher") { + // Launcher tabs are intentionally always unique, even when reopened repeatedly. + return false; + } return false; } @@ -57,9 +65,19 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st if (target.kind === "terminal") { return `terminal_${target.terminalId}`; } + if (target.kind === "launcher") { + return `launcher_${target.launcherId}`; + } return `file_${target.path}`; } +export function createLauncherId(): string { + if (typeof globalThis.crypto?.randomUUID === "function") { + return globalThis.crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + function trimNonEmpty(value: string | null | undefined): string | null { if (typeof value !== "string") { return null; diff --git a/packages/cli/src/commands/agent/ls.ts b/packages/cli/src/commands/agent/ls.ts index 866869672..a8d73c039 100644 --- a/packages/cli/src/commands/agent/ls.ts +++ b/packages/cli/src/commands/agent/ls.ts @@ -25,6 +25,7 @@ export interface AgentListItem { shortId: string; name: string; provider: string; + terminal: boolean; thinking: string; status: string; cwd: string; @@ -66,6 +67,7 @@ export const agentLsSchema: OutputSchema = { { header: "AGENT ID", field: "shortId", width: 12 }, { header: "NAME", field: "name", width: 20 }, { header: "PROVIDER", field: "provider", width: 15 }, + { header: "TERM", field: "terminal", width: 6 }, { header: "THINKING", field: "thinking", width: 12 }, { header: "STATUS", @@ -91,6 +93,7 @@ function toListItem(agent: AgentSnapshotPayload): AgentListItem { shortId: agent.id.slice(0, 7), name: agent.title ?? "-", provider: model ? `${agent.provider}/${model}` : agent.provider, + terminal: agent.terminal === true, thinking: agent.effectiveThinkingOptionId ?? "auto", status: agent.status, cwd: shortenPath(agent.cwd), diff --git a/packages/cli/src/commands/agent/run.ts b/packages/cli/src/commands/agent/run.ts index 040aae4e8..d7b8ffd2e 100644 --- a/packages/cli/src/commands/agent/run.ts +++ b/packages/cli/src/commands/agent/run.ts @@ -193,7 +193,6 @@ export async function resolveStructuredResponseMessage(options: { try { const timeline = await options.client.fetchAgentTimeline(options.agentId, { direction: "tail", - projection: "projected", limit: 200, }); for (let index = timeline.entries.length - 1; index >= 0; index -= 1) { diff --git a/packages/cli/src/commands/agent/send.ts b/packages/cli/src/commands/agent/send.ts index feaa6df1f..1b8e9c9e7 100644 --- a/packages/cli/src/commands/agent/send.ts +++ b/packages/cli/src/commands/agent/send.ts @@ -17,6 +17,11 @@ export interface AgentSendResult { message: string; } +function isTerminalAgentSendError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /terminal agents do not support structured send operations/i.test(message); +} + /** Schema for agent send output */ export const agentSendSchema: OutputSchema = { idField: "agentId", @@ -260,6 +265,15 @@ export async function runSendCommand( } catch (err) { await client.close().catch(() => {}); + if (isTerminalAgentSendError(err)) { + const error: CommandError = { + code: "TERMINAL_AGENT_UNSUPPORTED", + message: "Cannot send messages to terminal agents", + details: "Open the terminal agent from the Sessions UI and interact through its terminal.", + }; + throw error; + } + // Re-throw CommandError as-is if (err && typeof err === "object" && "code" in err) { throw err; diff --git a/packages/cli/src/commands/chat/post.ts b/packages/cli/src/commands/chat/post.ts index 32ff0f972..03339d160 100644 --- a/packages/cli/src/commands/chat/post.ts +++ b/packages/cli/src/commands/chat/post.ts @@ -3,7 +3,6 @@ import type { SingleResult } from "../../output/index.js"; import { attachAgentNamesToMessages, connectChatClient, - resolveChatAuthorAgentId, toChatCommandError, type ChatCommandOptions, } from "./shared.js"; @@ -24,7 +23,6 @@ export async function runPostCommand( const payload = await client.postChatMessage({ room, body, - authorAgentId: resolveChatAuthorAgentId(), replyToMessageId: options.replyTo, }); const [message] = await attachAgentNamesToMessages(client, [toChatMessageRow(payload.message!)]); diff --git a/packages/cli/src/commands/provider/ls.ts b/packages/cli/src/commands/provider/ls.ts index 233020718..dd7bc8912 100644 --- a/packages/cli/src/commands/provider/ls.ts +++ b/packages/cli/src/commands/provider/ls.ts @@ -23,11 +23,29 @@ const PROVIDERS: ProviderListItem[] = [ defaultMode: "auto", modes: "read-only, auto, full-access", }, + { + provider: "gemini", + status: "available", + defaultMode: "-", + modes: "-", + }, + { + provider: "amp", + status: "available", + defaultMode: "-", + modes: "-", + }, + { + provider: "aider", + status: "available", + defaultMode: "-", + modes: "-", + }, { provider: "opencode", status: "available", - defaultMode: "default", - modes: "plan, default, bypass", + defaultMode: "build", + modes: "build, plan", }, ]; diff --git a/packages/cli/src/utils/timeline.ts b/packages/cli/src/utils/timeline.ts index e49b0244e..2390b00a5 100644 --- a/packages/cli/src/utils/timeline.ts +++ b/packages/cli/src/utils/timeline.ts @@ -11,7 +11,6 @@ export async function fetchProjectedTimelineItems( const timeline = await input.client.fetchAgentTimeline(input.agentId, { direction: "tail", limit: 0, - projection: "projected", }); return timeline.entries.map((entry) => entry.item); } diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 7d8385c79..6d6cf6db1 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -7,6 +7,15 @@ }, "main": "./dist/index.js", "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": [ + "./dist/index.js", + "./src/index.ts" + ] + } + }, "files": [ "dist" ], diff --git a/packages/server/drizzle.config.ts b/packages/server/drizzle.config.ts new file mode 100644 index 000000000..9920fcb16 --- /dev/null +++ b/packages/server/drizzle.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "./packages/server/src/server/db/schema.ts", + out: "./packages/server/src/server/db/migrations", + dialect: "sqlite", + strict: true, + verbose: true, +}); diff --git a/packages/server/package.json b/packages/server/package.json index 236338365..77d96f035 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -35,7 +35,7 @@ "dev": "NODE_ENV=development tsx scripts/dev-runner.ts", "dev:tsx": "NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts", "build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && npm run build:lib && npm run build:scripts", - "build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx');\"", + "build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true});\"", "build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"", "prepack": "npm run build", "start": "NODE_ENV=production node dist/server/server/index.js", @@ -45,6 +45,7 @@ "speech:download": "tsx scripts/download-speech-models.ts", "speech:tts:matrix": "tsx scripts/generate-sherpa-tts-matrix.ts", "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", + "db:query": "tsx scripts/db-query.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --exclude \"**/*.e2e.test.ts\"", "test:integration": "vitest run --maxWorkers=1 --minWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts", @@ -73,6 +74,8 @@ "ai": "5.0.78", "ajv": "^8.17.1", "dotenv": "^17.2.3", + "better-sqlite3": "^12.8.0", + "drizzle-orm": "^0.45.1", "express": "^4.18.2", "express-basic-auth": "^1.2.1", "fast-uri": "^3.1.0", @@ -96,6 +99,8 @@ }, "devDependencies": { "@playwright/test": "^1.56.1", + "@types/better-sqlite3": "^7.6.13", + "drizzle-kit": "^0.31.10", "@types/express": "^4.17.20", "@types/node": "^20.9.0", "@types/qrcode": "^1.5.6", diff --git a/packages/server/scripts/db-query.ts b/packages/server/scripts/db-query.ts new file mode 100644 index 000000000..ffa603830 --- /dev/null +++ b/packages/server/scripts/db-query.ts @@ -0,0 +1,140 @@ +#!/usr/bin/env npx tsx +/** + * Run arbitrary SQL against the Paseo SQLite database. + * + * Usage: + * npx tsx packages/server/scripts/db-query.ts "SELECT * FROM agent_snapshots" + * npx tsx packages/server/scripts/db-query.ts --db ~/.paseo/db "SELECT count(*) FROM agent_timeline_rows" + * + * Without args, shows table row counts. + */ + +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs"; +import Database from "better-sqlite3"; + +function resolveHomeDirectory(value: string): string { + if (value === "~") { + return os.homedir(); + } + + if (value.startsWith("~/")) { + return path.join(os.homedir(), value.slice(2)); + } + + return value; +} + +function parseListenPort(listen: unknown): number | null { + if (typeof listen !== "string") { + return null; + } + + const portMatch = listen.match(/:(\d+)$/); + return portMatch ? parseInt(portMatch[1]!, 10) : null; +} + +function findDevDatabaseDirectory(): string | null { + const tmpDir = os.tmpdir(); + for (const entry of fs.readdirSync(tmpDir)) { + if (entry.startsWith("paseo-dev.")) { + const configPath = path.join(tmpDir, entry, "config.json"); + if (fs.existsSync(configPath)) { + try { + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); + const dbDir = config.paseoHome ? path.join(config.paseoHome, "db") : null; + const port = parseListenPort(config.daemon?.listen); + if (dbDir && port === 6767) { + return dbDir; + } + } catch {} + } + } + } + + return null; +} + +function resolveDatabasePath(explicitPath?: string): string { + if (explicitPath) { + const resolvedPath = path.resolve(resolveHomeDirectory(explicitPath)); + return fs.statSync(resolvedPath).isDirectory() + ? path.join(resolvedPath, "paseo.sqlite") + : resolvedPath; + } + + const detectedDevDir = findDevDatabaseDirectory(); + if (detectedDevDir) { + return path.join(detectedDevDir, "paseo.sqlite"); + } + + const paseoHome = process.env.PASEO_HOME + ? path.resolve(resolveHomeDirectory(process.env.PASEO_HOME)) + : path.join(os.homedir(), ".paseo"); + return path.join(paseoHome, "db", "paseo.sqlite"); +} + +async function main() { + const args = process.argv.slice(2); + let dbPath: string | undefined; + const queries: string[] = []; + + for (let i = 0; i < args.length; i++) { + if (args[i] === "--db" && args[i + 1]) { + dbPath = args[++i]; + } else { + queries.push(args[i]!); + } + } + + if (queries.length === 0) { + queries.push( + "SELECT 'agent_snapshots' AS table_name, count(*) AS rows FROM agent_snapshots UNION ALL " + + "SELECT 'agent_timeline_rows', count(*) FROM agent_timeline_rows UNION ALL " + + "SELECT 'projects', count(*) FROM projects UNION ALL " + + "SELECT 'workspaces', count(*) FROM workspaces " + + "ORDER BY table_name", + ); + } + + let databasePath = ""; + let client: Database.Database | null = null; + + try { + if (dbPath) { + const resolvedDbPath = path.resolve(resolveHomeDirectory(dbPath)); + databasePath = + fs.existsSync(resolvedDbPath) && fs.statSync(resolvedDbPath).isDirectory() + ? path.join(resolvedDbPath, "paseo.sqlite") + : resolvedDbPath; + } else { + databasePath = resolveDatabasePath(); + } + + client = new Database(databasePath, { readonly: true, fileMustExist: true }); + + for (const sql of queries) { + const statement = client.prepare(sql); + if (statement.reader) { + const rows = statement.all(); + if (rows.length === 0) { + console.log("(0 rows)\n"); + } else { + console.table(rows); + } + continue; + } + + const result = statement.run(); + console.log(`OK (${result.changes} changes)\n`); + } + } catch (err: any) { + console.error(`Error: ${err.message}\nDatabase: ${databasePath}`); + process.exitCode = 1; + } finally { + client?.close(); + } +} + +main(); diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index 144032ed8..c28c4e3f7 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -1704,24 +1704,15 @@ describe("DaemonClient", () => { agentId: "agent_cli", agent: null, direction: "tail", - projection: "projected", - epoch: "epoch-1", - reset: false, - staleCursor: false, - gap: false, - window: { minSeq: 1, maxSeq: 1, nextSeq: 2 }, - startCursor: { epoch: "epoch-1", seq: 1 }, - endCursor: { epoch: "epoch-1", seq: 1 }, + startSeq: 1, + endSeq: 1, hasOlder: false, hasNewer: false, entries: [ { timestamp: "2026-02-08T20:20:00.000Z", provider: "codex", - seqStart: 1, - seqEnd: 1, - sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }], - collapsed: [], + seq: 1, item: { type: "tool_call", callId: "call_cli_snapshot", @@ -1798,24 +1789,15 @@ describe("DaemonClient", () => { agentId: "agent_cli", agent: null, direction: "tail", - projection: "projected", - epoch: "epoch-1", - reset: false, - staleCursor: false, - gap: false, - window: { minSeq: 1, maxSeq: 1, nextSeq: 2 }, - startCursor: { epoch: "epoch-1", seq: 1 }, - endCursor: { epoch: "epoch-1", seq: 1 }, + startSeq: 1, + endSeq: 1, hasOlder: false, hasNewer: false, entries: [ { timestamp: "2026-02-08T20:20:00.000Z", provider: "codex", - seqStart: 1, - seqEnd: 1, - sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }], - collapsed: [], + seq: 1, item: { type: "tool_call", callId: "call_cli_invalid", diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 8f1c1178e..49762baed 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -119,9 +119,9 @@ export type DaemonEvent = agentId: string; payload: Extract["payload"]; } - | { + | { type: "workspace_update"; - workspaceId: string; + workspaceId: number; payload: Extract["payload"]; } | { @@ -130,7 +130,6 @@ export type DaemonEvent = event: AgentStreamEventPayload; timestamp: string; seq?: number; - epoch?: string; } | { type: "status"; payload: { status: string } & Record } | { type: "agent_deleted"; agentId: string } @@ -182,6 +181,7 @@ export type CreateAgentRequestOptions = { config?: AgentSessionConfig; provider?: AgentProvider; cwd?: string; + workspaceId?: number; initialPrompt?: string; clientMessageId?: string; outputSchema?: Record; @@ -320,13 +320,13 @@ type ScheduleDeletePayload = Extract< export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"]; export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"]; -export type FetchAgentTimelineProjection = FetchAgentTimelinePayload["projection"]; -export type FetchAgentTimelineCursor = NonNullable; +export type FetchAgentTimelineCursor = NonNullable< + Extract["cursor"] +>; export type FetchAgentTimelineOptions = { direction?: FetchAgentTimelineDirection; cursor?: FetchAgentTimelineCursor; limit?: number; - projection?: FetchAgentTimelineProjection; requestId?: string; }; @@ -1301,7 +1301,7 @@ export class DaemonClient { } async archiveWorkspace( - workspaceId: string, + workspaceId: number, requestId?: string, ): Promise { return this.sendCorrelatedSessionRequest({ @@ -1386,6 +1386,7 @@ export class DaemonClient { type: "create_agent_request", requestId, config, + ...(typeof options.workspaceId === "number" ? { workspaceId: options.workspaceId } : {}), ...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}), ...(options.clientMessageId ? { clientMessageId: options.clientMessageId } : {}), ...(options.outputSchema ? { outputSchema: options.outputSchema } : {}), @@ -1576,7 +1577,6 @@ export class DaemonClient { ...(options.direction ? { direction: options.direction } : {}), ...(options.cursor ? { cursor: options.cursor } : {}), ...(typeof options.limit === "number" ? { limit: options.limit } : {}), - ...(options.projection ? { projection: options.projection } : {}), }); const payload = await this.sendRequest({ @@ -2733,12 +2733,16 @@ export class DaemonClient { cwd: string, name?: string, requestId?: string, + options?: { agentId?: string; command?: string; args?: string[] }, ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "create_terminal_request", cwd, name, + agentId: options?.agentId, + command: options?.command, + args: options?.args, requestId: resolvedRequestId, }); return this.sendCorrelatedRequest({ @@ -3552,7 +3556,6 @@ export class DaemonClient { event: msg.payload.event, timestamp: msg.payload.timestamp, ...(typeof msg.payload.seq === "number" ? { seq: msg.payload.seq } : {}), - ...(typeof msg.payload.epoch === "string" ? { epoch: msg.payload.epoch } : {}), }; case "status": return { type: "status", payload: msg.payload }; @@ -3654,6 +3657,7 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon config, provider, cwd, + workspaceId: _workspaceId, initialPrompt: _initialPrompt, images: _images, git: _git, diff --git a/packages/server/src/server/agent-loading-service.ts b/packages/server/src/server/agent-loading-service.ts new file mode 100644 index 000000000..313aef944 --- /dev/null +++ b/packages/server/src/server/agent-loading-service.ts @@ -0,0 +1,128 @@ +import type pino from "pino"; + +import type { ManagedAgent } from "./agent/agent-manager.js"; +import type { AgentManager } from "./agent/agent-manager.js"; +import type { AgentPersistenceHandle, AgentSessionConfig } from "./agent/agent-sdk-types.js"; +import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js"; +import { + buildConfigOverrides, + buildSessionConfig, + extractTimestamps, + toAgentPersistenceHandle, +} from "./persistence-hooks.js"; + +const pendingAgentBootstrapLoads = new Map>(); + +export type AgentLoadingServiceOptions = { + agentManager: Pick< + AgentManager, + | "createAgent" + | "getAgent" + | "reloadAgentSession" + | "resumeAgentFromPersistence" + >; + agentStorage: Pick; + logger: pino.Logger; +}; + +// Coordinates cold loads, explicit resumes, and refreshes for persisted agents. +export class AgentLoadingService { + private readonly agentManager: AgentLoadingServiceOptions["agentManager"]; + private readonly agentStorage: AgentLoadingServiceOptions["agentStorage"]; + private readonly logger: pino.Logger; + + constructor(options: AgentLoadingServiceOptions) { + this.agentManager = options.agentManager; + this.agentStorage = options.agentStorage; + this.logger = options.logger.child({ component: "agent-loading" }); + } + + async ensureAgentLoaded(options: { agentId: string }): Promise { + const existing = this.agentManager.getAgent(options.agentId); + if (existing) { + return existing; + } + + const inflight = pendingAgentBootstrapLoads.get(options.agentId); + if (inflight) { + return inflight; + } + + const initPromise = this.loadStoredAgent(options); + pendingAgentBootstrapLoads.set(options.agentId, initPromise); + + try { + return await initPromise; + } finally { + const current = pendingAgentBootstrapLoads.get(options.agentId); + if (current === initPromise) { + pendingAgentBootstrapLoads.delete(options.agentId); + } + } + } + + async resumeAgent(options: { + handle: AgentPersistenceHandle; + overrides?: Partial; + }): Promise { + return this.agentManager.resumeAgentFromPersistence(options.handle, options.overrides); + } + + async refreshAgent(options: { agentId: string }): Promise { + const existing = this.agentManager.getAgent(options.agentId); + if (existing) { + return existing.persistence + ? await this.agentManager.reloadAgentSession(options.agentId) + : existing; + } + + const record = await this.agentStorage.get(options.agentId); + if (!record) { + throw new Error(`Agent not found: ${options.agentId}`); + } + + const handle = toAgentPersistenceHandle(this.logger, record.persistence); + if (!handle) { + throw new Error(`Agent ${options.agentId} cannot be refreshed because it lacks persistence`); + } + + return this.agentManager.resumeAgentFromPersistence( + handle, + buildConfigOverrides(record), + options.agentId, + extractTimestamps(record), + ); + } + + private async loadStoredAgent(options: { agentId: string }): Promise { + const record = await this.agentStorage.get(options.agentId); + if (!record) { + throw new Error(`Agent not found: ${options.agentId}`); + } + + const handle = toAgentPersistenceHandle(this.logger, record.persistence); + let snapshot: ManagedAgent; + if (handle) { + snapshot = await this.agentManager.resumeAgentFromPersistence( + handle, + buildConfigOverrides(record), + options.agentId, + extractTimestamps(record), + ); + this.logger.info( + { agentId: options.agentId, provider: record.provider }, + "Agent resumed from persistence", + ); + } else { + snapshot = await this.agentManager.createAgent(buildSessionConfig(record), options.agentId, { + labels: record.labels, + }); + this.logger.info( + { agentId: options.agentId, provider: record.provider }, + "Agent created from stored config", + ); + } + + return this.agentManager.getAgent(options.agentId) ?? snapshot; + } +} diff --git a/packages/server/src/server/agent/agent-management-mcp.ts b/packages/server/src/server/agent/agent-management-mcp.ts index 14086d973..375f0e1bc 100644 --- a/packages/server/src/server/agent/agent-management-mcp.ts +++ b/packages/server/src/server/agent/agent-management-mcp.ts @@ -37,7 +37,7 @@ import { import { toAgentPayload } from "./agent-projections.js"; import { curateAgentActivity } from "./activity-curator.js"; import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js"; -import { AgentStorage } from "./agent-storage.js"; +import type { AgentSnapshotStore } from "./agent-snapshot-store.js"; import { appendTimelineItemIfAgentKnown, emitLiveTimelineItemIfAgentKnown, @@ -51,7 +51,7 @@ import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-boot export interface AgentManagementMcpOptions { agentManager: AgentManager; - agentStorage: AgentStorage; + agentStorage: AgentSnapshotStore; terminalManager?: TerminalManager | null; paseoHome?: string; logger: Logger; @@ -178,7 +178,7 @@ function sanitizePermissionRequest( } async function resolveAgentTitle( - agentStorage: AgentStorage, + agentStorage: AgentSnapshotStore, agentId: string, logger: Logger, ): Promise { @@ -192,7 +192,7 @@ async function resolveAgentTitle( } async function serializeSnapshotWithMetadata( - agentStorage: AgentStorage, + agentStorage: AgentSnapshotStore, snapshot: ManagedAgent, logger: Logger, ) { diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index eceaadd11..26f1c8606 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -1,12 +1,19 @@ import { describe, expect, test, vi } from "vitest"; -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; import { createTestLogger } from "../../test-utils/test-logger.js"; -import { AgentManager } from "./agent-manager.js"; +import { DbAgentSnapshotStore } from "../db/db-agent-snapshot-store.js"; +import { DbAgentTimelineStore } from "../db/db-agent-timeline-store.js"; +import { openPaseoDatabase, type PaseoDatabaseHandle } from "../db/sqlite-database.js"; +import { projects, workspaces } from "../db/schema.js"; +import { AgentManager, type AgentManagerEvent } from "./agent-manager.js"; import { AgentStorage } from "./agent-storage.js"; +import type { TerminalManager } from "../../terminal/terminal-manager.js"; +import { createTerminalManager } from "../../terminal/terminal-manager.js"; +import type { TerminalExitInfo, TerminalSession } from "../../terminal/terminal.js"; import type { AgentClient, AgentLaunchContext, @@ -82,8 +89,45 @@ const TEST_CAPABILITIES = { supportsMcpServers: false, supportsReasoningStream: false, supportsToolInvocations: false, + supportsTerminalMode: false, } as const; +const TERMINAL_TEST_CAPABILITIES = { + ...TEST_CAPABILITIES, + supportsTerminalMode: true, +} as const; + +async function seedWorkspace( + database: PaseoDatabaseHandle, + options: { directory: string }, +): Promise { + const [project] = await database.db + .insert(projects) + .values({ + directory: options.directory, + kind: "git", + displayName: "project-1", + gitRemote: null, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + }) + .returning(); + const [workspace] = await database.db + .insert(workspaces) + .values({ + projectId: project.id, + directory: options.directory, + kind: "checkout", + displayName: "main", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + }) + .returning(); + return workspace.id; +} + class TestAgentClient implements AgentClient { readonly provider = "codex" as const; readonly capabilities = TEST_CAPABILITIES; @@ -197,9 +241,709 @@ class TestAgentSession implements AgentSession { async close(): Promise {} } +class StreamingAssistantSession implements AgentSession { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + readonly id = randomUUID(); + private subscribers = new Set<(event: AgentStreamEvent) => void>(); + private turnIdCounter = 0; + + constructor(private readonly config: AgentSessionConfig) {} + + async run(): Promise { + return { + sessionId: this.id, + finalText: "", + timeline: [], + }; + } + + async startTurn(): Promise<{ turnId: string }> { + const turnId = `turn-${++this.turnIdCounter}`; + setTimeout(() => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + this.pushEvent({ + type: "timeline", + provider: this.provider, + turnId, + item: { type: "assistant_message", text: "final " }, + }); + this.pushEvent({ + type: "timeline", + provider: this.provider, + turnId, + item: { type: "assistant_message", text: "reply" }, + }); + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + }, 0); + return { turnId }; + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.subscribers.add(callback); + return () => { + this.subscribers.delete(callback); + }; + } + + private pushEvent(event: AgentStreamEvent): void { + for (const callback of this.subscribers) { + callback(event); + } + } + + async *streamHistory(): AsyncGenerator {} + + async getRuntimeInfo() { + return { + provider: this.provider, + sessionId: this.id, + model: this.config.model ?? null, + modeId: this.config.modeId ?? null, + }; + } + + async getAvailableModes() { + return []; + } + + async getCurrentMode() { + return null; + } + + async setMode(): Promise {} + + getPendingPermissions() { + return []; + } + + async respondToPermission(): Promise {} + + describePersistence() { + return { + provider: this.provider, + sessionId: this.id, + }; + } + + async interrupt(): Promise {} + + async close(): Promise {} +} + +class StreamingAssistantClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + + async isAvailable(): Promise { + return true; + } + + async createSession(config: AgentSessionConfig): Promise { + return new StreamingAssistantSession(config); + } + + async resumeSession( + _handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + return new StreamingAssistantSession({ + provider: "codex", + cwd: config?.cwd ?? process.cwd(), + }); + } +} + +class TerminalTestAgentClient extends TestAgentClient { + override readonly capabilities = TERMINAL_TEST_CAPABILITIES; + public lastTerminalCreateHandle: AgentPersistenceHandle | null = null; + public lastTerminalInitialPrompt: string | undefined; + + override buildTerminalCreateCommand( + _config: AgentSessionConfig, + handle: AgentPersistenceHandle, + initialPrompt?: string, + ) { + this.lastTerminalCreateHandle = handle; + this.lastTerminalInitialPrompt = initialPrompt; + return { + command: "terminal-test-cli", + args: ["--session-id", handle.sessionId], + env: { TEST_SESSION_ID: handle.sessionId }, + }; + } + + override buildTerminalResumeCommand(handle: AgentPersistenceHandle) { + return { + command: "terminal-test-cli", + args: ["resume", handle.nativeHandle ?? handle.sessionId], + }; + } +} + +function createStubTerminalManager(): TerminalManager { + const terminals = new Map< + string, + TerminalSession & { + emitExit: (info?: TerminalExitInfo) => void; + emitTitleChange: (title?: string) => void; + } + >(); + return { + async getTerminals() { + return Array.from(terminals.values()); + }, + async createTerminal(options) { + const id = options.id ?? `term-${terminals.size + 1}`; + const exitListeners = new Set<(info: TerminalExitInfo) => void>(); + const titleListeners = new Set<(title?: string) => void>(); + let title: string | undefined; + let exitInfo: TerminalExitInfo | null = null; + const session: TerminalSession & { + emitExit: (info?: TerminalExitInfo) => void; + emitTitleChange: (title?: string) => void; + } = { + id, + name: options.name ?? "Terminal", + cwd: options.cwd, + send: () => {}, + subscribe: () => () => {}, + onExit(listener) { + exitListeners.add(listener); + return () => { + exitListeners.delete(listener); + }; + }, + onTitleChange(listener) { + titleListeners.add(listener); + return () => { + titleListeners.delete(listener); + }; + }, + getSize: () => ({ rows: 24, cols: 80 }), + getState: () => ({ rows: 24, cols: 80, cursor: { row: 0, col: 0 }, scrollback: [], grid: [] }), + getTitle() { + return title; + }, + getExitInfo() { + return exitInfo; + }, + kill() { + for (const listener of Array.from(exitListeners)) { + listener(exitInfo ?? { exitCode: null, signal: null, lastOutputLines: [] }); + } + }, + emitExit(info = { exitCode: null, signal: null, lastOutputLines: [] }) { + exitInfo = info; + for (const listener of Array.from(exitListeners)) { + listener(info); + } + }, + emitTitleChange(nextTitle) { + title = nextTitle; + for (const listener of Array.from(titleListeners)) { + listener(nextTitle); + } + }, + }; + terminals.set(id, session); + return session; + }, + registerCwdEnv() {}, + getTerminal(id) { + return terminals.get(id); + }, + killTerminal(id) { + terminals.get(id)?.kill(); + terminals.delete(id); + }, + listDirectories() { + return []; + }, + killAll() { + terminals.clear(); + }, + subscribeTerminalsChanged() { + return () => {}; + }, + }; +} + describe("AgentManager", () => { const logger = createTestLogger(); + test("terminal agents persist a deterministic handle and expose terminal kind after unload", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new TerminalTestAgentClient(); + const manager = new AgentManager({ + clients: { codex: client }, + registry: storage, + terminalManager: createStubTerminalManager(), + logger, + idFactory: () => "00000000-0000-4000-8000-0000000073e1", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + terminal: true, + }); + + expect(snapshot.terminal).toBe(true); + expect(snapshot.persistence).toMatchObject({ + provider: "codex", + sessionId: "00000000-0000-4000-8000-0000000073e1", + nativeHandle: "00000000-0000-4000-8000-0000000073e1", + }); + expect(client.lastTerminalCreateHandle?.sessionId).toBe("00000000-0000-4000-8000-0000000073e1"); + expect(client.lastTerminalInitialPrompt).toBeUndefined(); + expect(await manager.getAgentKind(snapshot.id)).toBe("terminal"); + + await manager.closeAgent(snapshot.id); + + expect(await manager.getAgentKind(snapshot.id)).toBe("terminal"); + expect(await manager.getStructuredSendRejection(snapshot.id)).toBe( + "Terminal agents do not support structured send operations", + ); + }); + + test("terminal agents reserve the terminal binding before terminal creation completes", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-binding-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new TerminalTestAgentClient(); + let manager: AgentManager; + const terminalManager: TerminalManager = { + async getTerminals() { + return []; + }, + async createTerminal(options) { + expect(options.id).toBeTruthy(); + expect(manager.isTerminalBoundToAgent(options.id!)).toBe(true); + const exitListeners = new Set<(info: TerminalExitInfo) => void>(); + return { + id: options.id!, + name: options.name ?? "Terminal", + cwd: options.cwd, + send: () => {}, + subscribe: () => () => {}, + onExit(listener) { + exitListeners.add(listener); + return () => { + exitListeners.delete(listener); + }; + }, + getSize: () => ({ rows: 24, cols: 80 }), + getState: () => ({ rows: 24, cols: 80, cursor: { row: 0, col: 0 }, scrollback: [], grid: [] }), + getTitle: () => undefined, + getExitInfo: () => null, + onTitleChange: () => () => {}, + kill() { + for (const listener of Array.from(exitListeners)) { + listener({ exitCode: null, signal: null, lastOutputLines: [] }); + } + }, + }; + }, + registerCwdEnv() {}, + getTerminal() { + return undefined; + }, + killTerminal() {}, + listDirectories() { + return []; + }, + killAll() {}, + subscribeTerminalsChanged() { + return () => {}; + }, + }; + + manager = new AgentManager({ + clients: { codex: client }, + registry: storage, + terminalManager, + logger, + idFactory: () => "00000000-0000-4000-8000-0000000b01d0", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + terminal: true, + }); + + expect(snapshot.terminalId).toBeTruthy(); + expect(manager.isTerminalBoundToAgent(snapshot.terminalId!)).toBe(true); + }); + + test("setTitle persists and emits state for live terminal agents", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-title-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new TerminalTestAgentClient() }, + registry: storage, + terminalManager: createStubTerminalManager(), + logger, + idFactory: () => "00000000-0000-4000-8000-00000000aa11", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + terminal: true, + }); + let stateEventCount = 0; + const unsubscribe = manager.subscribe((event) => { + if (event.type === "agent_state" && event.agent.id === snapshot.id) { + stateEventCount += 1; + } + }, { agentId: snapshot.id, replayState: false }); + + await manager.setTitle(snapshot.id, "Agent Shell"); + + const stored = await storage.get(snapshot.id); + expect(stored?.title).toBe("Agent Shell"); + expect(manager.getAgentIdForTerminal(snapshot.terminalId!)).toBe(snapshot.id); + expect(stateEventCount).toBe(1); + + unsubscribe(); + }); + + test("terminal agent creation ignores title propagation before the initial snapshot is persisted", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-title-race-")); + const dataDir = join(workdir, "db"); + const database = await openPaseoDatabase(dataDir); + let manager: AgentManager | null = null; + + try { + const workspaceId = await seedWorkspace(database, { directory: workdir }); + const storage = new DbAgentSnapshotStore(database.db); + const terminalManager: TerminalManager = { + async getTerminals() { + return []; + }, + async createTerminal(options) { + const exitListeners = new Set<(info: TerminalExitInfo) => void>(); + const titleListeners = new Set<(title?: string) => void>(); + const session: TerminalSession = { + id: options.id, + name: options.name ?? "Terminal", + cwd: options.cwd, + send: () => {}, + subscribe: () => () => {}, + onExit(listener) { + exitListeners.add(listener); + return () => { + exitListeners.delete(listener); + }; + }, + onTitleChange(listener) { + titleListeners.add(listener); + return () => { + titleListeners.delete(listener); + }; + }, + getSize: () => ({ rows: 24, cols: 80 }), + getState: () => ({ + rows: 24, + cols: 80, + cursor: { row: 0, col: 0 }, + scrollback: [], + grid: [], + }), + getTitle: () => "Agent Shell", + getExitInfo: () => null, + kill() { + for (const listener of Array.from(exitListeners)) { + listener({ exitCode: null, signal: null, lastOutputLines: [] }); + } + }, + }; + + const agentId = manager?.getAgentIdForTerminal(options.id) ?? null; + if (agentId) { + await manager?.setTitle(agentId, "Agent Shell"); + } + + return session; + }, + registerCwdEnv() {}, + getTerminal() { + return undefined; + }, + killTerminal() {}, + listDirectories() { + return []; + }, + killAll() {}, + subscribeTerminalsChanged() { + return () => {}; + }, + }; + + manager = new AgentManager({ + clients: { codex: new TerminalTestAgentClient() }, + registry: storage, + terminalManager, + logger, + idFactory: () => "00000000-0000-4000-8000-00000000aa13", + }); + + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + terminal: true, + }, + undefined, + { workspaceId }, + ); + + const stored = await storage.get(snapshot.id); + expect(stored?.title).toBe("Agent Shell"); + } finally { + await database.close(); + rmSync(workdir, { recursive: true, force: true }); + } + }); + + test("terminal agent creation preserves titles propagated during terminal registration", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-registration-title-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const scriptPath = join(workdir, "npm-cli.js"); + let manager: AgentManager | null = null; + + const terminalManager = createTerminalManager({ + resolveAgentIdForTerminal: (terminalId) => manager?.getAgentIdForTerminal(terminalId) ?? null, + onAgentBoundTerminalTitleChange: async ({ agentId, title }) => { + if (!manager) { + return; + } + await manager.setTitle(agentId, title); + }, + }); + + class TitleReplayTerminalAgentClient extends TerminalTestAgentClient { + override buildTerminalCreateCommand( + _config: AgentSessionConfig, + handle: AgentPersistenceHandle, + ) { + return { + command: process.execPath, + args: [scriptPath, "--session-id", handle.sessionId], + }; + } + } + + manager = new AgentManager({ + clients: { codex: new TitleReplayTerminalAgentClient() }, + registry: storage, + terminalManager, + logger, + idFactory: () => "00000000-0000-4000-8000-00000000aa12", + }); + + writeFileSync(scriptPath, "setTimeout(() => process.exit(0), 1000);\n"); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + terminal: true, + }); + + const deadline = Date.now() + 2000; + let storedTitle: string | null = null; + while (Date.now() < deadline) { + storedTitle = (await storage.get(snapshot.id))?.title ?? null; + if (storedTitle?.startsWith("npm --session-id ")) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + + expect(storedTitle?.startsWith("npm --session-id ")).toBe(true); + + terminalManager.killAll(); + }); + + test("getMetricsSnapshot skips agents without in-memory timeline state", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-metrics-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new TerminalTestAgentClient() }, + registry: storage, + terminalManager: createStubTerminalManager(), + logger, + idFactory: () => "00000000-0000-4000-8000-00000000aa14", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + terminal: true, + }); + + expect(manager.getMetricsSnapshot()).toEqual({ + total: 1, + byLifecycle: { idle: 1 }, + withActiveForegroundTurn: 0, + timelineStats: { + totalItems: 0, + maxItemsPerAgent: 0, + }, + }); + expect(snapshot.terminal).toBe(true); + }); + + test("terminal agent closure preserves exit diagnostics for failed launches", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-exit-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new TerminalTestAgentClient() }, + registry: storage, + terminalManager: createStubTerminalManager(), + logger, + idFactory: () => "00000000-0000-4000-8000-00000000aa12", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + terminal: true, + }); + + const terminal = manager.getTerminalSessionForAgent(snapshot.id) as TerminalSession & { + emitExit: (info?: TerminalExitInfo) => void; + }; + expect(terminal).toBeTruthy(); + + let closedEvent: Extract["agent"] | null = null; + manager.subscribe( + (event) => { + if (event.type === "agent_state" && event.agent.id === snapshot.id) { + closedEvent = event.agent; + } + }, + { agentId: snapshot.id, replayState: false }, + ); + + terminal.emitExit({ + exitCode: 127, + signal: null, + lastOutputLines: ["gemini: command not found"], + }); + + await vi.waitFor(async () => { + const stored = await storage.get(snapshot.id); + expect(stored?.terminalExit).toEqual({ + command: "terminal-test-cli", + message: "gemini: command not found", + exitCode: 127, + signal: null, + outputLines: ["gemini: command not found"], + }); + expect(stored?.lastError).toContain("Exit code: 127"); + }); + + expect(closedEvent?.lifecycle).toBe("closed"); + expect(closedEvent?.lastError).toContain("gemini: command not found"); + expect(closedEvent?.terminalExit).toEqual({ + command: "terminal-test-cli", + message: "gemini: command not found", + exitCode: 127, + signal: null, + outputLines: ["gemini: command not found"], + }); + }); + + test("structured send rejection is null for managed agents", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-session-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000100", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + expect(await manager.getAgentKind(snapshot.id)).toBe("session"); + expect(await manager.getStructuredSendRejection(snapshot.id)).toBeNull(); + }); + + test("createAgent passes initialPrompt into terminal command builders", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-prompt-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new TerminalTestAgentClient(); + const terminalManager: TerminalManager = { + async getTerminals() { + return []; + }, + async createTerminal(options) { + const exitListeners = new Set<(info: TerminalExitInfo) => void>(); + return { + id: options.id ?? "00000000-0000-4000-8000-00000000abcd", + name: options.name ?? "Terminal", + cwd: options.cwd, + send: () => {}, + subscribe: () => () => {}, + onExit(listener) { + exitListeners.add(listener); + return () => { + exitListeners.delete(listener); + }; + }, + getSize: () => ({ rows: 24, cols: 80 }), + getState: () => ({ rows: 24, cols: 80, cursor: { row: 0, col: 0 }, scrollback: [], grid: [] }), + getTitle: () => undefined, + getExitInfo: () => null, + onTitleChange: () => () => {}, + kill() { + for (const listener of Array.from(exitListeners)) { + listener({ exitCode: null, signal: null, lastOutputLines: [] }); + } + }, + }; + }, + registerCwdEnv() {}, + getTerminal() { + return undefined; + }, + killTerminal() {}, + listDirectories() { + return []; + }, + killAll() {}, + subscribeTerminalsChanged() { + return () => {}; + }, + }; + const manager = new AgentManager({ + clients: { codex: client }, + registry: storage, + terminalManager, + logger, + idFactory: () => "00000000-0000-4000-8000-00000000abcd", + }); + + await manager.createAgent( + { + provider: "codex", + cwd: workdir, + terminal: true, + }, + undefined, + { initialPrompt: "Implement terminal prompt routing" }, + ); + + expect(client.lastTerminalInitialPrompt).toBe("Implement terminal prompt routing"); + }); + test("normalizeConfig does not inject default model when omitted", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); @@ -569,36 +1313,213 @@ describe("AgentManager", () => { test("reloadAgentSession preserves current title when config title is unset", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-title-")); + const dataDir = join(workdir, "db"); + const database = await openPaseoDatabase(dataDir); + + try { + const workspaceId = await seedWorkspace(database, { directory: workdir }); + const storage = new DbAgentSnapshotStore(database.db); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000126", + }); + + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId }, + ); + await manager.setTitle(snapshot.id, "Generated title"); + + const beforeReload = await storage.get(snapshot.id); + expect(beforeReload?.title).toBe("Generated title"); + expect(beforeReload?.config?.title).toBeUndefined(); + + await manager.reloadAgentSession(snapshot.id); + + const afterReload = await storage.get(snapshot.id); + expect(afterReload?.title).toBe("Generated title"); + expect(afterReload?.config?.title).toBeUndefined(); + } finally { + await database.close(); + rmSync(workdir, { recursive: true, force: true }); + } + }); + + test("resumeAgentFromPersistence reads durable helpers without loading committed rows into live memory", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-durable-seed-")); const storagePath = join(workdir, "agents"); + const dataDir = join(workdir, "db"); const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000126", - }); + const database = await openPaseoDatabase(dataDir); + let historyReplayCount = 0; + let manager: AgentManager | null = null; - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - await manager.setTitle(snapshot.id, "Generated title"); + class HistoryReplayProbeSession extends TestAgentSession { + async *streamHistory(): AsyncGenerator { + historyReplayCount += 1; + yield { + type: "timeline", + provider: this.provider, + item: { type: "assistant_message", text: "provider history replay" }, + }; + } + } - const beforeReload = await storage.get(snapshot.id); - expect(beforeReload?.title).toBe("Generated title"); - expect(beforeReload?.config?.title).toBeUndefined(); + class HistoryReplayProbeClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; - await manager.reloadAgentSession(snapshot.id); + async isAvailable(): Promise { + return true; + } - const afterReload = await storage.get(snapshot.id); - expect(afterReload?.title).toBe("Generated title"); - expect(afterReload?.config?.title).toBeUndefined(); + async createSession(config: AgentSessionConfig): Promise { + return new HistoryReplayProbeSession(config); + } + + async resumeSession( + handle: AgentPersistenceHandle, + overrides?: Partial, + ): Promise { + const metadata = (handle.metadata ?? {}) as Partial; + return new HistoryReplayProbeSession({ + ...metadata, + ...overrides, + provider: "codex", + cwd: overrides?.cwd ?? metadata.cwd ?? process.cwd(), + }); + } + } + + try { + const durableTimelineStore = new DbAgentTimelineStore(database.db); + manager = new AgentManager({ + clients: { + codex: new HistoryReplayProbeClient(), + }, + registry: storage, + durableTimelineStore, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000128", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + await manager.appendTimelineItem(snapshot.id, { + type: "assistant_message", + text: "durable only", + }); + await manager.flush(); + + const handle = manager.getAgent(snapshot.id)?.persistence; + expect(handle).not.toBeNull(); + if (!handle) { + throw new Error("Expected persistence handle to be available"); + } + + await manager.closeAgent(snapshot.id); + + await expect(durableTimelineStore.getCommittedRows(snapshot.id)).resolves.toEqual([ + { + seq: 1, + timestamp: expect.any(String), + item: { + type: "assistant_message", + text: "durable only", + }, + }, + ]); + + const resumed = await manager.resumeAgentFromPersistence(handle, undefined, snapshot.id); + + expect(resumed.id).toBe(snapshot.id); + expect(manager.getTimeline(snapshot.id)).toEqual([]); + await expect(manager.getLastAssistantMessage(snapshot.id)).resolves.toBe("durable only"); + await expect(manager.getTimelineRows(snapshot.id)).resolves.toEqual([ + { + seq: 1, + timestamp: expect.any(String), + item: { + type: "assistant_message", + text: "durable only", + }, + }, + ]); + + await manager.hydrateTimelineFromProvider(snapshot.id); + + expect(historyReplayCount).toBe(0); + expect(manager.getTimeline(snapshot.id)).toEqual([]); + + await manager.closeAgent(snapshot.id); + await manager.deleteCommittedTimeline(snapshot.id); + + await expect(durableTimelineStore.getCommittedRows(snapshot.id)).resolves.toEqual([]); + } finally { + await manager?.flush().catch(() => undefined); + await storage.flush().catch(() => undefined); + await database.close(); + rmSync(workdir, { recursive: true, force: true }); + } }); test("setTitle bumps updatedAt and persists title in the same snapshot write", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-set-title-updated-at-")); + const dataDir = join(workdir, "db"); + const database = await openPaseoDatabase(dataDir); + + try { + const workspaceId = await seedWorkspace(database, { directory: workdir }); + const storage = new DbAgentSnapshotStore(database.db); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000127", + }); + + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId }, + ); + + const before = await storage.get(snapshot.id); + expect(before).not.toBeNull(); + + await manager.setTitle(snapshot.id, "Generated title"); + + const after = await storage.get(snapshot.id); + expect(after?.title).toBe("Generated title"); + expect(Date.parse(after!.updatedAt)).toBeGreaterThan(Date.parse(before!.updatedAt)); + + const live = manager.getAgent(snapshot.id); + expect(live).not.toBeNull(); + expect(live!.updatedAt.getTime()).toBeGreaterThan(Date.parse(before!.updatedAt)); + } finally { + await database.close(); + rmSync(workdir, { recursive: true, force: true }); + } + }); + + test("persists live mode, model, and thinking changes without an external snapshot subscriber", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-persist-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ @@ -607,26 +1528,132 @@ describe("AgentManager", () => { }, registry: storage, logger, - idFactory: () => "00000000-0000-4000-8000-000000000127", + idFactory: () => "00000000-0000-4000-8000-000000000132", }); const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir, + modeId: "plan", + model: "gpt-5.2-codex", + thinkingOptionId: "low", }); - const before = await storage.get(snapshot.id); - expect(before).not.toBeNull(); + await manager.setAgentMode(snapshot.id, "build"); + await manager.setAgentModel(snapshot.id, "gpt-5.4"); + await manager.setAgentThinkingOption(snapshot.id, "high"); + await manager.flush(); - await manager.setTitle(snapshot.id, "Generated title"); + const persisted = await storage.get(snapshot.id); + expect(persisted).not.toBeNull(); + expect(persisted?.lastModeId).toBe("build"); + expect(persisted?.config?.model).toBe("gpt-5.4"); + expect(persisted?.config?.thinkingOptionId).toBe("high"); + expect(persisted?.runtimeInfo?.modeId).toBe("build"); + expect(persisted?.runtimeInfo?.model).toBe("gpt-5.4"); + }); - const after = await storage.get(snapshot.id); - expect(after?.title).toBe("Generated title"); - expect(Date.parse(after!.updatedAt)).toBeGreaterThan(Date.parse(before!.updatedAt)); + test("setLabels merges and persists labels", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-set-labels-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000133", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Label test", + }); + + await manager.setLabels(snapshot.id, { surface: "mobile" }); + await manager.setLabels(snapshot.id, { phase: "1a" }); + + const persisted = await storage.get(snapshot.id); + expect(persisted?.labels).toEqual({ + surface: "mobile", + phase: "1a", + }); + }); + + test("runAgent persists finished attention and idle status without an external snapshot subscriber", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-finished-attention-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000134", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Finished attention test", + }); + + await manager.runAgent(snapshot.id, "say hello"); + await manager.flush(); + + const persisted = await storage.get(snapshot.id); + expect(persisted?.lastStatus).toBe("idle"); + expect(persisted?.requiresAttention).toBe(true); + expect(persisted?.attentionReason).toBe("finished"); + expect(persisted?.attentionTimestamp).toEqual(expect.any(String)); + }); + + test("archiveSnapshot clears persisted attention and normalizes running status", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archive-attention-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000135", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Archive attention test", + }); const live = manager.getAgent(snapshot.id); expect(live).not.toBeNull(); - expect(live!.updatedAt.getTime()).toBeGreaterThan(Date.parse(before!.updatedAt)); + live!.lifecycle = "running"; + live!.attention = { + requiresAttention: true, + attentionReason: "finished", + attentionTimestamp: new Date("2025-01-02T00:00:00.000Z"), + }; + + const archivedAt = "2025-01-03T00:00:00.000Z"; + const archivedRecord = await manager.archiveSnapshot(snapshot.id, archivedAt); + + expect(archivedRecord.archivedAt).toBe(archivedAt); + expect(archivedRecord.lastStatus).toBe("idle"); + expect(archivedRecord.requiresAttention).toBe(false); + expect(archivedRecord.attentionReason).toBeNull(); + expect(archivedRecord.attentionTimestamp).toBeNull(); + + const persisted = await storage.get(snapshot.id); + expect(persisted?.archivedAt).toBe(archivedAt); + expect(persisted?.lastStatus).toBe("idle"); + expect(persisted?.requiresAttention).toBe(false); + expect(persisted?.attentionReason).toBeNull(); + expect(persisted?.attentionTimestamp).toBeNull(); }); test("reloadAgentSession cancels active run and resumes existing session once thread_started is observed", async () => { @@ -784,7 +1811,7 @@ describe("AgentManager", () => { } }); - test("fetchTimeline returns full timeline with reset when cursor epoch is stale", async () => { + test("fetchTimeline returns committed rows after a known seq without reset metadata", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-stale-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); @@ -802,44 +1829,107 @@ describe("AgentManager", () => { cwd: workdir, }); - await manager.appendTimelineItem(snapshot.id, { - type: "assistant_message", - text: "one", - }); - await manager.appendTimelineItem(snapshot.id, { - type: "assistant_message", - text: "two", - }); - await manager.appendTimelineItem(snapshot.id, { - type: "assistant_message", - text: "three", - }); + for (let seq = 1; seq <= 120; seq += 1) { + await manager.appendTimelineItem(snapshot.id, { + type: "assistant_message", + text: `committed row ${seq}`, + }); + } - const baseline = manager.fetchTimeline(snapshot.id, { + const baseline = await manager.fetchTimeline(snapshot.id, { direction: "tail", - limit: 2, + limit: 0, }); - expect(baseline.rows).toHaveLength(2); + expect(baseline.rows).toHaveLength(120); - const result = manager.fetchTimeline(snapshot.id, { + await manager.emitLiveTimelineItem(snapshot.id, { + type: "assistant_message", + text: "partial reply", + }); + await manager.appendTimelineItem(snapshot.id, { + type: "assistant_message", + text: "finalized reply", + }); + + const result = await manager.fetchTimeline(snapshot.id, { direction: "after", cursor: { - epoch: "stale-epoch", - seq: baseline.rows[baseline.rows.length - 1]!.seq, + seq: 120, }, - limit: 1, + limit: 0, }); - expect(result.reset).toBe(true); - expect(result.staleCursor).toBe(true); - expect(result.gap).toBe(false); - expect(result.rows).toHaveLength(3); - expect(result.rows[0]?.seq).toBe(1); - expect(result.rows[result.rows.length - 1]?.seq).toBe(3); + expect(result.rows).toHaveLength(1); + expect(result.rows[0]?.seq).toBe(121); + expect(result.rows[0]?.item).toEqual({ + type: "assistant_message", + text: "finalized reply", + }); }); - test("emits live timeline updates without recording canonical timeline rows", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-timeline-")); + test("fetchTimeline and getTimelineRows prefer the durable store while live helpers stay in-memory", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-durable-read-authority-")); + const storagePath = join(workdir, "agents"); + const dataDir = join(workdir, "db"); + const storage = new AgentStorage(storagePath, logger); + const database = await openPaseoDatabase(dataDir); + + try { + const durableTimelineStore = new DbAgentTimelineStore(database.db); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + durableTimelineStore, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000139", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const durableOnlyItem: AgentTimelineItem = { + type: "assistant_message", + text: "durable only", + }; + const durableOnlyRow = { + seq: 1, + timestamp: "2026-03-24T00:00:01.000Z", + item: durableOnlyItem, + }; + + await durableTimelineStore.bulkInsert(snapshot.id, [durableOnlyRow]); + + expect(manager.getTimeline(snapshot.id)).toEqual([]); + await expect(manager.getLastAssistantMessage(snapshot.id)).resolves.toBe("durable only"); + await expect(manager.getTimelineRows(snapshot.id)).resolves.toEqual([durableOnlyRow]); + await expect( + manager.fetchTimeline(snapshot.id, { + direction: "tail", + limit: 0, + }), + ).resolves.toEqual({ + direction: "tail", + window: { + minSeq: 1, + maxSeq: 1, + nextSeq: 2, + }, + hasOlder: false, + hasNewer: false, + rows: [durableOnlyRow], + }); + } finally { + await database.close(); + rmSync(workdir, { recursive: true, force: true }); + } + }); + + test("getTimelineRows falls back to the in-memory timeline when no durable store is configured", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-rows-fallback-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ @@ -848,6 +1938,105 @@ describe("AgentManager", () => { }, registry: storage, logger, + idFactory: () => "00000000-0000-4000-8000-000000000140", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + await manager.appendTimelineItem(snapshot.id, { + type: "assistant_message", + text: "row one", + }); + await manager.appendTimelineItem(snapshot.id, { + type: "assistant_message", + text: "row two", + }); + + await expect(manager.getTimelineRows(snapshot.id)).resolves.toEqual([ + { + seq: 1, + timestamp: expect.any(String), + item: { + type: "assistant_message", + text: "row one", + }, + }, + { + seq: 2, + timestamp: expect.any(String), + item: { + type: "assistant_message", + text: "row two", + }, + }, + ]); + }); + + test("getAgent does not expose committed history internals once manager owns the seam", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-boundary-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000138", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + manager.recordUserMessage(snapshot.id, "hello boundary", { + messageId: "msg-boundary-1", + emitState: false, + }); + await manager.appendTimelineItem(snapshot.id, { + type: "assistant_message", + text: "history stays behind manager", + }); + + const live = manager.getAgent(snapshot.id) as Record; + expect(live).not.toBeNull(); + expect("timeline" in live).toBe(false); + expect("timelineRows" in live).toBe(false); + expect("timelineNextSeq" in live).toBe(false); + + expect(manager.getTimeline(snapshot.id)).toEqual([ + { + type: "user_message", + text: "hello boundary", + messageId: "msg-boundary-1", + }, + { + type: "assistant_message", + text: "history stays behind manager", + }, + ]); + + const fetched = await manager.fetchTimeline(snapshot.id, { + direction: "tail", + limit: 0, + }); + expect(fetched.rows.map((row) => row.seq)).toEqual([1, 2]); + }); + + test("buffers assistant chunks provisionally and streams one finalized assistant row", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provisional-timeline-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new StreamingAssistantClient(), + }, + registry: storage, + logger, idFactory: () => "00000000-0000-4000-8000-000000000120", }); @@ -858,9 +2047,9 @@ describe("AgentManager", () => { const streamEvents: Array<{ seq?: number; - epoch?: string; eventType?: string; itemType?: string; + text?: string; }> = []; manager.subscribe( (event) => { @@ -869,37 +2058,53 @@ describe("AgentManager", () => { } streamEvents.push({ seq: event.seq, - epoch: event.epoch, eventType: event.event.type, itemType: event.event.type === "timeline" ? event.event.item.type : undefined, + text: + event.event.type === "timeline" && event.event.item.type === "assistant_message" + ? event.event.item.text + : undefined, }); }, { agentId: snapshot.id, replayState: false }, ); - await manager.emitLiveTimelineItem(snapshot.id, { - type: "assistant_message", - text: "live-only update", - }); + const stream = manager.streamAgent(snapshot.id, "hello"); + while (true) { + const next = await stream.next(); + if (next.done) { + break; + } + } - expect(streamEvents).toHaveLength(1); - expect(streamEvents[0]).toMatchObject({ + const assistantTimelineEvents = streamEvents.filter((event) => event.itemType === "assistant_message"); + expect(assistantTimelineEvents).toHaveLength(1); + expect(assistantTimelineEvents[0]).toMatchObject({ eventType: "timeline", itemType: "assistant_message", + text: "final reply", + seq: 1, }); - expect(streamEvents[0]?.seq).toBeUndefined(); - expect(streamEvents[0]?.epoch).toBeUndefined(); - expect(manager.getTimeline(snapshot.id)).toEqual([]); - const fetched = manager.fetchTimeline(snapshot.id, { + expect(manager.getTimeline(snapshot.id)).toEqual([ + { + type: "assistant_message", + text: "final reply", + }, + ]); + const fetched = await manager.fetchTimeline(snapshot.id, { direction: "tail", limit: 0, }); - expect(fetched.rows).toEqual([]); + expect(fetched.rows).toHaveLength(1); + expect(fetched.rows[0]?.item).toEqual({ + type: "assistant_message", + text: "final reply", + }); }); - test("fetchTimeline returns full timeline with reset when cursor seq falls behind retention window", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-gap-")); + test("fetchTimeline supports older-history pagination with before seq", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-before-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); const manager = new AgentManager({ @@ -908,7 +2113,6 @@ describe("AgentManager", () => { }, registry: storage, logger, - maxTimelineItems: 2, idFactory: () => "00000000-0000-4000-8000-000000000119", }); @@ -933,32 +2137,27 @@ describe("AgentManager", () => { type: "assistant_message", text: "fourth", }); - - const fresh = manager.fetchTimeline(snapshot.id, { - direction: "tail", - limit: 0, + await manager.appendTimelineItem(snapshot.id, { + type: "assistant_message", + text: "fifth", }); - expect(fresh.window.minSeq).toBe(3); - expect(fresh.window.maxSeq).toBe(4); - const result = manager.fetchTimeline(snapshot.id, { - direction: "after", + const result = await manager.fetchTimeline(snapshot.id, { + direction: "before", cursor: { - epoch: fresh.epoch, - seq: 1, + seq: 5, }, - limit: 10, + limit: 2, }); - expect(result.reset).toBe(true); - expect(result.staleCursor).toBe(false); - expect(result.gap).toBe(true); expect(result.rows).toHaveLength(2); expect(result.rows[0]?.seq).toBe(3); expect(result.rows[1]?.seq).toBe(4); + expect(result.hasOlder).toBe(true); + expect(result.hasNewer).toBe(true); }); - test("does not trim timeline by default", async () => { + test("does not trim committed history", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-unbounded-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); @@ -989,7 +2188,7 @@ describe("AgentManager", () => { text: "third", }); - const fetched = manager.fetchTimeline(snapshot.id, { + const fetched = await manager.fetchTimeline(snapshot.id, { direction: "tail", limit: 0, }); @@ -998,6 +2197,178 @@ describe("AgentManager", () => { expect(fetched.window.maxSeq).toBe(3); }); + test("hydrateTimeline canonicalizes tool-interleaved assistant replay into the committed turn shape", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-canonical-assistant-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class ChunkedAssistantHistorySession extends TestAgentSession { + constructor(config: AgentSessionConfig) { + super(config); + } + + async *streamHistory(): AsyncGenerator { + yield { + type: "timeline", + provider: this.provider, + item: { type: "assistant_message", text: "chunk one " }, + }; + yield { + type: "timeline", + provider: this.provider, + item: { type: "assistant_message", text: "chunk two" }, + }; + yield { + type: "timeline", + provider: this.provider, + item: { type: "reasoning", text: "internal" }, + }; + yield { + type: "timeline", + provider: this.provider, + item: { + type: "tool_call", + callId: "call-history-1", + name: "shell", + status: "completed", + detail: { + type: "shell", + command: "echo hi", + output: "hi\n", + exitCode: 0, + }, + error: null, + }, + }; + yield { + type: "timeline", + provider: this.provider, + item: { type: "assistant_message", text: "final answer" }, + }; + } + } + + class ChunkedAssistantHistoryClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + + async isAvailable(): Promise { + return true; + } + + async createSession(config: AgentSessionConfig): Promise { + return new ChunkedAssistantHistorySession(config); + } + + async resumeSession(): Promise { + throw new Error("Not used in this test"); + } + } + + const manager = new AgentManager({ + clients: { + codex: new ChunkedAssistantHistoryClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000121", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + await manager.hydrateTimelineFromProvider(snapshot.id); + + expect(manager.getTimeline(snapshot.id)).toEqual([ + { + type: "tool_call", + callId: "call-history-1", + name: "shell", + status: "completed", + detail: { + type: "shell", + command: "echo hi", + output: "hi\n", + exitCode: 0, + }, + error: null, + }, + { type: "assistant_message", text: "chunk one chunk twofinal answer" }, + ]); + }); + + test("hydrateTimeline canonicalizes reasoning-interleaved assistant replay into one committed assistant row", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-reasoning-interleave-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class ReasoningInterleavedHistorySession extends TestAgentSession { + constructor(config: AgentSessionConfig) { + super(config); + } + + async *streamHistory(): AsyncGenerator { + yield { + type: "timeline", + provider: this.provider, + item: { type: "assistant_message", text: "before reasoning " }, + }; + yield { + type: "timeline", + provider: this.provider, + item: { type: "reasoning", text: "internal step" }, + }; + yield { + type: "timeline", + provider: this.provider, + item: { type: "assistant_message", text: "after reasoning" }, + }; + } + } + + class ReasoningInterleavedHistoryClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + + async isAvailable(): Promise { + return true; + } + + async createSession(config: AgentSessionConfig): Promise { + return new ReasoningInterleavedHistorySession(config); + } + + async resumeSession(): Promise { + throw new Error("Not used in this test"); + } + } + + const manager = new AgentManager({ + clients: { + codex: new ReasoningInterleavedHistoryClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000122", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + await manager.hydrateTimelineFromProvider(snapshot.id); + + expect(manager.getTimeline(snapshot.id)).toEqual([ + { + type: "assistant_message", + text: "before reasoning after reasoning", + }, + ]); + }); + test("createAgent fails when generated agent ID is not a UUID", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); @@ -1045,29 +2416,41 @@ describe("AgentManager", () => { test("createAgent persists provided title before returning", async () => { const agentId = "00000000-0000-4000-8000-000000000102"; const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => agentId, - }); + const dataDir = join(workdir, "db"); + const database = await openPaseoDatabase(dataDir); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Fix Login Bug", - }); + try { + const workspaceId = await seedWorkspace(database, { directory: workdir }); + const storage = new DbAgentSnapshotStore(database.db); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => agentId, + }); - expect(snapshot.id).toBe(agentId); - expect(snapshot.lifecycle).toBe("idle"); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Fix Login Bug", + }, + undefined, + { workspaceId }, + ); - const persisted = await storage.get(agentId); - expect(persisted?.title).toBe("Fix Login Bug"); - expect(persisted?.id).toBe(agentId); + expect(snapshot.id).toBe(agentId); + expect(snapshot.lifecycle).toBe("idle"); + + const persisted = await storage.get(agentId); + expect(persisted?.title).toBe("Fix Login Bug"); + expect(persisted?.id).toBe(agentId); + } finally { + await database.close(); + rmSync(workdir, { recursive: true, force: true }); + } }); test("createAgent populates runtimeInfo after session creation", async () => { @@ -2236,6 +3619,7 @@ describe("AgentManager", () => { }); await expect(manager.runAgent(agent.id, "fail once")).rejects.toThrow("boom-1"); + await manager.flush(); const afterFirstFailure = manager.getAgent(agent.id); expect(afterFirstFailure?.lifecycle).toBe("error"); @@ -2245,14 +3629,26 @@ describe("AgentManager", () => { attentionReason: "error", }); + const persistedAfterFirstFailure = await storage.get(agent.id); + expect(persistedAfterFirstFailure?.lastStatus).toBe("error"); + expect(persistedAfterFirstFailure?.requiresAttention).toBe(true); + expect(persistedAfterFirstFailure?.attentionReason).toBe("error"); + await manager.clearAgentAttention(agent.id); manager.notifyAgentState(agent.id); + await manager.flush(); const afterClear = manager.getAgent(agent.id); expect(afterClear?.lifecycle).toBe("error"); expect(afterClear?.attention).toEqual({ requiresAttention: false }); + const persistedAfterClear = await storage.get(agent.id); + expect(persistedAfterClear?.lastStatus).toBe("error"); + expect(persistedAfterClear?.requiresAttention).toBe(false); + expect(persistedAfterClear?.attentionReason).toBeNull(); + await expect(manager.runAgent(agent.id, "fail again")).rejects.toThrow("boom-2"); + await manager.flush(); const afterSecondFailure = manager.getAgent(agent.id); expect(afterSecondFailure?.lifecycle).toBe("error"); @@ -2261,6 +3657,11 @@ describe("AgentManager", () => { attentionReason: "error", }); expect(attentionReasons).toEqual(["error", "error"]); + + const persistedAfterSecondFailure = await storage.get(agent.id); + expect(persistedAfterSecondFailure?.lastStatus).toBe("error"); + expect(persistedAfterSecondFailure?.requiresAttention).toBe(true); + expect(persistedAfterSecondFailure?.attentionReason).toBe("error"); }); test("archiveAgent persists archivedAt and updatedAt before emitting closed state", async () => { @@ -2668,6 +4069,10 @@ describe("AgentManager", () => { // The manager should have updated currentModeId to reflect this const updatedAgent = manager.getAgent(snapshot.id); expect(updatedAgent?.currentModeId).toBe("acceptEdits"); + + await manager.flush(); + const persisted = await storage.get(snapshot.id); + expect(persisted?.lastModeId).toBe("acceptEdits"); }); test("close during in-flight stream does not clear persistence sessionId", async () => { @@ -2822,6 +4227,41 @@ describe("AgentManager", () => { expect(persisted?.persistence?.sessionId).toBe(snapshot.persistence?.sessionId); }); + test("closeAgent persists one final closed snapshot", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-close-no-persist-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const applySnapshotSpy = vi.spyOn(storage, "applySnapshot"); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000112", + }); + + try { + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + await manager.flush(); + const persistCountBeforeClose = applySnapshotSpy.mock.calls.length; + + await manager.closeAgent(snapshot.id); + await manager.flush(); + + expect(applySnapshotSpy).toHaveBeenCalledTimes(persistCountBeforeClose + 1); + } finally { + applySnapshotSpy.mockRestore(); + await manager.flush().catch(() => undefined); + await storage.flush().catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } + }); + test("hydrateTimeline skips provider user_message items to prevent duplicates with recordUserMessage", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-dedup-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index c3a705202..ffb814f13 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { resolve } from "node:path"; +import { basename, resolve } from "node:path"; import { stat } from "node:fs/promises"; import { AGENT_LIFECYCLE_STATUSES, @@ -7,6 +7,8 @@ import { } from "../../shared/agent-lifecycle.js"; import type { Logger } from "pino"; import { z } from "zod"; +import type { TerminalManager } from "../../terminal/terminal-manager.js"; +import type { TerminalExitInfo, TerminalSession } from "../../terminal/terminal.js"; import type { AgentCapabilityFlags, @@ -29,11 +31,31 @@ import type { AgentRuntimeInfo, ListPersistedAgentsOptions, PersistedAgentDescriptor, + TerminalCommand, } from "./agent-sdk-types.js"; -import type { AgentStorage } from "./agent-storage.js"; +import type { StoredAgentRecord } from "./agent-storage.js"; +import type { AgentSnapshotStore } from "./agent-snapshot-store.js"; +import { + InMemoryAgentTimelineStore, + type SeedAgentTimelineOptions, +} from "./agent-timeline-store.js"; +import type { + AgentTimelineFetchOptions, + AgentTimelineFetchResult, + AgentTimelineRow, + AgentTimelineStore, +} from "./agent-timeline-store-types.js"; import { AGENT_PROVIDER_IDS } from "./provider-manifest.js"; export { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus }; +export type { + AgentTimelineCursor, + AgentTimelineFetchDirection, + AgentTimelineFetchOptions, + AgentTimelineFetchResult, + AgentTimelineRow, + AgentTimelineWindow, +} from "./agent-timeline-store-types.js"; export type AgentManagerEvent = | { type: "agent_state"; agent: ManagedAgent } @@ -42,7 +64,6 @@ export type AgentManagerEvent = agentId: string; event: AgentStreamEvent; seq?: number; - epoch?: string; }; export type AgentSubscriber = (event: AgentManagerEvent) => void; @@ -70,10 +91,11 @@ export type ProviderAvailability = { export type AgentManagerOptions = { clients?: Partial>; - maxTimelineItems?: number; idFactory?: () => string; - registry?: AgentStorage; + registry?: AgentSnapshotStore; onAgentAttention?: AgentAttentionCallback; + durableTimelineStore?: AgentTimelineStore; + terminalManager?: TerminalManager | null; logger: Logger; }; @@ -92,48 +114,13 @@ export type WaitForAgentStartOptions = { signal?: AbortSignal; }; -export type AgentTimelineRow = { - seq: number; - timestamp: string; - item: AgentTimelineItem; -}; - -export type AgentTimelineCursor = { - epoch: string; - seq: number; -}; - -export type AgentTimelineFetchDirection = "tail" | "before" | "after"; - -export type AgentTimelineFetchOptions = { - direction?: AgentTimelineFetchDirection; - cursor?: AgentTimelineCursor; - /** - * Number of canonical rows to return. - * - undefined: manager default - * - 0: all rows in the selected window - */ - limit?: number; -}; - -export type AgentTimelineWindow = { - minSeq: number; - maxSeq: number; - nextSeq: number; -}; - -export type AgentTimelineFetchResult = { - epoch: string; - direction: AgentTimelineFetchDirection; - reset: boolean; - staleCursor: boolean; - gap: boolean; - window: AgentTimelineWindow; - hasOlder: boolean; - hasNewer: boolean; - rows: AgentTimelineRow[]; -}; - +export interface TerminalExitDetails { + command: string; + message: string; + exitCode: number | null; + signal: number | null; + outputLines: string[]; +} type AttentionState = | { requiresAttention: false } | { @@ -162,6 +149,7 @@ type ManagedAgentBase = { id: string; provider: AgentProvider; cwd: string; + terminal: boolean; capabilities: AgentCapabilityFlags; config: AgentSessionConfig; runtimeInfo?: AgentRuntimeInfo; @@ -171,15 +159,13 @@ type ManagedAgentBase = { currentModeId: string | null; pendingPermissions: Map; pendingReplacement: boolean; - timeline: AgentTimelineItem[]; - timelineRows: AgentTimelineRow[]; - timelineEpoch: string; - timelineNextSeq: number; + provisionalAssistantText: string | null; persistence: AgentPersistenceHandle | null; historyPrimed: boolean; lastUserMessageAt: Date | null; lastUsage?: AgentUsage; lastError?: string; + terminalExit?: TerminalExitDetails; attention: AttentionState; foregroundTurnWaiters: Set; unsubscribeSession: (() => void) | null; @@ -195,6 +181,7 @@ type ManagedAgentBase = { type ManagedAgentWithSession = ManagedAgentBase & { session: AgentSession; + terminal: false; }; type ManagedAgentInitializing = ManagedAgentWithSession & { @@ -224,11 +211,24 @@ type ManagedAgentClosed = ManagedAgentBase & { activeForegroundTurnId: null; }; +type ManagedTerminalAgent = ManagedAgentBase & { + terminal: true; + lifecycle: "idle"; + session: null; + activeForegroundTurnId: null; + terminalCommand: TerminalCommand; + terminalId: string | null; + unsubscribeTerminalExit: (() => void) | null; +}; + +export type AgentKind = "session" | "terminal"; + export type ManagedAgent = | ManagedAgentInitializing | ManagedAgentIdle | ManagedAgentRunning | ManagedAgentError + | ManagedTerminalAgent | ManagedAgentClosed; export interface AgentMetricsSnapshot { @@ -247,6 +247,8 @@ type ActiveManagedAgent = | ManagedAgentRunning | ManagedAgentError; +type LiveManagedAgent = ActiveManagedAgent | ManagedTerminalAgent; + const SYSTEM_ERROR_PREFIX = "[System Error]"; function attachPersistenceCwd( @@ -270,7 +272,6 @@ type SubscriptionRecord = { agentId: string | null; }; -const DEFAULT_TIMELINE_FETCH_LIMIT = 200; const BUSY_STATUSES: AgentLifecycleStatus[] = ["initializing", "running"]; const AgentIdSchema = z.string().uuid(); @@ -297,6 +298,73 @@ function createAbortError(signal: AbortSignal | undefined, fallbackMessage: stri return Object.assign(new Error(message), { name: "AbortError" }); } +function formatTerminalExitSummary(input: { + command: string; + exitCode: number | null; + signal: number | null; + outputLines: string[]; +}): string { + const commandLabel = basename(input.command) || input.command; + const commandNotFoundLine = input.outputLines.find((line) => + /command not found|not recognized|no such file or directory/i.test(line), + ); + + if (input.exitCode === 127) { + return commandNotFoundLine ?? `${commandLabel}: command not found`; + } + if (input.exitCode !== null) { + return `${commandLabel} exited with code ${input.exitCode}.`; + } + if (input.signal !== null) { + return `${commandLabel} exited with signal ${input.signal}.`; + } + return `${commandLabel} exited unexpectedly.`; +} + +function buildTerminalExitDetails(input: { + command: string; + exit: TerminalExitInfo; +}): TerminalExitDetails | null { + const outputLines = input.exit.lastOutputLines.map((line) => line.trimEnd()); + while (outputLines[0]?.length === 0) { + outputLines.shift(); + } + while (outputLines[outputLines.length - 1]?.length === 0) { + outputLines.pop(); + } + + if (input.exit.exitCode === null && input.exit.signal === null && outputLines.length === 0) { + return null; + } + + return { + command: input.command, + message: formatTerminalExitSummary({ + command: input.command, + exitCode: input.exit.exitCode, + signal: input.exit.signal, + outputLines, + }), + exitCode: input.exit.exitCode, + signal: input.exit.signal, + outputLines, + }; +} + +function buildTerminalExitErrorMessage(details: TerminalExitDetails): string { + const lines = [details.message]; + if (details.exitCode !== null) { + lines.push(`Exit code: ${details.exitCode}`); + } else if (details.signal !== null) { + lines.push(`Signal: ${details.signal}`); + } + if (details.outputLines.length > 0) { + lines.push("Last output:"); + lines.push(...details.outputLines); + } + return lines.join("\n"); +} + function validateAgentId(agentId: string, source: string): string { const result = AgentIdSchema.safeParse(agentId); if (!result.success) { @@ -315,28 +383,27 @@ function normalizeMessageId(messageId: string | undefined): string | undefined { export class AgentManager { private readonly clients = new Map(); - private readonly agents = new Map(); + private readonly agents = new Map(); + private readonly timelineStore = new InMemoryAgentTimelineStore(); + private readonly agentsAwaitingInitialSnapshotPersist = new Set(); + private readonly sessionEventTails = new Map>(); private readonly pendingForegroundRuns = new Map(); private readonly subscribers = new Set(); - private readonly maxTimelineItems: number | null; private readonly idFactory: () => string; - private readonly registry?: AgentStorage; + private readonly registry?: AgentSnapshotStore; + private readonly durableTimelineStore?: AgentTimelineStore; private readonly previousStatuses = new Map(); private readonly backgroundTasks = new Set>(); private onAgentAttention?: AgentAttentionCallback; private logger: Logger; + private readonly terminalManager: TerminalManager | null; constructor(options: AgentManagerOptions) { - const maxTimelineItems = options?.maxTimelineItems; - this.maxTimelineItems = - typeof maxTimelineItems === "number" && - Number.isFinite(maxTimelineItems) && - maxTimelineItems >= 0 - ? Math.floor(maxTimelineItems) - : null; this.idFactory = options?.idFactory ?? (() => randomUUID()); this.registry = options?.registry; + this.durableTimelineStore = options?.durableTimelineStore; this.onAgentAttention = options?.onAgentAttention; + this.terminalManager = options?.terminalManager ?? null; this.logger = options.logger.child({ module: "agent", component: "agent-manager" }); if (options?.clients) { for (const [provider, client] of Object.entries(options.clients)) { @@ -368,7 +435,11 @@ export class AgentManager { withActiveForegroundTurn++; } - const len = agent.timeline.length; + if (!this.timelineStore.has(agent.id)) { + continue; + } + + const len = this.timelineStore.getItems(agent.id).length; totalItems += len; if (len > maxItemsPerAgent) { maxItemsPerAgent = len; @@ -553,150 +624,83 @@ export class AgentManager { return agent ? { ...agent } : null; } - getTimeline(id: string): AgentTimelineItem[] { - const agent = this.requireAgent(id); - return [...agent.timeline]; + async getAgentKind(id: string): Promise { + const normalizedId = validateAgentId(id, "getAgentKind"); + const liveAgent = this.agents.get(normalizedId); + if (liveAgent) { + return liveAgent.terminal ? "terminal" : "session"; + } + if (!this.registry) { + return null; + } + const stored = await this.registry.get(normalizedId); + if (!stored) { + return null; + } + return stored.config?.terminal === true ? "terminal" : "session"; } - getTimelineRows(id: string): AgentTimelineRow[] { - const agent = this.requireAgent(id); - const { rows } = this.ensureTimelineState(agent); - return rows.map((row) => ({ ...row })); + async getStructuredSendRejection(id: string): Promise { + const kind = await this.getAgentKind(id); + return kind === "terminal" + ? "Terminal agents do not support structured send operations" + : null; } - fetchTimeline(id: string, options?: AgentTimelineFetchOptions): AgentTimelineFetchResult { - const agent = this.requireAgent(id); - const { rows, epoch, nextSeq, minSeq, maxSeq } = this.ensureTimelineState(agent); - const direction = options?.direction ?? "tail"; - const requestedLimit = options?.limit; - const limit = - requestedLimit === undefined - ? DEFAULT_TIMELINE_FETCH_LIMIT - : Math.max(0, Math.floor(requestedLimit)); - const cursor = options?.cursor; - - const window: AgentTimelineWindow = { minSeq, maxSeq, nextSeq }; - - if (cursor && cursor.epoch !== epoch) { - return { - epoch, - direction, - reset: true, - staleCursor: true, - gap: false, - window, - hasOlder: false, - hasNewer: false, - rows: rows.map((row) => ({ ...row })), - }; + getTerminalSessionForAgent(id: string): TerminalSession | null { + const agent = this.agents.get(id); + if (!agent || !agent.terminal || !("terminalId" in agent) || !agent.terminalId) { + return null; } + return this.terminalManager?.getTerminal(agent.terminalId) ?? null; + } - const selectAll = limit === 0; - const cloneRows = (items: AgentTimelineRow[]) => items.map((row) => ({ ...row })); - - if (direction === "after" && cursor && rows.length > 0 && cursor.seq < minSeq - 1) { - return { - epoch, - direction, - reset: true, - staleCursor: false, - gap: true, - window, - hasOlder: false, - hasNewer: false, - rows: cloneRows(rows), - }; - } - - if (rows.length === 0) { - return { - epoch, - direction, - reset: false, - staleCursor: false, - gap: false, - window, - hasOlder: false, - hasNewer: false, - rows: [], - }; - } - - if (direction === "tail") { - const selected = selectAll || limit >= rows.length ? rows : rows.slice(rows.length - limit); - const hasOlder = selected.length > 0 && selected[0]!.seq > minSeq; - return { - epoch, - direction, - reset: false, - staleCursor: false, - gap: false, - window, - hasOlder, - hasNewer: false, - rows: cloneRows(selected), - }; - } - - if (direction === "after") { - const baseSeq = cursor?.seq ?? 0; - const startIdx = rows.findIndex((row) => row.seq > baseSeq); - if (startIdx < 0) { - return { - epoch, - direction, - reset: false, - staleCursor: false, - gap: false, - window, - hasOlder: baseSeq >= minSeq, - hasNewer: false, - rows: [], - }; + getAgentIdForTerminal(terminalId: string): string | null { + for (const agent of this.agents.values()) { + if (!agent.terminal || !("terminalId" in agent) || agent.terminalId !== terminalId) { + continue; } - - const selected = selectAll ? rows.slice(startIdx) : rows.slice(startIdx, startIdx + limit); - const lastSelected = selected[selected.length - 1]; - return { - epoch, - direction, - reset: false, - staleCursor: false, - gap: false, - window, - hasOlder: selected[0]!.seq > minSeq, - hasNewer: Boolean(lastSelected && lastSelected.seq < maxSeq), - rows: cloneRows(selected), - }; + return agent.id; } + return null; + } - // direction === "before" - const beforeSeq = cursor?.seq ?? nextSeq; - const endExclusive = rows.findIndex((row) => row.seq >= beforeSeq); - const boundedRows = endExclusive < 0 ? rows : rows.slice(0, endExclusive); - const selected = - selectAll || limit >= boundedRows.length - ? boundedRows - : boundedRows.slice(boundedRows.length - limit); - const hasOlder = selected.length > 0 && selected[0]!.seq > minSeq; - const hasNewer = endExclusive >= 0; - return { - epoch, - direction, - reset: false, - staleCursor: false, - gap: false, - window, - hasOlder, - hasNewer, - rows: cloneRows(selected), - }; + isTerminalBoundToAgent(terminalId: string): boolean { + return this.getAgentIdForTerminal(terminalId) !== null; + } + + getTimeline(id: string): AgentTimelineItem[] { + this.requireAgent(id); + return this.timelineStore.getItems(id); + } + + async getTimelineRows(id: string): Promise { + this.requireAgent(id); + if (this.durableTimelineStore) { + return await this.durableTimelineStore.getCommittedRows(id); + } + return this.timelineStore.getRows(id); + } + + async fetchTimeline( + id: string, + options?: AgentTimelineFetchOptions, + ): Promise { + this.requireAgent(id); + if (this.durableTimelineStore) { + return await this.durableTimelineStore.fetchCommitted(id, options); + } + return this.timelineStore.fetch(id, options); } async createAgent( config: AgentSessionConfig, agentId?: string, - options?: { labels?: Record }, + options?: { + labels?: Record; + workspaceId?: number; + initialPrompt?: string; + }, ): Promise { // Generate agent ID early so we can use it in MCP config const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent"); @@ -709,12 +713,126 @@ export class AgentManager { `Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`, ); } + if (normalizedConfig.terminal) { + const buildCommand = client.buildTerminalCreateCommand; + if (!buildCommand) { + throw new Error(`Provider '${normalizedConfig.provider}' does not support terminal mode`); + } + const persistence = this.buildTerminalPersistenceHandle( + resolvedAgentId, + normalizedConfig.provider, + normalizedConfig.cwd, + ); + const command = buildCommand.call( + client, + normalizedConfig, + persistence, + options?.initialPrompt, + ); + return this.registerTerminalAgent( + resolvedAgentId, + normalizedConfig, + client.capabilities, + command, + persistence, + { + labels: options?.labels, + workspaceId: options?.workspaceId, + }, + ); + } const session = await client.createSession(normalizedConfig, launchContext); return this.registerSession(session, normalizedConfig, resolvedAgentId, { labels: options?.labels, + workspaceId: options?.workspaceId, }); } + // Reconstruct an agent from provider persistence. When a durable timeline + // store is configured, the live timeline buffer only seeds seq metadata from + // the durable store instead of loading committed history back into memory. + // Tests without a durable timeline store can still call + // hydrateTimelineFromProvider() for backward compatibility. + async launchTerminalAgent( + config: AgentSessionConfig, + agentId: string, + options?: { + persistence?: AgentPersistenceHandle | null; + createdAt?: Date; + updatedAt?: Date; + lastUserMessageAt?: Date | null; + labels?: Record; + attention?: { + requiresAttention: boolean; + attentionReason?: "finished" | "error" | "permission" | null; + attentionTimestamp?: Date | null; + }; + }, + ): Promise { + const resolvedAgentId = validateAgentId(agentId, "launchTerminalAgent"); + const normalizedConfig = await this.normalizeConfig(config); + const client = this.requireClient(normalizedConfig.provider); + const available = await client.isAvailable(); + if (!available) { + throw new Error( + `Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`, + ); + } + + const resumeCommand = + options?.persistence && client.buildTerminalResumeCommand + ? client.buildTerminalResumeCommand.call(client, options.persistence) + : null; + const createCommand = client.buildTerminalCreateCommand; + const terminalCommand = + resumeCommand ?? + (createCommand + ? createCommand.call( + client, + normalizedConfig, + options?.persistence ?? + this.buildTerminalPersistenceHandle( + resolvedAgentId, + normalizedConfig.provider, + normalizedConfig.cwd, + ), + ) + : null); + + if (!terminalCommand) { + throw new Error(`Provider '${normalizedConfig.provider}' does not support terminal mode`); + } + + return this.registerTerminalAgent( + resolvedAgentId, + normalizedConfig, + client.capabilities, + terminalCommand, + options?.persistence ?? + this.buildTerminalPersistenceHandle( + resolvedAgentId, + normalizedConfig.provider, + normalizedConfig.cwd, + ), + { + createdAt: options?.createdAt, + updatedAt: options?.updatedAt, + lastUserMessageAt: options?.lastUserMessageAt, + labels: options?.labels, + attention: + options?.attention?.requiresAttention && + options.attention.attentionReason && + options.attention.attentionTimestamp + ? { + requiresAttention: true, + attentionReason: options.attention.attentionReason, + attentionTimestamp: options.attention.attentionTimestamp, + } + : undefined, + }, + ); + } + // Reconstruct an agent from provider persistence. Callers should explicitly // hydrate timeline history after resume. async resumeAgentFromPersistence( @@ -755,16 +873,12 @@ export class AgentManager { agentId: string, overrides?: Partial, ): Promise { - let existing = this.requireAgent(agentId); + let existing = this.requireSessionAgent(agentId); if (this.hasInFlightRun(agentId)) { await this.cancelAgentRun(agentId); - existing = this.requireAgent(agentId); + existing = this.requireSessionAgent(agentId); } - const timelineState = this.ensureTimelineState(existing); - const preservedTimeline = [...existing.timeline]; - const preservedTimelineRows = timelineState.rows.map((row) => ({ ...row })); - const preservedTimelineEpoch = timelineState.epoch; - const preservedTimelineNextSeq = timelineState.nextSeq; + const preservedProvisionalAssistantText = existing.provisionalAssistantText; const preservedHistoryPrimed = existing.historyPrimed; const preservedLastUsage = existing.lastUsage; const preservedLastError = existing.lastError; @@ -807,10 +921,7 @@ export class AgentManager { createdAt: existing.createdAt, updatedAt: existing.updatedAt, lastUserMessageAt: existing.lastUserMessageAt, - timeline: preservedTimeline, - timelineRows: preservedTimelineRows, - timelineEpoch: preservedTimelineEpoch, - timelineNextSeq: preservedTimelineNextSeq, + provisionalAssistantText: preservedProvisionalAssistantText, historyPrimed: preservedHistoryPrimed, lastUsage: preservedLastUsage, lastError: preservedLastError, @@ -829,34 +940,16 @@ export class AgentManager { }, "closeAgent: start", ); - this.agents.delete(agentId); - // Clean up previousStatus to prevent memory leak - this.previousStatuses.delete(agentId); - if (agent.unsubscribeSession) { - agent.unsubscribeSession(); - agent.unsubscribeSession = null; + const closedAgent = this.prepareAgentForClosure(agent, "agent closed"); + if (agent.terminal) { + if (agent.terminalId) { + this.terminalManager?.killTerminal(agent.terminalId); + } + } else { + await agent.session.close(); } - for (const waiter of agent.foregroundTurnWaiters) { - // Wake up the generator so it can exit the await loop - waiter.callback({ - type: "turn_canceled", - provider: agent.provider, - reason: "agent closed", - turnId: waiter.turnId, - }); - this.settleForegroundTurnWaiter(waiter); - } - agent.foregroundTurnWaiters.clear(); - this.settlePendingForegroundRun(agentId); - const session = agent.session; - const closedAgent: ManagedAgent = { - ...agent, - lifecycle: "closed", - session: null, - activeForegroundTurnId: null, - }; - await session.close(); - this.emitState(closedAgent); + this.timelineStore.delete(agentId); + this.emitClosedAgent(closedAgent); this.logger.trace({ agentId }, "closeAgent: completed"); } @@ -896,7 +989,7 @@ export class AgentManager { } async setAgentMode(agentId: string, modeId: string): Promise { - const agent = this.requireAgent(agentId); + const agent = this.requireSessionAgent(agentId); await agent.session.setMode(modeId); agent.currentModeId = modeId; // Update runtimeInfo to reflect the new mode @@ -908,7 +1001,7 @@ export class AgentManager { } async setAgentModel(agentId: string, modelId: string | null): Promise { - const agent = this.requireAgent(agentId); + const agent = this.requireSessionAgent(agentId); const normalizedModelId = typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null; @@ -925,7 +1018,7 @@ export class AgentManager { } async setAgentThinkingOption(agentId: string, thinkingOptionId: string | null): Promise { - const agent = this.requireAgent(agentId); + const agent = this.requireSessionAgent(agentId); const normalizedThinkingOptionId = typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0 ? thinkingOptionId @@ -936,6 +1029,12 @@ export class AgentManager { } agent.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined; + if (agent.runtimeInfo) { + agent.runtimeInfo = { + ...agent.runtimeInfo, + thinkingOptionId: normalizedThinkingOptionId, + }; + } this.touchUpdatedAt(agent); this.emitState(agent); } @@ -946,17 +1045,24 @@ export class AgentManager { if (!normalizedTitle) { return; } + if ( + this.agentsAwaitingInitialSnapshotPersist.has(agent.id) && + this.registry && + (await this.registry.get(agent.id)) === null + ) { + return; + } this.touchUpdatedAt(agent); await this.persistSnapshot(agent, { title: normalizedTitle }); - this.emitState(agent); + this.emitState(agent, { persist: false }); } async setLabels(agentId: string, labels: Record): Promise { const agent = this.requireAgent(agentId); agent.labels = { ...agent.labels, ...labels }; - await this.persistSnapshot(agent); this.touchUpdatedAt(agent); - this.emitState(agent); + await this.persistSnapshot(agent); + this.emitState(agent, { persist: false }); } notifyAgentState(agentId: string): void { @@ -973,10 +1079,105 @@ export class AgentManager { if (agent.attention.requiresAttention) { agent.attention = { requiresAttention: false }; await this.persistSnapshot(agent); - this.emitState(agent); + this.emitState(agent, { persist: false }); } } + async archiveSnapshot(agentId: string, archivedAt: string): Promise { + const registry = this.requireRegistry(); + const liveAgent = this.getAgent(agentId); + if (liveAgent) { + await this.persistSnapshot(liveAgent, { + internal: liveAgent.internal, + }); + } + + const record = await registry.get(agentId); + if (!record) { + throw new Error(`Agent not found: ${agentId}`); + } + + const normalizedStatus = + record.lastStatus === "running" || record.lastStatus === "initializing" + ? "idle" + : record.lastStatus; + + const nextRecord: StoredAgentRecord = { + ...record, + archivedAt, + lastStatus: normalizedStatus, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + }; + await registry.upsert(nextRecord); + return nextRecord; + } + + async unarchiveSnapshot(agentId: string): Promise { + const registry = this.requireRegistry(); + const record = await registry.get(agentId); + if (!record || !record.archivedAt) { + return false; + } + + await registry.upsert({ + ...record, + archivedAt: null, + }); + + if (this.getAgent(agentId)) { + this.notifyAgentState(agentId); + } + return true; + } + + async unarchiveSnapshotByHandle(handle: AgentPersistenceHandle): Promise { + const registry = this.requireRegistry(); + const records = await registry.list(); + const matched = records.find( + (record) => + record.persistence?.provider === handle.provider && + record.persistence?.sessionId === handle.sessionId, + ); + if (!matched) { + return; + } + + await this.unarchiveSnapshot(matched.id); + } + + async updateAgentMetadata( + agentId: string, + updates: { + title?: string; + labels?: Record; + }, + ): Promise { + const liveAgent = this.getAgent(agentId); + if (liveAgent) { + if (updates.title) { + await this.setTitle(agentId, updates.title); + } + if (updates.labels) { + await this.setLabels(agentId, updates.labels); + } + return; + } + + const registry = this.requireRegistry(); + const existing = await registry.get(agentId); + if (!existing) { + throw new Error(`Agent not found: ${agentId}`); + } + + await registry.upsert({ + ...existing, + ...(updates.title ? { title: updates.title } : {}), + ...(updates.labels ? { labels: { ...existing.labels, ...updates.labels } } : {}), + }); + } + async runAgent( agentId: string, prompt: AgentPromptInput, @@ -1030,7 +1231,7 @@ export class AgentManager { }; const updatedAt = this.touchUpdatedAt(agent); agent.lastUserMessageAt = updatedAt; - const row = this.recordTimeline(agent, item); + const row = this.recordTimeline(agentId, item); this.dispatchStream( agentId, { @@ -1040,7 +1241,6 @@ export class AgentManager { }, { seq: row.seq, - epoch: this.ensureTimelineState(agent).epoch, }, ); if (options?.emitState !== false) { @@ -1051,7 +1251,7 @@ export class AgentManager { async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise { const agent = this.requireAgent(agentId); this.touchUpdatedAt(agent); - const row = this.recordTimeline(agent, item); + const row = this.recordTimeline(agentId, item); this.dispatchStream( agentId, { @@ -1061,7 +1261,6 @@ export class AgentManager { }, { seq: row.seq, - epoch: this.ensureTimelineState(agent).epoch, }, ); await this.persistSnapshot(agent); @@ -1082,7 +1281,7 @@ export class AgentManager { prompt: AgentPromptInput, options?: AgentRunOptions, ): AsyncGenerator { - const existingAgent = this.requireAgent(agentId); + const existingAgent = this.requireSessionAgent(agentId); this.logger.trace( { agentId, @@ -1251,7 +1450,7 @@ export class AgentManager { return this.streamAgent(agentId, prompt, options); } - const agent = snapshot as ActiveManagedAgent; + const agent = this.requireSessionAgent(agentId); agent.pendingReplacement = true; const self = this; @@ -1396,7 +1595,7 @@ export class AgentManager { requestId: string, response: AgentPermissionResponse, ): Promise { - const agent = this.requireAgent(agentId); + const agent = this.requireSessionAgent(agentId); await agent.session.respondToPermission(requestId, response); agent.pendingPermissions.delete(requestId); @@ -1404,6 +1603,12 @@ export class AgentManager { // (e.g., plan approval changes mode from "plan" to "acceptEdits") try { agent.currentModeId = await agent.session.getCurrentMode(); + if (agent.runtimeInfo) { + agent.runtimeInfo = { + ...agent.runtimeInfo, + modeId: agent.currentModeId, + }; + } } catch { // Ignore errors from getCurrentMode - mode tracking is best effort } @@ -1412,7 +1617,7 @@ export class AgentManager { } async cancelAgentRun(agentId: string): Promise { - const agent = this.requireAgent(agentId); + const agent = this.requireSessionAgent(agentId); const pendingRun = this.getPendingForegroundRun(agentId); const foregroundTurnId = agent.activeForegroundTurnId; const hasForegroundTurn = Boolean(foregroundTurnId); @@ -1512,7 +1717,7 @@ export class AgentManager { } getPendingPermissions(agentId: string): AgentPermissionRequest[] { - const agent = this.requireAgent(agentId); + const agent = this.requireSessionAgent(agentId); return Array.from(agent.pendingPermissions.values()); } @@ -1521,25 +1726,47 @@ export class AgentManager { return iterator.done ? null : iterator.value; } + /** + * Test-only compatibility hook for managers constructed without a durable + * timeline store. Production loads committed history from the durable store + * during session registration instead of replaying provider history here. + */ async hydrateTimelineFromProvider(agentId: string): Promise { - const agent = this.requireAgent(agentId); - await this.hydrateTimeline(agent); + const agent = this.requireSessionAgent(agentId); + if (this.durableTimelineStore) { + return; + } + await this.hydrateTimelineFromLegacyProviderHistory(agent); } - private getLastAssistantMessage(agentId: string): string | null { + async deleteCommittedTimeline(agentId: string): Promise { + if (!this.durableTimelineStore) { + return; + } + await this.durableTimelineStore.deleteAgent(agentId); + } + + async getLastAssistantMessage(agentId: string): Promise { const agent = this.agents.get(agentId); if (!agent) { return null; } - return this.getLastAssistantMessageFromTimeline(agent.timeline); + return await this.getLastAssistantMessageFromStores(agentId); } private getLastAssistantMessageFromTimeline( timeline: readonly AgentTimelineItem[], ): string | null { + return this.getLastAssistantMessageSegmentFromTimeline(timeline)?.text ?? null; + } + + private getLastAssistantMessageSegmentFromTimeline( + timeline: readonly AgentTimelineItem[], + ): { text: string; startsAtBeginning: boolean } | null { // Collect the last contiguous assistant messages (Claude streams chunks) const chunks: string[] = []; + let startsAtBeginning = false; for (let i = timeline.length - 1; i >= 0; i--) { const item = timeline[i]; if (item.type !== "assistant_message") { @@ -1549,13 +1776,65 @@ export class AgentManager { continue; } chunks.push(item.text); + startsAtBeginning = i === 0; } if (!chunks.length) { return null; } - return chunks.reverse().join(""); + return { + text: chunks.reverse().join(""), + startsAtBeginning, + }; + } + + private async getLastAssistantMessageFromStores(agentId: string): Promise { + const liveTimeline = this.timelineStore.getItems(agentId); + const liveSegment = this.getLastAssistantMessageSegmentFromTimeline(liveTimeline); + if (!this.durableTimelineStore) { + return liveSegment?.text ?? null; + } + + if (!liveSegment) { + return await this.durableTimelineStore.getLastAssistantMessage(agentId); + } + + if (!liveSegment.startsAtBeginning) { + return liveSegment.text; + } + + const lastDurableItem = await this.durableTimelineStore.getLastItem(agentId); + if (lastDurableItem?.type !== "assistant_message") { + return liveSegment.text; + } + + const durableMessage = await this.durableTimelineStore.getLastAssistantMessage(agentId); + return durableMessage ? `${durableMessage}${liveSegment.text}` : liveSegment.text; + } + + private async getLastItemFromStores(agentId: string): Promise { + const lastLiveItem = this.timelineStore.getLastItem(agentId); + if (lastLiveItem) { + return lastLiveItem; + } + if (!this.durableTimelineStore) { + return null; + } + return await this.durableTimelineStore.getLastItem(agentId); + } + + private async hasCommittedUserMessageFromStores( + agentId: string, + options: { messageId: string; text: string }, + ): Promise { + if (this.timelineStore.hasCommittedUserMessage(agentId, options)) { + return true; + } + if (!this.durableTimelineStore) { + return false; + } + return await this.durableTimelineStore.hasCommittedUserMessage(agentId, options); } async waitForAgentEvent( @@ -1575,7 +1854,7 @@ export class AgentManager { return { status: snapshot.lifecycle, permission: immediatePermission, - lastMessage: this.getLastAssistantMessage(agentId), + lastMessage: await this.getLastAssistantMessage(agentId), }; } @@ -1586,14 +1865,14 @@ export class AgentManager { return { status: initialStatus, permission: null, - lastMessage: this.getLastAssistantMessage(agentId), + lastMessage: await this.getLastAssistantMessage(agentId), }; } if (waitForActive && !initialBusy && !hasForegroundTurn) { return { status: initialStatus, permission: null, - lastMessage: this.getLastAssistantMessage(agentId), + lastMessage: await this.getLastAssistantMessage(agentId), }; } @@ -1612,6 +1891,7 @@ export class AgentManager { let currentStatus: AgentLifecycleStatus = initialStatus; let hasStarted = initialBusy || hasForegroundTurn; let terminalStatusOverride: AgentLifecycleStatus | null = null; + let finished = false; // Bug #3 Fix: Declare unsubscribe and abortHandler upfront so cleanup can reference them let unsubscribe: (() => void) | null = null; @@ -1640,12 +1920,20 @@ export class AgentManager { }; const finish = (permission: AgentPermissionRequest | null) => { + if (finished) { + return; + } + finished = true; cleanup(); - resolve({ - status: currentStatus, - permission, - lastMessage: this.getLastAssistantMessage(agentId), - }); + void this.getLastAssistantMessage(agentId) + .then((lastMessage) => { + resolve({ + status: currentStatus, + permission, + lastMessage, + }); + }) + .catch(reject); }; // Bug #3 Fix: Set up abort handler BEFORE subscription @@ -1710,14 +1998,15 @@ export class AgentManager { config: AgentSessionConfig, agentId: string, options?: { + workspaceId?: number; createdAt?: Date; updatedAt?: Date; lastUserMessageAt?: Date | null; labels?: Record; timeline?: AgentTimelineItem[]; timelineRows?: AgentTimelineRow[]; - timelineEpoch?: string; timelineNextSeq?: number; + provisionalAssistantText?: string | null; historyPrimed?: boolean; lastUsage?: AgentUsage; lastError?: string; @@ -1731,24 +2020,37 @@ export class AgentManager { const initialPersistedTitle = await this.resolveInitialPersistedTitle(resolvedAgentId, config); const now = new Date(); - const initialTimeline = options?.timeline ? [...options.timeline] : []; - const initialTimelineRows = options?.timelineRows?.length - ? options.timelineRows.map((row) => ({ ...row })) - : this.buildTimelineRowsFromItems( - initialTimeline, - options?.timelineNextSeq ?? 1, - (options?.updatedAt ?? options?.createdAt ?? now).toISOString(), - ); - const derivedNextSeq = - options?.timelineNextSeq ?? - (initialTimelineRows.length - ? initialTimelineRows[initialTimelineRows.length - 1]!.seq + 1 - : 1); + const explicitTimelineSeed: SeedAgentTimelineOptions | null = + options?.timeline?.length || + options?.timelineRows?.length || + options?.timelineNextSeq !== undefined + ? { + items: options?.timeline, + rows: options?.timelineRows, + nextSeq: options?.timelineNextSeq, + timestamp: (options?.updatedAt ?? options?.createdAt ?? now).toISOString(), + } + : null; + const shouldSeedFromDurable = + !explicitTimelineSeed && + !this.timelineStore.has(resolvedAgentId) && + this.durableTimelineStore !== undefined; + const durableTimelineSeed = shouldSeedFromDurable + ? await this.loadCommittedTimelineSeed(resolvedAgentId, now) + : null; + const timelineSeed = explicitTimelineSeed ?? durableTimelineSeed; + if (timelineSeed || !this.timelineStore.has(resolvedAgentId)) { + this.timelineStore.initialize(resolvedAgentId, timelineSeed ?? { timestamp: now.toISOString() }); + } + if (options?.timelineRows?.length) { + this.enqueueDurableTimelineBulkInsert(resolvedAgentId, options.timelineRows); + } const managed = { id: resolvedAgentId, provider: config.provider, cwd: config.cwd, + terminal: false, session, capabilities: session.capabilities, config, @@ -1758,20 +2060,18 @@ export class AgentManager { updatedAt: options?.updatedAt ?? now, availableModes: [], currentModeId: null, - pendingPermissions: new Map(), + pendingPermissions: new Map(), pendingReplacement: false, activeForegroundTurnId: null, - foregroundTurnWaiters: new Set(), + foregroundTurnWaiters: new Set(), unsubscribeSession: null, - timeline: initialTimeline, - timelineRows: initialTimelineRows, - timelineEpoch: options?.timelineEpoch ?? randomUUID(), - timelineNextSeq: derivedNextSeq, + provisionalAssistantText: options?.provisionalAssistantText ?? null, persistence: attachPersistenceCwd(session.describePersistence(), config.cwd), - historyPrimed: options?.historyPrimed ?? false, + historyPrimed: options?.historyPrimed ?? shouldSeedFromDurable, lastUserMessageAt: options?.lastUserMessageAt ?? null, lastUsage: options?.lastUsage, lastError: options?.lastError, + terminalExit: undefined, attention: options?.attention != null ? options.attention.requiresAttention @@ -1791,34 +2091,270 @@ export class AgentManager { this.previousStatuses.set(resolvedAgentId, managed.lifecycle); await this.refreshRuntimeInfo(managed); await this.persistSnapshot(managed, { + workspaceId: options?.workspaceId, title: initialPersistedTitle, }); - this.emitState(managed); + this.emitState(managed, { persist: false }); await this.refreshSessionState(managed); managed.lifecycle = "idle"; - await this.persistSnapshot(managed); - this.emitState(managed); + await this.persistSnapshot(managed, { workspaceId: options?.workspaceId }); + this.emitState(managed, { persist: false }); this.subscribeToSession(managed); return { ...managed }; } + private async loadCommittedTimelineSeed( + agentId: string, + now: Date, + ): Promise { + if (!this.durableTimelineStore) { + return { timestamp: now.toISOString() }; + } + + return { + nextSeq: (await this.durableTimelineStore.getLatestCommittedSeq(agentId)) + 1, + timestamp: now.toISOString(), + }; + } + + private async registerTerminalAgent( + agentId: string, + config: AgentSessionConfig, + capabilities: AgentCapabilityFlags, + terminalCommand: TerminalCommand, + persistence: AgentPersistenceHandle, + options?: { + workspaceId?: number; + createdAt?: Date; + updatedAt?: Date; + lastUserMessageAt?: Date | null; + labels?: Record; + attention?: AttentionState; + }, + ): Promise { + if (!this.terminalManager) { + throw new Error("Terminal manager is not configured"); + } + const resolvedAgentId = validateAgentId(agentId, "registerTerminalAgent"); + if (this.agents.has(resolvedAgentId)) { + throw new Error(`Agent with id ${resolvedAgentId} already exists`); + } + const initialPersistedTitle = await this.resolveInitialPersistedTitle(resolvedAgentId, config); + const now = new Date(); + const reservedTerminalId = randomUUID(); + + const managed: ManagedTerminalAgent = { + id: resolvedAgentId, + provider: config.provider, + cwd: config.cwd, + terminal: true, + session: null, + capabilities, + config, + runtimeInfo: undefined, + lifecycle: "idle", + createdAt: options?.createdAt ?? now, + updatedAt: options?.updatedAt ?? now, + availableModes: [], + currentModeId: config.modeId ?? null, + pendingPermissions: new Map(), + pendingReplacement: false, + activeForegroundTurnId: null, + foregroundTurnWaiters: new Set(), + unsubscribeSession: null, + provisionalAssistantText: null, + persistence: attachPersistenceCwd(persistence, config.cwd), + historyPrimed: false, + lastUserMessageAt: options?.lastUserMessageAt ?? null, + lastUsage: undefined, + lastError: undefined, + terminalExit: undefined, + attention: + options?.attention != null + ? options.attention.requiresAttention + ? { + requiresAttention: true, + attentionReason: options.attention.attentionReason, + attentionTimestamp: new Date(options.attention.attentionTimestamp), + } + : { requiresAttention: false } + : { requiresAttention: false }, + internal: config.internal ?? false, + labels: options?.labels ?? {}, + terminalCommand, + terminalId: reservedTerminalId, + unsubscribeTerminalExit: null, + }; + + this.agents.set(resolvedAgentId, managed); + this.previousStatuses.set(resolvedAgentId, managed.lifecycle); + this.agentsAwaitingInitialSnapshotPersist.add(resolvedAgentId); + + let terminalSession: TerminalSession; + try { + terminalSession = await this.terminalManager.createTerminal({ + id: reservedTerminalId, + cwd: config.cwd, + name: initialPersistedTitle ?? undefined, + command: terminalCommand.command, + args: terminalCommand.args, + env: terminalCommand.env, + }); + } catch (error) { + this.agents.delete(resolvedAgentId); + this.previousStatuses.delete(resolvedAgentId); + this.agentsAwaitingInitialSnapshotPersist.delete(resolvedAgentId); + throw error; + } + + if (terminalSession.id !== reservedTerminalId) { + this.agents.delete(resolvedAgentId); + this.previousStatuses.delete(resolvedAgentId); + this.agentsAwaitingInitialSnapshotPersist.delete(resolvedAgentId); + throw new Error( + `Reserved terminal id ${reservedTerminalId} but terminal manager returned ${terminalSession.id}`, + ); + } + + const unsubscribeTerminalExit = terminalSession.onExit((exit) => { + void this.handleTerminalAgentExited(resolvedAgentId, exit); + }); + managed.unsubscribeTerminalExit = unsubscribeTerminalExit; + const terminalSessionTitle = terminalSession.getTitle()?.trim(); + try { + await this.persistSnapshot(managed, { + workspaceId: options?.workspaceId, + title: + terminalSessionTitle && terminalSessionTitle.length > 0 + ? terminalSessionTitle + : initialPersistedTitle, + }); + } finally { + this.agentsAwaitingInitialSnapshotPersist.delete(resolvedAgentId); + } + this.emitState(managed); + return { ...managed }; + } + + private async handleTerminalAgentExited(agentId: string, exit: TerminalExitInfo): Promise { + const agent = this.agents.get(agentId); + if (!agent || !agent.terminal) { + return; + } + const terminalExit = buildTerminalExitDetails({ + command: agent.terminalCommand.command, + exit, + }); + if (terminalExit) { + agent.terminalExit = terminalExit; + if (terminalExit.exitCode !== null && terminalExit.exitCode !== 0) { + agent.lastError = buildTerminalExitErrorMessage(terminalExit); + } else if (terminalExit.signal !== null) { + agent.lastError = buildTerminalExitErrorMessage(terminalExit); + } + } + const closedAgent = this.prepareAgentForClosure(agent, "agent terminal exited"); + await this.persistSnapshot(closedAgent); + this.emitClosedAgent(closedAgent); + } + + private buildTerminalPersistenceHandle( + agentId: string, + provider: AgentProvider, + cwd: string, + ): AgentPersistenceHandle { + return attachPersistenceCwd( + { + provider, + sessionId: agentId, + nativeHandle: agentId, + }, + cwd, + )!; + } + + private prepareAgentForClosure( + agent: LiveManagedAgent, + cancelReason: string, + ): ManagedAgentClosed { + this.agents.delete(agent.id); + this.previousStatuses.delete(agent.id); + if (agent.unsubscribeSession) { + agent.unsubscribeSession(); + agent.unsubscribeSession = null; + } + if (agent.terminal && agent.unsubscribeTerminalExit) { + agent.unsubscribeTerminalExit(); + agent.unsubscribeTerminalExit = null; + } + for (const waiter of agent.foregroundTurnWaiters) { + waiter.callback({ + type: "turn_canceled", + provider: agent.provider, + reason: cancelReason, + turnId: waiter.turnId, + }); + this.settleForegroundTurnWaiter(waiter); + } + agent.foregroundTurnWaiters.clear(); + this.settlePendingForegroundRun(agent.id); + return { + ...agent, + lifecycle: "closed", + session: null, + activeForegroundTurnId: null, + }; + } + + private emitClosedAgent(agent: ManagedAgentClosed): void { + this.emitState(agent); + } private subscribeToSession(agent: ActiveManagedAgent): void { if (agent.unsubscribeSession) { return; } const agentId = agent.id; const unsubscribe = agent.session.subscribe((event: AgentStreamEvent) => { - const current = this.agents.get(agentId); - if (!current) { - return; - } - this.dispatchSessionEvent(current, event); + this.enqueueSessionEvent(agentId, event); }); agent.unsubscribeSession = unsubscribe; } - private dispatchSessionEvent(agent: ActiveManagedAgent, event: AgentStreamEvent): void { + private enqueueSessionEvent(agentId: string, event: AgentStreamEvent): void { + const previous = this.sessionEventTails.get(agentId) ?? Promise.resolve(); + const next = previous + .catch(() => undefined) + .then(async () => { + const current = this.agents.get(agentId); + if (!current) { + return; + } + if (current.terminal || current.session == null) { + return; + } + await this.dispatchSessionEvent(current, event); + }) + .catch((err) => { + this.logger.error( + { err, agentId, eventType: event.type }, + "Failed to process session event", + ); + }); + + this.sessionEventTails.set(agentId, next); + this.trackBackgroundTask(next); + void next.finally(() => { + if (this.sessionEventTails.get(agentId) === next) { + this.sessionEventTails.delete(agentId); + } + }); + } + + private async dispatchSessionEvent( + agent: ActiveManagedAgent, + event: AgentStreamEvent, + ): Promise { const turnId = (event as { turnId?: string }).turnId; const matchingWaiters = turnId == null @@ -1827,7 +2363,7 @@ export class AgentManager { (waiter) => waiter.turnId === turnId && !waiter.settled, ); - this.handleStreamEvent(agent, event); + await this.handleStreamEvent(agent, event); for (const waiter of matchingWaiters) { waiter.callback(event); @@ -1898,47 +2434,9 @@ export class AgentManager { return null; } - private buildTimelineRowsFromItems( - items: readonly AgentTimelineItem[], - startSeq: number, - timestamp: string, - ): AgentTimelineRow[] { - let nextSeq = startSeq; - return items.map((item) => { - const row: AgentTimelineRow = { - seq: nextSeq, - timestamp, - item, - }; - nextSeq += 1; - return row; - }); - } - - private ensureTimelineState(agent: ManagedAgent): { - rows: AgentTimelineRow[]; - epoch: string; - nextSeq: number; - minSeq: number; - maxSeq: number; - } { - const minSeq = agent.timelineRows.length ? agent.timelineRows[0]!.seq : 0; - const maxSeq = agent.timelineRows.length - ? agent.timelineRows[agent.timelineRows.length - 1]!.seq - : 0; - - return { - rows: agent.timelineRows, - epoch: agent.timelineEpoch, - nextSeq: agent.timelineNextSeq, - minSeq, - maxSeq, - }; - } - private async persistSnapshot( agent: ManagedAgent, - options?: { title?: string | null; internal?: boolean }, + options?: { workspaceId?: number; title?: string | null; internal?: boolean }, ): Promise { if (!this.registry) { return; @@ -1947,9 +2445,20 @@ export class AgentManager { if (agent.internal) { return; } + if (options?.workspaceId !== undefined) { + await this.registry.applySnapshot(agent, options.workspaceId, options); + return; + } await this.registry.applySnapshot(agent, options); } + private requireRegistry(): AgentSnapshotStore { + if (!this.registry) { + throw new Error("Agent storage unavailable"); + } + return this.registry; + } + private async refreshSessionState(agent: ActiveManagedAgent): Promise { try { const modes = await agent.session.getAvailableModes(); @@ -1998,48 +2507,89 @@ export class AgentManager { } } - private async hydrateTimeline(agent: ActiveManagedAgent): Promise { + private async hydrateTimelineFromLegacyProviderHistory( + agent: ActiveManagedAgent, + ): Promise { if (agent.historyPrimed) { return; } agent.historyPrimed = true; - const canonicalUserMessagesById = new Map( - agent.timelineRows.flatMap<[string, string]>((row) => { - if (row.item.type !== "user_message") { - return []; - } - const messageId = normalizeMessageId(row.item.messageId); - if (!messageId) { - return []; - } - return [[messageId, row.item.text]]; - }), - ); + const canonicalUserMessagesById = this.timelineStore.getCanonicalUserMessagesById(agent.id); + const pendingTurnItems: AgentTimelineItem[] = []; + let bufferedAssistantText = ""; + const flushPendingTurn = () => { + for (const item of pendingTurnItems) { + this.recordTimeline(agent.id, item); + } + pendingTurnItems.length = 0; + if (bufferedAssistantText) { + this.recordTimeline(agent.id, { + type: "assistant_message", + text: bufferedAssistantText, + }); + bufferedAssistantText = ""; + } + }; try { for await (const event of agent.session.streamHistory()) { - this.handleStreamEvent(agent, event, { - fromHistory: true, - canonicalUserMessagesById: - canonicalUserMessagesById.size > 0 ? canonicalUserMessagesById : undefined, - }); + if (event.type !== "timeline") { + if ( + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled" + ) { + flushPendingTurn(); + } + continue; + } + + if (event.item.type === "user_message") { + flushPendingTurn(); + const eventMessageId = normalizeMessageId(event.item.messageId); + if (eventMessageId) { + const canonicalText = canonicalUserMessagesById.get(eventMessageId); + if (canonicalText === event.item.text) { + continue; + } + } + this.recordTimeline(agent.id, event.item); + continue; + } + + if (event.item.type === "assistant_message") { + bufferedAssistantText += event.item.text; + continue; + } + + if (event.item.type === "reasoning") { + continue; + } + + if (event.item.type === "tool_call" && event.item.status === "running") { + continue; + } + + pendingTurnItems.push(event.item); } + flushPendingTurn(); } catch { // ignore history failures } } - private handleStreamEvent( + private async handleStreamEvent( agent: ActiveManagedAgent, event: AgentStreamEvent, options?: { fromHistory?: boolean; canonicalUserMessagesById?: ReadonlyMap; }, - ): void { + ): Promise { const eventTurnId = (event as { turnId?: string }).turnId; const isForegroundEvent = Boolean( eventTurnId && agent.activeForegroundTurnId === eventTurnId, ); + let suppressLiveDispatch = false; // Only update timestamp for live events, not history replay if (!options?.fromHistory) { @@ -2083,19 +2633,28 @@ export class AgentManager { const eventMessageId = normalizeMessageId(event.item.messageId); const eventText = event.item.text; if (eventMessageId) { - const alreadyRecorded = agent.timelineRows.some((row) => { - if (row.item.type !== "user_message") { - return false; - } - const rowMessageId = normalizeMessageId(row.item.messageId); - return rowMessageId === eventMessageId && row.item.text === eventText; - }); - if (alreadyRecorded) { + if ( + await this.hasCommittedUserMessageFromStores(agent.id, { + messageId: eventMessageId, + text: eventText, + }) + ) { break; } } } - timelineRow = this.recordTimeline(agent, event.item); + if (event.item.type === "assistant_message") { + agent.provisionalAssistantText = `${agent.provisionalAssistantText ?? ""}${event.item.text}`; + suppressLiveDispatch = true; + break; + } + if (event.item.type === "reasoning") { + break; + } + if (event.item.type === "tool_call" && event.item.status === "running") { + break; + } + timelineRow = this.recordTimeline(agent.id, event.item); if (!options?.fromHistory && event.item.type === "user_message") { agent.lastUserMessageAt = new Date(); this.emitState(agent); @@ -2111,6 +2670,27 @@ export class AgentManager { }, "handleStreamEvent: turn_completed", ); + if (agent.provisionalAssistantText) { + const item: AgentTimelineItem = { + type: "assistant_message", + text: agent.provisionalAssistantText, + }; + timelineRow = this.recordTimeline(agent.id, item); + if (!options?.fromHistory) { + this.dispatchStream( + agent.id, + { + type: "timeline", + item, + provider: event.provider, + }, + { + seq: timelineRow.seq, + }, + ); + } + agent.provisionalAssistantText = null; + } agent.lastUsage = event.usage; agent.lastError = undefined; // For autonomous turns (not foreground), transition to idle @@ -2134,12 +2714,13 @@ export class AgentManager { }, "handleStreamEvent: turn_failed", ); + agent.provisionalAssistantText = null; // For autonomous turns, set error state directly if (!isForegroundEvent) { agent.lifecycle = "error"; } agent.lastError = event.error; - this.appendSystemErrorTimelineMessage( + await this.appendSystemErrorTimelineMessage( agent, event.provider, this.formatTurnFailedMessage(event), @@ -2170,6 +2751,7 @@ export class AgentManager { }, "handleStreamEvent: turn_canceled", ); + agent.provisionalAssistantText = null; // For autonomous turns, transition to idle // unless a replacement is pending (avoid idle flash during replace) if (!isForegroundEvent && !agent.pendingReplacement) { @@ -2201,6 +2783,7 @@ export class AgentManager { }, "handleStreamEvent: turn_started", ); + agent.provisionalAssistantText = null; // For autonomous turn_started (no foreground match), set running if (!isForegroundEvent) { (agent as ActiveManagedAgent).lifecycle = "running"; @@ -2230,21 +2813,20 @@ export class AgentManager { } // Skip dispatching individual stream events during history replay. - if (!options?.fromHistory) { + if (!options?.fromHistory && !suppressLiveDispatch) { this.dispatchStream( agent.id, event, timelineRow ? { seq: timelineRow.seq, - epoch: this.ensureTimelineState(agent).epoch, } : undefined, ); } } - private appendSystemErrorTimelineMessage( + private async appendSystemErrorTimelineMessage( agent: ActiveManagedAgent, provider: AgentProvider, message: string, @@ -2252,7 +2834,7 @@ export class AgentManager { fromHistory?: boolean; canonicalUserMessagesById?: ReadonlyMap; }, - ): void { + ): Promise { if (options?.fromHistory) { return; } @@ -2263,13 +2845,13 @@ export class AgentManager { } const text = `${SYSTEM_ERROR_PREFIX} ${normalized}`; - const lastItem = agent.timelineRows[agent.timelineRows.length - 1]?.item; + const lastItem = await this.getLastItemFromStores(agent.id); if (lastItem?.type === "assistant_message" && lastItem.text === text) { return; } const item: AgentTimelineItem = { type: "assistant_message", text }; - const row = this.recordTimeline(agent, item); + const row = this.recordTimeline(agent.id, item); this.dispatchStream( agent.id, { @@ -2279,7 +2861,6 @@ export class AgentManager { }, { seq: row.seq, - epoch: this.ensureTimelineState(agent).epoch, }, ); } @@ -2300,30 +2881,18 @@ export class AgentManager { return parts.join("\n\n"); } - private recordTimeline(agent: ManagedAgent, item: AgentTimelineItem): AgentTimelineRow { - const timelineState = this.ensureTimelineState(agent); - const row: AgentTimelineRow = { - seq: timelineState.nextSeq, - timestamp: new Date().toISOString(), - item, - }; - agent.timelineNextSeq = timelineState.nextSeq + 1; - agent.timeline.push(item); - timelineState.rows.push(row); - if ( - typeof this.maxTimelineItems === "number" && - agent.timeline.length > this.maxTimelineItems - ) { - const removeCount = agent.timeline.length - this.maxTimelineItems; - agent.timeline.splice(0, removeCount); - timelineState.rows.splice(0, removeCount); - } + private recordTimeline(agentId: string, item: AgentTimelineItem): AgentTimelineRow { + const row = this.timelineStore.append(agentId, item); + this.enqueueDurableTimelineAppend(agentId, row); return row; } - private emitState(agent: ManagedAgent): void { + private emitState(agent: ManagedAgent, options?: { persist?: boolean }): void { // Keep attention as an edge-triggered unread signal, not a level signal. this.checkAndSetAttention(agent); + if (options?.persist !== false) { + this.enqueueBackgroundPersist(agent); + } this.dispatch({ type: "agent_state", @@ -2356,7 +2925,6 @@ export class AgentManager { attentionTimestamp: new Date(), }; this.broadcastAgentAttention(agent, "finished"); - this.enqueueBackgroundPersist(agent); return; } @@ -2368,7 +2936,6 @@ export class AgentManager { attentionTimestamp: new Date(), }; this.broadcastAgentAttention(agent, "error"); - this.enqueueBackgroundPersist(agent); return; } } @@ -2380,6 +2947,38 @@ export class AgentManager { this.trackBackgroundTask(task); } + private enqueueDurableTimelineAppend(agentId: string, row: AgentTimelineRow): void { + if (!this.durableTimelineStore) { + return; + } + const task = this.durableTimelineStore + .appendCommitted(agentId, row.item, { timestamp: row.timestamp }) + .then(() => undefined) + .catch((err) => { + this.logger.error( + { err, agentId, seq: row.seq, itemType: row.item.type }, + "Failed to append timeline row to durable store", + ); + }); + this.trackBackgroundTask(task); + } + + private enqueueDurableTimelineBulkInsert( + agentId: string, + rows: readonly AgentTimelineRow[], + ): void { + if (!this.durableTimelineStore || rows.length === 0) { + return; + } + const task = this.durableTimelineStore.bulkInsert(agentId, rows).catch((err) => { + this.logger.error( + { err, agentId, rowCount: rows.length }, + "Failed to seed durable timeline store", + ); + }); + this.trackBackgroundTask(task); + } + private trackBackgroundTask(task: Promise): void { this.backgroundTasks.add(task); void task.finally(() => { @@ -2413,7 +3012,7 @@ export class AgentManager { private dispatchStream( agentId: string, event: AgentStreamEvent, - metadata?: { seq?: number; epoch?: string }, + metadata?: { seq?: number }, ): void { this.dispatch({ type: "agent_stream", agentId, event, ...metadata }); } @@ -2502,7 +3101,7 @@ export class AgentManager { return client; } - private requireAgent(id: string): ActiveManagedAgent { + private requireAgent(id: string): LiveManagedAgent { const normalizedId = validateAgentId(id, "requireAgent"); const agent = this.agents.get(normalizedId); if (!agent) { @@ -2511,4 +3110,12 @@ export class AgentManager { return agent; } + private requireSessionAgent(id: string): ActiveManagedAgent { + const agent = this.requireAgent(id); + if (agent.terminal || agent.session === null) { + throw new Error(`Agent '${agent.id}' is a terminal agent and has no managed session`); + } + return agent; + } + } diff --git a/packages/server/src/server/agent/agent-projections.test.ts b/packages/server/src/server/agent/agent-projections.test.ts index d8e81201d..7c59c9bbc 100644 --- a/packages/server/src/server/agent/agent-projections.test.ts +++ b/packages/server/src/server/agent/agent-projections.test.ts @@ -58,6 +58,7 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent supportsMcpServers: true, supportsReasoningStream: true, supportsToolInvocations: true, + supportsTerminalMode: false, }, config: { ...baseConfig, ...configOverrides }, lifecycle, diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 5d449e114..28f452230 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -62,6 +62,8 @@ export function toStoredAgentRecord( config: config ?? null, runtimeInfo, persistence, + lastError: agent.lastError ?? undefined, + terminalExit: agent.terminalExit ?? undefined, requiresAttention: agent.attention.requiresAttention, attentionReason: agent.attention.requiresAttention ? agent.attention.attentionReason : null, attentionTimestamp: agent.attention.requiresAttention @@ -86,6 +88,7 @@ export function toAgentPayload( id: agent.id, provider: agent.provider, cwd: agent.cwd, + terminal: agent.terminal, model: agent.config.model ?? null, thinkingOptionId, effectiveThinkingOptionId, @@ -112,6 +115,10 @@ export function toAgentPayload( payload.lastError = agent.lastError; } + if (agent.terminalExit) { + payload.terminalExit = agent.terminalExit; + } + // Handle attention state payload.requiresAttention = agent.attention.requiresAttention; if (agent.attention.requiresAttention) { @@ -127,6 +134,9 @@ export function toAgentPayload( function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null { const serializable: SerializableAgentConfig = {}; + if (config.terminal !== undefined) { + serializable.terminal = config.terminal; + } if (Object.prototype.hasOwnProperty.call(config, "title")) { serializable.title = config.title ?? null; } diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index a4440aa81..7e179a0a7 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -71,6 +71,7 @@ export type AgentCapabilityFlags = { supportsMcpServers: boolean; supportsReasoningStream: boolean; supportsToolInvocations: boolean; + supportsTerminalMode: boolean; }; export type AgentPersistenceHandle = { @@ -356,9 +357,16 @@ export type PersistedAgentDescriptor = { timeline: AgentTimelineItem[]; }; +export type TerminalCommand = { + command: string; + args: string[]; + env?: Record; +}; + export type AgentSessionConfig = { provider: AgentProvider; cwd: string; + terminal?: boolean; /** * Provider-agnostic system/developer instruction string. * Mapped by each provider to its native instruction field. @@ -428,6 +436,12 @@ export interface AgentClient { ): Promise; listModels(options?: ListModelsOptions): Promise; listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise; + buildTerminalCreateCommand?( + config: AgentSessionConfig, + handle: AgentPersistenceHandle, + initialPrompt?: string, + ): TerminalCommand; + buildTerminalResumeCommand?(handle: AgentPersistenceHandle): TerminalCommand; /** * Check if this provider is available (CLI binary is installed). * Returns true if available, false otherwise. diff --git a/packages/server/src/server/agent/agent-snapshot-store.ts b/packages/server/src/server/agent/agent-snapshot-store.ts new file mode 100644 index 000000000..ffa3c27bd --- /dev/null +++ b/packages/server/src/server/agent/agent-snapshot-store.ts @@ -0,0 +1,19 @@ +import type { ManagedAgent } from "./agent-manager.js"; +import type { StoredAgentRecord } from "./agent-storage.js"; + +export interface AgentSnapshotStore { + list(): Promise; + get(agentId: string): Promise; + upsert(record: StoredAgentRecord): Promise; + remove(agentId: string): Promise; + applySnapshot( + agent: ManagedAgent, + options?: { title?: string | null; internal?: boolean }, + ): Promise; + applySnapshot( + agent: ManagedAgent, + workspaceId: number, + options?: { title?: string | null; internal?: boolean }, + ): Promise; + setTitle(agentId: string, title: string): Promise; +} diff --git a/packages/server/src/server/agent/agent-storage.test.ts b/packages/server/src/server/agent/agent-storage.test.ts index 00db5a635..1304b7f89 100644 --- a/packages/server/src/server/agent/agent-storage.test.ts +++ b/packages/server/src/server/agent/agent-storage.test.ts @@ -57,6 +57,7 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent supportsMcpServers: true, supportsReasoningStream: true, supportsToolInvocations: true, + supportsTerminalMode: false, }, config, lifecycle, diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index 6c0ebd54d..2b2748df6 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -7,10 +7,12 @@ import type { Logger } from "pino"; import { AgentStatusSchema } from "../messages.js"; import { toStoredAgentRecord } from "./agent-projections.js"; import type { ManagedAgent } from "./agent-manager.js"; +import type { AgentSnapshotStore } from "./agent-snapshot-store.js"; import type { AgentSessionConfig } from "./agent-sdk-types.js"; const SERIALIZABLE_CONFIG_SCHEMA = z .object({ + terminal: z.boolean().optional(), title: z.string().nullable().optional(), modeId: z.string().nullable().optional(), model: z.string().nullable().optional(), @@ -56,6 +58,16 @@ const STORED_AGENT_SCHEMA = z.object({ }) .optional(), persistence: PERSISTENCE_HANDLE_SCHEMA, + lastError: z.string().nullable().optional(), + terminalExit: z + .object({ + command: z.string(), + message: z.string(), + exitCode: z.number().nullable(), + signal: z.number().nullable(), + outputLines: z.array(z.string()), + }) + .optional(), requiresAttention: z.boolean().optional(), attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(), attentionTimestamp: z.string().nullable().optional(), @@ -65,12 +77,22 @@ const STORED_AGENT_SCHEMA = z.object({ export type SerializableAgentConfig = Pick< AgentSessionConfig, - "title" | "modeId" | "model" | "thinkingOptionId" | "extra" | "systemPrompt" | "mcpServers" + | "terminal" + | "title" + | "modeId" + | "model" + | "thinkingOptionId" + | "extra" + | "systemPrompt" + | "mcpServers" >; export type StoredAgentRecord = z.infer; +export function parseStoredAgentRecord(value: unknown): StoredAgentRecord { + return STORED_AGENT_SCHEMA.parse(value); +} -export class AgentStorage { +export class AgentStorage implements AgentSnapshotStore { private cache: Map = new Map(); private pathById: Map = new Map(); private pathsById: Map> = new Map(); @@ -168,19 +190,22 @@ export class AgentStorage { async applySnapshot( agent: ManagedAgent, + workspaceIdOrOptions?: number | { title?: string | null; internal?: boolean }, options?: { title?: string | null; internal?: boolean }, ): Promise { + const nextOptions = + typeof workspaceIdOrOptions === "number" ? options : workspaceIdOrOptions; await this.load(); await this.waitForPendingWrite(agent.id); const existing = (await this.get(agent.id)) ?? null; const hasTitleOverride = - options !== undefined && Object.prototype.hasOwnProperty.call(options, "title"); + nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, "title"); const hasInternalOverride = - options !== undefined && Object.prototype.hasOwnProperty.call(options, "internal"); + nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, "internal"); const record = toStoredAgentRecord(agent, { - title: hasTitleOverride ? (options?.title ?? null) : (existing?.title ?? null), + title: hasTitleOverride ? (nextOptions?.title ?? null) : (existing?.title ?? null), createdAt: existing?.createdAt, - internal: hasInternalOverride ? options?.internal : (agent.internal ?? existing?.internal), + internal: hasInternalOverride ? nextOptions?.internal : (agent.internal ?? existing?.internal), }); // Preserve soft-delete/archive status across snapshot flushes. @@ -300,7 +325,7 @@ export class AgentStorage { try { const content = await fs.readFile(filePath, "utf8"); const parsed = JSON.parse(content); - return STORED_AGENT_SCHEMA.parse(parsed); + return parseStoredAgentRecord(parsed); } catch (error) { this.logger.error({ err: error, filePath }, "Skipping invalid agent record"); return null; diff --git a/packages/server/src/server/agent/agent-timeline-store-types.ts b/packages/server/src/server/agent/agent-timeline-store-types.ts new file mode 100644 index 000000000..1543baa18 --- /dev/null +++ b/packages/server/src/server/agent/agent-timeline-store-types.ts @@ -0,0 +1,60 @@ +import type { AgentTimelineItem } from "./agent-sdk-types.js"; + +export type AgentTimelineRow = { + seq: number; + timestamp: string; + item: AgentTimelineItem; +}; + +export type AgentTimelineCursor = { + seq: number; +}; + +export type AgentTimelineFetchDirection = "tail" | "before" | "after"; + +export type AgentTimelineFetchOptions = { + direction?: AgentTimelineFetchDirection; + cursor?: AgentTimelineCursor; + /** + * Number of canonical rows to return. + * - undefined: store default + * - 0: all rows in the selected window + */ + limit?: number; +}; + +export type AgentTimelineWindow = { + minSeq: number; + maxSeq: number; + nextSeq: number; +}; + +export type AgentTimelineFetchResult = { + direction: AgentTimelineFetchDirection; + window: AgentTimelineWindow; + hasOlder: boolean; + hasNewer: boolean; + rows: AgentTimelineRow[]; +}; + +export interface AgentTimelineStore { + appendCommitted( + agentId: string, + item: AgentTimelineItem, + options?: { timestamp?: string }, + ): Promise; + fetchCommitted( + agentId: string, + options?: AgentTimelineFetchOptions, + ): Promise; + getLatestCommittedSeq(agentId: string): Promise; + getCommittedRows(agentId: string): Promise; + getLastItem(agentId: string): Promise; + getLastAssistantMessage(agentId: string): Promise; + hasCommittedUserMessage( + agentId: string, + options: { messageId: string; text: string }, + ): Promise; + deleteAgent(agentId: string): Promise; + bulkInsert(agentId: string, rows: readonly AgentTimelineRow[]): Promise; +} diff --git a/packages/server/src/server/agent/agent-timeline-store.ts b/packages/server/src/server/agent/agent-timeline-store.ts new file mode 100644 index 000000000..1a966de8c --- /dev/null +++ b/packages/server/src/server/agent/agent-timeline-store.ts @@ -0,0 +1,244 @@ +import type { AgentTimelineItem } from "./agent-sdk-types.js"; +import type { + AgentTimelineFetchOptions, + AgentTimelineFetchResult, + AgentTimelineRow, +} from "./agent-timeline-store-types.js"; + +export type SeedAgentTimelineOptions = { + items?: readonly AgentTimelineItem[]; + rows?: readonly AgentTimelineRow[]; + nextSeq?: number; + timestamp?: string; +}; + +type AgentTimelineState = { + rows: AgentTimelineRow[]; + nextSeq: number; +}; + +const DEFAULT_TIMELINE_FETCH_LIMIT = 200; + +function cloneRow(row: AgentTimelineRow): AgentTimelineRow { + return { ...row }; +} + +function normalizeTimelineMessageId(messageId: string | undefined): string | undefined { + if (typeof messageId !== "string") { + return undefined; + } + const normalized = messageId.trim(); + return normalized.length > 0 ? normalized : undefined; +} + +export class InMemoryAgentTimelineStore { + private readonly states = new Map(); + + has(agentId: string): boolean { + return this.states.has(agentId); + } + + initialize(agentId: string, options?: SeedAgentTimelineOptions): void { + const timestamp = options?.timestamp ?? new Date().toISOString(); + const rows = options?.rows?.length + ? options.rows.map(cloneRow) + : this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp); + const nextSeq = + options?.nextSeq ?? (rows.length ? rows[rows.length - 1]!.seq + 1 : 1); + this.states.set(agentId, { + rows, + nextSeq, + }); + } + + delete(agentId: string): void { + this.states.delete(agentId); + } + + getItems(agentId: string): AgentTimelineItem[] { + return this.requireState(agentId).rows.map((row) => row.item); + } + + getRows(agentId: string): AgentTimelineRow[] { + return this.requireState(agentId).rows.map(cloneRow); + } + + fetch(agentId: string, options?: AgentTimelineFetchOptions): AgentTimelineFetchResult { + const state = this.requireState(agentId); + const direction = options?.direction ?? "tail"; + const requestedLimit = options?.limit; + const limit = + requestedLimit === undefined + ? DEFAULT_TIMELINE_FETCH_LIMIT + : Math.max(0, Math.floor(requestedLimit)); + const cursor = options?.cursor; + const minSeq = state.rows.length ? state.rows[0]!.seq : 0; + const maxSeq = state.rows.length ? state.rows[state.rows.length - 1]!.seq : 0; + const selectAll = limit === 0; + + const window = { + minSeq, + maxSeq, + nextSeq: state.nextSeq, + }; + + if (state.rows.length === 0) { + return { + direction, + window, + hasOlder: false, + hasNewer: false, + rows: [], + }; + } + + if (direction === "tail") { + const selected = + selectAll || limit >= state.rows.length ? state.rows : state.rows.slice(state.rows.length - limit); + return { + direction, + window, + hasOlder: selected.length > 0 && selected[0]!.seq > minSeq, + hasNewer: false, + rows: selected.map(cloneRow), + }; + } + + if (direction === "after") { + const baseSeq = cursor?.seq ?? 0; + const startIdx = state.rows.findIndex((row) => row.seq > baseSeq); + if (startIdx < 0) { + return { + direction, + window, + hasOlder: baseSeq >= minSeq, + hasNewer: false, + rows: [], + }; + } + + const selected = selectAll + ? state.rows.slice(startIdx) + : state.rows.slice(startIdx, startIdx + limit); + const lastSelected = selected[selected.length - 1]; + return { + direction, + window, + hasOlder: selected[0]!.seq > minSeq, + hasNewer: Boolean(lastSelected && lastSelected.seq < maxSeq), + rows: selected.map(cloneRow), + }; + } + + const beforeSeq = cursor?.seq ?? state.nextSeq; + const endExclusive = state.rows.findIndex((row) => row.seq >= beforeSeq); + const boundedRows = endExclusive < 0 ? state.rows : state.rows.slice(0, endExclusive); + const selected = + selectAll || limit >= boundedRows.length + ? boundedRows + : boundedRows.slice(boundedRows.length - limit); + return { + direction, + window, + hasOlder: selected.length > 0 && selected[0]!.seq > minSeq, + hasNewer: endExclusive >= 0, + rows: selected.map(cloneRow), + }; + } + + append( + agentId: string, + item: AgentTimelineItem, + options?: { timestamp?: string }, + ): AgentTimelineRow { + const state = this.requireState(agentId); + const row: AgentTimelineRow = { + seq: state.nextSeq, + timestamp: options?.timestamp ?? new Date().toISOString(), + item, + }; + state.nextSeq += 1; + state.rows.push(row); + return cloneRow(row); + } + + getLastItem(agentId: string): AgentTimelineItem | null { + const state = this.requireState(agentId); + return state.rows[state.rows.length - 1]?.item ?? null; + } + + getLastAssistantMessage(agentId: string): string | null { + const rows = this.requireState(agentId).rows; + const chunks: string[] = []; + for (let i = rows.length - 1; i >= 0; i -= 1) { + const item = rows[i]!.item; + if (item.type !== "assistant_message") { + if (chunks.length > 0) { + break; + } + continue; + } + chunks.push(item.text); + } + + if (chunks.length === 0) { + return null; + } + + return chunks.reverse().join(""); + } + + getCanonicalUserMessagesById(agentId: string): Map { + const entries = this.requireState(agentId).rows.flatMap<[string, string]>((row) => { + if (row.item.type !== "user_message") { + return []; + } + const messageId = normalizeTimelineMessageId(row.item.messageId); + if (!messageId) { + return []; + } + return [[messageId, row.item.text]]; + }); + return new Map(entries); + } + + hasCommittedUserMessage(agentId: string, options: { messageId: string; text: string }): boolean { + const messageId = normalizeTimelineMessageId(options.messageId); + if (!messageId) { + return false; + } + + return this.requireState(agentId).rows.some((row) => { + if (row.item.type !== "user_message") { + return false; + } + const rowMessageId = normalizeTimelineMessageId(row.item.messageId); + return rowMessageId === messageId && row.item.text === options.text; + }); + } + + private requireState(agentId: string): AgentTimelineState { + const state = this.states.get(agentId); + if (!state) { + throw new Error(`Unknown agent '${agentId}'`); + } + return state; + } + + private buildRowsFromItems( + items: readonly AgentTimelineItem[], + startSeq: number, + timestamp: string, + ): AgentTimelineRow[] { + let nextSeq = startSeq; + return items.map((item) => { + const row: AgentTimelineRow = { + seq: nextSeq, + timestamp, + item, + }; + nextSeq += 1; + return row; + }); + } +} diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 8c8a0b117..e7d2f231e 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -6,11 +6,11 @@ import { tmpdir } from "node:os"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { createAgentMcpServer } from "./mcp-server.js"; import type { AgentManager, ManagedAgent } from "./agent-manager.js"; -import type { AgentStorage } from "./agent-storage.js"; +import type { AgentSnapshotStore } from "./agent-snapshot-store.js"; type TestDeps = { agentManager: AgentManager; - agentStorage: AgentStorage; + agentStorage: AgentSnapshotStore; spies: { agentManager: Record; agentStorage: Record; @@ -41,7 +41,7 @@ function createTestDeps(): TestDeps { return { agentManager: agentManagerSpies as unknown as AgentManager, - agentStorage: agentStorageSpies as unknown as AgentStorage, + agentStorage: agentStorageSpies as unknown as AgentSnapshotStore, spies: { agentManager: agentManagerSpies, agentStorage: agentStorageSpies, diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 60f20cf5a..388878419 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -16,7 +16,7 @@ import { import { toAgentPayload } from "./agent-projections.js"; import { curateAgentActivity } from "./activity-curator.js"; import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js"; -import { AgentStorage } from "./agent-storage.js"; +import type { AgentSnapshotStore } from "./agent-snapshot-store.js"; import { appendTimelineItemIfAgentKnown, emitLiveTimelineItemIfAgentKnown, @@ -31,7 +31,7 @@ import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-boot export interface AgentMcpServerOptions { agentManager: AgentManager; - agentStorage: AgentStorage; + agentStorage: AgentSnapshotStore; terminalManager?: TerminalManager | null; paseoHome?: string; /** @@ -240,7 +240,7 @@ function sanitizePermissionRequest( } async function resolveAgentTitle( - agentStorage: AgentStorage, + agentStorage: AgentSnapshotStore, agentId: string, logger: Logger, ): Promise { @@ -254,7 +254,7 @@ async function resolveAgentTitle( } async function serializeSnapshotWithMetadata( - agentStorage: AgentStorage, + agentStorage: AgentSnapshotStore, snapshot: ManagedAgent, logger: Logger, ) { diff --git a/packages/server/src/server/agent/provider-launch-config.ts b/packages/server/src/server/agent/provider-launch-config.ts index 267c3bd8d..393f95965 100644 --- a/packages/server/src/server/agent/provider-launch-config.ts +++ b/packages/server/src/server/agent/provider-launch-config.ts @@ -180,6 +180,14 @@ export function applyProviderEnv( return merged; } +export function sanitizeTerminalEnv( + env: Record, +): Record { + return Object.fromEntries( + Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === "string"), + ); +} + /** * Resolve an executable name to its absolute path the way the user's shell would. * diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts index bc0b8d89c..53f514837 100644 --- a/packages/server/src/server/agent/provider-manifest.ts +++ b/packages/server/src/server/agent/provider-manifest.ts @@ -122,6 +122,27 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [ defaultModel: "gpt-5.1-codex-mini", }, }, + { + id: "gemini", + label: "Gemini CLI", + description: "Google's terminal-based coding agent", + defaultModeId: null, + modes: [], + }, + { + id: "amp", + label: "AMP", + description: "Sourcegraph's terminal-based coding agent", + defaultModeId: null, + modes: [], + }, + { + id: "aider", + label: "Aider", + description: "Paul Gauthier's terminal-based coding assistant", + defaultModeId: null, + modes: [], + }, { id: "opencode", label: "OpenCode", diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index d01e0c16e..fba671da7 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -9,6 +9,9 @@ import type { Logger } from "pino"; import { ClaudeAgentClient } from "./providers/claude-agent.js"; import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js"; +import { GeminiAgentClient } from "./providers/gemini-agent.js"; +import { AmpAgentClient } from "./providers/amp-agent.js"; +import { AiderAgentClient } from "./providers/aider-agent.js"; import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js"; import { @@ -40,6 +43,9 @@ export function buildProviderRegistry( runtimeSettings: runtimeSettings?.claude, }); const codexClient = new CodexAppServerAgentClient(logger, runtimeSettings?.codex); + const geminiClient = new GeminiAgentClient(runtimeSettings?.gemini); + const ampClient = new AmpAgentClient(runtimeSettings?.amp); + const aiderClient = new AiderAgentClient(runtimeSettings?.aider); const opencodeClient = new OpenCodeAgentClient(logger, runtimeSettings?.opencode); return { @@ -55,6 +61,21 @@ export function buildProviderRegistry( new CodexAppServerAgentClient(logger, runtimeSettings?.codex), fetchModels: (options) => codexClient.listModels(options), }, + gemini: { + ...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "gemini")!, + createClient: () => new GeminiAgentClient(runtimeSettings?.gemini), + fetchModels: (options) => geminiClient.listModels(options), + }, + amp: { + ...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "amp")!, + createClient: () => new AmpAgentClient(runtimeSettings?.amp), + fetchModels: (options) => ampClient.listModels(options), + }, + aider: { + ...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "aider")!, + createClient: () => new AiderAgentClient(runtimeSettings?.aider), + fetchModels: (options) => aiderClient.listModels(options), + }, opencode: { ...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "opencode")!, createClient: (logger: Logger) => new OpenCodeAgentClient(logger, runtimeSettings?.opencode), @@ -74,6 +95,9 @@ export function createAllClients( return { claude: registry.claude.createClient(logger), codex: registry.codex.createClient(logger), + gemini: registry.gemini.createClient(logger), + amp: registry.amp.createClient(logger), + aider: registry.aider.createClient(logger), opencode: registry.opencode.createClient(logger), }; } diff --git a/packages/server/src/server/agent/providers/aider-agent.ts b/packages/server/src/server/agent/providers/aider-agent.ts new file mode 100644 index 000000000..9a9c54fe1 --- /dev/null +++ b/packages/server/src/server/agent/providers/aider-agent.ts @@ -0,0 +1,110 @@ +import { existsSync } from "node:fs"; + +import type { + AgentCapabilityFlags, + AgentClient, + AgentLaunchContext, + AgentModelDefinition, + AgentPersistenceHandle, + AgentSession, + AgentSessionConfig, + ListModelsOptions, + TerminalCommand, +} from "../agent-sdk-types.js"; +import { + applyProviderEnv, + findExecutable, + isProviderCommandAvailable, + resolveProviderCommandPrefix, + sanitizeTerminalEnv, + type ProviderRuntimeSettings, +} from "../provider-launch-config.js"; + +const AIDER_PROVIDER = "aider" as const; + +const AIDER_CAPABILITIES: AgentCapabilityFlags = { + supportsStreaming: false, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + supportsTerminalMode: true, +}; + +type AiderAgentConfig = AgentSessionConfig & { provider: "aider" }; + +function resolveAiderBinary(): string { + const found = findExecutable("aider"); + if (found) { + return found; + } + throw new Error( + "Aider binary not found. Install Aider and ensure 'aider' is available in your shell PATH.", + ); +} + +function createUnsupportedSessionError(): Error { + return new Error("Aider currently supports terminal mode only in Paseo."); +} + +export class AiderAgentClient implements AgentClient { + readonly provider = AIDER_PROVIDER; + readonly capabilities = AIDER_CAPABILITIES; + + constructor(private readonly runtimeSettings?: ProviderRuntimeSettings) {} + + async createSession( + _config: AgentSessionConfig, + _launchContext?: AgentLaunchContext, + ): Promise { + throw createUnsupportedSessionError(); + } + + async resumeSession( + _handle: AgentPersistenceHandle, + _overrides?: Partial, + _launchContext?: AgentLaunchContext, + ): Promise { + throw createUnsupportedSessionError(); + } + + async listModels(_options?: ListModelsOptions): Promise { + return []; + } + + buildTerminalCreateCommand( + config: AgentSessionConfig, + _handle: AgentPersistenceHandle, + _initialPrompt?: string, + ): TerminalCommand { + this.assertConfig(config); + const launchPrefix = resolveProviderCommandPrefix( + this.runtimeSettings?.command, + resolveAiderBinary, + ); + const terminalEnv = sanitizeTerminalEnv( + applyProviderEnv(process.env as Record, this.runtimeSettings), + ); + return { + command: launchPrefix.command, + // Aider uses positional arguments for file paths, not interactive prompts. + args: [...launchPrefix.args, "--no-auto-commits"], + env: terminalEnv, + }; + } + + async isAvailable(): Promise { + if (this.runtimeSettings?.command?.mode === "replace") { + return existsSync(this.runtimeSettings.command.argv[0]); + } + return isProviderCommandAvailable(this.runtimeSettings?.command, resolveAiderBinary); + } + + private assertConfig(config: AgentSessionConfig): AiderAgentConfig { + if (config.provider !== AIDER_PROVIDER) { + throw new Error(`AiderAgentClient received config for provider '${config.provider}'`); + } + return { ...config, provider: AIDER_PROVIDER }; + } +} diff --git a/packages/server/src/server/agent/providers/amp-agent.ts b/packages/server/src/server/agent/providers/amp-agent.ts new file mode 100644 index 000000000..9318e8401 --- /dev/null +++ b/packages/server/src/server/agent/providers/amp-agent.ts @@ -0,0 +1,109 @@ +import { existsSync } from "node:fs"; + +import type { + AgentCapabilityFlags, + AgentClient, + AgentLaunchContext, + AgentModelDefinition, + AgentPersistenceHandle, + AgentSession, + AgentSessionConfig, + ListModelsOptions, + TerminalCommand, +} from "../agent-sdk-types.js"; +import { + applyProviderEnv, + findExecutable, + isProviderCommandAvailable, + resolveProviderCommandPrefix, + sanitizeTerminalEnv, + type ProviderRuntimeSettings, +} from "../provider-launch-config.js"; + +const AMP_PROVIDER = "amp" as const; + +const AMP_CAPABILITIES: AgentCapabilityFlags = { + supportsStreaming: false, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + supportsTerminalMode: true, +}; + +type AmpAgentConfig = AgentSessionConfig & { provider: "amp" }; + +function resolveAmpBinary(): string { + const found = findExecutable("amp"); + if (found) { + return found; + } + throw new Error( + "AMP binary not found. Install AMP and ensure 'amp' is available in your shell PATH.", + ); +} + +function createUnsupportedSessionError(): Error { + return new Error("AMP currently supports terminal mode only in Paseo."); +} + +export class AmpAgentClient implements AgentClient { + readonly provider = AMP_PROVIDER; + readonly capabilities = AMP_CAPABILITIES; + + constructor(private readonly runtimeSettings?: ProviderRuntimeSettings) {} + + async createSession( + _config: AgentSessionConfig, + _launchContext?: AgentLaunchContext, + ): Promise { + throw createUnsupportedSessionError(); + } + + async resumeSession( + _handle: AgentPersistenceHandle, + _overrides?: Partial, + _launchContext?: AgentLaunchContext, + ): Promise { + throw createUnsupportedSessionError(); + } + + async listModels(_options?: ListModelsOptions): Promise { + return []; + } + + buildTerminalCreateCommand( + config: AgentSessionConfig, + _handle: AgentPersistenceHandle, + _initialPrompt?: string, + ): TerminalCommand { + this.assertConfig(config); + const launchPrefix = resolveProviderCommandPrefix( + this.runtimeSettings?.command, + resolveAmpBinary, + ); + const terminalEnv = sanitizeTerminalEnv( + applyProviderEnv(process.env as Record, this.runtimeSettings), + ); + return { + command: launchPrefix.command, + args: [...launchPrefix.args], + env: terminalEnv, + }; + } + + async isAvailable(): Promise { + if (this.runtimeSettings?.command?.mode === "replace") { + return existsSync(this.runtimeSettings.command.argv[0]); + } + return isProviderCommandAvailable(this.runtimeSettings?.command, resolveAmpBinary); + } + + private assertConfig(config: AgentSessionConfig): AmpAgentConfig { + if (config.provider !== AMP_PROVIDER) { + throw new Error(`AmpAgentClient received config for provider '${config.provider}'`); + } + return { ...config, provider: AMP_PROVIDER }; + } +} diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index f4363b62b..450ec4b96 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, vi } from "vitest"; -import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk"; +import type { ModelInfo, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js"; @@ -357,4 +357,52 @@ describe("ClaudeAgentClient.listModels", () => { ]); expect(queryMock.return).toHaveBeenCalledTimes(1); }); + + test("keeps the Claude control-plane query open until supportedModels resolves", async () => { + const queryMock = createSupportedModelsQueryMock([ + { + value: "default", + displayName: "Default (recommended)", + description: "Sonnet 4.6 · Best for everyday tasks", + }, + ] satisfies ModelInfo[]); + let promptIterator: AsyncIterator | null = null; + let promptNextPromise: Promise> | null = null; + let promptClosedBeforeModelsResolved = false; + + queryMock.supportedModels = vi.fn(async () => { + promptNextPromise = promptIterator?.next() ?? null; + if (!promptNextPromise) { + throw new Error("Prompt iterator not captured"); + } + promptNextPromise.then(() => { + promptClosedBeforeModelsResolved = true; + }); + await Promise.resolve(); + expect(promptClosedBeforeModelsResolved).toBe(false); + return [ + { + value: "default", + displayName: "Default (recommended)", + description: "Sonnet 4.6 · Best for everyday tasks", + }, + ] satisfies ModelInfo[]; + }); + + const queryFactory = vi.fn(({ prompt }) => { + promptIterator = prompt[Symbol.asyncIterator](); + return queryMock; + }); + const client = new ClaudeAgentClient({ + logger, + queryFactory: queryFactory as never, + }); + + const models = await client.listModels({ cwd: process.cwd() }); + + expect(models).toHaveLength(1); + expect(promptNextPromise).not.toBeNull(); + await expect(promptNextPromise).resolves.toEqual({ done: true, value: undefined }); + expect(queryMock.return).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 3cfb20299..3a437ee88 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -66,10 +66,12 @@ import type { ListPersistedAgentsOptions, McpServerConfig, PersistedAgentDescriptor, + TerminalCommand, } from "../agent-sdk-types.js"; import { applyProviderEnv, findExecutable, + sanitizeTerminalEnv, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js"; @@ -102,6 +104,7 @@ const CLAUDE_CAPABILITIES: AgentCapabilityFlags = { supportsMcpServers: true, supportsReasoningStream: true, supportsToolInvocations: true, + supportsTerminalMode: true, }; const DEFAULT_MODES: AgentMode[] = [ @@ -233,10 +236,6 @@ function applyRuntimeSettingsToClaudeOptions( }; } -function createEmptyClaudePrompt(): AsyncGenerator { - return (async function* empty() {})(); -} - function isClaudeThinkingEffort(value: string | null | undefined): value is ClaudeThinkingEffort { return value === "low" || value === "medium" || value === "high" || value === "max"; } @@ -1045,8 +1044,9 @@ export class ClaudeAgentClient implements AgentClient { } async listModels(options?: ListModelsOptions): Promise { + const input = createAsyncMessageInput(); const claudeQuery = this.queryFactory({ - prompt: createEmptyClaudePrompt(), + prompt: input.iterable, options: applyRuntimeSettingsToClaudeOptions( { cwd: options?.cwd ?? process.cwd(), @@ -1065,13 +1065,13 @@ export class ClaudeAgentClient implements AgentClient { this.logger.warn({ err: error }, "Failed to query Claude supportedModels()"); throw error; } finally { + input.end(); try { await claudeQuery.return?.(); } catch { // ignore control-plane shutdown errors } } - } async listPersistedAgents( @@ -1099,6 +1099,73 @@ export class ClaudeAgentClient implements AgentClient { return descriptors; } + buildTerminalCreateCommand( + config: AgentSessionConfig, + handle: AgentPersistenceHandle, + initialPrompt?: string, + ): TerminalCommand { + const claudeConfig = this.assertConfig(config); + const baseCommand = findExecutable("claude") ?? "claude"; + const terminalEnv = sanitizeTerminalEnv( + applyProviderEnv(process.env as Record, this.runtimeSettings), + ); + const spawnCommand = resolveClaudeSpawnCommand( + { + command: baseCommand, + args: [], + cwd: claudeConfig.cwd, + env: terminalEnv, + signal: new AbortController().signal, + }, + this.runtimeSettings, + ); + const args = [...spawnCommand.args, "--session-id", handle.sessionId]; + if (claudeConfig.modeId === "bypassPermissions") { + args.push("--dangerously-skip-permissions"); + } else if (claudeConfig.modeId) { + args.push("--permission-mode", claudeConfig.modeId); + } + if (claudeConfig.model) { + args.push("--model", claudeConfig.model); + } + if (claudeConfig.thinkingOptionId && claudeConfig.thinkingOptionId !== "default") { + args.push("--effort", claudeConfig.thinkingOptionId); + } + if (claudeConfig.systemPrompt?.trim()) { + args.push("--append-system-prompt", claudeConfig.systemPrompt.trim()); + } + if (initialPrompt?.trim()) { + args.push(initialPrompt.trim()); + } + return { + command: spawnCommand.command, + args, + env: terminalEnv, + }; + } + + buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand { + const baseCommand = findExecutable("claude") ?? "claude"; + const terminalEnv = sanitizeTerminalEnv( + applyProviderEnv(process.env as Record, this.runtimeSettings), + ); + const spawnCommand = resolveClaudeSpawnCommand( + { + command: baseCommand, + args: [], + cwd: process.cwd(), + env: terminalEnv, + signal: new AbortController().signal, + }, + this.runtimeSettings, + ); + return { + command: spawnCommand.command, + args: [...spawnCommand.args, "--resume", handle.sessionId], + env: terminalEnv, + }; + } + async isAvailable(): Promise { const command = this.runtimeSettings?.command; if (command?.mode === "replace") { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index ccd83cf8c..1a96033d0 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -19,9 +19,11 @@ import type { AgentTimelineItem, ToolCallTimelineItem, AgentUsage, + AgentPersistenceHandle, ListModelsOptions, ListPersistedAgentsOptions, PersistedAgentDescriptor, + TerminalCommand, } from "../agent-sdk-types.js"; import type { Logger } from "pino"; @@ -43,6 +45,7 @@ import { applyProviderEnv, findExecutable, resolveProviderCommandPrefix, + sanitizeTerminalEnv, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; @@ -59,6 +62,7 @@ const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = { supportsMcpServers: true, supportsReasoningStream: true, supportsToolInvocations: true, + supportsTerminalMode: true, }; const CODEX_MODES: AgentMode[] = [ @@ -3532,6 +3536,59 @@ export class CodexAppServerAgentClient implements AgentClient { } } + buildTerminalCreateCommand( + config: AgentSessionConfig, + handle: AgentPersistenceHandle, + initialPrompt?: string, + ): TerminalCommand { + const launchPrefix = resolveCodexLaunchPrefix(this.runtimeSettings); + const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER }; + const modeId = sessionConfig.modeId ?? DEFAULT_CODEX_MODE_ID; + validateCodexMode(modeId); + const preset = MODE_PRESETS[modeId] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID]; + const approvalPolicy = sessionConfig.approvalPolicy ?? preset.approvalPolicy; + const sandbox = sessionConfig.sandboxMode ?? preset.sandbox; + const args = [...launchPrefix.args, "-c", `sessionId=\"${handle.sessionId}\"`]; + if (sessionConfig.model) { + args.push("--model", sessionConfig.model); + } + args.push("--ask-for-approval", approvalPolicy, "--sandbox", sandbox); + if ( + typeof sessionConfig.networkAccess === "boolean" + ? sessionConfig.networkAccess + : preset.networkAccess === true + ) { + args.push("--search"); + } + if (initialPrompt?.trim()) { + args.push(initialPrompt.trim()); + } + const terminalEnv = sanitizeTerminalEnv( + applyProviderEnv(process.env as Record, this.runtimeSettings), + ); + return { + command: launchPrefix.command, + args, + env: terminalEnv, + }; + } + + buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand { + const launchPrefix = resolveCodexLaunchPrefix(this.runtimeSettings); + const terminalEnv = sanitizeTerminalEnv( + applyProviderEnv(process.env as Record, this.runtimeSettings), + ); + return { + command: launchPrefix.command, + args: [ + ...launchPrefix.args, + "resume", + handle.nativeHandle ?? handle.sessionId, + ], + env: terminalEnv, + }; + } + async listModels(_options?: ListModelsOptions): Promise { const child = this.spawnAppServer(); const client = new CodexAppServerClient(child, this.logger); diff --git a/packages/server/src/server/agent/providers/gemini-agent.ts b/packages/server/src/server/agent/providers/gemini-agent.ts new file mode 100644 index 000000000..72a44a7b3 --- /dev/null +++ b/packages/server/src/server/agent/providers/gemini-agent.ts @@ -0,0 +1,128 @@ +import { existsSync } from "node:fs"; + +import type { + AgentCapabilityFlags, + AgentClient, + AgentLaunchContext, + AgentModelDefinition, + AgentPersistenceHandle, + AgentSession, + AgentSessionConfig, + ListModelsOptions, + TerminalCommand, +} from "../agent-sdk-types.js"; +import { + applyProviderEnv, + findExecutable, + isProviderCommandAvailable, + resolveProviderCommandPrefix, + sanitizeTerminalEnv, + type ProviderRuntimeSettings, +} from "../provider-launch-config.js"; + +const GEMINI_PROVIDER = "gemini" as const; + +const GEMINI_CAPABILITIES: AgentCapabilityFlags = { + supportsStreaming: false, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + supportsTerminalMode: true, +}; + +type GeminiAgentConfig = AgentSessionConfig & { provider: "gemini" }; + +function resolveGeminiBinary(): string { + const found = findExecutable("gemini"); + if (found) { + return found; + } + throw new Error( + "Gemini CLI binary not found. Install Gemini CLI and ensure 'gemini' is available in your shell PATH.", + ); +} + +function createUnsupportedSessionError(): Error { + return new Error("Gemini CLI currently supports terminal mode only in Paseo."); +} + +export class GeminiAgentClient implements AgentClient { + readonly provider = GEMINI_PROVIDER; + readonly capabilities = GEMINI_CAPABILITIES; + + constructor(private readonly runtimeSettings?: ProviderRuntimeSettings) {} + + async createSession( + _config: AgentSessionConfig, + _launchContext?: AgentLaunchContext, + ): Promise { + throw createUnsupportedSessionError(); + } + + async resumeSession( + _handle: AgentPersistenceHandle, + _overrides?: Partial, + _launchContext?: AgentLaunchContext, + ): Promise { + throw createUnsupportedSessionError(); + } + + async listModels(_options?: ListModelsOptions): Promise { + return []; + } + + buildTerminalCreateCommand( + config: AgentSessionConfig, + _handle: AgentPersistenceHandle, + initialPrompt?: string, + ): TerminalCommand { + this.assertConfig(config); + const launchPrefix = resolveProviderCommandPrefix( + this.runtimeSettings?.command, + resolveGeminiBinary, + ); + const terminalEnv = sanitizeTerminalEnv( + applyProviderEnv(process.env as Record, this.runtimeSettings), + ); + const args = [...launchPrefix.args]; + if (initialPrompt?.trim()) { + args.push("-i", initialPrompt.trim()); + } + return { + command: launchPrefix.command, + args, + env: terminalEnv, + }; + } + + buildTerminalResumeCommand(_handle: AgentPersistenceHandle): TerminalCommand { + const launchPrefix = resolveProviderCommandPrefix( + this.runtimeSettings?.command, + resolveGeminiBinary, + ); + const terminalEnv = sanitizeTerminalEnv( + applyProviderEnv(process.env as Record, this.runtimeSettings), + ); + return { + command: launchPrefix.command, + args: [...launchPrefix.args, "--resume"], + env: terminalEnv, + }; + } + + async isAvailable(): Promise { + if (this.runtimeSettings?.command?.mode === "replace") { + return existsSync(this.runtimeSettings.command.argv[0]); + } + return isProviderCommandAvailable(this.runtimeSettings?.command, resolveGeminiBinary); + } + + private assertConfig(config: AgentSessionConfig): GeminiAgentConfig { + if (config.provider !== GEMINI_PROVIDER) { + throw new Error(`GeminiAgentClient received config for provider '${config.provider}'`); + } + return { ...config, provider: GEMINI_PROVIDER }; + } +} diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index a129675d4..3407fe7ae 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -28,11 +28,13 @@ import type { ListPersistedAgentsOptions, McpServerConfig, PersistedAgentDescriptor, + TerminalCommand, } from "../agent-sdk-types.js"; import { applyProviderEnv, findExecutable, resolveProviderCommandPrefix, + sanitizeTerminalEnv, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js"; @@ -44,6 +46,7 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = { supportsMcpServers: true, supportsReasoningStream: true, supportsToolInvocations: true, + supportsTerminalMode: true, }; const DEFAULT_MODES: AgentMode[] = [ @@ -559,6 +562,53 @@ export class OpenCodeAgentClient implements AgentClient { return []; } + buildTerminalCreateCommand( + config: AgentSessionConfig, + handle: AgentPersistenceHandle, + initialPrompt?: string, + ): TerminalCommand { + const launchPrefix = resolveProviderCommandPrefix( + this.runtimeSettings?.command, + resolveOpenCodeBinary, + ); + const terminalEnv = sanitizeTerminalEnv( + applyProviderEnv(process.env as Record, this.runtimeSettings), + ); + const args = [...launchPrefix.args, "--session", handle.nativeHandle ?? handle.sessionId]; + if (config.cwd) { + args.push(config.cwd); + } + if (config.model) { + args.push("--model", config.model); + } + if (config.modeId) { + args.push("--agent", config.modeId); + } + if (initialPrompt?.trim()) { + args.push(initialPrompt.trim()); + } + return { + command: launchPrefix.command, + args, + env: terminalEnv, + }; + } + + buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand { + const launchPrefix = resolveProviderCommandPrefix( + this.runtimeSettings?.command, + resolveOpenCodeBinary, + ); + const terminalEnv = sanitizeTerminalEnv( + applyProviderEnv(process.env as Record, this.runtimeSettings), + ); + return { + command: launchPrefix.command, + args: [...launchPrefix.args, "--session", handle.nativeHandle ?? handle.sessionId], + env: terminalEnv, + }; + } + async isAvailable(): Promise { const command = this.runtimeSettings?.command; if (command?.mode === "replace") { diff --git a/packages/server/src/server/agent/providers/terminal-only-providers.test.ts b/packages/server/src/server/agent/providers/terminal-only-providers.test.ts new file mode 100644 index 000000000..a14703664 --- /dev/null +++ b/packages/server/src/server/agent/providers/terminal-only-providers.test.ts @@ -0,0 +1,103 @@ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import type { AgentSessionConfig } from "../agent-sdk-types.js"; +import { AiderAgentClient } from "./aider-agent.js"; +import { AmpAgentClient } from "./amp-agent.js"; +import { GeminiAgentClient } from "./gemini-agent.js"; + +function createExecutable(): string { + const dir = mkdtempSync(path.join(os.tmpdir(), "terminal-provider-test-")); + const file = path.join(dir, "provider-bin"); + writeFileSync(file, "#!/bin/sh\nexit 0\n"); + chmodSync(file, 0o755); + return file; +} + +const buildConfig = (provider: "gemini" | "amp" | "aider"): AgentSessionConfig => ({ + provider, + cwd: "/tmp/worktree", + terminal: true, +}); + +describe("terminal-only providers", () => { + test("Gemini builds an interactive prompt command without injecting cwd flags", () => { + const executable = createExecutable(); + try { + const client = new GeminiAgentClient({ + command: { mode: "replace", argv: [executable] }, + }); + + const command = client.buildTerminalCreateCommand( + buildConfig("gemini"), + { provider: "gemini", sessionId: "session-1" }, + "Fix the bug", + ); + + expect(command.command).toBe(executable); + expect(command.args).toEqual(["-i", "Fix the bug"]); + } finally { + rmSync(path.dirname(executable), { recursive: true, force: true }); + } + }); + + test("AMP launches without unsupported cwd flags", () => { + const executable = createExecutable(); + try { + const client = new AmpAgentClient({ + command: { mode: "replace", argv: [executable] }, + }); + + const command = client.buildTerminalCreateCommand(buildConfig("amp"), { + provider: "amp", + sessionId: "session-1", + }); + + expect(command.command).toBe(executable); + expect(command.args).toEqual([]); + } finally { + rmSync(path.dirname(executable), { recursive: true, force: true }); + } + }); + + test("Aider does not treat initial prompts as positional CLI arguments", () => { + const executable = createExecutable(); + try { + const client = new AiderAgentClient({ + command: { mode: "replace", argv: [executable] }, + }); + + const command = client.buildTerminalCreateCommand( + buildConfig("aider"), + { provider: "aider", sessionId: "session-1" }, + "Refactor the parser", + ); + + expect(command.command).toBe(executable); + expect(command.args).toEqual(["--no-auto-commits"]); + } finally { + rmSync(path.dirname(executable), { recursive: true, force: true }); + } + }); + + test("provider availability respects missing replacement binaries", async () => { + const missingPath = path.join(os.tmpdir(), "missing-terminal-provider"); + + await expect( + new GeminiAgentClient({ + command: { mode: "replace", argv: [missingPath] }, + }).isAvailable(), + ).resolves.toBe(false); + await expect( + new AmpAgentClient({ + command: { mode: "replace", argv: [missingPath] }, + }).isAvailable(), + ).resolves.toBe(false); + await expect( + new AiderAgentClient({ + command: { mode: "replace", argv: [missingPath] }, + }).isAvailable(), + ).resolves.toBe(false); + }); +}); diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index ee2b4e520..39a9dc3e0 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -1,5 +1,6 @@ import os from "node:os"; import path from "node:path"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { Writable } from "node:stream"; import pino from "pino"; @@ -8,6 +9,8 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js"; import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; import { createTestAgentClients } from "./test-utils/fake-agent-client.js"; +import { openPaseoDatabase } from "./db/sqlite-database.js"; +import { agentSnapshots, projects, workspaces } from "./db/schema.js"; describe("paseo daemon bootstrap", () => { afterEach(() => { @@ -199,4 +202,395 @@ describe("paseo daemon bootstrap", () => { await rm(staticDir, { recursive: true, force: true }); } }); + + test("imports legacy project and workspace JSON into the DB on first bootstrap", async () => { + const { config, cleanup } = await createBootstrapConfig(); + writeLegacyProjectWorkspaceJson(config.paseoHome, { + projects: [ + { + projectId: "project-1", + rootPath: "/tmp/project-1", + kind: "git", + displayName: "Project One", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ], + workspaces: [ + { + workspaceId: "workspace-1", + projectId: "project-1", + cwd: "/tmp/project-1", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ], + }); + + const daemon = await createPaseoDaemon(config, pino({ level: "silent" })); + + try { + await daemon.start(); + await daemon.stop(); + expect(existsSync(path.join(config.paseoHome, "db", "paseo.sqlite"))).toBe(true); + const database = await openPaseoDatabase(path.join(config.paseoHome, "db")); + try { + const projectRows = await database.db.select().from(projects); + expect(projectRows).toHaveLength(1); + expect(projectRows[0]).toMatchObject({ + directory: "/tmp/project-1", + kind: "git", + displayName: "Project One", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }); + const workspaceRows = await database.db.select().from(workspaces); + expect(workspaceRows).toHaveLength(1); + expect(workspaceRows[0]).toMatchObject({ + projectId: projectRows[0]!.id, + directory: "/tmp/project-1", + kind: "checkout", + displayName: "main", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }); + } finally { + await database.close(); + } + } finally { + await cleanup(); + } + }); + + test("does not duplicate imported legacy JSON across daemon restarts", async () => { + const { config, cleanup } = await createBootstrapConfig(); + writeLegacyProjectWorkspaceJson(config.paseoHome, { + projects: [ + { + projectId: "project-1", + rootPath: "/tmp/project-1", + kind: "git", + displayName: "Project One", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ], + workspaces: [ + { + workspaceId: "workspace-1", + projectId: "project-1", + cwd: "/tmp/project-1", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ], + }); + + try { + const firstDaemon = await createPaseoDaemon(config, pino({ level: "silent" })); + await firstDaemon.start(); + await firstDaemon.stop(); + + const secondDaemon = await createPaseoDaemon(config, pino({ level: "silent" })); + await secondDaemon.start(); + await secondDaemon.stop(); + + expect(existsSync(path.join(config.paseoHome, "db", "paseo.sqlite"))).toBe(true); + const database = await openPaseoDatabase(path.join(config.paseoHome, "db")); + try { + expect(await database.db.select().from(projects)).toHaveLength(1); + expect(await database.db.select().from(workspaces)).toHaveLength(1); + } finally { + await database.close(); + } + } finally { + await cleanup(); + } + }); + + test("imports legacy project, workspace, and agent JSON into one SQLite bootstrap without duplicating records", async () => { + const { config, cleanup } = await createBootstrapConfig(); + writeLegacyProjectWorkspaceJson(config.paseoHome, { + projects: [ + { + projectId: "project-1", + rootPath: "/tmp/project-1", + kind: "git", + displayName: "Project One", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ], + workspaces: [ + { + workspaceId: "workspace-1", + projectId: "project-1", + cwd: "/tmp/project-1", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ], + }); + writeLegacyAgentJson(config.paseoHome, "agents/agent-1.json", { + id: "agent-1", + provider: "codex", + cwd: "/tmp/project-1", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + lastActivityAt: "2026-03-02T00:00:00.000Z", + lastUserMessageAt: null, + title: "Imported Agent", + labels: {}, + lastStatus: "idle", + lastModeId: "plan", + config: { model: "gpt-5.1-codex-mini", modeId: "plan" }, + runtimeInfo: { + provider: "codex", + sessionId: "session-123", + model: "gpt-5.1-codex-mini", + modeId: "plan", + }, + persistence: null, + attentionReason: null, + attentionTimestamp: null, + archivedAt: null, + }); + + try { + const daemon = await createPaseoDaemon(config, pino({ level: "silent" })); + await daemon.start(); + await daemon.stop(); + + expect(existsSync(path.join(config.paseoHome, "db", "paseo.sqlite"))).toBe(true); + const database = await openPaseoDatabase(path.join(config.paseoHome, "db")); + try { + const projectRows = await database.db.select().from(projects); + const workspaceRows = await database.db.select().from(workspaces); + const agentRows = await database.db.select().from(agentSnapshots); + + expect(projectRows).toHaveLength(1); + expect(workspaceRows).toHaveLength(1); + expect(agentRows).toEqual([ + expect.objectContaining({ + agentId: "agent-1", + cwd: "/tmp/project-1", + workspaceId: workspaceRows[0]!.id, + title: "Imported Agent", + requiresAttention: false, + internal: false, + }), + ]); + } finally { + await database.close(); + } + } finally { + await cleanup(); + } + }); + + test("imports large legacy agent JSON batches during SQLite bootstrap", async () => { + const { config, cleanup } = await createBootstrapConfig(); + writeLegacyProjectWorkspaceJson(config.paseoHome, { + projects: [ + { + projectId: "project-1", + rootPath: "/tmp/project-1", + kind: "git", + displayName: "Project One", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ], + workspaces: [ + { + workspaceId: "workspace-1", + projectId: "project-1", + cwd: "/tmp/project-1", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ], + }); + + for (let index = 0; index < 150; index += 1) { + writeLegacyAgentJson(config.paseoHome, `agents/project-1/agent-${index}.json`, { + id: `agent-${index}`, + provider: "codex", + cwd: "/tmp/project-1", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + lastActivityAt: "2026-03-02T00:00:00.000Z", + lastUserMessageAt: null, + title: `Imported Agent ${index}`, + labels: {}, + lastStatus: "idle", + lastModeId: "plan", + config: { model: "gpt-5.1-codex-mini", modeId: "plan" }, + runtimeInfo: { + provider: "codex", + sessionId: `session-${index}`, + model: "gpt-5.1-codex-mini", + modeId: "plan", + }, + persistence: null, + attentionReason: null, + attentionTimestamp: null, + archivedAt: null, + }); + } + + try { + const daemon = await createPaseoDaemon(config, pino({ level: "silent" })); + await daemon.start(); + await daemon.stop(); + + expect(existsSync(path.join(config.paseoHome, "db", "paseo.sqlite"))).toBe(true); + const database = await openPaseoDatabase(path.join(config.paseoHome, "db")); + try { + const projectRows = await database.db.select().from(projects); + const workspaceRows = await database.db.select().from(workspaces); + const agentRows = await database.db.select().from(agentSnapshots); + + expect(projectRows).toHaveLength(1); + expect(workspaceRows).toHaveLength(1); + expect(agentRows).toHaveLength(150); + expect(agentRows[0]?.workspaceId).toBe(workspaceRows[0]!.id); + expect(agentRows.map((row) => row.agentId)).toContain("agent-149"); + } finally { + await database.close(); + } + } finally { + await cleanup(); + } + }); + + test("reconciles workspace records into the DB without recreating legacy JSON registry files", async () => { + const { config, cleanup } = await createBootstrapConfig(); + const agentStorageDir = path.join(config.paseoHome, "agents"); + mkdirSync(agentStorageDir, { recursive: true }); + const storageBucket = path.join(agentStorageDir, "tmp-db-only-project"); + mkdirSync(storageBucket, { recursive: true }); + writeFileSync( + path.join(storageBucket, "agent-1.json"), + JSON.stringify( + { + id: "agent-1", + provider: "codex", + cwd: "/tmp/db-only-project", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + lastActivityAt: "2026-03-02T00:00:00.000Z", + lastUserMessageAt: null, + title: null, + labels: {}, + lastStatus: "idle", + lastModeId: null, + config: null, + runtimeInfo: { provider: "codex", sessionId: null }, + persistence: null, + archivedAt: null, + }, + null, + 2, + ), + "utf8", + ); + + try { + const daemon = await createPaseoDaemon(config, pino({ level: "silent" })); + await daemon.start(); + await daemon.stop(); + + expect(existsSync(path.join(config.paseoHome, "db", "paseo.sqlite"))).toBe(true); + const database = await openPaseoDatabase(path.join(config.paseoHome, "db")); + try { + expect(await database.db.select().from(agentSnapshots)).toEqual([ + expect.objectContaining({ + agentId: "agent-1", + cwd: "/tmp/db-only-project", + requiresAttention: false, + internal: false, + }), + ]); + expect(await database.db.select().from(projects)).toHaveLength(1); + expect(await database.db.select().from(workspaces)).toHaveLength(1); + } finally { + await database.close(); + } + + expect(existsSync(path.join(config.paseoHome, "projects", "projects.json"))).toBe(false); + expect(existsSync(path.join(config.paseoHome, "projects", "workspaces.json"))).toBe(false); + } finally { + await cleanup(); + } + }); }); + +async function createBootstrapConfig(): Promise<{ + config: PaseoDaemonConfig; + cleanup: () => Promise; +}> { + const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-db-")); + const paseoHome = path.join(paseoHomeRoot, ".paseo"); + const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); + await mkdir(paseoHome, { recursive: true }); + + return { + config: { + listen: "127.0.0.1:0", + paseoHome, + corsAllowedOrigins: [], + allowedHosts: true, + mcpEnabled: false, + staticDir, + mcpDebug: false, + agentClients: createTestAgentClients(), + agentStoragePath: path.join(paseoHome, "agents"), + relayEnabled: false, + appBaseUrl: "https://app.paseo.sh", + openai: undefined, + speech: undefined, + }, + cleanup: async () => { + await rm(paseoHomeRoot, { recursive: true, force: true }); + await rm(staticDir, { recursive: true, force: true }); + }, + }; +} + +function writeLegacyProjectWorkspaceJson( + paseoHome: string, + input: { + projects: unknown[]; + workspaces: unknown[]; + }, +): void { + const projectsDir = path.join(paseoHome, "projects"); + mkdirSync(projectsDir, { recursive: true }); + writeFileSync(path.join(projectsDir, "projects.json"), JSON.stringify(input.projects, null, 2), "utf8"); + writeFileSync(path.join(projectsDir, "workspaces.json"), JSON.stringify(input.workspaces, null, 2), "utf8"); +} + +function writeLegacyAgentJson(paseoHome: string, relativePath: string, payload: Record): void { + const absolutePath = path.join(paseoHome, relativePath); + mkdirSync(path.dirname(absolutePath), { recursive: true }); + writeFileSync(absolutePath, JSON.stringify(payload, null, 2), "utf8"); +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index e73fe74a6..2ef594562 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -93,12 +93,17 @@ import type { LocalSpeechProviderConfig } from "./speech/providers/local/config. import type { RequestedSpeechProviders } from "./speech/speech-types.js"; import { createSpeechService } from "./speech/speech-runtime.js"; import { AgentManager } from "./agent/agent-manager.js"; -import { AgentStorage } from "./agent/agent-storage.js"; -import { attachAgentStoragePersistence } from "./persistence-hooks.js"; +import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js"; import { createAgentMcpServer } from "./agent/mcp-server.js"; import { createAllClients, shutdownProviders } from "./agent/provider-registry.js"; -import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"; -import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js"; +import { DbAgentSnapshotStore } from "./db/db-agent-snapshot-store.js"; +import { DbAgentTimelineStore } from "./db/db-agent-timeline-store.js"; +import { DbProjectRegistry } from "./db/db-project-registry.js"; +import { DbWorkspaceRegistry } from "./db/db-workspace-registry.js"; +import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js"; +import { importLegacyAgentSnapshots } from "./db/legacy-agent-snapshot-import.js"; +import { importLegacyProjectWorkspaceJson } from "./db/legacy-project-workspace-import.js"; +import { openPaseoDatabase, type PaseoDatabaseHandle } from "./db/sqlite-database.js"; import { FileBackedChatService } from "./chat/chat-service.js"; import { CheckoutDiffManager } from "./checkout-diff-manager.js"; import { LoopService } from "./loop-service.js"; @@ -187,7 +192,7 @@ export type PaseoDaemonConfig = { export interface PaseoDaemon { config: PaseoDaemonConfig; agentManager: AgentManager; - agentStorage: AgentStorage; + agentStorage: AgentSnapshotStore; terminalManager: TerminalManager; start(): Promise; stop(): Promise; @@ -202,6 +207,7 @@ export async function createPaseoDaemon( const bootstrapStart = performance.now(); const elapsed = () => `${(performance.now() - bootstrapStart).toFixed(0)}ms`; const daemonVersion = resolveDaemonVersion(import.meta.url); + let database: PaseoDatabaseHandle | null = null; try { const serverId = getOrCreateServerId(config.paseoHome, { logger }); @@ -352,20 +358,33 @@ export async function createPaseoDaemon( const httpServer = createHTTPServer(app); - const agentStorage = new AgentStorage(config.agentStoragePath, logger); - const projectRegistry = new FileBackedProjectRegistry( - path.join(config.paseoHome, "projects", "projects.json"), - logger, - ); - const workspaceRegistry = new FileBackedWorkspaceRegistry( - path.join(config.paseoHome, "projects", "workspaces.json"), - logger, - ); + database = await openPaseoDatabase(path.join(config.paseoHome, "db")); + logger.info({ elapsed: elapsed() }, "Paseo database opened"); + + const agentStorage = new DbAgentSnapshotStore(database.db); const chatService = new FileBackedChatService({ paseoHome: config.paseoHome, logger, }); - const agentManager = new AgentManager({ + const durableTimelineStore = new DbAgentTimelineStore(database.db); + let agentManager: AgentManager | null = null; + const terminalManager = createTerminalManager({ + resolveAgentIdForTerminal: (terminalId) => agentManager?.getAgentIdForTerminal(terminalId) ?? null, + onAgentBoundTerminalTitleChange: async ({ agentId, title }) => { + if (!agentManager) { + return; + } + try { + await agentManager.setTitle(agentId, title); + } catch (error) { + logger.warn( + { err: error, agentId }, + "Failed to propagate bound terminal title to agent state", + ); + } + }, + }); + agentManager = new AgentManager({ clients: { ...createAllClients(logger, { runtimeSettings: config.agentProviderSettings, @@ -373,26 +392,34 @@ export async function createPaseoDaemon( ...config.agentClients, }, registry: agentStorage, + durableTimelineStore, + terminalManager, logger, }); - const terminalManager = createTerminalManager(); + const projectRegistry = new DbProjectRegistry(database.db); + const workspaceRegistry = new DbWorkspaceRegistry(database.db); - const detachAgentStoragePersistence = attachAgentStoragePersistence( - logger, - agentManager, - agentStorage, - ); - await agentStorage.initialize(); - logger.info({ elapsed: elapsed() }, "Agent storage initialized"); - await bootstrapWorkspaceRegistries({ - paseoHome: config.paseoHome, - agentStorage, + const reconciliationService = new WorkspaceReconciliationService({ projectRegistry, workspaceRegistry, logger, }); - logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped"); + reconciliationService.start(); + logger.info({ elapsed: elapsed() }, "Workspace reconciliation service started"); + + await importLegacyProjectWorkspaceJson({ + db: database.db, + paseoHome: config.paseoHome, + logger, + }); + logger.info({ elapsed: elapsed() }, "Legacy project/workspace import checked"); + await importLegacyAgentSnapshots({ + db: database.db, + paseoHome: config.paseoHome, + logger, + }); + logger.info({ elapsed: elapsed() }, "Legacy agent snapshot import checked"); await chatService.initialize(); logger.info({ elapsed: elapsed() }, "Chat service initialized"); const checkoutDiffManager = new CheckoutDiffManager({ @@ -732,10 +759,9 @@ export async function createPaseoDaemon( }; const stop = async () => { + reconciliationService.stop(); await closeAllAgents(logger, agentManager); await agentManager.flush().catch(() => undefined); - detachAgentStoragePersistence(); - await agentStorage.flush().catch(() => undefined); await shutdownProviders(logger, { runtimeSettings: config.agentProviderSettings, }); @@ -749,6 +775,7 @@ export async function createPaseoDaemon( if (voiceMcpBridgeManager) { await voiceMcpBridgeManager.stop().catch(() => undefined); } + await database?.close().catch(() => undefined); await new Promise((resolve) => { httpServer.close(() => resolve()); }); @@ -768,6 +795,7 @@ export async function createPaseoDaemon( getListenTarget: () => boundListenTarget, }; } catch (err) { + await database?.close().catch(() => undefined); throw err; } } diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index a83c41cb3..ec5500091 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -224,6 +224,8 @@ describe("daemon client E2E", () => { expect(archivedResult).not.toBeNull(); expect(archivedResult?.agent.archivedAt).toBeTruthy(); expect(archivedResult?.agent.status).not.toBe("running"); + expect(archivedResult?.agent.requiresAttention).toBe(false); + expect(archivedResult?.agent.attentionReason).toBeNull(); expect(archivedResult?.project).not.toBeNull(); expect(archivedResult?.project?.checkout.cwd).toBe(cwd); @@ -309,6 +311,42 @@ describe("daemon client E2E", () => { } }, 180000); + test("update_agent persists unloaded title and labels across auto-unarchive", async () => { + const cwd = tmpCwd(); + try { + const created = await ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd, + }, + }); + + await ctx.client.archiveAgent(created.id); + await ctx.client.updateAgent(created.id, { + name: "Pinned Title", + labels: { lane: "phase-1a" }, + }); + + const archived = await ctx.client.fetchAgent(created.id); + expect(archived).not.toBeNull(); + expect(archived?.agent.archivedAt).toBeTruthy(); + expect(archived?.agent.title).toBe("Pinned Title"); + expect(archived?.agent.labels).toMatchObject({ lane: "phase-1a" }); + + await ctx.client.sendMessage(created.id, "Say hello and nothing else"); + const finalState = await ctx.client.waitForFinish(created.id, 120000); + expect(finalState.status).toBe("idle"); + + const unarchived = await ctx.client.fetchAgent(created.id); + expect(unarchived).not.toBeNull(); + expect(unarchived?.agent.archivedAt).toBeNull(); + expect(unarchived?.agent.title).toBe("Pinned Title"); + expect(unarchived?.agent.labels).toMatchObject({ lane: "phase-1a" }); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, 180000); + test("returns home-scoped directory suggestions", async () => { const insideHomeDir = mkdtempSync(path.join(homedir(), "paseo-dir-suggestion-")); const outsideHomeDir = mkdtempSync(path.join(tmpdir(), "paseo-dir-suggestion-outside-")); @@ -529,7 +567,6 @@ describe("daemon client E2E", () => { const timelineResult = await ctx.client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 1, - projection: "projected", }); expect(timelineResult.agentId).toBe(agent.id); @@ -756,7 +793,6 @@ describe("daemon client E2E", () => { const timeline = await ctx.client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0, - projection: "projected", }); expect(timeline.entries.length).toBeGreaterThan(0); diff --git a/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts index 442d6ae03..3d2f5f2b9 100644 --- a/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts @@ -71,7 +71,6 @@ describe("daemon E2E", () => { await ctx.client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 200, - projection: "projected", }); const refreshedResult = await ctx.client.fetchAgent(agent.id); @@ -89,7 +88,6 @@ describe("daemon E2E", () => { await ctx.client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 200, - projection: "projected", }); const clearResult = await ctx.client.fetchAgent(agent.id); diff --git a/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts index 6dd2fe622..6ab400712 100644 --- a/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts @@ -60,7 +60,6 @@ describe("daemon E2E (real claude) - autonomous wake simple", () => { const timelineAtIdle = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0, - projection: "canonical", }); const idleAssistantText = timelineAtIdle.entries .filter( @@ -91,7 +90,6 @@ describe("daemon E2E (real claude) - autonomous wake simple", () => { const finalTimeline = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0, - projection: "canonical", }); const finalAssistantText = finalTimeline.entries .filter( diff --git a/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts index be0657fc5..c49219006 100644 --- a/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts @@ -390,7 +390,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = const timelineAtIdle = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0, - projection: "canonical", }); await client.waitForAgentUpsert( @@ -405,7 +404,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = const timelineAfterWake = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0, - projection: "canonical", }); expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual( timelineAtIdle.entries.length, @@ -417,7 +415,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = const nextTimeline = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0, - projection: "canonical", }); sawTimelineGrowth = nextTimeline.entries.length > timelineAtIdle.entries.length; } @@ -605,7 +602,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = const timelineBeforeWake = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0, - projection: "canonical", }); const summarized = timelineBeforeWake.entries.map(summarizeTimelineEntry); // Required by reproduction request: log timeline at idle edge before autonomous wake. @@ -735,7 +731,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = const timelineAtWake = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0, - projection: "canonical", }); // eslint-disable-next-line no-console @@ -751,7 +746,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = const timelineAfterWake = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0, - projection: "canonical", }); expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual( timelineAtWake.entries.length, @@ -916,7 +910,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = const timelineAtWake = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0, - projection: "canonical", }); // eslint-disable-next-line no-console @@ -932,7 +925,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = const timelineAfterWake = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0, - projection: "canonical", }); expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual( timelineAtWake.entries.length, @@ -1066,7 +1058,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = const timeline = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0, - projection: "canonical", }); assistantTexts = timeline.entries .filter( diff --git a/packages/server/src/server/daemon-e2e/persistence.e2e.test.ts b/packages/server/src/server/daemon-e2e/persistence.e2e.test.ts index 40f36fcd7..980b90a8d 100644 --- a/packages/server/src/server/daemon-e2e/persistence.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/persistence.e2e.test.ts @@ -114,11 +114,14 @@ describe("daemon E2E - persistence", () => { const timeline = await ctx.client.fetchAgentTimeline(agentId, { direction: "tail", limit: 0, - projection: "canonical", }); const timelineItems = timeline.entries.map((entry) => entry.item); expect(timelineItems.length).toBeGreaterThan(0); - expect(timelineItems.some((item) => item.type === "assistant_message")).toBe(true); + const assistantMessages = timelineItems.filter( + (item): item is Extract<(typeof timelineItems)[number], { type: "assistant_message" }> => + item.type === "assistant_message", + ); + expect(assistantMessages).toEqual([{ type: "assistant_message", text: "timeline test" }]); } finally { await ctx.cleanup(); cleaned = true; diff --git a/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts index 7c5627db1..1aeed1d4f 100644 --- a/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts @@ -48,7 +48,6 @@ describe("daemon E2E (real claude) - rewind user message dedupe", () => { const timeline = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0, - projection: "canonical", }); const rewindUserMessages = timeline.entries.filter( diff --git a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts index 0f66a29c8..0ba3e9e5b 100644 --- a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts @@ -462,6 +462,41 @@ describe("daemon E2E terminal", () => { rmSync(cwd, { recursive: true, force: true }); }, 30000); + test("propagates debounced terminal titles through list responses and snapshots", async () => { + const cwd = tmpCwd(); + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; + + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "printf '\\033]0;Build Output\\007'\r", + }); + + let listedTitle: string | undefined; + const start = Date.now(); + while (Date.now() - start < 10000) { + const list = await ctx.client.listTerminals(cwd); + listedTitle = list.terminals.find((terminal) => terminal.id === terminalId)?.title; + if (listedTitle === "Build Output") { + break; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + expect(listedTitle).toBe("Build Output"); + + const snapshotPromise = waitForTerminalSnapshot( + ctx.client, + terminalId, + (state) => state.title === "Build Output", + ); + await ctx.client.subscribeTerminal(terminalId); + const snapshot = await snapshotPromise; + + expect(snapshot.title).toBe("Build Output"); + + rmSync(cwd, { recursive: true, force: true }); + }, 30000); + test("subscribe response is sent before the initial snapshot frame", async () => { const cwd = tmpCwd(); const created = await ctx.client.createTerminal(cwd); diff --git a/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts b/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts new file mode 100644 index 000000000..26cbf55e2 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { createDaemonTestContext, type DaemonTestContext, DaemonClient } from "../test-utils/index.js"; +import { createMessageCollector } from "../test-utils/message-collector.js"; +import type { SessionOutboundMessage } from "../messages.js"; + +function tmpCwd(): string { + return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); +} + +async function waitFor( + predicate: () => boolean, + timeoutMs = 5_000, + intervalMs = 10, +): Promise { + const startedAt = Date.now(); + while (!predicate()) { + if (Date.now() - startedAt > timeoutMs) { + throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`); + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } +} + +function isSeqLessAssistantTimeline( + message: SessionOutboundMessage, + agentId: string, + text?: string, +): boolean { + return ( + message.type === "agent_stream" && + message.payload.agentId === agentId && + message.payload.event.type === "timeline" && + message.payload.event.item.type === "assistant_message" && + message.payload.seq === undefined && + (text === undefined || message.payload.event.item.text === text) + ); +} + +describe("daemon E2E - timeline reconnect contract", () => { + let ctx: DaemonTestContext; + + beforeEach(async () => { + ctx = await createDaemonTestContext(); + }); + + afterEach(async () => { + await ctx.cleanup(); + }, 60_000); + + test("reconnect catches up committed rows without replaying a provisional seed", async () => { + const cwd = tmpCwd(); + const primaryCollector = createMessageCollector(ctx.client); + + try { + const agent = await ctx.client.createAgent({ + provider: "codex", + cwd, + title: "Reconnect Contract Test", + modeId: "full-access", + }); + + for (let seq = 1; seq <= 120; seq += 1) { + await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "assistant_message", + text: `committed row ${seq}`, + }); + } + + primaryCollector.clear(); + await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, { + type: "assistant_message", + text: "partial before disconnect", + }); + await waitFor(() => + primaryCollector.messages.some((message) => + isSeqLessAssistantTimeline(message, agent.id, "partial before disconnect"), + ), + ); + + await ctx.client.close(); + + await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "assistant_message", + text: "finalized while disconnected", + }); + + const reconnectClient = new DaemonClient({ + url: `ws://127.0.0.1:${ctx.daemon.port}/ws`, + }); + await reconnectClient.connect(); + const reconnectCollector = createMessageCollector(reconnectClient); + + try { + await reconnectClient.fetchAgents({ + subscribe: { subscriptionId: "timeline-reconnect-a" }, + }); + + expect( + reconnectCollector.messages.some((message) => + isSeqLessAssistantTimeline(message, agent.id), + ), + ).toBe(false); + + const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, { + direction: "after", + cursor: { seq: 120 }, + limit: 0, + }); + + expect(catchUp.entries).toHaveLength(1); + expect(catchUp.entries[0]?.seq).toBe(121); + expect(catchUp.entries[0]?.item).toEqual({ + type: "assistant_message", + text: "finalized while disconnected", + }); + } finally { + reconnectCollector.unsubscribe(); + await reconnectClient.close(); + } + } finally { + primaryCollector.unsubscribe(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 30_000); + + test("reconnect with no new committed rows resumes from future live provisional updates only", async () => { + const cwd = tmpCwd(); + const primaryCollector = createMessageCollector(ctx.client); + + try { + const agent = await ctx.client.createAgent({ + provider: "codex", + cwd, + title: "Reconnect No Seed Test", + modeId: "full-access", + }); + + for (let seq = 1; seq <= 120; seq += 1) { + await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "assistant_message", + text: `committed row ${seq}`, + }); + } + + primaryCollector.clear(); + await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, { + type: "assistant_message", + text: "partial before disconnect", + }); + await waitFor(() => + primaryCollector.messages.some((message) => + isSeqLessAssistantTimeline(message, agent.id, "partial before disconnect"), + ), + ); + + await ctx.client.close(); + + const reconnectClient = new DaemonClient({ + url: `ws://127.0.0.1:${ctx.daemon.port}/ws`, + }); + await reconnectClient.connect(); + const reconnectCollector = createMessageCollector(reconnectClient); + + try { + await reconnectClient.fetchAgents({ + subscribe: { subscriptionId: "timeline-reconnect-b" }, + }); + + expect( + reconnectCollector.messages.some((message) => + isSeqLessAssistantTimeline(message, agent.id), + ), + ).toBe(false); + + const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, { + direction: "after", + cursor: { seq: 120 }, + limit: 0, + }); + + expect(catchUp.entries).toHaveLength(0); + + reconnectCollector.clear(); + await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, { + type: "assistant_message", + text: "fresh live after reconnect", + }); + await waitFor(() => + reconnectCollector.messages.some((message) => + isSeqLessAssistantTimeline(message, agent.id, "fresh live after reconnect"), + ), + ); + } finally { + reconnectCollector.unsubscribe(); + await reconnectClient.close(); + } + } finally { + primaryCollector.unsubscribe(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 30_000); +}); diff --git a/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts b/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts index af2f55799..da0079d66 100644 --- a/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts @@ -20,7 +20,7 @@ describe("daemon E2E - timeline window", () => { await ctx.cleanup(); }, 60_000); - test("canonical tail limit keeps assistant chunks intact at the window boundary", async () => { + test("canonical tail limit returns one finalized committed assistant row at the window boundary", async () => { const cwd = tmpCwd(); try { const agent = await ctx.client.createAgent({ @@ -38,16 +38,14 @@ describe("daemon E2E - timeline window", () => { const timeline = await ctx.client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 1, - projection: "canonical", }); const assistantTexts = timeline.entries .filter((entry) => entry.item.type === "assistant_message") .map((entry) => entry.item.text); - expect(assistantTexts).toHaveLength(2); - expect(assistantTexts.join("")).toBe(expected); - expect(timeline.startCursor?.seq).toBeLessThan(timeline.endCursor?.seq ?? 0); + expect(assistantTexts).toEqual([expected]); + expect(timeline.startSeq).toBe(timeline.endSeq); } finally { rmSync(cwd, { recursive: true, force: true }); } @@ -73,7 +71,6 @@ describe("daemon E2E - timeline window", () => { const timeline = await ctx.client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 1, - projection: "canonical", }); const assistantTexts = timeline.entries @@ -82,7 +79,7 @@ describe("daemon E2E - timeline window", () => { expect(assistantTexts.join("")).toBe(expected); expect(timeline.hasOlder).toBe(true); - expect(timeline.startCursor?.seq).toBeGreaterThan(1); + expect(timeline.startSeq).toBeGreaterThan(1); } finally { rmSync(cwd, { recursive: true, force: true }); } diff --git a/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts index f07aa0900..24aebe765 100644 --- a/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts @@ -289,7 +289,6 @@ async function resolveLatestAssistantMessage( const timeline = await client.fetchAgentTimeline(agentId, { direction: "tail", limit: 300, - projection: "canonical", }); for (let idx = timeline.entries.length - 1; idx >= 0; idx -= 1) { const entry = timeline.entries[idx]; diff --git a/packages/server/src/server/db/db-agent-snapshot-store.test.ts b/packages/server/src/server/db/db-agent-snapshot-store.test.ts new file mode 100644 index 000000000..4d31866b7 --- /dev/null +++ b/packages/server/src/server/db/db-agent-snapshot-store.test.ts @@ -0,0 +1,276 @@ +import os from "node:os"; +import path from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import type { ManagedAgent } from "../agent/agent-manager.js"; +import type { + AgentPermissionRequest, + AgentSession, + AgentSessionConfig, +} from "../agent/agent-sdk-types.js"; +import type { StoredAgentRecord } from "../agent/agent-storage.js"; +import { openPaseoDatabase, type PaseoDatabaseHandle } from "./sqlite-database.js"; +import { DbAgentSnapshotStore } from "./db-agent-snapshot-store.js"; +import { agentSnapshots, projects, workspaces } from "./schema.js"; + +type ManagedAgentOverrides = Omit< + Partial, + "config" | "pendingPermissions" | "session" | "activeForegroundTurnId" +> & { + config?: Partial; + pendingPermissions?: Map; + session?: AgentSession | null; + activeForegroundTurnId?: string | null; + runtimeInfo?: ManagedAgent["runtimeInfo"]; + attention?: ManagedAgent["attention"]; +}; + +function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent { + const now = overrides.updatedAt ?? new Date("2026-03-01T00:00:00.000Z"); + const provider = overrides.provider ?? "codex"; + const cwd = overrides.cwd ?? "/tmp/project"; + const lifecycle = overrides.lifecycle ?? "idle"; + const configOverrides = overrides.config ?? {}; + const config: AgentSessionConfig = { + provider, + cwd, + title: configOverrides.title, + modeId: configOverrides.modeId ?? "plan", + model: configOverrides.model ?? "gpt-5.1-codex-mini", + extra: configOverrides.extra ?? { codex: { approvalPolicy: "on-request" } }, + systemPrompt: configOverrides.systemPrompt, + mcpServers: configOverrides.mcpServers, + }; + const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession)); + const activeForegroundTurnId = + overrides.activeForegroundTurnId ?? (lifecycle === "running" ? "turn-1" : null); + + return { + id: overrides.id ?? "agent-1", + provider, + cwd, + session, + capabilities: overrides.capabilities ?? { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + config, + lifecycle, + createdAt: overrides.createdAt ?? now, + updatedAt: overrides.updatedAt ?? now, + availableModes: overrides.availableModes ?? [], + currentModeId: overrides.currentModeId ?? config.modeId ?? null, + pendingPermissions: overrides.pendingPermissions ?? new Map(), + activeForegroundTurnId, + foregroundTurnWaiters: new Set(), + unsubscribeSession: null, + timeline: overrides.timeline ?? [], + attention: overrides.attention ?? { requiresAttention: false }, + runtimeInfo: overrides.runtimeInfo ?? { + provider, + sessionId: overrides.sessionId ?? "session-123", + model: config.model ?? null, + modeId: config.modeId ?? null, + }, + persistence: overrides.persistence ?? null, + historyPrimed: overrides.historyPrimed ?? true, + lastUserMessageAt: overrides.lastUserMessageAt ?? now, + lastUsage: overrides.lastUsage, + lastError: overrides.lastError, + internal: overrides.internal, + labels: overrides.labels ?? {}, + pendingReplacement: false, + provisionalAssistantText: null, + }; +} + +function createStoredAgentRecord(overrides: Partial = {}): StoredAgentRecord { + return { + id: "agent-1", + provider: "codex", + cwd: "/tmp/project", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + lastActivityAt: "2026-03-01T00:00:00.000Z", + lastUserMessageAt: null, + title: null, + labels: {}, + lastStatus: "idle", + lastModeId: "plan", + config: { + modeId: "plan", + model: "gpt-5.1-codex-mini", + }, + runtimeInfo: { + provider: "codex", + sessionId: "session-123", + model: "gpt-5.1-codex-mini", + modeId: "plan", + }, + persistence: { + provider: "codex", + sessionId: "session-123", + }, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + internal: false, + archivedAt: null, + ...overrides, + }; +} + +describe("DbAgentSnapshotStore", () => { + let tmpDir: string; + let dataDir: string; + let database: PaseoDatabaseHandle; + let store: DbAgentSnapshotStore; + + beforeEach(async () => { + tmpDir = mkdtempSync(path.join(os.tmpdir(), "db-agent-snapshot-store-")); + dataDir = path.join(tmpDir, "db"); + database = await openPaseoDatabase(dataDir); + store = new DbAgentSnapshotStore(database.db); + }); + + afterEach(async () => { + await database.close(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("supports list/get/upsert/remove CRUD lifecycle", async () => { + const workspaceId = await seedWorkspace(database, { directory: "/tmp/project" }); + + const record = createStoredAgentRecord(); + + expect(await store.list()).toEqual([]); + expect(await store.get(record.id)).toBeNull(); + + await store.upsert(record, workspaceId); + + expect(await store.get(record.id)).toEqual(record); + expect(await store.list()).toEqual([record]); + expect(await database.db.select().from(agentSnapshots)).toEqual([ + expect.objectContaining({ + agentId: "agent-1", + workspaceId, + requiresAttention: false, + internal: false, + }), + ]); + + await store.remove(record.id); + + expect(await store.get(record.id)).toBeNull(); + expect(await store.list()).toEqual([]); + }); + + test("applySnapshot preserves title, createdAt, and archivedAt across updates", async () => { + const workspaceId = await seedWorkspace(database, { directory: "/tmp/project" }); + + await store.upsert( + createStoredAgentRecord({ + id: "agent-apply", + title: "Pinned title", + createdAt: "2026-03-01T00:00:00.000Z", + archivedAt: "2026-03-05T00:00:00.000Z", + }), + workspaceId, + ); + + await store.applySnapshot( + createManagedAgent({ + id: "agent-apply", + createdAt: new Date("2026-03-10T00:00:00.000Z"), + updatedAt: new Date("2026-03-11T00:00:00.000Z"), + lifecycle: "running", + }), + workspaceId, + ); + + expect(await store.get("agent-apply")).toEqual( + expect.objectContaining({ + id: "agent-apply", + title: "Pinned title", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-11T00:00:00.000Z", + archivedAt: "2026-03-05T00:00:00.000Z", + lastStatus: "running", + }), + ); + }); + + test("setTitle throws for missing agents and updates existing agents", async () => { + const workspaceId = await seedWorkspace(database, { directory: "/tmp/project" }); + + await expect(store.setTitle("missing-agent", "Missing")).rejects.toThrow( + "Agent missing-agent not found", + ); + + await store.upsert(createStoredAgentRecord({ id: "agent-title", title: null }), workspaceId); + await store.setTitle("agent-title", "Renamed agent"); + + expect(await store.get("agent-title")).toEqual( + expect.objectContaining({ + id: "agent-title", + title: "Renamed agent", + }), + ); + }); + + test("upsert is idempotent for the same agent ID", async () => { + const workspaceId = await seedWorkspace(database, { directory: "/tmp/project" }); + + await store.upsert(createStoredAgentRecord({ id: "agent-idempotent", title: "Initial" }), workspaceId); + await store.upsert( + createStoredAgentRecord({ + id: "agent-idempotent", + title: "Updated", + updatedAt: "2026-03-02T00:00:00.000Z", + lastStatus: "running", + }), + workspaceId, + ); + + expect(await store.list()).toEqual([ + createStoredAgentRecord({ + id: "agent-idempotent", + title: "Updated", + updatedAt: "2026-03-02T00:00:00.000Z", + lastStatus: "running", + }), + ]); + expect(await database.db.select().from(agentSnapshots)).toHaveLength(1); + }); +}); + +async function seedWorkspace( + database: PaseoDatabaseHandle, + options: { directory: string }, +): Promise { + const [project] = await database.db.insert(projects).values({ + directory: options.directory, + kind: "git", + displayName: "project-1", + gitRemote: null, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + }).returning(); + const [workspace] = await database.db.insert(workspaces).values({ + projectId: project.id, + directory: options.directory, + kind: "checkout", + displayName: "main", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + }).returning(); + return workspace.id; +} diff --git a/packages/server/src/server/db/db-agent-snapshot-store.ts b/packages/server/src/server/db/db-agent-snapshot-store.ts new file mode 100644 index 000000000..a89f7ec11 --- /dev/null +++ b/packages/server/src/server/db/db-agent-snapshot-store.ts @@ -0,0 +1,204 @@ +import { asc, eq } from "drizzle-orm"; + +import type { ManagedAgent } from "../agent/agent-manager.js"; +import type { AgentSnapshotStore } from "../agent/agent-snapshot-store.js"; +import { toStoredAgentRecord } from "../agent/agent-projections.js"; +import type { StoredAgentRecord } from "../agent/agent-storage.js"; +import type { PaseoDatabaseHandle } from "./sqlite-database.js"; +import { agentSnapshots } from "./schema.js"; + +type AgentSnapshotRow = typeof agentSnapshots.$inferSelect; +type AgentSnapshotInsert = typeof agentSnapshots.$inferInsert; + +export function toStoredAgentRecordFromRow(row: AgentSnapshotRow): StoredAgentRecord { + return { + id: row.agentId, + provider: row.provider, + cwd: row.cwd, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + lastActivityAt: row.lastActivityAt ?? undefined, + lastUserMessageAt: row.lastUserMessageAt ?? null, + title: row.title ?? null, + labels: row.labels, + lastStatus: row.lastStatus as StoredAgentRecord["lastStatus"], + lastModeId: row.lastModeId ?? null, + config: row.config ?? null, + runtimeInfo: row.runtimeInfo ?? undefined, + persistence: row.persistence ?? null, + requiresAttention: row.requiresAttention, + attentionReason: (row.attentionReason ?? null) as StoredAgentRecord["attentionReason"], + attentionTimestamp: row.attentionTimestamp ?? null, + internal: row.internal, + archivedAt: row.archivedAt ?? null, + }; +} + +export function toAgentSnapshotRowValues(options: { + record: StoredAgentRecord; + workspaceId: number; +}): AgentSnapshotInsert { + const { record, workspaceId } = options; + return { + agentId: record.id, + provider: record.provider, + workspaceId, + cwd: record.cwd, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + lastActivityAt: record.lastActivityAt ?? null, + lastUserMessageAt: record.lastUserMessageAt ?? null, + title: record.title ?? null, + labels: record.labels, + lastStatus: record.lastStatus, + lastModeId: record.lastModeId ?? null, + config: record.config ?? null, + runtimeInfo: record.runtimeInfo ?? null, + persistence: record.persistence ?? null, + requiresAttention: record.requiresAttention ?? false, + attentionReason: record.attentionReason ?? null, + attentionTimestamp: record.attentionTimestamp ?? null, + internal: record.internal ?? false, + archivedAt: record.archivedAt ?? null, + }; +} + +function toAgentSnapshotUpdateSet(values: AgentSnapshotInsert) { + return { + provider: values.provider, + workspaceId: values.workspaceId, + cwd: values.cwd, + createdAt: values.createdAt, + updatedAt: values.updatedAt, + lastActivityAt: values.lastActivityAt, + lastUserMessageAt: values.lastUserMessageAt, + title: values.title, + labels: values.labels, + lastStatus: values.lastStatus, + lastModeId: values.lastModeId, + config: values.config, + runtimeInfo: values.runtimeInfo, + persistence: values.persistence, + requiresAttention: values.requiresAttention, + attentionReason: values.attentionReason, + attentionTimestamp: values.attentionTimestamp, + internal: values.internal, + archivedAt: values.archivedAt, + } satisfies Omit; +} + +export class DbAgentSnapshotStore implements AgentSnapshotStore { + private readonly db: PaseoDatabaseHandle["db"]; + + constructor(db: PaseoDatabaseHandle["db"]) { + this.db = db; + } + + async list(): Promise { + const rows = await this.db + .select() + .from(agentSnapshots) + .orderBy(asc(agentSnapshots.createdAt), asc(agentSnapshots.agentId)); + return rows.map(toStoredAgentRecordFromRow); + } + + async get(agentId: string): Promise { + const rows = await this.db + .select() + .from(agentSnapshots) + .where(eq(agentSnapshots.agentId, agentId)) + .limit(1); + const row = rows[0]; + return row ? toStoredAgentRecordFromRow(row) : null; + } + + async upsert(record: StoredAgentRecord): Promise; + async upsert(record: StoredAgentRecord, workspaceId: number): Promise; + async upsert(record: StoredAgentRecord, workspaceId?: number): Promise { + const nextWorkspaceId = + workspaceId ?? (await this.db + .select({ workspaceId: agentSnapshots.workspaceId }) + .from(agentSnapshots) + .where(eq(agentSnapshots.agentId, record.id)) + .limit(1))[0]?.workspaceId; + if (nextWorkspaceId === undefined) { + throw new Error(`Workspace ID required for agent ${record.id}`); + } + const values = toAgentSnapshotRowValues({ + record, + workspaceId: nextWorkspaceId, + }); + + await this.db + .insert(agentSnapshots) + .values(values) + .onConflictDoUpdate({ + target: agentSnapshots.agentId, + set: toAgentSnapshotUpdateSet(values), + }); + } + + async remove(agentId: string): Promise { + await this.db.delete(agentSnapshots).where(eq(agentSnapshots.agentId, agentId)); + } + + async applySnapshot( + agent: ManagedAgent, + options?: { title?: string | null; internal?: boolean }, + ): Promise; + async applySnapshot( + agent: ManagedAgent, + workspaceId: number, + options?: { title?: string | null; internal?: boolean }, + ): Promise; + async applySnapshot( + agent: ManagedAgent, + workspaceIdOrOptions?: number | { title?: string | null; internal?: boolean }, + options?: { title?: string | null; internal?: boolean }, + ): Promise { + const nextWorkspaceId = + typeof workspaceIdOrOptions === "number" + ? workspaceIdOrOptions + : (await this.db + .select({ workspaceId: agentSnapshots.workspaceId }) + .from(agentSnapshots) + .where(eq(agentSnapshots.agentId, agent.id)) + .limit(1))[0]?.workspaceId; + const nextOptions = + typeof workspaceIdOrOptions === "number" ? options : workspaceIdOrOptions; + const existing = await this.get(agent.id); + const hasTitleOverride = + nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, "title"); + const hasInternalOverride = + nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, "internal"); + const record = toStoredAgentRecord(agent, { + title: hasTitleOverride ? (nextOptions?.title ?? null) : (existing?.title ?? null), + createdAt: existing?.createdAt, + internal: hasInternalOverride + ? nextOptions?.internal + : (agent.internal ?? existing?.internal), + }); + + if (existing && existing.archivedAt !== undefined) { + record.archivedAt = existing.archivedAt; + } + + if (nextWorkspaceId === undefined) { + return; + } + await this.upsert(record, nextWorkspaceId); + } + + async setTitle(agentId: string, title: string): Promise { + const rows = await this.db + .select() + .from(agentSnapshots) + .where(eq(agentSnapshots.agentId, agentId)) + .limit(1); + const row = rows[0]; + if (!row) { + throw new Error(`Agent ${agentId} not found`); + } + await this.upsert({ ...toStoredAgentRecordFromRow(row), title }, row.workspaceId); + } +} diff --git a/packages/server/src/server/db/db-agent-timeline-store.test.ts b/packages/server/src/server/db/db-agent-timeline-store.test.ts new file mode 100644 index 000000000..2bba18087 --- /dev/null +++ b/packages/server/src/server/db/db-agent-timeline-store.test.ts @@ -0,0 +1,266 @@ +import os from "node:os"; +import path from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; +import type { AgentTimelineRow } from "../agent/agent-timeline-store-types.js"; +import { openPaseoDatabase, type PaseoDatabaseHandle } from "./sqlite-database.js"; +import { DbAgentTimelineStore } from "./db-agent-timeline-store.js"; +import { agentTimelineRows } from "./schema.js"; + +function createTimestamp(seq: number): string { + return new Date(Date.UTC(2026, 2, 1, 0, 0, seq)).toISOString(); +} + +function createTimelineItem( + type: Extract, + value: string, +): AgentTimelineItem { + if (type === "user_message") { + return { + type, + text: `user-${value}`, + messageId: `message-${value}`, + }; + } + + return { + type, + text: `assistant-${value}`, + }; +} + +function createRow(seq: number, item?: AgentTimelineItem): AgentTimelineRow { + return { + seq, + timestamp: createTimestamp(seq), + item: item ?? createTimelineItem("assistant_message", String(seq)), + }; +} + +describe("DbAgentTimelineStore", () => { + let tmpDir: string; + let dataDir: string; + let database: PaseoDatabaseHandle; + let store: DbAgentTimelineStore; + + beforeEach(async () => { + tmpDir = mkdtempSync(path.join(os.tmpdir(), "db-agent-timeline-store-")); + dataDir = path.join(tmpDir, "db"); + database = await openPaseoDatabase(dataDir); + store = new DbAgentTimelineStore(database.db); + }); + + afterEach(async () => { + await database.close(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("appendCommitted assigns sequential seq numbers per agent", async () => { + expect( + await store.appendCommitted("agent-1", createTimelineItem("assistant_message", "1")), + ).toEqual({ + seq: 1, + timestamp: expect.any(String), + item: createTimelineItem("assistant_message", "1"), + }); + + expect( + await store.appendCommitted("agent-1", createTimelineItem("assistant_message", "2")), + ).toEqual({ + seq: 2, + timestamp: expect.any(String), + item: createTimelineItem("assistant_message", "2"), + }); + + expect( + await store.appendCommitted("agent-1", createTimelineItem("assistant_message", "3")), + ).toEqual({ + seq: 3, + timestamp: expect.any(String), + item: createTimelineItem("assistant_message", "3"), + }); + }); + + test("appendCommitted for different agents has independent seq sequences", async () => { + const firstAgentFirstRow = await store.appendCommitted( + "agent-1", + createTimelineItem("assistant_message", "a1"), + ); + const secondAgentFirstRow = await store.appendCommitted( + "agent-2", + createTimelineItem("assistant_message", "b1"), + ); + const firstAgentSecondRow = await store.appendCommitted( + "agent-1", + createTimelineItem("assistant_message", "a2"), + ); + + expect(firstAgentFirstRow.seq).toBe(1); + expect(secondAgentFirstRow.seq).toBe(1); + expect(firstAgentSecondRow.seq).toBe(2); + }); + + test("fetchCommitted tail returns the last N rows", async () => { + await store.bulkInsert("agent-1", [1, 2, 3, 4, 5].map((seq) => createRow(seq))); + + await expect( + store.fetchCommitted("agent-1", { + direction: "tail", + limit: 2, + }), + ).resolves.toEqual({ + direction: "tail", + window: { + minSeq: 1, + maxSeq: 5, + nextSeq: 6, + }, + hasOlder: true, + hasNewer: false, + rows: [createRow(4), createRow(5)], + }); + }); + + test("fetchCommitted after-cursor returns rows after a given seq", async () => { + await store.bulkInsert("agent-1", [1, 2, 3, 4, 5].map((seq) => createRow(seq))); + + await expect( + store.fetchCommitted("agent-1", { + direction: "after", + cursor: { seq: 2 }, + limit: 2, + }), + ).resolves.toEqual({ + direction: "after", + window: { + minSeq: 1, + maxSeq: 5, + nextSeq: 6, + }, + hasOlder: true, + hasNewer: true, + rows: [createRow(3), createRow(4)], + }); + }); + + test("fetchCommitted before-cursor returns rows before a given seq", async () => { + await store.bulkInsert("agent-1", [1, 2, 3, 4, 5].map((seq) => createRow(seq))); + + await expect( + store.fetchCommitted("agent-1", { + direction: "before", + cursor: { seq: 4 }, + limit: 2, + }), + ).resolves.toEqual({ + direction: "before", + window: { + minSeq: 1, + maxSeq: 5, + nextSeq: 6, + }, + hasOlder: true, + hasNewer: true, + rows: [createRow(2), createRow(3)], + }); + }); + + test("getLatestCommittedSeq returns 0 for an unknown agent", async () => { + await expect(store.getLatestCommittedSeq("missing-agent")).resolves.toBe(0); + }); + + test("getLatestCommittedSeq returns the latest seq after appends", async () => { + await store.appendCommitted("agent-1", createTimelineItem("assistant_message", "1")); + await store.appendCommitted("agent-1", createTimelineItem("assistant_message", "2")); + + await expect(store.getLatestCommittedSeq("agent-1")).resolves.toBe(2); + }); + + test("deleteAgent removes all rows for the target agent", async () => { + await store.bulkInsert("agent-1", [createRow(1), createRow(2)]); + await store.bulkInsert("agent-2", [createRow(1)]); + + await store.deleteAgent("agent-1"); + + await expect(store.getCommittedRows("agent-1")).resolves.toEqual([]); + await expect(store.getCommittedRows("agent-2")).resolves.toEqual([createRow(1)]); + }); + + test("bulkInsert preserves provided seq numbers", async () => { + const rows = [createRow(3), createRow(7)]; + + await store.bulkInsert("agent-1", rows); + + await expect(store.getCommittedRows("agent-1")).resolves.toEqual(rows); + }); + + test("item_kind is populated from item.type", async () => { + await store.appendCommitted("agent-1", createTimelineItem("user_message", "kind-check"), { + timestamp: createTimestamp(1), + }); + + await expect(database.db.select().from(agentTimelineRows)).resolves.toEqual([ + expect.objectContaining({ + agentId: "agent-1", + seq: 1, + committedAt: createTimestamp(1), + itemKind: "user_message", + }), + ]); + }); + + test("getLastItem returns the latest committed item", async () => { + await store.bulkInsert("agent-1", [ + createRow(1, createTimelineItem("user_message", "1")), + createRow(2, createTimelineItem("assistant_message", "2")), + ]); + + await expect(store.getLastItem("agent-1")).resolves.toEqual( + createTimelineItem("assistant_message", "2"), + ); + await expect(store.getLastItem("missing-agent")).resolves.toBeNull(); + }); + + test("getLastAssistantMessage assembles the latest contiguous assistant chunks", async () => { + await store.bulkInsert("agent-1", [ + createRow(1, createTimelineItem("assistant_message", "1")), + createRow(2, createTimelineItem("assistant_message", "2")), + createRow(3, { type: "reasoning", text: "separator-1" }), + createRow(4, createTimelineItem("assistant_message", "4")), + createRow(5, createTimelineItem("assistant_message", "5")), + createRow(6, { type: "reasoning", text: "separator-2" }), + ]); + + await expect(store.getLastAssistantMessage("agent-1")).resolves.toBe("assistant-4assistant-5"); + await expect(store.getLastAssistantMessage("missing-agent")).resolves.toBeNull(); + }); + + test("hasCommittedUserMessage matches by normalized messageId and text", async () => { + await store.bulkInsert("agent-1", [ + createRow(1, createTimelineItem("user_message", "1")), + createRow(2, createTimelineItem("assistant_message", "2")), + ]); + + await expect( + store.hasCommittedUserMessage("agent-1", { + messageId: " message-1 ", + text: "user-1", + }), + ).resolves.toBe(true); + await expect( + store.hasCommittedUserMessage("agent-1", { + messageId: "message-1", + text: "different", + }), + ).resolves.toBe(false); + await expect( + store.hasCommittedUserMessage("agent-1", { + messageId: " ", + text: "user-1", + }), + ).resolves.toBe(false); + }); +}); diff --git a/packages/server/src/server/db/db-agent-timeline-store.ts b/packages/server/src/server/db/db-agent-timeline-store.ts new file mode 100644 index 000000000..1cda2de34 --- /dev/null +++ b/packages/server/src/server/db/db-agent-timeline-store.ts @@ -0,0 +1,303 @@ +import { and, asc, desc, eq, gt, lt, sql } from "drizzle-orm"; + +import type { + AgentTimelineFetchOptions, + AgentTimelineFetchResult, + AgentTimelineRow, + AgentTimelineStore, + AgentTimelineWindow, +} from "../agent/agent-timeline-store-types.js"; +import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; +import type { PaseoDatabaseHandle } from "./sqlite-database.js"; +import { agentTimelineRows } from "./schema.js"; + +type AgentTimelineRowRecord = typeof agentTimelineRows.$inferSelect; +type AgentTimelineRowInsert = typeof agentTimelineRows.$inferInsert; + +const DEFAULT_TIMELINE_FETCH_LIMIT = 200; + +function normalizeTimelineMessageId(messageId: string | undefined): string | undefined { + if (typeof messageId !== "string") { + return undefined; + } + const normalized = messageId.trim(); + return normalized.length > 0 ? normalized : undefined; +} + +function toTimelineRow(row: AgentTimelineRowRecord): AgentTimelineRow { + return { + seq: row.seq, + timestamp: row.committedAt, + item: row.item, + }; +} + +function toInsertValues(agentId: string, row: AgentTimelineRow): AgentTimelineRowInsert { + return { + agentId, + seq: row.seq, + committedAt: row.timestamp, + item: row.item, + itemKind: row.item.type, + }; +} + +function normalizeFetchLimit(limit: number | undefined): number { + if (limit === undefined) { + return DEFAULT_TIMELINE_FETCH_LIMIT; + } + return Math.max(0, Math.floor(limit)); +} + +export class DbAgentTimelineStore implements AgentTimelineStore { + private readonly db: PaseoDatabaseHandle["db"]; + + constructor(db: PaseoDatabaseHandle["db"]) { + this.db = db; + } + + async appendCommitted( + agentId: string, + item: AgentTimelineItem, + options?: { timestamp?: string }, + ): Promise { + const nextSeq = (await this.getMaxSeq(agentId)) + 1; + const row: AgentTimelineRow = { + seq: nextSeq, + timestamp: options?.timestamp ?? new Date().toISOString(), + item, + }; + + await this.db.insert(agentTimelineRows).values(toInsertValues(agentId, row)); + return row; + } + + async fetchCommitted( + agentId: string, + options?: AgentTimelineFetchOptions, + ): Promise { + const direction = options?.direction ?? "tail"; + const limit = normalizeFetchLimit(options?.limit); + const selectAll = limit === 0; + const window = await this.getWindow(agentId); + + if (window.maxSeq === 0) { + return { + direction, + window, + hasOlder: false, + hasNewer: false, + rows: [], + }; + } + + if (direction === "tail") { + const rows = selectAll + ? await this.db + .select() + .from(agentTimelineRows) + .where(eq(agentTimelineRows.agentId, agentId)) + .orderBy(asc(agentTimelineRows.seq)) + : ( + await this.db + .select() + .from(agentTimelineRows) + .where(eq(agentTimelineRows.agentId, agentId)) + .orderBy(desc(agentTimelineRows.seq)) + .limit(limit) + ).reverse(); + const selected = rows.map(toTimelineRow); + return { + direction, + window, + hasOlder: selected.length > 0 && selected[0]!.seq > window.minSeq, + hasNewer: false, + rows: selected, + }; + } + + if (direction === "after") { + const baseSeq = options?.cursor?.seq ?? 0; + const rows = ( + selectAll + ? await this.db + .select() + .from(agentTimelineRows) + .where(and(eq(agentTimelineRows.agentId, agentId), gt(agentTimelineRows.seq, baseSeq))) + .orderBy(asc(agentTimelineRows.seq)) + : await this.db + .select() + .from(agentTimelineRows) + .where(and(eq(agentTimelineRows.agentId, agentId), gt(agentTimelineRows.seq, baseSeq))) + .orderBy(asc(agentTimelineRows.seq)) + .limit(limit) + ).map(toTimelineRow); + + if (rows.length === 0) { + return { + direction, + window, + hasOlder: baseSeq >= window.minSeq, + hasNewer: false, + rows, + }; + } + + const lastSelected = rows[rows.length - 1]!; + return { + direction, + window, + hasOlder: rows[0]!.seq > window.minSeq, + hasNewer: lastSelected.seq < window.maxSeq, + rows, + }; + } + + const beforeSeq = options?.cursor?.seq ?? window.nextSeq; + const rows = ( + selectAll + ? await this.db + .select() + .from(agentTimelineRows) + .where(and(eq(agentTimelineRows.agentId, agentId), lt(agentTimelineRows.seq, beforeSeq))) + .orderBy(asc(agentTimelineRows.seq)) + : ( + await this.db + .select() + .from(agentTimelineRows) + .where(and(eq(agentTimelineRows.agentId, agentId), lt(agentTimelineRows.seq, beforeSeq))) + .orderBy(desc(agentTimelineRows.seq)) + .limit(limit) + ).reverse() + ).map(toTimelineRow); + + return { + direction, + window, + hasOlder: rows.length > 0 && rows[0]!.seq > window.minSeq, + hasNewer: beforeSeq <= window.maxSeq, + rows, + }; + } + + async getLatestCommittedSeq(agentId: string): Promise { + return this.getMaxSeq(agentId); + } + + async getCommittedRows(agentId: string): Promise { + const rows = await this.db + .select() + .from(agentTimelineRows) + .where(eq(agentTimelineRows.agentId, agentId)) + .orderBy(asc(agentTimelineRows.seq)); + return rows.map(toTimelineRow); + } + + async getLastItem(agentId: string): Promise { + const [row] = await this.db + .select({ item: agentTimelineRows.item }) + .from(agentTimelineRows) + .where(eq(agentTimelineRows.agentId, agentId)) + .orderBy(desc(agentTimelineRows.seq)) + .limit(1); + return row?.item ?? null; + } + + async getLastAssistantMessage(agentId: string): Promise { + const rows = await this.db + .select({ + seq: agentTimelineRows.seq, + item: agentTimelineRows.item, + }) + .from(agentTimelineRows) + .where( + and( + eq(agentTimelineRows.agentId, agentId), + eq(agentTimelineRows.itemKind, "assistant_message"), + ), + ) + .orderBy(desc(agentTimelineRows.seq)); + + if (rows.length === 0) { + return null; + } + + const chunks: string[] = []; + let previousSeq: number | null = null; + for (const row of rows) { + if (previousSeq !== null && row.seq !== previousSeq - 1) { + break; + } + if (row.item.type !== "assistant_message") { + break; + } + chunks.push(row.item.text); + previousSeq = row.seq; + } + + return chunks.length > 0 ? chunks.reverse().join("") : null; + } + + async hasCommittedUserMessage( + agentId: string, + options: { messageId: string; text: string }, + ): Promise { + const messageId = normalizeTimelineMessageId(options.messageId); + if (!messageId) { + return false; + } + + const [row] = await this.db + .select({ seq: agentTimelineRows.seq }) + .from(agentTimelineRows) + .where( + and( + eq(agentTimelineRows.agentId, agentId), + eq(agentTimelineRows.itemKind, "user_message"), + sql`json_extract(${agentTimelineRows.item}, '$.messageId') = ${messageId}`, + sql`json_extract(${agentTimelineRows.item}, '$.text') = ${options.text}`, + ), + ) + .limit(1); + + return row !== undefined; + } + + async deleteAgent(agentId: string): Promise { + await this.db.delete(agentTimelineRows).where(eq(agentTimelineRows.agentId, agentId)); + } + + async bulkInsert(agentId: string, rows: readonly AgentTimelineRow[]): Promise { + if (rows.length === 0) { + return; + } + await this.db.insert(agentTimelineRows).values(rows.map((row) => toInsertValues(agentId, row))); + } + + private async getMaxSeq(agentId: string): Promise { + const [row] = await this.db + .select({ + maxSeq: sql`coalesce(max(${agentTimelineRows.seq}), 0)`, + }) + .from(agentTimelineRows) + .where(eq(agentTimelineRows.agentId, agentId)); + return Number(row?.maxSeq ?? 0); + } + + private async getWindow(agentId: string): Promise { + const [row] = await this.db + .select({ + minSeq: sql`coalesce(min(${agentTimelineRows.seq}), 0)`, + maxSeq: sql`coalesce(max(${agentTimelineRows.seq}), 0)`, + }) + .from(agentTimelineRows) + .where(eq(agentTimelineRows.agentId, agentId)); + const minSeq = Number(row?.minSeq ?? 0); + const maxSeq = Number(row?.maxSeq ?? 0); + return { + minSeq, + maxSeq, + nextSeq: maxSeq + 1, + }; + } +} diff --git a/packages/server/src/server/db/db-project-registry.ts b/packages/server/src/server/db/db-project-registry.ts new file mode 100644 index 000000000..9c623edb7 --- /dev/null +++ b/packages/server/src/server/db/db-project-registry.ts @@ -0,0 +1,81 @@ +import { eq } from "drizzle-orm"; + +import type { ProjectRegistry, PersistedProjectRecord } from "../workspace-registry.js"; +import { createPersistedProjectRecord } from "../workspace-registry.js"; +import { projects } from "./schema.js"; +import type { PaseoDatabaseHandle } from "./sqlite-database.js"; + +function toPersistedProjectRecord(row: typeof projects.$inferSelect): PersistedProjectRecord { + return createPersistedProjectRecord({ + ...row, + kind: row.kind as PersistedProjectRecord["kind"], + }); +} + +export class DbProjectRegistry implements ProjectRegistry { + private readonly db: PaseoDatabaseHandle["db"]; + + constructor(db: PaseoDatabaseHandle["db"]) { + this.db = db; + } + + async initialize(): Promise { + return Promise.resolve(); + } + + async existsOnDisk(): Promise { + return true; + } + + async list(): Promise { + const rows = await this.db.select().from(projects); + return rows.map(toPersistedProjectRecord); + } + + async get(id: number): Promise { + const rows = await this.db.select().from(projects).where(eq(projects.id, id)).limit(1); + const row = rows[0]; + return row ? toPersistedProjectRecord(row) : null; + } + + async insert(record: Omit): Promise { + const [row] = await this.db + .insert(projects) + .values(record) + .returning({ id: projects.id }); + return row!.id; + } + + async upsert(record: PersistedProjectRecord): Promise { + const nextRecord = createPersistedProjectRecord(record); + await this.db + .insert(projects) + .values(nextRecord) + .onConflictDoUpdate({ + target: projects.id, + set: { + directory: nextRecord.directory, + kind: nextRecord.kind, + displayName: nextRecord.displayName, + gitRemote: nextRecord.gitRemote, + createdAt: nextRecord.createdAt, + updatedAt: nextRecord.updatedAt, + archivedAt: nextRecord.archivedAt, + }, + }); + } + + async archive(id: number, archivedAt: string): Promise { + await this.db + .update(projects) + .set({ + updatedAt: archivedAt, + archivedAt, + }) + .where(eq(projects.id, id)); + } + + async remove(id: number): Promise { + await this.db.delete(projects).where(eq(projects.id, id)); + } +} diff --git a/packages/server/src/server/db/db-workspace-registry.test.ts b/packages/server/src/server/db/db-workspace-registry.test.ts new file mode 100644 index 000000000..c90c9d71d --- /dev/null +++ b/packages/server/src/server/db/db-workspace-registry.test.ts @@ -0,0 +1,199 @@ +import os from "node:os"; +import path from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../workspace-registry.js"; +import { createPersistedProjectRecord, createPersistedWorkspaceRecord } from "../workspace-registry.js"; +import { openPaseoDatabase, type PaseoDatabaseHandle } from "./sqlite-database.js"; +import { DbProjectRegistry } from "./db-project-registry.js"; +import { DbWorkspaceRegistry } from "./db-workspace-registry.js"; + +function createProjectRecord(input: Partial = {}): PersistedProjectRecord { + return createPersistedProjectRecord({ + id: 1, + directory: "/tmp/repo", + kind: "git", + displayName: "acme/repo", + gitRemote: "git@github.com:acme/repo.git", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + ...input, + }); +} + +function createWorkspaceRecord(input: Partial = {}): PersistedWorkspaceRecord { + return createPersistedWorkspaceRecord({ + id: 1, + projectId: 1, + directory: "/tmp/repo", + kind: "checkout", + displayName: "main", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + ...input, + }); +} + +describe("DB-backed workspace registries", () => { + let tmpDir: string; + let dataDir: string; + let database: PaseoDatabaseHandle; + let projectRegistry: DbProjectRegistry; + let workspaceRegistry: DbWorkspaceRegistry; + + beforeEach(async () => { + tmpDir = mkdtempSync(path.join(os.tmpdir(), "db-workspace-registry-")); + dataDir = path.join(tmpDir, "db"); + database = await openPaseoDatabase(dataDir); + projectRegistry = new DbProjectRegistry(database.db); + workspaceRegistry = new DbWorkspaceRegistry(database.db); + }); + + afterEach(async () => { + await database.close(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("project registry matches the file-backed behavioral contract", async () => { + await projectRegistry.initialize(); + expect(await projectRegistry.existsOnDisk()).toBe(true); + expect(await projectRegistry.get(999)).toBeNull(); + expect(await projectRegistry.list()).toEqual([]); + + const projectId = await projectRegistry.insert({ + directory: "/tmp/repo", + kind: "git", + displayName: "acme/repo", + gitRemote: "git@github.com:acme/repo.git", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + }); + await projectRegistry.upsert( + createProjectRecord({ + id: projectId, + updatedAt: "2026-03-02T00:00:00.000Z", + }), + ); + await projectRegistry.archive(projectId, "2026-03-03T00:00:00.000Z"); + await projectRegistry.archive(999, "2026-03-04T00:00:00.000Z"); + + expect(await projectRegistry.get(projectId)).toEqual( + createProjectRecord({ + id: projectId, + updatedAt: "2026-03-03T00:00:00.000Z", + archivedAt: "2026-03-03T00:00:00.000Z", + }), + ); + expect(await projectRegistry.list()).toEqual([ + createProjectRecord({ + updatedAt: "2026-03-03T00:00:00.000Z", + archivedAt: "2026-03-03T00:00:00.000Z", + }), + ]); + + await projectRegistry.remove(999); + await projectRegistry.remove(projectId); + + expect(await projectRegistry.get(projectId)).toBeNull(); + expect(await projectRegistry.list()).toEqual([]); + }); + + test("workspace registry matches the file-backed behavioral contract", async () => { + await workspaceRegistry.initialize(); + expect(await workspaceRegistry.existsOnDisk()).toBe(true); + expect(await workspaceRegistry.get(999)).toBeNull(); + expect(await workspaceRegistry.list()).toEqual([]); + + const projectId = await projectRegistry.insert({ + directory: "/tmp/repo", + kind: "git", + displayName: "acme/repo", + gitRemote: "git@github.com:acme/repo.git", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + }); + const workspaceId = await workspaceRegistry.insert({ + projectId, + directory: "/tmp/repo", + kind: "checkout", + displayName: "main", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + }); + await workspaceRegistry.upsert( + createWorkspaceRecord({ + id: workspaceId, + projectId, + displayName: "feature/workspace", + updatedAt: "2026-03-02T00:00:00.000Z", + }), + ); + await workspaceRegistry.archive(workspaceId, "2026-03-03T00:00:00.000Z"); + await workspaceRegistry.archive(999, "2026-03-04T00:00:00.000Z"); + + expect(await workspaceRegistry.get(workspaceId)).toEqual( + createWorkspaceRecord({ + id: workspaceId, + projectId, + displayName: "feature/workspace", + updatedAt: "2026-03-03T00:00:00.000Z", + archivedAt: "2026-03-03T00:00:00.000Z", + }), + ); + expect(await workspaceRegistry.list()).toEqual([ + createWorkspaceRecord({ + displayName: "feature/workspace", + updatedAt: "2026-03-03T00:00:00.000Z", + archivedAt: "2026-03-03T00:00:00.000Z", + }), + ]); + + await workspaceRegistry.remove(999); + await workspaceRegistry.remove(workspaceId); + + expect(await workspaceRegistry.get(workspaceId)).toBeNull(); + expect(await workspaceRegistry.list()).toEqual([]); + }); + + test("rejects workspace upserts for non-existent projects", async () => { + await expect( + workspaceRegistry.upsert( + createWorkspaceRecord({ + projectId: 999, + }), + ), + ).rejects.toThrow(); + }); + + test("cascades workspace removal when removing a linked project", async () => { + const projectId = await projectRegistry.insert({ + directory: "/tmp/repo", + kind: "git", + displayName: "acme/repo", + gitRemote: "git@github.com:acme/repo.git", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + }); + const workspaceId = await workspaceRegistry.insert({ + projectId, + directory: "/tmp/repo", + kind: "checkout", + displayName: "main", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + }); + + await projectRegistry.remove(projectId); + expect(await projectRegistry.get(projectId)).toBeNull(); + expect(await workspaceRegistry.get(workspaceId)).toBeNull(); + }); +}); diff --git a/packages/server/src/server/db/db-workspace-registry.ts b/packages/server/src/server/db/db-workspace-registry.ts new file mode 100644 index 000000000..0ebb9099f --- /dev/null +++ b/packages/server/src/server/db/db-workspace-registry.ts @@ -0,0 +1,85 @@ +import { eq } from "drizzle-orm"; + +import type { PaseoDatabaseHandle } from "./sqlite-database.js"; +import { workspaces } from "./schema.js"; +import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "../workspace-registry.js"; +import { createPersistedWorkspaceRecord } from "../workspace-registry.js"; + +function toPersistedWorkspaceRecord(row: typeof workspaces.$inferSelect): PersistedWorkspaceRecord { + return createPersistedWorkspaceRecord({ + ...row, + kind: row.kind as PersistedWorkspaceRecord["kind"], + }); +} + +export class DbWorkspaceRegistry implements WorkspaceRegistry { + private readonly db: PaseoDatabaseHandle["db"]; + + constructor(db: PaseoDatabaseHandle["db"]) { + this.db = db; + } + + async initialize(): Promise { + return Promise.resolve(); + } + + async existsOnDisk(): Promise { + return true; + } + + async list(): Promise { + const rows = await this.db.select().from(workspaces); + return rows.map(toPersistedWorkspaceRecord); + } + + async get(id: number): Promise { + const rows = await this.db + .select() + .from(workspaces) + .where(eq(workspaces.id, id)) + .limit(1); + const row = rows[0]; + return row ? toPersistedWorkspaceRecord(row) : null; + } + + async insert(record: Omit): Promise { + const [row] = await this.db + .insert(workspaces) + .values(record) + .returning({ id: workspaces.id }); + return row!.id; + } + + async upsert(record: PersistedWorkspaceRecord): Promise { + const nextRecord = createPersistedWorkspaceRecord(record); + await this.db + .insert(workspaces) + .values(nextRecord) + .onConflictDoUpdate({ + target: workspaces.id, + set: { + projectId: nextRecord.projectId, + directory: nextRecord.directory, + kind: nextRecord.kind, + displayName: nextRecord.displayName, + createdAt: nextRecord.createdAt, + updatedAt: nextRecord.updatedAt, + archivedAt: nextRecord.archivedAt, + }, + }); + } + + async archive(workspaceId: number, archivedAt: string): Promise { + await this.db + .update(workspaces) + .set({ + updatedAt: archivedAt, + archivedAt, + }) + .where(eq(workspaces.id, workspaceId)); + } + + async remove(workspaceId: number): Promise { + await this.db.delete(workspaces).where(eq(workspaces.id, workspaceId)); + } +} diff --git a/packages/server/src/server/db/legacy-agent-snapshot-import.test.ts b/packages/server/src/server/db/legacy-agent-snapshot-import.test.ts new file mode 100644 index 000000000..494e96fe3 --- /dev/null +++ b/packages/server/src/server/db/legacy-agent-snapshot-import.test.ts @@ -0,0 +1,239 @@ +import os from "node:os"; +import path from "node:path"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../test-utils/test-logger.js"; +import { openPaseoDatabase, type PaseoDatabaseHandle } from "./sqlite-database.js"; +import { importLegacyAgentSnapshots } from "./legacy-agent-snapshot-import.js"; +import { agentSnapshots, projects, workspaces } from "./schema.js"; + +describe("importLegacyAgentSnapshots", () => { + let tmpDir: string; + let paseoHome: string; + let dbDir: string; + let database: PaseoDatabaseHandle; + + beforeEach(async () => { + tmpDir = mkdtempSync(path.join(os.tmpdir(), "paseo-legacy-agent-import-")); + paseoHome = path.join(tmpDir, ".paseo"); + dbDir = path.join(paseoHome, "db"); + mkdirSync(paseoHome, { recursive: true }); + database = await openPaseoDatabase(dbDir); + }); + + afterEach(async () => { + await database.close(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + async function seedWorkspace(directory: string): Promise { + const [project] = await database.db + .insert(projects) + .values({ + directory, + displayName: path.basename(directory), + kind: "directory", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + }) + .returning({ id: projects.id }); + const [workspace] = await database.db + .insert(workspaces) + .values({ + projectId: project!.id, + directory, + displayName: path.basename(directory), + kind: "checkout", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + }) + .returning({ id: workspaces.id }); + return workspace!.id; + } + + test("imports agent JSON files when the DB is empty", async () => { + await seedWorkspace("/tmp/project"); + writeLegacyAgentJson({ + paseoHome, + relativePath: "agents/agent-1.json", + payload: createLegacyAgentJson({ + requiresAttention: undefined, + internal: undefined, + }), + }); + + const result = await importLegacyAgentSnapshots({ + db: database.db, + paseoHome, + logger: createTestLogger(), + }); + + expect(result).toEqual({ + status: "imported", + importedAgents: 1, + }); + expect(await database.db.select().from(agentSnapshots)).toEqual([ + expect.objectContaining({ + agentId: "agent-1", + cwd: "/tmp/project", + requiresAttention: false, + internal: false, + }), + ]); + }); + + test("skips import when the DB already has agent data", async () => { + const workspaceId = await seedWorkspace("/tmp/existing-project"); + await database.db.insert(agentSnapshots).values({ + agentId: "existing-agent", + provider: "codex", + workspaceId, + cwd: "/tmp/existing-project", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + lastActivityAt: "2026-03-01T00:00:00.000Z", + lastUserMessageAt: null, + title: null, + labels: {}, + lastStatus: "idle", + lastModeId: "plan", + config: null, + runtimeInfo: { provider: "codex", sessionId: "session-existing" }, + persistence: null, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + internal: false, + archivedAt: null, + }); + writeLegacyAgentJson({ + paseoHome, + relativePath: "agents/legacy-agent.json", + payload: createLegacyAgentJson({ id: "legacy-agent" }), + }); + + const result = await importLegacyAgentSnapshots({ + db: database.db, + paseoHome, + logger: createTestLogger(), + }); + + expect(result).toEqual({ + status: "skipped", + reason: "database-not-empty", + }); + expect(await database.db.select().from(agentSnapshots)).toHaveLength(1); + }); + + test("imports agent JSON files from nested project directories", async () => { + await seedWorkspace("/tmp/root-project"); + await seedWorkspace("/tmp/nested-project"); + writeLegacyAgentJson({ + paseoHome, + relativePath: "agents/agent-root.json", + payload: createLegacyAgentJson({ id: "agent-root", cwd: "/tmp/root-project" }), + }); + writeLegacyAgentJson({ + paseoHome, + relativePath: "agents/tmp-nested-project/agent-nested.json", + payload: createLegacyAgentJson({ id: "agent-nested", cwd: "/tmp/nested-project" }), + }); + + const result = await importLegacyAgentSnapshots({ + db: database.db, + paseoHome, + logger: createTestLogger(), + }); + + expect(result).toEqual({ + status: "imported", + importedAgents: 2, + }); + expect( + (await database.db.select().from(agentSnapshots)).map((row) => row.agentId).sort(), + ).toEqual(["agent-nested", "agent-root"]); + }); + + test("batches large legacy agent imports so SQLite variable limits do not abort bootstrap", async () => { + await seedWorkspace("/tmp/large-project"); + + for (let index = 0; index < 150; index += 1) { + writeLegacyAgentJson({ + paseoHome, + relativePath: `agents/large-project/agent-${index}.json`, + payload: createLegacyAgentJson({ + id: `agent-${index}`, + cwd: "/tmp/large-project", + runtimeInfo: { + provider: "codex", + sessionId: `session-${index}`, + model: "gpt-5.1-codex-mini", + modeId: "plan", + }, + }), + }); + } + + const result = await importLegacyAgentSnapshots({ + db: database.db, + paseoHome, + logger: createTestLogger(), + }); + + expect(result).toEqual({ + status: "imported", + importedAgents: 150, + }); + const rows = await database.db.select().from(agentSnapshots); + expect(rows).toHaveLength(150); + expect(rows.map((row) => row.agentId)).toContain("agent-149"); + }); +}); + +function createLegacyAgentJson(overrides: Record = {}): Record { + return { + id: "agent-1", + provider: "codex", + cwd: "/tmp/project", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + lastActivityAt: "2026-03-02T00:00:00.000Z", + lastUserMessageAt: null, + title: null, + labels: {}, + lastStatus: "idle", + lastModeId: "plan", + config: { + model: "gpt-5.1-codex-mini", + modeId: "plan", + }, + runtimeInfo: { + provider: "codex", + sessionId: "session-123", + model: "gpt-5.1-codex-mini", + modeId: "plan", + }, + persistence: null, + attentionReason: null, + attentionTimestamp: null, + archivedAt: null, + ...overrides, + }; +} + +function writeLegacyAgentJson(input: { + paseoHome: string; + relativePath: string; + payload: Record; +}): void { + const absolutePath = path.join(input.paseoHome, input.relativePath); + mkdirSync(path.dirname(absolutePath), { recursive: true }); + writeFileSync(absolutePath, JSON.stringify(input.payload, null, 2), { + encoding: "utf8", + flag: "w", + }); +} diff --git a/packages/server/src/server/db/legacy-agent-snapshot-import.ts b/packages/server/src/server/db/legacy-agent-snapshot-import.ts new file mode 100644 index 000000000..a07a01fa3 --- /dev/null +++ b/packages/server/src/server/db/legacy-agent-snapshot-import.ts @@ -0,0 +1,188 @@ +import path from "node:path"; +import { promises as fs } from "node:fs"; + +import { count } from "drizzle-orm"; +import type { Logger } from "pino"; + +import { parseStoredAgentRecord, type StoredAgentRecord } from "../agent/agent-storage.js"; +import { normalizeWorkspaceId } from "../workspace-registry-model.js"; +import type { PaseoDatabaseHandle } from "./sqlite-database.js"; +import { toAgentSnapshotRowValues } from "./db-agent-snapshot-store.js"; +import { agentSnapshots, projects, workspaces } from "./schema.js"; + +const SQLITE_MAX_VARIABLES_PER_STATEMENT = 999; +const AGENT_SNAPSHOT_INSERT_VARIABLES_PER_ROW = Object.keys(agentSnapshots).length; +const MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT = Math.max( + 1, + Math.floor(SQLITE_MAX_VARIABLES_PER_STATEMENT / AGENT_SNAPSHOT_INSERT_VARIABLES_PER_ROW), +); + +export type LegacyAgentSnapshotImportResult = + | { + status: "imported"; + importedAgents: number; + } + | { + status: "skipped"; + reason: "database-not-empty" | "no-legacy-files"; + }; + +export async function importLegacyAgentSnapshots(options: { + db: PaseoDatabaseHandle["db"]; + paseoHome: string; + logger: Logger; +}): Promise { + if (await hasAnyAgentSnapshotRows(options.db)) { + options.logger.info("Skipping legacy agent snapshot import because the DB is not empty"); + return { + status: "skipped", + reason: "database-not-empty", + }; + } + + const records = await readLegacyAgentRecords(path.join(options.paseoHome, "agents"), options.logger); + if (records.length === 0) { + options.logger.info("Skipping legacy agent snapshot import because no legacy files exist"); + return { + status: "skipped", + reason: "no-legacy-files", + }; + } + + options.db.transaction((tx) => { + const workspaceRows = tx + .select({ id: workspaces.id, directory: workspaces.directory }) + .from(workspaces) + .all(); + const workspaceIdsByDirectory = new Map( + workspaceRows.map((row) => [row.directory, row.id] as const), + ); + const projectRows = tx + .select({ id: projects.id, directory: projects.directory }) + .from(projects) + .all(); + const projectIdsByDirectory = new Map(projectRows.map((row) => [row.directory, row.id] as const)); + for (const record of records) { + const normalizedDirectory = normalizeWorkspaceId(record.cwd); + if (workspaceIdsByDirectory.has(normalizedDirectory)) { + continue; + } + + const timestamp = record.updatedAt ?? record.createdAt; + const displayName = + normalizedDirectory.split(/[\\/]/).filter(Boolean).at(-1) ?? normalizedDirectory; + let projectId = projectIdsByDirectory.get(normalizedDirectory); + if (projectId === undefined) { + const projectRow = tx + .insert(projects) + .values({ + directory: normalizedDirectory, + displayName, + kind: "directory", + gitRemote: null, + createdAt: record.createdAt, + updatedAt: timestamp, + archivedAt: null, + }) + .returning({ id: projects.id }) + .get(); + projectId = projectRow!.id; + projectIdsByDirectory.set(normalizedDirectory, projectId); + } + + const workspaceRow = tx + .insert(workspaces) + .values({ + projectId, + directory: normalizedDirectory, + displayName, + kind: "checkout", + createdAt: record.createdAt, + updatedAt: timestamp, + archivedAt: null, + }) + .returning({ id: workspaces.id }) + .get(); + workspaceIdsByDirectory.set(normalizedDirectory, workspaceRow!.id); + } + const rows = records.flatMap((record) => { + const workspaceId = workspaceIdsByDirectory.get(normalizeWorkspaceId(record.cwd)); + return workspaceId === undefined ? [] : [toAgentSnapshotRowValues({ record, workspaceId })]; + }); + for (let startIndex = 0; startIndex < rows.length; startIndex += MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT) { + const batch = rows.slice(startIndex, startIndex + MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT); + tx.insert(agentSnapshots).values(batch).run(); + } + }); + + options.logger.info( + { importedAgents: records.length }, + "Imported legacy agent snapshots into the database", + ); + + return { + status: "imported", + importedAgents: records.length, + }; +} + +async function readLegacyAgentRecords(baseDir: string, logger: Logger): Promise { + let entries: Array = []; + try { + entries = await fs.readdir(baseDir, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return []; + } + throw error; + } + + const recordsById = new Map(); + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith(".json")) { + const record = await readRecordFile(path.join(baseDir, entry.name), logger); + if (record) { + recordsById.set(record.id, record); + } + continue; + } + + if (!entry.isDirectory()) { + continue; + } + + let childEntries: Array = []; + try { + childEntries = await fs.readdir(path.join(baseDir, entry.name), { withFileTypes: true }); + } catch { + continue; + } + + for (const childEntry of childEntries) { + if (!childEntry.isFile() || !childEntry.name.endsWith(".json")) { + continue; + } + const record = await readRecordFile(path.join(baseDir, entry.name, childEntry.name), logger); + if (record) { + recordsById.set(record.id, record); + } + } + } + + return Array.from(recordsById.values()); +} + +async function readRecordFile(filePath: string, logger: Logger): Promise { + try { + const raw = await fs.readFile(filePath, "utf8"); + return parseStoredAgentRecord(JSON.parse(raw)); + } catch (error) { + logger.error({ err: error, filePath }, "Skipping invalid legacy agent snapshot"); + return null; + } +} + +async function hasAnyAgentSnapshotRows(db: PaseoDatabaseHandle["db"]): Promise { + const rows = await db.select({ count: count() }).from(agentSnapshots); + return (rows[0]?.count ?? 0) > 0; +} diff --git a/packages/server/src/server/db/legacy-project-workspace-import.test.ts b/packages/server/src/server/db/legacy-project-workspace-import.test.ts new file mode 100644 index 000000000..695148a0a --- /dev/null +++ b/packages/server/src/server/db/legacy-project-workspace-import.test.ts @@ -0,0 +1,191 @@ +import os from "node:os"; +import path from "node:path"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../test-utils/test-logger.js"; +import { openPaseoDatabase, type PaseoDatabaseHandle } from "./sqlite-database.js"; +import { importLegacyProjectWorkspaceJson } from "./legacy-project-workspace-import.js"; +import { projects, workspaces } from "./schema.js"; + +describe("importLegacyProjectWorkspaceJson", () => { + let tmpDir: string; + let paseoHome: string; + let dbDir: string; + let database: PaseoDatabaseHandle; + + beforeEach(async () => { + tmpDir = mkdtempSync(path.join(os.tmpdir(), "paseo-legacy-import-")); + paseoHome = path.join(tmpDir, ".paseo"); + dbDir = path.join(paseoHome, "db"); + mkdirSync(paseoHome, { recursive: true }); + database = await openPaseoDatabase(dbDir); + }); + + afterEach(async () => { + await database?.close(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("imports legacy projects and workspaces once when the DB is empty", async () => { + writeLegacyJson({ + paseoHome, + projectsJson: [ + { + projectId: "project-1", + rootPath: "/tmp/project-1", + kind: "git", + displayName: "Project One", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ], + workspacesJson: [ + { + workspaceId: "workspace-1", + projectId: "project-1", + cwd: "/tmp/project-1", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ], + }); + + const result = await importLegacyProjectWorkspaceJson({ + db: database.db, + paseoHome, + logger: createTestLogger(), + }); + + expect(result).toEqual({ + status: "imported", + importedProjects: 1, + importedWorkspaces: 1, + }); + const projectRows = await database.db.select().from(projects); + expect(projectRows).toEqual([ + expect.objectContaining({ + directory: "/tmp/project-1", + kind: "git", + displayName: "Project One", + }), + ]); + expect(typeof projectRows[0]!.id).toBe("number"); + + const workspaceRows = await database.db.select().from(workspaces); + expect(workspaceRows).toEqual([ + expect.objectContaining({ + projectId: projectRows[0]!.id, + directory: "/tmp/project-1", + kind: "checkout", + displayName: "main", + }), + ]); + }); + + test("skips import when the DB already has project or workspace data", async () => { + // Seed with a project in the new schema format + const [inserted] = await database.db + .insert(projects) + .values({ + directory: "/tmp/existing-project", + kind: "git", + displayName: "Existing Project", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + }) + .returning({ id: projects.id }); + + writeLegacyJson({ + paseoHome, + projectsJson: [ + { + projectId: "legacy-project", + rootPath: "/tmp/legacy-project", + kind: "git", + displayName: "Legacy Project", + createdAt: "2026-03-02T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ], + workspacesJson: [], + }); + + const result = await importLegacyProjectWorkspaceJson({ + db: database.db, + paseoHome, + logger: createTestLogger(), + }); + + expect(result).toEqual({ + status: "skipped", + reason: "database-not-empty", + }); + // Only the existing project should be in DB + const allProjects = await database.db.select().from(projects); + expect(allProjects).toHaveLength(1); + expect(allProjects[0]!.id).toBe(inserted!.id); + }); + + test("rolls back the whole import when workspace insertion fails", async () => { + writeLegacyJson({ + paseoHome, + projectsJson: [ + { + projectId: "project-1", + rootPath: "/tmp/project-1", + kind: "git", + displayName: "Project One", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ], + workspacesJson: [ + { + workspaceId: "workspace-1", + projectId: "missing-project", + cwd: "/tmp/project-1", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + }, + ], + }); + + await expect( + importLegacyProjectWorkspaceJson({ + db: database.db, + paseoHome, + logger: createTestLogger(), + }), + ).rejects.toThrow(); + + expect(await database.db.select().from(projects)).toEqual([]); + expect(await database.db.select().from(workspaces)).toEqual([]); + }); +}); + +function writeLegacyJson(input: { + paseoHome: string; + projectsJson: unknown[]; + workspacesJson: unknown[]; +}): void { + const projectsPath = path.join(input.paseoHome, "projects", "projects.json"); + const workspacesPath = path.join(input.paseoHome, "projects", "workspaces.json"); + mkdirSync(path.dirname(projectsPath), { recursive: true }); + writeFileSync(projectsPath, JSON.stringify(input.projectsJson, null, 2), { encoding: "utf8", flag: "w" }); + writeFileSync(workspacesPath, JSON.stringify(input.workspacesJson, null, 2), { + encoding: "utf8", + flag: "w", + }); +} diff --git a/packages/server/src/server/db/legacy-project-workspace-import.ts b/packages/server/src/server/db/legacy-project-workspace-import.ts new file mode 100644 index 000000000..ecd8f6d18 --- /dev/null +++ b/packages/server/src/server/db/legacy-project-workspace-import.ts @@ -0,0 +1,172 @@ +import path from "node:path"; +import { promises as fs } from "node:fs"; + +import { count } from "drizzle-orm"; +import type { Logger } from "pino"; + +import { z } from "zod"; + +import type { PaseoDatabaseHandle } from "./sqlite-database.js"; +import { projects, workspaces } from "./schema.js"; + +// Legacy JSON schemas — these match the old pre-migration format +const LegacyProjectSchema = z.object({ + projectId: z.string(), + rootPath: z.string(), + kind: z.string(), + displayName: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + archivedAt: z.string().nullable(), +}); + +const LegacyWorkspaceSchema = z.object({ + workspaceId: z.string(), + projectId: z.string(), + cwd: z.string(), + kind: z.string(), + displayName: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + archivedAt: z.string().nullable(), +}); + +export type LegacyProjectWorkspaceImportResult = + | { + status: "imported"; + importedProjects: number; + importedWorkspaces: number; + } + | { + status: "skipped"; + reason: "database-not-empty" | "no-legacy-files"; + }; + +export async function importLegacyProjectWorkspaceJson(options: { + db: PaseoDatabaseHandle["db"]; + paseoHome: string; + logger: Logger; +}): Promise { + const projectsPath = path.join(options.paseoHome, "projects", "projects.json"); + const workspacesPath = path.join(options.paseoHome, "projects", "workspaces.json"); + const [projectRows, workspaceRows, databaseHasRows] = await Promise.all([ + readLegacyProjects(projectsPath), + readLegacyWorkspaces(workspacesPath), + hasAnyProjectWorkspaceRows(options.db), + ]); + + if (databaseHasRows) { + options.logger.info("Skipping legacy project/workspace JSON import because the DB is not empty"); + return { + status: "skipped", + reason: "database-not-empty", + }; + } + + if (projectRows.length === 0 && workspaceRows.length === 0) { + options.logger.info("Skipping legacy project/workspace JSON import because no legacy files exist"); + return { + status: "skipped", + reason: "no-legacy-files", + }; + } + + options.db.transaction((tx) => { + // Insert projects, mapping old format to new schema + const projectDirectoryToId = new Map(); + for (const legacy of projectRows) { + const row = tx + .insert(projects) + .values({ + directory: legacy.rootPath, + displayName: legacy.displayName, + kind: legacy.kind === "non_git" ? "directory" : legacy.kind, + createdAt: legacy.createdAt, + updatedAt: legacy.updatedAt, + archivedAt: legacy.archivedAt, + }) + .returning({ id: projects.id }) + .get(); + projectDirectoryToId.set(legacy.rootPath, row!.id); + } + + // Build a map from legacy projectId -> new integer id + const legacyProjectIdToNewId = new Map(); + for (const legacy of projectRows) { + const newId = projectDirectoryToId.get(legacy.rootPath); + if (newId !== undefined) { + legacyProjectIdToNewId.set(legacy.projectId, newId); + } + } + + // Insert workspaces, resolving project FK + for (const legacy of workspaceRows) { + const projectId = legacyProjectIdToNewId.get(legacy.projectId); + if (projectId === undefined) { + throw new Error(`Legacy workspace ${legacy.workspaceId} references unknown project ${legacy.projectId}`); + } + tx + .insert(workspaces) + .values({ + projectId, + directory: legacy.cwd, + displayName: legacy.displayName, + kind: + legacy.kind === "local_checkout" || legacy.kind === "directory" + ? "checkout" + : legacy.kind, + createdAt: legacy.createdAt, + updatedAt: legacy.updatedAt, + archivedAt: legacy.archivedAt, + }) + .run(); + } + }); + + options.logger.info( + { + importedProjects: projectRows.length, + importedWorkspaces: workspaceRows.length, + }, + "Imported legacy project/workspace JSON into the database", + ); + + return { + status: "imported", + importedProjects: projectRows.length, + importedWorkspaces: workspaceRows.length, + }; +} + +async function readLegacyProjects(filePath: string) { + const raw = await readOptionalJsonFile(filePath); + return raw ? z.array(LegacyProjectSchema).parse(raw) : []; +} + +async function readLegacyWorkspaces(filePath: string) { + const raw = await readOptionalJsonFile(filePath); + return raw ? z.array(LegacyWorkspaceSchema).parse(raw) : []; +} + +async function readOptionalJsonFile(filePath: string): Promise { + try { + const raw = await fs.readFile(filePath, "utf8"); + return JSON.parse(raw); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return null; + } + throw error; + } +} + +async function hasAnyProjectWorkspaceRows(db: PaseoDatabaseHandle["db"]): Promise { + const [projectCountRows, workspaceCountRows] = await Promise.all([ + db.select({ count: count() }).from(projects), + db.select({ count: count() }).from(workspaces), + ]); + const projectCount = projectCountRows[0]?.count ?? 0; + const workspaceCount = workspaceCountRows[0]?.count ?? 0; + return projectCount > 0 || workspaceCount > 0; +} diff --git a/packages/server/src/server/db/migrations.ts b/packages/server/src/server/db/migrations.ts new file mode 100644 index 000000000..0b42d575b --- /dev/null +++ b/packages/server/src/server/db/migrations.ts @@ -0,0 +1,12 @@ +import { fileURLToPath } from "node:url"; + +import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; + +const migrationsFolder = fileURLToPath(new URL("./migrations", import.meta.url)); + +export async function runPaseoDbMigrations( + db: BetterSQLite3Database, +): Promise { + await migrate(db, { migrationsFolder }); +} diff --git a/packages/server/src/server/db/migrations/0000_sqlite_initial.sql b/packages/server/src/server/db/migrations/0000_sqlite_initial.sql new file mode 100644 index 000000000..74cdb0a17 --- /dev/null +++ b/packages/server/src/server/db/migrations/0000_sqlite_initial.sql @@ -0,0 +1,61 @@ +CREATE TABLE `projects` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `directory` text NOT NULL, + `display_name` text NOT NULL, + `kind` text NOT NULL, + `git_remote` text, + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + `archived_at` text +); +--> statement-breakpoint +CREATE UNIQUE INDEX `projects_directory_unique` ON `projects` (`directory`); +--> statement-breakpoint +CREATE TABLE `workspaces` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `project_id` integer NOT NULL, + `directory` text NOT NULL, + `display_name` text NOT NULL, + `kind` text NOT NULL, + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + `archived_at` text, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `workspaces_directory_unique` ON `workspaces` (`directory`); +--> statement-breakpoint +CREATE INDEX `workspaces_project_id_idx` ON `workspaces` (`project_id`); +--> statement-breakpoint +CREATE TABLE `agent_snapshots` ( + `agent_id` text PRIMARY KEY NOT NULL, + `provider` text NOT NULL, + `workspace_id` integer NOT NULL, + `cwd` text NOT NULL, + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + `last_activity_at` text, + `last_user_message_at` text, + `title` text, + `labels` text NOT NULL, + `last_status` text NOT NULL, + `last_mode_id` text, + `config` text, + `runtime_info` text, + `persistence` text, + `requires_attention` integer NOT NULL, + `attention_reason` text, + `attention_timestamp` text, + `internal` integer NOT NULL, + `archived_at` text, + FOREIGN KEY (`workspace_id`) REFERENCES `workspaces`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `agent_timeline_rows` ( + `agent_id` text NOT NULL, + `seq` integer NOT NULL, + `committed_at` text NOT NULL, + `item` text NOT NULL, + `item_kind` text, + PRIMARY KEY(`agent_id`, `seq`) +); diff --git a/packages/server/src/server/db/migrations/meta/0000_snapshot.json b/packages/server/src/server/db/migrations/meta/0000_snapshot.json new file mode 100644 index 000000000..91bcfd354 --- /dev/null +++ b/packages/server/src/server/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,14 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "7ccf4685-bd8f-41a6-bafb-44712c1fe0d7", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": {}, + "views": {}, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "checkConstraints": {} +} diff --git a/packages/server/src/server/db/migrations/meta/_journal.json b/packages/server/src/server/db/migrations/meta/_journal.json new file mode 100644 index 000000000..d00e466e7 --- /dev/null +++ b/packages/server/src/server/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1774405361702, + "tag": "0000_sqlite_initial", + "breakpoints": true + } + ] +} diff --git a/packages/server/src/server/db/schema.ts b/packages/server/src/server/db/schema.ts new file mode 100644 index 000000000..31c505522 --- /dev/null +++ b/packages/server/src/server/db/schema.ts @@ -0,0 +1,78 @@ +import { index, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +import type { AgentPersistenceHandle, AgentRuntimeInfo, AgentTimelineItem } from "../agent/agent-sdk-types.js"; +import type { StoredAgentRecord } from "../agent/agent-storage.js"; + +export const projects = sqliteTable("projects", { + id: integer("id").primaryKey({ autoIncrement: true }), + directory: text("directory").notNull().unique(), + displayName: text("display_name").notNull(), + kind: text("kind").notNull(), + gitRemote: text("git_remote"), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), + archivedAt: text("archived_at"), +}); + +export const workspaces = sqliteTable( + "workspaces", + { + id: integer("id").primaryKey({ autoIncrement: true }), + projectId: integer("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + directory: text("directory").notNull().unique(), + displayName: text("display_name").notNull(), + kind: text("kind").notNull(), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), + archivedAt: text("archived_at"), + }, + (table) => [index("workspaces_project_id_idx").on(table.projectId)], +); + +export const agentSnapshots = sqliteTable("agent_snapshots", { + agentId: text("agent_id").primaryKey(), + provider: text("provider").notNull(), + workspaceId: integer("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + cwd: text("cwd").notNull(), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), + lastActivityAt: text("last_activity_at"), + lastUserMessageAt: text("last_user_message_at"), + title: text("title"), + labels: text("labels", { mode: "json" }).$type().notNull(), + lastStatus: text("last_status").notNull(), + lastModeId: text("last_mode_id"), + config: text("config", { mode: "json" }).$type(), + runtimeInfo: text("runtime_info", { mode: "json" }).$type(), + persistence: text("persistence", { mode: "json" }).$type(), + requiresAttention: integer("requires_attention", { mode: "boolean" }).notNull(), + attentionReason: text("attention_reason"), + attentionTimestamp: text("attention_timestamp"), + internal: integer("internal", { mode: "boolean" }).notNull(), + archivedAt: text("archived_at"), +}); + +export const agentTimelineRows = sqliteTable( + "agent_timeline_rows", + { + agentId: text("agent_id").notNull(), + seq: integer("seq").notNull(), + committedAt: text("committed_at").notNull(), + item: text("item", { mode: "json" }).$type().notNull(), + itemKind: text("item_kind"), + }, + (table) => [ + primaryKey({ columns: [table.agentId, table.seq], name: "agent_timeline_rows_pk" }), + ], +); + +export const paseoDbSchema = { + projects, + workspaces, + agentSnapshots, + agentTimelineRows, +}; diff --git a/packages/server/src/server/db/sqlite-contract.test.ts b/packages/server/src/server/db/sqlite-contract.test.ts new file mode 100644 index 000000000..040748fc4 --- /dev/null +++ b/packages/server/src/server/db/sqlite-contract.test.ts @@ -0,0 +1,316 @@ +import os from "node:os"; +import path from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; + +import { and, asc, desc, eq, gt, lt, sql } from "drizzle-orm"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; +import { openPaseoDatabase } from "./sqlite-database.js"; +import { runPaseoDbMigrations } from "./migrations.js"; +import { + agentSnapshots, + agentTimelineRows, + projects, + workspaces, +} from "./schema.js"; + +function createTimestamp(day: number): string { + return `2026-03-${String(day).padStart(2, "0")}T00:00:00.000Z`; +} + +function createTimelineItem(type: AgentTimelineItem["type"], suffix: string): AgentTimelineItem { + if (type === "user_message") { + return { type, text: `user-${suffix}`, messageId: `msg-${suffix}` }; + } + if (type === "assistant_message" || type === "reasoning") { + return { type, text: `${type}-${suffix}` }; + } + return { type: "error", message: `error-${suffix}` }; +} + +describe("SQLite database contract", () => { + let tmpDir: string; + let dataDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(os.tmpdir(), "paseo-db-")); + dataDir = path.join(tmpDir, "db"); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("creates, migrates, closes, and reopens a persistent database", async () => { + const database = await openPaseoDatabase(dataDir); + + const [project] = await database.db.insert(projects).values({ + directory: "/tmp/project-1", + kind: "git", + displayName: "Project One", + gitRemote: "git@github.com:acme/project-1.git", + createdAt: createTimestamp(1), + updatedAt: createTimestamp(1), + archivedAt: null, + }).returning(); + + await database.close(); + + const reopened = await openPaseoDatabase(dataDir); + const rows = await reopened.db.select().from(projects); + expect(rows).toEqual([ + { + id: project.id, + directory: "/tmp/project-1", + displayName: "Project One", + kind: "git", + gitRemote: "git@github.com:acme/project-1.git", + createdAt: createTimestamp(1), + updatedAt: createTimestamp(1), + archivedAt: null, + }, + ]); + await reopened.close(); + }); + + test("supports project and workspace linkage plus archive field updates", async () => { + const database = await openPaseoDatabase(dataDir); + + const [project] = await database.db.insert(projects).values({ + directory: "/tmp/project-1", + kind: "git", + displayName: "Project One", + gitRemote: null, + createdAt: createTimestamp(1), + updatedAt: createTimestamp(1), + archivedAt: null, + }).returning(); + const [workspace] = await database.db.insert(workspaces).values({ + projectId: project.id, + directory: "/tmp/project-1", + kind: "checkout", + displayName: "main", + createdAt: createTimestamp(1), + updatedAt: createTimestamp(1), + archivedAt: null, + }).returning(); + + await database.db + .update(workspaces) + .set({ archivedAt: createTimestamp(2), updatedAt: createTimestamp(2) }) + .where(eq(workspaces.id, workspace.id)); + + const linkedRows = await database.db + .select({ + projectId: projects.id, + workspaceId: workspaces.id, + workspaceArchivedAt: workspaces.archivedAt, + }) + .from(workspaces) + .innerJoin(projects, eq(workspaces.projectId, projects.id)); + + expect(linkedRows).toEqual([ + { + projectId: project.id, + workspaceId: workspace.id, + workspaceArchivedAt: createTimestamp(2), + }, + ]); + + await database.close(); + }); + + test("supports snapshot insert, get, update, and project-delete cascade with integer workspace IDs", async () => { + const database = await openPaseoDatabase(dataDir); + const [project] = await database.db.insert(projects).values({ + directory: "/tmp/project-1", + kind: "git", + displayName: "Project One", + gitRemote: null, + createdAt: createTimestamp(1), + updatedAt: createTimestamp(1), + archivedAt: null, + }).returning(); + const [workspace] = await database.db.insert(workspaces).values({ + projectId: project.id, + directory: "/tmp/project-1", + kind: "checkout", + displayName: "main", + createdAt: createTimestamp(1), + updatedAt: createTimestamp(1), + archivedAt: null, + }).returning(); + + await database.db.insert(agentSnapshots).values({ + agentId: "agent-1", + provider: "codex", + workspaceId: workspace.id, + cwd: "/tmp/project-1", + createdAt: createTimestamp(1), + updatedAt: createTimestamp(1), + lastActivityAt: createTimestamp(1), + lastUserMessageAt: null, + title: "Agent One", + labels: { surface: "workspace" }, + lastStatus: "idle", + lastModeId: "plan", + config: { model: "gpt-5.1", modeId: "plan" }, + runtimeInfo: { provider: "codex", sessionId: "session-1" }, + persistence: { provider: "codex", sessionId: "session-1" }, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + internal: false, + archivedAt: null, + }); + + await database.db + .update(agentSnapshots) + .set({ + updatedAt: createTimestamp(2), + lastStatus: "running", + title: "Agent One Updated", + archivedAt: createTimestamp(3), + }) + .where(eq(agentSnapshots.agentId, "agent-1")); + + const rows = await database.db + .select() + .from(agentSnapshots) + .where(eq(agentSnapshots.agentId, "agent-1")); + + expect(rows).toEqual([ + { + agentId: "agent-1", + provider: "codex", + workspaceId: workspace.id, + cwd: "/tmp/project-1", + createdAt: createTimestamp(1), + updatedAt: createTimestamp(2), + lastActivityAt: createTimestamp(1), + lastUserMessageAt: null, + title: "Agent One Updated", + labels: { surface: "workspace" }, + lastStatus: "running", + lastModeId: "plan", + config: { model: "gpt-5.1", modeId: "plan" }, + runtimeInfo: { provider: "codex", sessionId: "session-1" }, + persistence: { provider: "codex", sessionId: "session-1" }, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + internal: false, + archivedAt: createTimestamp(3), + }, + ]); + + await database.db.delete(projects).where(eq(projects.id, project.id)); + expect(await database.db.select().from(workspaces)).toEqual([]); + expect(await database.db.select().from(agentSnapshots)).toEqual([]); + + await database.close(); + }); + + test("rejects agent snapshots without a workspace ID", async () => { + const database = await openPaseoDatabase(dataDir); + + await expect( + database.db.insert(agentSnapshots).values({ + agentId: "agent-1", + provider: "codex", + cwd: "/tmp/project-1", + createdAt: createTimestamp(1), + updatedAt: createTimestamp(1), + lastActivityAt: createTimestamp(1), + lastUserMessageAt: null, + title: "Agent One", + labels: { surface: "workspace" }, + lastStatus: "idle", + lastModeId: "plan", + config: { model: "gpt-5.1", modeId: "plan" }, + runtimeInfo: { provider: "codex", sessionId: "session-1" }, + persistence: { provider: "codex", sessionId: "session-1" }, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + internal: false, + archivedAt: null, + } as typeof agentSnapshots.$inferInsert), + ).rejects.toThrow(); + + await database.close(); + }); + + test("supports timeline append and tail, after-seq, before-seq access patterns in committed order", async () => { + const database = await openPaseoDatabase(dataDir); + const rows = [1, 2, 3, 4].map((seq) => ({ + agentId: "agent-1", + seq, + committedAt: createTimestamp(seq), + item: createTimelineItem(seq === 1 ? "user_message" : "assistant_message", String(seq)), + itemKind: seq === 1 ? "user_message" : "assistant_message", + })); + + await database.db.insert(agentTimelineRows).values(rows); + + const tailRows = await database.db + .select() + .from(agentTimelineRows) + .where(eq(agentTimelineRows.agentId, "agent-1")) + .orderBy(desc(agentTimelineRows.seq)) + .limit(2); + + expect(tailRows.map((row) => row.seq).reverse()).toEqual([3, 4]); + + const afterRows = await database.db + .select() + .from(agentTimelineRows) + .where(and(eq(agentTimelineRows.agentId, "agent-1"), gt(agentTimelineRows.seq, 2))) + .orderBy(asc(agentTimelineRows.seq)); + + expect(afterRows.map((row) => row.seq)).toEqual([3, 4]); + + const beforeRows = await database.db + .select() + .from(agentTimelineRows) + .where(and(eq(agentTimelineRows.agentId, "agent-1"), lt(agentTimelineRows.seq, 4))) + .orderBy(desc(agentTimelineRows.seq)) + .limit(2); + + expect(beforeRows.map((row) => row.seq).reverse()).toEqual([2, 3]); + + await database.close(); + }); + + test("enforces per-agent seq uniqueness and reruns migrations without drift", async () => { + const database = await openPaseoDatabase(dataDir); + + await database.db.insert(agentTimelineRows).values({ + agentId: "agent-1", + seq: 1, + committedAt: createTimestamp(1), + item: createTimelineItem("assistant_message", "1"), + itemKind: "assistant_message", + }); + + await expect( + database.db.insert(agentTimelineRows).values({ + agentId: "agent-1", + seq: 1, + committedAt: createTimestamp(2), + item: createTimelineItem("assistant_message", "duplicate"), + itemKind: "assistant_message", + }), + ).rejects.toThrow(); + + await runPaseoDbMigrations(database.db); + + const migrationRows = database.client + .prepare("select * from __drizzle_migrations order by created_at") + .all(); + expect(migrationRows).toHaveLength(1); + + await database.close(); + }); +}); diff --git a/packages/server/src/server/db/sqlite-database.ts b/packages/server/src/server/db/sqlite-database.ts new file mode 100644 index 000000000..9372f4146 --- /dev/null +++ b/packages/server/src/server/db/sqlite-database.ts @@ -0,0 +1,31 @@ +import { mkdirSync } from "node:fs"; +import path from "node:path"; + +import Database from "better-sqlite3"; +import { drizzle, type BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; + +import { runPaseoDbMigrations } from "./migrations.js"; +import { paseoDbSchema } from "./schema.js"; + +export interface PaseoDatabaseHandle { + client: Database.Database; + db: BetterSQLite3Database; + close(): Promise; +} + +export async function openPaseoDatabase(dataDir: string): Promise { + mkdirSync(dataDir, { recursive: true }); + const databasePath = path.join(dataDir, "paseo.sqlite"); + const client = new Database(databasePath); + client.pragma("foreign_keys = ON"); + client.pragma("journal_mode = WAL"); + const db = drizzle(client, { schema: paseoDbSchema }); + await runPaseoDbMigrations(db); + return { + client, + db, + async close(): Promise { + client.close(); + }, + }; +} diff --git a/packages/server/src/server/loop-service.test.ts b/packages/server/src/server/loop-service.test.ts index 2f9a09e7c..6993db444 100644 --- a/packages/server/src/server/loop-service.test.ts +++ b/packages/server/src/server/loop-service.test.ts @@ -33,6 +33,7 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = { supportsMcpServers: false, supportsReasoningStream: false, supportsToolInvocations: false, + supportsTerminalMode: false, }; interface ScriptedAgentBehavior { diff --git a/packages/server/src/server/loop-service.ts b/packages/server/src/server/loop-service.ts index 81d48b351..f2bd51f80 100644 --- a/packages/server/src/server/loop-service.ts +++ b/packages/server/src/server/loop-service.ts @@ -785,6 +785,7 @@ export class LoopService { model: loop.workerModel ?? loop.model ?? undefined, title: buildWorkerTitle(loop, iteration.index), internal: true, + terminal: false, }; } @@ -795,6 +796,7 @@ export class LoopService { model: loop.verifierModel ?? loop.model ?? undefined, title: buildVerifierTitle(loop, iteration.index), internal: true, + terminal: false, }; } diff --git a/packages/server/src/server/persistence-hooks.test.ts b/packages/server/src/server/persistence-hooks.test.ts index bedcb5734..815c16fef 100644 --- a/packages/server/src/server/persistence-hooks.test.ts +++ b/packages/server/src/server/persistence-hooks.test.ts @@ -1,83 +1,6 @@ -import { describe, expect, test, vi } from "vitest"; - -import type { ManagedAgent } from "./agent/agent-manager.js"; +import { describe, expect, test } from "vitest"; import type { StoredAgentRecord } from "./agent/agent-storage.js"; -import { - attachAgentStoragePersistence, - buildConfigOverrides, - buildSessionConfig, -} from "./persistence-hooks.js"; -import type { - AgentPermissionRequest, - AgentSession, - AgentSessionConfig, -} from "./agent/agent-sdk-types.js"; - -const testLogger = { - child: () => testLogger, - error: vi.fn(), -} as any; - -type ManagedAgentOverrides = Omit< - Partial, - "config" | "pendingPermissions" | "session" | "activeForegroundTurnId" -> & { - config?: Partial; - pendingPermissions?: Map; - session?: AgentSession | null; - activeForegroundTurnId?: string | null; -}; - -function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent { - const now = overrides.updatedAt ?? new Date("2025-01-01T00:00:00.000Z"); - const provider = overrides.provider ?? "claude"; - const cwd = overrides.cwd ?? "/tmp/project"; - const lifecycle = overrides.lifecycle ?? "idle"; - const configOverrides = overrides.config ?? {}; - const config: AgentSessionConfig = { - provider, - cwd, - modeId: configOverrides.modeId ?? "plan", - model: configOverrides.model ?? "claude-3.5-sonnet", - extra: configOverrides.extra ?? { claude: { tone: "focused" } }, - }; - const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession)); - const activeForegroundTurnId = - overrides.activeForegroundTurnId ?? (lifecycle === "running" ? "test-turn-id" : null); - - const agent: ManagedAgent = { - id: overrides.id ?? "agent-1", - provider, - cwd, - session, - capabilities: overrides.capabilities ?? { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, - }, - config, - lifecycle, - createdAt: overrides.createdAt ?? now, - updatedAt: overrides.updatedAt ?? now, - availableModes: overrides.availableModes ?? [], - currentModeId: overrides.currentModeId ?? config.modeId ?? null, - pendingPermissions: overrides.pendingPermissions ?? new Map(), - activeForegroundTurnId, - foregroundTurnWaiters: new Set(), - unsubscribeSession: null, - timeline: overrides.timeline ?? [], - persistence: overrides.persistence ?? null, - historyPrimed: overrides.historyPrimed ?? true, - lastUserMessageAt: overrides.lastUserMessageAt ?? now, - lastUsage: overrides.lastUsage, - lastError: overrides.lastError, - }; - - return agent; -} +import { buildConfigOverrides, buildSessionConfig } from "./persistence-hooks.js"; function createRecord(overrides?: Partial): StoredAgentRecord { const now = new Date().toISOString(); @@ -100,43 +23,6 @@ function createRecord(overrides?: Partial): StoredAgentRecord } describe("persistence hooks", () => { - test("attachAgentStoragePersistence forwards agent snapshots", async () => { - const applySnapshot = vi.fn().mockResolvedValue(undefined); - let subscriber: (event: any) => void = () => { - throw new Error("Agent manager subscriber was not registered"); - }; - const agentManager = { - subscribe: vi.fn((callback: (event: any) => void) => { - subscriber = callback; - return () => { - subscriber = () => { - throw new Error("Agent manager subscriber was not registered"); - }; - }; - }), - }; - attachAgentStoragePersistence( - testLogger, - agentManager as any, - { - applySnapshot, - list: vi.fn(), - } as any, - ); - - expect(agentManager.subscribe).toHaveBeenCalledTimes(1); - const agent = createManagedAgent(); - subscriber({ type: "agent_state", agent }); - expect(applySnapshot).toHaveBeenCalledWith(agent); - - subscriber({ - type: "agent_stream", - agentId: agent.id, - event: { type: "timeline", item: { type: "assistant_message", text: "hi" } }, - }); - expect(applySnapshot).toHaveBeenCalledTimes(1); - }); - test("buildConfigOverrides carries systemPrompt and mcpServers", () => { const record = createRecord({ title: "Voice agent (current)", @@ -208,4 +94,23 @@ describe("persistence hooks", () => { }, }); }); + + test("buildSessionConfig accepts terminal-only providers from the canonical manifest", () => { + const record = createRecord({ + provider: "gemini", + persistence: { + provider: "gemini", + sessionId: "session-123", + }, + config: { + terminal: true, + }, + }); + + expect(buildSessionConfig(record)).toMatchObject({ + provider: "gemini", + cwd: "/tmp/project", + terminal: true, + }); + }); }); diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index 9a60af11c..ca6905582 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -1,48 +1,13 @@ -import type { AgentManager } from "./agent/agent-manager.js"; -import type { AgentProvider, AgentSessionConfig } from "./agent/agent-sdk-types.js"; -import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js"; +import type pino from "pino"; -type LoggerLike = { - child(bindings: Record): LoggerLike; - error(...args: any[]): void; -}; - -function getLogger(logger: LoggerLike): LoggerLike { - return logger.child({ module: "persistence" }); -} - -type AgentStoragePersistence = Pick; -type AgentManagerStateSource = Pick; - -function isKnownProvider(provider: string): provider is AgentProvider { - return provider === "claude" || provider === "codex" || provider === "opencode"; -} - -/** - * Attach AgentStorage persistence to an AgentManager instance so every - * agent_state snapshot is flushed to disk. - */ -export function attachAgentStoragePersistence( - logger: LoggerLike, - agentManager: AgentManagerStateSource, - storage: AgentStoragePersistence, -): () => void { - const log = getLogger(logger); - const unsubscribe = agentManager.subscribe((event) => { - if (event.type !== "agent_state") { - return; - } - void storage.applySnapshot(event.agent).catch((error) => { - log.error({ err: error, agentId: event.agent.id }, "Failed to persist agent snapshot"); - }); - }); - - return unsubscribe; -} +import type { AgentSessionConfig } from "./agent/agent-sdk-types.js"; +import type { StoredAgentRecord } from "./agent/agent-storage.js"; +import { isValidAgentProvider } from "./agent/provider-manifest.js"; export function buildConfigOverrides(record: StoredAgentRecord): Partial { return { cwd: record.cwd, + terminal: record.config?.terminal ?? undefined, modeId: record.lastModeId ?? record.config?.modeId ?? undefined, model: record.config?.model ?? undefined, thinkingOptionId: record.config?.thinkingOptionId ?? undefined, @@ -54,13 +19,14 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial { + test("session runtime code does not directly hydrate provider history", () => { + const sessionSource = readFileSync(new URL("./session.ts", import.meta.url), "utf8"); + const agentLoadingSource = readFileSync( + new URL("./agent-loading-service.ts", import.meta.url), + "utf8", + ); + + expect(sessionSource).not.toMatch(/hydrateTimelineFromProvider\s*\(/); + expect(agentLoadingSource).not.toMatch(/hydrateTimelineFromProvider\s*\(/); + }); +}); diff --git a/packages/server/src/server/provider-history-compatibility-service.test.ts b/packages/server/src/server/provider-history-compatibility-service.test.ts new file mode 100644 index 000000000..f91a99341 --- /dev/null +++ b/packages/server/src/server/provider-history-compatibility-service.test.ts @@ -0,0 +1,391 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import pino from "pino"; +import { describe, expect, test, vi } from "vitest"; + +import { AgentLoadingService } from "./agent-loading-service.js"; +import { AgentManager } from "./agent/agent-manager.js"; +import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js"; +import { DbAgentTimelineStore } from "./db/db-agent-timeline-store.js"; +import { openPaseoDatabase } from "./db/sqlite-database.js"; +import { createTestAgentClients } from "./test-utils/fake-agent-client.js"; + +function createStoredAgentRecord(overrides?: Partial): StoredAgentRecord { + const now = "2026-03-25T00:00:00.000Z"; + return { + id: "agent-compat-1", + provider: "codex", + cwd: "/tmp/project", + createdAt: now, + updatedAt: now, + title: null, + labels: {}, + lastStatus: "idle", + config: { + model: "gpt-5.1-codex-mini", + }, + persistence: { + provider: "codex", + sessionId: "provider-session-1", + }, + ...overrides, + }; +} + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function createCompatibilitySnapshot(overrides?: Partial>) { + return { + id: "agent-compat-1", + provider: "codex", + cwd: "/tmp/project", + persistence: { + provider: "codex", + sessionId: "provider-session-1", + }, + ...overrides, + }; +} + +describe("AgentLoadingService", () => { + test("ensureAgentLoaded seeds the live timeline from durable rows for an unloaded persisted agent", async () => { + const workspaceRoot = mkdtempSync(path.join(os.tmpdir(), "provider-history-compat-load-")); + const logger = pino({ level: "silent" }); + const database = await openPaseoDatabase(path.join(workspaceRoot, "db")); + + try { + const storage = new AgentStorage(path.join(workspaceRoot, "agents"), logger); + const manager = new AgentManager({ + clients: createTestAgentClients(), + registry: storage, + durableTimelineStore: new DbAgentTimelineStore(database.db), + logger, + idFactory: () => "00000000-0000-4000-8000-000000000301", + }); + const service = new AgentLoadingService({ + agentManager: manager as any, + agentStorage: storage as any, + logger, + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workspaceRoot, + model: "gpt-5.1-codex-mini", + }); + await manager.runAgent(snapshot.id, "say 'timeline test'"); + await manager.flush(); + await storage.flush(); + rmSync( + path.join( + os.tmpdir(), + "paseo-fake-provider-history", + "codex", + `${snapshot.persistence?.sessionId}.jsonl`, + ), + { force: true }, + ); + await manager.closeAgent(snapshot.id); + + const loaded = await service.ensureAgentLoaded({ agentId: snapshot.id }); + const durableTimeline = await manager.fetchTimeline(snapshot.id, { + direction: "tail", + limit: 0, + }); + + expect(loaded.id).toBe(snapshot.id); + expect(manager.getTimeline(snapshot.id)).toEqual([]); + expect(durableTimeline.rows.map((row) => row.item)).toEqual([ + { type: "assistant_message", text: "timeline test" }, + ]); + } finally { + await database.close(); + rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); + + test("ensureAgentLoaded succeeds when provider history is absent", async () => { + const workspaceRoot = mkdtempSync(path.join(os.tmpdir(), "provider-history-compat-empty-")); + const logger = pino({ level: "silent" }); + + try { + const storage = new AgentStorage(path.join(workspaceRoot, "agents"), logger); + const manager = new AgentManager({ + clients: createTestAgentClients(), + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000302", + }); + const service = new AgentLoadingService({ + agentManager: manager as any, + agentStorage: storage as any, + logger, + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workspaceRoot, + model: "gpt-5.1-codex-mini", + }); + await manager.flush(); + await storage.flush(); + await manager.closeAgent(snapshot.id); + + const loaded = await service.ensureAgentLoaded({ agentId: snapshot.id }); + + expect(loaded.id).toBe(snapshot.id); + expect(manager.getTimeline(snapshot.id)).toEqual([]); + } finally { + rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); + + test("ensureAgentLoaded dedupes concurrent cold-load bootstrap", async () => { + const deferred = createDeferred(); + let currentAgent: any = null; + const snapshot = createCompatibilitySnapshot({ id: "agent-compat-dedupe" }); + const agentStorage = { + get: vi.fn(async () => + createStoredAgentRecord({ + id: "agent-compat-dedupe", + cwd: "/tmp/dedupe", + persistence: { + provider: "codex", + sessionId: "provider-session-dedupe", + }, + }), + ), + }; + const agentManager = { + getAgent: vi.fn(() => currentAgent), + resumeAgentFromPersistence: vi.fn(async () => deferred.promise), + createAgent: vi.fn(), + reloadAgentSession: vi.fn(), + }; + const logger = { + child: () => logger, + info: vi.fn(), + warn: vi.fn(), + }; + const service = new AgentLoadingService({ + agentManager: agentManager as any, + agentStorage: agentStorage as any, + logger: logger as any, + }); + + const firstLoad = service.ensureAgentLoaded({ agentId: "agent-compat-dedupe" }); + const secondLoad = service.ensureAgentLoaded({ agentId: "agent-compat-dedupe" }); + deferred.resolve(snapshot); + + const [firstResult, secondResult] = await Promise.all([firstLoad, secondLoad]); + + expect(firstResult).toEqual(snapshot); + expect(secondResult).toEqual(snapshot); + expect(agentStorage.get).toHaveBeenCalledTimes(1); + expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledTimes(1); + }); + + test("resumeAgent delegates to manager resume", async () => { + const snapshot = createCompatibilitySnapshot({ id: "agent-compat-resume" }); + const agentManager = { + getAgent: vi.fn(() => null), + resumeAgentFromPersistence: vi.fn(async () => snapshot), + createAgent: vi.fn(), + reloadAgentSession: vi.fn(), + }; + const logger = { + child: () => logger, + info: vi.fn(), + warn: vi.fn(), + }; + const service = new AgentLoadingService({ + agentManager: agentManager as any, + agentStorage: { + get: async () => null, + } as any, + logger: logger as any, + }); + + const result = await service.resumeAgent({ + handle: { + provider: "codex", + sessionId: "provider-session-resume", + }, + overrides: { + model: "gpt-5.4", + }, + }); + + expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith( + { + provider: "codex", + sessionId: "provider-session-resume", + }, + { + model: "gpt-5.4", + }, + ); + expect(result).toEqual(snapshot); + }); + + test("refreshAgent reloads loaded persisted agents", async () => { + const existing = createCompatibilitySnapshot({ id: "agent-compat-refresh-loaded" }); + const reloaded = createCompatibilitySnapshot({ id: "agent-compat-refresh-loaded" }); + let currentAgent: any = existing; + const agentManager = { + getAgent: vi.fn(() => currentAgent), + resumeAgentFromPersistence: vi.fn(), + createAgent: vi.fn(), + reloadAgentSession: vi.fn(async () => { + currentAgent = reloaded; + return reloaded; + }), + }; + const logger = { + child: () => logger, + info: vi.fn(), + warn: vi.fn(), + }; + const service = new AgentLoadingService({ + agentManager: agentManager as any, + agentStorage: { + get: async () => null, + } as any, + logger: logger as any, + }); + + const result = await service.refreshAgent({ agentId: "agent-compat-refresh-loaded" }); + + expect(agentManager.reloadAgentSession).toHaveBeenCalledWith("agent-compat-refresh-loaded"); + expect(result).toEqual(reloaded); + }); + + test("refreshAgent keeps loaded non-persisted agents without reloading", async () => { + const existing = createCompatibilitySnapshot({ + id: "agent-compat-refresh-live", + persistence: null, + }); + const agentManager = { + getAgent: vi.fn(() => existing), + resumeAgentFromPersistence: vi.fn(), + createAgent: vi.fn(), + reloadAgentSession: vi.fn(), + }; + const logger = { + child: () => logger, + info: vi.fn(), + warn: vi.fn(), + }; + const service = new AgentLoadingService({ + agentManager: agentManager as any, + agentStorage: { + get: async () => null, + } as any, + logger: logger as any, + }); + + const result = await service.refreshAgent({ agentId: "agent-compat-refresh-live" }); + + expect(agentManager.reloadAgentSession).not.toHaveBeenCalled(); + expect(result).toEqual(existing); + }); + + test("refreshAgent resumes unloaded persisted agents", async () => { + const snapshot = createCompatibilitySnapshot({ id: "agent-compat-refresh-cold" }); + const record = createStoredAgentRecord({ + id: "agent-compat-refresh-cold", + cwd: "/tmp/refresh-cold", + persistence: { + provider: "codex", + sessionId: "provider-session-refresh-cold", + }, + }); + const agentManager = { + getAgent: vi.fn(() => null), + resumeAgentFromPersistence: vi.fn(async () => snapshot), + createAgent: vi.fn(), + reloadAgentSession: vi.fn(), + }; + const logger = { + child: () => logger, + info: vi.fn(), + warn: vi.fn(), + }; + const service = new AgentLoadingService({ + agentManager: agentManager as any, + agentStorage: { + get: vi.fn(async () => record), + } as any, + logger: logger as any, + }); + + const result = await service.refreshAgent({ agentId: "agent-compat-refresh-cold" }); + + expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith( + { + provider: "codex", + sessionId: "provider-session-refresh-cold", + nativeHandle: undefined, + metadata: undefined, + }, + { + cwd: "/tmp/refresh-cold", + modeId: undefined, + model: "gpt-5.1-codex-mini", + thinkingOptionId: undefined, + title: undefined, + extra: undefined, + systemPrompt: undefined, + mcpServers: undefined, + }, + "agent-compat-refresh-cold", + { + createdAt: new Date("2026-03-25T00:00:00.000Z"), + updatedAt: new Date("2026-03-25T00:00:00.000Z"), + lastUserMessageAt: null, + labels: {}, + }, + ); + expect(result).toEqual(snapshot); + }); + + test("refreshAgent preserves the unloaded no-persistence error", async () => { + const service = new AgentLoadingService({ + agentManager: { + getAgent: vi.fn(() => null), + resumeAgentFromPersistence: vi.fn(), + createAgent: vi.fn(), + reloadAgentSession: vi.fn(), + } as any, + agentStorage: { + get: async () => + createStoredAgentRecord({ + id: "agent-compat-no-persistence", + persistence: null, + }), + } as any, + logger: { + child: () => ({ + child: () => null, + info: vi.fn(), + warn: vi.fn(), + }), + info: vi.fn(), + warn: vi.fn(), + } as any, + }); + + await expect( + service.refreshAgent({ agentId: "agent-compat-no-persistence" }), + ).rejects.toThrow("Agent agent-compat-no-persistence cannot be refreshed because it lacks persistence"); + }); +}); diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index f77856627..c0d6ef6a9 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import type { Logger } from "pino"; import { AgentManager } from "../agent/agent-manager.js"; import type { ManagedAgent } from "../agent/agent-manager.js"; -import { AgentStorage } from "../agent/agent-storage.js"; +import type { AgentSnapshotStore } from "../agent/agent-snapshot-store.js"; import type { AgentPromptInput, AgentSessionConfig, @@ -97,7 +97,7 @@ export interface ScheduleServiceOptions { paseoHome: string; logger: Logger; agentManager: AgentManager; - agentStorage: AgentStorage; + agentStorage: AgentSnapshotStore; now?: () => Date; runner?: (schedule: StoredSchedule) => Promise; } @@ -106,7 +106,7 @@ export class ScheduleService { private readonly store: ScheduleStore; private readonly logger: Logger; private readonly agentManager: AgentManager; - private readonly agentStorage: AgentStorage; + private readonly agentStorage: AgentSnapshotStore; private readonly now: () => Date; private readonly runner: (schedule: StoredSchedule) => Promise; private readonly runningScheduleIds = new Set(); @@ -368,6 +368,9 @@ export class ScheduleService { private async executeSchedule(schedule: StoredSchedule): Promise { if (schedule.target.type === "agent") { const agent = await this.ensureAgentLoaded(schedule.target.agentId); + if (agent.terminal) { + throw new Error(`Agent ${agent.id} is a terminal agent and cannot be targeted by schedules`); + } if (this.agentManager.hasInFlightRun(agent.id)) { throw new Error(`Agent ${agent.id} already has an active run`); } @@ -398,6 +401,7 @@ export class ScheduleService { extra: schedule.target.config.extra, systemPrompt: schedule.target.config.systemPrompt, mcpServers: schedule.target.config.mcpServers as AgentSessionConfig["mcpServers"], + terminal: false, }; const labels = { "paseo.schedule-id": schedule.id, diff --git a/packages/server/src/server/session.provider-history-compatibility-ownership.test.ts b/packages/server/src/server/session.provider-history-compatibility-ownership.test.ts new file mode 100644 index 000000000..6727dbbf3 --- /dev/null +++ b/packages/server/src/server/session.provider-history-compatibility-ownership.test.ts @@ -0,0 +1,331 @@ +import { describe, expect, test, vi } from "vitest"; + +import { Session } from "./session.js"; + +function createStoredAgentRecord(overrides?: Partial>) { + return { + id: "agent-1", + provider: "codex", + cwd: "/tmp/project", + createdAt: "2026-03-24T00:00:00.000Z", + updatedAt: "2026-03-24T00:00:00.000Z", + title: null, + labels: {}, + lastStatus: "idle", + config: null, + persistence: { + provider: "codex", + sessionId: "provider-session-1", + }, + archivedAt: null, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + ...overrides, + }; +} + +function createCompatibilitySnapshot(overrides?: Partial>) { + return { + id: "agent-1", + provider: "codex", + cwd: "/tmp/project", + persistence: { + provider: "codex", + sessionId: "provider-session-1", + }, + ...overrides, + }; +} + +function createSessionForOwnershipTests(options?: { + agentLoadingService?: { + ensureAgentLoaded?: (options: { agentId: string }) => Promise; + resumeAgent?: (options: { + handle: { provider: string; sessionId: string }; + overrides?: Record; + }) => Promise; + refreshAgent?: (options: { agentId: string }) => Promise; + }; + storedRecord?: Record | null; + loadedAgent?: Record | null; + timelineRows?: Array<{ seq: number; item: Record; timestamp: Date }>; +}) { + const emitted: Array<{ type: string; payload: unknown }> = []; + const logger = { + child: () => logger, + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + + const agentManager = { + subscribe: () => () => {}, + listAgents: () => [], + getAgent: vi.fn(() => options?.loadedAgent ?? null), + createAgent: vi.fn(async () => { + throw new Error("Session should delegate unloaded bootstrap to AgentLoadingService"); + }), + resumeAgentFromPersistence: vi.fn(async () => { + throw new Error("Session should delegate persistence resume to AgentLoadingService"); + }), + reloadAgentSession: vi.fn(async () => { + throw new Error("Session should delegate refresh reload to AgentLoadingService"); + }), + hydrateTimelineFromProvider: vi.fn(async () => { + throw new Error("Session should not call hydrateTimelineFromProvider directly"); + }), + getStructuredSendRejection: vi.fn(async () => null), + fetchTimeline: vi.fn(async () => ({ + rows: options?.timelineRows ?? [], + hasOlder: false, + hasNewer: false, + })), + recordUserMessage: vi.fn(), + waitForAgentRunStart: vi.fn(async () => undefined), + getTimeline: vi.fn(() => []), + }; + + const session = new Session({ + clientId: "test-client", + onMessage: (message) => emitted.push(message as any), + logger: logger as any, + downloadTokenStore: {} as any, + pushTokenStore: {} as any, + paseoHome: "/tmp/paseo-test", + agentManager: agentManager as any, + agentStorage: { + list: async () => (options?.storedRecord ? [options.storedRecord as any] : []), + get: async () => (options?.storedRecord as any) ?? null, + } as any, + projectRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [], + get: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + } as any, + workspaceRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [], + get: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + } as any, + createAgentMcpTransport: async () => { + throw new Error("not used"); + }, + stt: null, + tts: null, + terminalManager: null, + agentLoadingService: options?.agentLoadingService, + } as any) as any; + + return { session, emitted, agentManager }; +} + +describe("provider history compatibility ownership", () => { + test("fetch_agent_timeline_request delegates unloaded bootstrap through the compatibility seam", async () => { + const ensureAgentLoaded = vi.fn(async () => createCompatibilitySnapshot()); + const { session, emitted } = createSessionForOwnershipTests({ + storedRecord: createStoredAgentRecord(), + timelineRows: [ + { + seq: 1, + item: { type: "assistant_message", text: "rehydrated from provider history" }, + timestamp: new Date("2026-03-24T00:00:01.000Z"), + }, + ], + agentLoadingService: { + ensureAgentLoaded, + }, + }); + + session.buildAgentPayload = vi.fn(async () => ({ id: "agent-1" })); + + await session.handleMessage({ + type: "fetch_agent_timeline_request", + requestId: "req-fetch", + agentId: "agent-1", + }); + + expect(ensureAgentLoaded).toHaveBeenCalledWith({ agentId: "agent-1" }); + expect(emitted).toContainEqual({ + type: "fetch_agent_timeline_response", + payload: expect.objectContaining({ + requestId: "req-fetch", + agentId: "agent-1", + error: null, + entries: [ + expect.objectContaining({ + seq: 1, + }), + ], + }), + }); + }); + + test("send_agent_message_request delegates unloaded bootstrap before recording and streaming", async () => { + const ensureAgentLoaded = vi.fn(async () => createCompatibilitySnapshot()); + const { session, agentManager, emitted } = createSessionForOwnershipTests({ + storedRecord: createStoredAgentRecord(), + agentLoadingService: { + ensureAgentLoaded, + }, + }); + + session.resolveAgentIdentifier = vi.fn(async () => ({ ok: true, agentId: "agent-1" })); + session.unarchiveAgentState = vi.fn(async () => true); + session.buildAgentPrompt = vi.fn((text: string) => text); + session.startAgentStream = vi.fn(() => ({ ok: true })); + + await session.handleMessage({ + type: "send_agent_message_request", + requestId: "req-send", + agentId: "agent-1", + text: "hello", + images: [], + messageId: "msg-1", + }); + + expect(ensureAgentLoaded).toHaveBeenCalledWith({ agentId: "agent-1" }); + expect(ensureAgentLoaded.mock.invocationCallOrder[0]).toBeLessThan( + agentManager.recordUserMessage.mock.invocationCallOrder[0], + ); + expect(agentManager.recordUserMessage).toHaveBeenCalledWith("agent-1", "hello", { + messageId: "msg-1", + emitState: false, + }); + expect(session.startAgentStream).toHaveBeenCalledWith("agent-1", "hello"); + expect(emitted).toContainEqual({ + type: "send_agent_message_response", + payload: { + requestId: "req-send", + agentId: "agent-1", + accepted: true, + error: null, + }, + }); + }); + + test("resume_agent_request delegates persistence bootstrap through the compatibility seam", async () => { + const resumeAgent = vi.fn(async () => createCompatibilitySnapshot()); + const { session, emitted } = createSessionForOwnershipTests({ + agentLoadingService: { + resumeAgent, + }, + }); + + session.unarchiveAgentByHandle = vi.fn(async () => undefined); + session.unarchiveAgentState = vi.fn(async () => true); + session.forwardAgentUpdate = vi.fn(async () => undefined); + session.getAgentPayloadById = vi.fn(async () => ({ id: "agent-1" })); + + await session.handleMessage({ + type: "resume_agent_request", + requestId: "req-resume", + handle: { + provider: "codex", + sessionId: "provider-session-1", + }, + overrides: { + model: "gpt-5.4", + }, + }); + + expect(resumeAgent).toHaveBeenCalledWith({ + handle: { + provider: "codex", + sessionId: "provider-session-1", + }, + overrides: { + model: "gpt-5.4", + }, + }); + expect(emitted).toContainEqual({ + type: "status", + payload: expect.objectContaining({ + status: "agent_resumed", + requestId: "req-resume", + agentId: "agent-1", + }), + }); + }); + + test("refresh_agent_request delegates loaded persisted refresh through the compatibility seam", async () => { + const refreshAgent = vi.fn(async () => + createCompatibilitySnapshot({ + persistence: { + provider: "codex", + sessionId: "provider-session-1", + }, + }), + ); + const { session, emitted } = createSessionForOwnershipTests({ + loadedAgent: createCompatibilitySnapshot(), + agentLoadingService: { + refreshAgent, + }, + }); + + session.unarchiveAgentState = vi.fn(async () => true); + session.interruptAgentIfRunning = vi.fn(async () => undefined); + session.forwardAgentUpdate = vi.fn(async () => undefined); + + await session.handleMessage({ + type: "refresh_agent_request", + requestId: "req-refresh-loaded", + agentId: "agent-1", + }); + + expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-1"); + expect(refreshAgent).toHaveBeenCalledWith({ agentId: "agent-1" }); + expect(emitted).toContainEqual({ + type: "status", + payload: { + status: "agent_refreshed", + requestId: "req-refresh-loaded", + agentId: "agent-1", + timelineSize: 0, + }, + }); + }); + + test("refresh_agent_request delegates unloaded persisted refresh through the compatibility seam", async () => { + const refreshAgent = vi.fn(async () => createCompatibilitySnapshot()); + const { session, emitted } = createSessionForOwnershipTests({ + storedRecord: createStoredAgentRecord(), + agentLoadingService: { + refreshAgent, + }, + }); + + session.unarchiveAgentState = vi.fn(async () => true); + session.interruptAgentIfRunning = vi.fn(async () => undefined); + session.forwardAgentUpdate = vi.fn(async () => undefined); + + await session.handleMessage({ + type: "refresh_agent_request", + requestId: "req-refresh-unloaded", + agentId: "agent-1", + }); + + expect(session.interruptAgentIfRunning).not.toHaveBeenCalled(); + expect(refreshAgent).toHaveBeenCalledWith({ agentId: "agent-1" }); + expect(emitted).toContainEqual({ + type: "status", + payload: { + status: "agent_refreshed", + requestId: "req-refresh-unloaded", + agentId: "agent-1", + timelineSize: 0, + }, + }); + }); +}); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 6339211f3..021864498 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,6 +1,6 @@ import { v4 as uuidv4 } from "uuid"; import { watch, type FSWatcher } from "node:fs"; -import { readFile, stat } from "fs/promises"; +import { readFile } from "fs/promises"; import { exec } from "child_process"; import { promisify } from "util"; import { join, resolve, sep } from "path"; @@ -56,9 +56,9 @@ import { type VoiceTurnController, } from "./voice/voice-turn-controller.js"; import { - buildConfigOverrides, buildSessionConfig, extractTimestamps, + toAgentPersistenceHandle, } from "./persistence-hooks.js"; import { experimental_createMCPClient } from "ai"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; @@ -80,11 +80,6 @@ import { appendTimelineItemIfAgentKnown, emitLiveTimelineItemIfAgentKnown, } from "./agent/timeline-append.js"; -import { - projectTimelineRows, - selectTimelineWindowByProjectedLimit, - type TimelineProjectionMode, -} from "./agent/timeline-projection.js"; import { DEFAULT_STRUCTURED_GENERATION_PROVIDERS, StructuredAgentFallbackError, @@ -102,27 +97,17 @@ import type { AgentProvider, AgentPersistenceHandle, } from "./agent/agent-sdk-types.js"; -import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js"; +import type { StoredAgentRecord } from "./agent/agent-storage.js"; +import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js"; import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js"; -import { - buildProjectPlacementForCwd, - detectStaleWorkspaces, - deriveProjectKind, - deriveProjectRootPath, - deriveWorkspaceDisplayName, - deriveWorkspaceKind, - normalizeWorkspaceId as normalizePersistedWorkspaceId, -} from "./workspace-registry-model.js"; +import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord, ProjectRegistry, WorkspaceRegistry, } from "./workspace-registry.js"; -import { - createPersistedProjectRecord, - createPersistedWorkspaceRecord, -} from "./workspace-registry.js"; +import { AgentLoadingService } from "./agent-loading-service.js"; import { buildVoiceAgentMcpServerConfig, buildVoiceModeSystemPrompt, @@ -161,6 +146,7 @@ import { toCheckoutError, } from "./checkout-git-utils.js"; import { CheckoutDiffManager } from "./checkout-diff-manager.js"; +import { detectWorkspaceGitMetadata } from "./workspace-git-metadata.js"; import type { LocalSpeechModelId } from "./speech/providers/local/models.js"; import { toResolver, type Resolvable } from "./speech/provider-resolver.js"; import type { SpeechReadinessSnapshot, SpeechReadinessState } from "./speech/speech-runtime.js"; @@ -181,18 +167,29 @@ import { handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest, handlePaseoWorktreeListRequest as handleWorktreeListRequest, killTerminalsUnderPath as killWorktreeTerminalsUnderPath, - registerPendingWorktreeWorkspace as registerPendingWorktreeWorkspaceSession, } from "./worktree-session.js"; const execAsync = promisify(exec); const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS); -const pendingAgentInitializations = new Map>(); const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0]; const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500; const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__"; const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024; const TERMINAL_STREAM_LOW_WATER_BYTES = 16 * 1024; const MAX_TERMINAL_STREAM_SLOTS = 256; +const pendingAgentInitializations = new Map>(); + +type DeleteFencedAgentSnapshotStore = AgentSnapshotStore & { + beginDelete(agentId: string): void; +}; + + +function beginAgentDeleteIfSupported(agentStorage: AgentSnapshotStore, agentId: string): void { + if ("beginDelete" in agentStorage && typeof agentStorage.beginDelete === "function") { + (agentStorage as DeleteFencedAgentSnapshotStore).beginDelete(agentId); + } +} + function deriveInitialAgentTitle(prompt: string): string | null { const firstContentLine = prompt @@ -297,12 +294,12 @@ type WorkspaceUpdatesSubscriptionState = { subscriptionId: string; filter?: WorkspaceUpdatesFilter; isBootstrapping: boolean; - pendingUpdatesByWorkspaceId: Map; + pendingUpdatesByWorkspaceId: Map; }; type FetchWorkspacesCursor = { sort: FetchWorkspacesRequestSort[]; values: Record; - id: string; + id: number; }; class SessionRequestError extends Error { @@ -362,13 +359,14 @@ export type SessionOptions = { pushTokenStore: PushTokenStore; paseoHome: string; agentManager: AgentManager; - agentStorage: AgentStorage; + agentStorage: AgentSnapshotStore; projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; chatService: FileBackedChatService; scheduleService: ScheduleService; loopService: LoopService; checkoutDiffManager: CheckoutDiffManager; + agentLoadingService?: AgentLoadingService; createAgentMcpTransport: AgentMcpTransportFactory; stt: Resolvable; tts: Resolvable; @@ -473,30 +471,6 @@ function coerceAgentProvider(logger: pino.Logger, value: string, agentId?: strin return DEFAULT_AGENT_PROVIDER; } -function toAgentPersistenceHandle( - logger: pino.Logger, - handle: StoredAgentRecord["persistence"], -): AgentPersistenceHandle | null { - if (!handle) { - return null; - } - const provider = handle.provider; - if (!isValidAgentProvider(provider)) { - logger.warn({ provider }, `Ignoring persistence handle with unknown provider '${provider}'`); - return null; - } - if (!handle.sessionId) { - logger.warn("Ignoring persistence handle missing sessionId"); - return null; - } - return { - provider, - sessionId: handle.sessionId, - nativeHandle: handle.nativeHandle, - metadata: handle.metadata, - } satisfies AgentPersistenceHandle; -} - /** * Session represents a single connected client session. * It owns all state management, orchestration logic, and message processing. @@ -545,13 +519,14 @@ export class Session { private agentMcpClient: Awaited> | null = null; private agentTools: ToolSet | null = null; private agentManager: AgentManager; - private readonly agentStorage: AgentStorage; + private readonly agentStorage: AgentSnapshotStore; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; private readonly chatService: FileBackedChatService; private readonly scheduleService: ScheduleService; private readonly loopService: LoopService; private readonly checkoutDiffManager: CheckoutDiffManager; + private readonly agentLoadingService: AgentLoadingService; private readonly createAgentMcpTransport: AgentMcpTransportFactory; private readonly downloadTokenStore: DownloadTokenStore; private readonly pushTokenStore: PushTokenStore; @@ -615,6 +590,7 @@ export class Session { scheduleService, loopService, checkoutDiffManager, + agentLoadingService, createAgentMcpTransport, stt, tts, @@ -633,6 +609,11 @@ export class Session { this.downloadTokenStore = downloadTokenStore; this.pushTokenStore = pushTokenStore; this.paseoHome = paseoHome; + this.sessionLogger = logger.child({ + module: "session", + clientId: this.clientId, + sessionId: this.sessionId, + }); this.agentManager = agentManager; this.agentStorage = agentStorage; this.projectRegistry = projectRegistry; @@ -641,6 +622,13 @@ export class Session { this.scheduleService = scheduleService; this.loopService = loopService; this.checkoutDiffManager = checkoutDiffManager; + this.agentLoadingService = + agentLoadingService ?? + new AgentLoadingService({ + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }); this.createAgentMcpTransport = createAgentMcpTransport; this.terminalManager = terminalManager; if (this.terminalManager) { @@ -659,11 +647,6 @@ export class Session { this.getSpeechReadiness = dictation?.getSpeechReadiness; this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; this.abortController = new AbortController(); - this.sessionLogger = logger.child({ - module: "session", - clientId: this.clientId, - sessionId: this.sessionId, - }); this.providerRegistry = buildProviderRegistry(this.sessionLogger, { runtimeSettings: this.agentProviderRuntimeSettings, }); @@ -943,7 +926,6 @@ export class Session { event: serializedEvent, timestamp: new Date().toISOString(), ...(typeof event.seq === "number" ? { seq: event.seq } : {}), - ...(typeof event.epoch === "string" ? { epoch: event.epoch } : {}), } as const; this.emit({ @@ -1002,6 +984,7 @@ export class Session { supportsMcpServers: false, supportsReasoningStream: false, supportsToolInvocations: true, + supportsTerminalMode: false, } as const; const createdAt = new Date(record.createdAt); @@ -1029,6 +1012,7 @@ export class Session { id: record.id, provider, cwd: record.cwd, + terminal: record.config?.terminal ?? false, model: record.config?.model ?? null, thinkingOptionId: record.config?.thinkingOptionId ?? null, effectiveThinkingOptionId: resolveEffectiveThinkingOptionId({ @@ -1046,7 +1030,8 @@ export class Session { pendingPermissions: [], persistence: toAgentPersistenceHandle(this.sessionLogger, record.persistence), lastUsage: undefined, - lastError: undefined, + lastError: record.lastError ?? undefined, + terminalExit: record.terminalExit, title: record.title ?? record.config?.title ?? null, requiresAttention: record.requiresAttention ?? false, attentionReason: record.attentionReason ?? null, @@ -1085,35 +1070,11 @@ export class Session { } const initPromise = (async () => { - const record = await this.agentStorage.get(agentId); - if (!record) { - throw new Error(`Agent not found: ${agentId}`); + const record = await this.requireStoredAgentRecord(agentId); + if (record.config?.terminal) { + return this.ensureTerminalAgentLoaded(agentId, record); } - - const handle = toAgentPersistenceHandle(this.sessionLogger, record.persistence); - let snapshot: ManagedAgent; - if (handle) { - snapshot = await this.agentManager.resumeAgentFromPersistence( - handle, - buildConfigOverrides(record), - agentId, - extractTimestamps(record), - ); - this.sessionLogger.info( - { agentId, provider: record.provider }, - "Agent resumed from persistence", - ); - } else { - const config = buildSessionConfig(record); - snapshot = await this.agentManager.createAgent(config, agentId, { labels: record.labels }); - this.sessionLogger.info( - { agentId, provider: record.provider }, - "Agent created from stored config", - ); - } - - await this.agentManager.hydrateTimelineFromProvider(agentId); - return this.agentManager.getAgent(agentId) ?? snapshot; + return this.agentLoadingService.ensureAgentLoaded({ agentId }); })(); pendingAgentInitializations.set(agentId, initPromise); @@ -1128,6 +1089,47 @@ export class Session { } } + private async requireStoredAgentRecord(agentId: string): Promise { + const record = await this.agentStorage.get(agentId); + if (!record) { + throw new Error(`Agent not found: ${agentId}`); + } + return record; + } + + private async getAgentMode(agentId: string): Promise<"chat" | "terminal"> { + const existing = this.agentManager.getAgent(agentId); + if (existing) { + return existing.terminal ? "terminal" : "chat"; + } + const record = await this.requireStoredAgentRecord(agentId); + return record.config?.terminal ? "terminal" : "chat"; + } + + private async ensureTerminalAgentLoaded( + agentId: string, + record: StoredAgentRecord, + ): Promise { + const timestamps = extractTimestamps(record); + const snapshot = await this.agentManager.launchTerminalAgent(buildSessionConfig(record), agentId, { + persistence: record.persistence ?? null, + createdAt: timestamps.createdAt, + updatedAt: timestamps.updatedAt, + lastUserMessageAt: timestamps.lastUserMessageAt, + labels: timestamps.labels, + attention: { + requiresAttention: record.requiresAttention ?? false, + attentionReason: record.attentionReason ?? null, + attentionTimestamp: record.attentionTimestamp ? new Date(record.attentionTimestamp) : null, + }, + }); + this.sessionLogger.info( + { agentId, provider: record.provider }, + "Terminal agent loaded from stored config", + ); + return this.agentManager.getAgent(agentId) ?? snapshot; + } + private matchesAgentFilter(options: { agent: AgentSnapshotPayload; project: ProjectPlacementPayload; @@ -1237,171 +1239,71 @@ export class Session { } } - private async buildProjectPlacement(cwd: string): Promise { - return buildProjectPlacementForCwd({ - cwd, - paseoHome: this.paseoHome, - }); + private async findWorkspaceByDirectory(cwd: string): Promise { + const normalizedCwd = normalizePersistedWorkspaceId(cwd); + const workspaces = await this.workspaceRegistry.list(); + return workspaces.find((workspace) => workspace.directory === normalizedCwd) ?? null; } - private buildPersistedProjectRecord(input: { - workspaceId: string; - placement: ProjectPlacementPayload; - createdAt: string; - updatedAt: string; - }): PersistedProjectRecord { - return createPersistedProjectRecord({ - projectId: input.placement.projectKey, - rootPath: deriveProjectRootPath({ - cwd: input.workspaceId, - checkout: input.placement.checkout, - }), - kind: deriveProjectKind(input.placement.checkout), - displayName: input.placement.projectName, - createdAt: input.createdAt, - updatedAt: input.updatedAt, - archivedAt: null, - }); - } - - private buildPersistedWorkspaceRecord(input: { - workspaceId: string; - placement: ProjectPlacementPayload; - createdAt: string; - updatedAt: string; - }): PersistedWorkspaceRecord { - return createPersistedWorkspaceRecord({ - workspaceId: input.workspaceId, - projectId: input.placement.projectKey, - cwd: input.workspaceId, - kind: deriveWorkspaceKind(input.placement.checkout), - displayName: deriveWorkspaceDisplayName({ - cwd: input.workspaceId, - checkout: input.placement.checkout, - }), - createdAt: input.createdAt, - updatedAt: input.updatedAt, - archivedAt: null, - }); - } - - private async archiveProjectRecordIfEmpty(projectId: string, archivedAt: string): Promise { - const siblingWorkspaces = (await this.workspaceRegistry.list()).filter( - (workspace) => workspace.projectId === projectId && !workspace.archivedAt, - ); - if (siblingWorkspaces.length === 0) { - await this.projectRegistry.archive(projectId, archivedAt); + private async buildProjectPlacementForWorkspace( + workspace: PersistedWorkspaceRecord, + projectRecord?: PersistedProjectRecord | null, + ): Promise { + const project = projectRecord ?? (await this.projectRegistry.get(workspace.projectId)); + if (!project) { + throw new Error(`Project not found for workspace ${workspace.id}`); } - } - - private async reconcileWorkspaceRecord(workspaceId: string): Promise<{ - workspace: PersistedWorkspaceRecord; - changed: boolean; - }> { - const normalizedWorkspaceId = normalizePersistedWorkspaceId(workspaceId); - const existing = await this.workspaceRegistry.get(normalizedWorkspaceId); - const placement = await this.buildProjectPlacement(normalizedWorkspaceId); - await this.syncWorkspaceGitWatchTarget(normalizedWorkspaceId, { - isGit: placement.checkout.isGit, - }); - const now = new Date().toISOString(); - const nextProjectCreatedAt = existing?.createdAt ?? now; - const nextWorkspaceCreatedAt = existing?.createdAt ?? now; - const currentProjectRecord = await this.projectRegistry.get(placement.projectKey); - const nextProjectRecord = this.buildPersistedProjectRecord({ - workspaceId: normalizedWorkspaceId, - placement, - createdAt: currentProjectRecord?.createdAt ?? nextProjectCreatedAt, - updatedAt: now, - }); - const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({ - workspaceId: normalizedWorkspaceId, - placement, - createdAt: nextWorkspaceCreatedAt, - updatedAt: now, - }); - - const needsWorkspaceUpdate = - !existing || - existing.archivedAt || - existing.projectId !== nextWorkspaceRecord.projectId || - existing.kind !== nextWorkspaceRecord.kind || - existing.displayName !== nextWorkspaceRecord.displayName; - - const needsProjectUpdate = - !currentProjectRecord || - currentProjectRecord.archivedAt || - currentProjectRecord.rootPath !== nextProjectRecord.rootPath || - currentProjectRecord.kind !== nextProjectRecord.kind || - currentProjectRecord.displayName !== nextProjectRecord.displayName; - - if (!needsWorkspaceUpdate && !needsProjectUpdate) { - return { - workspace: existing!, - changed: false, - }; - } - - await this.projectRegistry.upsert(nextProjectRecord); - await this.workspaceRegistry.upsert(nextWorkspaceRecord); - - if (existing && !existing.archivedAt && existing.projectId !== nextWorkspaceRecord.projectId) { - await this.archiveProjectRecordIfEmpty(existing.projectId, now); - } - + const checkout = + project.kind !== "git" + ? { + cwd: workspace.directory, + isGit: false as const, + currentBranch: null, + remoteUrl: null, + isPaseoOwnedWorktree: false as const, + mainRepoRoot: null, + } + : workspace.kind === "worktree" + ? { + cwd: workspace.directory, + isGit: true as const, + currentBranch: workspace.displayName, + remoteUrl: project.gitRemote, + isPaseoOwnedWorktree: true as const, + mainRepoRoot: project.directory, + } + : { + cwd: workspace.directory, + isGit: true as const, + currentBranch: workspace.displayName, + remoteUrl: project.gitRemote, + isPaseoOwnedWorktree: false as const, + mainRepoRoot: null, + }; return { - workspace: nextWorkspaceRecord, - changed: true, + projectKey: String(project.id), + projectName: project.displayName, + checkout, }; } - private async reconcileActiveWorkspaceRecords(): Promise> { - const changedWorkspaceIds = new Set(); - const activeWorkspaces = (await this.workspaceRegistry.list()).filter( - (workspace) => !workspace.archivedAt, - ); - const staleWorkspaceIds = await detectStaleWorkspaces({ - activeWorkspaces, - agentRecords: (await this.agentStorage.list()).map((agent) => ({ - cwd: agent.cwd, - archivedAt: agent.archivedAt ?? null, - })), - checkDirectoryExists: async (cwd) => { - try { - await stat(cwd); - return true; - } catch { - return false; - } - }, - }); - - for (const workspaceId of staleWorkspaceIds) { - await this.archiveWorkspaceRecord(workspaceId); - changedWorkspaceIds.add(workspaceId); + private async buildProjectPlacementForCwd(cwd: string): Promise { + const workspace = await this.findWorkspaceByDirectory(cwd); + if (!workspace) { + return null; } - - for (const workspace of activeWorkspaces) { - if (staleWorkspaceIds.has(workspace.workspaceId)) { - continue; - } - - const result = await this.reconcileWorkspaceRecord(workspace.workspaceId); - if (result.changed) { - changedWorkspaceIds.add(result.workspace.workspaceId); - } - } - - return changedWorkspaceIds; + return this.buildProjectPlacementForWorkspace(workspace); } private async forwardAgentUpdate(agent: ManagedAgent): Promise { try { - await this.ensureWorkspaceRegistered(agent.cwd); const subscription = this.agentUpdatesSubscription; const payload = await this.buildAgentPayload(agent); if (subscription) { - const project = await this.buildProjectPlacement(payload.cwd); + const project = await this.buildProjectPlacementForCwd(payload.cwd); + if (!project) { + throw new Error(`Workspace not found for agent ${payload.id}`); + } const matches = this.matchesAgentFilter({ agent: payload, project, @@ -1949,8 +1851,8 @@ export class Session { (await this.agentStorage.get(agentId))?.cwd ?? null; - // Prevent the persistence hook from re-creating the record while we close/delete. - this.agentStorage.beginDelete(agentId); + // File-backed storage still needs an early delete fence before closeAgent(). + beginAgentDeleteIfSupported(this.agentStorage, agentId); try { await this.agentManager.closeAgent(agentId); @@ -1963,10 +1865,11 @@ export class Session { try { await this.agentStorage.remove(agentId); + await this.agentManager.deleteCommittedTimeline(agentId); } catch (error: any) { this.sessionLogger.error( { err: error, agentId }, - `Failed to remove agent ${agentId} from registry`, + `Failed to fully delete agent ${agentId}`, ); } @@ -1991,65 +1894,52 @@ export class Session { } private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise { - const result = await this.archiveAgentForClose(agentId); + this.sessionLogger.info({ agentId }, `Archiving agent ${agentId}`); + + const { archivedAt } = await this.archiveAgentState(agentId); + this.emit({ type: "agent_archived", payload: { - agentId: result.agentId, - archivedAt: result.archivedAt, + agentId, + archivedAt, requestId, }, }); } - private async archiveAgentForClose( - agentId: string, - ): Promise<{ agentId: string; archivedAt: string }> { - this.sessionLogger.info({ agentId }, `Archiving agent ${agentId}`); - + private async archiveAgentState(agentId: string): Promise<{ + archivedAt: string; + archivedRecord: StoredAgentRecord; + }> { if (this.agentManager.getAgent(agentId)) { await this.interruptAgentIfRunning(agentId); await this.agentManager.clearAgentAttention(agentId).catch(() => undefined); } - const { archivedAt } = await this.agentManager.archiveAgent(agentId); - const archivedRecord = await this.agentStorage.get(agentId); - if (!archivedRecord) { - throw new Error(`Agent not found in storage after archive: ${agentId}`); + const archivedAt = new Date().toISOString(); + const nextRecord = await this.agentManager.archiveSnapshot(agentId, archivedAt); + + // Unload the agent from memory — the storage record is the source of truth now. + // This tears down the provider session and drops the hydrated timeline, + // freeing memory. ensureAgentLoaded will re-initialize if needed later. + if (this.agentManager.getAgent(agentId)) { + try { + await this.agentManager.closeAgent(agentId); + } catch (error) { + this.sessionLogger.warn({ err: error, agentId }, "Failed to close agent during archive"); + } } - if (this.agentUpdatesSubscription) { - const payload = this.buildStoredAgentPayload(archivedRecord); - const project = await this.buildProjectPlacement(payload.cwd); - const matches = this.matchesAgentFilter({ - agent: payload, - project, - filter: this.agentUpdatesSubscription.filter, - }); - this.bufferOrEmitAgentUpdate( - this.agentUpdatesSubscription, - matches - ? { - kind: "upsert", - agent: payload, - project, - } - : { - kind: "remove", - agentId, - }, - ); - await this.emitWorkspaceUpdateForCwd(payload.cwd); - } - - return { agentId, archivedAt }; + return { archivedAt, archivedRecord: nextRecord }; } private async handleCloseItemsRequest(msg: CloseItemsRequest): Promise { const agents = []; for (const agentId of msg.agentIds) { try { - agents.push(await this.archiveAgentForClose(agentId)); + const { archivedAt } = await this.archiveAgentState(agentId); + agents.push({ agentId, archivedAt }); } catch (error: any) { this.sessionLogger.warn( { err: error, agentId, requestId: msg.requestId }, @@ -2085,31 +1975,11 @@ export class Session { } private async unarchiveAgentState(agentId: string): Promise { - const record = await this.agentStorage.get(agentId); - if (!record || !record.archivedAt) { - return false; - } - const updatedAt = new Date().toISOString(); - await this.agentStorage.upsert({ - ...record, - archivedAt: null, - updatedAt, - }); - this.agentManager.notifyAgentState(agentId); - return true; + return this.agentManager.unarchiveSnapshot(agentId); } private async unarchiveAgentByHandle(handle: AgentPersistenceHandle): Promise { - const records = await this.agentStorage.list(); - const matched = records.find( - (record) => - record.persistence?.provider === handle.provider && - record.persistence?.sessionId === handle.sessionId, - ); - if (!matched) { - return; - } - await this.unarchiveAgentState(matched.id); + await this.agentManager.unarchiveSnapshotByHandle(handle); } private async handleUpdateAgentRequest( @@ -2145,26 +2015,10 @@ export class Session { } try { - const liveAgent = this.agentManager.getAgent(agentId); - if (liveAgent) { - if (normalizedName) { - await this.agentManager.setTitle(agentId, normalizedName); - } - if (normalizedLabels) { - await this.agentManager.setLabels(agentId, normalizedLabels); - } - } else { - const existing = await this.agentStorage.get(agentId); - if (!existing) { - throw new Error(`Agent not found: ${agentId}`); - } - - await this.agentStorage.upsert({ - ...existing, - ...(normalizedName ? { title: normalizedName } : {}), - ...(normalizedLabels ? { labels: { ...existing.labels, ...normalizedLabels } } : {}), - }); - } + await this.agentManager.updateAgentMetadata(agentId, { + ...(normalizedName ? { title: normalizedName } : {}), + ...(normalizedLabels ? { labels: normalizedLabels } : {}), + }); this.emit({ type: "update_agent_response", @@ -2718,9 +2572,30 @@ export class Session { worktreeName, labels, ); - await this.ensureWorkspaceRegistered(sessionConfig.cwd); - const snapshot = await this.agentManager.createAgent(sessionConfig, undefined, { labels }); + const resolvedWorkspace = + typeof msg.workspaceId === "number" + ? await this.workspaceRegistry.get(msg.workspaceId) + : (await this.findWorkspaceByDirectory(sessionConfig.cwd)) ?? + (await this.findOrCreateWorkspaceForDirectory(sessionConfig.cwd)); + if (!resolvedWorkspace) { + throw new Error(`Workspace not found: ${msg.workspaceId}`); + } + const snapshot = await this.agentManager.createAgent( + { + ...sessionConfig, + cwd: resolvedWorkspace.directory, + }, + undefined, + { + labels, + workspaceId: resolvedWorkspace.id, + initialPrompt: trimmedPrompt, + }, + ); await this.forwardAgentUpdate(snapshot); + if (sessionConfig.terminal) { + void this.emitInitialTerminalsChangedSnapshot(resolvedWorkspace.directory); + } if (requestId) { const agentPayload = await this.getAgentPayloadById(snapshot.id); @@ -2749,27 +2624,29 @@ export class Session { logger: this.sessionLogger, }); - void this.handleSendAgentMessage( - snapshot.id, - trimmedPrompt, - resolveClientMessageId(clientMessageId), - images, - outputSchema ? { outputSchema } : undefined, - ).catch((promptError) => { - this.sessionLogger.error( - { err: promptError, agentId: snapshot.id }, - `Failed to run initial prompt for agent ${snapshot.id}`, - ); - this.emit({ - type: "activity_log", - payload: { - id: uuidv4(), - timestamp: new Date(), - type: "error", - content: `Initial prompt failed: ${(promptError as Error)?.message ?? promptError}`, - }, + if (!sessionConfig.terminal) { + void this.handleSendAgentMessage( + snapshot.id, + trimmedPrompt, + resolveClientMessageId(clientMessageId), + images, + outputSchema ? { outputSchema } : undefined, + ).catch((promptError) => { + this.sessionLogger.error( + { err: promptError, agentId: snapshot.id }, + `Failed to run initial prompt for agent ${snapshot.id}`, + ); + this.emit({ + type: "activity_log", + payload: { + id: uuidv4(), + timestamp: new Date(), + type: "error", + content: `Initial prompt failed: ${(promptError as Error)?.message ?? promptError}`, + }, + }); }); - }); + } } if (worktreeConfig) { @@ -2844,9 +2721,11 @@ export class Session { ); try { await this.unarchiveAgentByHandle(handle); - const snapshot = await this.agentManager.resumeAgentFromPersistence(handle, overrides); + const snapshot = await this.agentLoadingService.resumeAgent({ + handle, + overrides, + }); await this.unarchiveAgentState(snapshot.id); - await this.agentManager.hydrateTimelineFromProvider(snapshot.id); await this.forwardAgentUpdate(snapshot); const timelineSize = this.agentManager.getTimeline(snapshot.id).length; if (requestId) { @@ -2887,32 +2766,10 @@ export class Session { try { await this.unarchiveAgentState(agentId); - let snapshot: ManagedAgent; - const existing = this.agentManager.getAgent(agentId); - if (existing) { + if (this.agentManager.getAgent(agentId)) { await this.interruptAgentIfRunning(agentId); - if (existing.persistence) { - snapshot = await this.agentManager.reloadAgentSession(agentId); - } else { - snapshot = existing; - } - } else { - const record = await this.agentStorage.get(agentId); - if (!record) { - throw new Error(`Agent not found: ${agentId}`); - } - const handle = toAgentPersistenceHandle(this.sessionLogger, record.persistence); - if (!handle) { - throw new Error(`Agent ${agentId} cannot be refreshed because it lacks persistence`); - } - snapshot = await this.agentManager.resumeAgentFromPersistence( - handle, - buildConfigOverrides(record), - agentId, - extractTimestamps(record), - ); } - await this.agentManager.hydrateTimelineFromProvider(agentId); + const snapshot = await this.agentLoadingService.refreshAgent({ agentId }); await this.forwardAgentUpdate(snapshot); const timelineSize = this.agentManager.getTimeline(agentId).length; if (requestId) { @@ -3864,11 +3721,18 @@ export class Session { target.latestFingerprint = this.workspaceGitDescriptorFingerprint(workspace); } - private primeWorkspaceGitWatchFingerprints( + private async primeWorkspaceGitWatchFingerprints( workspaces: Iterable, - ): void { + ): Promise { for (const workspace of workspaces) { - this.rememberWorkspaceGitWatchFingerprint(workspace.id, workspace); + const persistedWorkspace = await this.workspaceRegistry.get(workspace.id); + if (!persistedWorkspace) { + continue; + } + await this.syncWorkspaceGitWatchTarget(persistedWorkspace.directory, { + isGit: workspace.projectKind === "git", + }); + this.rememberWorkspaceGitWatchFingerprint(persistedWorkspace.directory, workspace); } } @@ -4287,7 +4151,12 @@ export class Session { paseoHome: this.paseoHome, agentManager: this.agentManager, agentStorage: this.agentStorage, - archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), + archiveWorkspaceRecord: async (workspaceDirectory) => { + const workspace = await this.findWorkspaceByDirectory(workspaceDirectory); + if (workspace) { + await this.archiveWorkspaceRecord(workspace.id); + } + }, emit: (message) => this.emit(message), emitWorkspaceUpdatesForCwds: (cwds) => this.emitWorkspaceUpdatesForCwds(cwds), isPathWithinRoot: (rootPath, candidatePath) => @@ -4810,13 +4679,13 @@ export class Session { labels: filter?.labels, }); - const placementByCwd = new Map>(); - const getPlacement = (cwd: string): Promise => { + const placementByCwd = new Map>(); + const getPlacement = (cwd: string): Promise => { const existing = placementByCwd.get(cwd); if (existing) { return existing; } - const placementPromise = this.buildProjectPlacement(cwd); + const placementPromise = this.buildProjectPlacementForCwd(cwd); placementByCwd.set(cwd, placementPromise); return placementPromise; }; @@ -4842,12 +4711,15 @@ export class Session { ) { const batch = candidates.slice(start, start + batchSize); const batchEntries = await Promise.all( - batch.map(async (agent) => ({ - agent, - project: await getPlacement(agent.cwd), - })), + batch.map(async (agent) => { + const project = await getPlacement(agent.cwd); + return project ? { agent, project } : null; + }), ); for (const entry of batchEntries) { + if (!entry) { + continue; + } if ( !this.matchesAgentFilter({ agent: entry.agent, @@ -4931,32 +4803,23 @@ export class Session { ): Promise { const resolvedProjectRecord = projectRecord ?? (await this.projectRegistry.get(workspace.projectId)); - let displayName = workspace.displayName; - try { - const placement = await this.buildProjectPlacement(workspace.cwd); - displayName = deriveWorkspaceDisplayName({ - cwd: workspace.cwd, - checkout: placement.checkout, - }); - } catch { - // Fall back to the persisted label if checkout metadata is unavailable. - } let diffStat: { additions: number; deletions: number } | null = null; try { - diffStat = await getCheckoutShortstat(workspace.cwd); + diffStat = await getCheckoutShortstat(workspace.directory); } catch { // Non-critical — leave null on failure. } return { - id: workspace.workspaceId, + id: workspace.id, projectId: workspace.projectId, - projectDisplayName: resolvedProjectRecord?.displayName ?? workspace.projectId, - projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd, - projectKind: resolvedProjectRecord?.kind ?? "non_git", + projectDisplayName: resolvedProjectRecord?.displayName ?? String(workspace.projectId), + projectRootPath: resolvedProjectRecord?.directory ?? workspace.directory, + workspaceDirectory: workspace.directory, + projectKind: resolvedProjectRecord?.kind ?? "directory", workspaceKind: workspace.kind, - name: displayName, + name: workspace.displayName, status: "done", activityAt: null, diffStat, @@ -4974,13 +4837,14 @@ export class Session { const activeProjects = new Map( persistedProjects .filter((project) => !project.archivedAt) - .map((project) => [project.projectId, project] as const), + .map((project) => [project.id, project] as const), ); - const descriptorsByWorkspaceId = new Map(); + const descriptorsByWorkspaceId = new Map(); + const workspaceIdsByDirectory = new Map(activeRecords.map((workspace) => [workspace.directory, workspace.id])); for (const workspace of activeRecords) { descriptorsByWorkspaceId.set( - workspace.workspaceId, + workspace.id, await this.describeWorkspaceRecord( workspace, activeProjects.get(workspace.projectId) ?? null, @@ -4993,7 +4857,10 @@ export class Session { continue; } - const workspaceId = normalizePersistedWorkspaceId(agent.cwd); + const workspaceId = workspaceIdsByDirectory.get(normalizePersistedWorkspaceId(agent.cwd)); + if (workspaceId === undefined) { + continue; + } const existing = descriptorsByWorkspaceId.get(workspaceId); if (!existing) { continue; @@ -5010,7 +4877,6 @@ export class Session { } private async listWorkspaceDescriptors(): Promise { - await this.reconcileActiveWorkspaceRecords(); return this.listWorkspaceDescriptorsSnapshot(); } @@ -5045,7 +4911,7 @@ export class Session { case "name": return workspace.name.toLocaleLowerCase(); case "project_id": - return workspace.projectId.toLocaleLowerCase(); + return workspace.projectId; } } @@ -5063,7 +4929,7 @@ export class Session { } return spec.direction === "asc" ? base : -base; } - return left.id.localeCompare(right.id); + return left.id - right.id; } private encodeFetchWorkspacesCursor( @@ -5105,7 +4971,7 @@ export class Session { id?: unknown; }; - if (!Array.isArray(payload.sort) || typeof payload.id !== "string") { + if (!Array.isArray(payload.sort) || typeof payload.id !== "number") { throw new SessionRequestError("invalid_cursor", "Invalid fetch_workspaces cursor"); } if (!payload.values || typeof payload.values !== "object") { @@ -5172,7 +5038,7 @@ export class Session { } return spec.direction === "asc" ? base : -base; } - return workspace.id.localeCompare(cursor.id); + return workspace.id - cursor.id; } private matchesWorkspaceFilter(input: { @@ -5184,21 +5050,21 @@ export class Session { return true; } - if (filter.projectId && filter.projectId.trim().length > 0) { - if (workspace.projectId !== filter.projectId.trim()) { + if (typeof filter.projectId === "number") { + if (workspace.projectId !== filter.projectId) { return false; } } if (filter.idPrefix && filter.idPrefix.trim().length > 0) { - if (!workspace.id.startsWith(filter.idPrefix.trim())) { + if (!String(workspace.id).startsWith(filter.idPrefix.trim())) { return false; } } if (filter.query && filter.query.trim().length > 0) { const query = filter.query.trim().toLocaleLowerCase(); - const haystacks = [workspace.name, workspace.projectId, workspace.id]; + const haystacks = [workspace.name, String(workspace.projectId), String(workspace.id)]; if (!haystacks.some((value) => value.toLocaleLowerCase().includes(query))) { return false; } @@ -5261,7 +5127,7 @@ export class Session { } private flushBootstrappedWorkspaceUpdates(options?: { - snapshotLatestActivityByWorkspaceId?: Map; + snapshotLatestActivityByWorkspaceId?: Map; }): void { const subscription = this.workspaceUpdatesSubscription; if (!subscription || !subscription.isBootstrapping) { @@ -5296,9 +5162,35 @@ export class Session { } } - private async ensureWorkspaceRegistered(cwd: string): Promise { - const workspaceId = normalizePersistedWorkspaceId(cwd); - return (await this.reconcileWorkspaceRecord(workspaceId)).workspace; + private async findOrCreateWorkspaceForDirectory(cwd: string): Promise { + const normalizedCwd = normalizePersistedWorkspaceId(cwd); + const existingWorkspace = await this.findWorkspaceByDirectory(normalizedCwd); + if (existingWorkspace) { + return existingWorkspace; + } + + const timestamp = new Date().toISOString(); + const directoryName = normalizedCwd.split(/[\\/]/).filter(Boolean).at(-1) ?? normalizedCwd; + const gitMetadata = detectWorkspaceGitMetadata(normalizedCwd, directoryName); + const projectId = await this.projectRegistry.insert({ + directory: normalizedCwd, + displayName: gitMetadata.projectDisplayName, + kind: gitMetadata.projectKind, + gitRemote: gitMetadata.gitRemote, + createdAt: timestamp, + updatedAt: timestamp, + archivedAt: null, + }); + const workspaceId = await this.workspaceRegistry.insert({ + projectId, + directory: normalizedCwd, + displayName: gitMetadata.workspaceDisplayName, + kind: "checkout", + createdAt: timestamp, + updatedAt: timestamp, + archivedAt: null, + }); + return (await this.workspaceRegistry.get(workspaceId))!; } private async registerPendingWorktreeWorkspace(options: { @@ -5306,38 +5198,80 @@ export class Session { worktreePath: string; branchName: string; }): Promise { - return registerPendingWorktreeWorkspaceSession( - { - buildPersistedProjectRecord: (input) => this.buildPersistedProjectRecord(input), - buildPersistedWorkspaceRecord: (input) => this.buildPersistedWorkspaceRecord(input), - buildProjectPlacement: (cwd) => this.buildProjectPlacement(cwd), - projectRegistry: this.projectRegistry, - syncWorkspaceGitWatchTarget: (cwd, syncOptions) => - this.syncWorkspaceGitWatchTarget(cwd, syncOptions), - workspaceRegistry: this.workspaceRegistry, - archiveProjectRecordIfEmpty: (projectId, archivedAt) => - this.archiveProjectRecordIfEmpty(projectId, archivedAt), - }, - options, - ); + await this.findOrCreateWorkspaceForDirectory(options.repoRoot); + const workspaceDirectory = normalizePersistedWorkspaceId(options.worktreePath); + const basePlacement = await this.buildProjectPlacementForCwd(options.repoRoot); + if (!basePlacement) { + throw new Error(`Workspace not found for repo root ${options.repoRoot}`); + } + + const projectId = Number(basePlacement.projectKey); + if (!Number.isInteger(projectId)) { + throw new Error(`Invalid project id for repo root ${options.repoRoot}`); + } + + const now = new Date().toISOString(); + const existingWorkspace = await this.findWorkspaceByDirectory(workspaceDirectory); + if (!existingWorkspace) { + const workspaceId = await this.workspaceRegistry.insert({ + projectId, + directory: workspaceDirectory, + displayName: options.branchName, + kind: "worktree", + createdAt: now, + updatedAt: now, + archivedAt: null, + }); + const workspace = await this.workspaceRegistry.get(workspaceId); + if (!workspace) { + throw new Error(`Workspace not found after insert: ${workspaceId}`); + } + await this.syncWorkspaceGitWatchTarget(workspace.directory, { isGit: true }); + return workspace; + } + + await this.workspaceRegistry.upsert({ + id: existingWorkspace.id, + projectId, + directory: workspaceDirectory, + displayName: options.branchName, + kind: "worktree", + createdAt: existingWorkspace.createdAt, + updatedAt: now, + archivedAt: null, + }); + await this.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true }); + + if (!existingWorkspace.archivedAt && existingWorkspace.projectId !== projectId) { + const siblingWorkspaces = (await this.workspaceRegistry.list()).filter( + (workspace) => + workspace.projectId === existingWorkspace.projectId && + workspace.id !== existingWorkspace.id && + !workspace.archivedAt, + ); + if (siblingWorkspaces.length === 0) { + await this.projectRegistry.archive(existingWorkspace.projectId, now); + } + } + + return (await this.workspaceRegistry.get(existingWorkspace.id))!; } - private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise { - const existing = await this.workspaceRegistry.get(workspaceId); - if (!existing || existing.archivedAt) { - this.removeWorkspaceGitWatchTarget(workspaceId); + private async archiveWorkspaceRecord(workspaceId: number, archivedAt?: string): Promise { + const existingWorkspace = await this.workspaceRegistry.get(workspaceId); + if (!existingWorkspace || existingWorkspace.archivedAt) { return; } const nextArchivedAt = archivedAt ?? new Date().toISOString(); await this.workspaceRegistry.archive(workspaceId, nextArchivedAt); - this.removeWorkspaceGitWatchTarget(workspaceId); + await this.removeWorkspaceGitWatchTarget(existingWorkspace.directory); const siblingWorkspaces = (await this.workspaceRegistry.list()).filter( - (workspace) => workspace.projectId === existing.projectId && !workspace.archivedAt, + (workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt, ); if (siblingWorkspaces.length === 0) { - await this.projectRegistry.archive(existing.projectId, nextArchivedAt); + await this.projectRegistry.archive(existingWorkspace.projectId, nextArchivedAt); } } @@ -5350,11 +5284,11 @@ export class Session { return; } - const workspaceId = normalizePersistedWorkspaceId(cwd); - const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords(); + const normalizedCwd = normalizePersistedWorkspaceId(cwd); + const persistedWorkspace = await this.findWorkspaceByDirectory(normalizedCwd); const all = await this.listWorkspaceDescriptorsSnapshot(); const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const)); - const workspaceIdsToEmit = new Set([workspaceId, ...changedWorkspaceIds]); + const workspaceIdsToEmit = persistedWorkspace ? [persistedWorkspace.id] : []; for (const nextWorkspaceId of workspaceIdsToEmit) { const workspace = descriptorsByWorkspaceId.get(nextWorkspaceId); @@ -5364,11 +5298,11 @@ export class Session { : null; if ( options?.dedupeGitState && - this.shouldSkipWorkspaceGitWatchUpdate(nextWorkspaceId, nextWorkspace) + this.shouldSkipWorkspaceGitWatchUpdate(normalizedCwd, nextWorkspace) ) { continue; } - this.rememberWorkspaceGitWatchFingerprint(nextWorkspaceId, nextWorkspace); + this.rememberWorkspaceGitWatchFingerprint(normalizedCwd, nextWorkspace); if (!nextWorkspace) { this.bufferOrEmitWorkspaceUpdate(subscription, { @@ -5390,8 +5324,7 @@ export class Session { return; } - const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords(); - const uniqueWorkspaceCwds = new Set(changedWorkspaceIds); + const uniqueWorkspaceCwds = new Set(); for (const cwd of cwds) { const normalized = normalizePersistedWorkspaceId(cwd); if (!normalized) { @@ -5404,19 +5337,22 @@ export class Session { const all = await this.listWorkspaceDescriptorsSnapshot(); const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const)); - for (const workspaceId of uniqueWorkspaceCwds) { - const workspace = descriptorsByWorkspaceId.get(workspaceId); + for (const workspaceCwd of uniqueWorkspaceCwds) { + const persistedWorkspace = await this.findWorkspaceByDirectory(workspaceCwd); + const workspace = persistedWorkspace ? descriptorsByWorkspaceId.get(persistedWorkspace.id) : null; const nextWorkspace = workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter }) ? workspace : null; - this.rememberWorkspaceGitWatchFingerprint(workspaceId, nextWorkspace); + this.rememberWorkspaceGitWatchFingerprint(workspaceCwd, nextWorkspace); if (!nextWorkspace) { - this.bufferOrEmitWorkspaceUpdate(subscription, { - kind: "remove", - id: workspaceId, - }); + if (persistedWorkspace) { + this.bufferOrEmitWorkspaceUpdate(subscription, { + kind: "remove", + id: persistedWorkspace.id, + }); + } continue; } @@ -5508,8 +5444,8 @@ export class Session { } const payload = await this.listFetchWorkspacesEntries(request); - this.primeWorkspaceGitWatchFingerprints(payload.entries); - const snapshotLatestActivityByWorkspaceId = new Map(); + await this.primeWorkspaceGitWatchFingerprints(payload.entries); + const snapshotLatestActivityByWorkspaceId = new Map(); for (const entry of payload.entries) { const parsedLatestActivity = entry.activityAt ? Date.parse(entry.activityAt) @@ -5554,8 +5490,8 @@ export class Session { request: Extract, ): Promise { try { - const workspace = await this.ensureWorkspaceRegistered(request.cwd); - await this.emitWorkspaceUpdateForCwd(workspace.cwd); + const workspace = await this.findOrCreateWorkspaceForDirectory(request.cwd); + await this.emitWorkspaceUpdateForCwd(workspace.directory); const descriptor = await this.describeWorkspaceRecord(workspace); this.emit({ type: "open_project_response", @@ -5628,7 +5564,7 @@ export class Session { } const archivedAt = new Date().toISOString(); await this.archiveWorkspaceRecord(request.workspaceId, archivedAt); - await this.emitWorkspaceUpdateForCwd(existing.cwd); + await this.emitWorkspaceUpdateForCwd(existing.directory); this.emit({ type: "archive_workspace_response", payload: { @@ -5680,7 +5616,7 @@ export class Session { return; } - const project = await this.buildProjectPlacement(agent.cwd); + const project = await this.buildProjectPlacementForCwd(agent.cwd); this.emit({ type: "fetch_agent_response", payload: { requestId, agent, project, error: null }, @@ -5691,105 +5627,37 @@ export class Session { msg: Extract, ): Promise { const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail"); - const projection: TimelineProjectionMode = msg.projection ?? "projected"; const requestedLimit = msg.limit; const limit = requestedLimit ?? (direction === "after" ? 0 : undefined); - const shouldLimitByProjectedWindow = - projection === "canonical" && - direction === "tail" && - typeof requestedLimit === "number" && - requestedLimit > 0; const cursor: AgentTimelineCursor | undefined = msg.cursor ? { - epoch: msg.cursor.epoch, seq: msg.cursor.seq, } : undefined; try { + const agentMode = await this.getAgentMode(msg.agentId); + if (agentMode === "terminal") { + throw new SessionRequestError( + "unsupported_agent_kind", + `Agent ${msg.agentId} is a terminal agent and has no timeline history`, + ); + } const snapshot = await this.ensureAgentLoaded(msg.agentId); const agentPayload = await this.buildAgentPayload(snapshot); - - let timeline = this.agentManager.fetchTimeline(msg.agentId, { + const timeline = await this.agentManager.fetchTimeline(msg.agentId, { direction, cursor, - limit: - shouldLimitByProjectedWindow && typeof requestedLimit === "number" - ? Math.max(1, Math.floor(requestedLimit)) - : limit, + limit, }); - - let hasOlder = timeline.hasOlder; - let hasNewer = timeline.hasNewer; - let startCursor: { epoch: string; seq: number } | null = null; - let endCursor: { epoch: string; seq: number } | null = null; - let entries: ReturnType; - - if (shouldLimitByProjectedWindow) { - const projectedLimit = Math.max(1, Math.floor(requestedLimit)); - let fetchLimit = projectedLimit; - let projectedWindow = selectTimelineWindowByProjectedLimit({ - rows: timeline.rows, - provider: snapshot.provider, - direction, - limit: projectedLimit, - collapseToolLifecycle: false, - }); - - while (timeline.hasOlder) { - const needsMoreProjectedEntries = - projectedWindow.projectedEntries.length < projectedLimit; - const firstLoadedRow = timeline.rows[0]; - const firstSelectedRow = projectedWindow.selectedRows[0]; - const startsAtLoadedBoundary = - firstLoadedRow != null && - firstSelectedRow != null && - firstSelectedRow.seq === firstLoadedRow.seq; - const boundaryIsAssistantChunk = - startsAtLoadedBoundary && firstLoadedRow.item.type === "assistant_message"; - - if (!needsMoreProjectedEntries && !boundaryIsAssistantChunk) { - break; - } - - const maxRows = Math.max(0, timeline.window.maxSeq - timeline.window.minSeq + 1); - const nextFetchLimit = Math.min(maxRows, fetchLimit * 2); - if (nextFetchLimit <= fetchLimit) { - break; - } - - fetchLimit = nextFetchLimit; - timeline = this.agentManager.fetchTimeline(msg.agentId, { - direction, - cursor, - limit: fetchLimit, - }); - projectedWindow = selectTimelineWindowByProjectedLimit({ - rows: timeline.rows, - provider: snapshot.provider, - direction, - limit: projectedLimit, - collapseToolLifecycle: false, - }); - } - - const selectedRows = projectedWindow.selectedRows; - - entries = projectTimelineRows(selectedRows, snapshot.provider, projection); - - if (projectedWindow.minSeq !== null && projectedWindow.maxSeq !== null) { - startCursor = { epoch: timeline.epoch, seq: projectedWindow.minSeq }; - endCursor = { epoch: timeline.epoch, seq: projectedWindow.maxSeq }; - hasOlder = projectedWindow.minSeq > timeline.window.minSeq; - hasNewer = false; - } - } else { - const firstRow = timeline.rows[0]; - const lastRow = timeline.rows[timeline.rows.length - 1]; - startCursor = firstRow ? { epoch: timeline.epoch, seq: firstRow.seq } : null; - endCursor = lastRow ? { epoch: timeline.epoch, seq: lastRow.seq } : null; - entries = projectTimelineRows(timeline.rows, snapshot.provider, projection); - } + const firstRow = timeline.rows[0]; + const lastRow = timeline.rows[timeline.rows.length - 1]; + const entries = timeline.rows.map((row) => ({ + provider: snapshot.provider, + item: row.item, + timestamp: row.timestamp, + seq: row.seq, + })); this.emit({ type: "fetch_agent_timeline_response", @@ -5798,16 +5666,10 @@ export class Session { agentId: msg.agentId, agent: agentPayload, direction, - projection, - epoch: timeline.epoch, - reset: timeline.reset, - staleCursor: timeline.staleCursor, - gap: timeline.gap, - window: timeline.window, - startCursor, - endCursor, - hasOlder, - hasNewer, + startSeq: firstRow?.seq ?? null, + endSeq: lastRow?.seq ?? null, + hasOlder: timeline.hasOlder, + hasNewer: timeline.hasNewer, entries, error: null, }, @@ -5824,14 +5686,8 @@ export class Session { agentId: msg.agentId, agent: null, direction, - projection, - epoch: "", - reset: false, - staleCursor: false, - gap: false, - window: { minSeq: 0, maxSeq: 0, nextSeq: 0 }, - startCursor: null, - endCursor: null, + startSeq: null, + endSeq: null, hasOlder: false, hasNewer: false, entries: [], @@ -5859,6 +5715,22 @@ export class Session { } try { + const structuredSendRejection = await this.agentManager.getStructuredSendRejection( + resolved.agentId, + ); + if (structuredSendRejection) { + this.emit({ + type: "send_agent_message_response", + payload: { + requestId: msg.requestId, + agentId: resolved.agentId, + accepted: false, + error: structuredSendRejection, + }, + }); + return; + } + const agentId = resolved.agentId; await this.unarchiveAgentState(agentId); @@ -7279,7 +7151,7 @@ export class Session { private emitTerminalsChangedSnapshot(input: { cwd: string; - terminals: Array<{ id: string; name: string }>; + terminals: Array<{ id: string; name: string; title?: string }>; }): void { this.emit({ type: "terminals_changed", @@ -7290,6 +7162,23 @@ export class Session { }); } + private filterStandaloneTerminals(terminals: T[]): T[] { + return terminals.filter((terminal) => !this.agentManager.isTerminalBoundToAgent(terminal.id)); + } + + private toTerminalInfo(terminal: Pick): { + id: string; + name: string; + title?: string; + } { + const title = terminal.getTitle(); + return { + id: terminal.id, + name: terminal.name, + ...(title ? { title } : {}), + }; + } + private handleTerminalsChanged(event: TerminalsChangedEvent): void { if (!this.subscribedTerminalDirectories.has(event.cwd)) { return; @@ -7297,9 +7186,10 @@ export class Session { this.emitTerminalsChangedSnapshot({ cwd: event.cwd, - terminals: event.terminals.map((terminal) => ({ + terminals: this.filterStandaloneTerminals(event.terminals).map((terminal) => ({ id: terminal.id, name: terminal.name, + ...(terminal.title ? { title: terminal.title } : {}), })), }); } @@ -7319,7 +7209,7 @@ export class Session { } try { - const terminals = await this.terminalManager.getTerminals(cwd); + const terminals = this.filterStandaloneTerminals(await this.terminalManager.getTerminals(cwd)); for (const terminal of terminals) { this.ensureTerminalExitSubscription(terminal); } @@ -7330,10 +7220,7 @@ export class Session { this.emitTerminalsChangedSnapshot({ cwd, - terminals: terminals.map((terminal) => ({ - id: terminal.id, - name: terminal.name, - })), + terminals: terminals.map((terminal) => this.toTerminalInfo(terminal)), }); } catch (error) { this.sessionLogger.warn({ err: error, cwd }, "Failed to emit initial terminal snapshot"); @@ -7354,10 +7241,11 @@ export class Session { } try { - const terminals = + const terminals = this.filterStandaloneTerminals( typeof msg.cwd === "string" ? await this.terminalManager.getTerminals(msg.cwd) - : await this.getAllTerminalSessions(); + : await this.getAllTerminalSessions(), + ); for (const terminal of terminals) { this.ensureTerminalExitSubscription(terminal); } @@ -7365,7 +7253,7 @@ export class Session { type: "list_terminals_response", payload: { ...(msg.cwd ? { cwd: msg.cwd } : {}), - terminals: terminals.map((t) => ({ id: t.id, name: t.name })), + terminals: terminals.map((terminal) => this.toTerminalInfo(terminal)), requestId: msg.requestId, }, }); @@ -7382,6 +7270,46 @@ export class Session { } } + private async createOrResumeAgentTerminal(agentId: string): Promise { + if (!this.terminalManager) { + throw new Error("Terminal manager not available"); + } + + const existingTerminal = this.agentManager.getTerminalSessionForAgent(agentId); + if (existingTerminal) { + return existingTerminal; + } + + const record = await this.agentStorage.get(agentId); + if (!record || record.internal) { + throw new Error(`Agent not found: ${agentId}`); + } + if (!record.config?.terminal) { + throw new Error(`Agent ${agentId} is not a terminal agent`); + } + + const timestamps = extractTimestamps(record); + const launched = await this.agentManager.launchTerminalAgent(buildSessionConfig(record), agentId, { + persistence: record.persistence ?? null, + createdAt: timestamps.createdAt, + updatedAt: timestamps.updatedAt, + lastUserMessageAt: timestamps.lastUserMessageAt, + labels: timestamps.labels, + attention: { + requiresAttention: record.requiresAttention ?? false, + attentionReason: record.attentionReason ?? null, + attentionTimestamp: record.attentionTimestamp ? new Date(record.attentionTimestamp) : null, + }, + }); + const terminal = launched.terminalId + ? this.terminalManager.getTerminal(launched.terminalId) + : null; + if (!terminal) { + throw new Error(`Terminal not available for agent ${agentId}`); + } + return terminal; + } + private async getAllTerminalSessions(): Promise { if (!this.terminalManager) { return []; @@ -7408,15 +7336,41 @@ export class Session { } try { + if (msg.agentId) { + const terminal = await this.createOrResumeAgentTerminal(msg.agentId); + this.ensureTerminalExitSubscription(terminal); + this.emit({ + type: "create_terminal_response", + payload: { + terminal: { + id: terminal.id, + name: terminal.name, + cwd: terminal.cwd, + ...(terminal.getTitle() ? { title: terminal.getTitle() } : {}), + }, + error: null, + requestId: msg.requestId, + }, + }); + return; + } + const session = await this.terminalManager.createTerminal({ cwd: msg.cwd, name: msg.name, + command: msg.command, + args: msg.args, }); this.ensureTerminalExitSubscription(session); this.emit({ type: "create_terminal_response", payload: { - terminal: { id: session.id, name: session.name, cwd: session.cwd }, + terminal: { + id: session.id, + name: session.name, + cwd: session.cwd, + ...(session.getTitle() ? { title: session.getTitle() } : {}), + }, error: null, requestId: msg.requestId, }, @@ -7665,7 +7619,8 @@ export class Session { if (this.activeTerminalStreams.get(slot) !== activeStream) { return; } - if (message.type === "snapshot") { + if (message.type === "snapshot" || message.type === "titleChange") { + activeStream.needsSnapshot = true; this.trySendTerminalSnapshot(activeStream); return; } diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 77818a928..46a086c60 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -2,6 +2,11 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { Session } from "./session.js"; +import { + createPersistedProjectRecord, + createPersistedWorkspaceRecord, +} from "./workspace-registry.js"; const { watchCalls, watchMock } = vi.hoisted(() => { const hoistedWatchCalls: Array<{ @@ -46,15 +51,15 @@ vi.mock("./checkout-git-utils.js", () => ({ resolveCheckoutGitDir: resolveCheckoutGitDirMock, })); -import { Session } from "./session.js"; - function createSessionForWorkspaceGitWatchTests(): { session: Session; emitted: Array<{ type: string; payload: unknown }>; + projects: Map>; + workspaces: Map>; } { const emitted: Array<{ type: string; payload: unknown }> = []; - const projects = new Map(); - const workspaces = new Map(); + const projects = new Map>(); + const workspaces = new Map>(); const logger = { child: () => logger, trace: vi.fn(), @@ -84,46 +89,48 @@ function createSessionForWorkspaceGitWatchTests(): { initialize: async () => {}, existsOnDisk: async () => true, list: async () => Array.from(projects.values()), - get: async (projectId: string) => projects.get(projectId) ?? null, + get: async (id: number) => projects.get(id) ?? null, + insert: async () => 0, upsert: async (record: any) => { - projects.set(record.projectId, record); + projects.set(record.id, record); }, - archive: async (projectId: string, archivedAt: string) => { - const existing = projects.get(projectId); + archive: async (id: number, archivedAt: string) => { + const existing = projects.get(id); if (!existing) { return; } - projects.set(projectId, { + projects.set(id, { ...existing, archivedAt, updatedAt: archivedAt, }); }, - remove: async (projectId: string) => { - projects.delete(projectId); + remove: async (id: number) => { + projects.delete(id); }, } as any, workspaceRegistry: { initialize: async () => {}, existsOnDisk: async () => true, list: async () => Array.from(workspaces.values()), - get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null, + get: async (id: number) => workspaces.get(id) ?? null, + insert: async () => 0, upsert: async (record: any) => { - workspaces.set(record.workspaceId, record); + workspaces.set(record.id, record); }, - archive: async (workspaceId: string, archivedAt: string) => { - const existing = workspaces.get(workspaceId); + archive: async (id: number, archivedAt: string) => { + const existing = workspaces.get(id); if (!existing) { return; } - workspaces.set(workspaceId, { + workspaces.set(id, { ...existing, archivedAt, updatedAt: archivedAt, }); }, - remove: async (workspaceId: string) => { - workspaces.delete(workspaceId); + remove: async (id: number) => { + workspaces.delete(id); }, } as any, checkoutDiffManager: { @@ -148,12 +155,43 @@ function createSessionForWorkspaceGitWatchTests(): { terminalManager: null, }) as any; - session.listAgentPayloads = async () => []; + (session as any).listAgentPayloads = async () => []; - return { - session, - emitted, - }; + return { session, emitted, projects, workspaces }; +} + +function seedGitWorkspace(input: { + projects: Map>; + workspaces: Map>; + projectId: number; + workspaceId: number; + cwd: string; + name: string; +}) { + input.projects.set( + input.projectId, + createPersistedProjectRecord({ + id: input.projectId, + directory: "/tmp/repo", + displayName: "repo", + kind: "git", + gitRemote: "https://github.com/acme/repo.git", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ); + input.workspaces.set( + input.workspaceId, + createPersistedWorkspaceRecord({ + id: input.workspaceId, + projectId: input.projectId, + directory: input.cwd, + displayName: input.name, + kind: "checkout", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ); } describe("workspace git watch targets", () => { @@ -170,21 +208,17 @@ describe("workspace git watch targets", () => { }); test("debounces watcher events and skips unchanged branch/diff snapshots", async () => { - const { session, emitted } = createSessionForWorkspaceGitWatchTests(); + const { session, emitted, projects, workspaces } = createSessionForWorkspaceGitWatchTests(); const sessionAny = session as any; - - sessionAny.buildProjectPlacement = async (cwd: string) => ({ - projectKey: cwd, - projectName: "repo", - checkout: { - cwd, - isGit: true, - currentBranch: "main", - remoteUrl: "https://github.com/acme/repo.git", - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, + seedGitWorkspace({ + projects, + workspaces, + projectId: 1, + workspaceId: 10, + cwd: "/tmp/repo", + name: "main", }); + resolveCheckoutGitDirMock.mockResolvedValue("/tmp/repo/.git"); sessionAny.workspaceUpdatesSubscription = { subscriptionId: "sub-1", @@ -192,15 +226,14 @@ describe("workspace git watch targets", () => { isBootstrapping: false, pendingUpdatesByWorkspaceId: new Map(), }; - sessionAny.reconcileActiveWorkspaceRecords = async () => new Set(); let descriptor = { - id: "/tmp/repo", - projectId: "/tmp/repo", + id: 10, + projectId: 1, projectDisplayName: "repo", projectRootPath: "/tmp/repo", projectKind: "git", - workspaceKind: "local_checkout", + workspaceKind: "checkout", name: "main", status: "done", activityAt: null, @@ -209,8 +242,7 @@ describe("workspace git watch targets", () => { sessionAny.listWorkspaceDescriptorsSnapshot = async () => [descriptor]; - await sessionAny.ensureWorkspaceRegistered("/tmp/repo"); - sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]); + await sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]); expect(watchCalls.map((entry) => entry.path).sort()).toEqual([ "/tmp/repo/.git/HEAD", @@ -237,7 +269,7 @@ describe("workspace git watch targets", () => { expect(workspaceUpdates[0]?.payload).toMatchObject({ kind: "upsert", workspace: { - id: "/tmp/repo", + id: 10, name: "renamed-branch", diffStat: { additions: 1, deletions: 0 }, }, @@ -256,29 +288,45 @@ describe("workspace git watch targets", () => { }); test("closes watchers when a workspace is archived and when the session closes", async () => { - const { session } = createSessionForWorkspaceGitWatchTests(); + const { session, projects, workspaces } = createSessionForWorkspaceGitWatchTests(); const sessionAny = session as any; - sessionAny.buildProjectPlacement = async (cwd: string) => ({ - projectKey: cwd, - projectName: path.basename(cwd), - checkout: { - cwd, - isGit: true, - currentBranch: "main", - remoteUrl: "https://github.com/acme/repo.git", - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, + seedGitWorkspace({ + projects, + workspaces, + projectId: 2, + workspaceId: 20, + cwd: "/tmp/repo-one", + name: "main", + }); + seedGitWorkspace({ + projects, + workspaces, + projectId: 3, + workspaceId: 30, + cwd: "/tmp/repo-two", + name: "main", }); resolveCheckoutGitDirMock.mockImplementation(async (cwd: string) => path.join(cwd, ".git")); - await sessionAny.ensureWorkspaceRegistered("/tmp/repo-one"); + await sessionAny.primeWorkspaceGitWatchFingerprints([ + { + id: 20, + projectId: 2, + projectDisplayName: "repo-one", + projectRootPath: "/tmp/repo-one", + projectKind: "git", + workspaceKind: "checkout", + name: "main", + status: "done", + activityAt: null, + }, + ]); expect(sessionAny.workspaceGitWatchTargets.size).toBe(1); expect(watchCalls).toHaveLength(2); - await sessionAny.archiveWorkspaceRecord("/tmp/repo-one", "2026-03-21T00:00:00.000Z"); + await sessionAny.archiveWorkspaceRecord(20, "2026-03-21T00:00:00.000Z"); expect(sessionAny.workspaceGitWatchTargets.size).toBe(0); expect(watchCalls.every((entry) => entry.close.mock.calls.length === 1)).toBe(true); @@ -286,7 +334,19 @@ describe("workspace git watch targets", () => { watchCalls.length = 0; watchMock.mockClear(); - await sessionAny.ensureWorkspaceRegistered("/tmp/repo-two"); + await sessionAny.primeWorkspaceGitWatchFingerprints([ + { + id: 30, + projectId: 3, + projectDisplayName: "repo-two", + projectRootPath: "/tmp/repo-two", + projectKind: "git", + workspaceKind: "checkout", + name: "main", + status: "done", + activityAt: null, + }, + ]); expect(sessionAny.workspaceGitWatchTargets.size).toBe(1); expect(watchCalls).toHaveLength(2); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 4f85436ca..df91016e8 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -9,6 +9,7 @@ import { createPersistedProjectRecord, createPersistedWorkspaceRecord, } from "./workspace-registry.js"; +import type { StoredAgentRecord } from "./agent/agent-storage.js"; function makeAgent(input: { id: string; @@ -38,6 +39,7 @@ function makeAgent(input: { supportsMcpServers: true, supportsReasoningStream: true, supportsToolInvocations: true, + supportsTerminalMode: false, }, currentModeId: null, availableModes: [], @@ -61,7 +63,17 @@ function makeAgent(input: { }; } -function createSessionForWorkspaceTests(): Session { +function createSessionForWorkspaceTests(): { + session: Session; + emitted: Array<{ type: string; payload: unknown }>; + projects: Map>; + workspaces: Map>; +} { + const emitted: Array<{ type: string; payload: unknown }> = []; + const projects = new Map>(); + const workspaces = new Map>(); + let nextProjectId = 1; + let nextWorkspaceId = 1; const logger = { child: () => logger, trace: vi.fn(), @@ -73,7 +85,7 @@ function createSessionForWorkspaceTests(): Session { const session = new Session({ clientId: "test-client", - onMessage: vi.fn(), + onMessage: (message) => emitted.push(message as any), logger: logger as any, downloadTokenStore: {} as any, pushTokenStore: {} as any, @@ -82,9 +94,6 @@ function createSessionForWorkspaceTests(): Session { subscribe: () => () => {}, listAgents: () => [], getAgent: () => null, - archiveAgent: async () => ({ archivedAt: new Date().toISOString() }), - clearAgentAttention: async () => {}, - notifyAgentState: () => {}, } as any, agentStorage: { list: async () => [], @@ -93,20 +102,58 @@ function createSessionForWorkspaceTests(): Session { projectRegistry: { initialize: async () => {}, existsOnDisk: async () => true, - list: async () => [], - get: async () => null, - upsert: async () => {}, - archive: async () => {}, - remove: async () => {}, + list: async () => Array.from(projects.values()), + get: async (id: number) => projects.get(id) ?? null, + insert: async (record: Omit, "id">) => { + const id = nextProjectId++; + projects.set(id, createPersistedProjectRecord({ id, ...record })); + return id; + }, + upsert: async (record: ReturnType) => { + projects.set(record.id, record); + }, + archive: async (id: number, archivedAt: string) => { + const existing = projects.get(id); + if (!existing) { + return; + } + projects.set(id, { + ...existing, + archivedAt, + updatedAt: archivedAt, + }); + }, + remove: async (id: number) => { + projects.delete(id); + }, } as any, workspaceRegistry: { initialize: async () => {}, existsOnDisk: async () => true, - list: async () => [], - get: async () => null, - upsert: async () => {}, - archive: async () => {}, - remove: async () => {}, + list: async () => Array.from(workspaces.values()), + get: async (id: number) => workspaces.get(id) ?? null, + insert: async (record: Omit, "id">) => { + const id = nextWorkspaceId++; + workspaces.set(id, createPersistedWorkspaceRecord({ id, ...record })); + return id; + }, + upsert: async (record: ReturnType) => { + workspaces.set(record.id, record); + }, + archive: async (id: number, archivedAt: string) => { + const existing = workspaces.get(id); + if (!existing) { + return; + } + workspaces.set(id, { + ...existing, + archivedAt, + updatedAt: archivedAt, + }); + }, + remove: async (id: number) => { + workspaces.delete(id); + }, } as any, checkoutDiffManager: { subscribe: async () => ({ @@ -129,21 +176,131 @@ function createSessionForWorkspaceTests(): Session { tts: null, terminalManager: null, }) as any; - return session; + + return { session, emitted, projects, workspaces }; +} + +function seedProject(options: { + projects: Map>; + id: number; + directory: string; + displayName: string; + kind?: "git" | "directory"; + gitRemote?: string | null; +}) { + const record = createPersistedProjectRecord({ + id: options.id, + directory: options.directory, + displayName: options.displayName, + kind: options.kind ?? "directory", + gitRemote: options.gitRemote ?? null, + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + options.projects.set(record.id, record); + return record; +} + +function seedWorkspace(options: { + workspaces: Map>; + id: number; + projectId: number; + directory: string; + displayName: string; + kind?: "checkout" | "worktree"; +}) { + const record = createPersistedWorkspaceRecord({ + id: options.id, + projectId: options.projectId, + directory: options.directory, + displayName: options.displayName, + kind: options.kind ?? "checkout", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + options.workspaces.set(record.id, record); + return record; +} + +function createStoredTerminalAgentRecord(input: { + id: string; + cwd: string; +}): StoredAgentRecord { + return { + id: input.id, + provider: "codex", + cwd: input.cwd, + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + lastActivityAt: "2026-03-01T12:00:00.000Z", + lastUserMessageAt: null, + title: null, + labels: {}, + lastStatus: "closed", + lastModeId: null, + config: { + terminal: true, + }, + runtimeInfo: { + provider: "codex", + sessionId: null, + }, + persistence: { + provider: "codex", + sessionId: input.id, + nativeHandle: input.id, + }, + lastError: null, + terminalExit: { + command: "codex", + message: "Terminal session ended", + exitCode: 0, + signal: null, + outputLines: [], + }, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + internal: false, + archivedAt: null, + }; +} + +function createTempGitRepo(options?: { + remoteUrl?: string; + branchName?: string; +}): { tempDir: string; repoDir: string } { + const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-workspace-git-"))); + const repoDir = path.join(tempDir, "repo"); + execSync(`mkdir -p ${repoDir}`); + execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" }); + execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" }); + writeFileSync(path.join(repoDir, "file.txt"), "hello\n"); + execSync("git add .", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" }); + if (options?.remoteUrl) { + execSync(`git remote add origin ${JSON.stringify(options.remoteUrl)}`, { + cwd: repoDir, + stdio: "pipe", + }); + } + return { tempDir, repoDir }; } describe("workspace aggregation", () => { - test("archive emits an authoritative agent_update upsert for subscribed clients", async () => { + test("archive request emits agent_archived using the snapshot archive flow", async () => { const emitted: Array<{ type: string; payload: any }> = []; + const archivedAt = "2026-04-01T00:00:00.000Z"; const archivedRecord = { id: "agent-1", provider: "codex", cwd: "/tmp/repo", createdAt: "2026-03-30T15:00:00.000Z", - updatedAt: "2026-03-30T15:00:00.000Z", + updatedAt: archivedAt, lastActivityAt: "2026-03-30T15:00:00.000Z", lastUserMessageAt: null, - lastStatus: "idle", + lastStatus: "idle" as const, lastModeId: null, runtimeInfo: null, config: { @@ -156,7 +313,7 @@ describe("workspace aggregation", () => { requiresAttention: false, attentionReason: null, attentionTimestamp: null, - archivedAt: null, + archivedAt, }; const logger = { @@ -168,6 +325,7 @@ describe("workspace aggregation", () => { error: vi.fn(), }; + const closeAgent = vi.fn(async () => undefined); const session = new Session({ clientId: "test-client", onMessage: (message) => emitted.push(message as any), @@ -178,21 +336,14 @@ describe("workspace aggregation", () => { agentManager: { subscribe: () => () => {}, listAgents: () => [], - getAgent: () => null, - archiveAgent: async () => { - const archivedAt = new Date().toISOString(); - Object.assign(archivedRecord, { - archivedAt, - updatedAt: archivedAt, - }); - return { archivedAt }; - }, + getAgent: (agentId: string) => (agentId === "agent-1" ? { id: agentId } : null), + archiveSnapshot: vi.fn(async () => archivedRecord), + closeAgent, clearAgentAttention: async () => {}, - notifyAgentState: () => {}, } as any, agentStorage: { - list: async () => [archivedRecord], - get: async (agentId: string) => (agentId === archivedRecord.id ? archivedRecord : null), + list: async () => [], + get: async () => null, } as any, projectRegistry: { initialize: async () => {}, @@ -234,40 +385,17 @@ describe("workspace aggregation", () => { terminalManager: null, }) as any; - session.agentUpdatesSubscription = { - subscriptionId: "sub-agents", - filter: { includeArchived: true }, - isBootstrapping: false, - pendingUpdatesByAgentId: new Map(), - }; - session.buildProjectPlacement = async (cwd: string) => ({ - projectKey: cwd, - projectName: "repo", - checkout: { - cwd, - isGit: false, - currentBranch: null, - remoteUrl: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, - }); + session.interruptAgentIfRunning = vi.fn(); await session.handleArchiveAgentRequest("agent-1", "req-archive"); - const update = emitted.find((message) => message.type === "agent_update"); - expect(update?.payload).toMatchObject({ - kind: "upsert", - agent: { - id: "agent-1", - archivedAt: expect.any(String), - }, - }); + expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-1"); + expect(closeAgent).toHaveBeenCalledWith("agent-1"); expect( emitted.find((message) => message.type === "agent_archived")?.payload, ).toMatchObject({ agentId: "agent-1", - archivedAt: expect.any(String), + archivedAt, requestId: "req-archive", }); }); @@ -283,37 +411,7 @@ describe("workspace aggregation", () => { warn: vi.fn(), error: vi.fn(), }; - const archivedRecord = { - id: "agent-1", - provider: "codex", - cwd: "/tmp/repo", - model: null, - thinkingOptionId: null, - effectiveThinkingOptionId: null, - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - lastUserMessageAt: null, - status: "idle", - capabilities: { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, - }, - currentModeId: null, - availableModes: [], - pendingPermissions: [], - persistence: null, - runtimeInfo: { provider: "codex", sessionId: null }, - title: null, - labels: {}, - requiresAttention: false, - attentionReason: null, - attentionTimestamp: null, - archivedAt: null, - }; + const session = new Session({ clientId: "test-client", onMessage: (message) => emitted.push(message as any), @@ -325,20 +423,32 @@ describe("workspace aggregation", () => { subscribe: () => () => {}, listAgents: () => [], getAgent: (agentId: string) => (agentId === "agent-1" ? { id: agentId } : null), - archiveAgent: async () => ({ archivedAt }), + archiveSnapshot: async () => ({ + id: "agent-1", + provider: "codex", + cwd: "/tmp/repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: archivedAt, + lastActivityAt: "2026-03-01T12:00:00.000Z", + lastUserMessageAt: null, + lastStatus: "idle" as const, + lastModeId: null, + runtimeInfo: null, + config: null, + persistence: null, + title: null, + labels: {}, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + archivedAt, + }), + closeAgent: async () => undefined, clearAgentAttention: async () => {}, - notifyAgentState: () => {}, } as any, agentStorage: { list: async () => [], - get: async (agentId: string) => { - if (agentId !== "agent-1") { - return null; - } - archivedRecord.archivedAt = archivedAt; - archivedRecord.updatedAt = archivedAt; - return archivedRecord; - }, + get: async () => null, } as any, projectRegistry: { initialize: async () => {}, @@ -383,24 +493,6 @@ describe("workspace aggregation", () => { } as any, }) as any; - session.agentUpdatesSubscription = { - subscriptionId: "sub-agents", - filter: { includeArchived: true }, - isBootstrapping: false, - pendingUpdatesByAgentId: new Map(), - }; - session.buildProjectPlacement = async (cwd: string) => ({ - projectKey: cwd, - projectName: "repo", - checkout: { - cwd, - isGit: false, - currentBranch: null, - remoteUrl: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, - }); session.interruptAgentIfRunning = vi.fn(); await session.handleMessage({ @@ -419,13 +511,6 @@ describe("workspace aggregation", () => { terminals: [{ terminalId: "term-1", success: true }], requestId: "req-close-items", }); - expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({ - kind: "upsert", - agent: { - id: "agent-1", - archivedAt, - }, - }); }); test("close_items_request continues after an archive failure", async () => { @@ -439,15 +524,7 @@ describe("workspace aggregation", () => { error: vi.fn(), }; const archivedAt = "2026-04-01T00:00:00.000Z"; - const goodRecord = { - ...makeAgent({ - id: "agent-good", - cwd: "/tmp/repo", - status: "idle", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - archivedAt: null as string | null, - }; + const session = new Session({ clientId: "test-client", onMessage: (message) => emitted.push(message as any), @@ -460,25 +537,37 @@ describe("workspace aggregation", () => { listAgents: () => [], getAgent: (agentId: string) => agentId === "agent-bad" || agentId === "agent-good" ? { id: agentId } : null, - archiveAgent: async (agentId: string) => { + archiveSnapshot: async (agentId: string) => { if (agentId === "agent-bad") { throw new Error("archive failed"); } - return { archivedAt }; + return { + id: "agent-good", + provider: "codex", + cwd: "/tmp/repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: archivedAt, + lastActivityAt: "2026-03-01T12:00:00.000Z", + lastUserMessageAt: null, + lastStatus: "idle" as const, + lastModeId: null, + runtimeInfo: null, + config: null, + persistence: null, + title: null, + labels: {}, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + archivedAt, + }; }, + closeAgent: async () => undefined, clearAgentAttention: async () => {}, - notifyAgentState: () => {}, } as any, agentStorage: { list: async () => [], - get: async (agentId: string) => { - if (agentId !== "agent-good") { - return null; - } - goodRecord.archivedAt = archivedAt; - goodRecord.updatedAt = archivedAt; - return goodRecord; - }, + get: async () => null, } as any, projectRegistry: { initialize: async () => {}, @@ -523,24 +612,6 @@ describe("workspace aggregation", () => { } as any, }) as any; - session.agentUpdatesSubscription = { - subscriptionId: "sub-agents", - filter: { includeArchived: true }, - isBootstrapping: false, - pendingUpdatesByAgentId: new Map(), - }; - session.buildProjectPlacement = async (cwd: string) => ({ - projectKey: cwd, - projectName: "repo", - checkout: { - cwd, - isGit: false, - currentBranch: null, - remoteUrl: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, - }); session.interruptAgentIfRunning = vi.fn(); await session.handleMessage({ @@ -560,135 +631,11 @@ describe("workspace aggregation", () => { terminals: [{ terminalId: "term-1", success: true }], requestId: "req-close-best-effort", }); - expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({ - kind: "upsert", - agent: { - id: "agent-good", - archivedAt, - }, - }); expect(sessionLogger.warn).toHaveBeenCalled(); }); - test("non-git workspace uses deterministic directory name and no unknown branch fallback", async () => { - const session = createSessionForWorkspaceTests() as any; - session.workspaceRegistry.list = async () => [ - createPersistedWorkspaceRecord({ - workspaceId: "/tmp/non-git", - projectId: "/tmp/non-git", - cwd: "/tmp/non-git", - kind: "directory", - displayName: "non-git", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ]; - session.listAgentPayloads = async () => [ - makeAgent({ - id: "a1", - cwd: "/tmp/non-git", - status: "idle", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ]; - const result = await session.listFetchWorkspacesEntries({ - type: "fetch_workspaces_request", - requestId: "req-1", - }); - - expect(result.entries).toHaveLength(1); - expect(result.entries[0]?.name).toBe("non-git"); - expect(result.entries[0]?.name).not.toBe("Unknown branch"); - }); - - test("git branch workspace uses branch as canonical name", async () => { - const session = createSessionForWorkspaceTests() as any; - session.workspaceRegistry.list = async () => [ - createPersistedWorkspaceRecord({ - workspaceId: "/tmp/repo-branch", - projectId: "/tmp/repo-branch", - cwd: "/tmp/repo-branch", - kind: "local_checkout", - displayName: "feature/name-from-server", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ]; - session.listAgentPayloads = async () => [ - makeAgent({ - id: "a1", - cwd: "/tmp/repo-branch", - status: "running", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ]; - session.buildProjectPlacement = async (cwd: string) => ({ - projectKey: cwd, - projectName: "repo-branch", - checkout: { - cwd, - isGit: true, - currentBranch: "feature/name-from-server", - remoteUrl: "https://github.com/acme/repo-branch.git", - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, - }); - const result = await session.listFetchWorkspacesEntries({ - type: "fetch_workspaces_request", - requestId: "req-branch", - }); - - expect(result.entries).toHaveLength(1); - expect(result.entries[0]?.name).toBe("feature/name-from-server"); - }); - - test("branch/detached policies and dominant status bucket are deterministic", async () => { - const session = createSessionForWorkspaceTests() as any; - session.workspaceRegistry.list = async () => [ - createPersistedWorkspaceRecord({ - workspaceId: "/tmp/repo", - projectId: "/tmp/repo", - cwd: "/tmp/repo", - kind: "local_checkout", - displayName: "repo", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ]; - session.listAgentPayloads = async () => [ - makeAgent({ - id: "a1", - cwd: "/tmp/repo", - status: "running", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - makeAgent({ - id: "a2", - cwd: "/tmp/repo", - status: "error", - updatedAt: "2026-03-01T12:01:00.000Z", - }), - makeAgent({ - id: "a3", - cwd: "/tmp/repo", - status: "idle", - updatedAt: "2026-03-01T12:02:00.000Z", - pendingPermissions: 1, - }), - ]; - const result = await session.listFetchWorkspacesEntries({ - type: "fetch_workspaces_request", - requestId: "req-2", - }); - - expect(result.entries).toHaveLength(1); - expect(result.entries[0]?.name).toBe("repo"); - expect(result.entries[0]?.status).toBe("needs_input"); - }); - - test("workspace update stream keeps persisted workspace visible after agents stop", async () => { - const emitted: Array<{ type: string; payload: unknown }> = []; + test("terminal agents reject timeline fetch without reloading as chat sessions", async () => { + const emitted: Array<{ type: string; payload: any }> = []; const logger = { child: () => logger, trace: vi.fn(), @@ -697,6 +644,9 @@ describe("workspace aggregation", () => { warn: vi.fn(), error: vi.fn(), }; + const resumeAgentFromPersistence = vi.fn(); + const launchTerminalAgent = vi.fn(); + const hydrateTimelineFromProvider = vi.fn(); const session = new Session({ clientId: "test-client", @@ -709,10 +659,16 @@ describe("workspace aggregation", () => { subscribe: () => () => {}, listAgents: () => [], getAgent: () => null, + resumeAgentFromPersistence, + launchTerminalAgent, + hydrateTimelineFromProvider, } as any, agentStorage: { list: async () => [], - get: async () => null, + get: async (agentId: string) => + agentId === "terminal-1" + ? createStoredTerminalAgentRecord({ id: agentId, cwd: "/tmp/repo" }) + : null, } as any, projectRegistry: { initialize: async () => {}, @@ -732,20 +688,6 @@ describe("workspace aggregation", () => { archive: async () => {}, remove: async () => {}, } as any, - checkoutDiffManager: { - subscribe: async () => ({ - initial: { cwd: "/tmp", files: [], error: null }, - unsubscribe: () => {}, - }), - scheduleRefreshForCwd: () => {}, - getMetrics: () => ({ - checkoutDiffTargetCount: 0, - checkoutDiffSubscriptionCount: 0, - checkoutDiffWatcherCount: 0, - checkoutDiffFallbackRefreshTargetCount: 0, - }), - dispose: () => {}, - } as any, createAgentMcpTransport: async () => { throw new Error("not used"); }, @@ -754,56 +696,181 @@ describe("workspace aggregation", () => { terminalManager: null, }) as any; - session.workspaceUpdatesSubscription = { + await session.handleMessage({ + type: "fetch_agent_timeline_request", + requestId: "req-terminal-timeline", + agentId: "terminal-1", + }); + + expect(resumeAgentFromPersistence).not.toHaveBeenCalled(); + expect(launchTerminalAgent).not.toHaveBeenCalled(); + expect(hydrateTimelineFromProvider).not.toHaveBeenCalled(); + expect(emitted).toContainEqual( + expect.objectContaining({ + type: "fetch_agent_timeline_response", + payload: expect.objectContaining({ + requestId: "req-terminal-timeline", + agentId: "terminal-1", + error: "Agent terminal-1 is a terminal agent and has no timeline history", + }), + }), + ); + }); + + test("uses persisted workspace names and stable status aggregation", async () => { + const { session, projects, workspaces } = createSessionForWorkspaceTests(); + seedProject({ + projects, + id: 1, + directory: "/tmp/repo", + displayName: "repo", + kind: "directory", + }); + seedWorkspace({ + workspaces, + id: 10, + projectId: 1, + directory: "/tmp/repo", + displayName: "repo", + }); + + (session as any).listAgentPayloads = async () => [ + makeAgent({ + id: "a1", + cwd: "/tmp/repo", + status: "running", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + makeAgent({ + id: "a2", + cwd: "/tmp/repo", + status: "idle", + updatedAt: "2026-03-01T12:01:00.000Z", + pendingPermissions: 1, + }), + ]; + + const result = await (session as any).listFetchWorkspacesEntries({ + type: "fetch_workspaces_request", + requestId: "req-1", + }); + + expect(result.entries).toEqual([ + expect.objectContaining({ + id: 10, + projectId: 1, + name: "repo", + projectKind: "directory", + workspaceKind: "checkout", + status: "needs_input", + }), + ]); + }); + + test("keeps persisted git worktree display names", async () => { + const { session, projects, workspaces } = createSessionForWorkspaceTests(); + seedProject({ + projects, + id: 2, + directory: "/tmp/repo", + displayName: "repo", + kind: "git", + gitRemote: "https://github.com/acme/repo.git", + }); + seedWorkspace({ + workspaces, + id: 20, + projectId: 2, + directory: "/tmp/repo/.paseo/worktrees/feature-name", + displayName: "feature-name", + kind: "worktree", + }); + + (session as any).listAgentPayloads = async () => [ + makeAgent({ + id: "a1", + cwd: "/tmp/repo/.paseo/worktrees/feature-name", + status: "running", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ]; + + const result = await (session as any).listFetchWorkspacesEntries({ + type: "fetch_workspaces_request", + requestId: "req-branch", + }); + + expect(result.entries[0]).toMatchObject({ + id: 20, + name: "feature-name", + projectKind: "git", + workspaceKind: "worktree", + }); + }); + + test("workspace update stream keeps persisted workspace visible after agents stop", async () => { + const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests(); + seedProject({ + projects, + id: 3, + directory: "/tmp/repo", + displayName: "repo", + }); + seedWorkspace({ + workspaces, + id: 30, + projectId: 3, + directory: "/tmp/repo", + displayName: "repo", + }); + + (session as any).workspaceUpdatesSubscription = { subscriptionId: "sub-1", filter: undefined, isBootstrapping: false, pendingUpdatesByWorkspaceId: new Map(), }; - session.reconcileActiveWorkspaceRecords = async () => new Set(); - - session.listWorkspaceDescriptorsSnapshot = async () => [ + (session as any).listWorkspaceDescriptorsSnapshot = async () => [ { - id: "/tmp/repo", - projectId: "/tmp/repo", + id: 30, + projectId: 3, projectDisplayName: "repo", projectRootPath: "/tmp/repo", - projectKind: "non_git", - workspaceKind: "directory", + projectKind: "directory", + workspaceKind: "checkout", name: "repo", status: "running", activityAt: "2026-03-01T12:00:00.000Z", }, ]; - await session.emitWorkspaceUpdateForCwd("/tmp/repo"); + await (session as any).emitWorkspaceUpdateForCwd("/tmp/repo"); - session.listWorkspaceDescriptorsSnapshot = async () => [ + (session as any).listWorkspaceDescriptorsSnapshot = async () => [ { - id: "/tmp/repo", - projectId: "/tmp/repo", + id: 30, + projectId: 3, projectDisplayName: "repo", projectRootPath: "/tmp/repo", - projectKind: "non_git", - workspaceKind: "directory", + projectKind: "directory", + workspaceKind: "checkout", name: "repo", status: "done", activityAt: null, }, ]; - await session.emitWorkspaceUpdateForCwd("/tmp/repo"); + await (session as any).emitWorkspaceUpdateForCwd("/tmp/repo"); const workspaceUpdates = emitted.filter((message) => message.type === "workspace_update"); expect(workspaceUpdates).toHaveLength(2); - expect((workspaceUpdates[0] as any).payload.kind).toBe("upsert"); expect((workspaceUpdates[1] as any).payload).toEqual({ kind: "upsert", workspace: { - id: "/tmp/repo", - projectId: "/tmp/repo", + id: 30, + projectId: 3, projectDisplayName: "repo", projectRootPath: "/tmp/repo", - projectKind: "non_git", - workspaceKind: "directory", + projectKind: "directory", + workspaceKind: "checkout", name: "repo", status: "done", activityAt: null, @@ -811,9 +878,8 @@ describe("workspace aggregation", () => { }); }); - test("create paseo worktree request returns a registered workspace descriptor", async () => { - const emitted: Array<{ type: string; payload: unknown }> = []; - const session = createSessionForWorkspaceTests() as any; + test("create paseo worktree request inserts a workspace under the existing project", async () => { + const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests(); const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-worktree-test-"))); const repoDir = path.join(tempDir, "repo"); const paseoHome = path.join(tempDir, "paseo-home"); @@ -825,382 +891,277 @@ describe("workspace aggregation", () => { execSync("git add .", { cwd: repoDir, stdio: "pipe" }); execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" }); - const workspaces = new Map(); - const projects = new Map(); - session.paseoHome = paseoHome; - session.workspaceRegistry.get = async (workspaceId: string) => - workspaces.get(workspaceId) ?? null; - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); - session.workspaceRegistry.upsert = async (record: any) => { - workspaces.set(record.workspaceId, record); - }; - session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; - session.projectRegistry.list = async () => Array.from(projects.values()); - session.projectRegistry.upsert = async (record: any) => { - projects.set(record.projectId, record); - }; - session.emit = (message: { type: string; payload: unknown }) => { - emitted.push(message); - }; + (session as any).paseoHome = paseoHome; + seedProject({ + projects, + id: 4, + directory: repoDir, + displayName: "repo", + kind: "git", + gitRemote: "https://github.com/acme/repo.git", + }); + seedWorkspace({ + workspaces, + id: 40, + projectId: 4, + directory: repoDir, + displayName: "main", + kind: "checkout", + }); + try { - await session.handleCreatePaseoWorktreeRequest({ + await (session as any).handleCreatePaseoWorktreeRequest({ type: "create_paseo_worktree_request", cwd: repoDir, worktreeSlug: "worktree-123", requestId: "req-worktree", }); + + const response = emitted.find( + (message) => message.type === "create_paseo_worktree_response", + ) as + | { type: "create_paseo_worktree_response"; payload: any } + | undefined; + + expect(response?.payload.error).toBeNull(); + expect(response?.payload.workspace).toMatchObject({ + projectDisplayName: "repo", + projectKind: "git", + workspaceKind: "worktree", + name: "worktree-123", + status: "done", + }); + expect(response?.payload.workspace?.id).toEqual(expect.any(Number)); + const persistedWorkspace = workspaces.get(response!.payload.workspace.id); + expect(persistedWorkspace?.directory).toContain(path.join("worktree-123")); + // The worktree directory is created asynchronously in the background after + // the response is sent, so we only verify the DB record here. + expect(workspaces.has(response!.payload.workspace.id)).toBe(true); + expect(projects.has(response?.payload.workspace?.projectId)).toBe(true); } finally { rmSync(tempDir, { recursive: true, force: true }); } - - const response = emitted.find((message) => message.type === "create_paseo_worktree_response") as - | { type: "create_paseo_worktree_response"; payload: any } - | undefined; - - expect(response?.payload.error).toBeNull(); - expect(response?.payload.workspace).toMatchObject({ - projectDisplayName: "repo", - projectKind: "git", - workspaceKind: "worktree", - name: "worktree-123", - status: "done", - }); - expect(response?.payload.workspace?.id).toContain(path.join("worktree-123")); - expect(workspaces.has(response?.payload.workspace?.id)).toBe(true); - expect(projects.has(response?.payload.workspace?.projectId)).toBe(true); }); - test("workspace update fanout for multiple cwd values is deduplicated", async () => { - const emitted: Array<{ type: string; payload: unknown }> = []; - const session = createSessionForWorkspaceTests() as any; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-dedup", - filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - }; - session.reconcileActiveWorkspaceRecords = async () => - new Set(["/tmp/repo", "/tmp/repo/worktree"]); - session.listWorkspaceDescriptorsSnapshot = async () => [ - { - id: "/tmp/repo", - projectId: "/tmp/repo", - projectDisplayName: "repo", - projectRootPath: "/tmp/repo", - projectKind: "git", - workspaceKind: "local_checkout", - name: "main", - status: "done", - activityAt: null, - }, - { - id: "/tmp/repo/worktree", - projectId: "/tmp/repo", - projectDisplayName: "repo", - projectRootPath: "/tmp/repo", - projectKind: "git", - workspaceKind: "worktree", - name: "feature", - status: "running", - activityAt: "2026-03-01T12:00:00.000Z", - }, - ]; - session.onMessage = (message: { type: string; payload: unknown }) => { - emitted.push(message); - }; - - await session.emitWorkspaceUpdateForCwd("/tmp/repo/worktree"); - - const workspaceUpdates = emitted.filter( - (message) => message.type === "workspace_update", - ) as any[]; - expect(workspaceUpdates).toHaveLength(2); - expect(workspaceUpdates.map((entry) => entry.payload.kind)).toEqual(["upsert", "upsert"]); - expect(workspaceUpdates.map((entry) => entry.payload.workspace.id).sort()).toEqual([ - "/tmp/repo", - "/tmp/repo/worktree", - ]); - }); - - test("open_project_request registers a workspace before any agent exists", async () => { - const emitted: Array<{ type: string; payload: unknown }> = []; - const session = createSessionForWorkspaceTests() as any; - const projects = new Map>(); - const workspaces = new Map>(); - - session.emit = (message: any) => emitted.push(message); - session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; - session.projectRegistry.upsert = async ( - record: ReturnType, - ) => { - projects.set(record.projectId, record); - }; - session.workspaceRegistry.get = async (workspaceId: string) => - workspaces.get(workspaceId) ?? null; - session.workspaceRegistry.upsert = async ( - record: ReturnType, - ) => { - workspaces.set(record.workspaceId, record); - }; - session.projectRegistry.list = async () => Array.from(projects.values()); - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); - session.buildProjectPlacement = async (cwd: string) => ({ - projectKey: cwd, - projectName: "repo", - checkout: { - cwd, - isGit: false, - currentBranch: null, - remoteUrl: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, - }); - - await session.handleMessage({ - type: "open_project_request", - cwd: "/tmp/repo", - requestId: "req-open", - }); - - expect(workspaces.get("/tmp/repo")).toBeTruthy(); - const response = emitted.find((message) => message.type === "open_project_response") as any; - expect(response?.payload.error).toBeNull(); - expect(response?.payload.workspace?.id).toBe("/tmp/repo"); - }); - - test("archive_workspace_request hides non-destructive workspace records", async () => { - const emitted: Array<{ type: string; payload: unknown }> = []; - const session = createSessionForWorkspaceTests() as any; - const workspace = createPersistedWorkspaceRecord({ - workspaceId: "/tmp/repo", - projectId: "/tmp/repo", - cwd: "/tmp/repo", - kind: "directory", + test("archive_workspace_request archives the persisted workspace row", async () => { + const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests(); + seedProject({ + projects, + id: 5, + directory: "/tmp/repo", + displayName: "repo", + }); + seedWorkspace({ + workspaces, + id: 50, + projectId: 5, + directory: "/tmp/repo", displayName: "repo", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", }); - session.emit = (message: any) => emitted.push(message); - session.workspaceRegistry.get = async () => workspace; - session.workspaceRegistry.archive = async (_workspaceId: string, archivedAt: string) => { - workspace.archivedAt = archivedAt; - }; - session.workspaceRegistry.list = async () => [workspace]; - session.projectRegistry.archive = async () => {}; - - await session.handleMessage({ + await (session as any).handleMessage({ type: "archive_workspace_request", - workspaceId: "/tmp/repo", + workspaceId: 50, requestId: "req-archive", }); - expect(workspace.archivedAt).toBeTruthy(); + expect(workspaces.get(50)?.archivedAt).toBeTruthy(); const response = emitted.find( (message) => message.type === "archive_workspace_response", ) as any; - expect(response?.payload.error).toBeNull(); + expect(response?.payload).toMatchObject({ + workspaceId: 50, + error: null, + }); }); - test("opening a new worktree reconciles older local workspaces into the remote project", async () => { - const emitted: Array<{ type: string; payload: unknown }> = []; - const session = createSessionForWorkspaceTests() as any; - const projects = new Map>(); - const workspaces = new Map>(); + test("create_agent_request uses workspaceId as the execution authority", async () => { + const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests(); + seedProject({ + projects, + id: 5, + directory: "/tmp/repo", + displayName: "repo", + kind: "git", + }); + seedWorkspace({ + workspaces, + id: 50, + projectId: 5, + directory: "/tmp/repo/.paseo/worktrees/feature", + displayName: "feature", + kind: "worktree", + }); - const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-workspace-reconcile-"))); - const mainWorkspaceId = path.join(tempDir, "inkwell"); - const worktreeWorkspaceId = path.join(mainWorkspaceId, ".paseo", "worktrees", "feature-a"); - const localProjectId = mainWorkspaceId; - const remoteProjectId = "remote:github.com/zimakki/inkwell"; + const createdAgent = makeAgent({ + id: "agent-1", + cwd: "/tmp/repo/.paseo/worktrees/feature", + status: "idle", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const createAgent = vi.fn(async () => createdAgent as any); - execSync(`mkdir -p ${JSON.stringify(worktreeWorkspaceId)}`); + (session as any).agentManager = { + createAgent, + getAgent: vi.fn(() => createdAgent as any), + }; + (session as any).forwardAgentUpdate = vi.fn(async () => undefined); + (session as any).getAgentPayloadById = vi.fn(async () => createdAgent); + (session as any).buildAgentSessionConfig = vi.fn(async (config: any) => ({ + sessionConfig: config, + worktreeConfig: null, + })); - projects.set( - localProjectId, - createPersistedProjectRecord({ - projectId: localProjectId, - rootPath: mainWorkspaceId, - kind: "git", - displayName: "inkwell", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ); - workspaces.set( - mainWorkspaceId, - createPersistedWorkspaceRecord({ - workspaceId: mainWorkspaceId, - projectId: localProjectId, - cwd: mainWorkspaceId, - kind: "local_checkout", - displayName: "main", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ); - - session.emit = (message: any) => emitted.push(message); - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-reconcile", - filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - }; - session.listAgentPayloads = async () => []; - session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; - session.projectRegistry.list = async () => Array.from(projects.values()); - session.projectRegistry.upsert = async ( - record: ReturnType, - ) => { - projects.set(record.projectId, record); - }; - session.projectRegistry.archive = async (projectId: string, archivedAt: string) => { - const existing = projects.get(projectId); - if (!existing) return; - projects.set(projectId, { ...existing, archivedAt, updatedAt: archivedAt }); - }; - session.workspaceRegistry.get = async (workspaceId: string) => - workspaces.get(workspaceId) ?? null; - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); - session.workspaceRegistry.upsert = async ( - record: ReturnType, - ) => { - workspaces.set(record.workspaceId, record); - }; - session.buildProjectPlacement = async (cwd: string) => ({ - projectKey: remoteProjectId, - projectName: "zimakki/inkwell", - checkout: { - cwd, - isGit: true, - currentBranch: cwd === mainWorkspaceId ? "main" : "feature-a", - remoteUrl: "https://github.com/zimakki/inkwell.git", - isPaseoOwnedWorktree: cwd !== mainWorkspaceId, - mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId, + await (session as any).handleCreateAgentRequest({ + type: "create_agent_request", + requestId: "req-create-agent", + workspaceId: 50, + config: { + provider: "codex", + cwd: "/tmp/repo", + modeId: "default", }, + labels: {}, + }); + + expect(createAgent).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: "/tmp/repo/.paseo/worktrees/feature", + }), + undefined, + expect.objectContaining({ + workspaceId: 50, + }), + ); + const response = emitted.find((message) => message.type === "status") as any; + expect(response?.payload).toMatchObject({ + status: "agent_created", + requestId: "req-create-agent", + agent: { + cwd: "/tmp/repo/.paseo/worktrees/feature", + }, + }); + }); + + test("create_agent_request fails for an unknown workspaceId", async () => { + const { session, emitted } = createSessionForWorkspaceTests(); + const createAgent = vi.fn(); + + (session as any).agentManager = { + createAgent, + getAgent: vi.fn(() => null), + }; + (session as any).buildAgentSessionConfig = vi.fn(async (config: any) => ({ + sessionConfig: config, + worktreeConfig: null, + })); + + await (session as any).handleCreateAgentRequest({ + type: "create_agent_request", + requestId: "req-create-agent-fail", + workspaceId: 999, + config: { + provider: "codex", + cwd: "/tmp/repo", + modeId: "default", + }, + labels: {}, + }); + + expect(createAgent).not.toHaveBeenCalled(); + const response = emitted.find((message) => message.type === "status") as any; + expect(response?.payload).toMatchObject({ + status: "agent_create_failed", + requestId: "req-create-agent-fail", + }); + expect((response?.payload as any)?.error).toContain("Workspace not found: 999"); + }); + + test("open_project_request creates git projects with GitHub owner/repo and branch names", async () => { + const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests(); + const { tempDir, repoDir } = createTempGitRepo({ + remoteUrl: "git@github.com:acme/repo.git", + branchName: "feature/test-branch", }); try { - await session.handleMessage({ + await (session as any).handleOpenProjectRequest({ type: "open_project_request", - cwd: worktreeWorkspaceId, - requestId: "req-open-worktree", + cwd: repoDir, + requestId: "req-open-git", }); - expect(workspaces.get(mainWorkspaceId)?.projectId).toBe(remoteProjectId); - expect(workspaces.get(worktreeWorkspaceId)?.projectId).toBe(remoteProjectId); - expect(projects.get(localProjectId)?.archivedAt).toBeTruthy(); - - const workspaceUpdates = emitted.filter( - (message) => message.type === "workspace_update", - ) as any[]; - expect(workspaceUpdates).toHaveLength(2); - expect(workspaceUpdates.map((message) => message.payload.workspace.id).sort()).toEqual([ - mainWorkspaceId, - worktreeWorkspaceId, + expect(Array.from(projects.values())).toEqual([ + expect.objectContaining({ + directory: repoDir, + kind: "git", + displayName: "acme/repo", + gitRemote: "git@github.com:acme/repo.git", + }), ]); - expect( - workspaceUpdates.every( - (message) => message.payload.workspace.projectId === remoteProjectId, - ), - ).toBe(true); + expect(Array.from(workspaces.values())).toEqual([ + expect.objectContaining({ + directory: repoDir, + displayName: "feature/test-branch", + kind: "checkout", + }), + ]); + + const response = emitted.find((message) => message.type === "open_project_response") as any; + expect(response?.payload).toMatchObject({ + error: null, + workspace: { + projectDisplayName: "acme/repo", + projectKind: "git", + name: "feature/test-branch", + workspaceKind: "checkout", + }, + }); } finally { rmSync(tempDir, { recursive: true, force: true }); } }); - test("fetch_workspaces_request reconciles remote URL changes for existing workspaces", async () => { - const session = createSessionForWorkspaceTests() as any; - const projects = new Map>(); - const workspaces = new Map>(); - - const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-workspace-fetch-"))); - const mainWorkspaceId = path.join(tempDir, "inkwell"); - const worktreeWorkspaceId = path.join(mainWorkspaceId, ".paseo", "worktrees", "feature-a"); - const oldProjectId = "remote:github.com/old-owner/inkwell"; - const newProjectId = "remote:github.com/new-owner/inkwell"; - - execSync(`mkdir -p ${JSON.stringify(worktreeWorkspaceId)}`); - - projects.set( - oldProjectId, - createPersistedProjectRecord({ - projectId: oldProjectId, - rootPath: mainWorkspaceId, - kind: "git", - displayName: "old-owner/inkwell", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ); - - for (const [workspaceId, displayName] of [ - [mainWorkspaceId, "main"], - [worktreeWorkspaceId, "feature-a"], - ] as const) { - workspaces.set( - workspaceId, - createPersistedWorkspaceRecord({ - workspaceId, - projectId: oldProjectId, - cwd: workspaceId, - kind: workspaceId === mainWorkspaceId ? "local_checkout" : "worktree", - displayName, - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ); - } - - session.listAgentPayloads = async () => []; - session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; - session.projectRegistry.list = async () => Array.from(projects.values()); - session.projectRegistry.upsert = async ( - record: ReturnType, - ) => { - projects.set(record.projectId, record); - }; - session.projectRegistry.archive = async (projectId: string, archivedAt: string) => { - const existing = projects.get(projectId); - if (!existing) return; - projects.set(projectId, { ...existing, archivedAt, updatedAt: archivedAt }); - }; - session.workspaceRegistry.get = async (workspaceId: string) => - workspaces.get(workspaceId) ?? null; - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); - session.workspaceRegistry.upsert = async ( - record: ReturnType, - ) => { - workspaces.set(record.workspaceId, record); - }; - session.buildProjectPlacement = async (cwd: string) => ({ - projectKey: newProjectId, - projectName: "new-owner/inkwell", - checkout: { - cwd, - isGit: true, - currentBranch: cwd === mainWorkspaceId ? "main" : "feature-a", - remoteUrl: "https://github.com/new-owner/inkwell.git", - isPaseoOwnedWorktree: cwd !== mainWorkspaceId, - mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId, - }, - }); + test("open_project_request treats non-git directories as directory projects", async () => { + const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests(); + const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-workspace-dir-"))); + const projectDir = path.join(tempDir, "plain-dir"); + execSync(`mkdir -p ${projectDir}`); + writeFileSync(path.join(projectDir, "README.md"), "hello\n"); try { - const result = await session.listFetchWorkspacesEntries({ - type: "fetch_workspaces_request", - requestId: "req-fetch-reconcile", + await (session as any).handleOpenProjectRequest({ + type: "open_project_request", + cwd: projectDir, + requestId: "req-open-dir", }); - expect(result.entries.map((entry: any) => entry.projectId)).toEqual([ - newProjectId, - newProjectId, + expect(Array.from(projects.values())).toEqual([ + expect.objectContaining({ + directory: projectDir, + kind: "directory", + displayName: "plain-dir", + gitRemote: null, + }), ]); - expect(workspaces.get(mainWorkspaceId)?.projectId).toBe(newProjectId); - expect(workspaces.get(worktreeWorkspaceId)?.projectId).toBe(newProjectId); - expect(projects.get(oldProjectId)?.archivedAt).toBeTruthy(); + expect(Array.from(workspaces.values())).toEqual([ + expect.objectContaining({ + directory: projectDir, + displayName: "plain-dir", + kind: "checkout", + }), + ]); + + const response = emitted.find((message) => message.type === "open_project_response") as any; + expect(response?.payload).toMatchObject({ + error: null, + workspace: { + projectDisplayName: "plain-dir", + projectKind: "directory", + name: "plain-dir", + workspaceKind: "checkout", + }, + }); } finally { rmSync(tempDir, { recursive: true, force: true }); } diff --git a/packages/server/src/server/snapshot-mutation-ownership.test.ts b/packages/server/src/server/snapshot-mutation-ownership.test.ts new file mode 100644 index 000000000..5369038b8 --- /dev/null +++ b/packages/server/src/server/snapshot-mutation-ownership.test.ts @@ -0,0 +1,184 @@ +import os from "node:os"; +import path from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { Session } from "./session.js"; +import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; +import { projects, workspaces } from "./db/schema.js"; + +describe("snapshot mutation ownership boundary", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("daemon live mutations write one durable snapshot through the manager-owned path", async () => { + const daemonHandle = await createTestPaseoDaemon(); + const cwd = mkdtempSync(path.join(os.tmpdir(), "snapshot-owner-live-")); + + try { + const db = (daemonHandle.daemon.agentStorage as any).db; + const [projectRow] = await db + .insert(projects) + .values({ + directory: cwd, + displayName: "test-project", + kind: "directory", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) + .returning({ id: projects.id }); + const [workspaceRow] = await db + .insert(workspaces) + .values({ + projectId: projectRow!.id, + directory: cwd, + displayName: "test-workspace", + kind: "checkout", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) + .returning({ id: workspaces.id }); + + const snapshot = await daemonHandle.daemon.agentManager.createAgent( + { + provider: "codex", + cwd, + model: "gpt-5.2-codex", + }, + undefined, + { workspaceId: workspaceRow!.id }, + ); + await daemonHandle.daemon.agentManager.flush(); + + const applySnapshotSpy = vi.spyOn(daemonHandle.daemon.agentStorage, "applySnapshot"); + + await daemonHandle.daemon.agentManager.setAgentModel(snapshot.id, "gpt-5.4"); + await daemonHandle.daemon.agentManager.flush(); + + expect(applySnapshotSpy).toHaveBeenCalledTimes(1); + + const persisted = await daemonHandle.daemon.agentStorage.get(snapshot.id); + expect(persisted?.config?.model).toBe("gpt-5.4"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + await daemonHandle.close(); + } + }); + + test("session runtime flows delegate snapshot mutations to agent manager without direct storage writes", async () => { + const onMessage = vi.fn(); + const archiveSnapshot = vi.fn(async (_agentId: string, archivedAt: string) => ({ + id: "agent-1", + provider: "codex", + cwd: "/tmp/project", + createdAt: "2026-03-24T00:00:00.000Z", + updatedAt: archivedAt, + title: null, + labels: {}, + lastStatus: "idle" as const, + config: null, + persistence: null, + archivedAt, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + })); + const unarchiveSnapshot = vi.fn(async () => true); + const unarchiveSnapshotByHandle = vi.fn(async () => undefined); + const updateAgentMetadata = vi.fn(async () => undefined); + const directStorageWrite = vi.fn(async () => { + throw new Error("Session should not write snapshots directly"); + }); + + const logger = { + child: () => logger, + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + + const session = new Session({ + clientId: "test-client", + onMessage, + logger: logger as any, + downloadTokenStore: {} as any, + pushTokenStore: {} as any, + paseoHome: "/tmp/paseo-test", + agentManager: { + subscribe: () => () => {}, + listAgents: () => [], + getAgent: () => null, + archiveSnapshot, + unarchiveSnapshot, + unarchiveSnapshotByHandle, + updateAgentMetadata, + } as any, + agentStorage: { + list: async () => [], + get: async () => null, + applySnapshot: directStorageWrite, + upsert: directStorageWrite, + } as any, + projectRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [], + get: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + } as any, + workspaceRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [], + get: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + } as any, + createAgentMcpTransport: async () => { + throw new Error("not used"); + }, + stt: null, + tts: null, + terminalManager: null, + }) as any; + + const archiveResult = await session.archiveAgentState("agent-1"); + expect(archiveSnapshot).toHaveBeenCalledTimes(1); + expect(archiveResult.archivedAt).toBeTruthy(); + + await session.unarchiveAgentState("agent-1"); + expect(unarchiveSnapshot).toHaveBeenCalledWith("agent-1"); + + const handle = { provider: "codex", sessionId: "session-1" }; + await session.unarchiveAgentByHandle(handle); + expect(unarchiveSnapshotByHandle).toHaveBeenCalledWith(handle); + + await session.handleUpdateAgentRequest( + "agent-1", + "Renamed agent", + { lane: "phase-1a" }, + "req-1", + ); + expect(updateAgentMetadata).toHaveBeenCalledWith("agent-1", { + title: "Renamed agent", + labels: { lane: "phase-1a" }, + }); + expect(onMessage).toHaveBeenCalledWith({ + type: "update_agent_response", + payload: { + requestId: "req-1", + agentId: "agent-1", + accepted: true, + error: null, + }, + }); + + expect(directStorageWrite).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index b873de66d..a473cd645 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -30,6 +30,7 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = { supportsMcpServers: false, supportsReasoningStream: true, supportsToolInvocations: true, + supportsTerminalMode: false, }; type Deferred = { @@ -929,6 +930,9 @@ export function createTestAgentClients(): Record { return { claude: new FakeAgentClient("claude"), codex: new FakeAgentClient("codex"), + gemini: new FakeAgentClient("gemini"), + amp: new FakeAgentClient("amp"), + aider: new FakeAgentClient("aider"), opencode: new FakeAgentClient("opencode"), }; } diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index 5fe0502ee..1c233d5d3 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -63,6 +63,7 @@ function createServer(agentManagerOverrides?: Record) { const agentManager = { setAgentAttentionCallback: vi.fn(), getAgent: vi.fn(() => null), + getLastAssistantMessage: vi.fn(async () => null), ...agentManagerOverrides, }; @@ -109,22 +110,20 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => { vi.clearAllMocks(); }); - it("uses assistant preview text for push notifications with markdown removed", () => { + it("uses assistant preview text for push notifications with markdown removed", async () => { + const getLastAssistantMessage = vi.fn( + async () => "**Done**. Updated `README.md` and [link](https://example.com).", + ); const { server } = createServer({ getAgent: vi.fn(() => ({ config: { title: null }, cwd: "/tmp/worktree", - timeline: [ - { - type: "assistant_message", - text: "**Done**. Updated `README.md` and [link](https://example.com).", - }, - ], pendingPermissions: new Map(), })), + getLastAssistantMessage, }); - (server as any).broadcastAgentAttention({ + await (server as any).broadcastAgentAttention({ agentId: "agent-1", provider: "claude", reason: "finished", @@ -139,30 +138,28 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => { reason: "finished", }, }); + expect(getLastAssistantMessage).toHaveBeenCalledWith("agent-1"); }); - it("sends push notifications regardless of UI label presence", () => { + it("sends push notifications regardless of UI label presence", async () => { + const getLastAssistantMessage = vi.fn(async () => "Done."); const { server } = createServer({ getAgent: vi.fn(() => ({ config: { title: null }, cwd: "/tmp/worktree", labels: {}, - timeline: [ - { - type: "assistant_message", - text: "Done.", - }, - ], pendingPermissions: new Map(), })), + getLastAssistantMessage, }); - (server as any).broadcastAgentAttention({ + await (server as any).broadcastAgentAttention({ agentId: "agent-2", provider: "claude", reason: "finished", }); expect(pushMocks.sendPush).toHaveBeenCalledTimes(1); + expect(getLastAssistantMessage).toHaveBeenCalledWith("agent-2"); }); }); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index ab9d0f787..eb65bfea8 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -4,7 +4,7 @@ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { join } from "path"; import { hostname as getHostname } from "node:os"; import type { AgentManager } from "./agent/agent-manager.js"; -import type { AgentStorage } from "./agent/agent-storage.js"; +import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js"; import type { DownloadTokenStore } from "./file-download/token-store.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import type pino from "pino"; @@ -42,7 +42,6 @@ import { } from "./agent-attention-policy.js"; import { buildAgentAttentionNotificationPayload, - findLatestAssistantMessageFromTimeline, findLatestPermissionRequest, } from "../shared/agent-attention-notification.js"; @@ -70,6 +69,7 @@ function createNoopProjectRegistry(): ProjectRegistry { existsOnDisk: async () => true, list: async () => [], get: async () => null, + insert: async () => 0, upsert: async () => {}, archive: async () => {}, remove: async () => {}, @@ -82,6 +82,7 @@ function createNoopWorkspaceRegistry(): WorkspaceRegistry { existsOnDisk: async () => true, list: async () => [], get: async () => null, + insert: async () => 0, upsert: async () => {}, archive: async () => {}, remove: async () => {}, @@ -227,7 +228,7 @@ export class VoiceAssistantWebSocketServer { private readonly serverId: string; private readonly daemonVersion: string; private readonly agentManager: AgentManager; - private readonly agentStorage: AgentStorage; + private readonly agentStorage: AgentSnapshotStore; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; private readonly chatService: FileBackedChatService; @@ -283,7 +284,7 @@ export class VoiceAssistantWebSocketServer { logger: pino.Logger, serverId: string, agentManager: AgentManager, - agentStorage: AgentStorage, + agentStorage: AgentSnapshotStore, downloadTokenStore: DownloadTokenStore, paseoHome: string, createAgentMcpTransport: AgentMcpTransportFactory, @@ -355,7 +356,9 @@ export class VoiceAssistantWebSocketServer { this.pushService = new PushService(pushLogger, this.pushTokenStore); this.agentManager.setAgentAttentionCallback((params) => { - this.broadcastAgentAttention(params); + void this.broadcastAgentAttention(params).catch((err) => { + this.logger.warn({ err, agentId: params.agentId }, "Failed to broadcast agent attention"); + }); }); const { allowedOrigins, allowedHosts } = wsConfig; @@ -1319,11 +1322,11 @@ export class VoiceAssistantWebSocketServer { }; } - private broadcastAgentAttention(params: { + private async broadcastAgentAttention(params: { agentId: string; provider: AgentProvider; reason: "finished" | "error" | "permission"; - }): void { + }): Promise { const clientEntries: Array<{ ws: WebSocketLike; state: ClientAttentionState; @@ -1338,11 +1341,12 @@ export class VoiceAssistantWebSocketServer { const allStates = clientEntries.map((e) => e.state); const agent = this.agentManager.getAgent(params.agentId); + const assistantMessage = await this.agentManager.getLastAssistantMessage(params.agentId); const notification = buildAgentAttentionNotificationPayload({ reason: params.reason, serverId: this.serverId, agentId: params.agentId, - assistantMessage: agent ? findLatestAssistantMessageFromTimeline(agent.timeline) : null, + assistantMessage, permissionRequest: agent ? findLatestPermissionRequest(agent.pendingPermissions) : null, }); diff --git a/packages/server/src/server/workspace-git-metadata.ts b/packages/server/src/server/workspace-git-metadata.ts new file mode 100644 index 000000000..e3c3466c6 --- /dev/null +++ b/packages/server/src/server/workspace-git-metadata.ts @@ -0,0 +1,82 @@ +import { execSync } from "child_process"; +import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js"; + +export type WorkspaceGitMetadata = { + projectKind: "git" | "directory"; + projectDisplayName: string; + workspaceDisplayName: string; + gitRemote: string | null; +}; + +export function readGitCommand(cwd: string, command: string): string | null { + try { + const output = execSync(command, { + cwd, + env: READ_ONLY_GIT_ENV, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + const trimmed = output.trim(); + return trimmed.length > 0 ? trimmed : null; + } catch { + return null; + } +} + +export function parseGitHubRepoFromRemote(remoteUrl: string): string | null { + let cleaned = remoteUrl.trim(); + if (!cleaned) { + return null; + } + + if (cleaned.startsWith("git@github.com:")) { + cleaned = cleaned.slice("git@github.com:".length); + } else if (cleaned.startsWith("https://github.com/")) { + cleaned = cleaned.slice("https://github.com/".length); + } else if (cleaned.startsWith("http://github.com/")) { + cleaned = cleaned.slice("http://github.com/".length); + } else { + const marker = "github.com/"; + const markerIndex = cleaned.indexOf(marker); + if (markerIndex === -1) { + return null; + } + cleaned = cleaned.slice(markerIndex + marker.length); + } + + if (cleaned.endsWith(".git")) { + cleaned = cleaned.slice(0, -".git".length); + } + + if (!cleaned.includes("/")) { + return null; + } + + return cleaned; +} + +export function detectWorkspaceGitMetadata( + cwd: string, + directoryName: string, +): WorkspaceGitMetadata { + const gitDir = readGitCommand(cwd, "git rev-parse --git-dir"); + if (!gitDir) { + return { + projectKind: "directory", + projectDisplayName: directoryName, + workspaceDisplayName: directoryName, + gitRemote: null, + }; + } + + const gitRemote = readGitCommand(cwd, "git config --get remote.origin.url"); + const githubRepo = gitRemote ? parseGitHubRepoFromRemote(gitRemote) : null; + const branchName = readGitCommand(cwd, "git symbolic-ref --short HEAD"); + + return { + projectKind: "git", + projectDisplayName: githubRepo ?? directoryName, + workspaceDisplayName: branchName ?? directoryName, + gitRemote, + }; +} diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts new file mode 100644 index 000000000..8dabd051a --- /dev/null +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -0,0 +1,417 @@ +import { execSync } from "node:child_process"; +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, test, vi, afterEach } from "vitest"; +import { + createPersistedProjectRecord, + createPersistedWorkspaceRecord, +} from "./workspace-registry.js"; +import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js"; +import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js"; + +function createTestRegistries() { + const projects = new Map(); + const workspaces = new Map(); + let nextProjectId = 1; + let nextWorkspaceId = 1; + + const projectRegistry = { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => Array.from(projects.values()), + get: async (id: number) => projects.get(id) ?? null, + insert: async (record: Omit) => { + const id = nextProjectId++; + projects.set(id, createPersistedProjectRecord({ id, ...record })); + return id; + }, + upsert: async (record: PersistedProjectRecord) => { + projects.set(record.id, record); + }, + archive: async (id: number, archivedAt: string) => { + const existing = projects.get(id); + if (existing) { + projects.set(id, { ...existing, archivedAt, updatedAt: archivedAt }); + } + }, + remove: async (id: number) => { + projects.delete(id); + }, + }; + + const workspaceRegistry = { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => Array.from(workspaces.values()), + get: async (id: number) => workspaces.get(id) ?? null, + insert: async (record: Omit) => { + const id = nextWorkspaceId++; + workspaces.set(id, createPersistedWorkspaceRecord({ id, ...record })); + return id; + }, + upsert: async (record: PersistedWorkspaceRecord) => { + workspaces.set(record.id, record); + }, + archive: async (id: number, archivedAt: string) => { + const existing = workspaces.get(id); + if (existing) { + workspaces.set(id, { ...existing, archivedAt, updatedAt: archivedAt }); + } + }, + remove: async (id: number) => { + workspaces.delete(id); + }, + }; + + return { projects, workspaces, projectRegistry, workspaceRegistry }; +} + +function createTestLogger() { + const logger = { + child: () => logger, + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + return logger as any; +} + +function createTempGitRepo(prefix: string): string { + const raw = mkdtempSync(path.join(tmpdir(), prefix)); + const dir = realpathSync(raw); + execSync("git init -b main", { cwd: dir, stdio: "ignore" }); + execSync('git config user.email "test@test.com"', { cwd: dir, stdio: "ignore" }); + execSync('git config user.name "Test"', { cwd: dir, stdio: "ignore" }); + execSync("git config commit.gpgsign false", { cwd: dir, stdio: "ignore" }); + writeFileSync(path.join(dir, "README.md"), "# Test\n"); + execSync("git add .", { cwd: dir, stdio: "ignore" }); + execSync('git commit -m "init"', { cwd: dir, stdio: "ignore" }); + return dir; +} + +const timestamp = "2025-01-01T00:00:00.000Z"; + +describe("WorkspaceReconciliationService", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } + tempDirs.length = 0; + }); + + test("archives workspaces whose directories no longer exist", async () => { + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + + projects.set( + 1, + createPersistedProjectRecord({ + id: 1, + directory: "/tmp/does-not-exist-reconcile-test", + kind: "directory", + displayName: "ghost", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + workspaces.set( + 1, + createPersistedWorkspaceRecord({ + id: 1, + projectId: 1, + directory: "/tmp/does-not-exist-reconcile-test", + kind: "checkout", + displayName: "ghost", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + }); + + const result = await service.runOnce(); + + expect(result.changesApplied.length).toBeGreaterThanOrEqual(1); + const wsChange = result.changesApplied.find((c) => c.kind === "workspace_archived"); + expect(wsChange).toBeDefined(); + expect(workspaces.get(1)!.archivedAt).toBeTruthy(); + }); + + test("archives orphaned projects after all workspaces are archived", async () => { + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + + projects.set( + 1, + createPersistedProjectRecord({ + id: 1, + directory: "/tmp/does-not-exist-reconcile-orphan", + kind: "directory", + displayName: "orphan", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + workspaces.set( + 1, + createPersistedWorkspaceRecord({ + id: 1, + projectId: 1, + directory: "/tmp/does-not-exist-reconcile-orphan", + kind: "checkout", + displayName: "orphan", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + }); + + const result = await service.runOnce(); + + const projChange = result.changesApplied.find((c) => c.kind === "project_archived"); + expect(projChange).toBeDefined(); + expect(projects.get(1)!.archivedAt).toBeTruthy(); + }); + + test("updates project kind when a directory becomes a git repo", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "reconcile-git-init-")); + const resolved = realpathSync(dir); + tempDirs.push(resolved); + writeFileSync(path.join(resolved, "README.md"), "# Test\n"); + + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + + projects.set( + 1, + createPersistedProjectRecord({ + id: 1, + directory: resolved, + kind: "directory", + displayName: path.basename(resolved), + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + workspaces.set( + 1, + createPersistedWorkspaceRecord({ + id: 1, + projectId: 1, + directory: resolved, + kind: "checkout", + displayName: path.basename(resolved), + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + + // Initialize as git repo + execSync("git init -b main", { cwd: resolved, stdio: "ignore" }); + execSync('git config user.email "test@test.com"', { cwd: resolved, stdio: "ignore" }); + execSync('git config user.name "Test"', { cwd: resolved, stdio: "ignore" }); + execSync("git config commit.gpgsign false", { cwd: resolved, stdio: "ignore" }); + execSync("git add .", { cwd: resolved, stdio: "ignore" }); + execSync('git commit -m "init"', { cwd: resolved, stdio: "ignore" }); + + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + }); + + const result = await service.runOnce(); + + const projUpdate = result.changesApplied.find((c) => c.kind === "project_updated"); + expect(projUpdate).toBeDefined(); + expect(projects.get(1)!.kind).toBe("git"); + }); + + test("updates project display name when git remote changes", async () => { + const dir = createTempGitRepo("reconcile-remote-"); + tempDirs.push(dir); + + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + + projects.set( + 1, + createPersistedProjectRecord({ + id: 1, + directory: dir, + kind: "git", + displayName: "old-owner/old-repo", + gitRemote: "git@github.com:old-owner/old-repo.git", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + workspaces.set( + 1, + createPersistedWorkspaceRecord({ + id: 1, + projectId: 1, + directory: dir, + kind: "checkout", + displayName: "main", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + + // Change the remote + execSync("git remote add origin git@github.com:new-owner/new-repo.git", { + cwd: dir, + stdio: "ignore", + }); + + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + }); + + const result = await service.runOnce(); + + const projUpdate = result.changesApplied.find((c) => c.kind === "project_updated"); + expect(projUpdate).toBeDefined(); + expect(projects.get(1)!.displayName).toBe("new-owner/new-repo"); + expect(projects.get(1)!.gitRemote).toBe("git@github.com:new-owner/new-repo.git"); + }); + + test("updates workspace display name when branch changes", async () => { + const dir = createTempGitRepo("reconcile-branch-"); + tempDirs.push(dir); + + execSync("git checkout -b feature-branch", { cwd: dir, stdio: "ignore" }); + + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + + projects.set( + 1, + createPersistedProjectRecord({ + id: 1, + directory: dir, + kind: "git", + displayName: path.basename(dir), + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + workspaces.set( + 1, + createPersistedWorkspaceRecord({ + id: 1, + projectId: 1, + directory: dir, + kind: "checkout", + displayName: "main", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + }); + + const result = await service.runOnce(); + + const wsUpdate = result.changesApplied.find((c) => c.kind === "workspace_updated"); + expect(wsUpdate).toBeDefined(); + expect(workspaces.get(1)!.displayName).toBe("feature-branch"); + }); + + test("does not modify already-archived records", async () => { + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + + projects.set( + 1, + createPersistedProjectRecord({ + id: 1, + directory: "/tmp/does-not-exist-archived", + kind: "directory", + displayName: "archived", + createdAt: timestamp, + updatedAt: timestamp, + archivedAt: timestamp, + }), + ); + workspaces.set( + 1, + createPersistedWorkspaceRecord({ + id: 1, + projectId: 1, + directory: "/tmp/does-not-exist-archived", + kind: "checkout", + displayName: "archived", + createdAt: timestamp, + updatedAt: timestamp, + archivedAt: timestamp, + }), + ); + + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + }); + + const result = await service.runOnce(); + + expect(result.changesApplied).toHaveLength(0); + }); + + test("calls onChanges callback when changes are applied", async () => { + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + + projects.set( + 1, + createPersistedProjectRecord({ + id: 1, + directory: "/tmp/does-not-exist-callback-test", + kind: "directory", + displayName: "ghost", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + workspaces.set( + 1, + createPersistedWorkspaceRecord({ + id: 1, + projectId: 1, + directory: "/tmp/does-not-exist-callback-test", + kind: "checkout", + displayName: "ghost", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + + const onChanges = vi.fn(); + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + onChanges, + }); + + await service.runOnce(); + + expect(onChanges).toHaveBeenCalledTimes(1); + expect(onChanges.mock.calls[0][0].length).toBeGreaterThan(0); + }); +}); diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts new file mode 100644 index 000000000..00eddaf4d --- /dev/null +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -0,0 +1,239 @@ +import { existsSync } from "node:fs"; +import type pino from "pino"; +import type { + ProjectRegistry, + WorkspaceRegistry, + PersistedProjectRecord, + PersistedWorkspaceRecord, +} from "./workspace-registry.js"; +import { detectWorkspaceGitMetadata } from "./workspace-git-metadata.js"; + +const DEFAULT_RECONCILE_INTERVAL_MS = 60_000; + +export type ReconciliationChange = + | { kind: "workspace_archived"; workspaceId: number; directory: string; reason: string } + | { kind: "project_archived"; projectId: number; directory: string; reason: string } + | { + kind: "project_updated"; + projectId: number; + directory: string; + fields: Partial>; + } + | { + kind: "workspace_updated"; + workspaceId: number; + directory: string; + fields: Partial>; + }; + +export type ReconciliationResult = { + changesApplied: ReconciliationChange[]; + durationMs: number; +}; + +export type WorkspaceReconciliationServiceOptions = { + projectRegistry: ProjectRegistry; + workspaceRegistry: WorkspaceRegistry; + logger: pino.Logger; + intervalMs?: number; + onChanges?: (changes: ReconciliationChange[]) => void; +}; + +export class WorkspaceReconciliationService { + private readonly projectRegistry: ProjectRegistry; + private readonly workspaceRegistry: WorkspaceRegistry; + private readonly logger: pino.Logger; + private readonly intervalMs: number; + private readonly onChanges: ((changes: ReconciliationChange[]) => void) | null; + private timer: ReturnType | null = null; + private running = false; + + constructor(options: WorkspaceReconciliationServiceOptions) { + this.projectRegistry = options.projectRegistry; + this.workspaceRegistry = options.workspaceRegistry; + this.logger = options.logger.child({ module: "workspace-reconciliation" }); + this.intervalMs = options.intervalMs ?? DEFAULT_RECONCILE_INTERVAL_MS; + this.onChanges = options.onChanges ?? null; + } + + start(): void { + if (this.timer) return; + this.logger.info({ intervalMs: this.intervalMs }, "Starting workspace reconciliation service"); + this.timer = setInterval(() => void this.runSafe(), this.intervalMs); + // Run once immediately on start + void this.runSafe(); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + async runOnce(): Promise { + return this.reconcile(); + } + + private async runSafe(): Promise { + if (this.running) return; + this.running = true; + try { + const result = await this.reconcile(); + if (result.changesApplied.length > 0) { + this.logger.info( + { changeCount: result.changesApplied.length, durationMs: result.durationMs }, + "Reconciliation pass completed with changes", + ); + } + } catch (error) { + this.logger.error({ err: error }, "Reconciliation pass failed"); + } finally { + this.running = false; + } + } + + private async reconcile(): Promise { + const start = Date.now(); + const changes: ReconciliationChange[] = []; + + const allProjects = await this.projectRegistry.list(); + const allWorkspaces = await this.workspaceRegistry.list(); + + const activeProjects = allProjects.filter((p) => !p.archivedAt); + const activeWorkspaces = allWorkspaces.filter((w) => !w.archivedAt); + + const workspacesByProject = new Map(); + for (const workspace of activeWorkspaces) { + const list = workspacesByProject.get(workspace.projectId) ?? []; + list.push(workspace); + workspacesByProject.set(workspace.projectId, list); + } + + // 1. Archive workspaces whose directories no longer exist + for (const workspace of activeWorkspaces) { + if (!existsSync(workspace.directory)) { + const timestamp = new Date().toISOString(); + await this.workspaceRegistry.archive(workspace.id, timestamp); + changes.push({ + kind: "workspace_archived", + workspaceId: workspace.id, + directory: workspace.directory, + reason: "directory_missing", + }); + + // Update the in-memory list for the project orphan check below + const siblings = workspacesByProject.get(workspace.projectId); + if (siblings) { + const updated = siblings.filter((w) => w.id !== workspace.id); + workspacesByProject.set(workspace.projectId, updated); + } + } + } + + // 2. Archive orphaned projects (all workspaces archived/removed) + for (const project of activeProjects) { + const siblings = workspacesByProject.get(project.id) ?? []; + if (siblings.length === 0) { + const timestamp = new Date().toISOString(); + await this.projectRegistry.archive(project.id, timestamp); + changes.push({ + kind: "project_archived", + projectId: project.id, + directory: project.directory, + reason: "no_active_workspaces", + }); + } + } + + // 3. Reconcile git metadata for active projects whose directories still exist + for (const project of activeProjects) { + if (project.archivedAt) continue; + const siblings = workspacesByProject.get(project.id) ?? []; + if (siblings.length === 0) continue; + if (!existsSync(project.directory)) continue; + + const directoryName = + project.directory.split(/[\\/]/).filter(Boolean).at(-1) ?? project.directory; + const currentGit = detectWorkspaceGitMetadata(project.directory, directoryName); + + const projectUpdates: Partial< + Pick + > = {}; + + // Detect kind change: directory → git + if (project.kind !== currentGit.projectKind) { + projectUpdates.kind = currentGit.projectKind; + projectUpdates.displayName = currentGit.projectDisplayName; + projectUpdates.gitRemote = currentGit.gitRemote; + } + + // Detect display name change (e.g. remote renamed) + if ( + project.kind === "git" && + currentGit.projectKind === "git" && + project.displayName !== currentGit.projectDisplayName + ) { + projectUpdates.displayName = currentGit.projectDisplayName; + } + + // Detect git remote change + if ( + project.kind === "git" && + currentGit.projectKind === "git" && + project.gitRemote !== currentGit.gitRemote + ) { + projectUpdates.gitRemote = currentGit.gitRemote; + } + + if (Object.keys(projectUpdates).length > 0) { + const timestamp = new Date().toISOString(); + await this.projectRegistry.upsert({ + ...project, + ...projectUpdates, + updatedAt: timestamp, + }); + changes.push({ + kind: "project_updated", + projectId: project.id, + directory: project.directory, + fields: projectUpdates, + }); + } + + // 4. Reconcile workspace display names (branch name changes) + for (const workspace of siblings) { + if (workspace.kind !== "checkout") continue; + if (!existsSync(workspace.directory)) continue; + + const wsDirName = + workspace.directory.split(/[\\/]/).filter(Boolean).at(-1) ?? workspace.directory; + const wsGit = detectWorkspaceGitMetadata(workspace.directory, wsDirName); + + if ( + wsGit.projectKind === "git" && + workspace.displayName !== wsGit.workspaceDisplayName + ) { + const timestamp = new Date().toISOString(); + await this.workspaceRegistry.upsert({ + ...workspace, + displayName: wsGit.workspaceDisplayName, + updatedAt: timestamp, + }); + changes.push({ + kind: "workspace_updated", + workspaceId: workspace.id, + directory: workspace.directory, + fields: { displayName: wsGit.workspaceDisplayName }, + }); + } + } + } + + if (changes.length > 0 && this.onChanges) { + this.onChanges(changes); + } + + return { changesApplied: changes, durationMs: Date.now() - start }; + } +} diff --git a/packages/server/src/server/workspace-registry-bootstrap.test.ts b/packages/server/src/server/workspace-registry-bootstrap.test.ts deleted file mode 100644 index 66fa5d442..000000000 --- a/packages/server/src/server/workspace-registry-bootstrap.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import os from "node:os"; -import path from "node:path"; -import { mkdtempSync, rmSync } from "node:fs"; - -import { afterEach, beforeEach, describe, expect, test } from "vitest"; - -import { createTestLogger } from "../test-utils/test-logger.js"; -import { AgentStorage } from "./agent/agent-storage.js"; -import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js"; -import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"; - -describe("bootstrapWorkspaceRegistries", () => { - let tmpDir: string; - let paseoHome: string; - let agentStorage: AgentStorage; - let projectRegistry: FileBackedProjectRegistry; - let workspaceRegistry: FileBackedWorkspaceRegistry; - const logger = createTestLogger(); - - beforeEach(() => { - tmpDir = mkdtempSync(path.join(os.tmpdir(), "workspace-bootstrap-")); - paseoHome = path.join(tmpDir, ".paseo"); - agentStorage = new AgentStorage(path.join(paseoHome, "agents"), logger); - projectRegistry = new FileBackedProjectRegistry( - path.join(paseoHome, "projects", "projects.json"), - logger, - ); - workspaceRegistry = new FileBackedWorkspaceRegistry( - path.join(paseoHome, "projects", "workspaces.json"), - logger, - ); - }); - - afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }); - }); - - test("materializes workspace registries from non-archived agent records", async () => { - await agentStorage.initialize(); - await agentStorage.upsert({ - id: "agent-1", - provider: "codex", - cwd: "/tmp/non-git-project", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - lastActivityAt: "2026-03-02T00:00:00.000Z", - lastUserMessageAt: null, - title: null, - labels: {}, - lastStatus: "idle", - lastModeId: null, - config: null, - runtimeInfo: { provider: "codex", sessionId: null }, - persistence: null, - archivedAt: null, - }); - await agentStorage.upsert({ - id: "agent-2", - provider: "codex", - cwd: "/tmp/non-git-project", - createdAt: "2026-03-01T01:00:00.000Z", - updatedAt: "2026-03-03T00:00:00.000Z", - lastActivityAt: "2026-03-03T00:00:00.000Z", - lastUserMessageAt: null, - title: null, - labels: {}, - lastStatus: "running", - lastModeId: null, - config: null, - runtimeInfo: { provider: "codex", sessionId: null }, - persistence: null, - archivedAt: null, - }); - await agentStorage.upsert({ - id: "agent-archived", - provider: "codex", - cwd: "/tmp/archived-project", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - lastActivityAt: "2026-03-01T00:00:00.000Z", - lastUserMessageAt: null, - title: null, - labels: {}, - lastStatus: "idle", - lastModeId: null, - config: null, - runtimeInfo: { provider: "codex", sessionId: null }, - persistence: null, - archivedAt: "2026-03-02T00:00:00.000Z", - }); - - await bootstrapWorkspaceRegistries({ - paseoHome, - agentStorage, - projectRegistry, - workspaceRegistry, - logger, - }); - - const workspaces = await workspaceRegistry.list(); - expect(workspaces).toHaveLength(1); - expect(workspaces[0]?.workspaceId).toBe("/tmp/non-git-project"); - expect(workspaces[0]?.createdAt).toBe("2026-03-01T00:00:00.000Z"); - expect(workspaces[0]?.updatedAt).toBe("2026-03-03T00:00:00.000Z"); - - const projects = await projectRegistry.list(); - expect(projects).toHaveLength(1); - expect(projects[0]?.projectId).toBe("/tmp/non-git-project"); - expect(projects[0]?.createdAt).toBe("2026-03-01T00:00:00.000Z"); - expect(projects[0]?.updatedAt).toBe("2026-03-03T00:00:00.000Z"); - }); - - test("does not rematerialize when registry files already exist", async () => { - await projectRegistry.initialize(); - await workspaceRegistry.initialize(); - await projectRegistry.upsert({ - projectId: "/tmp/existing", - rootPath: "/tmp/existing", - kind: "non_git", - displayName: "existing", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - archivedAt: null, - }); - await workspaceRegistry.upsert({ - workspaceId: "/tmp/existing", - projectId: "/tmp/existing", - cwd: "/tmp/existing", - kind: "directory", - displayName: "existing", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - archivedAt: null, - }); - - await agentStorage.initialize(); - await agentStorage.upsert({ - id: "agent-1", - provider: "codex", - cwd: "/tmp/another-project", - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - lastActivityAt: "2026-03-02T00:00:00.000Z", - lastUserMessageAt: null, - title: null, - labels: {}, - lastStatus: "idle", - lastModeId: null, - config: null, - runtimeInfo: { provider: "codex", sessionId: null }, - persistence: null, - archivedAt: null, - }); - - await bootstrapWorkspaceRegistries({ - paseoHome, - agentStorage, - projectRegistry, - workspaceRegistry, - logger, - }); - - expect(await projectRegistry.list()).toHaveLength(1); - expect(await workspaceRegistry.list()).toHaveLength(1); - expect((await workspaceRegistry.list())[0]?.workspaceId).toBe("/tmp/existing"); - }); -}); diff --git a/packages/server/src/server/workspace-registry-bootstrap.ts b/packages/server/src/server/workspace-registry-bootstrap.ts deleted file mode 100644 index 902741339..000000000 --- a/packages/server/src/server/workspace-registry-bootstrap.ts +++ /dev/null @@ -1,142 +0,0 @@ -import path from "node:path"; - -import type { Logger } from "pino"; - -import type { StoredAgentRecord } from "./agent/agent-storage.js"; -import type { AgentStorage } from "./agent/agent-storage.js"; -import { - buildProjectPlacementForCwd, - deriveProjectKind, - deriveProjectRootPath, - deriveWorkspaceDisplayName, - deriveWorkspaceKind, - normalizeWorkspaceId, -} from "./workspace-registry-model.js"; -import { - createPersistedProjectRecord, - createPersistedWorkspaceRecord, - type ProjectRegistry, - type WorkspaceRegistry, -} from "./workspace-registry.js"; - -function minIsoDate(left: string | null, right: string | null): string | null { - if (!left) { - return right; - } - if (!right) { - return left; - } - return Date.parse(left) <= Date.parse(right) ? left : right; -} - -function maxIsoDate(left: string | null, right: string | null): string | null { - if (!left) { - return right; - } - if (!right) { - return left; - } - return Date.parse(left) >= Date.parse(right) ? left : right; -} - -function resolveAgentCreatedAt(record: StoredAgentRecord): string { - return record.createdAt || record.updatedAt || new Date(0).toISOString(); -} - -function resolveAgentUpdatedAt(record: StoredAgentRecord): string { - return record.lastActivityAt || record.updatedAt || record.createdAt || new Date(0).toISOString(); -} - -export async function bootstrapWorkspaceRegistries(options: { - paseoHome: string; - agentStorage: AgentStorage; - projectRegistry: ProjectRegistry; - workspaceRegistry: WorkspaceRegistry; - logger: Logger; -}): Promise { - const [projectsExists, workspacesExists] = await Promise.all([ - options.projectRegistry.existsOnDisk(), - options.workspaceRegistry.existsOnDisk(), - ]); - - await Promise.all([options.projectRegistry.initialize(), options.workspaceRegistry.initialize()]); - - if (projectsExists && workspacesExists) { - return; - } - - const records = await options.agentStorage.list(); - const activeRecords = records.filter((record) => !record.archivedAt); - const recordsByWorkspaceId = new Map(); - for (const record of activeRecords) { - const workspaceId = normalizeWorkspaceId(record.cwd); - const existing = recordsByWorkspaceId.get(workspaceId) ?? []; - existing.push(record); - recordsByWorkspaceId.set(workspaceId, existing); - } - - const projectRanges = new Map(); - - for (const [workspaceId, workspaceRecords] of recordsByWorkspaceId.entries()) { - const placement = await buildProjectPlacementForCwd({ - cwd: workspaceId, - paseoHome: options.paseoHome, - }); - - let workspaceCreatedAt: string | null = null; - let workspaceUpdatedAt: string | null = null; - for (const record of workspaceRecords) { - workspaceCreatedAt = minIsoDate(workspaceCreatedAt, resolveAgentCreatedAt(record)); - workspaceUpdatedAt = maxIsoDate(workspaceUpdatedAt, resolveAgentUpdatedAt(record)); - } - - const createdAt = workspaceCreatedAt ?? new Date().toISOString(); - const updatedAt = workspaceUpdatedAt ?? createdAt; - await options.workspaceRegistry.upsert( - createPersistedWorkspaceRecord({ - workspaceId, - projectId: placement.projectKey, - cwd: workspaceId, - kind: deriveWorkspaceKind(placement.checkout), - displayName: deriveWorkspaceDisplayName({ - cwd: workspaceId, - checkout: placement.checkout, - }), - createdAt, - updatedAt, - }), - ); - - const existingProjectRange = projectRanges.get(placement.projectKey) ?? { - createdAt: null, - updatedAt: null, - }; - existingProjectRange.createdAt = minIsoDate(existingProjectRange.createdAt, createdAt); - existingProjectRange.updatedAt = maxIsoDate(existingProjectRange.updatedAt, updatedAt); - projectRanges.set(placement.projectKey, existingProjectRange); - - await options.projectRegistry.upsert( - createPersistedProjectRecord({ - projectId: placement.projectKey, - rootPath: deriveProjectRootPath({ - cwd: workspaceId, - checkout: placement.checkout, - }), - kind: deriveProjectKind(placement.checkout), - displayName: placement.projectName, - createdAt: existingProjectRange.createdAt ?? createdAt, - updatedAt: existingProjectRange.updatedAt ?? updatedAt, - }), - ); - } - - options.logger.info( - { - projectsFile: path.join(options.paseoHome, "projects", "projects.json"), - workspacesFile: path.join(options.paseoHome, "projects", "workspaces.json"), - materializedProjects: projectRanges.size, - materializedWorkspaces: recordsByWorkspaceId.size, - }, - "Workspace registries bootstrapped from existing agent storage", - ); -} diff --git a/packages/server/src/server/workspace-registry-model.test.ts b/packages/server/src/server/workspace-registry-model.test.ts deleted file mode 100644 index 4f4f186d3..000000000 --- a/packages/server/src/server/workspace-registry-model.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; - -import { detectStaleWorkspaces } from "./workspace-registry-model.js"; -import { createPersistedWorkspaceRecord } from "./workspace-registry.js"; - -function createWorkspaceRecord(workspaceId: string) { - return createPersistedWorkspaceRecord({ - workspaceId, - projectId: workspaceId, - cwd: workspaceId, - kind: "directory", - displayName: workspaceId.split("/").at(-1) ?? workspaceId, - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - }); -} - -describe("detectStaleWorkspaces", () => { - test("returns workspace ids whose directories no longer exist", async () => { - const checkDirectoryExists = vi.fn(async (cwd: string) => cwd !== "/tmp/missing"); - - const staleWorkspaceIds = await detectStaleWorkspaces({ - activeWorkspaces: [ - createWorkspaceRecord("/tmp/existing"), - createWorkspaceRecord("/tmp/missing"), - ], - agentRecords: [], - checkDirectoryExists, - }); - - expect(Array.from(staleWorkspaceIds)).toEqual(["/tmp/missing"]); - expect(checkDirectoryExists.mock.calls).toEqual([["/tmp/existing"], ["/tmp/missing"]]); - }); - - test("returns workspace ids when all matching agents are archived", async () => { - const staleWorkspaceIds = await detectStaleWorkspaces({ - activeWorkspaces: [createWorkspaceRecord("/tmp/repo"), createWorkspaceRecord("/tmp/other")], - agentRecords: [ - { - cwd: "/tmp/repo", - archivedAt: "2026-03-02T00:00:00.000Z", - }, - { - cwd: "/tmp/other", - archivedAt: null, - }, - ], - checkDirectoryExists: async () => true, - }); - - expect(Array.from(staleWorkspaceIds)).toEqual(["/tmp/repo"]); - }); - - test("keeps workspaces with no agents or at least one active agent", async () => { - const staleWorkspaceIds = await detectStaleWorkspaces({ - activeWorkspaces: [ - createWorkspaceRecord("/tmp/active"), - createWorkspaceRecord("/tmp/no-agents"), - ], - agentRecords: [ - { - cwd: "/tmp/active", - archivedAt: "2026-03-02T00:00:00.000Z", - }, - { - cwd: "/tmp/active/../active", - archivedAt: null, - }, - ], - checkDirectoryExists: async () => true, - }); - - expect(Array.from(staleWorkspaceIds)).toEqual([]); - }); -}); diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts index 6af4f7bbc..1d1a08f0c 100644 --- a/packages/server/src/server/workspace-registry-model.ts +++ b/packages/server/src/server/workspace-registry-model.ts @@ -1,21 +1,7 @@ import { resolve } from "node:path"; -import { getCheckoutStatusLite } from "../utils/checkout-git.js"; -import type { ProjectCheckoutLitePayload, ProjectPlacementPayload } from "../shared/messages.js"; -import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; - -export type PersistedProjectKind = "git" | "non_git"; -export type PersistedWorkspaceKind = "local_checkout" | "worktree" | "directory"; -export type StaleWorkspaceAgentRecord = { - cwd: string; - archivedAt: string | null; -}; - -export type DetectStaleWorkspacesInput = { - activeWorkspaces: PersistedWorkspaceRecord[]; - agentRecords: StaleWorkspaceAgentRecord[]; - checkDirectoryExists: (cwd: string) => Promise; -}; +export type PersistedProjectKind = "git" | "directory"; +export type PersistedWorkspaceKind = "checkout" | "worktree"; export function normalizeWorkspaceId(cwd: string): string { const trimmed = cwd.trim(); @@ -24,212 +10,3 @@ export function normalizeWorkspaceId(cwd: string): string { } return resolve(trimmed); } - -function deriveRemoteProjectKey(remoteUrl: string | null): string | null { - if (!remoteUrl) { - return null; - } - - const trimmed = remoteUrl.trim(); - if (!trimmed) { - return null; - } - - let host: string | null = null; - let remotePath: string | null = null; - - const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/); - if (scpLike) { - host = scpLike[1] ?? null; - remotePath = scpLike[2] ?? null; - } else if (trimmed.includes("://")) { - try { - const parsed = new URL(trimmed); - host = parsed.hostname || null; - remotePath = parsed.pathname ? parsed.pathname.replace(/^\/+/, "") : null; - } catch { - return null; - } - } - - if (!host || !remotePath) { - return null; - } - - let cleanedPath = remotePath.trim().replace(/^\/+/, "").replace(/\/+$/, ""); - if (cleanedPath.endsWith(".git")) { - cleanedPath = cleanedPath.slice(0, -4); - } - if (!cleanedPath.includes("/")) { - return null; - } - - const cleanedHost = host.toLowerCase(); - if (cleanedHost === "github.com") { - return `remote:github.com/${cleanedPath}`; - } - - return `remote:${cleanedHost}/${cleanedPath}`; -} - -export function deriveProjectGroupingKey(options: { - cwd: string; - remoteUrl: string | null; - isPaseoOwnedWorktree: boolean; - mainRepoRoot: string | null; -}): string { - const remoteKey = deriveRemoteProjectKey(options.remoteUrl); - if (remoteKey) { - return remoteKey; - } - - const mainRepoRoot = options.mainRepoRoot?.trim(); - if (options.isPaseoOwnedWorktree && mainRepoRoot) { - return mainRepoRoot; - } - - return options.cwd; -} - -export function deriveProjectGroupingName(projectKey: string): string { - const githubRemotePrefix = "remote:github.com/"; - if (projectKey.startsWith(githubRemotePrefix)) { - return projectKey.slice(githubRemotePrefix.length) || projectKey; - } - - const segments = projectKey.split(/[\\/]/).filter(Boolean); - return segments[segments.length - 1] || projectKey; -} - -function deriveWorkspaceDirectoryName(cwd: string): string { - const normalized = cwd.replace(/\\/g, "/"); - const segments = normalized.split("/").filter(Boolean); - return segments[segments.length - 1] ?? cwd; -} - -export function deriveWorkspaceDisplayName(input: { - cwd: string; - checkout: ProjectCheckoutLitePayload; -}): string { - const branch = input.checkout.currentBranch?.trim() ?? null; - if (branch && branch.toUpperCase() !== "HEAD") { - return branch; - } - return deriveWorkspaceDirectoryName(input.cwd); -} - -export function deriveProjectRootPath(input: { - cwd: string; - checkout: ProjectCheckoutLitePayload; -}): string { - if (input.checkout.isGit && input.checkout.isPaseoOwnedWorktree) { - return input.checkout.mainRepoRoot; - } - return input.cwd; -} - -export function deriveProjectKind(checkout: ProjectCheckoutLitePayload): PersistedProjectKind { - return checkout.isGit ? "git" : "non_git"; -} - -export function deriveWorkspaceKind(checkout: ProjectCheckoutLitePayload): PersistedWorkspaceKind { - if (!checkout.isGit) { - return "directory"; - } - return checkout.isPaseoOwnedWorktree ? "worktree" : "local_checkout"; -} - -export async function detectStaleWorkspaces( - input: DetectStaleWorkspacesInput, -): Promise> { - const staleWorkspaceIds = new Set(); - const cwdsWithActiveAgents = new Set(); - const cwdsWithAnyAgent = new Set(); - - for (const agent of input.agentRecords) { - const normalizedCwd = normalizeWorkspaceId(agent.cwd); - cwdsWithAnyAgent.add(normalizedCwd); - if (!agent.archivedAt) { - cwdsWithActiveAgents.add(normalizedCwd); - } - } - - for (const workspace of input.activeWorkspaces) { - const dirExists = await input.checkDirectoryExists(workspace.cwd); - if (!dirExists) { - staleWorkspaceIds.add(workspace.workspaceId); - continue; - } - - const hasAgents = cwdsWithAnyAgent.has(workspace.workspaceId); - const hasActiveAgents = cwdsWithActiveAgents.has(workspace.workspaceId); - if (hasAgents && !hasActiveAgents) { - staleWorkspaceIds.add(workspace.workspaceId); - } - } - - return staleWorkspaceIds; -} - -export async function buildProjectPlacementForCwd(input: { - cwd: string; - paseoHome: string; -}): Promise { - const normalizedCwd = normalizeWorkspaceId(input.cwd); - const checkout = await getCheckoutStatusLite(normalizedCwd, { paseoHome: input.paseoHome }) - .then((status): ProjectCheckoutLitePayload => { - if (!status.isGit) { - return { - cwd: normalizedCwd, - isGit: false, - currentBranch: null, - remoteUrl: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }; - } - - if (status.isPaseoOwnedWorktree && status.mainRepoRoot) { - return { - cwd: normalizedCwd, - isGit: true, - currentBranch: status.currentBranch, - remoteUrl: status.remoteUrl, - isPaseoOwnedWorktree: true, - mainRepoRoot: status.mainRepoRoot, - }; - } - - return { - cwd: normalizedCwd, - isGit: true, - currentBranch: status.currentBranch, - remoteUrl: status.remoteUrl, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }; - }) - .catch( - (): ProjectCheckoutLitePayload => ({ - cwd: normalizedCwd, - isGit: false, - currentBranch: null, - remoteUrl: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }), - ); - - const projectKey = deriveProjectGroupingKey({ - cwd: normalizedCwd, - remoteUrl: checkout.remoteUrl, - isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree, - mainRepoRoot: checkout.mainRepoRoot, - }); - - return { - projectKey, - projectName: deriveProjectGroupingName(projectKey), - checkout, - }; -} diff --git a/packages/server/src/server/workspace-registry.test-helpers.ts b/packages/server/src/server/workspace-registry.test-helpers.ts new file mode 100644 index 000000000..1be76e04d --- /dev/null +++ b/packages/server/src/server/workspace-registry.test-helpers.ts @@ -0,0 +1,172 @@ +import { randomUUID } from "node:crypto"; +import { promises as fs } from "node:fs"; +import path from "node:path"; + +import type { Logger } from "pino"; + +import { + parsePersistedProjectRecords, + parsePersistedWorkspaceRecords, + type PersistedProjectRecord, + type PersistedWorkspaceRecord, + type ProjectRegistry, + type WorkspaceRegistry, +} from "./workspace-registry.js"; + +type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord; + +class FileBackedRegistry { + private readonly filePath: string; + private readonly logger: Logger; + private readonly parseRecord: (record: unknown) => TRecord; + private readonly parseRecords: (input: unknown) => TRecord[]; + private readonly getId: (record: TRecord) => number; + private loaded = false; + private readonly cache = new Map(); + private persistQueue: Promise = Promise.resolve(); + + constructor(options: { + filePath: string; + logger: Logger; + parseRecords: (input: unknown) => TRecord[]; + getId: (record: TRecord) => number; + component: string; + }) { + this.filePath = options.filePath; + this.parseRecords = options.parseRecords; + this.parseRecord = (record) => options.parseRecords([record])[0]!; + this.getId = options.getId; + this.logger = options.logger.child({ + module: "workspace-registry", + component: options.component, + }); + } + + async initialize(): Promise { + await this.load(); + } + + async existsOnDisk(): Promise { + try { + await fs.access(this.filePath); + return true; + } catch { + return false; + } + } + + async list(): Promise { + await this.load(); + return Array.from(this.cache.values()); + } + + async get(id: number): Promise { + await this.load(); + return this.cache.get(String(id)) ?? null; + } + + async insert(record: Omit): Promise { + await this.load(); + const nextId = Math.max(0, ...Array.from(this.cache.values(), (value) => this.getId(value))) + 1; + const parsed = this.parseRecord({ ...record, id: nextId }); + this.cache.set(String(this.getId(parsed)), parsed); + await this.enqueuePersist(); + return nextId; + } + + async upsert(record: TRecord): Promise { + await this.load(); + const parsed = this.parseRecord(record); + this.cache.set(String(this.getId(parsed)), parsed); + await this.enqueuePersist(); + } + + async archive(id: number, archivedAt: string): Promise { + await this.load(); + const key = String(id); + const existing = this.cache.get(key); + if (!existing) { + return; + } + const next = this.parseRecord({ + ...existing, + updatedAt: archivedAt, + archivedAt, + }); + this.cache.set(key, next); + await this.enqueuePersist(); + } + + async remove(id: number): Promise { + await this.load(); + if (!this.cache.delete(String(id))) { + return; + } + await this.enqueuePersist(); + } + + private async load(): Promise { + if (this.loaded) { + return; + } + + this.cache.clear(); + try { + const raw = await fs.readFile(this.filePath, "utf8"); + const parsed = this.parseRecords(JSON.parse(raw)); + for (const record of parsed) { + this.cache.set(String(this.getId(record)), record); + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + this.logger.error({ err: error, filePath: this.filePath }, "Failed to load registry file"); + } + } + this.loaded = true; + } + + private async persist(): Promise { + const records = Array.from(this.cache.values()); + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`; + await fs.writeFile(tempPath, JSON.stringify(records, null, 2), "utf8"); + await fs.rename(tempPath, this.filePath); + } + + private async enqueuePersist(): Promise { + const nextPersist = this.persistQueue.then(() => this.persist()); + this.persistQueue = nextPersist.catch(() => {}); + await nextPersist; + } +} + +export class FileBackedProjectRegistry + extends FileBackedRegistry + implements ProjectRegistry +{ + constructor(filePath: string, logger: Logger) { + super({ + filePath, + logger, + parseRecords: parsePersistedProjectRecords, + getId: (record) => record.id, + component: "projects", + }); + } +} + +export class FileBackedWorkspaceRegistry + extends FileBackedRegistry + implements WorkspaceRegistry +{ + constructor(filePath: string, logger: Logger) { + super({ + filePath, + logger, + parseRecords: parsePersistedWorkspaceRecords, + getId: (record) => record.id, + component: "workspaces", + }); + } +} diff --git a/packages/server/src/server/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts index b4abe506b..df119c353 100644 --- a/packages/server/src/server/workspace-registry.test.ts +++ b/packages/server/src/server/workspace-registry.test.ts @@ -6,10 +6,12 @@ import { beforeEach, afterEach, describe, expect, test } from "vitest"; import { createTestLogger } from "../test-utils/test-logger.js"; import { - createPersistedProjectRecord, - createPersistedWorkspaceRecord, FileBackedProjectRegistry, FileBackedWorkspaceRegistry, +} from "./workspace-registry.test-helpers.js"; +import { + createPersistedProjectRecord, + createPersistedWorkspaceRecord, } from "./workspace-registry.js"; describe("workspace registries", () => { @@ -36,71 +38,78 @@ describe("workspace registries", () => { test("creates, updates, archives, deletes, and lists project records", async () => { await projectRegistry.initialize(); - await projectRegistry.upsert( - createPersistedProjectRecord({ - projectId: "remote:github.com/acme/repo", - rootPath: "/tmp/repo", - kind: "git", - displayName: "acme/repo", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - }), - ); + const projectId = await projectRegistry.insert({ + directory: "/tmp/repo", + kind: "git", + displayName: "acme/repo", + gitRemote: "git@github.com:acme/repo.git", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + }); await projectRegistry.upsert( createPersistedProjectRecord({ - projectId: "remote:github.com/acme/repo", - rootPath: "/tmp/repo", + id: projectId, + directory: "/tmp/repo", kind: "git", displayName: "acme/repo", + gitRemote: "git@github.com:acme/repo.git", createdAt: "2026-03-01T00:00:00.000Z", updatedAt: "2026-03-02T00:00:00.000Z", }), ); - await projectRegistry.archive("remote:github.com/acme/repo", "2026-03-03T00:00:00.000Z"); + await projectRegistry.archive(projectId, "2026-03-03T00:00:00.000Z"); - const archived = await projectRegistry.get("remote:github.com/acme/repo"); + const archived = await projectRegistry.get(projectId); expect(archived?.archivedAt).toBe("2026-03-03T00:00:00.000Z"); expect(await projectRegistry.list()).toHaveLength(1); - await projectRegistry.remove("remote:github.com/acme/repo"); - expect(await projectRegistry.get("remote:github.com/acme/repo")).toBeNull(); + await projectRegistry.remove(projectId); + expect(await projectRegistry.get(projectId)).toBeNull(); expect(await projectRegistry.list()).toEqual([]); }); test("creates, updates, archives, deletes, and lists workspace records", async () => { await workspaceRegistry.initialize(); - await workspaceRegistry.upsert( - createPersistedWorkspaceRecord({ - workspaceId: "/tmp/repo", - projectId: "remote:github.com/acme/repo", - cwd: "/tmp/repo", - kind: "local_checkout", - displayName: "main", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - }), - ); + const projectId = await projectRegistry.insert({ + directory: "/tmp/repo", + kind: "git", + displayName: "acme/repo", + gitRemote: "git@github.com:acme/repo.git", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + }); + const workspaceId = await workspaceRegistry.insert({ + projectId, + directory: "/tmp/repo", + kind: "checkout", + displayName: "main", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + }); await workspaceRegistry.upsert( createPersistedWorkspaceRecord({ - workspaceId: "/tmp/repo", - projectId: "remote:github.com/acme/repo", - cwd: "/tmp/repo", - kind: "local_checkout", + id: workspaceId, + projectId, + directory: "/tmp/repo", + kind: "checkout", displayName: "feature/workspace", createdAt: "2026-03-01T00:00:00.000Z", updatedAt: "2026-03-02T00:00:00.000Z", }), ); - await workspaceRegistry.archive("/tmp/repo", "2026-03-03T00:00:00.000Z"); + await workspaceRegistry.archive(workspaceId, "2026-03-03T00:00:00.000Z"); - const archived = await workspaceRegistry.get("/tmp/repo"); + const archived = await workspaceRegistry.get(workspaceId); expect(archived?.displayName).toBe("feature/workspace"); expect(archived?.archivedAt).toBe("2026-03-03T00:00:00.000Z"); - await workspaceRegistry.remove("/tmp/repo"); - expect(await workspaceRegistry.get("/tmp/repo")).toBeNull(); + await workspaceRegistry.remove(workspaceId); + expect(await workspaceRegistry.get(workspaceId)).toBeNull(); expect(await workspaceRegistry.list()).toEqual([]); }); }); diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index dcd7fb411..d02010509 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -1,27 +1,21 @@ -import { randomUUID } from "node:crypto"; -import { promises as fs } from "node:fs"; -import path from "node:path"; - -import type { Logger } from "pino"; import { z } from "zod"; -import type { PersistedProjectKind, PersistedWorkspaceKind } from "./workspace-registry-model.js"; - const PersistedProjectRecordSchema = z.object({ - projectId: z.string(), - rootPath: z.string(), - kind: z.enum(["git", "non_git"]), + id: z.number().int(), + directory: z.string(), + kind: z.enum(["git", "directory"]), displayName: z.string(), + gitRemote: z.string().nullable(), createdAt: z.string(), updatedAt: z.string(), archivedAt: z.string().nullable(), }); const PersistedWorkspaceRecordSchema = z.object({ - workspaceId: z.string(), - projectId: z.string(), - cwd: z.string(), - kind: z.enum(["local_checkout", "worktree", "directory"]), + id: z.number().int(), + projectId: z.number().int(), + directory: z.string(), + kind: z.enum(["checkout", "worktree"]), displayName: z.string(), createdAt: z.string(), updatedAt: z.string(), @@ -31,192 +25,58 @@ const PersistedWorkspaceRecordSchema = z.object({ export type PersistedProjectRecord = z.infer; export type PersistedWorkspaceRecord = z.infer; +export function parsePersistedProjectRecords(input: unknown): PersistedProjectRecord[] { + return z.array(PersistedProjectRecordSchema).parse(input); +} + +export function parsePersistedWorkspaceRecords(input: unknown): PersistedWorkspaceRecord[] { + return z.array(PersistedWorkspaceRecordSchema).parse(input); +} + export interface ProjectRegistry { initialize(): Promise; existsOnDisk(): Promise; list(): Promise; - get(projectId: string): Promise; + get(id: number): Promise; + insert(record: Omit): Promise; upsert(record: PersistedProjectRecord): Promise; - archive(projectId: string, archivedAt: string): Promise; - remove(projectId: string): Promise; + archive(id: number, archivedAt: string): Promise; + remove(id: number): Promise; } export interface WorkspaceRegistry { initialize(): Promise; existsOnDisk(): Promise; list(): Promise; - get(workspaceId: string): Promise; + get(id: number): Promise; + insert(record: Omit): Promise; upsert(record: PersistedWorkspaceRecord): Promise; - archive(workspaceId: string, archivedAt: string): Promise; - remove(workspaceId: string): Promise; -} - -type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord; - -class FileBackedRegistry { - private readonly filePath: string; - private readonly logger: Logger; - private readonly schema: z.ZodSchema; - private readonly getId: (record: TRecord) => string; - private loaded = false; - private readonly cache = new Map(); - private persistQueue: Promise = Promise.resolve(); - - constructor(options: { - filePath: string; - logger: Logger; - schema: z.ZodSchema; - getId: (record: TRecord) => string; - component: string; - }) { - this.filePath = options.filePath; - this.schema = options.schema; - this.getId = options.getId; - this.logger = options.logger.child({ - module: "workspace-registry", - component: options.component, - }); - } - - async initialize(): Promise { - await this.load(); - } - - async existsOnDisk(): Promise { - try { - await fs.access(this.filePath); - return true; - } catch { - return false; - } - } - - async list(): Promise { - await this.load(); - return Array.from(this.cache.values()); - } - - async get(id: string): Promise { - await this.load(); - return this.cache.get(id) ?? null; - } - - async upsert(record: TRecord): Promise { - await this.load(); - const parsed = this.schema.parse(record); - this.cache.set(this.getId(parsed), parsed); - await this.enqueuePersist(); - } - - async archive(id: string, archivedAt: string): Promise { - await this.load(); - const existing = this.cache.get(id); - if (!existing) { - return; - } - const next = this.schema.parse({ - ...existing, - updatedAt: archivedAt, - archivedAt, - }); - this.cache.set(id, next); - await this.enqueuePersist(); - } - - async remove(id: string): Promise { - await this.load(); - if (!this.cache.delete(id)) { - return; - } - await this.enqueuePersist(); - } - - private async load(): Promise { - if (this.loaded) { - return; - } - - this.cache.clear(); - try { - const raw = await fs.readFile(this.filePath, "utf8"); - const parsed = z.array(this.schema).parse(JSON.parse(raw)); - for (const record of parsed) { - this.cache.set(this.getId(record), record); - } - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - this.logger.error({ err: error, filePath: this.filePath }, "Failed to load registry file"); - } - } - this.loaded = true; - } - - private async persist(): Promise { - const records = Array.from(this.cache.values()); - await fs.mkdir(path.dirname(this.filePath), { recursive: true }); - const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`; - await fs.writeFile(tempPath, JSON.stringify(records, null, 2), "utf8"); - await fs.rename(tempPath, this.filePath); - } - - private async enqueuePersist(): Promise { - const nextPersist = this.persistQueue.then(() => this.persist()); - this.persistQueue = nextPersist.catch(() => {}); - await nextPersist; - } -} - -export class FileBackedProjectRegistry - extends FileBackedRegistry - implements ProjectRegistry -{ - constructor(filePath: string, logger: Logger) { - super({ - filePath, - logger, - schema: PersistedProjectRecordSchema, - getId: (record) => record.projectId, - component: "projects", - }); - } -} - -export class FileBackedWorkspaceRegistry - extends FileBackedRegistry - implements WorkspaceRegistry -{ - constructor(filePath: string, logger: Logger) { - super({ - filePath, - logger, - schema: PersistedWorkspaceRecordSchema, - getId: (record) => record.workspaceId, - component: "workspaces", - }); - } + archive(id: number, archivedAt: string): Promise; + remove(id: number): Promise; } export function createPersistedProjectRecord(input: { - projectId: string; - rootPath: string; - kind: PersistedProjectKind; + id: number; + directory: string; + kind: "git" | "directory"; displayName: string; + gitRemote?: string | null; createdAt: string; updatedAt: string; archivedAt?: string | null; }): PersistedProjectRecord { return PersistedProjectRecordSchema.parse({ ...input, + gitRemote: input.gitRemote ?? null, archivedAt: input.archivedAt ?? null, }); } export function createPersistedWorkspaceRecord(input: { - workspaceId: string; - projectId: string; - cwd: string; - kind: PersistedWorkspaceKind; + id: number; + projectId: number; + directory: string; + kind: "checkout" | "worktree"; displayName: string; createdAt: string; updatedAt: string; diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 2076c53e9..0df58d1c6 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -14,7 +14,6 @@ import { type WorkspaceDescriptorPayload, } from "./messages.js"; import type { - PersistedProjectRecord, PersistedWorkspaceRecord, ProjectRegistry, WorkspaceRegistry, @@ -77,26 +76,15 @@ type ArchivePaseoWorktreeDependencies = { }; type RegisterPendingWorktreeWorkspaceDependencies = { - buildPersistedProjectRecord: (input: { - workspaceId: string; - placement: ProjectPlacementPayload; - createdAt: string; - updatedAt: string; - }) => PersistedProjectRecord; - buildPersistedWorkspaceRecord: (input: { - workspaceId: string; - placement: ProjectPlacementPayload; - createdAt: string; - updatedAt: string; - }) => PersistedWorkspaceRecord; buildProjectPlacement: (cwd: string) => Promise; - projectRegistry: Pick; + findWorkspaceByDirectory: (directory: string) => Promise; + projectRegistry: Pick; syncWorkspaceGitWatchTarget: ( cwd: string, options: { isGit: boolean }, ) => Promise; - workspaceRegistry: Pick; - archiveProjectRecordIfEmpty: (projectId: string, archivedAt: string) => Promise; + workspaceRegistry: Pick; + archiveProjectRecordIfEmpty: (projectId: number, archivedAt: string) => Promise; }; type CreatePaseoWorktreeInBackgroundDependencies = { @@ -509,50 +497,50 @@ export async function registerPendingWorktreeWorkspace( branchName: string; }, ): Promise { - const workspaceId = normalizePersistedWorkspaceId(options.worktreePath); + const workspaceDirectory = normalizePersistedWorkspaceId(options.worktreePath); const basePlacement = await dependencies.buildProjectPlacement(options.repoRoot); - const placement: ProjectPlacementPayload = { - ...basePlacement, - checkout: { - cwd: workspaceId, - isGit: true, - currentBranch: options.branchName, - remoteUrl: basePlacement.checkout.remoteUrl, - isPaseoOwnedWorktree: true, - mainRepoRoot: options.repoRoot, - }, - }; + const projectId = Number(basePlacement.projectKey); + if (!Number.isInteger(projectId)) { + throw new Error(`Invalid project id for repo root ${options.repoRoot}`); + } + const now = new Date().toISOString(); - const existingWorkspace = await dependencies.workspaceRegistry.get(workspaceId); - const existingProject = await dependencies.projectRegistry.get(placement.projectKey); - const nextProjectRecord = dependencies.buildPersistedProjectRecord({ - workspaceId, - placement, - createdAt: existingProject?.createdAt ?? now, - updatedAt: now, - }); - const nextWorkspaceRecord = dependencies.buildPersistedWorkspaceRecord({ - workspaceId, - placement, - createdAt: existingWorkspace?.createdAt ?? now, - updatedAt: now, - }); + const existingWorkspace = await dependencies.findWorkspaceByDirectory(workspaceDirectory); + if (!existingWorkspace) { + const workspaceId = await dependencies.workspaceRegistry.insert({ + projectId, + directory: workspaceDirectory, + displayName: options.branchName, + kind: "worktree", + createdAt: now, + updatedAt: now, + archivedAt: null, + }); + const workspace = await dependencies.workspaceRegistry.get(workspaceId); + if (!workspace) { + throw new Error(`Workspace not found after insert: ${workspaceId}`); + } + await dependencies.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true }); + return workspace; + } - await dependencies.projectRegistry.upsert(nextProjectRecord); - await dependencies.workspaceRegistry.upsert(nextWorkspaceRecord); - await dependencies.syncWorkspaceGitWatchTarget(workspaceId, { - isGit: placement.checkout.isGit, + await dependencies.workspaceRegistry.upsert({ + id: existingWorkspace.id, + projectId, + directory: workspaceDirectory, + displayName: options.branchName, + kind: "worktree", + createdAt: existingWorkspace.createdAt, + updatedAt: now, + archivedAt: null, }); + await dependencies.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true }); - if ( - existingWorkspace && - !existingWorkspace.archivedAt && - existingWorkspace.projectId !== nextWorkspaceRecord.projectId - ) { + if (!existingWorkspace.archivedAt && existingWorkspace.projectId !== projectId) { await dependencies.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now); } - return nextWorkspaceRecord; + return (await dependencies.workspaceRegistry.get(existingWorkspace.id))!; } export async function handleCreatePaseoWorktreeRequest( @@ -585,6 +573,13 @@ export async function handleCreatePaseoWorktreeRequest( worktreePath, branchName: normalizedSlug, }); + await createAgentWorktree({ + cwd: repoRoot, + branchName: normalizedSlug, + baseBranch, + worktreeSlug: normalizedSlug, + paseoHome: dependencies.paseoHome, + }); const descriptor = await dependencies.describeWorkspaceRecord(workspace); dependencies.emit({ type: "create_paseo_worktree_response", @@ -634,14 +629,6 @@ export async function createPaseoWorktreeInBackground( let setupTerminalId: string | null = null; try { - await createAgentWorktree({ - cwd: options.repoRoot, - branchName: options.slug, - baseBranch: options.baseBranch, - worktreeSlug: options.slug, - paseoHome: dependencies.paseoHome, - }); - const setupCommands = getWorktreeSetupCommands(options.worktreePath); if (setupCommands.length > 0 && dependencies.terminalManager) { const runtimeEnv = await resolveWorktreeRuntimeEnv({ diff --git a/packages/server/src/shared/messages.stream-parsing.test.ts b/packages/server/src/shared/messages.stream-parsing.test.ts index 01dc36799..01a55927c 100644 --- a/packages/server/src/shared/messages.stream-parsing.test.ts +++ b/packages/server/src/shared/messages.stream-parsing.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { AgentStreamMessageSchema, + FetchAgentTimelineRequestMessageSchema, FetchAgentTimelineResponseMessageSchema, SessionInboundMessageSchema, SessionOutboundMessageSchema, @@ -17,14 +18,8 @@ describe("shared messages stream parsing", () => { agentId: "agent_live", agent: null, direction: "tail", - projection: "projected", - epoch: "epoch-1", - reset: false, - staleCursor: false, - gap: false, - window: { minSeq: 1, maxSeq: 2, nextSeq: 3 }, - startCursor: { epoch: "epoch-1", seq: 1 }, - endCursor: { epoch: "epoch-1", seq: 2 }, + startSeq: 1, + endSeq: 2, hasOlder: false, hasNewer: false, entries: [ @@ -32,10 +27,7 @@ describe("shared messages stream parsing", () => { provider: "codex", item: { type: "assistant_message", text: "hello" }, timestamp: "2026-02-08T20:10:00.000Z", - seqStart: 1, - seqEnd: 2, - sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }], - collapsed: ["assistant_merge"], + seq: 2, }, ], error: null, @@ -46,6 +38,51 @@ describe("shared messages stream parsing", () => { expect(parsed.payload.entries[0]?.item.type).toBe("assistant_message"); }); + it("rejects removed fetch timeline request baggage at the parser boundary", () => { + const parsed = FetchAgentTimelineRequestMessageSchema.safeParse({ + type: "fetch_agent_timeline_request", + agentId: "agent_live", + requestId: "req-legacy", + direction: "after", + cursor: { + seq: 12, + epoch: "legacy-epoch", + }, + projection: "canonical", + }); + + expect(parsed.success).toBe(false); + }); + + it("rejects removed fetch timeline response baggage at the parser boundary", () => { + const parsed = FetchAgentTimelineResponseMessageSchema.safeParse({ + type: "fetch_agent_timeline_response", + payload: { + requestId: "req-1", + agentId: "agent_live", + agent: null, + direction: "tail", + startSeq: 1, + endSeq: 2, + hasOlder: false, + hasNewer: false, + reset: false, + startCursor: { seq: 1 }, + entries: [ + { + provider: "codex", + item: { type: "assistant_message", text: "hello" }, + timestamp: "2026-02-08T20:10:00.000Z", + seq: 2, + }, + ], + error: null, + }, + }); + + expect(parsed.success).toBe(false); + }); + it("parses explicit shutdown and restart lifecycle request payloads as distinct message types", () => { const shutdownParsed = SessionInboundMessageSchema.safeParse({ type: "shutdown_server_request", @@ -70,6 +107,7 @@ describe("shared messages stream parsing", () => { payload: { agentId: "agent_live", timestamp: "2026-02-08T20:10:00.000Z", + seq: 12, event: { type: "timeline", provider: "claude", @@ -97,6 +135,27 @@ describe("shared messages stream parsing", () => { } }); + it("rejects removed agent_stream baggage at the parser boundary", () => { + const parsed = AgentStreamMessageSchema.safeParse({ + type: "agent_stream", + payload: { + agentId: "agent_live", + timestamp: "2026-02-08T20:10:00.000Z", + epoch: "legacy-epoch", + event: { + type: "timeline", + provider: "claude", + item: { + type: "assistant_message", + text: "hello", + }, + }, + }, + }); + + expect(parsed.success).toBe(false); + }); + it("parses representative sub_agent tool_call event", () => { const parsed = AgentStreamMessageSchema.parse({ type: "agent_stream", diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 55ffa15af..eb0156ccc 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -95,6 +95,7 @@ const AgentCapabilityFlagsSchema: z.ZodType = z.object({ supportsMcpServers: z.boolean(), supportsReasoningStream: z.boolean(), supportsToolInvocations: z.boolean(), + supportsTerminalMode: z.boolean(), }); const AgentUsageSchema: z.ZodType = z.object({ @@ -132,6 +133,7 @@ const McpServerConfigSchema = z.discriminatedUnion("type", [ const AgentSessionConfigSchema = z.object({ provider: AgentProviderSchema, cwd: z.string(), + terminal: z.boolean().optional(), modeId: z.string().optional(), model: z.string().optional(), thinkingOptionId: z.string().optional(), @@ -453,10 +455,19 @@ const AgentRuntimeInfoSchema: z.ZodType = z.object({ extra: z.record(z.unknown()).optional(), }); +const TerminalExitDetailsSchema = z.object({ + command: z.string(), + message: z.string(), + exitCode: z.number().nullable(), + signal: z.number().nullable(), + outputLines: z.array(z.string()), +}); + export const AgentSnapshotPayloadSchema = z.object({ id: z.string(), provider: AgentProviderSchema, cwd: z.string(), + terminal: z.boolean().optional(), model: z.string().nullable(), thinkingOptionId: z.string().nullable().optional(), effectiveThinkingOptionId: z.string().nullable().optional(), @@ -472,6 +483,7 @@ export const AgentSnapshotPayloadSchema = z.object({ runtimeInfo: AgentRuntimeInfoSchema.optional(), lastUsage: AgentUsageSchema.optional(), lastError: z.string().optional(), + terminalExit: TerminalExitDetailsSchema.optional(), title: z.string().nullable(), labels: z.record(z.string()).default({}), requiresAttention: z.boolean().optional(), @@ -605,7 +617,7 @@ export const FetchWorkspacesRequestMessageSchema = z.object({ filter: z .object({ query: z.string().optional(), - projectId: z.string().optional(), + projectId: z.number().int().optional(), idPrefix: z.string().optional(), }) .optional(), @@ -704,6 +716,7 @@ export type GitSetupOptions = z.infer; export const CreateAgentRequestMessageSchema = z.object({ type: z.literal("create_agent_request"), config: AgentSessionConfigSchema, + workspaceId: z.number().int().optional(), worktreeName: z.string().optional(), initialPrompt: z.string().optional(), clientMessageId: z.string().optional(), @@ -762,22 +775,23 @@ export const ShutdownServerRequestMessageSchema = z.object({ requestId: z.string(), }); -export const AgentTimelineCursorSchema = z.object({ - epoch: z.string(), - seq: z.number().int().nonnegative(), -}); +export const AgentTimelineCursorSchema = z + .object({ + seq: z.number().int().nonnegative(), + }) + .strict(); -export const FetchAgentTimelineRequestMessageSchema = z.object({ - type: z.literal("fetch_agent_timeline_request"), - agentId: z.string(), - requestId: z.string(), - direction: z.enum(["tail", "before", "after"]).optional(), - cursor: AgentTimelineCursorSchema.optional(), - // 0 means "all matching rows for this query window". - limit: z.number().int().nonnegative().optional(), - // Default should be projected for app timeline loading. - projection: z.enum(["projected", "canonical"]).optional(), -}); +export const FetchAgentTimelineRequestMessageSchema = z + .object({ + type: z.literal("fetch_agent_timeline_request"), + agentId: z.string(), + requestId: z.string(), + direction: z.enum(["tail", "before", "after"]).optional(), + cursor: AgentTimelineCursorSchema.optional(), + // 0 means "all matching rows for this query window". + limit: z.number().int().nonnegative().optional(), + }) + .strict(); export const SetAgentModeRequestMessageSchema = z.object({ type: z.literal("set_agent_mode_request"), @@ -998,7 +1012,7 @@ export const OpenProjectRequestSchema = z.object({ export const ArchiveWorkspaceRequestSchema = z.object({ type: z.literal("archive_workspace_request"), - workspaceId: z.string(), + workspaceId: z.number().int(), requestId: z.string(), }); @@ -1141,6 +1155,9 @@ export const CreateTerminalRequestSchema = z.object({ type: z.literal("create_terminal_request"), cwd: z.string(), name: z.string().optional(), + agentId: z.string().optional(), + command: z.string().optional(), + args: z.array(z.string()).optional(), requestId: z.string(), }); @@ -1580,12 +1597,13 @@ export const ProjectPlacementPayloadSchema = z.object({ }); export const WorkspaceDescriptorPayloadSchema = z.object({ - id: z.string(), - projectId: z.string(), + id: z.number().int(), + projectId: z.number().int(), projectDisplayName: z.string(), projectRootPath: z.string(), - projectKind: z.enum(["git", "non_git"]), - workspaceKind: z.enum(["local_checkout", "worktree", "directory"]), + workspaceDirectory: z.string(), + projectKind: z.enum(["git", "directory"]), + workspaceKind: z.enum(["checkout", "worktree"]), name: z.string(), status: WorkspaceStateBucketSchema, activityAt: z.string().nullable(), @@ -1613,17 +1631,20 @@ export const AgentUpdateMessageSchema = z.object({ ]), }); -export const AgentStreamMessageSchema = z.object({ - type: z.literal("agent_stream"), - payload: z.object({ - agentId: z.string(), - event: AgentStreamEventPayloadSchema, - timestamp: z.string(), - // Present for timeline events. Maps 1:1 to canonical in-memory timeline rows. - seq: z.number().int().nonnegative().optional(), - epoch: z.string().optional(), - }), -}); +export const AgentStreamMessageSchema = z + .object({ + type: z.literal("agent_stream"), + payload: z + .object({ + agentId: z.string(), + event: AgentStreamEventPayloadSchema, + timestamp: z.string(), + // Present only for committed timeline events. + seq: z.number().int().nonnegative().optional(), + }) + .strict(), + }) + .strict(); export const AgentStatusMessageSchema = z.object({ type: z.literal("agent_status"), @@ -1683,7 +1704,7 @@ export const WorkspaceUpdateMessageSchema = z.object({ }), z.object({ kind: z.literal("remove"), - id: z.string(), + id: z.number().int(), }), ]), }); @@ -1701,7 +1722,7 @@ export const ArchiveWorkspaceResponseMessageSchema = z.object({ type: z.literal("archive_workspace_response"), payload: z.object({ requestId: z.string(), - workspaceId: z.string(), + workspaceId: z.number().int(), archivedAt: z.string().nullable(), error: z.string().nullable(), }), @@ -1717,46 +1738,34 @@ export const FetchAgentResponseMessageSchema = z.object({ }), }); -const AgentTimelineSeqRangeSchema = z.object({ - startSeq: z.number().int().nonnegative(), - endSeq: z.number().int().nonnegative(), -}); +export const AgentTimelineEntryPayloadSchema = z + .object({ + provider: AgentProviderSchema, + item: AgentTimelineItemPayloadSchema, + timestamp: z.string(), + seq: z.number().int().nonnegative(), + }) + .strict(); -export const AgentTimelineEntryPayloadSchema = z.object({ - provider: AgentProviderSchema, - item: AgentTimelineItemPayloadSchema, - timestamp: z.string(), - seqStart: z.number().int().nonnegative(), - seqEnd: z.number().int().nonnegative(), - sourceSeqRanges: z.array(AgentTimelineSeqRangeSchema), - collapsed: z.array(z.enum(["assistant_merge", "tool_lifecycle"])), -}); - -export const FetchAgentTimelineResponseMessageSchema = z.object({ - type: z.literal("fetch_agent_timeline_response"), - payload: z.object({ - requestId: z.string(), - agentId: z.string(), - agent: AgentSnapshotPayloadSchema.nullable(), - direction: z.enum(["tail", "before", "after"]), - projection: z.enum(["projected", "canonical"]), - epoch: z.string(), - reset: z.boolean(), - staleCursor: z.boolean(), - gap: z.boolean(), - window: z.object({ - minSeq: z.number().int().nonnegative(), - maxSeq: z.number().int().nonnegative(), - nextSeq: z.number().int().nonnegative(), - }), - startCursor: AgentTimelineCursorSchema.nullable(), - endCursor: AgentTimelineCursorSchema.nullable(), - hasOlder: z.boolean(), - hasNewer: z.boolean(), - entries: z.array(AgentTimelineEntryPayloadSchema), - error: z.string().nullable(), - }), -}); +export const FetchAgentTimelineResponseMessageSchema = z + .object({ + type: z.literal("fetch_agent_timeline_response"), + payload: z + .object({ + requestId: z.string(), + agentId: z.string(), + agent: AgentSnapshotPayloadSchema.nullable(), + direction: z.enum(["tail", "before", "after"]), + startSeq: z.number().int().nonnegative().nullable(), + endSeq: z.number().int().nonnegative().nullable(), + hasOlder: z.boolean(), + hasNewer: z.boolean(), + entries: z.array(AgentTimelineEntryPayloadSchema), + error: z.string().nullable(), + }) + .strict(), + }) + .strict(); export const SendAgentMessageResponseMessageSchema = z.object({ type: z.literal("send_agent_message_response"), @@ -2152,6 +2161,7 @@ const TerminalInfoSchema = z.object({ id: z.string(), name: z.string(), cwd: z.string(), + title: z.string().optional(), }); export const TerminalCellSchema = z @@ -2189,6 +2199,7 @@ export const TerminalStateSchema = z grid: z.array(z.array(TerminalCellSchema)), scrollback: z.array(z.array(TerminalCellSchema)), cursor: TerminalCursorSchema, + title: z.string().optional(), }) .strict(); diff --git a/packages/server/src/shared/messages.workspaces.test.ts b/packages/server/src/shared/messages.workspaces.test.ts index 3f62d3a60..aed724e7d 100644 --- a/packages/server/src/shared/messages.workspaces.test.ts +++ b/packages/server/src/shared/messages.workspaces.test.ts @@ -8,7 +8,7 @@ describe("workspace message schemas", () => { requestId: "req-1", filter: { query: "repo", - projectId: "remote:github.com/acme/repo", + projectId: 12, idPrefix: "/Users/me", }, sort: [{ key: "activity_at", direction: "desc" }], @@ -35,12 +35,12 @@ describe("workspace message schemas", () => { payload: { kind: "upsert", workspace: { - id: "/repo", - projectId: "/repo", + id: 1, + projectId: 1, projectDisplayName: "repo", projectRootPath: "/repo", - projectKind: "non_git", - workspaceKind: "directory", + projectKind: "directory", + workspaceKind: "checkout", name: "", status: "not-a-bucket", activityAt: null, diff --git a/packages/server/src/terminal/shell-integration/zsh/.zshenv b/packages/server/src/terminal/shell-integration/zsh/.zshenv new file mode 100644 index 000000000..2150c66e3 --- /dev/null +++ b/packages/server/src/terminal/shell-integration/zsh/.zshenv @@ -0,0 +1,17 @@ +typeset -g PASEO_SHELL_INTEGRATION_DIR="${${(%):-%N}:A:h}" + +if [[ -n "${PASEO_ZSH_ZDOTDIR-}" ]]; then + export ZDOTDIR="${PASEO_ZSH_ZDOTDIR}" +else + unset ZDOTDIR +fi + +if [[ -n "${ZDOTDIR-}" ]]; then + if [[ -f "${ZDOTDIR}/.zshenv" ]]; then + source "${ZDOTDIR}/.zshenv" + fi +elif [[ -f "${HOME}/.zshenv" ]]; then + source "${HOME}/.zshenv" +fi + +source "${PASEO_SHELL_INTEGRATION_DIR}/paseo-integration.zsh" diff --git a/packages/server/src/terminal/shell-integration/zsh/paseo-integration.zsh b/packages/server/src/terminal/shell-integration/zsh/paseo-integration.zsh new file mode 100644 index 000000000..3759b84e8 --- /dev/null +++ b/packages/server/src/terminal/shell-integration/zsh/paseo-integration.zsh @@ -0,0 +1,17 @@ +if [[ -n "${_PASEO_ZSH_INTEGRATION_LOADED-}" ]]; then + return +fi +typeset -g _PASEO_ZSH_INTEGRATION_LOADED=1 + +autoload -Uz add-zsh-hook + +function _paseo_precmd() { + printf '\e]2;%s\a' "${PWD/#$HOME/~}" +} + +function _paseo_preexec() { + printf '\e]2;%s\a' "$1" +} + +add-zsh-hook precmd _paseo_precmd +add-zsh-hook preexec _paseo_preexec diff --git a/packages/server/src/terminal/terminal-manager.test.ts b/packages/server/src/terminal/terminal-manager.test.ts index 20dadc4e7..b45566992 100644 --- a/packages/server/src/terminal/terminal-manager.test.ts +++ b/packages/server/src/terminal/terminal-manager.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, afterEach } from "vitest"; +import { describe, it, expect, afterEach, vi } from "vitest"; import { createTerminalManager, type TerminalManager } from "./terminal-manager.js"; -import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -301,11 +301,14 @@ describe("TerminalManager", () => { describe("subscribeTerminalsChanged", () => { it("emits cwd snapshots when terminals are created", async () => { manager = createTerminalManager(); - const snapshots: Array<{ cwd: string; terminalNames: string[] }> = []; + const snapshots: Array<{ cwd: string; terminals: Array<{ name: string; title?: string }> }> = []; const unsubscribe = manager.subscribeTerminalsChanged((input) => { snapshots.push({ cwd: input.cwd, - terminalNames: input.terminals.map((terminal) => terminal.name), + terminals: input.terminals.map((terminal) => ({ + name: terminal.name, + ...(terminal.title ? { title: terminal.title } : {}), + })), }); }); @@ -314,16 +317,111 @@ describe("TerminalManager", () => { expect(snapshots).toContainEqual({ cwd: "/tmp", - terminalNames: ["Terminal 1"], + terminals: [{ name: "Terminal 1" }], }); expect(snapshots).toContainEqual({ cwd: "/tmp", - terminalNames: ["Terminal 1", "Dev Server"], + terminals: [{ name: "Terminal 1" }, { name: "Dev Server" }], }); unsubscribe(); }); + it( + "emits updated terminal titles after debounced title changes", + async () => { + await withShell("/bin/sh", async () => { + manager = createTerminalManager(); + const snapshots: Array> = []; + const unsubscribe = manager.subscribeTerminalsChanged((input) => { + snapshots.push( + input.terminals.map((terminal) => ({ + id: terminal.id, + ...(terminal.title ? { title: terminal.title } : {}), + })), + ); + }); + + const session = await manager.createTerminal({ cwd: "/tmp" }); + session.send({ type: "input", data: "printf '\\033]0;Logs\\007'\r" }); + + await waitForCondition( + () => + snapshots.some((snapshot) => + snapshot.some((terminal) => terminal.id === session.id && terminal.title === "Logs"), + ), + 10000, + ); + + unsubscribe(); + }); + }, + 10000, + ); + + it("forwards bound terminal titles through the agent bridge without changing standalone lists", async () => { + await withShell("/bin/sh", async () => { + const onAgentBoundTerminalTitleChange = vi.fn(); + manager = createTerminalManager({ + resolveAgentIdForTerminal: () => "agent-1", + onAgentBoundTerminalTitleChange, + }); + + const snapshots: Array> = []; + const unsubscribe = manager.subscribeTerminalsChanged((input) => { + snapshots.push( + input.terminals.map((terminal) => ({ + id: terminal.id, + ...(terminal.title ? { title: terminal.title } : {}), + })), + ); + }); + + const session = await manager.createTerminal({ cwd: "/tmp" }); + session.send({ type: "input", data: "printf '\\033]0;Agent Shell\\007'\r" }); + + await waitForCondition(() => onAgentBoundTerminalTitleChange.mock.calls.length > 0, 10000); + + expect(onAgentBoundTerminalTitleChange).toHaveBeenCalledWith({ + agentId: "agent-1", + title: "Agent Shell", + }); + expect( + snapshots.some((snapshot) => + snapshot.some((terminal) => terminal.id === session.id && terminal.title === "Agent Shell"), + ), + ).toBe(true); + + unsubscribe(); + }); + }); + + it("forwards initial titles for agent-bound terminals created with command args", async () => { + const packageRoot = mkdtempSync(join(tmpdir(), "terminal-manager-title-script-")); + temporaryDirs.push(packageRoot); + const scriptPath = join(packageRoot, "npm-cli.js"); + writeFileSync(scriptPath, "setTimeout(() => process.exit(0), 1000);\n"); + + const onAgentBoundTerminalTitleChange = vi.fn(); + manager = createTerminalManager({ + resolveAgentIdForTerminal: () => "agent-1", + onAgentBoundTerminalTitleChange, + }); + + await manager.createTerminal({ + cwd: packageRoot, + command: process.execPath, + args: [scriptPath, "run", "dev"], + }); + + await waitForCondition(() => onAgentBoundTerminalTitleChange.mock.calls.length > 0, 10000); + + expect(onAgentBoundTerminalTitleChange).toHaveBeenCalledWith({ + agentId: "agent-1", + title: "npm run dev", + }); + }); + it("emits empty snapshot when last terminal is removed", async () => { manager = createTerminalManager(); const snapshots: Array<{ cwd: string; terminalCount: number }> = []; diff --git a/packages/server/src/terminal/terminal-manager.ts b/packages/server/src/terminal/terminal-manager.ts index 22e016875..915ff315e 100644 --- a/packages/server/src/terminal/terminal-manager.ts +++ b/packages/server/src/terminal/terminal-manager.ts @@ -5,6 +5,7 @@ export interface TerminalListItem { id: string; name: string; cwd: string; + title?: string; } export interface TerminalsChangedEvent { @@ -17,9 +18,12 @@ export type TerminalsChangedListener = (input: TerminalsChangedEvent) => void; export interface TerminalManager { getTerminals(cwd: string): Promise; createTerminal(options: { + id?: string; cwd: string; name?: string; env?: Record; + command?: string; + args?: string[]; }): Promise; registerCwdEnv(options: { cwd: string; env: Record }): void; getTerminal(id: string): TerminalSession | undefined; @@ -29,10 +33,16 @@ export interface TerminalManager { subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void; } -export function createTerminalManager(): TerminalManager { +type AgentBoundTerminalTitleHandler = (input: { agentId: string; title: string }) => Promise | void; + +export function createTerminalManager(options?: { + resolveAgentIdForTerminal?: (terminalId: string) => string | null; + onAgentBoundTerminalTitleChange?: AgentBoundTerminalTitleHandler; +}): TerminalManager { const terminalsByCwd = new Map(); const terminalsById = new Map(); const terminalExitUnsubscribeById = new Map void>(); + const terminalTitleUnsubscribeById = new Map void>(); const terminalsChangedListeners = new Set(); const defaultEnvByRootCwd = new Map>(); @@ -53,6 +63,11 @@ export function createTerminalManager(): TerminalManager { unsubscribeExit(); terminalExitUnsubscribeById.delete(id); } + const unsubscribeTitle = terminalTitleUnsubscribeById.get(id); + if (unsubscribeTitle) { + unsubscribeTitle(); + terminalTitleUnsubscribeById.delete(id); + } terminalsById.delete(id); @@ -96,7 +111,27 @@ export function createTerminalManager(): TerminalManager { const unsubscribeExit = session.onExit(() => { removeSessionById(session.id, { kill: false }); }); + const unsubscribeTitle = session.onTitleChange((title) => { + emitTerminalsChanged({ cwd: session.cwd }); + const normalizedTitle = title?.trim(); + if (!normalizedTitle) { + return; + } + const agentId = options?.resolveAgentIdForTerminal?.(session.id) ?? null; + if (!agentId) { + return; + } + void Promise.resolve( + options?.onAgentBoundTerminalTitleChange?.({ + agentId, + title: normalizedTitle, + }), + ).catch(() => { + // no-op + }); + }); terminalExitUnsubscribeById.set(session.id, unsubscribeExit); + terminalTitleUnsubscribeById.set(session.id, unsubscribeTitle); return session; } @@ -105,6 +140,7 @@ export function createTerminalManager(): TerminalManager { id: input.session.id, name: input.session.name, cwd: input.session.cwd, + title: input.session.getTitle(), }; } @@ -138,9 +174,12 @@ export function createTerminalManager(): TerminalManager { }, async createTerminal(options: { + id?: string; cwd: string; name?: string; env?: Record; + command?: string; + args?: string[]; }): Promise { assertAbsolutePath(options.cwd); @@ -153,8 +192,11 @@ export function createTerminalManager(): TerminalManager { : undefined; const session = registerSession( await createTerminal({ + ...(options.id ? { id: options.id } : {}), cwd: options.cwd, name: options.name ?? defaultName, + ...(options.command ? { command: options.command } : {}), + ...(options.args ? { args: options.args } : {}), ...(mergedEnv ? { env: mergedEnv } : {}), }), ); diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index 834cc1597..20ac2e438 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -1,8 +1,12 @@ import { describe, it, expect, afterEach } from "vitest"; import { + buildTerminalEnvironment, createTerminal, ensureNodePtySpawnHelperExecutableForCurrentPlatform, resolveDefaultTerminalShell, + humanizeProcessTitle, + normalizeProcessTitle, + resolveZshShellIntegrationDir, type TerminalSession, } from "./terminal.js"; import { chmodSync, mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs"; @@ -71,6 +75,23 @@ async function waitForState( throw new Error("Timeout waiting for terminal state predicate to match"); } +async function waitForTitle( + session: TerminalSession, + predicate: (title: string | undefined) => boolean, + timeoutMs = 5000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const title = session.getTitle(); + if (predicate(title)) { + return title; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + + throw new Error("Timeout waiting for terminal title predicate to match"); +} + describe("Terminal", () => { const sessions: TerminalSession[] = []; const temporaryDirs: string[] = []; @@ -94,6 +115,30 @@ describe("Terminal", () => { } describe("createTerminal", () => { + it("keeps full process titles while stripping path prefixes", () => { + expect(normalizeProcessTitle(" /usr/local/bin/npm run dev ")).toBe("npm run dev"); + expect(normalizeProcessTitle("/opt/homebrew/bin/node /tmp/work/npm-cli.js run dev")).toBe( + "node npm-cli.js run dev", + ); + expect(normalizeProcessTitle("")).toBeUndefined(); + }); + + it("humanizes interpreter-backed package manager commands", () => { + expect( + humanizeProcessTitle("/usr/local/bin/node /opt/homebrew/lib/node_modules/npm/bin/npm-cli.js run dev"), + ).toBe("npm run dev"); + expect( + humanizeProcessTitle("/usr/bin/env FOO=bar /opt/homebrew/bin/node /tmp/npm-cli.js test"), + ).toBe("npm test"); + }); + + it("drops common interpreter prefixes for direct scripts", () => { + expect(humanizeProcessTitle("/usr/bin/python3 /tmp/server.py --port 3000")).toBe( + "server.py --port 3000", + ); + expect(humanizeProcessTitle("/bin/bash /tmp/dev.sh")).toBe("dev.sh"); + }); + it("ensures darwin prebuild spawn-helper is executable", () => { const packageRoot = mkdtempSync(join(tmpdir(), "terminal-node-pty-helper-")); temporaryDirs.push(packageRoot); @@ -140,6 +185,20 @@ describe("Terminal", () => { expect(session.cwd).toBe("/tmp"); }); + it("sets zsh wrapper env when spawning zsh", () => { + const resolvedEnv = buildTerminalEnvironment({ + shell: "/bin/zsh", + env: { + HOME: "/tmp/paseo-home", + ZDOTDIR: "/tmp/paseo-zdotdir", + }, + }); + + expect(resolvedEnv.TERM).toBe("xterm-256color"); + expect(resolvedEnv.PASEO_ZSH_ZDOTDIR).toBe("/tmp/paseo-zdotdir"); + expect(resolvedEnv.ZDOTDIR).toBe(resolveZshShellIntegrationDir()); + }); + it("uses custom name when provided", async () => { const session = trackSession( await createTerminal({ @@ -192,6 +251,28 @@ describe("Terminal", () => { expect(state.rows).toBe(40); expect(state.cols).toBe(120); }); + + it("captures exit diagnostics from the terminal buffer", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + command: "/bin/sh", + args: ["-lc", "printf 'launch failed\\ncommand missing\\n'; exit 127"], + }), + ); + + const exitInfo = await new Promise>>( + (resolve) => { + session.onExit((info) => resolve(info)); + }, + ); + + expect(exitInfo.exitCode).toBe(127); + expect(exitInfo.signal).toBeNull(); + // lastOutputLines may be empty if the process exits before xterm processes the data write + expect(Array.isArray(exitInfo.lastOutputLines)).toBe(true); + expect(session.getExitInfo()).toEqual(exitInfo); + }); }); describe("send input", () => { @@ -268,6 +349,154 @@ describe("Terminal", () => { }); }); + describe("terminal title", () => { + it("restores the user's ZDOTDIR through the zsh wrapper", async () => { + const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-home-")); + temporaryDirs.push(homeDir); + const realZdotdir = join(homeDir, ".config", "zsh"); + mkdirSync(realZdotdir, { recursive: true }); + writeFileSync(join(realZdotdir, ".zshenv"), "export PASEO_TEST_REAL_ZDOTDIR=1\n"); + + const session = trackSession( + await createTerminal({ + cwd: homeDir, + command: "/bin/zsh", + args: ["-c", "printf '%s\\n%s\\n' \"${ZDOTDIR-}\" \"${PASEO_TEST_REAL_ZDOTDIR-}\""], + env: { + HOME: homeDir, + ZDOTDIR: realZdotdir, + }, + }), + ); + + const exitInfo = await new Promise>>( + (resolve) => { + session.onExit((info) => resolve(info)); + }, + ); + + expect(exitInfo.lastOutputLines).toEqual([realZdotdir, "1"]); + }); + + it("emits the initial title from command args to title listeners", async () => { + const packageRoot = mkdtempSync(join(tmpdir(), "terminal-title-script-")); + temporaryDirs.push(packageRoot); + const scriptPath = join(packageRoot, "npm-cli.js"); + writeFileSync(scriptPath, "setTimeout(() => process.exit(0), 1000);\n"); + + const session = trackSession( + await createTerminal({ + cwd: packageRoot, + command: process.execPath, + args: [scriptPath, "run", "dev"], + }), + ); + const seenTitles: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); + }); + + await waitForTitle(session, (title) => title === "npm run dev"); + await waitForState(session, (state) => state.title === "npm run dev"); + + expect(seenTitles).toContain("npm run dev"); + expect(session.getTitle()).toBe("npm run dev"); + expect(session.getState().title).toBe("npm run dev"); + + unsubscribeTitle(); + }); + + it("emits OSC title updates to title listeners", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const seenTitles: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" }); + + await waitForTitle(session, (title) => title === "Build Log"); + + expect(seenTitles).toContain("Build Log"); + expect(session.getTitle()).toBe("Build Log"); + expect(session.getState().title).toBe("Build Log"); + + unsubscribeTitle(); + }); + + it("debounces rapid title changes and emits only the final title", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const seenTitles: Array = []; + const seenMessages: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); + }); + const unsubscribeMessages = session.subscribe((message) => { + if (message.type === "titleChange") { + seenMessages.push(message.title); + } + }); + + await waitForLines(session, ["$"]); + session.send({ + type: "input", + data: + "printf '\\033]0;First\\007\\033]0;Second\\007\\033]0;Final\\007'\r", + }); + + await waitForTitle(session, (title) => title === "Final"); + + expect(seenTitles).toEqual(["Final"]); + expect(seenMessages).toEqual(["Final"]); + + unsubscribeMessages(); + unsubscribeTitle(); + }); + + it("emits zsh shell integration titles for commands and prompts", async () => { + const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-integration-home-")); + temporaryDirs.push(homeDir); + const realZdotdir = join(homeDir, ".config", "zsh"); + const workingDir = join(homeDir, "dev", "faro"); + mkdirSync(realZdotdir, { recursive: true }); + mkdirSync(workingDir, { recursive: true }); + writeFileSync(join(realZdotdir, ".zshenv"), ""); + writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n"); + + const session = trackSession( + await createTerminal({ + cwd: workingDir, + shell: "/bin/zsh", + env: { + HOME: homeDir, + ZDOTDIR: realZdotdir, + }, + }), + ); + + await waitForLines(session, ["$"]); + await waitForTitle(session, (title) => title === "~/dev/faro"); + + session.send({ type: "input", data: "sleep 1\r" }); + + await waitForTitle(session, (title) => title === "sleep 1"); + await waitForTitle(session, (title) => title === "~/dev/faro", 4000); + }); + }); + describe("colors", () => { it("captures ANSI 16 color codes (mode 1)", async () => { const session = trackSession( diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 60d9c7ef7..9e8c6d4f9 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -2,14 +2,24 @@ import * as pty from "node-pty"; import xterm, { type Terminal as TerminalType } from "@xterm/headless"; import { randomUUID } from "crypto"; import { chmodSync, existsSync, statSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; import stripAnsi from "strip-ansi"; import type { TerminalCell, TerminalState } from "../shared/messages.js"; const { Terminal } = xterm; const require = createRequire(import.meta.url); let nodePtySpawnHelperChecked = false; +const TERMINAL_TITLE_DEBOUNCE_MS = 150; +const TERMINAL_EXIT_OUTPUT_LINE_LIMIT = 12; +const TERMINAL_EXIT_OUTPUT_CHAR_LIMIT = 16000; + +export interface TerminalExitInfo { + exitCode: number | null; + signal: number | null; + lastOutputLines: string[]; +} export type ClientMessage = | { type: "input"; data: string } @@ -18,7 +28,8 @@ export type ClientMessage = export type ServerMessage = | { type: "output"; data: string } - | { type: "snapshot"; state: TerminalState }; + | { type: "snapshot"; state: TerminalState } + | { type: "titleChange"; title?: string }; export interface TerminalSession { id: string; @@ -26,19 +37,30 @@ export interface TerminalSession { cwd: string; send(msg: ClientMessage): void; subscribe(listener: (msg: ServerMessage) => void): () => void; - onExit(listener: () => void): () => void; + onExit(listener: (info: TerminalExitInfo) => void): () => void; + onTitleChange(listener: (title?: string) => void): () => void; getSize(): { rows: number; cols: number }; getState(): TerminalState; + getTitle(): string | undefined; + getExitInfo(): TerminalExitInfo | null; kill(): void; } export interface CreateTerminalOptions { + id?: string; cwd: string; shell?: string; env?: Record; rows?: number; cols?: number; name?: string; + command?: string; + args?: string[]; +} + +interface BuildTerminalEnvironmentInput { + shell: string; + env: Record; } export interface CaptureTerminalLinesOptions { @@ -135,6 +157,29 @@ export function resolveDefaultTerminalShell( return env.SHELL || "/bin/sh"; } +export function resolveZshShellIntegrationDir(): string { + return fileURLToPath(new URL("./shell-integration/zsh", import.meta.url)); +} + +export function buildTerminalEnvironment(input: BuildTerminalEnvironmentInput): Record { + const baseEnv: Record = { + ...process.env, + ...input.env, + TERM: "xterm-256color", + }; + + if (basename(input.shell) !== "zsh") { + return baseEnv; + } + + const originalZdotdir = baseEnv.ZDOTDIR ?? ""; + return { + ...baseEnv, + PASEO_ZSH_ZDOTDIR: originalZdotdir, + ZDOTDIR: resolveZshShellIntegrationDir(), + }; +} + function extractCell(terminal: TerminalType, row: number, col: number): TerminalCell { const buffer = terminal.buffer.active; const line = buffer.getLine(row); @@ -258,6 +303,157 @@ function extractCursorState(terminal: TerminalType): TerminalState["cursor"] { }; } +function normalizeProcessToken(token: string): string { + if (token.length === 0) { + return token; + } + + const quote = + token.startsWith('"') && token.endsWith('"') + ? '"' + : token.startsWith("'") && token.endsWith("'") + ? "'" + : ""; + const rawToken = quote ? token.slice(1, -1) : token; + if (rawToken.length === 0) { + return token; + } + + const assignmentMatch = rawToken.match(/^([A-Za-z_][A-Za-z0-9_]*=)(.+)$/); + const prefix = assignmentMatch ? assignmentMatch[1] : ""; + const value = assignmentMatch ? assignmentMatch[2] : rawToken; + if (!value.includes("/")) { + return token; + } + + const normalized = `${prefix}${basename(value)}`; + return quote ? `${quote}${normalized}${quote}` : normalized; +} + +export function normalizeProcessTitle(processTitle: string): string | undefined { + const trimmed = processTitle.trim().replace(/\s+/g, " "); + if (trimmed.length === 0) { + return undefined; + } + + const normalized = trimmed + .split(" ") + .map((token) => normalizeProcessToken(token)) + .join(" ") + .trim(); + return normalized.length > 0 ? normalized : undefined; +} + +const PROCESS_INTERPRETERS = new Set([ + "bash", + "bun", + "deno", + "node", + "nodejs", + "python", + "python3", + "ruby", + "sh", + "tsx", + "zsh", +]); + +const PACKAGE_MANAGER_SCRIPT_NAMES = new Map([ + ["bun.js", "bun"], + ["npm-cli.js", "npm"], + ["npx-cli.js", "npx"], + ["pnpm.cjs", "pnpm"], + ["pnpm.js", "pnpm"], + ["yarn.cjs", "yarn"], + ["yarn.js", "yarn"], +]); + +export function humanizeProcessTitle(processTitle: string): string | undefined { + const normalized = normalizeProcessTitle(processTitle); + if (!normalized) { + return undefined; + } + + const tokens = normalized.split(" ").filter(Boolean); + if (tokens.length === 0) { + return undefined; + } + + while (tokens[0] === "env") { + tokens.shift(); + while (tokens[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) { + tokens.shift(); + } + } + + if (tokens.length === 0) { + return normalized; + } + + const first = tokens[0]; + const second = tokens[1]; + if (PROCESS_INTERPRETERS.has(first) && second) { + const packageManager = PACKAGE_MANAGER_SCRIPT_NAMES.get(second); + if (packageManager) { + return [packageManager, ...tokens.slice(2)].join(" ").trim() || packageManager; + } + + if (!second.startsWith("-")) { + return [second, ...tokens.slice(2)].join(" ").trim(); + } + } + + return normalized; +} + +function extractLastOutputLines(terminal: TerminalType, limit: number): string[] { + const buffer = terminal.buffer.active; + const mergedLines: string[] = []; + + for (let row = 0; row < buffer.length; row++) { + const line = buffer.getLine(row); + if (!line) { + continue; + } + + const text = line.translateToString(true); + const isWrapped = (line as { isWrapped?: boolean }).isWrapped === true; + if (isWrapped && mergedLines.length > 0) { + mergedLines[mergedLines.length - 1] += text; + continue; + } + mergedLines.push(text); + } + + while (mergedLines.length > 0 && mergedLines[0]?.trim().length === 0) { + mergedLines.shift(); + } + while (mergedLines.length > 0 && mergedLines[mergedLines.length - 1]?.trim().length === 0) { + mergedLines.pop(); + } + + return mergedLines.slice(-limit); +} + +function stripAnsiSequences(input: string): string { + return input.replace( + /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\].*?(?:\x07|\x1b\\))/g, + "", + ); +} + +function extractLastOutputLinesFromText(text: string, limit: number): string[] { + const normalized = stripAnsiSequences(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + const lines = normalized.split("\n").map((line) => line.trimEnd()); + while (lines[0]?.trim().length === 0) { + lines.shift(); + } + while (lines[lines.length - 1]?.trim().length === 0) { + lines.pop(); + } + return lines.slice(-limit); +} + function cellsToPlainText(cells: TerminalCell[], options: { stripAnsi: boolean }): string { const text = cells.map((cell) => cell.char).join("").trimEnd(); return options.stripAnsi ? stripAnsi(text) : text; @@ -320,15 +516,23 @@ export async function createTerminal(options: CreateTerminalOptions): Promise void>(); - const exitListeners = new Set<() => void>(); + const exitListeners = new Set<(info: TerminalExitInfo) => void>(); + const titleChangeListeners = new Set<(title?: string) => void>(); let killed = false; let disposed = false; let exitEmitted = false; + let exitInfo: TerminalExitInfo | null = null; + let recentOutputText = ""; + let title: string | undefined; + let pendingTitle: string | undefined; + let titleDebounceTimer: ReturnType | null = null; // Create xterm.js headless terminal const terminal = new Terminal({ @@ -341,18 +545,43 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { if (params.length === 0 || (params.length === 1 && params[0] === 0)) { @@ -362,14 +591,41 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { + if (disposed || killed) { + return; + } + pendingTitle = nextTitle.trim().length > 0 ? nextTitle : undefined; + if (titleDebounceTimer) { + clearTimeout(titleDebounceTimer); + } + titleDebounceTimer = setTimeout(() => { + titleDebounceTimer = null; + emitTitleChange(pendingTitle); + }, TERMINAL_TITLE_DEBOUNCE_MS); + }); + + function buildExitInfo(input?: { exitCode?: number | null; signal?: number | null }): TerminalExitInfo { + const lastOutputLines = extractLastOutputLines(terminal, TERMINAL_EXIT_OUTPUT_LINE_LIMIT); + return { + exitCode: input?.exitCode ?? null, + signal: input?.signal && input.signal > 0 ? input.signal : null, + lastOutputLines: + lastOutputLines.length > 0 + ? lastOutputLines + : extractLastOutputLinesFromText(recentOutputText, TERMINAL_EXIT_OUTPUT_LINE_LIMIT), + }; + } + + function emitExit(info: TerminalExitInfo): void { if (exitEmitted) { return; } exitEmitted = true; + exitInfo = info; for (const listener of Array.from(exitListeners)) { try { - listener(); + listener(info); } catch { // no-op } @@ -382,14 +638,24 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { if (killed) return; + recentOutputText = `${recentOutputText}${data}`; + if (recentOutputText.length > TERMINAL_EXIT_OUTPUT_CHAR_LIMIT) { + recentOutputText = recentOutputText.slice(-TERMINAL_EXIT_OUTPUT_CHAR_LIMIT); + } terminal.write(data, () => { if (disposed || killed) { return; @@ -400,9 +666,14 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { + ptyProcess.onExit((event) => { killed = true; - emitExit(); + emitExit( + buildExitInfo({ + exitCode: event.exitCode, + signal: event.signal, + }), + ); disposeResources(); }); @@ -413,6 +684,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise void): () => void { + function onExit(listener: (info: TerminalExitInfo) => void): () => void { if (killed) { queueMicrotask(() => { try { - listener(); + listener(exitInfo ?? buildExitInfo()); } catch { // no-op } @@ -473,11 +745,38 @@ export async function createTerminal(options: CreateTerminalOptions): Promise void): () => void { + titleChangeListeners.add(listener); + if (title !== undefined) { + queueMicrotask(() => { + if (disposed || !titleChangeListeners.has(listener)) { + return; + } + try { + listener(title); + } catch { + // no-op + } + }); + } + return () => { + titleChangeListeners.delete(listener); + }; + } + + function getTitle(): string | undefined { + return title; + } + + function getExitInfo(): TerminalExitInfo | null { + return exitInfo; + } + function kill(): void { if (!killed) { killed = true; ptyProcess.kill(); - emitExit(); + emitExit(buildExitInfo()); } disposeResources(); } @@ -492,8 +791,11 @@ export async function createTerminal(options: CreateTerminalOptions): Promise