diff --git a/AGENTS.md b/AGENTS.md
index d49167ae4..a570a9ff1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -136,6 +136,7 @@ claude --plugin-dir ./apps/hook
**Config-only settings (`~/.plannotator/config.json`)**: Some settings have no env-var equivalent and are toggled by editing the config file directly:
- `pfmReminder` (`true` / `false`, default `false`) — when enabled, a Plannotator Flavored Markdown reminder is injected at plan-time describing the renderer's extensions (code-file links, callouts, tables, diagrams, task lists, hex swatches, wiki-links). Lets the planning agent enrich plans with PFM features without having to discover them. Composes cleanly with the compound-skill improvement hook. Supported across all three runtimes: Claude Code (`improve-context` PreToolUse hook in `apps/hook/server/index.ts`), OpenCode (`experimental.chat.system.transform` in `apps/opencode-plugin/index.ts`), and Pi (`before_agent_start` in `apps/pi-extension/index.ts`).
+- `legacyTabMode` (`true` / `false`, default `false`) — when enabled, the daemon opens a new browser tab for every session regardless of whether a frontend is already connected. Sessions use the full-screen `CompletionOverlay` with auto-close instead of the inline `CompletionBanner`. Preserves the pre-frontend tab-per-session behavior for users who prefer it.
**Legacy:** `SSH_TTY` and `SSH_CONNECTION` are still detected when `PLANNOTATOR_REMOTE` is unset. Set `PLANNOTATOR_REMOTE=1` / `true` to force remote mode or `0` / `false` to force local mode.
@@ -234,11 +235,33 @@ The daemon is the single long-running Bun server used by normal plan/review/anno
| `/daemon/sessions/:id/cancel` | POST | Cancel a session and dispose its resources |
| `/daemon/sessions/:id` | DELETE | Delete a session record |
| `/daemon/shutdown` | POST | Ask the daemon to stop |
-| `/daemon/ws` | WebSocket | Multiplex daemon lifecycle events, session-scoped external annotation events, agent job events, and correlated session actions |
+| `/daemon/config` | GET | Read global config (`~/.plannotator/config.json`) |
+| `/daemon/config` | POST | Write global config keys (allowlisted: `displayName`, `pfmReminder`, `legacyTabMode`, `diffOptions`, `conventionalComments`, `conventionalLabels`) |
+| `/daemon/git/user` | GET | Return git user name from `git config user.name` |
+| `/daemon/vaults` | GET | Detect available Obsidian vaults |
+| `/daemon/obsidian/vaults` | GET | Alias for `/daemon/vaults` |
+| `/daemon/hooks/status` | GET | Return PFM reminder and improvement hook status |
+| `/daemon/projects` | DELETE | Remove a project by `?cwd=` (optional `?clean=1` to cancel active sessions) |
+| `/daemon/projects/prs` | GET | List open PRs for a project (`?cwd=`) |
+| `/daemon/projects/prs/detailed` | GET | List PRs with review metadata for dashboard (`?cwd=`) |
+| `/daemon/fs/list` | GET | List directory contents (`?path=`) |
+| `/daemon/ws` | WebSocket | Multiplex daemon lifecycle events, session-scoped external annotation events, agent job events, session revision events, and correlated session actions |
| `/s/:id` | GET | Serve the browser HTML for a session |
| `/s/:id/api/...` | Any | Route browser API requests to that session's plan/review/annotate handler |
-Runtime live updates for daemon lifecycle events, external annotations, and agent jobs are delivered through `/daemon/ws`. Session-scoped updates subscribe by `{ family, sessionId }`. HTTP endpoints below remain for snapshots, mutations, uploads, and large payloads. AI query token streaming remains on `/api/ai/query`.
+Runtime live updates for daemon lifecycle events, external annotations, agent jobs, and session revisions are delivered through `/daemon/ws`. Session-scoped updates subscribe by `{ family, sessionId }`. HTTP endpoints below remain for snapshots, mutations, uploads, and large payloads. AI query token streaming remains on `/api/ai/query`.
+
+### Session Persistence and Resubmission
+
+When a user denies a plan (or sends feedback on a review/annotation), the session enters `awaiting-resubmission` status instead of completing. The session's HTTP handler stays alive. When the agent replans and submits again via `POST /daemon/sessions`, the daemon matches the new submission to the existing session by a match key (`plan:project:slug` for plans, `review:project:branch` for reviews, `annotate:project:filePath` for single-file annotations). The session reactivates in place — the frontend receives a `session-revision` event via WebSocket with the updated content.
+
+**Sessions never die.** No session type calls `store.complete()` from its decision handler. All sessions survive feedback, approve, and exit — the HTTP handler stays alive and the tab keeps working. `registerPersistentDecision` always calls `store.suspend()`. `registerReviewDecision` always calls `store.idle()`. Non-terminal sessions have no expiry timer.
+
+**Session statuses (plan/annotate):** `active` → `awaiting-resubmission` (on any decision) → `active` (on resubmit) → `awaiting-resubmission` ... repeating.
+
+**Session statuses (code review):** `active` → `idle` (on any decision) → `active` (on agent resubmit) → `idle` ... repeating.
+
+**Event families:** `daemon`, `external-annotations`, `agent-jobs`, `session-revision`.
### Plan Server (`packages/server/index.ts`)
@@ -309,7 +332,9 @@ Runtime live updates for daemon lifecycle events, external annotations, and agen
| Endpoint | Method | Purpose |
| --------------------- | ------ | ------------------------------------------ |
-| `/api/plan` | GET | Returns `{ plan, origin, mode: "annotate", filePath, sourceInfo?, gate, renderAs?, rawHtml? }` |
+| `/api/plan` | GET | Returns `{ plan, origin, mode: "annotate", filePath, sourceInfo?, gate, renderAs?, rawHtml?, previousPlan, versionInfo }` |
+| `/api/plan/version` | GET | Fetch specific version (`?v=N`) — single-file annotate only |
+| `/api/plan/versions` | GET | List all versions — single-file annotate only |
| `/api/feedback` | POST | Submit annotations (body: feedback, annotations) |
| `/api/approve` | POST | Approve without feedback (review-gate UX, `--gate`) |
| `/api/exit` | POST | Close session without feedback |
diff --git a/apps/frontend/package.json b/apps/frontend/package.json
index e1d9c3e2f..061a8540a 100644
--- a/apps/frontend/package.json
+++ b/apps/frontend/package.json
@@ -16,16 +16,20 @@
},
"dependencies": {
"@fontsource-variable/geist-mono": "^5.2.7",
+ "@fontsource-variable/instrument-sans": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8",
"@plannotator/code-review": "workspace:*",
+ "@plannotator/plan-review": "workspace:*",
"@plannotator/shared": "workspace:*",
"@plannotator/ui": "workspace:*",
"@radix-ui/react-collapsible": "^1.1.12",
+ "@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-router": "^1.141.0",
"class-variance-authority": "^0.7.1",
diff --git a/apps/frontend/src/app/Layout.tsx b/apps/frontend/src/app/Layout.tsx
index 9e45dd090..071f13d0d 100644
--- a/apps/frontend/src/app/Layout.tsx
+++ b/apps/frontend/src/app/Layout.tsx
@@ -4,9 +4,15 @@ import { Toaster } from "sonner";
import { SidebarProvider, useSidebar } from "@/components/ui/sidebar";
import { TooltipProvider } from "@/components/ui/tooltip";
import { AppSidebar } from "../components/sidebar/AppSidebar";
+import { SidebarPeek } from "../components/sidebar/SidebarPeek";
import { AddProjectDialog } from "../components/landing/AddProjectDialog";
+import { AppSettingsDialog } from "../components/settings/AppSettingsDialog";
import { SessionSurface } from "../components/sessions/SessionSurface";
+import { appStore } from "../stores/app-store";
+import { setGlobalFetchBase } from "@plannotator/ui/utils/api";
import { useDaemonEvents } from "../daemon/events/use-daemon-events";
+
+setGlobalFetchBase("/daemon");
import { projectStore } from "../stores/project-store";
import { useAppStore } from "../stores/app-store";
@@ -18,20 +24,35 @@ function LayoutContent() {
const matchRoute = useMatchRoute();
const { open: sidebarOpen } = useSidebar();
- useDaemonEvents();
+ const { reportActiveSession } = useDaemonEvents();
useEffect(() => {
void projectStore.getState().fetchProjects();
}, []);
const isOnSession = !!matchRoute({ to: "/s/$sessionId", fuzzy: true });
+
+ useEffect(() => {
+ reportActiveSession(isOnSession ? activeSessionId : null);
+ }, [reportActiveSession, isOnSession, activeSessionId]);
const showLanding = !isOnSession;
- const openAddProject = useCallback(() => setAddProjectOpen(true), [setAddProjectOpen]);
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === ",") {
+ e.preventDefault();
+ const current = appStore.getState().settingsOpen;
+ appStore.getState().setSettingsOpen(!current);
+ }
+ };
+ window.addEventListener("keydown", handler);
+ return () => window.removeEventListener("keydown", handler);
+ }, []);
return (
<>
-
+
+
- {Object.values(visitedSessions).map(({ sessionId, bootstrap }) => (
-