diff --git a/README.md b/README.md index ae6f7a22..92ce289b 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ The dashboard is the visual face of dreb: every agent session on the host, live -**Fleet overview.** Home base is every session across every project: live sessions with status chips (running / needs-attention / idle / error), activity lines, running subagents, task progress, context usage, and model — plus past sessions grouped by project, resumable with one tap. Terminal provider/API failures show their reason on the fleet card; transient failures clear that terminal card state when automatic retry begins. Live cards update through compact SSE snapshots instead of re-fetching the full cross-project inventory on every turn, keeping weak mobile links responsive. When a session needs input, the browser tab badges and (opt-in) sends a service-worker notification (installable PWA, works on Android and iOS). For opt-in local mobile transport profiling, see the [dashboard docs](packages/coding-agent/docs/dashboard.md#mobile-transport-profiling). +**Fleet overview.** Home base is every session across every project: live sessions with status chips (running / needs-attention / idle / error), activity lines, running subagents, task progress, context usage, and model — plus past sessions grouped by project, resumable with one tap. Sessions copied from another host or whose recorded project directory has moved remain visible; if that historical path is unavailable, Dashboard asks for an existing runtime project directory and keeps both paths visible without rewriting the session header. Terminal provider/API failures show their reason on the fleet card; transient failures clear that terminal card state when automatic retry begins. Live cards update through compact SSE snapshots instead of re-fetching the full cross-project inventory on every turn, keeping weak mobile links responsive. When a session needs input, the browser tab badges and (opt-in) sends a service-worker notification (installable PWA, works on Android and iOS). For opt-in local mobile transport profiling, see the [dashboard docs](packages/coding-agent/docs/dashboard.md#mobile-transport-profiling). **Full-parity session view.** Not a reduced chat client: streaming markdown, thinking blocks, bespoke tool cards (read/write/edit/bash and markdown-rendering tools), sanitized inline PNG/JPEG/GIF/WebP images returned by any tool, task panels, queued-message chips, image attach/paste with sent-image transcript previews, built-in slash-command autocomplete and execution (including model/settings, scoped-models, session tree, fork, compact, import/export, dream, resume/reload, and new/quit), model/thinking switchers, fork-from-message, HTML export. A fleet sidebar beside the transcript lists every other live session with the fleet cards' project, status/reasons, activity/latest-assistant preview, subagent summaries, tasks, model, context, cost, and message/activity metadata. Cards stay deterministically ordered (project path, then session start time), and clicking one loads that session's transcript without carrying over the previous view's controls or drafts. Desktop collapse and draggable width persist browser-locally; the resize handle also supports arrow keys and Home/End. While hidden, the toggle border reflects the highest-priority other-session status (error → needs attention → running → idle). On phones it becomes an overlay drawer opened from the session bar. The mounted header stays synchronized with live runtime snapshots and authoritative detail refreshes; confirmed model/thinking changes do not flash back to older values, and compacted context usage remains visible as a conservative estimate until fresh provider usage arrives. `/scoped-models` opens the Settings editor for the session's current project context. Built-ins are intercepted generically and rejected fail-closed at the RPC prompt boundary, so unsupported or future commands show guidance instead of leaking into model input. Provider/API failures render inline on the failed assistant attempt with any partial output preserved, including after refresh or recovery; transient failures then switch the session status to retrying without erasing that history. Tool-result images remain visible to the human even when the active model is text-only. While the agent works you can **steer** (inject into the running turn), **queue follow-ups**, or **stop** — the same queue semantics as the TUI. diff --git a/package-lock.json b/package-lock.json index b14d83d4..77d6979b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dreb", - "version": "2.66.1", + "version": "2.66.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dreb", - "version": "2.66.1", + "version": "2.66.2", "workspaces": [ "packages/*", "packages/coding-agent/examples/extensions/with-deps", @@ -10934,7 +10934,7 @@ }, "packages/agent": { "name": "@dreb/agent-core", - "version": "2.66.1", + "version": "2.66.2", "license": "MIT", "dependencies": { "@dreb/ai": "*" @@ -10963,7 +10963,7 @@ }, "packages/ai": { "name": "@dreb/ai", - "version": "2.66.1", + "version": "2.66.2", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.73.0", @@ -11109,7 +11109,7 @@ }, "packages/coding-agent": { "name": "@dreb/coding-agent", - "version": "2.66.1", + "version": "2.66.2", "license": "MIT", "dependencies": { "@dreb/agent-core": "*", @@ -11238,7 +11238,7 @@ }, "packages/dashboard": { "name": "@dreb/dashboard", - "version": "2.66.1", + "version": "2.66.2", "license": "MIT", "dependencies": { "@dreb/coding-agent": "*", @@ -11470,7 +11470,7 @@ }, "packages/semantic-search": { "name": "@dreb/semantic-search", - "version": "2.66.1", + "version": "2.66.2", "license": "MIT", "dependencies": { "@huggingface/transformers": "^4.0.1", @@ -11519,7 +11519,7 @@ }, "packages/telegram": { "name": "@dreb/telegram", - "version": "2.66.1", + "version": "2.66.2", "license": "MIT", "dependencies": { "@dreb/coding-agent": "*", @@ -11552,7 +11552,7 @@ }, "packages/tui": { "name": "@dreb/tui", - "version": "2.66.1", + "version": "2.66.2", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", diff --git a/package.json b/package.json index 49fe63e5..eca026b1 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "node": "22.x" }, "packageManager": "npm@11.5.1", - "version": "2.66.1", + "version": "2.66.2", "dependencies": { "@dreb/coding-agent": "*", "@mariozechner/jiti": "^2.6.5", diff --git a/packages/agent/package.json b/packages/agent/package.json index bb009847..7eae2e6a 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/agent-core", - "version": "2.66.1", + "version": "2.66.2", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", diff --git a/packages/ai/package.json b/packages/ai/package.json index b0700f38..953c0b97 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/ai", - "version": "2.66.1", + "version": "2.66.2", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index db8c4f63..bbbd6d35 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -81,7 +81,7 @@ Or use a custom provider (corporate proxy, Bedrock, etc.) — see [Custom provid Then just talk to dreb. All 13 standard built-in tools are enabled by default (unless `backgroundAgents.maxConcurrentSubagents` is `0`, which removes `subagent` from new parent sessions): `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`, `web_search`, `web_fetch`, `subagent`, `wait`, `watch_github_ci`, and `ask_user`. Use `--tools` to restrict to a subset (e.g., `--tools read,grep,find,ls` for read-only). Three additional tools — `search`, `skill`, and `tasks_update` — are always active regardless of `--tools`. `suggest_next` is active by default but excluded when `--tools` is specified. The model uses these to fulfill your requests. Add capabilities via [skills](#skills), [prompt templates](#prompt-templates), [extensions](#extensions), or [packages](#packages). -**Also available:** [`@dreb/telegram`](https://www.npmjs.com/package/@dreb/telegram) — run dreb as a Telegram bot with live tool status and visible results for user-facing tools (`npm install -g @dreb/telegram`). [`@dreb/dashboard`](https://www.npmjs.com/package/@dreb/dashboard) — run `dreb dashboard` for a browser UI with fleet overview, full chat steering, dismissible session banners confined to the transcript column without shifting the fleet sidebar, a grouped header with back navigation on the left, model/thinking controls in a separate row, and the fleet sidebar toggle in the bottom usage/context-stats row (also available with details collapsed), explicit navigation that preserves viewed closed transcripts as read-only snapshots, a TUI-parity rolling TPS indicator with long-term delta, generic fail-closed built-in slash-command discovery and execution, inline provider/API failures with partial output preserved, sanitized raster tool images plus sent user uploads retained as bounded transcript previews by default, a bounded all-agent subagent panel with drill-in, a fleet sidebar on session/subagent views with the fleet cards' project/status/reasons, activity/latest-assistant preview, subagents, tasks, model/context/cost, and message/activity metadata (deterministic order, persisted desktop collapse and pointer/keyboard-resizable width, highest-priority status border while hidden, safe-area-aware mobile drawer with managed focus and Escape-to-close without aborting the agent), host file browser, dreb memory editor with exact-revision saves and automatic index cleanup on delete, curated appearance themes with per-browser light/dark mode and font selection (theme default, Google-hosted IBM Plex Mono, or the bundled self-hosted JetBrains Mono, Fira Code, Iosevka, OpenDyslexic, and Atkinson Hyperlegible), and resilient Tailscale/rotating-code pairing with a configurable 180-day default and advance expiry warnings (`npm install -g @dreb/dashboard`; see [docs/dashboard.md](docs/dashboard.md)). Tool images cross browser-facing transport as content-addressed references; in dashboard mode each unique image crosses the RPC stdout pipe at most once per child process (later occurrences become references), and browser-local Settings offers placeholders, bounded previews, or informed-opt-in originals, with size disclosure and confirmation above 1 MiB. Full-resolution HTML export remains self-contained. The Memories screen is dreb-only (`~/.dreb/memory` and populated active/on-disk-session project `.dreb/memory`; empty projects are omitted), shows complete indexes with a >200-line warning, opens local index links within the selected scope, replaces stale editor content with visible loading feedback, surfaces malformed entry frontmatter for repair, preserves drafts on conflicts, and does not create/rename entries or expose Claude paths. Compact SSE snapshots update live fleet cards without repeatedly fetching the cross-project inventory, and session drill-in hydrates state, messages, and background agents through one ordered snapshot request. Direct sidebar switching, including leaving a closed session, loads the destination with its own screen state and per-session text draft; abandoned hydration cannot overwrite the new view. While drill-in remains mounted, authoritative detail refreshes keep its header current, confirmed model/thinking changes are reconciled without stale snapshot rollback, and post-compaction context uses a conservative estimate until fresh provider usage arrives. Terminal provider failures show their reason on fleet cards, while transient failures clear terminal state when automatic retry begins and remain recorded inline on the failed attempt. Its top bar and persistent session header indicators report connecting, connected, retrying, resyncing, disconnected, or auth failed; bounded SSE replay plus an explicit snapshot barrier restores session state, tasks, and image references after a reload, restart, gap, backpressure disconnect, or stalled stream, while authenticated image routes recover bytes separately from authoritative transcripts. +**Also available:** [`@dreb/telegram`](https://www.npmjs.com/package/@dreb/telegram) — run dreb as a Telegram bot with live tool status and visible results for user-facing tools (`npm install -g @dreb/telegram`). [`@dreb/dashboard`](https://www.npmjs.com/package/@dreb/dashboard) — run `dreb dashboard` for a browser UI with fleet overview, full chat steering, dismissible session banners confined to the transcript column without shifting the fleet sidebar, a grouped header with back navigation on the left, model/thinking controls in a separate row, and the fleet sidebar toggle in the bottom usage/context-stats row (also available with details collapsed), explicit navigation that preserves viewed closed transcripts as read-only snapshots, a TUI-parity rolling TPS indicator with long-term delta, generic fail-closed built-in slash-command discovery and execution, inline provider/API failures with partial output preserved, sanitized raster tool images plus sent user uploads retained as bounded transcript previews by default, a bounded all-agent subagent panel with drill-in, a fleet sidebar on session/subagent views with the fleet cards' project/status/reasons, activity/latest-assistant preview, subagents, tasks, model/context/cost, and message/activity metadata (deterministic order, persisted desktop collapse and pointer/keyboard-resizable width, highest-priority status border while hidden, safe-area-aware mobile drawer with managed focus and Escape-to-close without aborting the agent), host file browser, dreb memory editor with exact-revision saves and automatic index cleanup on delete, curated appearance themes with per-browser light/dark mode and font selection (theme default, Google-hosted IBM Plex Mono, or the bundled self-hosted JetBrains Mono, Fira Code, Iosevka, OpenDyslexic, and Atkinson Hyperlegible), and resilient Tailscale/rotating-code pairing with a configurable 180-day default and advance expiry warnings (`npm install -g @dreb/dashboard`; see [docs/dashboard.md](docs/dashboard.md)). Tool images cross browser-facing transport as content-addressed references; in dashboard mode each unique image crosses the RPC stdout pipe at most once per child process (later occurrences become references), and browser-local Settings offers placeholders, bounded previews, or informed-opt-in originals, with size disclosure and confirmation above 1 MiB. Full-resolution HTML export remains self-contained. The Memories screen is dreb-only (`~/.dreb/memory` and populated active/on-disk-session project `.dreb/memory`; empty projects are omitted), shows complete indexes with a >200-line warning, opens local index links within the selected scope, replaces stale editor content with visible loading feedback, surfaces malformed entry frontmatter for repair, preserves drafts on conflicts, and does not create/rename entries or expose Claude paths. Compact SSE snapshots update live fleet cards without repeatedly fetching the cross-project inventory, and session drill-in hydrates state, messages, and background agents through one ordered snapshot request. Direct sidebar switching, including leaving a closed session, loads the destination with its own screen state and per-session text draft; abandoned hydration cannot overwrite the new view. While drill-in remains mounted, authoritative detail refreshes keep its header current, confirmed model/thinking changes are reconciled without stale snapshot rollback, and post-compaction context uses a conservative estimate until fresh provider usage arrives. Terminal provider failures show their reason on fleet cards, while transient failures clear terminal state when automatic retry begins and remain recorded inline on the failed attempt. Its top bar and persistent session header indicators report connecting, connected, retrying, resyncing, disconnected, or auth failed; bounded SSE replay plus an explicit snapshot barrier restores session state, tasks, and image references after a reload, restart, gap, backpressure disconnect, or stalled stream, while authenticated image routes recover bytes separately from authoritative transcripts. Persisted sessions whose recorded working directory is missing remain visible and resumable: Dashboard asks for an existing runtime directory, validates it before starting the child, shows both historical and effective paths, and leaves the original session header unchanged. **Platform notes:** [Windows](docs/windows.md) | [Termux (Android)](docs/termux.md) | [tmux](docs/tmux.md) | [Terminal setup](docs/terminal-setup.md) | [Shell aliases](docs/shell-aliases.md) diff --git a/packages/coding-agent/docs/dashboard.md b/packages/coding-agent/docs/dashboard.md index 9944add3..cf7654a1 100644 --- a/packages/coding-agent/docs/dashboard.md +++ b/packages/coding-agent/docs/dashboard.md @@ -132,8 +132,8 @@ networking window above. | Screen | What it does | |---|---| -| **Fleet** | Home. Live-first: one grid of every live session at the top — status chip (● running / ◆ needs-attention / ○ idle / ✕ error), project path, activity line, live subagent lines, tasks progress, ctx%, model, terminal provider-error reason, last activity. Live cards keep a deterministic order by project path, then session start time; needs-attention cards badge the browser tab without jumping around. Below the grid: past sessions grouped by project, three compact rows per group with an "all N on disk" expander, resume and delete. | -| **Session view** | Full chat drill-in. Markdown streaming transcript (text, thinking blocks with expand preference, inline provider/API failures with partial output preserved, agent-result cards, tool cards with bespoke read/write/edit/bash bodies plus full expandable inputs, markdown-rendered results for markdown-contract tools like subagent/skill/web_fetch/suggest_next, and inline tool-result images, compaction/branch summaries, custom messages), per-message copy, tasks panel, a bounded scrollable subagent panel that lists every retained agent newest-first with full running/done counts, a shared dismissible banner region for model fallback, extension notices, retry/compaction/paused/provider status, and local action results, a controls-only dock line with elapsed time plus ■ stop and explicitly labelled retry/compaction aborts, a persistent session-header live indicator, and an info bar with cwd, branch, session name, token breakdown, cost/(sub)/daily rollup, ctx%, a TUI-parity latest-100 median TPS indicator (`~31 tok/s [100] · 10% ↑ median [10000]`), and a stats popover. A fleet sidebar lists every other live session beside the transcript with the fleet cards' project, status/reasons, activity/latest-assistant preview, running/done subagent summaries, task progress, model, context, cost, message count, and last activity, deterministically ordered by project path then session start time (the fleet page's comparator), so needs-attention/error entries are emphasized — a filled attention chip or outlined error chip plus an entry border — without moving position; clicking an entry navigates to that session, desktop collapse and pointer/keyboard-resizable width persist browser-locally (default expanded, 260px), the hidden toggle border reflects the highest-priority other-session status, and neither sidebar nor toggle renders when no other live sessions exist. The subagent drill-in shows the same sidebar with its parent session excluded. Composer supports auto-grow, history, `/` autocomplete from `get_commands`, image attach/paste with sent images retained as user-message previews, queued-message chips with restore-all, steer/follow-up modes, and suggest-next. Registered built-in slash commands are discovered generically, deduplicated ahead of colliding resource commands, and intercepted before prompting: dashboard actions cover settings, model, scoped-models, export/import, name/session stats, fork/tree, new/compact/dream, resume/reload, and quit. `/scoped-models` deep-links to the Settings editor with the session's current cwd as project context; login/logout show an explicit not-yet-implemented notice, while copy/hotkeys/buddy give terminal-only guidance. Future built-ins are intercepted automatically. The RPC prompt boundary rejects any built-in that reaches it during command-loading races or failures, so slash text cannot leak to the model. Attachments are retained and the command is visibly rejected rather than silently discarded. The ⋯ menu covers export HTML, compact, rename, fork-from-message, loaded context, and tool expand/collapse. Session names update live from manual rename or auto-naming. Extension UI requests for select/confirm/input/editor render as modals; a rich `ask`/`ask_user` request renders inline as a single wizard that presents all its questions together — each with Markdown-formatted question text, choices, optional free text — plus an in-card Stop agent action, Escape-to-stop, and the authoritative auto-stop countdown, and is answered as one batch submit. Pending questions set needs-attention state and use the existing hidden-page notification path. Extension notifications for the viewed session render in its banner region; other sessions' and app-global notices use the fixed top-center toast stack. | +| **Fleet** | Home. Live-first: one grid of every live session at the top — status chip (● running / ◆ needs-attention / ○ idle / ✕ error), project path, activity line, live subagent lines, tasks progress, ctx%, model, terminal provider-error reason, last activity. Live cards keep a deterministic order by project path, then session start time; needs-attention cards badge the browser tab without jumping around. Below the grid: past sessions grouped by their historical project, three compact rows per group with an "all N on disk" expander, resume and delete. Sessions remain visible when that historical directory is missing; resume opens an explicit runtime-directory chooser instead of mutating or hiding the session. | +| **Session view** | Full chat drill-in. Markdown streaming transcript (text, thinking blocks with expand preference, inline provider/API failures with partial output preserved, agent-result cards, tool cards with bespoke read/write/edit/bash bodies plus full expandable inputs, markdown-rendered results for markdown-contract tools like subagent/skill/web_fetch/suggest_next, and inline tool-result images, compaction/branch summaries, custom messages), per-message copy, tasks panel, a bounded scrollable subagent panel that lists every retained agent newest-first with full running/done counts, a shared dismissible banner region for model fallback, extension notices, retry/compaction/paused/provider status, and local action results, a controls-only dock line with elapsed time plus ■ stop and explicitly labelled retry/compaction aborts, a persistent session-header live indicator, and an info bar with cwd, original-session provenance when a historical session is resumed in a different runtime directory, branch, session name, token breakdown, cost/(sub)/daily rollup, ctx%, a TUI-parity latest-100 median TPS indicator (`~31 tok/s [100] · 10% ↑ median [10000]`), and a stats popover. A fleet sidebar lists every other live session beside the transcript with the fleet cards' project, status/reasons, activity/latest-assistant preview, running/done subagent summaries, task progress, model, context, cost, message count, last activity, and any original-session provenance, deterministically ordered by project path then session start time (the fleet page's comparator), so needs-attention/error entries are emphasized — a filled attention chip or outlined error chip plus an entry border — without moving position; clicking an entry navigates to that session, desktop collapse and pointer/keyboard-resizable width persist browser-locally (default expanded, 260px), the hidden toggle border reflects the highest-priority other-session status, and neither sidebar nor toggle renders when no other live sessions exist. The subagent drill-in shows the same sidebar with its parent session excluded. Closed-session banners offer direct resume when the captured runtime directory still exists and an explicit directory chooser when the historical directory is missing or should be replaced. Composer supports auto-grow, history, `/` autocomplete from `get_commands`, image attach/paste with sent images retained as user-message previews, queued-message chips with restore-all, steer/follow-up modes, and suggest-next. Registered built-in slash commands are discovered generically, deduplicated ahead of colliding resource commands, and intercepted before prompting: dashboard actions cover settings, model, scoped-models, export/import, name/session stats, fork/tree, new/compact/dream, resume/reload, and quit. `/scoped-models` deep-links to the Settings editor with the session's current cwd as project context; login/logout show an explicit not-yet-implemented notice, while copy/hotkeys/buddy give terminal-only guidance. Future built-ins are intercepted automatically. The RPC prompt boundary rejects any built-in that reaches it during command-loading races or failures, so slash text cannot leak to the model. Attachments are retained and the command is visibly rejected rather than silently discarded. The ⋯ menu covers export HTML, compact, rename, fork-from-message, loaded context, and tool expand/collapse. Session names update live from manual rename or auto-naming. Extension UI requests for select/confirm/input/editor render as modals; a rich `ask`/`ask_user` request renders inline as a single wizard that presents all its questions together — each with Markdown-formatted question text, choices, optional free text — plus an in-card Stop agent action, Escape-to-stop, and the authoritative auto-stop countdown, and is answered as one batch submit. Pending questions set needs-attention state and use the existing hidden-page notification path. Extension notifications for the viewed session render in its banner region; other sessions' and app-global notices use the fixed top-center toast stack. | | **Subagent view** | Transcript of a background agent: live events via the RPC relay, hydrated from the agent's on-disk session log (`/subagents/:agentId/messages`) so the transcript survives browser reloads. Shows the task, streaming output, tool activity, and any safe Dispatch Arbiter changed/unchanged/failure records with the final agent/model/thinking. No raw arbiter output is displayed or transported. While the child is running, a composer sends the user's text unchanged to that specific child as steering input, displays its pending steering queue, and reports its effective `one-at-a-time` or `all` delivery mode. Completed, failed, rehydrated, and unavailable children remain read-only. | | **Files** | Host-wide browser with places shortcuts (home, /tmp, project roots), breadcrumbs to `/`, new-folder, download, drop-zone/picker upload with explicit collision prompts, and "new session here" on any directory. It also shows the **effective global nested-context trust** for the displayed canonical directory: untrusted, trusted by that root, inherited from a granting root, or global expert trust-all. You can trust the displayed folder and descendants, or untrust the actual granting root; untrusting an inherited folder removes that root's trust for all descendants. | | **Memories** | Dreb-only memory management for `~/.dreb/memory` and `.dreb/memory` under active/disk project roots. It lists existing `MEMORY.md` indexes and direct child `.md` entries only (no Claude paths, create, or rename), shows entry frontmatter or metadata errors so malformed files can be repaired, renders sanitized Markdown preview beside a raw editor, and uses exact SHA-256 revisions so stale saves/deletes return conflicts while preserving drafts. The index view is complete, not truncated, and warns when it exceeds the 200-line memory-index convention. Entry deletion requires both entry and index revisions, removes only matching safe Markdown-link index lines (`file.md` / `./file.md`), writes the cleaned index atomically before unlinking the entry, and rolls back loudly if the unlink fails. | @@ -205,7 +205,32 @@ captured) and **Return to fleet** actions. It is not a live runtime: composers, steering, model/thinking controls, stop controls, polling, and other runtime actions are disabled. Leaving that session's main/subagent route family releases the retained snapshot; Fleet shows no live card, and the authoritative on-disk -session remains in its project group for normal resume. +session remains in its project group for normal resume. Closed views also offer +**Choose directory…** if their captured runtime path has disappeared. + +### Historical and runtime working directories + +A persisted session's `cwd` is historical metadata: it records where the session +started and remains unchanged when the JSONL file is copied to another host or +its project moves. Dashboard inventory reports that session even when the path no +longer exists. Missing paths are labelled unavailable and are excluded from +memory scopes, recent-project choices, and new-session shortcuts. + +A live runtime has a separate effective CWD. Valid historical paths are +canonicalized and continue to resume directly. If the historical path is +unavailable, Dashboard requires the user to choose an existing absolute +directory; missing, unreadable, and non-directory paths are rejected before the +RPC child is created. Tools, initial project context, settings, extensions, skills, git state, +and project agent discovery use the effective runtime directory. Fleet cards and +session chrome show that effective directory and retain the historical path as a +separate note whenever they differ. Switching to another transcript inside an +existing runtime does not change the process CWD. + +Choosing a fallback never rewrites the original session header. Normal resumed +work can still append conversation entries. Starting `/new` from that runtime +records the effective runtime directory in the new session's header, while the +source transcript retains its historical path. Existing session-format migration +behavior is unchanged. ### Dispatch Arbiter observability @@ -311,9 +336,12 @@ nor a disk inventory scan. Live runtime state and on-disk session inventory have separate refresh paths. Before fleet, inventory, or resync serialization, the server explicitly projects -each on-disk session to the declared browser DTO and bounds its first-message -preview to 256 Unicode characters. Internal parent paths and complete searchable -transcript text never cross this browser boundary. After creating, resuming, +each on-disk session to the declared browser DTO, includes server-validated +historical-CWD availability and its canonical candidate, and bounds its +first-message preview to 256 Unicode characters. Unavailable historical paths +remain visible but do not enter operational project-root inventories. Internal +parent paths and complete searchable transcript text never cross this browser +boundary. After creating, resuming, stopping, or deleting a session, the client narrowly refreshes the disk list through `GET /api/sessions` rather than reloading the whole fleet. While the Fleet screen or a session/subagent fleet sidebar is visible, it polls live-runtime diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index 8e07ccf8..ba00919d 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -18,6 +18,19 @@ await client.start(); This enables per-user filesystem isolation by plain Unix DAC: give each authenticated user a dedicated UID and a working directory owned by that UID at mode `0700`. If the parent lacks the required capability (or the platform doesn't support `uid`/`gid`, e.g. Windows), the spawn fails and `start()` rejects rather than silently running as the parent user. +### Runtime CWD and historical session CWD + +`RpcClientOptions.cwd` is the effective runtime directory supplied to the child +process. Tools, resource and project-agent discovery, git/tab-title metadata, +and relative runtime commands use it. Opening or switching to a persisted JSONL +session does not replace that process directory. + +A session listing's `cwd` is different: it is historical metadata copied from +the session header. It can name a moved or missing directory and is not rewritten +merely because a host launches the transcript in another runtime directory. +Embedders should display both values when they differ rather than treating the +historical field as a safe process-spawn path. + ### Process exit and pipe errors `RpcClient.onExit(listener)` receives an `RpcExitInfo` when the child dies: `{ code, signal }` from a process `exit`, or `{ error }` from a spawn/runtime `error`. Process exits also carry `stderrTail` — the last **2000 characters** of the child's captured stderr — so a host that only watches for exit codes can still surface the child's own diagnostic (for example the stdout backpressure guard's abort message) instead of an opaque exit code. In-flight requests are rejected with the exit reason. @@ -203,7 +216,7 @@ Response: {"type": "response", "command": "dream", "success": true, "data": {"message": "Dream backup path: /home/user/.dreb/memory-archive"}} ``` -A consolidation run performs and verifies the backup before prompting the agent. Backup, lock, path-validation, settings-write, or consolidation failures are explicit RPC errors. +A relative backup path is resolved from the effective runtime CWD, not the historical CWD in a resumed session header. A consolidation run performs and verifies the backup before prompting the agent. Backup, lock, path-validation, settings-write, or consolidation failures are explicit RPC errors. ### State @@ -302,7 +315,7 @@ Response: #### get_git_branch -Get the current git branch for the session cwd. Returns `null` outside a git repository and `"detached"` for detached HEAD. +Get the current git branch for the effective runtime CWD. Returns `null` outside a git repository and `"detached"` for detached HEAD. ```json {"type": "get_git_branch"} @@ -1054,7 +1067,7 @@ The extension SDK's `dreb.getCommands()` contract is unchanged: it continues to #### list_sessions -List sessions for the current working directory. Returns sessions sorted by most recently modified first. +List sessions associated with the current persisted session store and its historical working-directory provenance. Returns sessions sorted by most recently modified first. This is a transcript listing operation; switching one of these sessions does not change the RPC process's effective runtime CWD. ```json {"type": "list_sessions"} @@ -1178,7 +1191,7 @@ The response data is `{ "steeringMode": "one-at-a-time" | "all", "pending": RpcP #### list_agent_types -List discoverable subagent types for the current session working directory. This includes package-bundled agents, user-level agents, and project-level agents in `.dreb/agents/*.md`. Results are sorted by `name`. +List discoverable subagent types for the effective runtime working directory. This includes package-bundled agents, user-level agents, and project-level agents in `.dreb/agents/*.md`. Results are sorted by `name`. ```json {"type": "list_agent_types"} diff --git a/packages/coding-agent/docs/session.md b/packages/coding-agent/docs/session.md index 1e7080d6..4dcca989 100644 --- a/packages/coding-agent/docs/session.md +++ b/packages/coding-agent/docs/session.md @@ -386,7 +386,7 @@ Key methods for working with sessions programmatically. - `SessionManager.listAll(onProgress?)` - List all sessions across all projects ### Instance Methods - Session Management -- `newSession(options?)` - Start a new session (options: `{ parentSession?: string }`) +- `newSession(options?)` - Start a new session (options: `{ parentSession?: string, cwd?: string }`) - `setSessionFile(path)` - Switch to a different session file - `createBranchedSession(leafId)` - Extract a branch to a new session file while preserving the current session name metadata diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 489680b2..58c80765 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/coding-agent", - "version": "2.66.1", + "version": "2.66.2", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "drebConfig": { diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 526beec8..c0eb9cc4 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1479,6 +1479,11 @@ export class AgentSession { return this.agent.state; } + /** Effective runtime working directory used by tools, resources, and project discovery. */ + get cwd(): string { + return this._cwd; + } + /** Current session task list (read-only) */ get tasks(): readonly SessionTask[] { return this._tasks; @@ -2209,7 +2214,7 @@ export class AgentSession { await this.abort(); this.agent.reset(); this._dispatchArbiter.clearContext(); - this.sessionManager.newSession({ parentSession: options?.parentSession }); + this.sessionManager.newSession({ parentSession: options?.parentSession, cwd: this.cwd }); this.agent.sessionId = this.sessionManager.getSessionId(); this._steeringMessages = []; this._followUpMessages = []; @@ -3984,7 +3989,7 @@ export class AgentSession { ); } const { cancelled } = await this._performFork(entryId, () => { - this.sessionManager.createBranchedSession(entryId); + this.sessionManager.createBranchedSession(entryId, this.cwd); }); return { selectedText: "", cancelled }; } @@ -3996,9 +4001,9 @@ export class AgentSession { // offered as editor pre-fill. const { cancelled } = await this._performFork(entryId, (previousSessionFile) => { if (!selectedEntry.parentId) { - this.sessionManager.newSession({ parentSession: previousSessionFile }); + this.sessionManager.newSession({ parentSession: previousSessionFile, cwd: this.cwd }); } else { - this.sessionManager.createBranchedSession(selectedEntry.parentId); + this.sessionManager.createBranchedSession(selectedEntry.parentId, this.cwd); } }); diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index a676e6bf..438ebbe0 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -41,6 +41,8 @@ export interface SessionHeader { export interface NewSessionOptions { id?: string; parentSession?: string; + /** Working directory to record for a genuinely new session. */ + cwd?: string; } export interface SessionEntryBase { @@ -750,6 +752,7 @@ export class SessionManager { newSession(options?: NewSessionOptions): string | undefined { this.sessionId = options?.id ?? randomUUID(); + this.cwd = options?.cwd ?? this.cwd; const timestamp = new Date().toISOString(); const header: SessionHeader = { type: "session", @@ -1211,9 +1214,10 @@ export class SessionManager { /** * Create a new session file containing only the path from root to the specified leaf. * Useful for extracting a single conversation path from a branched session. + * The optional cwd records the effective working directory for the new branch. * Returns the new session file path, or undefined if not persisting. */ - createBranchedSession(leafId: string): string | undefined { + createBranchedSession(leafId: string, cwd?: string): string | undefined { const previousSessionFile = this.sessionFile; const path = this.getBranch(leafId); if (path.length === 0) { @@ -1237,6 +1241,7 @@ export class SessionManager { const timestamp = new Date().toISOString(); const fileTimestamp = timestamp.replace(/[:.]/g, "-"); const newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`); + this.cwd = cwd ?? this.cwd; const header: SessionHeader = { type: "session", diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 6cbc69fe..f7bfb0c7 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -204,7 +204,7 @@ async function runDreamForRpc(session: AgentSession, args = ""): Promise<{ messa return { message: `Dream backup path: ${session.settingsManager.getDreamArchivePath()}` }; } if (command.type === "setBackup") { - const absolutePath = resolveToCwd(command.path, session.sessionManager.getCwd()); + const absolutePath = resolveToCwd(command.path, session.cwd); const context = await resolveDreamContext(session.settingsManager); validateArchivePath(absolutePath, [ context.globalMemoryDir, @@ -1864,7 +1864,7 @@ export async function runRpcMode(session: AgentSession, modelFallbackMessage?: s return dailyCostTracker.getDailyCost(); }; - const cwd = session.sessionManager.getCwd(); + const cwd = session.cwd; const tabTitleSettings = session.settingsManager.getTabTitleSettings(); const tabTitleGenerator = !session.sessionName && tabTitleSettings?.enabled !== false @@ -2036,7 +2036,7 @@ export async function runRpcMode(session: AgentSession, modelFallbackMessage?: s } case "get_git_branch": { - return success(id, "get_git_branch", { branch: getGitBranch(session.sessionManager.getCwd()) }); + return success(id, "get_git_branch", { branch: getGitBranch(session.cwd) }); } case "get_daily_cost": { @@ -2298,6 +2298,8 @@ export async function runRpcMode(session: AgentSession, modelFallbackMessage?: s // ================================================================= case "list_sessions": { + // Session listing follows the persisted session store/provenance. It does + // not imply that switching a transcript changes this process's runtime cwd. const cwd = session.sessionManager.getCwd(); const sessionDir = session.sessionManager.getSessionDir(); const sessions = await SessionManager.list(cwd, sessionDir); @@ -2335,7 +2337,7 @@ export async function runRpcMode(session: AgentSession, modelFallbackMessage?: s case "list_agent_types": { return success(id, "list_agent_types", { - agentTypes: listAgentTypesForRpc(session.sessionManager.getCwd()), + agentTypes: listAgentTypesForRpc(session.cwd), }); } diff --git a/packages/coding-agent/test/agent-session-fork.test.ts b/packages/coding-agent/test/agent-session-fork.test.ts index 4e285112..e7bb7f45 100644 --- a/packages/coding-agent/test/agent-session-fork.test.ts +++ b/packages/coding-agent/test/agent-session-fork.test.ts @@ -12,9 +12,11 @@ * via buildSessionContext + replaceMessages. */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { createHarnessWithExtensions, type Harness } from "./test-harness.js"; -import { assistantMsg, userMsg } from "./utilities.js"; +import { assistantMsg, createTestSession, userMsg } from "./utilities.js"; describe("AgentSession.fork — any message", () => { let harness: Harness; @@ -88,6 +90,34 @@ describe("AgentSession.fork — any message", () => { expect(JSON.stringify(tail)).toContain("a1"); }); + it.each(["assistant", "user"] as const)( + "records the effective runtime cwd when forking from a non-root %s message after fallback resume", + async (role) => { + const { session, sessionManager, tempDir, cleanup } = createTestSession(); + const historicalCwd = join(tempDir, "missing-historical-project"); + sessionManager.newSession({ cwd: historicalCwd }); + + try { + sessionManager.appendMessage(userMsg("q1")); + const assistantId = sessionManager.appendMessage(assistantMsg("a1")); + const userId = sessionManager.appendMessage(userMsg("q2")); + sessionManager.appendMessage(assistantMsg("a2")); + const sourceFile = sessionManager.getSessionFile()!; + + await session.fork(role === "assistant" ? assistantId : userId); + + const branchFile = sessionManager.getSessionFile()!; + const sourceHeader = JSON.parse(readFileSync(sourceFile, "utf8").split("\n")[0]!); + const branchHeader = JSON.parse(readFileSync(branchFile, "utf8").split("\n")[0]!); + expect(sourceHeader.cwd).toBe(historicalCwd); + expect(branchHeader.cwd).toBe(tempDir); + expect(sessionManager.getCwd()).toBe(tempDir); + } finally { + cleanup(); + } + }, + ); + it("throws for a non-message / invalid entry id", async () => { harness = await createHarnessWithExtensions(); const { session, sessionManager } = harness; diff --git a/packages/coding-agent/test/rpc-dashboard-commands.test.ts b/packages/coding-agent/test/rpc-dashboard-commands.test.ts index a2e63c1f..dfccbe30 100644 --- a/packages/coding-agent/test/rpc-dashboard-commands.test.ts +++ b/packages/coding-agent/test/rpc-dashboard-commands.test.ts @@ -1,13 +1,14 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import type { Model } from "@dreb/ai"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createExtensionRuntime } from "../src/core/extensions/loader.js"; import { getGitBranch } from "../src/core/git-branch.js"; import * as outputGuard from "../src/core/output-guard.js"; import { createSyntheticSourceInfo } from "../src/core/source-info.js"; +import type { TabTitleDeps } from "../src/core/tab-title.js"; import type { RpcDashboardSnapshot as AggregateRpcDashboardSnapshot, RpcEvent as AggregateRpcEvent, @@ -33,6 +34,22 @@ import { } from "../src/modes/rpc/rpc-mode.js"; import { createTestResourceLoader, createTestSession } from "./utilities.js"; +const capturedTabTitleDeps = vi.hoisted(() => [] as TabTitleDeps[]); + +vi.mock("../src/core/tab-title.js", () => ({ + TabTitleGenerator: class { + readonly enabled = true; + + constructor(_settings: unknown, deps: TabTitleDeps) { + capturedTabTitleDeps.push(deps); + } + + onToolEnd(): void {} + + onMessageEnd(): void {} + }, +})); + const tempDirs: string[] = []; async function createTempDir(): Promise { @@ -350,6 +367,102 @@ describe("git branch helper used by RPC", () => { }); describe("runRpcMode dashboard dispatcher", () => { + it("uses the effective runtime cwd for git discovery when session provenance differs", async () => { + const { session, sessionManager, tempDir, cleanup } = createTestSession({ inMemory: true }); + mkdirSync(join(tempDir, ".git")); + writeFileSync(join(tempDir, ".git", "HEAD"), "ref: refs/heads/runtime-project\n"); + (sessionManager as unknown as { cwd: string }).cwd = "/missing/historical/project"; + + try { + const outputs = await dispatchRpcCommand(session, { id: "runtime-cwd", type: "get_git_branch" }); + expect(outputs[0]).toMatchObject({ + id: "runtime-cwd", + success: true, + data: { branch: "runtime-project" }, + }); + expect(session.cwd).toBe(tempDir); + expect(session.sessionManager.getCwd()).toBe("/missing/historical/project"); + } finally { + cleanup(); + } + }); + + it("records the effective runtime cwd when starting a new session after fallback resume", async () => { + const { session, sessionManager, tempDir, cleanup } = createTestSession({ inMemory: true }); + (sessionManager as unknown as { cwd: string }).cwd = "/missing/historical/project"; + + try { + expect(sessionManager.getHeader()?.cwd).not.toBe(tempDir); + await expect(session.newSession()).resolves.toBe(true); + expect(sessionManager.getHeader()?.cwd).toBe(tempDir); + expect(sessionManager.getCwd()).toBe(tempDir); + } finally { + cleanup(); + } + }); + + it("uses the effective runtime cwd for project agent discovery", async () => { + const { session, sessionManager, tempDir, cleanup } = createTestSession({ inMemory: true }); + const agentsDir = join(tempDir, ".dreb", "agents"); + mkdirSync(agentsDir, { recursive: true }); + writeFileSync( + join(agentsDir, "runtime-agent.md"), + "---\nname: Runtime Agent\ndescription: Found from effective cwd\n---\n\nRuntime prompt.\n", + ); + (sessionManager as unknown as { cwd: string }).cwd = "/missing/historical/project"; + + try { + const outputs = await dispatchRpcCommand(session, { id: "runtime-agents", type: "list_agent_types" }); + expect(outputs[0]).toMatchObject({ id: "runtime-agents", success: true }); + expect((outputs[0].data as { agentTypes: Array<{ name: string }> }).agentTypes).toContainEqual( + expect.objectContaining({ name: "Runtime Agent" }), + ); + } finally { + cleanup(); + } + }); + + it("resolves relative Dream backup paths from the effective runtime cwd", async () => { + const { session, sessionManager, tempDir, cleanup } = createTestSession({ inMemory: true }); + (sessionManager as unknown as { cwd: string }).cwd = "/missing/historical/project"; + const archivePath = join(tempDir, "dream-archives"); + + try { + const outputs = await dispatchRpcCommand(session, { + id: "runtime-dream-backup", + type: "dream", + args: "backup dream-archives", + }); + expect(outputs[0]).toMatchObject({ + id: "runtime-dream-backup", + success: true, + data: { message: `Dream backup path set to: ${archivePath}` }, + }); + expect(session.settingsManager.getDreamArchivePath()).toBe(archivePath); + } finally { + cleanup(); + } + }); + + it("builds tab-title metadata from the effective runtime cwd", async () => { + const { session, sessionManager, tempDir, cleanup } = createTestSession({ inMemory: true }); + mkdirSync(join(tempDir, ".git")); + writeFileSync(join(tempDir, ".git", "HEAD"), "ref: refs/heads/runtime-title\n"); + (sessionManager as unknown as { cwd: string }).cwd = "/missing/historical/project"; + capturedTabTitleDeps.length = 0; + + try { + await dispatchRpcCommand(session, { id: "runtime-title", type: "get_state" }); + const deps = capturedTabTitleDeps.at(-1); + expect(deps).toBeDefined(); + expect(deps?.getCwd?.()).toBe(tempDir); + expect(deps?.getRepo?.()).toBe(basename(tempDir)); + expect(deps?.getBranch?.()).toBe("runtime-title"); + } finally { + cleanup(); + } + }); + it.each([ { command: "prompt", method: "prompt" }, { command: "steer", method: "steer" }, diff --git a/packages/dashboard/README.md b/packages/dashboard/README.md index 2094287c..857f924d 100644 --- a/packages/dashboard/README.md +++ b/packages/dashboard/README.md @@ -35,7 +35,9 @@ Open `http://127.0.0.1:5343`. project path, status chip, current activity, live subagents, task progress, ctx%, model, cost, last-assistant preview, and terminal provider-error reason). Below, past sessions grouped by project — three compact rows each with an "all N on disk" expander — with resume/delete. - At <=700px, cards stack; long session names, status chips, project paths, + Sessions remain listed when their historical project path is missing; resume + then asks for an existing runtime directory and shows both paths without + changing the persisted session header. At <=700px, cards stack; long session names, status chips, project paths, activity/subagent text, and past-session labels wrap within cards or rows rather than spilling off-screen. `+ new session` anywhere. - **Session view** — full chat parity: markdown streaming transcript, tool @@ -139,8 +141,9 @@ a separate fixed top-center stack; neither surface expires automatically. Creating a runtime from Fleet or Files leaves the current screen in place. Closing the runtime currently being viewed likewise keeps the main-session or subagent route and its rendered history as a read-only browser snapshot, with -explicit **Resume session** and **Return to fleet** actions. Closing another -runtime produces no redundant toast. +explicit **Resume session**, **Choose directory…**, and **Return to fleet** +actions. The directory chooser recovers a closed session if its captured runtime +path has since disappeared. Closing another runtime produces no redundant toast. ### Scoped models @@ -213,9 +216,11 @@ runtime state, so they do not trigger child RPC calls or a disk inventory scan. Disk inventory is separate from live-runtime state. Before fleet, inventory, or resync serialization, the server projects each on-disk session to the declared -browser DTO and bounds its first-message preview to 256 Unicode characters; -internal parent paths and complete searchable transcript text never cross this -boundary. The client narrowly refreshes inventory with `GET /api/sessions` after +browser DTO, reports whether its historical CWD resolves to a directory (plus +the canonical candidate when it does), and bounds its first-message preview to +256 Unicode characters. Missing historical paths remain display metadata but are +not used as memory/project roots or recent runtime choices. Internal parent paths +and complete searchable transcript text never cross this boundary. The client narrowly refreshes inventory with `GET /api/sessions` after create, resume, stop, or delete, rather than reloading the whole fleet. While the Fleet screen or a session/subagent fleet sidebar is visible, it refreshes per-runtime stats on a shared 30-second cadence; the refresh is diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 96236e7d..9ee7d4b9 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/dashboard", - "version": "2.66.1", + "version": "2.66.2", "description": "Web dashboard for dreb — fleet overview, chat parity, subagent observability", "license": "MIT", "type": "module", diff --git a/packages/dashboard/src/client/components/resume-session-modal.tsx b/packages/dashboard/src/client/components/resume-session-modal.tsx new file mode 100644 index 00000000..76013c21 --- /dev/null +++ b/packages/dashboard/src/client/components/resume-session-modal.tsx @@ -0,0 +1,111 @@ +import { createSignal, For, type JSX, Show } from "solid-js"; +import type { FleetDto } from "../../shared/protocol.js"; +import { Modal } from "./common.js"; + +function shortenPath(path: string): string { + return path.replace(/^\/home\/[^/]+/, "~"); +} + +/** Valid canonical project roots that are safe to offer for runtime creation. */ +export function runtimeProjectChoices(fleet: FleetDto): string[] { + const paths = new Set(); + for (const runtime of fleet.runtimes) paths.add(runtime.cwd); + for (const session of fleet.diskSessions) { + if (session.resolvedCwd) paths.add(session.resolvedCwd); + } + return [...paths].slice(0, 8); +} + +export function ResumeSessionModal(props: { + historicalCwd: string; + historicalUnavailable?: boolean; + initialCwd?: string; + recentProjects: string[]; + onResume: (cwd: string) => Promise; + onClose: () => void; +}): JSX.Element { + const [cwd, setCwd] = createSignal(props.initialCwd ?? ""); + const [error, setError] = createSignal(); + const [busy, setBusy] = createSignal(false); + + async function resume(): Promise { + if (busy() || !cwd().trim()) return; + setBusy(true); + setError(undefined); + try { + await props.onResume(cwd()); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + return ( + { + if (!busy()) props.onClose(); + }} + class="resume-session-modal" + actions={ + <> + + + + } + > +

+ {props.historicalUnavailable + ? "The original working directory is unavailable on this host." + : "Choose the existing project directory this resumed runtime should use."} +

+
+ historical session path + {props.historicalCwd || "(not recorded)"} +
+
+ + setCwd(event.currentTarget.value)} + /> +

+ Tools and project context will use this directory. Session history stays unchanged. +

+
+ 0}> +
+ +
+ + {(project) => ( + + )} + +
+
+
+ + + +
+ ); +} diff --git a/packages/dashboard/src/client/components/session-card-summary.tsx b/packages/dashboard/src/client/components/session-card-summary.tsx index ff402835..bdf4687b 100644 --- a/packages/dashboard/src/client/components/session-card-summary.tsx +++ b/packages/dashboard/src/client/components/session-card-summary.tsx @@ -33,6 +33,13 @@ export function SessionCardSummary(props: { store: AppStore; runtime: RuntimeInf const tasksDone = () => tasks().filter((t) => t.status === "completed").length; const ctx = () => props.runtime.state.contextUsage; const model = () => props.runtime.state.model; + const historicalSession = () => + props.store.fleet().diskSessions.find((candidate) => candidate.path === props.runtime.state.sessionFile); + const historicalCwd = () => { + const historical = historicalSession(); + if (!historical || historical.cwd === props.runtime.cwd) return undefined; + return historical.cwd; + }; const activity = () => { const s = session(); if (s?.workingText) return `▸ ${s.workingText}`; @@ -52,6 +59,11 @@ export function SessionCardSummary(props: { store: AppStore; runtime: RuntimeInf {props.runtime.cwd.replace(/^\/home\/[^/]+/, "~")} + + + originally {historicalCwd()!.replace(/^\/home\/[^/]+/, "~")} + + {pendingQuestionsReason(session()?.uiRequests ?? []) ?? "needs attention"} diff --git a/packages/dashboard/src/client/screens/fleet.tsx b/packages/dashboard/src/client/screens/fleet.tsx index b4ceca74..208b660b 100644 --- a/packages/dashboard/src/client/screens/fleet.tsx +++ b/packages/dashboard/src/client/screens/fleet.tsx @@ -10,6 +10,7 @@ import type { RuntimeInfoDto, SessionInfoDto } from "../../shared/protocol.js"; import { api } from "../api.js"; import { Modal, relativeTime, Topbar } from "../components/common.js"; import { fleetSidebarOrder } from "../components/fleet-sidebar.js"; +import { ResumeSessionModal, runtimeProjectChoices } from "../components/resume-session-modal.js"; import { SessionCardSummary, sessionCardStatus } from "../components/session-card-summary.js"; import { createFleetStatsRefresh } from "../state/fleet-stats-refresh.js"; import type { AppStore } from "../state/store.js"; @@ -139,8 +140,10 @@ export function FleetScreen(props: { store: AppStore }): JSX.Element { const [newSessionCwd, setNewSessionCwd] = createSignal(); const [showNewSession, setShowNewSession] = createSignal(false); const [confirmDelete, setConfirmDelete] = createSignal(); + const [resumeSession, setResumeSession] = createSignal(); const [expandedGroups, setExpandedGroups] = createSignal>({}); const [resumeError, setResumeError] = createSignal(); + const resumeRequests = new Map>(); // Live sessions: one flat grid, deterministically ordered — alphabetical by // project path, then session start time as tiebreak. Stable ordering beats @@ -173,23 +176,44 @@ export function FleetScreen(props: { store: AppStore }): JSX.Element { }; }); - const recentProjects = createMemo(() => { - const paths = new Set(); - for (const runtime of props.store.fleet().runtimes) paths.add(runtime.cwd); - for (const session of props.store.fleet().diskSessions) paths.add(session.cwd); - return [...paths].slice(0, 8); - }); + const recentProjects = createMemo(() => runtimeProjectChoices(props.store.fleet())); createFleetStatsRefresh(props.store, () => true); - async function resume(session: SessionInfoDto) { - setResumeError(undefined); + function resumeIn(session: SessionInfoDto, cwd: string): Promise { + const existing = resumeRequests.get(session.path); + if (existing) return existing; + + const request = Promise.resolve() + .then(async () => { + setResumeError(undefined); + const runtime = await api.createRuntime(cwd, { sessionPath: session.path }); + props.store.upsertRuntime(runtime); + // Runtime creation is authoritative. Inventory refresh failure is surfaced + // separately and must not make a successful resume retryable. + void props.store.refreshDiskSessions().catch(() => {}); + props.store.navigate({ screen: "session", key: runtime.key }); + }) + .finally(() => resumeRequests.delete(session.path)); + resumeRequests.set(session.path, request); + return request; + } + + async function resume(session: SessionInfoDto): Promise { + if (session.cwdAvailable === false) { + setResumeSession(session); + return; + } try { - const runtime = await api.createRuntime(session.cwd, { sessionPath: session.path }); - props.store.upsertRuntime(runtime); - await props.store.refreshDiskSessions(); - props.store.navigate({ screen: "session", key: runtime.key }); + // Re-resolve the historical path at runtime creation time rather than + // trusting an inventory-time canonical target that a symlink may outlive. + await resumeIn(session, session.cwd); } catch (err) { + const status = (err as { status?: unknown })?.status; + if (status === 400 || status === 404) { + setResumeSession({ ...session, cwdAvailable: false, resolvedCwd: undefined }); + return; + } setResumeError(`Failed to resume session: ${err instanceof Error ? err.message : String(err)}`); } } @@ -261,21 +285,35 @@ export function FleetScreen(props: { store: AppStore }): JSX.Element { {([project, sessions]) => { const expanded = () => expandedGroups()[project] ?? false; const visible = () => (expanded() ? sessions : sessions.slice(0, 3)); + const newSessionCwd = () => { + const candidate = sessions.find( + (session) => session.resolvedCwd || session.cwdAvailable !== false, + ); + if (!candidate) return undefined; + return project === "/tmp" ? project : (candidate.resolvedCwd ?? candidate.cwd); + }; + const unavailable = () => sessions.every((session) => session.cwdAvailable === false); return (
-

{shortenPath(project)}

- {sessions.length} on disk - +

{project ? shortenPath(project) : "(not recorded)"}

+ + {sessions.length} on disk{unavailable() ? " · original path unavailable" : ""} + + + {(cwd) => ( + + )} +
{(session) => ( @@ -284,6 +322,7 @@ export function FleetScreen(props: { store: AppStore }): JSX.Element { {session.name ?? `“${session.firstMessage.slice(0, 60)}…”`} + {session.cwdAvailable === false ? "original path unavailable · " : ""} {session.messageCount} msgs · {relativeTime(session.modified)} @@ -325,6 +364,21 @@ export function FleetScreen(props: { store: AppStore }): JSX.Element { + + {(session) => ( + setResumeSession(undefined)} + onResume={async (cwd) => { + await resumeIn(session(), cwd); + setResumeSession(undefined); + }} + /> + )} + + (); const [showResumeModal, setShowResumeModal] = createSignal(false); + const [showClosedResumeModal, setShowClosedResumeModal] = createSignal(false); const [resumeSessions, setResumeSessions] = createSignal([]); const [resumeLoading, setResumeLoading] = createSignal(false); const [resumeError, setResumeError] = createSignal(); @@ -1710,18 +1712,25 @@ export function SessionScreen(props: { store: AppStore; sessionKey: string }): J const displaySessionName = () => session()?.sessionName ?? runtime()?.state.sessionName; const headerTitle = () => displaySessionName() ?? session()?.title ?? props.sessionKey; const sessionCwd = () => runtime()?.cwd ?? closed()?.cwd; + const historicalSession = () => { + const sessionFile = runtime()?.state.sessionFile ?? closed()?.sessionFile; + return sessionFile + ? props.store.fleet().diskSessions.find((candidate) => candidate.path === sessionFile) + : undefined; + }; + const historicalCwd = () => historicalSession()?.cwd ?? closed()?.historicalCwd; + const differingHistoricalCwd = () => { + const effective = sessionCwd(); + const historical = historicalCwd(); + if (!effective || !historical) return undefined; + return historical === effective ? undefined : historical; + }; const cwdWithBranch = () => { const cwd = sessionCwd(); if (!cwd) return undefined; const currentBranch = branch(); return `${shortenPath(cwd)}${currentBranch ? ` (${currentBranch})` : ""}`; }; - const infoLeft = () => { - const cwd = cwdWithBranch(); - const name = displaySessionName(); - if (cwd && name) return `${cwd} • ${name}`; - return cwd ?? name ?? "session"; - }; const tokenSummary = () => { const tokens = stats()?.tokens; if (!tokens) return undefined; @@ -1799,6 +1808,13 @@ export function SessionScreen(props: { store: AppStore; sessionKey: string }): J disabled: closedState.resuming, }); } + if (closedState.sessionFile) { + actions.push({ + label: "Choose directory…", + run: () => setShowClosedResumeModal(true), + disabled: closedState.resuming, + }); + } actions.push({ label: "Return to fleet", run: () => props.store.navigate({ screen: "fleet" }), @@ -1979,7 +1995,21 @@ export function SessionScreen(props: { store: AppStore; sessionKey: string }): J
- {infoLeft()} + + + {(cwd) => ( + <> + {cwd()} + {(name) => • {name()}} + + )} + + + + + original session: {shortenPath(differingHistoricalCwd()!)} + +
@@ -2590,6 +2620,21 @@ export function SessionScreen(props: { store: AppStore; sessionKey: string }): J /> + + setShowClosedResumeModal(false)} + onResume={async (cwd) => { + await props.store.resumeClosedSession(props.sessionKey, cwd); + const error = props.store.sessions[props.sessionKey]?.closed?.resumeError; + if (error) throw new Error(error); + setShowClosedResumeModal(false); + }} + /> + + (); const [steeringMode, setSteeringMode] = createSignal<"all" | "one-at-a-time">(); const [pending, setPending] = createSignal({ steering: [], followUp: [] }); + const [showClosedResumeModal, setShowClosedResumeModal] = createSignal(false); let disposed = false; onCleanup(() => { disposed = true; }); const closed = () => parent()?.closed; + const historicalCwd = () => { + const closedState = closed(); + if (!closedState) return undefined; + return ( + closedState.historicalCwd ?? + props.store.fleet().diskSessions.find((session) => session.path === closedState.sessionFile)?.cwd + ); + }; const isRunning = () => !closed() && agent()?.status === "running"; // Fleet sidebar: the other live sessions (the parent session excluded), @@ -149,6 +159,13 @@ export function SubagentScreen(props: { store: AppStore; sessionKey: string; age disabled: closedState.resuming, }); } + if (closedState.sessionFile) { + actions.push({ + label: "Choose directory…", + run: () => setShowClosedResumeModal(true), + disabled: closedState.resuming, + }); + } actions.push({ label: "Return to fleet", run: () => props.store.navigate({ screen: "fleet" }), @@ -340,6 +357,21 @@ export function SubagentScreen(props: { store: AppStore; sessionKey: string; age
+ + + setShowClosedResumeModal(false)} + onResume={async (cwd) => { + await props.store.resumeClosedSession(props.sessionKey, cwd); + const error = props.store.sessions[props.sessionKey]?.closed?.resumeError; + if (error) throw new Error(error); + setShowClosedResumeModal(false); + }} + /> + ); } diff --git a/packages/dashboard/src/client/state/reducer.ts b/packages/dashboard/src/client/state/reducer.ts index ed4daa48..fa1a1afa 100644 --- a/packages/dashboard/src/client/state/reducer.ts +++ b/packages/dashboard/src/client/state/reducer.ts @@ -107,7 +107,10 @@ export interface StatusLineEntry { } export interface ClosedSessionViewState { + /** Effective runtime directory captured before the runtime closed. */ cwd?: string; + /** Historical directory recorded by the active session, when known. */ + historicalCwd?: string; sessionFile?: string; bannerDismissed?: boolean; resuming?: boolean; diff --git a/packages/dashboard/src/client/state/store.ts b/packages/dashboard/src/client/state/store.ts index 1ebbf108..c479f930 100644 --- a/packages/dashboard/src/client/state/store.ts +++ b/packages/dashboard/src/client/state/store.ts @@ -533,10 +533,17 @@ export function createAppStore() { function retainClosedSession(key: string, runtime?: RuntimeInfoDto): void { clearHydrationTransaction(key); bumpHydrationGeneration(key); + const runtimeSessionFile = runtime?.state.sessionFile; + const historicalCwd = runtimeSessionFile + ? fleet().diskSessions.find((candidate) => candidate.path === runtimeSessionFile)?.cwd + : undefined; mutateSession(key, (session) => { const previous = session.closed; session.closed = { ...(runtime?.cwd || previous?.cwd ? { cwd: previous?.cwd ?? runtime?.cwd } : {}), + ...(historicalCwd || previous?.historicalCwd + ? { historicalCwd: previous?.historicalCwd ?? historicalCwd } + : {}), ...(runtime?.state.sessionFile || previous?.sessionFile ? { sessionFile: previous?.sessionFile ?? runtime?.state.sessionFile } : {}), @@ -610,16 +617,16 @@ export function createAppStore() { }); } - async function resumeClosedSession(key: string): Promise { + async function resumeClosedSession(key: string, cwdOverride?: string): Promise { const closed = sessions[key]?.closed; if (!closed || closed.resuming) return; - if (!closed.cwd || !closed.sessionFile) { + const cwd = cwdOverride ?? closed.cwd; + if (!cwd || !closed.sessionFile) { mutateSession(key, (session) => { if (session.closed) session.closed.resumeError = "This closed session has no captured resume path."; }); return; } - const cwd = closed.cwd; const sessionFile = closed.sessionFile; mutateSession(key, (session) => { if (!session.closed) return; diff --git a/packages/dashboard/src/client/styles/app.css b/packages/dashboard/src/client/styles/app.css index ce0a6b0b..920b7913 100644 --- a/packages/dashboard/src/client/styles/app.css +++ b/packages/dashboard/src/client/styles/app.css @@ -173,6 +173,14 @@ body { white-space: nowrap; } +.historical-cwd { + font-size: var(--fs-small); + color: var(--status-attention); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .attention-reason { font-weight: 600; font-size: var(--fs-secondary); @@ -415,6 +423,11 @@ body { white-space: nowrap; } +.session-info-bar .historical-cwd { + min-width: 0; + margin-left: var(--space-2); +} + /* Separate sidebar visibility from the back link: the toggle anchors the bottom header row, with usage/context stats grouped on its right. */ .session-summary-row { @@ -649,6 +662,7 @@ body { .fleet-sidebar-entry .name, .fleet-sidebar-entry .session-project, +.fleet-sidebar-entry .historical-cwd, .fleet-sidebar-entry .agent-line, .fleet-sidebar-entry .session-meta > span { white-space: normal; @@ -2547,13 +2561,28 @@ details.thinking .thinking-body { /* -------------------------------------------------------------- new session */ +.resume-cwd-warning { + color: var(--status-attention); + margin-bottom: var(--space-3); +} + +.resume-historical-cwd { + display: block; + padding: var(--space-2); + border: var(--hairline); + border-radius: var(--radius); + background: var(--bg); + overflow-wrap: anywhere; +} + .field { display: flex; flex-direction: column; gap: var(--space-1); } -.field label { +.field label, +.field-label { font-size: var(--fs-small); color: var(--muted); } @@ -3142,6 +3171,7 @@ details.thinking .thinking-body { } .session-project, + .historical-cwd, .attention-reason, .error-reason, .activity, @@ -3154,7 +3184,8 @@ details.thinking .thinking-body { overflow-wrap: anywhere; } - .session-project { + .session-project, + .historical-cwd { overflow: visible; text-overflow: clip; } diff --git a/packages/dashboard/src/index.ts b/packages/dashboard/src/index.ts index 46b65fba..019d7f64 100644 --- a/packages/dashboard/src/index.ts +++ b/packages/dashboard/src/index.ts @@ -27,7 +27,7 @@ import { createDashboardServer } from "./server/server.js"; export { DashboardAuth, TailscaleStatusResolver, TailscaleWhoisResolver } from "./server/auth.js"; export { DashboardImageService } from "./server/dashboard-images.js"; export { EventHub } from "./server/event-hub.js"; -export { canonicalizePath, FileApi } from "./server/files.js"; +export { canonicalizePath, FileApi, resolveExistingDirectory } from "./server/files.js"; export { ImagePreviewWorker } from "./server/image-preview.js"; export { FilePairingStorage, loadOrCreateDashboardSecret } from "./server/pairing-storage.js"; export { RuntimePool, resolveDrebCliPath } from "./server/runtime-pool.js"; diff --git a/packages/dashboard/src/server/files.ts b/packages/dashboard/src/server/files.ts index 9b42f407..4ae43cc2 100644 --- a/packages/dashboard/src/server/files.ts +++ b/packages/dashboard/src/server/files.ts @@ -9,7 +9,8 @@ */ import { randomBytes } from "node:crypto"; -import { link, mkdir, open, readdir, realpath, rename, stat, unlink } from "node:fs/promises"; +import { constants } from "node:fs"; +import { access, link, mkdir, open, readdir, realpath, rename, stat, unlink } from "node:fs/promises"; import { dirname, isAbsolute, join, normalize, resolve, sep } from "node:path"; import type { Writable } from "node:stream"; import type { DirListingDto, FileEntryDto } from "../shared/protocol.js"; @@ -70,17 +71,35 @@ export async function canonicalizePath(raw: string, opts: { mustExist: boolean } return join(realParent, leaf); } +/** Resolve and canonicalize an existing directory supplied by a Dashboard client. */ +export async function resolveExistingDirectory(rawPath: string): Promise { + const path = await canonicalizePath(rawPath, { mustExist: true }); + let info: Awaited>; + try { + info = await stat(path); + } catch (err) { + throw Object.assign(new Error(`Path does not exist or is unreadable: ${path}`), { status: 404, cause: err }); + } + if (!info.isDirectory()) { + throw Object.assign(new Error(`Not a directory: ${path}`), { status: 400 }); + } + try { + await access(path, constants.R_OK | constants.X_OK); + } catch (err) { + throw Object.assign(new Error(`Directory is not readable and searchable: ${path}`), { + status: 403, + cause: err, + }); + } + return path; +} + export class FileApi { constructor(private readonly log: FileOpLogger) {} /** Resolve an existing directory for listing and context-trust RPC operations. */ async resolveDirectory(rawPath: string): Promise { - const path = await canonicalizePath(rawPath, { mustExist: true }); - const info = await stat(path); - if (!info.isDirectory()) { - throw Object.assign(new Error(`Not a directory: ${path}`), { status: 400 }); - } - return path; + return resolveExistingDirectory(rawPath); } async list(rawPath: string): Promise> { diff --git a/packages/dashboard/src/server/server.ts b/packages/dashboard/src/server/server.ts index 4bf2ddd2..64d50676 100644 --- a/packages/dashboard/src/server/server.ts +++ b/packages/dashboard/src/server/server.ts @@ -39,7 +39,7 @@ import { DashboardImageService, } from "./dashboard-images.js"; import { EventHub, formatHeartbeatFrame, type SseWriteMetadata } from "./event-hub.js"; -import { defaultPlaces, FileApi } from "./files.js"; +import { defaultPlaces, FileApi, resolveExistingDirectory } from "./files.js"; import { ImagePreviewWorker } from "./image-preview.js"; import { MemoryApi } from "./memories.js"; import type { DashboardRuntimeSnapshot, RuntimePool } from "./runtime-pool.js"; @@ -100,14 +100,31 @@ function boundedSessionPreview(text: string): string { return preview; } -function toSessionInfoDto(session: DashboardSessionInfoSource): SessionInfoDto { +interface SessionInfoProjectionSource extends Omit { + created: Date | string; + modified: Date | string; +} + +function toIsoString(value: Date | string): string { + return value instanceof Date ? value.toISOString() : value; +} + +async function toSessionInfoDto(session: SessionInfoProjectionSource): Promise { + let resolvedCwd: string | undefined; + try { + resolvedCwd = await resolveExistingDirectory(session.cwd); + } catch { + // Historical metadata remains useful even when its directory disappeared. + } return { path: session.path, id: session.id, cwd: session.cwd, + cwdAvailable: resolvedCwd !== undefined, + ...(resolvedCwd ? { resolvedCwd } : {}), name: session.name, - created: session.created.toISOString(), - modified: session.modified.toISOString(), + created: toIsoString(session.created), + modified: toIsoString(session.modified), messageCount: session.messageCount, firstMessage: boundedSessionPreview(session.firstMessage), }; @@ -526,11 +543,11 @@ export function createDashboardServer(options: DashboardServerOptions): Dashboar // -- fleet ----------------------------------------------------------------- const listDiskSessions = async (): Promise => - (await options.listAllSessions()).filter((session) => existsSync(session.cwd)).map(toSessionInfoDto); + Promise.all((await options.listAllSessions()).map(toSessionInfoDto)); const currentCwdInventory = async (): Promise => [ ...pool.list().map((handle) => handle.cwd), - ...(await listDiskSessions()).map((session) => session.cwd), + ...(await listDiskSessions()).flatMap((session) => (session.resolvedCwd ? [session.resolvedCwd] : [])), ]; const handleMemoryError = (res: Response, err: unknown): void => { @@ -669,18 +686,15 @@ export function createDashboardServer(options: DashboardServerOptions): Dashboar // -- runtimes --------------------------------------------------------------- app.post("/api/runtimes", (req, res) => { (async () => { - const cwd = typeof req.body?.cwd === "string" ? req.body.cwd : ""; - if (!cwd || !existsSync(cwd)) { - res.status(400).json({ error: `Working directory does not exist: ${cwd || "(empty)"}` }); - return; - } + const requestedCwd = typeof req.body?.cwd === "string" ? req.body.cwd : ""; + const cwd = await resolveExistingDirectory(requestedCwd); const sessionPath = typeof req.body?.sessionPath === "string" ? req.body.sessionPath : undefined; const handle = await pool.create(cwd, sessionPath); log(`runtime ${handle.key} started in ${cwd}${sessionPath ? ` (resume ${basename(sessionPath)})` : ""}`); const firstPrompt = typeof req.body?.firstPrompt === "string" ? req.body.firstPrompt : undefined; if (firstPrompt) await handle.client.prompt(firstPrompt); res.status(201).json(await pool.describe(handle)); - })().catch((err) => res.status(500).json({ error: String(err?.message ?? err) })); + })().catch((err) => handleMemoryError(res, err)); }); app.delete("/api/runtimes/:key", (req, res) => { @@ -991,7 +1005,9 @@ export function createDashboardServer(options: DashboardServerOptions): Dashboar }); app.get("/api/runtimes/:key/sessions", (req, res) => { - withRuntime(req, res, async (h) => ({ sessions: await h.client.listSessions() })); + withRuntime(req, res, async (h) => ({ + sessions: await Promise.all((await h.client.listSessions()).map(toSessionInfoDto)), + })); }); app.post("/api/runtimes/:key/resume", (req, res) => { diff --git a/packages/dashboard/src/shared/protocol.ts b/packages/dashboard/src/shared/protocol.ts index d3cc8fde..3e195804 100644 --- a/packages/dashboard/src/shared/protocol.ts +++ b/packages/dashboard/src/shared/protocol.ts @@ -14,7 +14,12 @@ export const MAX_SESSION_PREVIEW_CHARACTERS = 256; export interface SessionInfoDto { path: string; id: string; + /** Historical working directory recorded in the session header. */ cwd: string; + /** Whether the historical cwd currently resolves to an existing directory. */ + cwdAvailable?: boolean; + /** Canonical runtime candidate when the historical cwd is available. */ + resolvedCwd?: string; name?: string; created: string; modified: string; diff --git a/packages/dashboard/test/client/fleet-layout.browser.test.ts b/packages/dashboard/test/client/fleet-layout.browser.test.ts index 31dfcf79..2775a010 100644 --- a/packages/dashboard/test/client/fleet-layout.browser.test.ts +++ b/packages/dashboard/test/client/fleet-layout.browser.test.ts @@ -40,6 +40,7 @@ function sessionCard(status: "running" | "attention" | "idle" | "error"): string ${statusDisplay.glyph} ${statusDisplay.label}

${prose}${unbroken}

+

originally ${prose}${unbroken}

${conditional}

${prose}${unbroken}

diff --git a/packages/dashboard/test/client/screens.test.tsx b/packages/dashboard/test/client/screens.test.tsx index d718af91..83c22915 100644 --- a/packages/dashboard/test/client/screens.test.tsx +++ b/packages/dashboard/test/client/screens.test.tsx @@ -3403,12 +3403,53 @@ describe("screen smoke tests", () => { expect(el.textContent).toContain("closed child transcript remains"); expect(el.textContent).toContain("subagent transcript is read-only"); expect(el.querySelector('[data-banner-key="closed"]')?.textContent).toContain("Resume session"); + expect(el.querySelector('[data-banner-key="closed"]')?.textContent).toContain("Choose directory"); expect(el.querySelector('[data-banner-key="closed"]')?.textContent).toContain("Return to fleet"); expect(el.querySelector("textarea")).toBeNull(); expect(el.querySelector(".composer")).toBeNull(); expect(el.querySelector("button.send")).toBeNull(); }); + it("closed subagent chooser submits the selected cwd and retains failures for retry", async () => { + const store = makeStore() as any; + const session = populatedSession("closed-subagent-chooser"); + session.closed = { cwd: "/missing/project", sessionFile: "/sessions/closed-subagent.jsonl" }; + let attempt = 0; + const resumeClosedSession = vi.fn(async () => { + attempt++; + session.closed!.resumeError = attempt === 1 ? "replacement rejected" : undefined; + }); + const fakeStore = { + ...store, + sessions: { "closed-subagent-chooser": session }, + fleet: () => ({ runtimes: [], diskSessions: [] }), + resumeClosedSession, + }; + const el = mount(() => ); + + const choose = [...el.querySelectorAll('[data-banner-key="closed"] button')].find((button) => + button.textContent?.includes("Choose directory"), + ) as HTMLButtonElement; + choose.click(); + const input = el.querySelector("#resume-runtime-cwd") as HTMLInputElement; + input.value = "/replacement/project"; + input.dispatchEvent(new InputEvent("input", { bubbles: true })); + const submit = [...el.querySelectorAll(".modal-actions button")].find( + (button) => button.textContent === "resume session", + ) as HTMLButtonElement; + + submit.click(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(resumeClosedSession).toHaveBeenLastCalledWith("closed-subagent-chooser", "/replacement/project"); + expect(el.querySelector(".resume-session-modal")).not.toBeNull(); + expect(el.querySelector('[role="alert"]')?.textContent).toContain("replacement rejected"); + + submit.click(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(resumeClosedSession).toHaveBeenCalledTimes(2); + expect(el.querySelector(".resume-session-modal")).toBeNull(); + }); + it("subagent drill-in sends unchanged steering text and shows the child queue mode", async () => { const store = makeStore() as any; const session = populatedSession("k-live-steer"); @@ -6735,6 +6776,42 @@ describe("dashboard client regressions", () => { expect(api.fleet).not.toHaveBeenCalled(); }); + it("session chrome shows the raw historical cwd when its canonical target matches runtime cwd", async () => { + const runtime = runtimeInfo("moved-session"); + runtime.cwd = "/new/project"; + runtime.state.sessionFile = "/sessions/moved.jsonl"; + vi.mocked(api.fleet).mockResolvedValueOnce({ + runtimes: [runtime], + diskSessions: [ + { + path: "/sessions/moved.jsonl", + id: "moved", + cwd: "/old/project", + cwdAvailable: true, + resolvedCwd: "/new/project", + created: new Date().toISOString(), + modified: new Date().toISOString(), + messageCount: 2, + firstMessage: "hello", + }, + ], + }); + vi.mocked(api.hydrate).mockResolvedValueOnce({ + key: runtime.key, + state: runtime.state, + messages: [], + backgroundAgents: [], + barrierSeq: 0, + }); + const store = makeStore(); + await store.start(); + const el = mount(() => ); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(el.querySelector(".session-bar .project")?.textContent).toBe("/new/project"); + expect(el.querySelector(".session-info-bar .historical-cwd")?.textContent).toContain("/old/project"); + }); + it("session stop keeps a closed read-only transcript snapshot on the current route", async () => { const runtime = runtimeInfo("stop-from-session"); runtime.state.sessionFile = "/sessions/stop-from-session.jsonl"; @@ -6775,6 +6852,7 @@ describe("dashboard client regressions", () => { expect(window.location.hash).toBe(routeBeforeStop); expect(el.textContent).toContain("retained after stop"); expect(el.querySelector('[data-banner-key="closed"]')?.textContent).toContain("Resume session"); + expect(el.querySelector('[data-banner-key="closed"]')?.textContent).toContain("Choose directory"); expect(el.querySelector('[data-banner-key="closed"]')?.textContent).toContain("Return to fleet"); expect(el.textContent).toContain("transcript is read-only"); expect(el.querySelector(".composer")).toBeNull(); @@ -6783,7 +6861,38 @@ describe("dashboard client regressions", () => { expect(el.querySelector(".status-line")).toBeNull(); }); - it("fleet resumes disk sessions with their session path", async () => { + it("closed parent-session chooser submits the selected cwd and closes after success", async () => { + const store = makeStore() as any; + const session = populatedSession("closed-parent-chooser"); + session.closed = { cwd: "/missing/project", sessionFile: "/sessions/closed-parent.jsonl" }; + const resumeClosedSession = vi.fn(async () => {}); + const fakeStore = { + ...store, + sessions: { "closed-parent-chooser": session }, + fleet: () => ({ runtimes: [], diskSessions: [] }), + hydrateSession: async () => {}, + resumeClosedSession, + }; + const el = mount(() => ); + + const choose = [...el.querySelectorAll('[data-banner-key="closed"] button')].find((button) => + button.textContent?.includes("Choose directory"), + ) as HTMLButtonElement; + choose.click(); + const input = el.querySelector("#resume-runtime-cwd") as HTMLInputElement; + input.value = "/replacement/project"; + input.dispatchEvent(new InputEvent("input", { bubbles: true })); + const submit = [...el.querySelectorAll(".modal-actions button")].find( + (button) => button.textContent === "resume session", + ) as HTMLButtonElement; + submit.click(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(resumeClosedSession).toHaveBeenCalledWith("closed-parent-chooser", "/replacement/project"); + expect(el.querySelector(".resume-session-modal")).toBeNull(); + }); + + it("fleet re-resolves the historical cwd when resuming a disk session", async () => { const store = makeStore() as any; const refreshDiskSessions = vi.fn(async () => {}); const upsertRuntime = vi.fn(); @@ -6799,7 +6908,9 @@ describe("dashboard client regressions", () => { { path: "/sessions/resume.jsonl", id: "resume", - cwd: "/repo", + cwd: "/historical/repo", + cwdAvailable: true, + resolvedCwd: "/repo", name: "resume me", created: new Date().toISOString(), modified: new Date().toISOString(), @@ -6814,13 +6925,234 @@ describe("dashboard client regressions", () => { (el.querySelector(".disk-row .actions .btn") as HTMLButtonElement).click(); await new Promise((resolve) => setTimeout(resolve, 10)); - expect(api.createRuntime).toHaveBeenCalledWith("/repo", { sessionPath: "/sessions/resume.jsonl" }); + expect(api.createRuntime).toHaveBeenCalledWith("/historical/repo", { + sessionPath: "/sessions/resume.jsonl", + }); expect(upsertRuntime).toHaveBeenCalledWith(expect.objectContaining({ key: "new-key" })); expect(refreshDiskSessions).toHaveBeenCalled(); expect(vi.mocked(api.fleet)).not.toHaveBeenCalled(); expect(navigate).toHaveBeenCalledWith({ screen: "session", key: "new-key" }); }); + it("fleet runs only one resume request per session while creation is pending", async () => { + const store = makeStore() as any; + const navigate = vi.fn(); + const resumedRuntime = runtimeInfo("single-flight-runtime"); + let resolveCreate!: (runtime: RuntimeInfoDto) => void; + vi.mocked(api.createRuntime).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCreate = resolve; + }), + ); + vi.mocked(api.createRuntime).mockClear(); + const fakeStore = { + ...store, + navigate, + fleet: () => ({ + runtimes: [], + diskSessions: [ + { + path: "/sessions/single-flight.jsonl", + id: "single-flight", + cwd: "/historical/repo", + cwdAvailable: true, + resolvedCwd: "/cached/repo", + created: new Date().toISOString(), + modified: new Date().toISOString(), + messageCount: 3, + firstMessage: "hello", + }, + ], + }), + }; + const el = mount(() => ); + const resumeButton = el.querySelector(".disk-row .actions .btn") as HTMLButtonElement; + + resumeButton.click(); + resumeButton.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(api.createRuntime).toHaveBeenCalledOnce(); + expect(api.createRuntime).toHaveBeenCalledWith("/historical/repo", { + sessionPath: "/sessions/single-flight.jsonl", + }); + + resolveCreate(resumedRuntime); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(navigate).toHaveBeenCalledOnce(); + }); + + it("fleet opens the directory chooser if a previously valid cwd disappears before resume", async () => { + const store = makeStore() as any; + const session = { + path: "/sessions/raced.jsonl", + id: "raced", + cwd: "/removed/project", + cwdAvailable: true, + resolvedCwd: "/removed/project", + created: new Date().toISOString(), + modified: new Date().toISOString(), + messageCount: 3, + firstMessage: "hello", + }; + const fakeStore = { + ...store, + fleet: () => ({ runtimes: [], diskSessions: [session] }), + }; + vi.mocked(api.createRuntime).mockRejectedValueOnce( + Object.assign(new Error("Path does not exist"), { status: 404 }), + ); + const el = mount(() => ); + + (el.querySelector(".disk-row .actions .btn") as HTMLButtonElement).click(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(el.querySelector(".resume-session-modal")).not.toBeNull(); + expect(el.textContent).toContain("The original working directory is unavailable"); + }); + + it("fleet treats fallback runtime creation as success when inventory refresh fails", async () => { + const store = makeStore() as any; + const refreshDiskSessions = vi.fn(async () => { + throw new Error("inventory unavailable"); + }); + const upsertRuntime = vi.fn(); + const navigate = vi.fn(); + const live = runtimeInfo("available-project"); + live.cwd = "/available/project"; + const unavailableSession = { + path: "/sessions/moved.jsonl", + id: "moved", + cwd: "/old/host/project", + cwdAvailable: false, + name: "moved session", + created: new Date().toISOString(), + modified: new Date().toISOString(), + messageCount: 3, + firstMessage: "hello", + }; + const fakeStore = { + ...store, + refreshDiskSessions, + upsertRuntime, + navigate, + fleet: () => ({ runtimes: [live], diskSessions: [unavailableSession] }), + }; + const el = mount(() => ); + vi.mocked(api.createRuntime).mockClear(); + + (el.querySelector(".disk-row .actions .btn") as HTMLButtonElement).click(); + expect(api.createRuntime).not.toHaveBeenCalled(); + expect(el.textContent).toContain("The original working directory is unavailable"); + expect(el.textContent).toContain("/old/host/project"); + expect(el.querySelector(".project-group .group-head .btn")).toBeNull(); + + const availableChoice = [...el.querySelectorAll(".recent-projects button")].find( + (button) => button.textContent === "/available/project", + ) as HTMLButtonElement; + availableChoice.click(); + const submit = [...el.querySelectorAll(".modal-actions button")].find( + (button) => button.textContent === "resume session", + ) as HTMLButtonElement; + vi.mocked(api.createRuntime).mockRejectedValueOnce(new Error("Not a directory: /available/project")); + submit.click(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(el.querySelector(".resume-session-modal")).not.toBeNull(); + expect(el.querySelector('[role="alert"]')?.textContent).toContain("Not a directory"); + expect((el.querySelector("#resume-runtime-cwd") as HTMLInputElement).value).toBe("/available/project"); + expect(navigate).not.toHaveBeenCalled(); + + submit.click(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(api.createRuntime).toHaveBeenLastCalledWith("/available/project", { + sessionPath: "/sessions/moved.jsonl", + }); + expect(upsertRuntime).toHaveBeenCalledWith(expect.objectContaining({ key: "new-key" })); + expect(refreshDiskSessions).toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith({ screen: "session", key: "new-key" }); + expect(el.querySelector(".resume-session-modal")).toBeNull(); + }); + + it("fleet keeps a pending fallback chooser mounted and blocks another submission", async () => { + const store = makeStore() as any; + const resumedRuntime = runtimeInfo("pending-fallback-runtime"); + let resolveCreate!: (runtime: RuntimeInfoDto) => void; + vi.mocked(api.createRuntime).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCreate = resolve; + }), + ); + vi.mocked(api.createRuntime).mockClear(); + const unavailableSession = { + path: "/sessions/pending.jsonl", + id: "pending", + cwd: "/missing/project", + cwdAvailable: false, + created: new Date().toISOString(), + modified: new Date().toISOString(), + messageCount: 3, + firstMessage: "hello", + }; + const fakeStore = { + ...store, + fleet: () => ({ runtimes: [], diskSessions: [unavailableSession] }), + }; + const el = mount(() => ); + (el.querySelector(".disk-row .actions .btn") as HTMLButtonElement).click(); + const input = el.querySelector("#resume-runtime-cwd") as HTMLInputElement; + input.value = "/replacement/project"; + input.dispatchEvent(new InputEvent("input", { bubbles: true })); + const submit = [...el.querySelectorAll(".modal-actions button")].find( + (button) => button.textContent === "resume session", + ) as HTMLButtonElement; + submit.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const backdrop = el.querySelector(".modal-backdrop") as HTMLElement; + backdrop.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + backdrop.click(); + expect(el.querySelector(".resume-session-modal")).not.toBeNull(); + expect(submit.disabled).toBe(true); + submit.click(); + expect(api.createRuntime).toHaveBeenCalledOnce(); + + resolveCreate(resumedRuntime); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(el.querySelector(".resume-session-modal")).toBeNull(); + }); + + it("fleet shows the raw historical cwd when its canonical target matches runtime cwd", () => { + const store = makeStore() as any; + const live = runtimeInfo("fallback-runtime"); + live.cwd = "/new/project"; + live.state.sessionFile = "/sessions/moved.jsonl"; + const fakeStore = { + ...store, + fleet: () => ({ + runtimes: [live], + diskSessions: [ + { + path: "/sessions/moved.jsonl", + id: "moved", + cwd: "/old/project", + cwdAvailable: true, + resolvedCwd: "/new/project", + created: new Date().toISOString(), + modified: new Date().toISOString(), + messageCount: 3, + firstMessage: "hello", + }, + ], + }), + }; + const el = mount(() => ); + + expect(el.querySelector(".session-project")?.textContent).toBe("/new/project"); + expect(el.querySelector(".session-card .historical-cwd")?.textContent).toContain("/old/project"); + }); + it("fleet deletes disk sessions and refreshes inventory", async () => { const store = makeStore() as any; const refreshDiskSessions = vi.fn(async () => {}); diff --git a/packages/dashboard/test/client/store.test.ts b/packages/dashboard/test/client/store.test.ts index 72a3adea..a41980b1 100644 --- a/packages/dashboard/test/client/store.test.ts +++ b/packages/dashboard/test/client/store.test.ts @@ -1364,12 +1364,27 @@ describe("app store SSE sync", () => { runtime.cwd = "/tmp/resume-project"; runtime.state.sessionFile = "/tmp/resume-project/closed.jsonl"; const resumed = runtimeSnapshot("resumed", false); - vi.mocked(api.fleet).mockResolvedValueOnce({ runtimes: [runtime], diskSessions: [] }); + vi.mocked(api.fleet).mockResolvedValueOnce({ + runtimes: [runtime], + diskSessions: [ + { + path: runtime.state.sessionFile, + id: "closed", + cwd: "/historical/project", + cwdAvailable: false, + created: new Date().toISOString(), + modified: new Date().toISOString(), + messageCount: 1, + firstMessage: "hello", + }, + ], + }); vi.mocked(api.createRuntime).mockResolvedValueOnce(resumed); window.location.hash = "#/session/closed"; const store = await makeStartedStore(); emit("closed", { type: "runtime_removed" }); await flushAsyncWork(); + expect(store.sessions.closed?.closed?.historicalCwd).toBe("/historical/project"); vi.mocked(api.sessions).mockClear(); await store.resumeClosedSession("closed"); @@ -1383,6 +1398,27 @@ describe("app store SSE sync", () => { expect(api.sessions).toHaveBeenCalledOnce(); }); + it("resumes a closed session in an explicitly selected replacement directory", async () => { + const runtime = runtimeSnapshot("closed", false); + runtime.cwd = "/missing/runtime-project"; + runtime.state.sessionFile = "/sessions/closed.jsonl"; + const resumed = runtimeSnapshot("resumed", false); + resumed.cwd = "/replacement/project"; + vi.mocked(api.fleet).mockResolvedValueOnce({ runtimes: [runtime], diskSessions: [] }); + vi.mocked(api.createRuntime).mockResolvedValueOnce(resumed); + window.location.hash = "#/session/closed"; + const store = await makeStartedStore(); + emit("closed", { type: "runtime_removed" }); + await flushAsyncWork(); + + await store.resumeClosedSession("closed", "/replacement/project"); + + expect(api.createRuntime).toHaveBeenCalledWith("/replacement/project", { + sessionPath: "/sessions/closed.jsonl", + }); + expect(store.fleet().runtimes).toEqual([resumed]); + }); + it("re-exposes resume failures after the close banner is dismissed while pending", async () => { const runtime = runtimeSnapshot("closed", false); runtime.cwd = "/tmp/resume-project"; diff --git a/packages/dashboard/test/files.test.ts b/packages/dashboard/test/files.test.ts index 2a058371..f74b21ed 100644 --- a/packages/dashboard/test/files.test.ts +++ b/packages/dashboard/test/files.test.ts @@ -1,8 +1,8 @@ -import { mkdtemp, readdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { canonicalizePath, FileApi } from "../src/server/files.js"; +import { canonicalizePath, FileApi, resolveExistingDirectory } from "../src/server/files.js"; const tempDirs: string[] = []; @@ -74,6 +74,31 @@ describe("canonicalizePath", () => { }); }); +describe("resolveExistingDirectory", () => { + it("canonicalizes directories and rejects files", async () => { + const dir = await makeTempDir(); + const file = join(dir, "file.txt"); + const link = join(dir, "directory-link"); + await writeFile(file, "file"); + await symlink(dir, link); + + await expect(resolveExistingDirectory(link)).resolves.toBe(dir); + await expect(resolveExistingDirectory(file)).rejects.toMatchObject({ status: 400 }); + }); + + it.skipIf(process.platform === "win32" || (typeof process.getuid === "function" && process.getuid() === 0))( + "rejects directories without read and search access", + async () => { + const dir = await makeTempDir(); + const unreadable = join(dir, "unreadable"); + await mkdir(unreadable); + await chmod(unreadable, 0o000); + + await expect(resolveExistingDirectory(unreadable)).rejects.toMatchObject({ status: 403 }); + }, + ); +}); + describe("FileApi", () => { function makeApi() { const log = vi.fn(); diff --git a/packages/dashboard/test/server.test.ts b/packages/dashboard/test/server.test.ts index a0dad5b4..696755dc 100644 --- a/packages/dashboard/test/server.test.ts +++ b/packages/dashboard/test/server.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { type IncomingMessage, request, type Server, ServerResponse } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -501,6 +501,7 @@ describe("dashboard server — fleet and runtimes", () => { it("GET /api/fleet returns runtimes and disk sessions", async () => { const dir = await createTempProject(); const disk = [diskSession(dir, { path: "/s/one.jsonl" })]; + const canonicalDir = await realpath(dir); const { base } = await startServer({ listAllSessions: async () => disk }); const res = await fetch(`${base}/api/fleet`); const body = (await res.json()) as { runtimes: unknown[]; diskSessions: unknown[] }; @@ -510,6 +511,8 @@ describe("dashboard server — fleet and runtimes", () => { path: "/s/one.jsonl", id: "one", cwd: dir, + cwdAvailable: true, + resolvedCwd: canonicalDir, name: "Session one", created: "2026-01-02T03:04:05.000Z", modified: "2026-02-03T04:05:06.000Z", @@ -521,6 +524,7 @@ describe("dashboard server — fleet and runtimes", () => { it("projects bounded disk-session DTOs for fleet, inventory, and resync responses", async () => { const dir = await createTempProject(); + const canonicalDir = await realpath(dir); const preview = `${"a".repeat(MAX_SESSION_PREVIEW_CHARACTERS - 1)}😀trailing text`; const internalSession = { ...diskSession(dir, { firstMessage: preview }), @@ -544,17 +548,21 @@ describe("dashboard server — fleet and runtimes", () => { expect(Object.keys(session ?? {}).sort()).toEqual([ "created", "cwd", + "cwdAvailable", "firstMessage", "id", "messageCount", "modified", "name", "path", + "resolvedCwd", ]); expect(session).toEqual({ path: internalSession.path, id: internalSession.id, cwd: internalSession.cwd, + cwdAvailable: true, + resolvedCwd: canonicalDir, name: internalSession.name, created: internalSession.created.toISOString(), modified: internalSession.modified.toISOString(), @@ -726,7 +734,7 @@ describe("dashboard server — fleet and runtimes", () => { expect(body.fleet).toBeDefined(); }); - it("GET /api/fleet hides disk sessions whose cwd no longer exists", async () => { + it("GET /api/fleet includes sessions whose historical cwd is missing", async () => { const liveCwd = await createTempProject(); const missingCwd = join(tmpdir(), `dreb-dash-missing-${Date.now()}-${Math.random().toString(36).slice(2)}`); const liveSession = diskSession(liveCwd, { path: "/s/live.jsonl", id: "live" }); @@ -734,16 +742,21 @@ describe("dashboard server — fleet and runtimes", () => { const { base } = await startServer({ listAllSessions: async () => [liveSession, missingSession] }); const res = await fetch(`${base}/api/fleet`); - const body = (await res.json()) as { runtimes: unknown[]; diskSessions: Array<{ id: string; cwd: string }> }; + const body = (await res.json()) as { + runtimes: unknown[]; + diskSessions: Array<{ id: string; cwd: string; cwdAvailable: boolean; resolvedCwd?: string }>; + }; expect(res.status).toBe(200); expect(body.diskSessions).toEqual([ - expect.objectContaining({ path: liveSession.path, id: liveSession.id, cwd: liveSession.cwd }), + expect.objectContaining({ id: liveSession.id, cwdAvailable: true }), + expect.objectContaining({ id: missingSession.id, cwd: missingCwd, cwdAvailable: false }), ]); - expect(body.diskSessions).not.toContainEqual(expect.objectContaining({ id: missingSession.id })); + expect(body.diskSessions[0].resolvedCwd).toBe(await realpath(liveCwd)); + expect(body.diskSessions[1].resolvedCwd).toBeUndefined(); }); - it("GET /api/sessions returns only existing disk sessions without describing runtimes", async () => { + it("GET /api/sessions includes unavailable historical cwd metadata without describing runtimes", async () => { const liveCwd = await createTempProject(); const missingCwd = join(tmpdir(), `dreb-dash-missing-${Date.now()}-${Math.random().toString(36).slice(2)}`); const liveSession = diskSession(liveCwd, { path: "/s/live.jsonl", id: "live" }); @@ -754,46 +767,125 @@ describe("dashboard server — fleet and runtimes", () => { const describe = vi.spyOn(pool, "describe"); const res = await fetch(`${base}/api/sessions`); - const body = (await res.json()) as { sessions: Array<{ id: string; cwd: string }> }; + const body = (await res.json()) as { + sessions: Array<{ id: string; cwd: string; cwdAvailable: boolean; resolvedCwd?: string }>; + }; expect(res.status).toBe(200); expect(body.sessions).toEqual([ - expect.objectContaining({ - path: liveSession.path, - id: liveSession.id, - cwd: liveSession.cwd, - created: liveSession.created.toISOString(), - modified: liveSession.modified.toISOString(), - }), + expect.objectContaining({ id: liveSession.id, cwdAvailable: true }), + expect.objectContaining({ id: missingSession.id, cwd: missingCwd, cwdAvailable: false }), ]); expect(listAllSessions).toHaveBeenCalledTimes(1); expect(describe).not.toHaveBeenCalled(); expect(clients[0].getState).not.toHaveBeenCalled(); }); - it("POST /api/runtimes validates cwd and creates a runtime", async () => { + it("projects cwd availability for runtime-local session listings", async () => { + const runtimeCwd = await createTempProject(); + const missingCwd = join(tmpdir(), `dreb-dash-missing-${Date.now()}-${Math.random().toString(36).slice(2)}`); + const { base, clients, pool } = await startServer(); + const handle = await pool.create(runtimeCwd); + vi.mocked(clients[0].listSessions).mockResolvedValueOnce([ + { + path: "/sessions/missing.jsonl", + id: "missing", + cwd: missingCwd, + created: "2026-01-02T03:04:05.000Z", + modified: "2026-02-03T04:05:06.000Z", + messageCount: 2, + firstMessage: "hello", + }, + ]); + + const response = await fetch(`${base}/api/runtimes/${handle.key}/sessions`); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + sessions: [ + expect.objectContaining({ + path: "/sessions/missing.jsonl", + cwd: missingCwd, + cwdAvailable: false, + }), + ], + }); + }); + + it("POST /api/runtimes requires and canonicalizes an existing directory", async () => { const dir = await mkdtemp(join(tmpdir(), "dreb-dash-server-")); - tempDirs.push(dir); + const file = join(dir, "not-a-directory.txt"); + const link = `${dir}-link`; + await writeFile(file, "file"); + await symlink(dir, link); + tempDirs.push(dir, link); const { base, pool } = await startServer(); + const create = vi.spyOn(pool, "create"); + const post = (cwd: string, sessionPath?: string) => + fetch(`${base}/api/runtimes`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ cwd, sessionPath }), + }); - const bad = await fetch(`${base}/api/runtimes`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ cwd: "/does/not/exist" }), - }); - expect(bad.status).toBe(400); + expect((await post("")).status).toBe(400); + expect((await post("relative/path")).status).toBe(400); + expect((await post("/does/not/exist")).status).toBe(404); + expect((await post(file)).status).toBe(400); + expect(create).not.toHaveBeenCalled(); - const good = await fetch(`${base}/api/runtimes`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ cwd: dir }), - }); + const good = await post(link, "/sessions/resume.jsonl"); expect(good.status).toBe(201); const body = (await good.json()) as { key: string; cwd: string }; - expect(body.cwd).toBe(dir); + const canonicalDir = await realpath(dir); + expect(body.cwd).toBe(canonicalDir); + expect(create).toHaveBeenCalledWith(canonicalDir, "/sessions/resume.jsonl"); expect(pool.get(body.key)).toBeDefined(); }); + it.skipIf(process.platform === "win32" || (typeof process.getuid === "function" && process.getuid() === 0))( + "POST /api/runtimes rejects unreadable directories before pool creation", + async () => { + const dir = await mkdtemp(join(tmpdir(), "dreb-dash-server-unreadable-")); + tempDirs.push(dir); + await chmod(dir, 0o000); + const { base, pool } = await startServer(); + const create = vi.spyOn(pool, "create"); + + const response = await fetch(`${base}/api/runtimes`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ cwd: dir }), + }); + + expect(response.status).toBe(403); + expect(create).not.toHaveBeenCalled(); + }, + ); + + it("fallback runtime creation does not rewrite the session header cwd", async () => { + const runtimeCwd = await createTempProject(); + const sessionFile = join(runtimeCwd, "copied-session.jsonl"); + const header = { + type: "session", + version: 3, + id: "copied", + timestamp: "2026-01-02T03:04:05.000Z", + cwd: "/missing/original/project", + }; + await writeFile(sessionFile, `${JSON.stringify(header)}\n`); + const { base } = await startServer(); + + const response = await fetch(`${base}/api/runtimes`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ cwd: runtimeCwd, sessionPath: sessionFile }), + }); + + expect(response.status).toBe(201); + const persistedHeader = JSON.parse((await readFile(sessionFile, "utf8")).split("\n")[0]); + expect(persistedHeader.cwd).toBe("/missing/original/project"); + }); + it("POST /api/runtimes with firstPrompt sends the prompt", async () => { const dir = await mkdtemp(join(tmpdir(), "dreb-dash-server-")); tempDirs.push(dir); diff --git a/packages/semantic-search/.claude-plugin/plugin.json b/packages/semantic-search/.claude-plugin/plugin.json index 2b0886ed..badc1632 100644 --- a/packages/semantic-search/.claude-plugin/plugin.json +++ b/packages/semantic-search/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "semantic-search", "description": "Semantic codebase search — natural language queries over code and docs using embeddings, tree-sitter parsing, and POEM multi-signal ranking", - "version": "2.66.1", + "version": "2.66.2", "author": { "name": "Drew Brereton" }, diff --git a/packages/semantic-search/package.json b/packages/semantic-search/package.json index d2c2dd43..4b0fb4da 100644 --- a/packages/semantic-search/package.json +++ b/packages/semantic-search/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/semantic-search", - "version": "2.66.1", + "version": "2.66.2", "description": "Semantic codebase search engine with embedding-based ranking and MCP server", "publishConfig": { "access": "public" diff --git a/packages/telegram/package.json b/packages/telegram/package.json index 615361a0..3024df9d 100644 --- a/packages/telegram/package.json +++ b/packages/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/telegram", - "version": "2.66.1", + "version": "2.66.2", "description": "Telegram bot frontend for dreb coding agent", "license": "MIT", "type": "module", diff --git a/packages/tui/package.json b/packages/tui/package.json index 2ecfac12..00577a2e 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/tui", - "version": "2.66.1", + "version": "2.66.2", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js",