Browse Source

feat(interview): add multi-session dashboard mode with failover recovery (#303)

* feat(interview): dashboard v2 with multi-session support, Zod validation, and 953 tests

* docs(interview): document dashboard mode, configuration, and architecture

* fix(interview): address Greptile review findings (P1 + P2)

- Fix EADDRINUSE on last retry throwing instead of returning null (P1)
- Replace console.log with shared log utility in dashboard.ts (P2)
- Extract duplicate decodeURIComponent to variable in server.ts (P2)
- P2 timestamp unit mismatch: false positive (SDK uses milliseconds)

* fix(interview): deduplicate files in dashboard file scanner

When the same .md filename exists in multiple scanned directories
(e.g. home dir and workspace), the file list showed duplicates.
Now deduplicates by filename — first found wins.

* Revert "fix(interview): deduplicate files in dashboard file scanner"

This reverts commit 1674387b5474eca7888059d30ee585a05ce29ac4.

* fix(interview): dedup recovered entries, nudge race, banner false-positive

- Extract dedupRecovered/hasLiveForSlug helpers for duplicate interview
  prevention across all code paths (HTTP create, HTTP state, pushState)
- Reorder nudge/answer processing before state refresh in manager to
  prevent race where completed mode overwrites awaiting-agent
- Replace unstable timestamp with stable sig in health endpoint to
  prevent false-positive 'Dashboard updated' banner on every poll
- Add directory labels and sessionID-based file dedup in dashboard UI
- Update health endpoint test to match new response shape

* fix(interview): address P2 review findings

- Validate individual answer items (questionId/answer must be strings)
  in dashboard answers endpoint instead of only checking Array.isArray
- Clean stateCache entries on session delete in removeSession to
  prevent stale abandoned entries from accumulating
ReqX 3 months ago
parent
commit
2a508da228

+ 5 - 0
docs/configuration.md

@@ -117,3 +117,8 @@ All config files support **JSONC** (JSON with Comments):
 | `todoContinuation.cooldownMs` | integer | `3000` | Delay in ms before auto-continuing — gives user time to abort (0–30000) |
 | `todoContinuation.autoEnable` | boolean | `false` | Automatically enable auto-continue when session has enough todos |
 | `todoContinuation.autoEnableThreshold` | integer | `4` | Number of todos that triggers auto-enable (only used when `autoEnable` is true, 1–50) |
+| `interview.maxQuestions` | integer | `2` | Max questions per interview round (1–10) |
+| `interview.outputFolder` | string | `"interview"` | Directory where interview markdown files are written (relative to project root) |
+| `interview.autoOpenBrowser` | boolean | `true` | Automatically open the interview UI in your default browser |
+| `interview.port` | integer | `0` | Interview server port (0–65535). `0` = OS-assigned random port (per-session mode). Any value > 0 enables [dashboard mode](interview.md#dashboard-mode) |
+| `interview.dashboard` | boolean | `false` | Enable [dashboard mode](interview.md#dashboard-mode) on the default port (43211). Setting `port` > 0 also enables dashboard mode. If both are set, `port` takes precedence |

+ 115 - 2
docs/interview.md

@@ -99,6 +99,20 @@ Example:
 
 If the assistant does not provide a title, the original input is slugified as a fallback.
 
+### Frontmatter
+
+Interview files include YAML frontmatter for recovery after a crash or restart:
+
+```yaml
+---
+sessionID: ses_abc123
+baseMessageCount: 42
+updatedAt: 2026-04-14T10:30:00.000Z
+---
+```
+
+This allows the dashboard to rebuild state from disk without a live session.
+
 ## Keyboard shortcuts
 
 Inside the interview page:
@@ -109,6 +123,94 @@ Inside the interview page:
 - `Cmd+Enter` or `Ctrl+Enter` submits
 - `Cmd+S` or `Ctrl+S` also submits
 
+## Modes
+
+The interview module has two modes: **per-session** (default) and **dashboard** (opt-in).
+
+### Per-session mode (default)
+
+When `port` is `0` (or unset) and `dashboard` is `false` (or unset), each OpenCode process runs its own interview server on a random port. This is the original behavior — no configuration needed.
+
+```jsonc
+{
+  "oh-my-opencode-slim": {
+    "interview": {}
+    // or explicitly:
+    // "interview": { "port": 0 }
+  }
+}
+```
+
+- one interview server per OpenCode process
+- server starts lazily on first `/interview` command
+- random port assigned by the OS
+- all state is local to the session
+
+### Dashboard mode
+
+When `dashboard` is `true` or `port` is set to a value greater than `0`, interview switches to dashboard mode. A single dashboard server aggregates interviews from **all** OpenCode sessions on the same machine.
+
+```jsonc
+// Option A: dashboard on default port (43211)
+"interview": { "dashboard": true }
+
+// Option B: dashboard on custom port
+"interview": { "dashboard": true, "port": 8888 }
+
+// Option C: port > 0 implies dashboard mode
+"interview": { "port": 43211 }
+```
+
+#### What the dashboard gives you
+
+- **Single URL.** One dashboard page lists all active and past interviews across all sessions.
+- **Multi-session coordination.** Each OpenCode process pushes interview state to the dashboard. The dashboard serves the web UI and relays answers back to the right session.
+- **Failover recovery.** If the dashboard process dies, the next OpenCode process to start claims the port and rebuilds state from `.md` files on disk.
+- **File browser.** Scans `interview/` (or your configured output folder) across all known project directories, including your home directory.
+
+#### How it works
+
+```
+┌──────────────────────────────────────────────┐
+│  Dashboard (dumb aggregator)                  │
+│                                               │
+│  • Receives state pushes from sessions        │
+│  • Serves dashboard UI + interview pages      │
+│  • Stores pending answers for session pickup  │
+│  • Binds to 127.0.0.1, token-authenticated    │
+└───────────▲───────────────────▲───────────────┘
+            │ POST state        │ GET pending answers
+┌───────────┴────────┐ ┌───────┴──────────────┐
+│  Session Process A  │ │  Session Process B    │
+│  (smart — drives    │ │  (smart — drives      │
+│   LLM locally)      │ │   LLM locally)        │
+└─────────────────────┘ └───────────────────────┘
+```
+
+Sessions are smart — they drive LLM interaction locally (parse state, inject prompts, write `.md` files). The dashboard is a dumb aggregator with a web UI. This means zero cross-process SDK dependency.
+
+#### Auto-failover
+
+Any OpenCode process can become the dashboard. The first process to bind the configured port wins. If it dies:
+
+1. Other sessions detect the dead dashboard (failed state push or health probe)
+2. The next process to start claims the port
+3. The new dashboard rebuilds from `.md` files on disk using frontmatter
+
+#### Session registration
+
+Sessions register their project directory with the dashboard so it knows where to scan for interview files. This happens automatically on first `/interview` command or session event — no manual setup needed.
+
+The dashboard also scans your home directory's output folder by default, so interviews created from a home-directory OpenCode session are always visible.
+
+#### Dashboard settings
+
+The dashboard page includes a settings panel for:
+
+- **Scan days** — how far back to look for sessions (default: 30)
+- **Add/remove folders** — manually add project directories to scan
+- **Discover sessions** — re-scan the OpenCode session list for new directories
+
 ## Configuration
 
 ```jsonc
@@ -118,7 +220,8 @@ Inside the interview page:
       "maxQuestions": 2,
       "outputFolder": "interview",
       "autoOpenBrowser": true,
-      "port": 0
+      "port": 0,
+      "dashboard": false
     }
   }
 }
@@ -129,7 +232,16 @@ Inside the interview page:
 - `maxQuestions` — max questions per round, `1-10`, default `2`
 - `outputFolder` — where markdown files are written, default `interview`
 - `autoOpenBrowser` — open the localhost UI in your default browser, default `true`
-- `port` — fixed port for the interview UI server, `0-65535`, default `0` (OS-assigned). Set a fixed port for remote access via Tailscale Serve, Cloudflare Tunnel, or SSH tunneling. Note: ports 1-1023 require elevated privileges on most systems.
+- `port` — port for the interview server, `0-65535`, default `0` (OS-assigned in per-session mode). Set a fixed port to enable dashboard mode. Note: ports 1-1023 require elevated privileges on most systems.
+- `dashboard` — enable dashboard mode on the default port (`43211`), default `false`. Setting `port` to a value greater than `0` also enables dashboard mode. If both are set, `port` takes precedence.
+
+### Mode selection
+
+| `port` | `dashboard` | Mode |
+|--------|-------------|------|
+| `0` (default) | `false` (default) | Per-session — each process runs its own server |
+| `0` | `true` | Dashboard on default port 43211 |
+| `> 0` | any | Dashboard on the specified port |
 
 ## Remote access
 
@@ -166,6 +278,7 @@ ssh -L <port>:127.0.0.1:<port> your-server
 - browser updates use polling, not realtime push
 - runtime interview state is in-memory; the markdown file is the durable artifact
 - the flow depends on the assistant returning valid `<interview_state>` blocks
+- dashboard mode answer delivery has a few seconds of latency (session polls the dashboard)
 
 ## Related
 

+ 1 - 0
docs/quick-reference.md

@@ -14,6 +14,7 @@
 | Doc | Contents |
 |-----|----------|
 | [Council Agent](council.md) | Multi-LLM consensus, presets, role prompts, timeouts |
+| [Interview](interview.md) | `/interview` command, browser UI, dashboard mode, multi-session coordination |
 | [Multiplexer Integration](multiplexer-integration.md) | Real-time pane monitoring, layouts, troubleshooting |
 | [Cartography Skill](cartography.md) | Hierarchical codemap generation |
 

+ 4 - 0
oh-my-opencode-slim.schema.json

@@ -450,6 +450,10 @@
           "type": "integer",
           "minimum": 0,
           "maximum": 65535
+        },
+        "dashboard": {
+          "default": false,
+          "type": "boolean"
         }
       }
     },

+ 1 - 0
src/config/schema.ts

@@ -170,6 +170,7 @@ export const InterviewConfigSchema = z.object({
   outputFolder: z.string().min(1).default('interview'),
   autoOpenBrowser: z.boolean().default(true),
   port: z.number().int().min(0).max(65535).default(0),
+  dashboard: z.boolean().default(false),
 });
 
 export type InterviewConfig = z.infer<typeof InterviewConfigSchema>;

+ 1303 - 0
src/interview/dashboard.test.ts

@@ -0,0 +1,1303 @@
+import { describe, expect, test } from 'bun:test';
+import * as fs from 'node:fs/promises';
+import { createServer } from 'node:http';
+import * as path from 'node:path';
+import { createDashboardServer } from './dashboard';
+
+// Helper to find a free port (matches interview.test.ts pattern)
+function findFreePort(): Promise<number> {
+  return new Promise((resolve, reject) => {
+    const server = createServer();
+    server.listen(0, () => {
+      const address = server.address();
+      if (address && typeof address !== 'string') {
+        const port = address.port;
+        server.close(() => resolve(port));
+      } else {
+        server.close(() => reject(new Error('Failed to get port')));
+      }
+    });
+  });
+}
+
+// Helper to start a dashboard on a free port
+async function startDashboard() {
+  const port = await findFreePort();
+  const dashboard = createDashboardServer({
+    port,
+    outputFolder: 'interview',
+  });
+  const baseUrl = await dashboard.start();
+
+  return {
+    dashboard,
+    baseUrl,
+    authToken: dashboard.authToken,
+    cleanup: () => {
+      dashboard.close();
+    },
+  };
+}
+
+// Helper to create a temp directory with interview files
+async function createTempInterviewDir() {
+  const tempDir = await fs.mkdtemp('/tmp/dashboard-test-');
+  const interviewDir = path.join(tempDir, 'interview');
+  await fs.mkdir(interviewDir, { recursive: true });
+  return tempDir;
+}
+
+describe('dashboard server', () => {
+  describe('health endpoint', () => {
+    test('returns 200 with status ok and counts', async () => {
+      const { baseUrl, cleanup } = await startDashboard();
+      try {
+        const response = await fetch(`${baseUrl}/api/health`);
+        expect(response.status).toBe(200);
+        const data = (await response.json()) as {
+          status: string;
+          sessions: number;
+          interviews: number;
+        };
+        expect(data.status).toBe('ok');
+        expect(data.sessions).toBe(0);
+        expect(data.interviews).toBe(0);
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('works without auth', async () => {
+      const { baseUrl, cleanup } = await startDashboard();
+      try {
+        const response = await fetch(`${baseUrl}/api/health`);
+        expect(response.status).toBe(200);
+      } finally {
+        cleanup();
+      }
+    });
+  });
+
+  describe('auth gate', () => {
+    test('POST /api/register without auth returns 401', async () => {
+      const { baseUrl, cleanup } = await startDashboard();
+      try {
+        const response = await fetch(`${baseUrl}/api/register`, {
+          method: 'POST',
+          body: JSON.stringify({
+            sessionID: 'test-session',
+            directory: '/test/dir',
+          }),
+          headers: { 'content-type': 'application/json' },
+        });
+        expect(response.status).toBe(401);
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('POST /api/interviews without auth returns 401', async () => {
+      const { baseUrl, cleanup } = await startDashboard();
+      try {
+        const response = await fetch(`${baseUrl}/api/interviews`, {
+          method: 'POST',
+          body: JSON.stringify({
+            interviewId: 'test-interview',
+            sessionID: 'test-session',
+            idea: 'Test idea',
+          }),
+          headers: { 'content-type': 'application/json' },
+        });
+        expect(response.status).toBe(401);
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('GET /api/sessions without auth returns 401', async () => {
+      const { baseUrl, cleanup } = await startDashboard();
+      try {
+        const response = await fetch(`${baseUrl}/api/sessions`);
+        expect(response.status).toBe(401);
+      } finally {
+        cleanup();
+      }
+    });
+  });
+
+  describe('auth methods', () => {
+    test('works with ?token= query param', async () => {
+      const { baseUrl, authToken, cleanup } = await startDashboard();
+      try {
+        const response = await fetch(
+          `${baseUrl}/api/sessions?token=${authToken}`,
+        );
+        expect(response.status).toBe(200);
+        const data = (await response.json()) as { sessions: unknown[] };
+        expect(Array.isArray(data.sessions)).toBe(true);
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('works with Cookie header', async () => {
+      const { baseUrl, authToken, cleanup } = await startDashboard();
+      try {
+        const response = await fetch(`${baseUrl}/api/sessions`, {
+          headers: {
+            cookie: `dashboard_token=${authToken}`,
+          },
+        });
+        expect(response.status).toBe(200);
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('works with Authorization: Bearer header', async () => {
+      const { baseUrl, authToken, cleanup } = await startDashboard();
+      try {
+        const response = await fetch(`${baseUrl}/api/sessions`, {
+          headers: {
+            authorization: `Bearer ${authToken}`,
+          },
+        });
+        expect(response.status).toBe(200);
+      } finally {
+        cleanup();
+      }
+    });
+  });
+
+  describe('session registration (POST /api/register)', () => {
+    test('registers a valid session', async () => {
+      const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
+      try {
+        const response = await fetch(
+          `${baseUrl}/api/register?token=${authToken}`,
+          {
+            method: 'POST',
+            body: JSON.stringify({
+              sessionID: 'session-123',
+              directory: '/test/directory',
+              pid: 12345,
+            }),
+            headers: { 'content-type': 'application/json' },
+          },
+        );
+        expect(response.status).toBe(200);
+        const data = (await response.json()) as { status: string };
+        expect(data.status).toBe('registered');
+
+        // Verify session is registered
+        const _state = dashboard.getState('dummy-interview');
+        // State doesn't exist yet, but session was registered
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('rejects missing sessionID', async () => {
+      const { baseUrl, authToken, cleanup } = await startDashboard();
+      try {
+        const response = await fetch(
+          `${baseUrl}/api/register?token=${authToken}`,
+          {
+            method: 'POST',
+            body: JSON.stringify({
+              directory: '/test/directory',
+            }),
+            headers: { 'content-type': 'application/json' },
+          },
+        );
+        expect(response.status).toBe(400);
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('rejects invalid sessionID (special chars)', async () => {
+      const { baseUrl, authToken, cleanup } = await startDashboard();
+      try {
+        const response = await fetch(
+          `${baseUrl}/api/register?token=${authToken}`,
+          {
+            method: 'POST',
+            body: JSON.stringify({
+              sessionID: 'session/with/slashes',
+              directory: '/test/directory',
+            }),
+            headers: { 'content-type': 'application/json' },
+          },
+        );
+        expect(response.status).toBe(400);
+      } finally {
+        cleanup();
+      }
+    });
+  });
+
+  describe('create interview (POST /api/interviews)', () => {
+    test('creates interview entry in cache', async () => {
+      const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
+      try {
+        const response = await fetch(
+          `${baseUrl}/api/interviews?token=${authToken}`,
+          {
+            method: 'POST',
+            body: JSON.stringify({
+              interviewId: 'interview-1',
+              sessionID: 'session-1',
+              idea: 'Test Interview',
+            }),
+            headers: { 'content-type': 'application/json' },
+          },
+        );
+        expect(response.status).toBe(200);
+        const data = (await response.json()) as {
+          interviewId: string;
+          url: string;
+        };
+        expect(data.interviewId).toBe('interview-1');
+        expect(data.url).toContain('interview-1');
+
+        // Verify interview is in cache
+        const state = dashboard.getState('interview-1');
+        expect(state?.interviewId).toBe('interview-1');
+        expect(state?.idea).toBe('Test Interview');
+        expect(state?.mode).toBe('awaiting-agent');
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('returns interview URL', async () => {
+      const { baseUrl, authToken, cleanup } = await startDashboard();
+      try {
+        const response = await fetch(
+          `${baseUrl}/api/interviews?token=${authToken}`,
+          {
+            method: 'POST',
+            body: JSON.stringify({
+              interviewId: 'interview-2',
+              sessionID: 'session-2',
+              idea: 'Test URL',
+            }),
+            headers: { 'content-type': 'application/json' },
+          },
+        );
+        const data = (await response.json()) as { url: string };
+        expect(data.url).toBe(`${baseUrl}/interview/interview-2`);
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('rejects missing fields', async () => {
+      const { baseUrl, authToken, cleanup } = await startDashboard();
+      try {
+        const response = await fetch(
+          `${baseUrl}/api/interviews?token=${authToken}`,
+          {
+            method: 'POST',
+            body: JSON.stringify({
+              interviewId: 'interview-3',
+              sessionID: 'session-3',
+            }),
+            headers: { 'content-type': 'application/json' },
+          },
+        );
+        expect(response.status).toBe(400);
+      } finally {
+        cleanup();
+      }
+    });
+  });
+
+  describe('state push/merge (POST /api/interviews/:id/state)', () => {
+    test('creates new entry when not in cache', async () => {
+      const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
+      try {
+        const response = await fetch(
+          `${baseUrl}/api/interviews/new-interview/state?token=${authToken}`,
+          {
+            method: 'POST',
+            body: JSON.stringify({
+              sessionID: 'session-new',
+              idea: 'New Idea',
+              summary: 'Test summary',
+              questions: [
+                { id: 'q-1', question: 'What?', options: ['A', 'B'] },
+              ],
+            }),
+            headers: { 'content-type': 'application/json' },
+          },
+        );
+        expect(response.status).toBe(200);
+
+        const state = dashboard.getState('new-interview');
+        expect(state?.interviewId).toBe('new-interview');
+        expect(state?.summary).toBe('Test summary');
+        expect(state?.questions.length).toBe(1);
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('merges partial state update when entry exists', async () => {
+      const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
+      try {
+        // First push - create entry
+        await fetch(
+          `${baseUrl}/api/interviews/merge-test/state?token=${authToken}`,
+          {
+            method: 'POST',
+            body: JSON.stringify({
+              sessionID: 'session-merge',
+              idea: 'Merge Test',
+              summary: 'Initial summary',
+              questions: [{ id: 'q-1', question: 'Q1?', options: ['A', 'B'] }],
+            }),
+            headers: { 'content-type': 'application/json' },
+          },
+        );
+
+        // Second push - merge updates
+        await fetch(
+          `${baseUrl}/api/interviews/merge-test/state?token=${authToken}`,
+          {
+            method: 'POST',
+            body: JSON.stringify({
+              mode: 'awaiting-user',
+              summary: 'Updated summary',
+              title: 'Updated Title',
+              questions: [{ id: 'q-2', question: 'Q2?', options: ['C', 'D'] }],
+            }),
+            headers: { 'content-type': 'application/json' },
+          },
+        );
+
+        const state = dashboard.getState('merge-test');
+        expect(state?.mode).toBe('awaiting-user');
+        expect(state?.summary).toBe('Updated summary');
+        expect(state?.title).toBe('Updated Title');
+        expect(state?.questions.length).toBe(1);
+        expect(state?.questions[0].id).toBe('q-2');
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('rejects invalid interview ID', async () => {
+      const { baseUrl, authToken, cleanup } = await startDashboard();
+      try {
+        const response = await fetch(
+          `${baseUrl}/api/interviews/invalid/id/state?token=${authToken}`,
+          {
+            method: 'POST',
+            body: JSON.stringify({}),
+            headers: { 'content-type': 'application/json' },
+          },
+        );
+        expect(response.status).toBe(400);
+      } finally {
+        cleanup();
+      }
+    });
+  });
+
+  describe('get state (GET /api/interviews/:id/state)', () => {
+    test('returns full state for existing interview', async () => {
+      const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
+      try {
+        // Create interview
+        dashboard.pushState({
+          interviewId: 'get-state-test',
+          sessionID: 'session-get',
+          idea: 'Get State Test',
+          mode: 'awaiting-user',
+          summary: 'Test summary',
+          title: 'Test Title',
+          questions: [{ id: 'q-1', question: 'What?', options: ['A', 'B'] }],
+          pendingAnswers: null,
+          lastUpdatedAt: Date.now(),
+          filePath: '',
+          nudgeAction: null,
+        });
+
+        const response = await fetch(
+          `${baseUrl}/api/interviews/get-state-test/state?token=${authToken}`,
+        );
+        expect(response.status).toBe(200);
+        const data = (await response.json()) as {
+          interview: { id: string; idea: string };
+          mode: string;
+          summary: string;
+          questions: Array<{ id: string }>;
+        };
+        expect(data.interview.id).toBe('get-state-test');
+        expect(data.interview.idea).toBe('Get State Test');
+        expect(data.mode).toBe('awaiting-user');
+        expect(data.summary).toBe('Test summary');
+        expect(data.questions.length).toBe(1);
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('returns 404 for unknown interview', async () => {
+      const { baseUrl, authToken, cleanup } = await startDashboard();
+      try {
+        const response = await fetch(
+          `${baseUrl}/api/interviews/unknown/state?token=${authToken}`,
+        );
+        expect(response.status).toBe(404);
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('includes document content from .md file when filePath points to real file', async () => {
+      const tempDir = await createTempInterviewDir();
+      const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
+      try {
+        // Create a markdown file
+        const mdPath = path.join(tempDir, 'interview', 'doc-test.md');
+        await fs.writeFile(mdPath, '# Test Document\n\nContent here.', 'utf8');
+
+        // Register the temp directory as a session
+        dashboard.registerSession({
+          sessionID: 'session-doc',
+          directory: tempDir,
+          pid: 0,
+          registeredAt: Date.now(),
+        });
+
+        // Push state with filePath
+        dashboard.pushState({
+          interviewId: 'doc-test',
+          sessionID: 'session-doc',
+          idea: 'Doc Test',
+          mode: 'completed',
+          summary: 'Test',
+          title: 'Doc Test',
+          questions: [],
+          pendingAnswers: null,
+          lastUpdatedAt: Date.now(),
+          filePath: mdPath,
+          nudgeAction: null,
+        });
+
+        const response = await fetch(
+          `${baseUrl}/api/interviews/doc-test/state?token=${authToken}`,
+        );
+        const data = (await response.json()) as { document: string };
+        expect(data.document).toContain('# Test Document');
+        expect(data.document).toContain('Content here.');
+
+        await fs.rm(tempDir, { recursive: true, force: true });
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('returns isBusy true when mode is awaiting-agent', async () => {
+      const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
+      try {
+        dashboard.pushState({
+          interviewId: 'busy-test',
+          sessionID: 'session-busy',
+          idea: 'Busy Test',
+          mode: 'awaiting-agent',
+          summary: 'Test',
+          title: 'Busy Test',
+          questions: [],
+          pendingAnswers: null,
+          lastUpdatedAt: Date.now(),
+          filePath: '',
+          nudgeAction: null,
+        });
+
+        const response = await fetch(
+          `${baseUrl}/api/interviews/busy-test/state?token=${authToken}`,
+        );
+        const data = (await response.json()) as { isBusy: boolean };
+        expect(data.isBusy).toBe(true);
+      } finally {
+        cleanup();
+      }
+    });
+  });
+
+  describe('submit answers (POST /api/interviews/:id/answers)', () => {
+    test('stores answers as pending', async () => {
+      const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
+      try {
+        // Create interview
+        dashboard.pushState({
+          interviewId: 'answers-test',
+          sessionID: 'session-answers',
+          idea: 'Answers Test',
+          mode: 'awaiting-user',
+          summary: 'Test',
+          title: 'Answers Test',
+          questions: [
+            {
+              id: 'q-1',
+              question: 'What?',
+              options: ['A', 'B'],
+              suggested: 'A',
+            },
+          ],
+          pendingAnswers: null,
+          lastUpdatedAt: Date.now(),
+          filePath: '',
+          nudgeAction: null,
+        });
+
+        // Submit answers
+        const response = await fetch(
+          `${baseUrl}/api/interviews/answers-test/answers?token=${authToken}`,
+          {
+            method: 'POST',
+            body: JSON.stringify({
+              answers: [{ questionId: 'q-1', answer: 'A' }],
+            }),
+            headers: { 'content-type': 'application/json' },
+          },
+        );
+        expect(response.status).toBe(200);
+
+        // Verify answers are stored
+        const state = dashboard.getState('answers-test');
+        expect(state?.pendingAnswers).toEqual([
+          { questionId: 'q-1', answer: 'A' },
+        ]);
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('sets mode to awaiting-agent', async () => {
+      const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
+      try {
+        dashboard.pushState({
+          interviewId: 'mode-test',
+          sessionID: 'session-mode',
+          idea: 'Mode Test',
+          mode: 'awaiting-user',
+          summary: 'Test',
+          title: 'Mode Test',
+          questions: [{ id: 'q-1', question: 'What?', options: ['A', 'B'] }],
+          pendingAnswers: null,
+          lastUpdatedAt: Date.now(),
+          filePath: '',
+          nudgeAction: null,
+        });
+
+        await fetch(
+          `${baseUrl}/api/interviews/mode-test/answers?token=${authToken}`,
+          {
+            method: 'POST',
+            body: JSON.stringify({
+              answers: [{ questionId: 'q-1', answer: 'A' }],
+            }),
+            headers: { 'content-type': 'application/json' },
+          },
+        );
+
+        const state = dashboard.getState('mode-test');
+        expect(state?.mode).toBe('awaiting-agent');
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('rejects non-array answers', async () => {
+      const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
+      try {
+        // Create an interview first so the route exists
+        dashboard.pushState({
+          interviewId: 'invalid-answers',
+          sessionID: 'session-invalid',
+          idea: 'Invalid Answers',
+          mode: 'awaiting-user',
+          summary: 'Test',
+          title: 'Invalid Answers',
+          questions: [],
+          pendingAnswers: null,
+          lastUpdatedAt: Date.now(),
+          filePath: '',
+          nudgeAction: null,
+        });
+
+        const response = await fetch(
+          `${baseUrl}/api/interviews/invalid-answers/answers?token=${authToken}`,
+          {
+            method: 'POST',
+            body: JSON.stringify({
+              answers: 'not-an-array',
+            }),
+            headers: { 'content-type': 'application/json' },
+          },
+        );
+        expect(response.status).toBe(400);
+      } finally {
+        cleanup();
+      }
+    });
+  });
+
+  describe('consume pending answers (GET /api/interviews/:id/pending)', () => {
+    test('returns and clears pending answers atomically', async () => {
+      const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
+      try {
+        // Create interview with pending answers
+        dashboard.pushState({
+          interviewId: 'pending-test',
+          sessionID: 'session-pending',
+          idea: 'Pending Test',
+          mode: 'awaiting-agent',
+          summary: 'Test',
+          title: 'Pending Test',
+          questions: [],
+          pendingAnswers: [{ questionId: 'q-1', answer: 'A' }],
+          lastUpdatedAt: Date.now(),
+          filePath: '',
+          nudgeAction: null,
+        });
+
+        // First call - returns answers
+        const response1 = await fetch(
+          `${baseUrl}/api/interviews/pending-test/pending?token=${authToken}`,
+        );
+        const data1 = (await response1.json()) as {
+          answers: Array<{ questionId: string; answer: string }> | null;
+        };
+        expect(data1.answers).toEqual([{ questionId: 'q-1', answer: 'A' }]);
+
+        // Verify state was cleared
+        const state = dashboard.getState('pending-test');
+        expect(state?.pendingAnswers).toBeNull();
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('returns null on second call (already consumed)', async () => {
+      const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
+      try {
+        dashboard.pushState({
+          interviewId: 'consume-test',
+          sessionID: 'session-consume',
+          idea: 'Consume Test',
+          mode: 'awaiting-agent',
+          summary: 'Test',
+          title: 'Consume Test',
+          questions: [],
+          pendingAnswers: [{ questionId: 'q-1', answer: 'A' }],
+          lastUpdatedAt: Date.now(),
+          filePath: '',
+          nudgeAction: null,
+        });
+
+        // First call
+        await fetch(
+          `${baseUrl}/api/interviews/consume-test/pending?token=${authToken}`,
+        );
+
+        // Second call - should return null
+        const response2 = await fetch(
+          `${baseUrl}/api/interviews/consume-test/pending?token=${authToken}`,
+        );
+        const data2 = (await response2.json()) as {
+          answers: Array<{ questionId: string; answer: string }> | null;
+        };
+        expect(data2.answers).toBeNull();
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('returns 404 for unknown interview', async () => {
+      const { baseUrl, authToken, cleanup } = await startDashboard();
+      try {
+        const response = await fetch(
+          `${baseUrl}/api/interviews/unknown/pending?token=${authToken}`,
+        );
+        expect(response.status).toBe(404);
+      } finally {
+        cleanup();
+      }
+    });
+  });
+
+  describe('nudge (POST /api/interviews/:id/nudge)', () => {
+    test('stores nudge action', async () => {
+      const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
+      try {
+        dashboard.pushState({
+          interviewId: 'nudge-test',
+          sessionID: 'session-nudge',
+          idea: 'Nudge Test',
+          mode: 'awaiting-user',
+          summary: 'Test',
+          title: 'Nudge Test',
+          questions: [],
+          pendingAnswers: null,
+          lastUpdatedAt: Date.now(),
+          filePath: '',
+          nudgeAction: null,
+        });
+
+        const response = await fetch(
+          `${baseUrl}/api/interviews/nudge-test/nudge?token=${authToken}`,
+          {
+            method: 'POST',
+            body: JSON.stringify({ action: 'more-questions' }),
+            headers: { 'content-type': 'application/json' },
+          },
+        );
+        expect(response.status).toBe(200);
+
+        const state = dashboard.getState('nudge-test');
+        expect(state?.nudgeAction).toBe('more-questions');
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('sets mode to awaiting-agent', async () => {
+      const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
+      try {
+        dashboard.pushState({
+          interviewId: 'nudge-mode-test',
+          sessionID: 'session-nudge-mode',
+          idea: 'Nudge Mode Test',
+          mode: 'awaiting-user',
+          summary: 'Test',
+          title: 'Nudge Mode Test',
+          questions: [],
+          pendingAnswers: null,
+          lastUpdatedAt: Date.now(),
+          filePath: '',
+          nudgeAction: null,
+        });
+
+        await fetch(
+          `${baseUrl}/api/interviews/nudge-mode-test/nudge?token=${authToken}`,
+          {
+            method: 'POST',
+            body: JSON.stringify({ action: 'confirm-complete' }),
+            headers: { 'content-type': 'application/json' },
+          },
+        );
+
+        const state = dashboard.getState('nudge-mode-test');
+        expect(state?.mode).toBe('awaiting-agent');
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('rejects invalid action', async () => {
+      const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
+      try {
+        // Create an interview first so the route exists
+        dashboard.pushState({
+          interviewId: 'nudge-invalid',
+          sessionID: 'session-nudge-invalid',
+          idea: 'Nudge Invalid',
+          mode: 'awaiting-user',
+          summary: 'Test',
+          title: 'Nudge Invalid',
+          questions: [],
+          pendingAnswers: null,
+          lastUpdatedAt: Date.now(),
+          filePath: '',
+          nudgeAction: null,
+        });
+
+        const response = await fetch(
+          `${baseUrl}/api/interviews/nudge-invalid/nudge?token=${authToken}`,
+          {
+            method: 'POST',
+            body: JSON.stringify({ action: 'invalid-action' }),
+            headers: { 'content-type': 'application/json' },
+          },
+        );
+        expect(response.status).toBe(400);
+      } finally {
+        cleanup();
+      }
+    });
+  });
+
+  describe('consume nudge (GET /api/interviews/:id/nudge)', () => {
+    test('returns and clears nudge action atomically', async () => {
+      const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
+      try {
+        dashboard.pushState({
+          interviewId: 'consume-nudge-test',
+          sessionID: 'session-consume-nudge',
+          idea: 'Consume Nudge Test',
+          mode: 'awaiting-agent',
+          summary: 'Test',
+          title: 'Consume Nudge Test',
+          questions: [],
+          pendingAnswers: null,
+          lastUpdatedAt: Date.now(),
+          filePath: '',
+          nudgeAction: 'more-questions',
+        });
+
+        const response = await fetch(
+          `${baseUrl}/api/interviews/consume-nudge-test/nudge?token=${authToken}`,
+        );
+        const data = (await response.json()) as {
+          action: 'more-questions' | 'confirm-complete' | null;
+        };
+        expect(data.action).toBe('more-questions');
+
+        const state = dashboard.getState('consume-nudge-test');
+        expect(state?.nudgeAction).toBeNull();
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('returns null on second call', async () => {
+      const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
+      try {
+        dashboard.pushState({
+          interviewId: 'nudge-second-test',
+          sessionID: 'session-nudge-second',
+          idea: 'Nudge Second Test',
+          mode: 'awaiting-agent',
+          summary: 'Test',
+          title: 'Nudge Second Test',
+          questions: [],
+          pendingAnswers: null,
+          lastUpdatedAt: Date.now(),
+          filePath: '',
+          nudgeAction: 'confirm-complete',
+        });
+
+        // First call
+        await fetch(
+          `${baseUrl}/api/interviews/nudge-second-test/nudge?token=${authToken}`,
+        );
+
+        // Second call
+        const response2 = await fetch(
+          `${baseUrl}/api/interviews/nudge-second-test/nudge?token=${authToken}`,
+        );
+        const data2 = (await response2.json()) as {
+          action: 'more-questions' | 'confirm-complete' | null;
+        };
+        expect(data2.action).toBeNull();
+      } finally {
+        cleanup();
+      }
+    });
+  });
+
+  describe('interview page (GET /interview/:id)', () => {
+    test('returns HTML with proper content type', async () => {
+      const { baseUrl, dashboard, cleanup } = await startDashboard();
+      try {
+        dashboard.pushState({
+          interviewId: 'page-test',
+          sessionID: 'session-page',
+          idea: 'Page Test',
+          mode: 'awaiting-user',
+          summary: 'Test',
+          title: 'Page Test',
+          questions: [],
+          pendingAnswers: null,
+          lastUpdatedAt: Date.now(),
+          filePath: '',
+          nudgeAction: null,
+        });
+
+        const response = await fetch(`${baseUrl}/interview/page-test`);
+        expect(response.status).toBe(200);
+        expect(response.headers.get('content-type')).toContain('text/html');
+        const html = await response.text();
+        expect(html).toContain('page-test');
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('sets session cookie', async () => {
+      const { baseUrl, dashboard, cleanup } = await startDashboard();
+      try {
+        dashboard.pushState({
+          interviewId: 'cookie-test',
+          sessionID: 'session-cookie',
+          idea: 'Cookie Test',
+          mode: 'awaiting-user',
+          summary: 'Test',
+          title: 'Cookie Test',
+          questions: [],
+          pendingAnswers: null,
+          lastUpdatedAt: Date.now(),
+          filePath: '',
+          nudgeAction: null,
+        });
+
+        const response = await fetch(`${baseUrl}/interview/cookie-test`);
+        const cookies = response.headers.get('set-cookie');
+        expect(cookies).toContain('dashboard_token=');
+        expect(cookies).toContain('HttpOnly');
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('returns 400 for invalid interview ID', async () => {
+      const { baseUrl, cleanup } = await startDashboard();
+      try {
+        const response = await fetch(`${baseUrl}/interview/invalid/id`);
+        expect(response.status).toBe(400);
+      } finally {
+        cleanup();
+      }
+    });
+  });
+
+  describe('dashboard page (GET /)', () => {
+    test('returns HTML', async () => {
+      const { baseUrl, cleanup } = await startDashboard();
+      try {
+        const response = await fetch(`${baseUrl}/`);
+        expect(response.status).toBe(200);
+        expect(response.headers.get('content-type')).toContain('text/html');
+        const html = await response.text();
+        expect(html).toContain('Interview');
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('requires auth', async () => {
+      const { baseUrl, cleanup } = await startDashboard();
+      try {
+        // The root endpoint actually sets a cookie, so it doesn't require auth
+        // Let's verify it works without auth (it sets cookie)
+        const response = await fetch(`${baseUrl}/`);
+        expect(response.status).toBe(200);
+      } finally {
+        cleanup();
+      }
+    });
+  });
+
+  describe('settings (GET /api/settings, POST /api/settings)', () => {
+    test('GET returns current settings', async () => {
+      const { baseUrl, authToken, cleanup } = await startDashboard();
+      try {
+        const response = await fetch(
+          `${baseUrl}/api/settings?token=${authToken}`,
+        );
+        expect(response.status).toBe(200);
+        const data = (await response.json()) as {
+          scanDays: number;
+          folders: string[];
+          discoveredFolders: string[];
+          registeredSessions: number;
+        };
+        expect(typeof data.scanDays).toBe('number');
+        expect(Array.isArray(data.folders)).toBe(true);
+        expect(Array.isArray(data.discoveredFolders)).toBe(true);
+        expect(typeof data.registeredSessions).toBe('number');
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('POST updates scan days', async () => {
+      const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
+      try {
+        const response = await fetch(
+          `${baseUrl}/api/settings?token=${authToken}`,
+          {
+            method: 'POST',
+            body: JSON.stringify({ scanDays: 60 }),
+            headers: { 'content-type': 'application/json' },
+          },
+        );
+        expect(response.status).toBe(200);
+        const data = (await response.json()) as { scanDays: number };
+        expect(data.scanDays).toBe(60);
+
+        // Verify it was updated
+        expect(dashboard.getScanDays()).toBe(60);
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('both require auth', async () => {
+      const { baseUrl, cleanup } = await startDashboard();
+      try {
+        const getResponse = await fetch(`${baseUrl}/api/settings`);
+        expect(getResponse.status).toBe(401);
+
+        const postResponse = await fetch(`${baseUrl}/api/settings`, {
+          method: 'POST',
+          body: JSON.stringify({ scanDays: 30 }),
+          headers: { 'content-type': 'application/json' },
+        });
+        expect(postResponse.status).toBe(401);
+      } finally {
+        cleanup();
+      }
+    });
+  });
+
+  describe('direct API methods', () => {
+    test('registerSession adds session to registry', async () => {
+      const { baseUrl, dashboard, cleanup } = await startDashboard();
+      try {
+        dashboard.registerSession({
+          sessionID: 'direct-session',
+          directory: '/direct/dir',
+          pid: 999,
+          registeredAt: Date.now(),
+        });
+
+        // Verify session is registered by checking it exists
+        const response = await fetch(
+          `${baseUrl}/api/sessions?token=${dashboard.authToken}`,
+        );
+        expect(response.status).toBe(200);
+        const data = (await response.json()) as {
+          sessions: Array<{ sessionID: string }>;
+        };
+        expect(
+          data.sessions.some((s) => s.sessionID === 'direct-session'),
+        ).toBe(true);
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('pushState updates cache', async () => {
+      const { dashboard, cleanup } = await startDashboard();
+      try {
+        dashboard.pushState({
+          interviewId: 'direct-push',
+          sessionID: 'session-direct',
+          idea: 'Direct Push',
+          mode: 'awaiting-user',
+          summary: 'Direct',
+          title: 'Direct Push',
+          questions: [],
+          pendingAnswers: null,
+          lastUpdatedAt: Date.now(),
+          filePath: '',
+          nudgeAction: null,
+        });
+
+        const state = dashboard.getState('direct-push');
+        expect(state?.idea).toBe('Direct Push');
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('storeAnswers stores pending answers', async () => {
+      const { dashboard, cleanup } = await startDashboard();
+      try {
+        dashboard.pushState({
+          interviewId: 'store-answers',
+          sessionID: 'session-store',
+          idea: 'Store Answers',
+          mode: 'awaiting-user',
+          summary: 'Store',
+          title: 'Store Answers',
+          questions: [],
+          pendingAnswers: null,
+          lastUpdatedAt: Date.now(),
+          filePath: '',
+          nudgeAction: null,
+        });
+
+        dashboard.storeAnswers('store-answers', [
+          { questionId: 'q-1', answer: 'Direct' },
+        ]);
+
+        const state = dashboard.getState('store-answers');
+        expect(state?.pendingAnswers).toEqual([
+          { questionId: 'q-1', answer: 'Direct' },
+        ]);
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('consumePendingAnswers clears pending answers', async () => {
+      const { dashboard, cleanup } = await startDashboard();
+      try {
+        dashboard.pushState({
+          interviewId: 'consume-direct',
+          sessionID: 'session-consume-direct',
+          idea: 'Consume Direct',
+          mode: 'awaiting-agent',
+          summary: 'Consume',
+          title: 'Consume Direct',
+          questions: [],
+          pendingAnswers: [{ questionId: 'q-1', answer: 'Test' }],
+          lastUpdatedAt: Date.now(),
+          filePath: '',
+          nudgeAction: null,
+        });
+
+        const answers = dashboard.consumePendingAnswers('consume-direct');
+        expect(answers).toEqual([{ questionId: 'q-1', answer: 'Test' }]);
+
+        const state = dashboard.getState('consume-direct');
+        expect(state?.pendingAnswers).toBeNull();
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('consumeNudgeAction clears nudge action', async () => {
+      const { dashboard, cleanup } = await startDashboard();
+      try {
+        dashboard.pushState({
+          interviewId: 'nudge-direct',
+          sessionID: 'session-nudge-direct',
+          idea: 'Nudge Direct',
+          mode: 'awaiting-agent',
+          summary: 'Nudge',
+          title: 'Nudge Direct',
+          questions: [],
+          pendingAnswers: null,
+          lastUpdatedAt: Date.now(),
+          filePath: '',
+          nudgeAction: 'more-questions',
+        });
+
+        const action = dashboard.consumeNudgeAction('nudge-direct');
+        expect(action).toBe('more-questions');
+
+        const state = dashboard.getState('nudge-direct');
+        expect(state?.nudgeAction).toBeNull();
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('addManualFolder and removeManualFolder', async () => {
+      const { dashboard, cleanup } = await startDashboard();
+      try {
+        dashboard.addManualFolder('/manual/folder1');
+        expect(dashboard.getManualFolders()).toContain('/manual/folder1');
+
+        dashboard.addManualFolder('/manual/folder2');
+        expect(dashboard.getManualFolders().length).toBe(2);
+
+        dashboard.removeManualFolder('/manual/folder1');
+        expect(dashboard.getManualFolders()).not.toContain('/manual/folder1');
+        expect(dashboard.getManualFolders()).toContain('/manual/folder2');
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('setScanDays and getScanDays', async () => {
+      const { dashboard, cleanup } = await startDashboard();
+      try {
+        dashboard.setScanDays(45);
+        expect(dashboard.getScanDays()).toBe(45);
+
+        dashboard.setScanDays(0);
+        expect(dashboard.getScanDays()).toBe(0);
+      } finally {
+        cleanup();
+      }
+    });
+  });
+
+  describe('file scanning (GET /api/files)', () => {
+    test('lists interview files from registered sessions', async () => {
+      const tempDir = await createTempInterviewDir();
+      const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
+      try {
+        // Create a test markdown file
+        const mdPath = path.join(tempDir, 'interview', 'test-file.md');
+        await fs.writeFile(
+          mdPath,
+          '# Test File\n\n## Current spec\n\nSpec content.\n\n## Q&A history\n\n',
+          'utf8',
+        );
+
+        // Register session with the temp directory
+        dashboard.registerSession({
+          sessionID: 'session-files',
+          directory: tempDir,
+          pid: 0,
+          registeredAt: Date.now(),
+        });
+
+        const response = await fetch(`${baseUrl}/api/files?token=${authToken}`);
+        expect(response.status).toBe(200);
+        const data = (await response.json()) as {
+          files: Array<{ fileName: string; title: string }>;
+        };
+        // os.homedir() is always scanned, so other files may appear.
+        // Assert our file is present rather than asserting exact count.
+        const ourFile = data.files.find((f) => f.fileName === 'test-file.md');
+        expect(ourFile).toBeDefined();
+        expect(ourFile?.title).toBe('Test File');
+
+        await fs.rm(tempDir, { recursive: true, force: true });
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('requires auth', async () => {
+      const { baseUrl, cleanup } = await startDashboard();
+      try {
+        const response = await fetch(`${baseUrl}/api/files`);
+        expect(response.status).toBe(401);
+      } finally {
+        cleanup();
+      }
+    });
+  });
+
+  describe('error handling', () => {
+    test('returns 404 for unknown routes', async () => {
+      const { baseUrl, cleanup } = await startDashboard();
+      try {
+        const response = await fetch(`${baseUrl}/api/unknown`);
+        expect(response.status).toBe(404);
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('handles invalid JSON body', async () => {
+      const { baseUrl, authToken, cleanup } = await startDashboard();
+      try {
+        const response = await fetch(
+          `${baseUrl}/api/register?token=${authToken}`,
+          {
+            method: 'POST',
+            body: 'invalid json',
+            headers: { 'content-type': 'application/json' },
+          },
+        );
+        expect(response.status).toBe(400);
+      } finally {
+        cleanup();
+      }
+    });
+  });
+});

+ 1193 - 0
src/interview/dashboard.ts

@@ -0,0 +1,1193 @@
+import crypto from 'node:crypto';
+import * as fsSync from 'node:fs';
+import fs from 'node:fs/promises';
+import {
+  createServer,
+  type IncomingMessage,
+  type Server,
+  type ServerResponse,
+} from 'node:http';
+import os from 'node:os';
+import path from 'node:path';
+import { URL } from 'node:url';
+import { log } from '../utils';
+import {
+  extractSummarySection,
+  extractTitle,
+  parseFrontmatter,
+  slugify,
+} from './document';
+import {
+  extractResumeSlug,
+  isValidId,
+  readJsonBody,
+  sendHtml,
+  sendJson,
+} from './helpers';
+import type { InterviewFileItem, InterviewStateEntry } from './types';
+import { renderDashboardPage, renderInterviewPage } from './ui';
+
+// ─── Auth Token File ────────────────────────────────────────────────
+// Dashboard writes its auth token to a file so sessions can discover it.
+// Both processes run as the same user on the same machine (localhost-only).
+
+function getAuthFilePath(port: number): string {
+  const dataHome =
+    process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
+  return path.join(dataHome, 'opencode', `.dashboard-${port}.json`);
+}
+
+function writeAuthFile(port: number, token: string): void {
+  const filePath = getAuthFilePath(port);
+  const dir = path.dirname(filePath);
+  try {
+    fsSync.mkdirSync(dir, { recursive: true });
+  } catch {
+    // Directory exists
+  }
+  fsSync.writeFileSync(
+    filePath,
+    JSON.stringify({
+      token,
+      pid: process.pid,
+      startedAt: Date.now(),
+    }),
+    { mode: 0o600 },
+  );
+}
+
+function removeAuthFile(port: number): void {
+  try {
+    fsSync.unlinkSync(getAuthFilePath(port));
+  } catch {
+    // File doesn't exist, ignore
+  }
+}
+
+export async function readDashboardAuthFile(
+  port: number,
+): Promise<{ token: string; pid: number; startedAt: number } | null> {
+  try {
+    const content = await fs.readFile(getAuthFilePath(port), 'utf8');
+    const data = JSON.parse(content) as {
+      token: string;
+      pid: number;
+      startedAt: number;
+    };
+    // Check if the PID is still alive — stale file from crashed dashboard
+    try {
+      process.kill(data.pid, 0); // signal 0 = existence check, no actual signal
+    } catch {
+      // PID doesn't exist — stale auth file from crashed dashboard
+      try {
+        fsSync.unlinkSync(getAuthFilePath(port));
+      } catch {
+        // Ignore cleanup errors
+      }
+      return null;
+    }
+    return data;
+  } catch {
+    return null;
+  }
+}
+
+// ─── Helpers ──────────────────────────────────────────────────────────
+
+function jitterMs(): number {
+  return 50 + Math.floor(Math.random() * 150);
+}
+
+/**
+ * When a live interview is created or pushes state, remove any stale
+ * recovered- entry for the same slug.  Called from three code paths:
+ * HTTP create, HTTP state-push, and in-process pushState.
+ */
+function dedupRecovered(
+  interviewId: string,
+  cache: Map<string, InterviewStateEntry>,
+): void {
+  if (interviewId.startsWith('recovered-')) return;
+  const slug = extractResumeSlug(interviewId);
+  if (!slug) return;
+  const recoveredKey = `recovered-${slug}`;
+  if (cache.has(recoveredKey)) {
+    cache.delete(recoveredKey);
+  }
+}
+
+/**
+ * Check whether the cache already contains a live (non-recovered) entry
+ * whose slug matches the given one.  Used by rebuildFromFiles to skip
+ * adding a recovered entry when a live session already covers it.
+ */
+function hasLiveForSlug(
+  slug: string,
+  cache: Map<string, InterviewStateEntry>,
+): boolean {
+  return [...cache.values()].some(
+    (e) =>
+      !e.interviewId.startsWith('recovered-') &&
+      extractResumeSlug(e.interviewId) === slug,
+  );
+}
+
+// ─── Types ────────────────────────────────────────────────────────────
+
+interface RegisteredSession {
+  sessionID: string;
+  directory: string;
+  pid: number;
+  registeredAt: number;
+}
+
+// ─── Config ───────────────────────────────────────────────────────────
+
+export const DEFAULT_DASHBOARD_PORT = 43211;
+
+export interface DashboardConfig {
+  port: number;
+  outputFolder: string;
+  sessionClient?: {
+    list: (params?: Record<string, unknown>) => Promise<{
+      data?: Array<{
+        directory?: string;
+        time?: { updated?: number };
+      }>;
+    }>;
+  };
+}
+
+// ─── Dashboard Server ─────────────────────────────────────────────────
+
+export function createDashboardServer(config: DashboardConfig): {
+  start: () => Promise<string>;
+  close: () => void;
+  registerSession: (info: RegisteredSession) => void;
+  removeSession: (sessionID: string) => void;
+  pushState: (entry: InterviewStateEntry) => void;
+  getState: (interviewId: string) => InterviewStateEntry | undefined;
+  storeAnswers: (
+    interviewId: string,
+    answers: Array<{ questionId: string; answer: string }>,
+  ) => void;
+  getPendingAnswers: (interviewId: string) => Array<{
+    questionId: string;
+    answer: string;
+  }> | null;
+  consumePendingAnswers: (
+    interviewId: string,
+  ) => Array<{ questionId: string; answer: string }> | null;
+  consumeNudgeAction: (
+    interviewId: string,
+  ) => 'more-questions' | 'confirm-complete' | null;
+  authToken: string;
+  discoverSessionDirectories: () => Promise<void>;
+  addManualFolder: (dir: string) => void;
+  removeManualFolder: (dir: string) => void;
+  getManualFolders: () => string[];
+  setScanDays: (days: number) => void;
+  getScanDays: () => number;
+  refreshFiles: () => Promise<void>;
+} {
+  const authToken = crypto.randomBytes(32).toString('hex');
+  let activeServer: Server | null = null;
+  let baseUrl: string | null = null;
+
+  // Session registry
+  const sessions = new Map<string, RegisteredSession>();
+
+  // Interview state cache
+  const stateCache = new Map<string, InterviewStateEntry>();
+
+  // Periodic cleanup: remove terminal entries older than 24h
+  const TERMINAL_MODES = new Set([
+    'abandoned',
+    'completed',
+    'session-disconnected',
+  ]);
+  const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
+  const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
+  const cleanupTimer = setInterval(() => {
+    const cutoff = Date.now() - CACHE_TTL_MS;
+    for (const [id, entry] of stateCache) {
+      if (TERMINAL_MODES.has(entry.mode) && entry.lastUpdatedAt < cutoff) {
+        stateCache.delete(id);
+      }
+    }
+  }, CLEANUP_INTERVAL_MS);
+  cleanupTimer.unref();
+
+  // File scan cache (TTL 10s)
+  let fileCache: { items: InterviewFileItem[]; at: number } | null = null;
+  const FILE_CACHE_TTL = 10_000;
+
+  // ─── Auth ─────────────────────────────────────────────────────────
+
+  function isAuthenticated(request: IncomingMessage): boolean {
+    // 1. Check HttpOnly cookie (browser requests)
+    const cookieHeader = request.headers.cookie ?? '';
+    const cookieMatch = cookieHeader.match(/(?:^|;\s*)dashboard_token=([^;]+)/);
+    if (cookieMatch?.[1] === authToken) return true;
+    // 2. Check query param (inter-process: session → dashboard)
+    const url = new URL(request.url ?? '/', `http://${request.headers.host}`);
+    const tokenParam = url.searchParams.get('token');
+    if (tokenParam === authToken) return true;
+    // 3. Check Authorization header (Bearer token)
+    const authHeader = request.headers.authorization;
+    const bearerToken = authHeader?.startsWith('Bearer ')
+      ? authHeader.slice(7)
+      : null;
+    if (bearerToken === authToken) return true;
+    return false;
+  }
+
+  function setSessionCookie(response: ServerResponse): void {
+    response.setHeader(
+      'Set-Cookie',
+      `dashboard_token=${authToken}; HttpOnly; SameSite=Strict; Path=/`,
+    );
+  }
+
+  // ─── Session Discovery ───────────────────────────────────────────
+
+  const manualFolders = new Set<string>();
+  const discoveredFolders = new Set<string>();
+  let scanDays = 30;
+
+  function getKnownDirectories(): Set<string> {
+    const dirs = new Set<string>();
+    // Always scan home directory — interviews may have been created from a
+    // session that ran with cwd=$HOME and never registered with the dashboard.
+    dirs.add(os.homedir());
+    for (const session of sessions.values()) {
+      if (session.directory) dirs.add(session.directory);
+    }
+    for (const folder of manualFolders) {
+      dirs.add(folder);
+    }
+    for (const folder of discoveredFolders) {
+      dirs.add(folder);
+    }
+    return dirs;
+  }
+
+  async function discoverSessionDirectories(): Promise<void> {
+    if (!config.sessionClient) return;
+    try {
+      const result = await config.sessionClient.list({ limit: 500 });
+      const sessionList = result.data;
+      if (!sessionList) return;
+
+      const cutoff =
+        scanDays > 0 ? Date.now() - scanDays * 24 * 60 * 60 * 1000 : 0;
+
+      for (const session of sessionList) {
+        if (!session.directory) continue;
+        if (cutoff > 0 && session.time?.updated) {
+          if (session.time.updated < cutoff) continue;
+        }
+        // Add to discovered set (not manualFolders) so user removal
+        // of a manual folder isn't undone by the next scan
+        discoveredFolders.add(session.directory);
+      }
+    } catch {
+      // Session list not available — rely on registered sessions
+    }
+  }
+
+  // ─── File Scanning ───────────────────────────────────────────────
+
+  async function scanInterviewFiles(): Promise<InterviewFileItem[]> {
+    if (fileCache && Date.now() - fileCache.at < FILE_CACHE_TTL) {
+      return fileCache.items;
+    }
+
+    const directories = getKnownDirectories();
+    const items: InterviewFileItem[] = [];
+
+    for (const dir of directories) {
+      const interviewDir = path.join(dir, config.outputFolder);
+      let entries: string[];
+      try {
+        entries = await fs.readdir(interviewDir);
+      } catch {
+        continue;
+      }
+
+      for (const entry of entries) {
+        if (!entry.endsWith('.md')) continue;
+
+        let content: string;
+        try {
+          content = await fs.readFile(path.join(interviewDir, entry), 'utf8');
+        } catch {
+          continue;
+        }
+
+        // Extract title and summary using shared extractors
+        const title = extractTitle(content) || entry.replace(/\.md$/, '');
+        const summary = extractSummarySection(content);
+        const baseName = entry.replace(/\.md$/, '');
+        const fm = parseFrontmatter(content);
+
+        items.push({
+          fileName: entry,
+          resumeCommand: `/interview ${baseName}`,
+          title,
+          summary:
+            summary.length > 120 ? `${summary.slice(0, 120)}\u2026` : summary,
+          sessionID: fm?.sessionID,
+          directory: dir,
+        });
+      }
+    }
+
+    const sorted = items.sort((a, b) => a.title.localeCompare(b.title));
+    fileCache = { items: sorted, at: Date.now() };
+    return sorted;
+  }
+
+  // ─── Failover: rebuild state from .md frontmatter ──────────────
+
+  async function rebuildFromFiles(): Promise<void> {
+    const directories = getKnownDirectories();
+    let rebuilt = 0;
+
+    for (const dir of directories) {
+      const interviewDir = path.join(dir, config.outputFolder);
+      let entries: string[];
+      try {
+        entries = await fs.readdir(interviewDir);
+      } catch {
+        continue;
+      }
+
+      for (const entry of entries) {
+        if (!entry.endsWith('.md')) continue;
+
+        let content: string;
+        try {
+          content = await fs.readFile(path.join(interviewDir, entry), 'utf8');
+        } catch {
+          continue;
+        }
+
+        // Parse frontmatter for session ID
+        const fm = parseFrontmatter(content);
+        if (!fm?.sessionID) continue;
+
+        // Extract title and summary using shared extractors
+        const title = extractTitle(content) || entry.replace(/\.md$/, '');
+        const summary = extractSummarySection(content);
+
+        // Generate a stable interview ID from slugified filename
+        const baseName = entry.replace(/\.md$/, '');
+        const interviewId = `recovered-${slugify(baseName) || baseName}`;
+
+        // Only add if not already in cache (sessions may have re-pushed)
+        if (stateCache.has(interviewId)) continue;
+
+        // Also skip if a live interview already covers this slug.
+        const slug = slugify(baseName) || baseName;
+        if (hasLiveForSlug(slug, stateCache)) continue;
+
+        stateCache.set(interviewId, {
+          interviewId,
+          sessionID: fm.sessionID,
+          idea: title,
+          mode: 'session-disconnected',
+          summary,
+          title,
+          questions: [],
+          pendingAnswers: null,
+          lastUpdatedAt: fm.updatedAt
+            ? new Date(fm.updatedAt).getTime()
+            : Date.now(),
+          filePath: path.join(interviewDir, entry),
+          nudgeAction: null,
+        });
+
+        // Also register the session directory
+        if (!sessions.has(fm.sessionID)) {
+          sessions.set(fm.sessionID, {
+            sessionID: fm.sessionID,
+            directory: dir,
+            pid: 0,
+            registeredAt: Date.now(),
+          });
+        }
+        rebuilt++;
+      }
+    }
+
+    if (rebuilt > 0) {
+      fileCache = null;
+      log(
+        `[interview] dashboard: rebuilt ${rebuilt} interview(s) from files`,
+        {},
+      );
+    }
+  }
+
+  // ─── Request Handler ─────────────────────────────────────────────
+
+  async function handleRequest(
+    request: IncomingMessage,
+    response: ServerResponse,
+  ): Promise<void> {
+    const url = new URL(
+      request.url ?? '/',
+      `http://${request.headers.host ?? '127.0.0.1'}`,
+    );
+    const pathname = decodeURIComponent(url.pathname);
+
+    // NOTE: No CORS headers. Same-origin only — dashboard pages and
+    // API share the same origin (127.0.0.1:port). Cross-origin POST is
+    // blocked by browser preflight since we don't send Access-Control
+    // headers. Do NOT add them without also adding CSRF protection.
+
+    // ── Health check (no auth required) ────────────────────────────
+    if (request.method === 'GET' && pathname === '/api/health') {
+      // Stable signature: changes only when stateCache or session count changes
+      const sig = [...stateCache.values()]
+        .map((e) => `${e.interviewId}:${e.mode}:${e.lastUpdatedAt}`)
+        .sort()
+        .join('|');
+      sendJson(response, 200, {
+        status: 'ok',
+        sessions: sessions.size,
+        interviews: stateCache.size,
+        sig,
+      });
+      return;
+    }
+
+    // ── API: settings (scan days, folders, discovery) ──────────────
+    if (request.method === 'GET' && pathname === '/api/settings') {
+      if (!isAuthenticated(request)) {
+        sendJson(response, 401, { error: 'Unauthorized' });
+        return;
+      }
+      sendJson(response, 200, {
+        scanDays,
+        folders: [...manualFolders],
+        discoveredFolders: [...discoveredFolders],
+        registeredSessions: sessions.size,
+      });
+      return;
+    }
+
+    if (request.method === 'POST' && pathname === '/api/settings') {
+      if (!isAuthenticated(request)) {
+        sendJson(response, 401, { error: 'Unauthorized' });
+        return;
+      }
+      let body: unknown;
+      try {
+        body = await readJsonBody(request);
+      } catch {
+        sendJson(response, 400, { error: 'Invalid JSON' });
+        return;
+      }
+      const data = body as {
+        scanDays?: number;
+        addFolder?: string;
+        removeFolder?: string;
+        discover?: boolean;
+      };
+      if (typeof data.scanDays === 'number' && data.scanDays >= 0) {
+        scanDays = data.scanDays;
+      }
+      if (data.addFolder) {
+        manualFolders.add(data.addFolder);
+        fileCache = null;
+      }
+      if (data.removeFolder) {
+        manualFolders.delete(data.removeFolder);
+        fileCache = null;
+      }
+      if (data.discover) {
+        await discoverSessionDirectories();
+        fileCache = null;
+        await rebuildFromFiles();
+      }
+      sendJson(response, 200, {
+        scanDays,
+        folders: [...manualFolders],
+      });
+      return;
+    }
+
+    // ── Dashboard UI ───────────────────────────────────────────────
+    if (request.method === 'GET' && pathname === '/') {
+      const files = await scanInterviewFiles();
+      // Render actual interviews from state cache (not raw sessions)
+      const activeInterviews: Array<{
+        id: string;
+        idea: string;
+        status: 'active' | 'abandoned';
+        mode: string;
+        createdAt: string;
+        url: string;
+        resumeSlug: string;
+        sessionID?: string;
+        directory?: string;
+      }> = [...stateCache.values()]
+        .sort((a, b) => b.lastUpdatedAt - a.lastUpdatedAt)
+        .map((entry) => {
+          const resumeSlug = extractResumeSlug(entry.interviewId);
+          const session = entry.sessionID
+            ? sessions.get(entry.sessionID)
+            : undefined;
+          return {
+            id: entry.interviewId,
+            idea: entry.idea,
+            status:
+              entry.mode === 'session-disconnected'
+                ? ('abandoned' as const)
+                : ('active' as const),
+            mode: entry.mode,
+            createdAt: new Date(entry.lastUpdatedAt).toISOString(),
+            url: `/interview/${entry.interviewId}`,
+            resumeSlug,
+            sessionID: entry.sessionID,
+            directory: session?.directory,
+          };
+        });
+      const outputFolder = config.outputFolder;
+      setSessionCookie(response);
+      sendHtml(
+        response,
+        renderDashboardPage(activeInterviews, files, outputFolder),
+      );
+      return;
+    }
+
+    // ── API: list sessions (auth required) ──────────────────────────
+    if (request.method === 'GET' && pathname === '/api/sessions') {
+      if (!isAuthenticated(request)) {
+        sendJson(response, 401, { error: 'Unauthorized' });
+        return;
+      }
+      const daysParam = url.searchParams.get('days');
+      const days = daysParam ? Number.parseInt(daysParam, 10) : 3;
+      const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
+      const result = [...sessions.values()]
+        .filter((s) => s.registeredAt > cutoff)
+        .map((s) => ({
+          sessionID: s.sessionID,
+          directory: s.directory,
+          pid: s.pid,
+        }));
+      sendJson(response, 200, { sessions: result });
+      return;
+    }
+
+    // ── API: list files (auth required) ─────────────────────────────
+    if (request.method === 'GET' && pathname === '/api/files') {
+      if (!isAuthenticated(request)) {
+        sendJson(response, 401, { error: 'Unauthorized' });
+        return;
+      }
+      const files = await scanInterviewFiles();
+      sendJson(response, 200, { files });
+      return;
+    }
+
+    // ── Auth gate for mutation endpoints ───────────────────────────
+    if (request.method === 'POST' && !isAuthenticated(request)) {
+      sendJson(response, 401, { error: 'Unauthorized' });
+      return;
+    }
+
+    // ── API: register session ──────────────────────────────────────
+    if (request.method === 'POST' && pathname === '/api/register') {
+      let body: unknown;
+      try {
+        body = await readJsonBody(request);
+      } catch {
+        sendJson(response, 400, { error: 'Invalid JSON' });
+        return;
+      }
+
+      const { sessionID, directory, pid } = body as {
+        sessionID?: string;
+        directory?: string;
+        pid?: number;
+      };
+      if (!sessionID || !directory || !isValidId(sessionID)) {
+        sendJson(response, 400, {
+          error: 'sessionID and directory required',
+        });
+        return;
+      }
+
+      sessions.set(sessionID, {
+        sessionID,
+        directory,
+        pid: pid ?? 0,
+        registeredAt: Date.now(),
+      });
+      fileCache = null; // invalidate
+      sendJson(response, 200, { status: 'registered' });
+      return;
+    }
+
+    // ── API: create interview ──────────────────────────────────────
+    if (request.method === 'POST' && pathname === '/api/interviews') {
+      let body: unknown;
+      try {
+        body = await readJsonBody(request);
+      } catch {
+        sendJson(response, 400, { error: 'Invalid JSON' });
+        return;
+      }
+
+      const { interviewId, sessionID, idea } = body as {
+        interviewId?: string;
+        sessionID?: string;
+        idea?: string;
+      };
+      if (!interviewId || !sessionID || !idea || !isValidId(interviewId)) {
+        sendJson(response, 400, {
+          error: 'interviewId, sessionID, and idea required',
+        });
+        return;
+      }
+
+      stateCache.set(interviewId, {
+        interviewId,
+        sessionID,
+        idea,
+        mode: 'awaiting-agent',
+        summary: 'Interview created.',
+        title: idea,
+        questions: [],
+        pendingAnswers: null,
+        lastUpdatedAt: Date.now(),
+        filePath: '',
+        nudgeAction: null,
+      });
+      dedupRecovered(interviewId, stateCache);
+      fileCache = null;
+
+      const interviewUrl = `${baseUrl}/interview/${interviewId}`;
+      sendJson(response, 200, {
+        interviewId,
+        url: interviewUrl,
+      });
+      return;
+    }
+
+    // ── API: push state (session → dashboard) ──────────────────────
+    if (
+      request.method === 'POST' &&
+      pathname.startsWith('/api/interviews/') &&
+      pathname.endsWith('/state')
+    ) {
+      const interviewId = pathname
+        .replace('/api/interviews/', '')
+        .replace('/state', '');
+      if (!interviewId || !isValidId(interviewId)) {
+        sendJson(response, 400, { error: 'Invalid interview ID' });
+        return;
+      }
+
+      let body: unknown;
+      try {
+        body = await readJsonBody(request);
+      } catch {
+        sendJson(response, 400, { error: 'Invalid JSON' });
+        return;
+      }
+
+      const state = body as Partial<InterviewStateEntry>;
+      const existing = stateCache.get(interviewId);
+      if (existing) {
+        // Merge state update
+        if (state.mode) existing.mode = state.mode;
+        if (state.summary) existing.summary = state.summary;
+        if (state.title) existing.title = state.title;
+        if (state.questions) existing.questions = state.questions;
+        if (state.filePath) existing.filePath = state.filePath;
+        existing.lastUpdatedAt = Date.now();
+        dedupRecovered(interviewId, stateCache);
+      } else {
+        // New entry
+        stateCache.set(interviewId, {
+          interviewId,
+          sessionID: state.sessionID ?? '',
+          idea: state.idea ?? '',
+          mode: state.mode ?? 'awaiting-agent',
+          summary: state.summary ?? '',
+          title: state.title ?? '',
+          questions: state.questions ?? [],
+          pendingAnswers: null,
+          lastUpdatedAt: Date.now(),
+          filePath: state.filePath ?? '',
+          nudgeAction: null,
+        });
+      }
+
+      sendJson(response, 200, { status: 'ok' });
+      return;
+    }
+
+    // ── API: get state (dashboard → browser poll, auth required) ───
+    if (
+      request.method === 'GET' &&
+      pathname.startsWith('/api/interviews/') &&
+      pathname.endsWith('/state')
+    ) {
+      if (!isAuthenticated(request)) {
+        sendJson(response, 401, { error: 'Unauthorized' });
+        return;
+      }
+      const interviewId = pathname
+        .replace('/api/interviews/', '')
+        .replace('/state', '');
+      if (!isValidId(interviewId)) {
+        sendJson(response, 400, { error: 'Invalid interview ID' });
+        return;
+      }
+      const entry = stateCache.get(interviewId);
+      if (!entry) {
+        sendJson(response, 404, { error: 'Interview not found' });
+        return;
+      }
+
+      // Read .md document from disk for completed/disconnected interviews
+      let document = '';
+      let markdownPath = entry.filePath;
+      if (entry.filePath) {
+        try {
+          document = await fs.readFile(entry.filePath, 'utf8');
+        } catch {
+          // File may not exist yet
+        }
+      } else {
+        // Fallback: try to find file in known session directories
+        const dirs = getKnownDirectories();
+        for (const dir of dirs) {
+          const slug = extractResumeSlug(interviewId);
+          const candidate = path.join(dir, config.outputFolder, `${slug}.md`);
+          try {
+            document = await fs.readFile(candidate, 'utf8');
+            markdownPath = candidate;
+            entry.filePath = candidate;
+            break;
+          } catch {
+            // Not in this directory
+          }
+        }
+      }
+
+      // Use just the filename to avoid leaking absolute paths
+      const displayPath = markdownPath
+        ? markdownPath.split('/').pop() || markdownPath
+        : 'interview.md';
+
+      sendJson(response, 200, {
+        interview: {
+          id: entry.interviewId,
+          sessionID: entry.sessionID,
+          idea: entry.idea,
+          markdownPath: displayPath,
+          createdAt: new Date(entry.lastUpdatedAt).toISOString(),
+          status:
+            entry.mode === 'session-disconnected'
+              ? ('abandoned' as const)
+              : ('active' as const),
+          baseMessageCount: 0, // Unknown for recovered entries
+        },
+        url: `${baseUrl}/interview/${entry.interviewId}`,
+        markdownPath,
+        mode: entry.mode,
+        isBusy: entry.mode === 'awaiting-agent',
+        summary: entry.summary,
+        questions: entry.questions,
+        document,
+        lastUpdatedAt: entry.lastUpdatedAt,
+        nudgeAction: entry.nudgeAction,
+      });
+      return;
+    }
+
+    // ── API: submit answers (browser → dashboard) ──────────────────
+    if (
+      request.method === 'POST' &&
+      pathname.startsWith('/api/interviews/') &&
+      pathname.endsWith('/answers')
+    ) {
+      const interviewId = pathname
+        .replace('/api/interviews/', '')
+        .replace('/answers', '');
+      if (!isValidId(interviewId)) {
+        sendJson(response, 400, { error: 'Invalid interview ID' });
+        return;
+      }
+      const entry = stateCache.get(interviewId);
+      if (!entry) {
+        sendJson(response, 404, { error: 'Interview not found' });
+        return;
+      }
+
+      let body: unknown;
+      try {
+        body = await readJsonBody(request);
+      } catch {
+        sendJson(response, 400, { error: 'Invalid JSON' });
+        return;
+      }
+
+      const { answers } = body as {
+        answers?: Array<{ questionId: string; answer: string }>;
+      };
+      if (
+        !Array.isArray(answers) ||
+        !answers.every(
+          (a) =>
+            typeof a === 'object' &&
+            a !== null &&
+            typeof a.questionId === 'string' &&
+            typeof a.answer === 'string',
+        )
+      ) {
+        sendJson(response, 400, {
+          error:
+            'answers array required, each item must have string questionId and answer',
+        });
+        return;
+      }
+
+      entry.pendingAnswers = answers;
+      entry.mode = 'awaiting-agent';
+      entry.lastUpdatedAt = Date.now();
+      sendJson(response, 200, { status: 'ok' });
+      return;
+    }
+
+    // ── API: get pending answers (session polls, auth required) ────
+    if (
+      request.method === 'GET' &&
+      pathname.startsWith('/api/interviews/') &&
+      pathname.endsWith('/pending')
+    ) {
+      if (!isAuthenticated(request)) {
+        sendJson(response, 401, { error: 'Unauthorized' });
+        return;
+      }
+      const interviewId = pathname
+        .replace('/api/interviews/', '')
+        .replace('/pending', '');
+      if (!isValidId(interviewId)) {
+        sendJson(response, 400, { error: 'Invalid interview ID' });
+        return;
+      }
+      const entry = stateCache.get(interviewId);
+      if (!entry) {
+        sendJson(response, 404, { error: 'Interview not found' });
+        return;
+      }
+      // Atomically consume pending answers (like nudge pattern)
+      const answers = entry.pendingAnswers;
+      if (answers) {
+        entry.pendingAnswers = null;
+      }
+      sendJson(response, 200, {
+        answers,
+      });
+      return;
+    }
+
+    // ── API: nudge agent (browser → dashboard) ────────────────────
+    if (
+      request.method === 'POST' &&
+      pathname.startsWith('/api/interviews/') &&
+      pathname.endsWith('/nudge')
+    ) {
+      if (!isAuthenticated(request)) {
+        sendJson(response, 401, { error: 'Unauthorized' });
+        return;
+      }
+
+      const interviewId = pathname
+        .replace('/api/interviews/', '')
+        .replace('/nudge', '');
+      if (!isValidId(interviewId)) {
+        sendJson(response, 400, { error: 'Invalid interview ID' });
+        return;
+      }
+      const entry = stateCache.get(interviewId);
+      if (!entry) {
+        sendJson(response, 404, { error: 'Interview not found' });
+        return;
+      }
+
+      let body: unknown;
+      try {
+        body = await readJsonBody(request);
+      } catch {
+        sendJson(response, 400, { error: 'Invalid JSON' });
+        return;
+      }
+
+      const { action } = body as {
+        action?: 'more-questions' | 'confirm-complete';
+      };
+      if (action !== 'more-questions' && action !== 'confirm-complete') {
+        sendJson(response, 400, {
+          error: 'action must be "more-questions" or "confirm-complete"',
+        });
+        return;
+      }
+
+      entry.nudgeAction = action;
+      entry.mode = 'awaiting-agent';
+      entry.lastUpdatedAt = Date.now();
+      sendJson(response, 200, { status: 'ok' });
+      return;
+    }
+
+    // ── API: get nudge action (session polls, auth required) ──────
+    if (
+      request.method === 'GET' &&
+      pathname.startsWith('/api/interviews/') &&
+      pathname.endsWith('/nudge')
+    ) {
+      if (!isAuthenticated(request)) {
+        sendJson(response, 401, { error: 'Unauthorized' });
+        return;
+      }
+      const interviewId = pathname
+        .replace('/api/interviews/', '')
+        .replace('/nudge', '');
+      if (!isValidId(interviewId)) {
+        sendJson(response, 400, { error: 'Invalid interview ID' });
+        return;
+      }
+      const entry = stateCache.get(interviewId);
+      if (!entry) {
+        sendJson(response, 404, { error: 'Interview not found' });
+        return;
+      }
+      const action = entry.nudgeAction;
+      if (action) {
+        entry.nudgeAction = null; // Clear after reading
+      }
+      sendJson(response, 200, { action });
+      return;
+    }
+
+    // ── Interview page ─────────────────────────────────────────────
+    if (request.method === 'GET' && pathname.startsWith('/interview/')) {
+      const interviewId = pathname.replace('/interview/', '');
+      if (!isValidId(interviewId)) {
+        sendJson(response, 400, { error: 'Invalid interview ID' });
+        return;
+      }
+      const entry = stateCache.get(interviewId);
+      if (!entry) {
+        sendJson(response, 404, { error: 'Interview not found' });
+        return;
+      }
+      const resumeSlug = extractResumeSlug(interviewId);
+      setSessionCookie(response);
+      sendHtml(response, renderInterviewPage(interviewId, resumeSlug));
+      return;
+    }
+
+    // ── 404 ────────────────────────────────────────────────────────
+    sendJson(response, 404, { error: 'Not found' });
+  }
+
+  // ─── Server Lifecycle ────────────────────────────────────────────
+
+  function start(): Promise<string> {
+    if (baseUrl) return Promise.resolve(baseUrl);
+
+    return new Promise((resolve, reject) => {
+      const server = createServer((request, response) => {
+        handleRequest(request, response).catch((error: unknown) => {
+          sendJson(response, 500, {
+            error:
+              error instanceof Error ? error.message : 'Internal server error',
+          });
+        });
+      });
+
+      server.requestTimeout = 30_000;
+      server.headersTimeout = 10_000;
+
+      server.on('error', (error: NodeJS.ErrnoException) => {
+        server.close();
+        if (error.code === 'EADDRINUSE') {
+          reject(new Error(`Dashboard port ${config.port} is already in use.`));
+        } else {
+          reject(error);
+        }
+      });
+
+      server.listen(config.port, '127.0.0.1', () => {
+        const address = server.address();
+        if (!address || typeof address === 'string') {
+          reject(new Error('Failed to start dashboard server'));
+          return;
+        }
+        activeServer = server;
+        baseUrl = `http://127.0.0.1:${address.port}`;
+        writeAuthFile(config.port, authToken);
+        resolve(baseUrl);
+      });
+    });
+  }
+
+  function close(): void {
+    if (activeServer) {
+      removeAuthFile(config.port);
+      activeServer.closeAllConnections();
+      activeServer.close();
+      activeServer = null;
+      baseUrl = null;
+    }
+  }
+
+  // ─── Public API ──────────────────────────────────────────────────
+
+  return {
+    start,
+    close,
+    registerSession: (info) => {
+      const wasEmpty = sessions.size === 0;
+      sessions.set(info.sessionID, info);
+      fileCache = null;
+      // Rebuild from files when first session registers (failover recovery)
+      if (wasEmpty) {
+        rebuildFromFiles().catch(() => {});
+      }
+    },
+    removeSession: (sessionID: string) => {
+      sessions.delete(sessionID);
+      // Clean up stateCache entries belonging to this session
+      for (const [id, entry] of stateCache) {
+        if (entry.sessionID === sessionID) {
+          stateCache.delete(id);
+        }
+      }
+      fileCache = null;
+    },
+    pushState: (entry: InterviewStateEntry) => {
+      // Preserve browser-submitted data that the session doesn't know about
+      const existing = stateCache.get(entry.interviewId);
+      if (existing) {
+        if (existing.pendingAnswers)
+          entry.pendingAnswers ??= existing.pendingAnswers;
+        if (existing.nudgeAction) entry.nudgeAction ??= existing.nudgeAction;
+      }
+      stateCache.set(entry.interviewId, entry);
+      dedupRecovered(entry.interviewId, stateCache);
+    },
+    getState: (id) => stateCache.get(id),
+    storeAnswers: (id, answers) => {
+      const entry = stateCache.get(id);
+      if (entry) {
+        entry.pendingAnswers = answers;
+        entry.mode = 'awaiting-agent';
+        entry.lastUpdatedAt = Date.now();
+      }
+    },
+    getPendingAnswers: (id) => stateCache.get(id)?.pendingAnswers ?? null,
+    consumePendingAnswers: (id) => {
+      const entry = stateCache.get(id);
+      if (!entry?.pendingAnswers) return null;
+      const answers = entry.pendingAnswers;
+      entry.pendingAnswers = null;
+      return answers;
+    },
+    consumeNudgeAction: (id) => {
+      const entry = stateCache.get(id);
+      if (!entry?.nudgeAction) return null;
+      const action = entry.nudgeAction;
+      entry.nudgeAction = null;
+      return action;
+    },
+    authToken,
+    discoverSessionDirectories,
+    addManualFolder: (dir: string) => {
+      manualFolders.add(dir);
+      fileCache = null;
+    },
+    removeManualFolder: (dir: string) => {
+      manualFolders.delete(dir);
+      fileCache = null;
+    },
+    getManualFolders: () => [...manualFolders],
+    setScanDays: (days: number) => {
+      scanDays = days;
+    },
+    getScanDays: () => scanDays,
+    refreshFiles: () => {
+      fileCache = null;
+      return rebuildFromFiles();
+    },
+  };
+}
+
+// ─── Health Probe (for session processes) ─────────────────────────────
+
+export async function probeDashboard(
+  port: number,
+): Promise<{ alive: boolean; timestamp: number }> {
+  try {
+    const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
+      signal: AbortSignal.timeout(2000),
+    });
+    if (!response.ok) return { alive: false, timestamp: 0 };
+    const data = (await response.json()) as {
+      status: string;
+      timestamp: number;
+    };
+    return {
+      alive: data.status === 'ok',
+      timestamp: data.timestamp,
+    };
+  } catch {
+    return { alive: false, timestamp: 0 };
+  }
+}
+
+// ─── Try Become Dashboard (with jitter retry) ─────────────────────────
+
+export async function tryBecomeDashboard(
+  config: DashboardConfig,
+  maxAttempts = 3,
+): Promise<ReturnType<typeof createDashboardServer> | null> {
+  for (let attempt = 0; attempt < maxAttempts; attempt++) {
+    // First, probe if a dashboard is already running
+    const probe = await probeDashboard(config.port);
+    if (probe.alive) {
+      return null; // Dashboard already running, we're a session
+    }
+
+    // Try to bind the port
+    const dashboard = createDashboardServer(config);
+    try {
+      await dashboard.start();
+      return dashboard;
+    } catch (error) {
+      const message = error instanceof Error ? error.message : String(error);
+      if (message.includes('already in use')) {
+        // Another process won the race, wait with jitter and retry
+        if (attempt < maxAttempts - 1) {
+          await new Promise((resolve) => setTimeout(resolve, jitterMs()));
+          continue;
+        }
+        return null; // All retries exhausted — treat as session
+      }
+      throw error;
+    }
+  }
+
+  return null;
+}

+ 256 - 0
src/interview/document.ts

@@ -0,0 +1,256 @@
+import * as fsSync from 'node:fs';
+import * as fs from 'node:fs/promises';
+import * as path from 'node:path';
+import type {
+  InterviewAnswer,
+  InterviewQuestion,
+  InterviewRecord,
+} from './types';
+
+// ─── Path Utilities ──────────────────────────────────────────────────
+
+export const DEFAULT_OUTPUT_FOLDER = 'interview';
+
+export function normalizeOutputFolder(outputFolder: string): string {
+  const normalized = outputFolder.trim().replace(/^\/+|\/+$/g, '');
+  return normalized || DEFAULT_OUTPUT_FOLDER;
+}
+
+export function createInterviewDirectoryPath(
+  directory: string,
+  outputFolder: string,
+): string {
+  return path.join(directory, normalizeOutputFolder(outputFolder));
+}
+
+export function createInterviewFilePath(
+  directory: string,
+  outputFolder: string,
+  idea: string,
+): string {
+  const fileName = `${slugify(idea) || 'interview'}.md`;
+  return path.join(
+    createInterviewDirectoryPath(directory, outputFolder),
+    fileName,
+  );
+}
+
+export function relativeInterviewPath(
+  directory: string,
+  filePath: string,
+): string {
+  return path.relative(directory, filePath) || path.basename(filePath);
+}
+
+/**
+ * Resolve a user-provided value to an existing .md file path.
+ * Checks absolute paths, relative paths, and output-folder-relative paths.
+ * Returns null if no matching file is found.
+ */
+export function resolveExistingInterviewPath(
+  directory: string,
+  outputFolder: string,
+  value: string,
+): string | null {
+  const trimmed = value.trim();
+  if (!trimmed) {
+    return null;
+  }
+
+  const outputDir = createInterviewDirectoryPath(directory, outputFolder);
+  const candidates = new Set<string>();
+  const resolvedRoot = path.resolve(directory);
+
+  if (path.isAbsolute(trimmed)) {
+    candidates.add(trimmed);
+  } else {
+    candidates.add(path.resolve(directory, trimmed));
+    candidates.add(path.join(outputDir, trimmed));
+    if (!trimmed.endsWith('.md')) {
+      candidates.add(path.join(outputDir, `${trimmed}.md`));
+    }
+  }
+
+  for (const candidate of candidates) {
+    if (path.extname(candidate) !== '.md') {
+      continue;
+    }
+    const resolved = path.resolve(candidate);
+    if (
+      !resolved.startsWith(resolvedRoot + path.sep) &&
+      resolved !== resolvedRoot
+    ) {
+      continue;
+    }
+    if (fsSync.existsSync(candidate)) {
+      return candidate;
+    }
+  }
+
+  return null;
+}
+
+// ─── String Utilities ────────────────────────────────────────────────
+
+export function slugify(value: string): string {
+  return value
+    .toLowerCase()
+    .replace(/[^a-z0-9]+/g, '-')
+    .replace(/^-+|-+$/g, '')
+    .slice(0, 48);
+}
+
+// ─── Markdown Document Operations ────────────────────────────────────
+
+function extractHistorySection(document: string): string {
+  const marker = '## Q&A history\n\n';
+  const index = document.indexOf(marker);
+  return index >= 0 ? document.slice(index + marker.length).trim() : '';
+}
+
+export function extractSummarySection(document: string): string {
+  const marker = '## Current spec\n\n';
+  const historyMarker = '\n\n## Q&A history';
+  const start = document.indexOf(marker);
+  if (start < 0) {
+    return '';
+  }
+  const summaryStart = start + marker.length;
+  const summaryEnd = document.indexOf(historyMarker, summaryStart);
+  return document
+    .slice(summaryStart, summaryEnd >= 0 ? summaryEnd : undefined)
+    .trim();
+}
+
+export function extractTitle(document: string): string {
+  const match = document.match(/^#\s+(.+)$/m);
+  return match?.[1]?.trim() ?? '';
+}
+
+export function buildInterviewDocument(
+  idea: string,
+  summary: string,
+  history: string,
+  meta?: { sessionID?: string; baseMessageCount?: number },
+): string {
+  const normalizedSummary = summary.trim() || 'Waiting for interview answers.';
+  const normalizedHistory = history.trim() || 'No answers yet.';
+
+  const frontmatter = meta?.sessionID
+    ? [
+        '---',
+        `sessionID: ${meta.sessionID}`,
+        `baseMessageCount: ${meta.baseMessageCount ?? 0}`,
+        `updatedAt: ${new Date().toISOString()}`,
+        '---',
+        '',
+      ].join('\n')
+    : '';
+
+  return [
+    frontmatter,
+    `# ${idea}`,
+    '',
+    '## Current spec',
+    '',
+    normalizedSummary,
+    '',
+    '## Q&A history',
+    '',
+    normalizedHistory,
+    '',
+  ].join('\n');
+}
+
+/** Parse frontmatter from a .md file. Returns null if no frontmatter. */
+export function parseFrontmatter(
+  content: string,
+): Record<string, string> | null {
+  const match = content.match(/^---\n([\s\S]*?)\n---\n/);
+  if (!match) return null;
+  const result: Record<string, string> = {};
+  for (const line of match[1].split('\n')) {
+    const colonIdx = line.indexOf(':');
+    if (colonIdx > 0) {
+      result[line.slice(0, colonIdx).trim()] = line.slice(colonIdx + 1).trim();
+    }
+  }
+  return result;
+}
+
+export async function ensureInterviewFile(
+  record: InterviewRecord,
+): Promise<void> {
+  await fs.mkdir(path.dirname(record.markdownPath), { recursive: true });
+  try {
+    await fs.access(record.markdownPath);
+  } catch {
+    await fs.writeFile(
+      record.markdownPath,
+      buildInterviewDocument(record.idea, '', '', {
+        sessionID: record.sessionID,
+        baseMessageCount: record.baseMessageCount,
+      }),
+      'utf8',
+    );
+  }
+}
+
+export async function readInterviewDocument(
+  record: InterviewRecord,
+): Promise<string> {
+  try {
+    return await fs.readFile(record.markdownPath, 'utf8');
+  } catch {
+    // File missing or unreadable — recreate it
+  }
+  await ensureInterviewFile(record);
+  return fs.readFile(record.markdownPath, 'utf8');
+}
+
+export async function rewriteInterviewDocument(
+  record: InterviewRecord,
+  summary: string,
+): Promise<string> {
+  const existing = await readInterviewDocument(record);
+  const history = extractHistorySection(existing);
+  const next = buildInterviewDocument(record.idea, summary, history, {
+    sessionID: record.sessionID,
+    baseMessageCount: record.baseMessageCount,
+  });
+  await fs.writeFile(record.markdownPath, next, 'utf8');
+  return next;
+}
+
+export async function appendInterviewAnswers(
+  record: InterviewRecord,
+  questions: InterviewQuestion[],
+  answers: InterviewAnswer[],
+): Promise<void> {
+  const existing = await readInterviewDocument(record);
+  const summary = extractSummarySection(existing);
+  const history = extractHistorySection(existing);
+  const questionMap = new Map(
+    questions.map((question) => [question.id, question]),
+  );
+  const appended = answers
+    .map((answer) => {
+      const question = questionMap.get(answer.questionId);
+      return question
+        ? `Q: ${question.question}\nA: ${answer.answer.trim()}`
+        : null;
+    })
+    .filter((value): value is string => value !== null)
+    .join('\n\n');
+  const nextHistory = [history === 'No answers yet.' ? '' : history, appended]
+    .filter(Boolean)
+    .join('\n\n');
+  await fs.writeFile(
+    record.markdownPath,
+    buildInterviewDocument(record.idea, summary, nextHistory, {
+      sessionID: record.sessionID,
+      baseMessageCount: record.baseMessageCount,
+    }),
+    'utf8',
+  );
+}

+ 353 - 0
src/interview/helpers.test.ts

@@ -0,0 +1,353 @@
+import { describe, expect, test } from 'bun:test';
+import {
+  createServer,
+  type IncomingMessage,
+  type ServerResponse,
+} from 'node:http';
+import {
+  extractResumeSlug,
+  isValidId,
+  readJsonBody,
+  sendHtml,
+  sendJson,
+} from './helpers';
+
+describe('isValidId', () => {
+  test('accepts alphanumeric IDs', () => {
+    expect(isValidId('abc123')).toBe(true);
+  });
+
+  test('accepts hyphens and underscores', () => {
+    expect(isValidId('my-interview_123')).toBe(true);
+  });
+
+  test('rejects empty string', () => {
+    expect(isValidId('')).toBe(false);
+  });
+
+  test('rejects spaces', () => {
+    expect(isValidId('has space')).toBe(false);
+  });
+
+  test('rejects special characters', () => {
+    expect(isValidId('foo@bar!')).toBe(false);
+  });
+
+  test('rejects path traversal attempts', () => {
+    expect(isValidId('../etc/passwd')).toBe(false);
+  });
+
+  test('rejects IDs over 256 chars', () => {
+    expect(isValidId('a'.repeat(257))).toBe(false);
+  });
+
+  test('accepts IDs at exactly 256 chars', () => {
+    expect(isValidId('a'.repeat(256))).toBe(true);
+  });
+});
+
+describe('extractResumeSlug', () => {
+  test('strips "recovered-" prefix', () => {
+    expect(extractResumeSlug('recovered-my-interview')).toBe('my-interview');
+  });
+
+  test('strips prefix from recovered IDs with longer names', () => {
+    expect(extractResumeSlug('recovered-task-manager')).toBe('task-manager');
+  });
+
+  test('strips hash prefix from standard interview IDs', () => {
+    // Standard format: abc123def-session-name → splits on first 2 dash-segments
+    const result = extractResumeSlug('abc123-my-interview');
+    expect(result).toBe('interview');
+  });
+
+  test('returns full ID when no prefix to strip', () => {
+    // Short IDs that don't match the hash-prefix pattern
+    expect(extractResumeSlug('simple')).toBe('simple');
+  });
+
+  test('handles recovered prefix with complex slug', () => {
+    expect(extractResumeSlug('recovered-some-complex-name')).toBe(
+      'some-complex-name',
+    );
+  });
+});
+
+describe('sendJson', () => {
+  test('sends JSON response with correct status and content type', async () => {
+    const server = createServer(
+      (_request: IncomingMessage, response: ServerResponse) => {
+        sendJson(response, 200, { status: 'ok' });
+      },
+    );
+
+    const port = await new Promise<number>((resolve, reject) => {
+      server.listen(0, '127.0.0.1', () => {
+        const addr = server.address();
+        if (!addr || typeof addr === 'string') {
+          reject(new Error('Failed to bind'));
+          return;
+        }
+        resolve(addr.port);
+      });
+      server.on('error', reject);
+    });
+
+    try {
+      const res = await fetch(`http://127.0.0.1:${port}/`);
+      expect(res.status).toBe(200);
+      expect(res.headers.get('content-type')).toBe(
+        'application/json; charset=utf-8',
+      );
+      const data = (await res.json()) as { status: string };
+      expect(data.status).toBe('ok');
+    } finally {
+      server.close();
+    }
+  });
+
+  test('sends error status code', async () => {
+    const server = createServer(
+      (_request: IncomingMessage, response: ServerResponse) => {
+        sendJson(response, 404, { error: 'Not found' });
+      },
+    );
+
+    const port = await new Promise<number>((resolve, reject) => {
+      server.listen(0, '127.0.0.1', () => {
+        const addr = server.address();
+        if (!addr || typeof addr === 'string') {
+          reject(new Error('Failed to bind'));
+          return;
+        }
+        resolve(addr.port);
+      });
+      server.on('error', reject);
+    });
+
+    try {
+      const res = await fetch(`http://127.0.0.1:${port}/`);
+      expect(res.status).toBe(404);
+      const data = (await res.json()) as { error: string };
+      expect(data.error).toBe('Not found');
+    } finally {
+      server.close();
+    }
+  });
+
+  test('JSON body ends with newline', async () => {
+    const server = createServer(
+      (_request: IncomingMessage, response: ServerResponse) => {
+        sendJson(response, 200, { key: 'value' });
+      },
+    );
+
+    const port = await new Promise<number>((resolve, reject) => {
+      server.listen(0, '127.0.0.1', () => {
+        const addr = server.address();
+        if (!addr || typeof addr === 'string') {
+          reject(new Error('Failed to bind'));
+          return;
+        }
+        resolve(addr.port);
+      });
+      server.on('error', reject);
+    });
+
+    try {
+      const res = await fetch(`http://127.0.0.1:${port}/`);
+      const text = await res.text();
+      expect(text.endsWith('\n')).toBe(true);
+    } finally {
+      server.close();
+    }
+  });
+});
+
+describe('sendHtml', () => {
+  test('sends HTML with correct content type', async () => {
+    const html = '<html><body>Hello</body></html>';
+    const server = createServer(
+      (_request: IncomingMessage, response: ServerResponse) => {
+        sendHtml(response, html);
+      },
+    );
+
+    const port = await new Promise<number>((resolve, reject) => {
+      server.listen(0, '127.0.0.1', () => {
+        const addr = server.address();
+        if (!addr || typeof addr === 'string') {
+          reject(new Error('Failed to bind'));
+          return;
+        }
+        resolve(addr.port);
+      });
+      server.on('error', reject);
+    });
+
+    try {
+      const res = await fetch(`http://127.0.0.1:${port}/`);
+      expect(res.status).toBe(200);
+      expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8');
+      expect(await res.text()).toBe(html);
+    } finally {
+      server.close();
+    }
+  });
+});
+
+describe('readJsonBody', () => {
+  test('parses valid JSON body', async () => {
+    const server = createServer(
+      async (request: IncomingMessage, response: ServerResponse) => {
+        const body = await readJsonBody(request);
+        sendJson(response, 200, body);
+      },
+    );
+
+    const port = await new Promise<number>((resolve, reject) => {
+      server.listen(0, '127.0.0.1', () => {
+        const addr = server.address();
+        if (!addr || typeof addr === 'string') {
+          reject(new Error('Failed to bind'));
+          return;
+        }
+        resolve(addr.port);
+      });
+      server.on('error', reject);
+    });
+
+    try {
+      const payload = { name: 'test', count: 42 };
+      const res = await fetch(`http://127.0.0.1:${port}/`, {
+        method: 'POST',
+        headers: { 'content-type': 'application/json' },
+        body: JSON.stringify(payload),
+      });
+      expect(res.status).toBe(200);
+      const data = (await res.json()) as typeof payload;
+      expect(data).toEqual(payload);
+    } finally {
+      server.close();
+    }
+  });
+
+  test('returns empty object for empty body', async () => {
+    const server = createServer(
+      async (request: IncomingMessage, response: ServerResponse) => {
+        const body = await readJsonBody(request);
+        sendJson(response, 200, { received: body });
+      },
+    );
+
+    const port = await new Promise<number>((resolve, reject) => {
+      server.listen(0, '127.0.0.1', () => {
+        const addr = server.address();
+        if (!addr || typeof addr === 'string') {
+          reject(new Error('Failed to bind'));
+          return;
+        }
+        resolve(addr.port);
+      });
+      server.on('error', reject);
+    });
+
+    try {
+      const res = await fetch(`http://127.0.0.1:${port}/`, {
+        method: 'POST',
+      });
+      const data = (await res.json()) as { received: unknown };
+      expect(data.received).toEqual({});
+    } finally {
+      server.close();
+    }
+  });
+
+  test('rejects body exceeding 64KB size limit', async () => {
+    // readJsonBody destroys the request on overflow, so we test the
+    // error path directly rather than via HTTP (the socket dies before
+    // a response can be sent).
+    let caughtError: Error | null = null;
+
+    const server = createServer(
+      async (request: IncomingMessage, response: ServerResponse) => {
+        try {
+          await readJsonBody(request);
+          sendJson(response, 200, { ok: true });
+        } catch (error) {
+          caughtError =
+            error instanceof Error ? error : new Error(String(error));
+          // Socket already destroyed — can't send response
+        }
+      },
+    );
+
+    const port = await new Promise<number>((resolve, reject) => {
+      server.listen(0, '127.0.0.1', () => {
+        const addr = server.address();
+        if (!addr || typeof addr === 'string') {
+          reject(new Error('Failed to bind'));
+          return;
+        }
+        resolve(addr.port);
+      });
+      server.on('error', reject);
+    });
+
+    try {
+      // Send a body larger than 64KB — connection will reset
+      const bigPayload = { data: 'x'.repeat(65 * 1024) };
+      try {
+        await fetch(`http://127.0.0.1:${port}/`, {
+          method: 'POST',
+          headers: { 'content-type': 'application/json' },
+          body: JSON.stringify(bigPayload),
+        });
+      } catch {
+        // Expected: connection reset when request is destroyed
+      }
+
+      // Give the server a tick to finish processing
+      await new Promise((resolve) => setTimeout(resolve, 50));
+      expect(caughtError).not.toBeNull();
+      expect(caughtError?.message).toContain('too large');
+    } finally {
+      server.close();
+    }
+  });
+
+  test('parses array JSON body', async () => {
+    const server = createServer(
+      async (request: IncomingMessage, response: ServerResponse) => {
+        const body = await readJsonBody(request);
+        sendJson(response, 200, {
+          type: Array.isArray(body) ? 'array' : 'other',
+        });
+      },
+    );
+
+    const port = await new Promise<number>((resolve, reject) => {
+      server.listen(0, '127.0.0.1', () => {
+        const addr = server.address();
+        if (!addr || typeof addr === 'string') {
+          reject(new Error('Failed to bind'));
+          return;
+        }
+        resolve(addr.port);
+      });
+      server.on('error', reject);
+    });
+
+    try {
+      const res = await fetch(`http://127.0.0.1:${port}/`, {
+        method: 'POST',
+        headers: { 'content-type': 'application/json' },
+        body: JSON.stringify([1, 2, 3]),
+      });
+      const data = (await res.json()) as { type: string };
+      expect(data.type).toBe('array');
+    } finally {
+      server.close();
+    }
+  });
+});

+ 53 - 0
src/interview/helpers.ts

@@ -0,0 +1,53 @@
+import type { IncomingMessage, ServerResponse } from 'node:http';
+
+export function sendJson(
+  response: ServerResponse,
+  status: number,
+  value: unknown,
+): void {
+  response.statusCode = status;
+  response.setHeader('content-type', 'application/json; charset=utf-8');
+  response.end(`${JSON.stringify(value)}\n`);
+}
+
+export function sendHtml(response: ServerResponse, html: string): void {
+  response.statusCode = 200;
+  response.setHeader('content-type', 'text/html; charset=utf-8');
+  response.end(html);
+}
+
+export function isValidId(id: string): boolean {
+  return /^[a-zA-Z0-9_-]+$/.test(id) && id.length <= 256;
+}
+
+export function extractResumeSlug(interviewId: string): string {
+  if (interviewId.startsWith('recovered-')) {
+    return interviewId.replace('recovered-', '');
+  }
+  const parts = interviewId.split('-');
+  return parts.slice(2).join('-') || interviewId;
+}
+
+const MAX_BODY_SIZE = 64 * 1024; // 64KB
+
+/**
+ * Read and parse JSON body from an HTTP request with size limit.
+ * Destroys the request if the body exceeds MAX_BODY_SIZE.
+ */
+export async function readJsonBody(request: IncomingMessage): Promise<unknown> {
+  const chunks: Buffer[] = [];
+  let size = 0;
+
+  for await (const chunk of request) {
+    const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
+    size += buffer.length;
+    if (size > MAX_BODY_SIZE) {
+      request.destroy();
+      throw new Error('Request body too large');
+    }
+    chunks.push(buffer);
+  }
+
+  const raw = Buffer.concat(chunks).toString('utf8').trim();
+  return raw ? JSON.parse(raw) : {};
+}

+ 17 - 11
src/interview/interview.test.ts

@@ -30,6 +30,12 @@ function createMockContext(overrides?: {
           }
           return {};
         }),
+        promptAsync: mock(async (args: any) => {
+          if (overrides?.promptImpl) {
+            return await overrides.promptImpl(args);
+          }
+          return {};
+        }),
       },
     },
     directory: overrides?.directory ?? '/test/directory',
@@ -1180,16 +1186,16 @@ describe('interview service', () => {
       });
 
       // Clear previous prompt calls to capture the answer prompt
-      ctx.client.session.prompt.mock.calls.length = 0;
+      ctx.client.session.promptAsync.mock.calls.length = 0;
 
       // Submit an answer
       const answers: InterviewAnswer[] = [{ questionId: 'q-1', answer: 'A' }];
       await service.submitAnswers(requiredInterviewId, answers);
 
       // Answer prompt should reference the configured maxQuestions
-      const lastPromptText = getPromptTexts(ctx.client.session.prompt).join(
-        '\n',
-      );
+      const lastPromptText = getPromptTexts(
+        ctx.client.session.promptAsync,
+      ).join('\n');
       expect(lastPromptText).toContain('Return 0 to 4 questions');
 
       // Cleanup
@@ -1448,7 +1454,7 @@ describe('interview service', () => {
 describe('renderInterviewPage', () => {
   test('escapes HTML special characters in interviewId for title', () => {
     const maliciousId = '<script>alert("xss")</script>';
-    const html = renderInterviewPage(maliciousId);
+    const html = renderInterviewPage(maliciousId, maliciousId);
 
     // Should not contain raw script tags in title
     expect(html).not.toContain('<title>Interview <script>');
@@ -1461,7 +1467,7 @@ describe('renderInterviewPage', () => {
 
   test('escapes ampersand in interviewId', () => {
     const idWithAmpersand = 'A&B Test';
-    const html = renderInterviewPage(idWithAmpersand);
+    const html = renderInterviewPage(idWithAmpersand, idWithAmpersand);
 
     expect(html).toContain('<title>Interview A&amp;B Test</title>');
     expect(html).not.toContain('<title>Interview A&B Test</title>');
@@ -1469,21 +1475,21 @@ describe('renderInterviewPage', () => {
 
   test('escapes single quotes in interviewId', () => {
     const idWithQuote = "test'quote";
-    const html = renderInterviewPage(idWithQuote);
+    const html = renderInterviewPage(idWithQuote, idWithQuote);
 
     expect(html).toContain('<title>Interview test&#39;quote</title>');
   });
 
   test('preserves safe interviewId characters', () => {
     const safeId = 'my-interview-123_test';
-    const html = renderInterviewPage(safeId);
+    const html = renderInterviewPage(safeId, safeId);
 
     expect(html).toContain(`<title>Interview ${safeId}</title>`);
   });
 
   test('interviewId in JSON script tag is properly stringified', () => {
     const idWithQuotes = 'test"onclick"evil';
-    const html = renderInterviewPage(idWithQuotes);
+    const html = renderInterviewPage(idWithQuotes, idWithQuotes);
 
     // The interviewId in the JavaScript should be JSON.stringify'd
     // JSON.stringify escapes quotes as \"
@@ -1494,7 +1500,7 @@ describe('renderInterviewPage', () => {
 
   test('does not inject raw interviewId into HTML title', () => {
     const xssAttempt = '<img src=x onerror=alert(1)>';
-    const html = renderInterviewPage(xssAttempt);
+    const html = renderInterviewPage(xssAttempt, xssAttempt);
 
     // Title should be escaped
     expect(html).not.toContain(`<title>Interview ${xssAttempt}</title>`);
@@ -1504,7 +1510,7 @@ describe('renderInterviewPage', () => {
   });
 
   test('renders a self-contained brand mark', () => {
-    const html = renderInterviewPage('brand-test');
+    const html = renderInterviewPage('brand-test', 'brand-test');
 
     expect(html).toContain('<svg');
     expect(html).not.toContain('https://ohmyopencodeslim.com');

+ 652 - 0
src/interview/manager.test.ts

@@ -0,0 +1,652 @@
+import { describe, expect, mock, test } from 'bun:test';
+import * as fs from 'node:fs/promises';
+import { createServer } from 'node:http';
+import type { PluginConfig } from '../config';
+import { readDashboardAuthFile } from './dashboard';
+import { createInterviewManager } from './manager';
+
+// Helper to find a free port (matches interview.test.ts pattern)
+async function findFreePort(): Promise<number> {
+  return new Promise((resolve, reject) => {
+    const server = createServer();
+    server.listen(0, () => {
+      const address = server.address();
+      if (address && typeof address !== 'string') {
+        const port = address.port;
+        server.close(() => resolve(port));
+      } else {
+        server.close(() => reject(new Error('Failed to get port')));
+      }
+    });
+  });
+}
+
+// Mock context pattern from interview.test.ts
+function createMockContext(overrides?: {
+  directory?: string;
+  messagesData?: Array<{
+    info?: { role: string };
+    parts?: Array<{ type: string; text?: string }>;
+  }>;
+  promptImpl?: (args: any) => Promise<unknown>;
+}) {
+  const messagesData = overrides?.messagesData ?? [];
+  return {
+    client: {
+      session: {
+        messages: mock(async () => ({ data: messagesData })),
+        prompt: mock(async (args: any) => {
+          if (overrides?.promptImpl) {
+            return await overrides.promptImpl(args);
+          }
+          return {};
+        }),
+        promptAsync: mock(async (args: any) => {
+          if (overrides?.promptImpl) {
+            return await overrides.promptImpl(args);
+          }
+          return {};
+        }),
+      },
+    },
+    directory: overrides?.directory ?? '/test/directory',
+  } as any;
+}
+
+// Helper to extract text from output parts
+function _extractOutputText(output: {
+  parts: Array<{ type: string; text?: string }>;
+}): string {
+  const textPart = output.parts.find((part) => part.type === 'text');
+  return textPart?.text ?? '';
+}
+
+describe('interview manager - per-session mode', () => {
+  describe('basic functionality', () => {
+    test('returns correct interface when port is 0 (default)', () => {
+      const ctx = createMockContext();
+      const config = { interview: { port: 0 } } as PluginConfig;
+
+      const manager = createInterviewManager(ctx, config);
+
+      expect(manager).toHaveProperty('registerCommand');
+      expect(manager).toHaveProperty('handleCommandExecuteBefore');
+      expect(manager).toHaveProperty('handleEvent');
+      expect(typeof manager.registerCommand).toBe('function');
+      expect(typeof manager.handleCommandExecuteBefore).toBe('function');
+      expect(typeof manager.handleEvent).toBe('function');
+    });
+
+    test('creates interview with /interview command', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/manager-test-');
+      const ctx = createMockContext({ directory: tempDir });
+      const config = { interview: { port: 0 } } as PluginConfig;
+
+      const manager = createInterviewManager(ctx, config);
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+      await manager.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-123',
+          arguments: 'My App Idea',
+        },
+        output,
+      );
+
+      // Should inject kickoff prompt into output
+      expect(output.parts.length).toBe(1);
+      expect(output.parts[0].type).toBe('text');
+      expect(output.parts[0].text).toContain('My App Idea');
+      expect(output.parts[0].text).toContain('<interview_state>');
+
+      // Cleanup
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+
+    test('marks interview as abandoned on session.deleted event', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/manager-test-');
+      const ctx = createMockContext({ directory: tempDir });
+      const config = { interview: { port: 0 } } as PluginConfig;
+
+      const manager = createInterviewManager(ctx, config);
+
+      // Create interview
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+      await manager.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-delete-test',
+          arguments: 'Delete Test',
+        },
+        output,
+      );
+
+      // Simulate session deletion
+      await manager.handleEvent({
+        event: {
+          type: 'session.deleted',
+          properties: { sessionID: 'session-delete-test' },
+        },
+      });
+
+      // Interview should still exist (file not deleted)
+      const interviewDir = `${tempDir}/interview`;
+      const remainingFiles = await fs.readdir(interviewDir);
+      expect(remainingFiles.length).toBe(1);
+      // Status is only tracked in memory, not written to markdown
+      // We verify the session deletion handler doesn't throw
+
+      // Cleanup
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+
+    test('registers session when interview is created', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/manager-test-');
+      const ctx = createMockContext({ directory: tempDir });
+
+      const freePort = await findFreePort();
+      const config = {
+        interview: {
+          port: freePort,
+          dashboard: true,
+        },
+      } as PluginConfig;
+
+      const manager = createInterviewManager(ctx, config);
+
+      // Wait for dashboard init
+      await new Promise((r) => setTimeout(r, 100));
+
+      try {
+        // Create interview (should trigger session registration)
+        const output = { parts: [] as Array<{ type: string; text?: string }> };
+        await manager.handleCommandExecuteBefore(
+          {
+            command: 'interview',
+            sessionID: 'session-reg-after-cmd',
+            arguments: 'Register After Cmd',
+          },
+          output,
+        );
+
+        // Extract interview ID
+        const promptCalls = ctx.client.session.prompt.mock.calls;
+        expect(promptCalls.length).toBeGreaterThan(0);
+        const text =
+          promptCalls[promptCalls.length - 1][0].body?.parts?.[0]?.text ?? '';
+        const match = text.match(/interview\/([^\s]+)/);
+        expect(match).not.toBeNull();
+        const interviewId = match?.[1];
+
+        // Give registration a moment
+        await new Promise((r) => setTimeout(r, 100));
+
+        // Read auth token
+        const auth = await readDashboardAuthFile(freePort);
+        expect(auth).not.toBeNull();
+
+        // Verify session is registered (interview exists in cache)
+        const listResponse = await fetch(
+          `http://127.0.0.1:${freePort}/api/interviews/${interviewId}/state?token=${auth?.token}`,
+        );
+        expect(listResponse.status).toBe(200);
+      } finally {
+        await fs.rm(tempDir, { recursive: true, force: true });
+      }
+    });
+  });
+
+  describe('dashboard: true with port 0', () => {
+    test('activates dashboard mode and creates interview', async () => {
+      const freePort = await findFreePort();
+      const tempDir = await fs.mkdtemp('/tmp/manager-test-');
+      const ctx = createMockContext({ directory: tempDir });
+
+      const config = {
+        interview: {
+          port: freePort,
+          dashboard: true,
+        },
+      } as PluginConfig;
+
+      const manager = createInterviewManager(ctx, config);
+
+      // Wait for async init
+      await new Promise((r) => setTimeout(r, 100));
+
+      try {
+        const output = { parts: [] as Array<{ type: string; text?: string }> };
+        await manager.handleCommandExecuteBefore(
+          {
+            command: 'interview',
+            sessionID: 'session-dashboard-bool',
+            arguments: 'Dashboard Bool Test',
+          },
+          output,
+        );
+
+        expect(output.parts.length).toBe(1);
+        expect(output.parts[0].text).toContain('Dashboard Bool Test');
+      } finally {
+        await fs.rm(tempDir, { recursive: true, force: true });
+      }
+    });
+  });
+});
+
+describe('interview manager - state push callback wiring', () => {
+  test('in dashboard mode, state push callback is wired', async () => {
+    const tempDir = await fs.mkdtemp('/tmp/manager-test-');
+    const ctx = createMockContext({ directory: tempDir });
+
+    const freePort = await findFreePort();
+    const config = {
+      interview: {
+        port: freePort,
+        dashboard: true,
+      },
+    } as PluginConfig;
+
+    const manager = createInterviewManager(ctx, config);
+
+    // Wait for dashboard init
+    await new Promise((r) => setTimeout(r, 100));
+
+    try {
+      // Create interview
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+      await manager.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-state-callback',
+          arguments: 'State Callback Test',
+        },
+        output,
+      );
+
+      // Extract interview ID from prompt calls
+      const promptCalls = ctx.client.session.prompt.mock.calls;
+      expect(promptCalls.length).toBeGreaterThan(0);
+      const text =
+        promptCalls[promptCalls.length - 1][0].body?.parts?.[0]?.text ?? '';
+      const match = text.match(/interview\/([^\s]+)/);
+      expect(match).not.toBeNull();
+      const interviewId = match?.[1];
+
+      // Give state push a moment
+      await new Promise((r) => setTimeout(r, 100));
+
+      // Read auth token
+      const auth = await readDashboardAuthFile(freePort);
+      expect(auth).not.toBeNull();
+
+      // Verify state was pushed to dashboard cache
+      const stateResponse = await fetch(
+        `http://127.0.0.1:${freePort}/api/interviews/${interviewId}/state?token=${auth?.token}`,
+      );
+      expect(stateResponse.status).toBe(200);
+
+      const stateData = (await stateResponse.json()) as {
+        interview: { idea: string };
+        mode: string;
+      };
+      expect(stateData.interview.idea).toBe('State Callback Test');
+    } finally {
+      await fs.rm(tempDir, { recursive: true, force: true });
+    }
+  });
+
+  test('in per-session mode, setBaseUrlResolver is called', async () => {
+    const tempDir = await fs.mkdtemp('/tmp/manager-test-');
+    const ctx = createMockContext({ directory: tempDir });
+    const config = { interview: { port: 0 } } as PluginConfig;
+
+    const manager = createInterviewManager(ctx, config);
+
+    try {
+      // Create interview (this triggers server start via setBaseUrlResolver)
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+      await manager.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-base-url',
+          arguments: 'Base URL Test',
+        },
+        output,
+      );
+
+      // Should create a markdown file (proof that server was initialized)
+      const interviewDir = `${tempDir}/interview`;
+      const files = await fs.readdir(interviewDir);
+      expect(files.length).toBe(1);
+    } finally {
+      await fs.rm(tempDir, { recursive: true, force: true });
+    }
+  });
+});
+
+describe('interview manager - session registration', () => {
+  test('registers session after handleCommandExecuteBefore in dashboard mode', async () => {
+    const tempDir = await fs.mkdtemp('/tmp/manager-test-');
+    const ctx = createMockContext({ directory: tempDir });
+
+    const freePort = await findFreePort();
+    const config = {
+      interview: {
+        port: freePort,
+        dashboard: true,
+      },
+    } as PluginConfig;
+
+    const manager = createInterviewManager(ctx, config);
+
+    // Wait for dashboard init
+    await new Promise((r) => setTimeout(r, 100));
+
+    try {
+      // Create interview (should trigger session registration)
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+      await manager.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-reg-after-cmd',
+          arguments: 'Register After Cmd',
+        },
+        output,
+      );
+
+      // Extract interview ID
+      const promptCalls = ctx.client.session.prompt.mock.calls;
+      expect(promptCalls.length).toBeGreaterThan(0);
+      const text =
+        promptCalls[promptCalls.length - 1][0].body?.parts?.[0]?.text ?? '';
+      const match = text.match(/interview\/([^\s]+)/);
+      expect(match).not.toBeNull();
+      const interviewId = match?.[1];
+
+      // Give registration a moment
+      await new Promise((r) => setTimeout(r, 100));
+
+      // Read auth token
+      const auth = await readDashboardAuthFile(freePort);
+      expect(auth).not.toBeNull();
+
+      // Verify session was registered by checking the interview state
+      const stateResponse = await fetch(
+        `http://127.0.0.1:${freePort}/api/interviews/${interviewId}/state?token=${auth?.token}`,
+      );
+      expect(stateResponse.status).toBe(200);
+    } finally {
+      await fs.rm(tempDir, { recursive: true, force: true });
+    }
+  });
+
+  test('removes session on session.deleted event', async () => {
+    const tempDir = await fs.mkdtemp('/tmp/manager-test-');
+    const ctx = createMockContext({ directory: tempDir });
+
+    const freePort = await findFreePort();
+    const config = {
+      interview: {
+        port: freePort,
+        dashboard: true,
+      },
+    } as PluginConfig;
+
+    const manager = createInterviewManager(ctx, config);
+
+    // Wait for dashboard init
+    await new Promise((r) => setTimeout(r, 100));
+
+    try {
+      // Create interview
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+      await manager.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-delete-reg',
+          arguments: 'Delete Register Test',
+        },
+        output,
+      );
+
+      // Extract interview ID
+      const promptCalls = ctx.client.session.prompt.mock.calls;
+      expect(promptCalls.length).toBeGreaterThan(0);
+      const text =
+        promptCalls[promptCalls.length - 1][0].body?.parts?.[0]?.text ?? '';
+      const match = text.match(/interview\/([^\s]+)/);
+      expect(match).not.toBeNull();
+      const _interviewId = match?.[1];
+
+      // Give registration a moment
+      await new Promise((r) => setTimeout(r, 100));
+
+      // Delete session
+      await manager.handleEvent({
+        event: {
+          type: 'session.deleted',
+          properties: { sessionID: 'session-delete-reg' },
+        },
+      });
+
+      // Give cleanup a moment
+      await new Promise((r) => setTimeout(r, 50));
+
+      // Interview file should still exist
+      const interviewDir = `${tempDir}/interview`;
+      const files = await fs.readdir(interviewDir);
+      expect(files.length).toBe(1);
+      // Status is only tracked in memory, not written to markdown
+    } finally {
+      await fs.rm(tempDir, { recursive: true, force: true });
+    }
+  });
+});
+
+describe('interview manager - edge cases', () => {
+  test('handles session.status event with idle status', async () => {
+    const tempDir = await fs.mkdtemp('/tmp/manager-test-');
+    const ctx = createMockContext({ directory: tempDir });
+    const config = { interview: { port: 0 } } as PluginConfig;
+
+    const manager = createInterviewManager(ctx, config);
+
+    try {
+      // Create interview
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+      await manager.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-idle',
+          arguments: 'Idle Event Test',
+        },
+        output,
+      );
+
+      // Send idle status event
+      await manager.handleEvent({
+        event: {
+          type: 'session.status',
+          properties: {
+            sessionID: 'session-idle',
+            status: { type: 'idle' },
+          },
+        },
+      });
+
+      // Should not throw
+      expect(true).toBe(true);
+    } finally {
+      await fs.rm(tempDir, { recursive: true, force: true });
+    }
+  });
+
+  test('handles session.status event without sessionID in properties', async () => {
+    const tempDir = await fs.mkdtemp('/tmp/manager-test-');
+    const ctx = createMockContext({ directory: tempDir });
+    const config = { interview: { port: 0 } } as PluginConfig;
+
+    const manager = createInterviewManager(ctx, config);
+
+    try {
+      // Send idle status event without sessionID
+      await manager.handleEvent({
+        event: {
+          type: 'session.status',
+          properties: {
+            status: { type: 'idle' },
+          },
+        },
+      });
+
+      // Should not throw
+      expect(true).toBe(true);
+    } finally {
+      await fs.rm(tempDir, { recursive: true, force: true });
+    }
+  });
+
+  test('handles unknown event types', async () => {
+    const tempDir = await fs.mkdtemp('/tmp/manager-test-');
+    const ctx = createMockContext({ directory: tempDir });
+    const config = { interview: { port: 0 } } as PluginConfig;
+
+    const manager = createInterviewManager(ctx, config);
+
+    try {
+      // Send unknown event type
+      await manager.handleEvent({
+        event: {
+          type: 'unknown.event',
+          properties: { sessionID: 'session-unknown' },
+        },
+      });
+
+      // Should not throw
+      expect(true).toBe(true);
+    } finally {
+      await fs.rm(tempDir, { recursive: true, force: true });
+    }
+  });
+
+  test('handles handleCommandExecuteBefore without sessionID', async () => {
+    const tempDir = await fs.mkdtemp('/tmp/manager-test-');
+    const ctx = createMockContext({ directory: tempDir });
+    const config = { interview: { port: 0 } } as PluginConfig;
+
+    const manager = createInterviewManager(ctx, config);
+
+    try {
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+      await manager.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: '',
+          arguments: 'No Session Test',
+        },
+        output,
+      );
+
+      // Should create interview (sessionID is optional in per-session mode)
+      expect(output.parts.length).toBe(1);
+    } finally {
+      await fs.rm(tempDir, { recursive: true, force: true });
+    }
+  });
+});
+
+describe('interview manager - integration with real dashboard', () => {
+  test('two managers on same port: first becomes dashboard, second becomes session', async () => {
+    const tempDir1 = await fs.mkdtemp('/tmp/manager-test-');
+    const tempDir2 = await fs.mkdtemp('/tmp/manager-test-');
+
+    const ctx1 = createMockContext({ directory: tempDir1 });
+    const ctx2 = createMockContext({ directory: tempDir2 });
+
+    const freePort = await findFreePort();
+    const config = {
+      interview: {
+        port: freePort,
+        dashboard: true,
+      },
+    } as PluginConfig;
+
+    const manager1 = createInterviewManager(ctx1, config);
+
+    // Wait for manager1 to become dashboard
+    await new Promise((r) => setTimeout(r, 100));
+
+    try {
+      // Manager1 should be the dashboard
+      const healthResponse = await fetch(
+        `http://127.0.0.1:${freePort}/api/health`,
+      );
+      expect(healthResponse.status).toBe(200);
+
+      // Manager2 should become a session (not throw when dashboard is found)
+      const manager2 = createInterviewManager(ctx2, config);
+
+      // Wait for manager2 init (probes dashboard)
+      await new Promise((r) => setTimeout(r, 100));
+
+      // Both managers should work
+      const output1 = { parts: [] as Array<{ type: string; text?: string }> };
+      await manager1.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-1',
+          arguments: 'Manager 1 Test',
+        },
+        output1,
+      );
+
+      const output2 = { parts: [] as Array<{ type: string; text?: string }> };
+      await manager2.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-2',
+          arguments: 'Manager 2 Test',
+        },
+        output2,
+      );
+
+      // Give state pushes a moment
+      await new Promise((r) => setTimeout(r, 100));
+
+      // Extract interview IDs
+      const promptCalls1 = ctx1.client.session.prompt.mock.calls;
+      const text1 =
+        promptCalls1[promptCalls1.length - 1][0].body?.parts?.[0]?.text ?? '';
+      const match1 = text1.match(/interview\/([^\s]+)/);
+      expect(match1).not.toBeNull();
+      const interviewId1 = match1?.[1];
+
+      const promptCalls2 = ctx2.client.session.prompt.mock.calls;
+      const text2 =
+        promptCalls2[promptCalls2.length - 1][0].body?.parts?.[0]?.text ?? '';
+      const match2 = text2.match(/interview\/([^\s]+)/);
+      expect(match2).not.toBeNull();
+      const interviewId2 = match2?.[1];
+
+      // Read auth token
+      const auth = await readDashboardAuthFile(freePort);
+      expect(auth).not.toBeNull();
+
+      // Both interviews should be in dashboard cache
+      const state1Response = await fetch(
+        `http://127.0.0.1:${freePort}/api/interviews/${interviewId1}/state?token=${auth?.token}`,
+      );
+      expect(state1Response.status).toBe(200);
+
+      const state2Response = await fetch(
+        `http://127.0.0.1:${freePort}/api/interviews/${interviewId2}/state?token=${auth?.token}`,
+      );
+      expect(state2Response.status).toBe(200);
+    } finally {
+      await fs.rm(tempDir1, { recursive: true, force: true });
+      await fs.rm(tempDir2, { recursive: true, force: true });
+    }
+  });
+});

+ 455 - 26
src/interview/manager.ts

@@ -1,25 +1,34 @@
+import path from 'node:path';
 import type { PluginInput } from '@opencode-ai/plugin';
 import type { PluginConfig } from '../config';
+import { log } from '../utils';
+import {
+  DEFAULT_DASHBOARD_PORT,
+  probeDashboard,
+  readDashboardAuthFile,
+  tryBecomeDashboard,
+} from './dashboard';
 import { createInterviewServer } from './server';
 import { createInterviewService } from './service';
+import type {
+  InterviewRecord,
+  InterviewState,
+  InterviewStateEntry,
+} from './types';
 
 /**
- * Interview Manager - Composition root wiring the lean service ↔ server flow.
+ * Interview Manager — Composition root.
  *
- * Architecture:
- * - Service: in-memory interview runtime + markdown document updates
- * - Server: localhost UI + JSON API
- * - Manager: small adapter exposing plugin hooks
+ * Two modes:
  *
- * Dependency injection pattern:
- * - Server depends on service.getState and service.submitAnswers
- * - Service depends on server.ensureStarted (via setBaseUrlResolver)
- * - Circular dependency resolved by lazy resolution
+ * 1. **Dashboard mode** (dashboard:true or port>0):
+ *    First process to bind the port becomes the dashboard (dumb aggregator).
+ *    Other processes register as sessions and push state to it.
+ *    Sessions drive LLM interaction locally, dashboard just serves the web UI.
  *
- * Plugin integration:
- * - registerCommand: injects /interview into OpenCode config
- * - handleCommandExecuteBefore: intercepts /interview execution
- * - handleEvent: listens to session.status and session.deleted events
+ * 2. **Per-session mode** (default, port=0, dashboard:false):
+ *    Upstream behavior. Each process runs its own interview server on a random
+ *    port. Lazy startup on first /interview command.
  */
 export function createInterviewManager(
   ctx: PluginInput,
@@ -34,21 +43,441 @@ export function createInterviewManager(
     event: { type: string; properties?: Record<string, unknown> };
   }) => Promise<void>;
 } {
-  const service = createInterviewService(ctx, config.interview);
-  const server = createInterviewServer({
-    getState: async (interviewId) => service.getInterviewState(interviewId),
-    submitAnswers: async (interviewId, answers) =>
-      service.submitAnswers(interviewId, answers),
-    port: config.interview?.port ?? 0,
-  });
+  const interviewConfig = config.interview;
+  const effectivePort = interviewConfig?.port ?? 0;
+  const dashboardEnabled =
+    interviewConfig?.dashboard === true || effectivePort > 0;
+  const outputFolder = interviewConfig?.outputFolder ?? 'interview';
+
+  // ─── Per-session mode (upstream behavior) ───────────────────────
+  if (!dashboardEnabled) {
+    const service = createInterviewService(ctx, interviewConfig);
+    const resolvedOutputPath = path.join(ctx.directory, outputFolder);
+    const server = createInterviewServer({
+      getState: async (interviewId) => service.getInterviewState(interviewId),
+      listInterviewFiles: async () => service.listInterviewFiles(),
+      listInterviews: () => service.listInterviews(),
+      submitAnswers: async (interviewId, answers) =>
+        service.submitAnswers(interviewId, answers),
+      handleNudgeAction: async (interviewId, action) =>
+        service.handleNudgeAction(interviewId, action),
+      outputFolder: resolvedOutputPath,
+      port: 0, // random port
+    });
+
+    service.setBaseUrlResolver(() => server.ensureStarted());
+
+    return {
+      registerCommand: (c) => service.registerCommand(c),
+      handleCommandExecuteBefore: async (input, output) =>
+        service.handleCommandExecuteBefore(input, output),
+      handleEvent: async (input) => service.handleEvent(input),
+    };
+  }
+
+  // ─── Dashboard mode ─────────────────────────────────────────────
+  const dashboardPort =
+    effectivePort > 0 ? effectivePort : DEFAULT_DASHBOARD_PORT;
+  const service = createInterviewService(ctx, interviewConfig);
+
+  // Async init — resolves once we know our role (dashboard or session)
+  let initDone = false;
+  let isDashboard = false;
+  let dashboardBaseUrl = '';
+  let authToken = '';
+  let dashboard: Awaited<ReturnType<typeof tryBecomeDashboard>> | null = null;
+  const registeredSessions = new Set<string>();
+
+  // ── Timer-based fallback for nudge/answer polling ─────────────
+  // Declared here, started later in initPromise once we confirm
+  // we're in session mode. References poll functions defined below.
+  const FALLBACK_POLL_INTERVAL = 10_000;
+  let fallbackTimer: ReturnType<typeof setInterval> | null = null;
+  const startFallbackTimer = () => {
+    if (fallbackTimer) return;
+    fallbackTimer = setInterval(() => {
+      if (isDashboard || !dashboardBaseUrl) return;
+      for (const sessionID of registeredSessions) {
+        const interviewId = service.getActiveInterviewId(sessionID);
+        if (!interviewId) continue;
+        pollPendingAnswers(sessionID).catch(() => {});
+        pollNudgeAction(sessionID).catch(() => {});
+      }
+    }, FALLBACK_POLL_INTERVAL);
+    fallbackTimer?.unref();
+  };
+
+  const initPromise = (async () => {
+    try {
+      dashboard = await tryBecomeDashboard({
+        port: dashboardPort,
+        outputFolder,
+        sessionClient: ctx.client.session,
+      });
+
+      if (dashboard) {
+        // ── We ARE the dashboard ────────────────────────────────────
+        isDashboard = true;
+        dashboardBaseUrl = `http://127.0.0.1:${dashboardPort}`;
+        authToken = dashboard.authToken;
+
+        service.setBaseUrlResolver(() => Promise.resolve(dashboardBaseUrl));
+
+        // State push: in-process, directly into dashboard cache
+        service.setStatePushCallback((id, state) => {
+          dashboard?.pushState(stateToEntry(id, state));
+        });
+
+        // Interview created: register in dashboard cache immediately
+        service.setOnInterviewCreated((interview) => {
+          dashboard?.pushState({
+            interviewId: interview.id,
+            sessionID: interview.sessionID,
+            idea: interview.idea,
+            mode: 'awaiting-agent',
+            summary: 'Interview created.',
+            title: interview.idea,
+            questions: [],
+            pendingAnswers: null,
+            lastUpdatedAt: Date.now(),
+            filePath: interview.markdownPath,
+            nudgeAction: null,
+          });
+          // Register session directory for file scanning
+          dashboard?.registerSession({
+            sessionID: interview.sessionID,
+            directory: ctx.directory,
+            pid: process.pid,
+            registeredAt: Date.now(),
+          });
+        });
+
+        log('[interview] dashboard mode: we are the dashboard', {
+          port: dashboardPort,
+        });
+
+        // Self-register: dashboard process is also a session with its
+        // own directory. This triggers rebuildFromFiles() for failover.
+        dashboard.registerSession({
+          sessionID: `dashboard-self-${process.pid}`,
+          directory: ctx.directory,
+          pid: process.pid,
+          registeredAt: Date.now(),
+        });
+
+        // Discover directories from past sessions via SDK
+        await dashboard.discoverSessionDirectories();
+        await dashboard.refreshFiles();
+      } else {
+        // ── We're a SESSION ─────────────────────────────────────────
+        const probe = await probeDashboard(dashboardPort);
+        if (!probe.alive) {
+          // Brief retry — dashboard may still be starting
+          await new Promise((r) => setTimeout(r, 500));
+          const retry = await probeDashboard(dashboardPort);
+          if (!retry.alive) {
+            log(
+              '[interview] dashboard mode: no dashboard found, falling back to per-session server',
+              {
+                port: dashboardPort,
+              },
+            );
+            // Fall back to per-session mode — start our own server
+            const perSessionServer = createInterviewServer({
+              getState: async (interviewId) =>
+                service.getInterviewState(interviewId),
+              listInterviewFiles: async () => service.listInterviewFiles(),
+              listInterviews: () => service.listInterviews(),
+              submitAnswers: async (interviewId, answers) =>
+                service.submitAnswers(interviewId, answers),
+              handleNudgeAction: async (interviewId, action) =>
+                service.handleNudgeAction(interviewId, action),
+              outputFolder: path.join(ctx.directory, outputFolder),
+              port: 0, // random port
+            });
+            service.setBaseUrlResolver(() => perSessionServer.ensureStarted());
+            isDashboard = false;
+            initDone = true;
+            return;
+          }
+        }
+
+        dashboardBaseUrl = `http://127.0.0.1:${dashboardPort}`;
+        const auth = await readDashboardAuthFile(dashboardPort);
+        authToken = auth?.token ?? '';
+
+        service.setBaseUrlResolver(() => Promise.resolve(dashboardBaseUrl));
+
+        // State push: HTTP to dashboard
+        service.setStatePushCallback((id, state) => {
+          pushStateViaHttp(dashboardBaseUrl, authToken, id, state).catch(
+            (err) => {
+              log('[interview] failed to push state to dashboard', {
+                error: err instanceof Error ? err.message : String(err),
+              });
+            },
+          );
+        });
+
+        // Interview created: POST to dashboard so it appears immediately
+        service.setOnInterviewCreated((interview) => {
+          registerInterviewViaHttp(
+            dashboardBaseUrl,
+            authToken,
+            interview,
+          ).catch((err) => {
+            log('[interview] failed to register interview with dashboard', {
+              error: err instanceof Error ? err.message : String(err),
+            });
+          });
+        });
+
+        log('[interview] dashboard mode: we are a session', {
+          port: dashboardPort,
+        });
+
+        // Start fallback poll timer now that we know we're in session mode
+        startFallbackTimer();
+      }
+    } catch (err) {
+      log('[interview] dashboard mode init failed', {
+        error: err instanceof Error ? err.message : String(err),
+      });
+    } finally {
+      initDone = true;
+    }
+  })();
+
+  async function ensureInit(): Promise<void> {
+    if (!initDone) await initPromise;
+  }
+
+  // ── Lazy session registration ──────────────────────────────────
+  // Register with dashboard on first hook call that includes a
+  // session ID. Dashboard needs our directory for file scanning.
+  async function registerSessionIfNeeded(sessionID: string): Promise<void> {
+    if (registeredSessions.has(sessionID)) return;
+    registeredSessions.add(sessionID);
+    if (isDashboard) return;
+
+    try {
+      await fetch(`${dashboardBaseUrl}/api/register?token=${authToken}`, {
+        method: 'POST',
+        headers: { 'content-type': 'application/json' },
+        body: JSON.stringify({
+          sessionID,
+          directory: ctx.directory,
+          pid: process.pid,
+        }),
+        signal: AbortSignal.timeout(3000),
+      });
+    } catch (err) {
+      log('[interview] failed to register session with dashboard', {
+        error: err instanceof Error ? err.message : String(err),
+      });
+    }
+  }
+
+  // ── Answer polling ─────────────────────────────────────────────
+  // When LLM finishes responding (session goes idle), check if the
+  // user submitted answers via the dashboard UI while we were busy.
+  async function pollPendingAnswers(sessionID: string): Promise<void> {
+    const interviewId = service.getActiveInterviewId(sessionID);
+    if (!interviewId) return;
 
-  // Inject server URL resolver into service (lazy: server starts on first request)
-  service.setBaseUrlResolver(() => server.ensureStarted());
+    try {
+      const response = await fetch(
+        `${dashboardBaseUrl}/api/interviews/${interviewId}/pending?token=${authToken}`,
+        { signal: AbortSignal.timeout(3000) },
+      );
+      if (!response.ok) return;
 
+      const data = (await response.json()) as {
+        answers: Array<{ questionId: string; answer: string }> | null;
+      };
+      if (!data.answers || data.answers.length === 0) return;
+
+      log('[interview] delivering pending answers from dashboard', {
+        interviewId,
+        count: data.answers.length,
+      });
+
+      // submitAnswers reads answers, injects prompt locally, and
+      // the resulting state push updates the dashboard cache
+      await service.submitAnswers(interviewId, data.answers);
+    } catch (err) {
+      log('[interview] failed to poll pending answers', {
+        error: err instanceof Error ? err.message : String(err),
+      });
+    }
+  }
+
+  // ── Nudge polling ──────────────────────────────────────────────
+  // Check if the user nudged the agent from the dashboard UI.
+  async function pollNudgeAction(sessionID: string): Promise<void> {
+    const interviewId = service.getActiveInterviewId(sessionID);
+    if (!interviewId) return;
+
+    try {
+      const response = await fetch(
+        `${dashboardBaseUrl}/api/interviews/${interviewId}/nudge?token=${authToken}`,
+        { signal: AbortSignal.timeout(3000) },
+      );
+      if (!response.ok) return;
+
+      const data = (await response.json()) as {
+        action: 'more-questions' | 'confirm-complete' | null;
+      };
+      if (!data.action) return;
+
+      log('[interview] delivering nudge action from dashboard', {
+        interviewId,
+        action: data.action,
+      });
+
+      await service.handleNudgeAction(interviewId, data.action);
+    } catch (err) {
+      log('[interview] failed to poll nudge action', {
+        error: err instanceof Error ? err.message : String(err),
+      });
+    }
+  }
+
+  return {
+    registerCommand: (c) => service.registerCommand(c),
+
+    handleCommandExecuteBefore: async (input, output) => {
+      await ensureInit();
+      await service.handleCommandExecuteBefore(input, output);
+      if (input.sessionID) {
+        await registerSessionIfNeeded(input.sessionID);
+      }
+    },
+
+    handleEvent: async (input) => {
+      await ensureInit();
+      await service.handleEvent(input);
+
+      const { event } = input;
+      const properties = event.properties ?? {};
+      const sessionID = properties.sessionID as string | undefined;
+
+      // Register session on first sighting
+      if (sessionID) {
+        await registerSessionIfNeeded(sessionID);
+      }
+
+      // When LLM finishes responding, push updated state + poll for pending answers
+      if (event.type === 'session.status' && sessionID) {
+        const status = properties.status as { type?: string } | undefined;
+        if (status?.type === 'idle') {
+          const interviewId = service.getActiveInterviewId(sessionID);
+
+          // Process pending nudges/answers BEFORE refreshing state.
+          // handleNudgeAction sets sessionBusy=true, so the state refresh
+          // below correctly pushes 'awaiting-agent' instead of 'completed'.
+          if (!isDashboard) {
+            // Session mode: HTTP poll the dashboard
+            await pollPendingAnswers(sessionID);
+            await pollNudgeAction(sessionID);
+          } else if (interviewId && dashboard) {
+            // Dashboard mode: read directly from in-process cache
+            const pending = dashboard.consumePendingAnswers(interviewId);
+            if (pending && pending.length > 0) {
+              log('[interview] delivering pending answers (in-process)', {
+                interviewId,
+                count: pending.length,
+              });
+              await service.submitAnswers(interviewId, pending);
+            }
+            const nudge = dashboard.consumeNudgeAction(interviewId);
+            if (nudge) {
+              log('[interview] delivering nudge action (in-process)', {
+                interviewId,
+                action: nudge,
+              });
+              await service.handleNudgeAction(interviewId, nudge);
+            }
+          }
+
+          // Refresh state: calls getInterviewState → syncInterview → onStateChange
+          // This runs AFTER nudge/answer processing so sessionBusy is accurate.
+          if (interviewId) {
+            service.getInterviewState(interviewId).catch((err) => {
+              log('[interview] failed to refresh state', {
+                error: err instanceof Error ? err.message : String(err),
+              });
+            });
+          }
+        }
+      }
+
+      // Clean up when a session is deleted
+      if (event.type === 'session.deleted' && sessionID) {
+        registeredSessions.delete(sessionID);
+        dashboard?.removeSession(sessionID);
+      }
+    },
+  };
+}
+
+// ─── Helpers ──────────────────────────────────────────────────────
+
+function stateToEntry(
+  interviewId: string,
+  state: InterviewState,
+): InterviewStateEntry {
   return {
-    registerCommand: (config) => service.registerCommand(config),
-    handleCommandExecuteBefore: async (input, output) =>
-      service.handleCommandExecuteBefore(input, output),
-    handleEvent: async (input) => service.handleEvent(input),
+    interviewId,
+    sessionID: state.interview.sessionID,
+    idea: state.interview.idea,
+    mode: state.mode,
+    summary: state.summary,
+    title: state.interview.idea,
+    questions: state.questions.map((q) => ({
+      id: q.id,
+      question: q.question,
+      options: q.options,
+      suggested: q.suggested,
+    })),
+    pendingAnswers: null,
+    lastUpdatedAt: Date.now(),
+    filePath: state.interview.markdownPath,
+    nudgeAction: null,
   };
 }
+
+async function pushStateViaHttp(
+  dashboardUrl: string,
+  token: string,
+  interviewId: string,
+  state: InterviewState,
+): Promise<void> {
+  const entry = stateToEntry(interviewId, state);
+  await fetch(
+    `${dashboardUrl}/api/interviews/${interviewId}/state?token=${token}`,
+    {
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      body: JSON.stringify(entry),
+      signal: AbortSignal.timeout(5000),
+    },
+  );
+}
+
+async function registerInterviewViaHttp(
+  dashboardUrl: string,
+  token: string,
+  interview: InterviewRecord,
+): Promise<void> {
+  await fetch(`${dashboardUrl}/api/interviews?token=${token}`, {
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    body: JSON.stringify({
+      interviewId: interview.id,
+      sessionID: interview.sessionID,
+      idea: interview.idea,
+    }),
+    signal: AbortSignal.timeout(3000),
+  });
+}

+ 290 - 0
src/interview/parser.test.ts

@@ -0,0 +1,290 @@
+import { describe, expect, test } from 'bun:test';
+import {
+  buildFallbackState,
+  findLatestAssistantState,
+  flattenMessage,
+  parseAssistantState,
+} from './parser';
+
+describe('parseAssistantState', () => {
+  test('parses valid interview state with questions', () => {
+    const text =
+      'Here are questions.\n<interview_state>\n{"summary":"Test app","questions":[{"id":"q-1","question":"Platform?","options":["Web","Mobile"],"suggested":"Web"}]}\n</interview_state>';
+    const result = parseAssistantState(text, 2);
+
+    expect(result.state).not.toBeNull();
+    expect(result.state?.summary).toBe('Test app');
+    expect(result.state?.questions).toHaveLength(1);
+    expect(result.state?.questions[0].id).toBe('q-1');
+    expect(result.state?.questions[0].question).toBe('Platform?');
+    expect(result.state?.questions[0].options).toEqual(['Web', 'Mobile']);
+    expect(result.state?.questions[0].suggested).toBe('Web');
+  });
+
+  test('parses state with title field', () => {
+    const text =
+      '<interview_state>\n{"summary":"Building X","title":"my-project","questions":[]}\n</interview_state>';
+    const result = parseAssistantState(text);
+
+    expect(result.state).not.toBeNull();
+    expect(result.state?.title).toBe('my-project');
+  });
+
+  test('returns null when no interview_state block', () => {
+    const result = parseAssistantState('No state block here.');
+    expect(result.state).toBeNull();
+    expect(result.error).toBeUndefined();
+  });
+
+  test('returns error for invalid JSON inside block', () => {
+    const text = '<interview_state>\n{not valid json}\n</interview_state>';
+    const result = parseAssistantState(text);
+
+    expect(result.state).toBeNull();
+    expect(result.error).toBeDefined();
+  });
+
+  test('handles malformed question objects gracefully', () => {
+    const text =
+      '<interview_state>\n{"summary":"Test","questions":[null,123,"string",{"question":"Valid?"}]}\n</interview_state>';
+    const result = parseAssistantState(text, 5);
+
+    expect(result.state).not.toBeNull();
+    // Only the valid question should survive Zod validation
+    expect(result.state?.questions).toHaveLength(1);
+    expect(result.state?.questions[0].question).toBe('Valid?');
+  });
+
+  test('respects maxQuestions limit', () => {
+    const text =
+      '<interview_state>\n{"summary":"Test","questions":[{"id":"q-1","question":"Q1?"},{"id":"q-2","question":"Q2?"},{"id":"q-3","question":"Q3?"},{"id":"q-4","question":"Q4?"}]}\n</interview_state>';
+    const result = parseAssistantState(text, 2);
+
+    expect(result.state?.questions).toHaveLength(2);
+  });
+
+  test('handles empty questions array', () => {
+    const text =
+      '<interview_state>\n{"summary":"Waiting","questions":[]}\n</interview_state>';
+    const result = parseAssistantState(text);
+
+    expect(result.state).not.toBeNull();
+    expect(result.state?.questions).toHaveLength(0);
+    expect(result.state?.summary).toBe('Waiting');
+  });
+
+  test('strips whitespace from question text', () => {
+    const text =
+      '<interview_state>\n{"summary":"Test","questions":[{"question":"  Spaced question  ","options":["  A  ","  B  "]}]}\n</interview_state>';
+    const result = parseAssistantState(text);
+
+    expect(result.state?.questions[0].question).toBe('Spaced question');
+    expect(result.state?.questions[0].options).toEqual(['A', 'B']);
+  });
+
+  test('generates fallback ID when question has no id', () => {
+    const text =
+      '<interview_state>\n{"summary":"Test","questions":[{"question":"No ID?"}]}\n</interview_state>';
+    const result = parseAssistantState(text);
+
+    expect(result.state?.questions[0].id).toBe('q-1');
+  });
+
+  test('trims options to max 4', () => {
+    const text =
+      '<interview_state>\n{"summary":"Test","questions":[{"question":"Q?","options":["A","B","C","D","E","F"]}]}\n</interview_state>';
+    const result = parseAssistantState(text);
+
+    expect(result.state?.questions[0].options).toHaveLength(4);
+  });
+
+  test('filters out non-string options', () => {
+    const text =
+      '<interview_state>\n{"summary":"Test","questions":[{"question":"Q?","options":["A",42,true,null,"B"]}]}\n</interview_state>';
+    const result = parseAssistantState(text);
+
+    expect(result.state?.questions[0].options).toEqual(['A', 'B']);
+  });
+
+  test('handles non-string summary gracefully', () => {
+    const text =
+      '<interview_state>\n{"summary":123,"questions":[]}\n</interview_state>';
+    const result = parseAssistantState(text);
+
+    expect(result.state).not.toBeNull();
+    expect(result.state?.summary).toBe('');
+  });
+
+  test('handles non-string title gracefully', () => {
+    const text =
+      '<interview_state>\n{"summary":"Test","title":456,"questions":[]}\n</interview_state>';
+    const result = parseAssistantState(text);
+
+    expect(result.state).not.toBeNull();
+    expect(result.state?.title).toBeUndefined();
+  });
+
+  test('handles missing questions field', () => {
+    const text =
+      '<interview_state>\n{"summary":"No questions field"}\n</interview_state>';
+    const result = parseAssistantState(text);
+
+    expect(result.state).not.toBeNull();
+    expect(result.state?.questions).toHaveLength(0);
+  });
+
+  test('handles question with empty question text', () => {
+    const text =
+      '<interview_state>\n{"summary":"Test","questions":[{"question":"  "}]}\n</interview_state>';
+    const result = parseAssistantState(text);
+
+    expect(result.state?.questions).toHaveLength(0);
+  });
+
+  test('is case-insensitive for interview_state tag', () => {
+    const text =
+      '<INTERVIEW_STATE>\n{"summary":"Upper","questions":[]}\n</INTERVIEW_STATE>';
+    const result = parseAssistantState(text);
+
+    expect(result.state).not.toBeNull();
+    expect(result.state?.summary).toBe('Upper');
+  });
+
+  test('handles deeply nested unexpected objects', () => {
+    const text =
+      '<interview_state>\n{"summary":"Test","questions":[{"question":"Q?","options":[{"nested":true}]}]}\n</interview_state>';
+    const result = parseAssistantState(text);
+
+    expect(result.state?.questions[0].options).toEqual([]);
+  });
+
+  test('handles extremely large input gracefully', () => {
+    const questions = Array.from({ length: 100 }, (_, i) => ({
+      id: `q-${i}`,
+      question: `Question ${i}?`,
+    }));
+    const text = `<interview_state>\n${JSON.stringify({ summary: 'Large', questions })}\n</interview_state>`;
+    const result = parseAssistantState(text, 5);
+
+    expect(result.state?.questions).toHaveLength(5);
+  });
+});
+
+describe('flattenMessage', () => {
+  test('joins text parts with newline', () => {
+    const message = {
+      parts: [
+        { type: 'text', text: 'Hello' },
+        { type: 'text', text: 'World' },
+      ],
+    };
+    expect(flattenMessage(message as any)).toBe('Hello\nWorld');
+  });
+
+  test('handles missing parts', () => {
+    expect(flattenMessage({} as any)).toBe('');
+  });
+
+  test('handles parts without text', () => {
+    const message = {
+      parts: [{ type: 'image' }, { type: 'text', text: 'only this' }],
+    };
+    expect(flattenMessage(message as any)).toBe('only this');
+  });
+});
+
+describe('buildFallbackState', () => {
+  test('returns waiting message for no answers', () => {
+    const state = buildFallbackState([]);
+    expect(state.summary).toContain('Waiting');
+    expect(state.questions).toHaveLength(0);
+  });
+
+  test('returns in-progress message when answers exist', () => {
+    const messages = [
+      { info: { role: 'user' } },
+      { info: { role: 'assistant' } },
+    ];
+    const state = buildFallbackState(messages as any);
+    expect(state.summary).toContain('in progress');
+  });
+});
+
+describe('findLatestAssistantState', () => {
+  test('finds state in last assistant message', () => {
+    const messages = [
+      {
+        info: { role: 'assistant' },
+        parts: [
+          {
+            type: 'text',
+            text: '<interview_state>\n{"summary":"First","questions":[]}\n</interview_state>',
+          },
+        ],
+      },
+      { info: { role: 'user' }, parts: [{ type: 'text', text: 'reply' }] },
+      {
+        info: { role: 'assistant' },
+        parts: [
+          {
+            type: 'text',
+            text: '<interview_state>\n{"summary":"Latest","questions":[]}\n</interview_state>',
+          },
+        ],
+      },
+    ];
+
+    const result = findLatestAssistantState(messages as any);
+    expect(result.state).not.toBeNull();
+    expect(result.state?.summary).toBe('Latest');
+  });
+
+  test('returns null when no assistant messages', () => {
+    const messages = [
+      { info: { role: 'user' }, parts: [{ type: 'text', text: 'hello' }] },
+    ];
+
+    const result = findLatestAssistantState(messages as any);
+    expect(result.state).toBeNull();
+  });
+
+  test('captures parse error from earlier messages', () => {
+    const messages = [
+      {
+        info: { role: 'assistant' },
+        parts: [
+          {
+            type: 'text',
+            text: '<interview_state>\n{bad json}\n</interview_state>',
+          },
+        ],
+      },
+      {
+        info: { role: 'assistant' },
+        parts: [
+          {
+            type: 'text',
+            text: '<interview_state>\n{"summary":"Recovery","questions":[]}\n</interview_state>',
+          },
+        ],
+      },
+    ];
+
+    const result = findLatestAssistantState(messages as any);
+    expect(result.state).not.toBeNull();
+    expect(result.state?.summary).toBe('Recovery');
+  });
+
+  test('returns latest error when no valid state found', () => {
+    const messages = [
+      {
+        info: { role: 'assistant' },
+        parts: [{ type: 'text', text: 'No state block here.' }],
+      },
+    ];
+
+    const result = findLatestAssistantState(messages as any);
+    expect(result.state).toBeNull();
+    expect(result.latestAssistantError).toContain('Missing');
+  });
+});

+ 28 - 17
src/interview/parser.ts

@@ -3,22 +3,28 @@ import type {
   InterviewMessage,
   InterviewQuestion,
 } from './types';
+import { RawInterviewStateSchema, RawQuestionSchema } from './types';
 
 const INTERVIEW_BLOCK_REGEX =
   /<interview_state>\s*([\s\S]*?)\s*<\/interview_state>/i;
 
 function normalizeQuestion(
-  value: Record<string, unknown>,
+  value: unknown,
   index: number,
 ): InterviewQuestion | null {
+  // Validate raw question object with Zod
+  const result = RawQuestionSchema.safeParse(value);
+  if (!result.success) {
+    return null;
+  }
   const question =
-    typeof value.question === 'string' ? value.question.trim() : '';
+    typeof result.data.question === 'string' ? result.data.question.trim() : '';
   if (!question) {
     return null;
   }
 
-  const options = Array.isArray(value.options)
-    ? value.options
+  const options = Array.isArray(result.data.options)
+    ? result.data.options
         .filter((option): option is string => typeof option === 'string')
         .map((option) => option.trim())
         .filter(Boolean)
@@ -27,14 +33,15 @@ function normalizeQuestion(
 
   return {
     id:
-      typeof value.id === 'string' && value.id.trim().length > 0
-        ? value.id.trim()
+      typeof result.data.id === 'string' && result.data.id.trim().length > 0
+        ? result.data.id.trim()
         : `q-${index + 1}`,
     question,
     options,
     suggested:
-      typeof value.suggested === 'string' && value.suggested.trim().length > 0
-        ? value.suggested.trim()
+      typeof result.data.suggested === 'string' &&
+      result.data.suggested.trim().length > 0
+        ? result.data.suggested.trim()
         : undefined,
   };
 }
@@ -75,7 +82,12 @@ export function parseAssistantState(
   }
 
   try {
-    const parsed = JSON.parse(match[1]) as Record<string, unknown>;
+    const raw = JSON.parse(match[1]);
+    // Validate raw LLM output with Zod before processing
+    const parsed = RawInterviewStateSchema.parse(raw) as Record<
+      string,
+      unknown
+    >;
     const summary =
       typeof parsed.summary === 'string' ? parsed.summary.trim() : '';
     const title =
@@ -84,10 +96,6 @@ export function parseAssistantState(
         : undefined;
     const questions = Array.isArray(parsed.questions)
       ? parsed.questions
-          .filter(
-            (value): value is Record<string, unknown> =>
-              typeof value === 'object' && value !== null,
-          )
           .map((value, index) => normalizeQuestion(value, index))
           .filter((value): value is InterviewQuestion => value !== null)
           .slice(0, maxQuestions)
@@ -118,6 +126,8 @@ export function findLatestAssistantState(
   state: InterviewAssistantState | null;
   latestAssistantError?: string;
 } {
+  let latestAssistantError: string | undefined;
+
   for (let index = messages.length - 1; index >= 0; index -= 1) {
     const message = messages[index];
     if (message.info?.role !== 'assistant') {
@@ -128,16 +138,17 @@ export function findLatestAssistantState(
     if (parsed.state) {
       return {
         state: parsed.state,
+        latestAssistantError,
       };
     }
 
-    return {
-      state: null,
-      latestAssistantError: parsed.error ?? 'Missing <interview_state> block',
-    };
+    if (!latestAssistantError) {
+      latestAssistantError = parsed.error ?? 'Missing <interview_state> block';
+    }
   }
 
   return {
     state: null,
+    latestAssistantError,
   };
 }

+ 96 - 42
src/interview/server.ts

@@ -5,8 +5,14 @@ import {
   type ServerResponse,
 } from 'node:http';
 import { URL } from 'node:url';
-import type { InterviewAnswer, InterviewState } from './types';
-import { renderInterviewPage } from './ui';
+import { extractResumeSlug, readJsonBody, sendHtml, sendJson } from './helpers';
+import type {
+  InterviewAnswer,
+  InterviewFileItem,
+  InterviewListItem,
+  InterviewState,
+} from './types';
+import { renderDashboardPage, renderInterviewPage } from './ui';
 
 function getSubmissionStatus(error: unknown): number {
   if (error instanceof SyntaxError) {
@@ -64,46 +70,19 @@ function parseAnswersPayload(value: unknown): { answers: InterviewAnswer[] } {
   };
 }
 
-async function readJsonBody(request: IncomingMessage): Promise<unknown> {
-  const chunks: Buffer[] = [];
-  let size = 0;
-
-  for await (const chunk of request) {
-    const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
-    size += buffer.length;
-    if (size > 64 * 1024) {
-      request.destroy();
-      throw new Error('Request body too large');
-    }
-    chunks.push(buffer);
-  }
-
-  const raw = Buffer.concat(chunks).toString('utf8').trim();
-  return raw ? JSON.parse(raw) : {};
-}
-
-function sendJson(
-  response: ServerResponse,
-  status: number,
-  value: unknown,
-): void {
-  response.statusCode = status;
-  response.setHeader('content-type', 'application/json; charset=utf-8');
-  response.end(`${JSON.stringify(value)}\n`);
-}
-
-function sendHtml(response: ServerResponse, html: string): void {
-  response.statusCode = 200;
-  response.setHeader('content-type', 'text/html; charset=utf-8');
-  response.end(html);
-}
-
 export function createInterviewServer(deps: {
   getState: (interviewId: string) => Promise<InterviewState>;
+  listInterviewFiles: () => Promise<InterviewFileItem[]>;
+  listInterviews: () => InterviewListItem[];
   submitAnswers: (
     interviewId: string,
     answers: InterviewAnswer[],
   ) => Promise<void>;
+  handleNudgeAction: (
+    interviewId: string,
+    action: 'more-questions' | 'confirm-complete',
+  ) => Promise<void>;
+  outputFolder: string;
   port: number;
 }): {
   ensureStarted: () => Promise<string>;
@@ -113,6 +92,20 @@ export function createInterviewServer(deps: {
   let startPromise: Promise<string> | null = null;
   let activeServer: Server | null = null;
 
+  async function loadDashboardData() {
+    const interviews = deps.listInterviews().map((item) => {
+      const resumeSlug = extractResumeSlug(item.id);
+      return {
+        ...item,
+        url: `/interview/${item.id}`,
+        mode: 'active',
+        resumeSlug,
+      };
+    });
+    const files = await deps.listInterviewFiles();
+    return { interviews, files };
+  }
+
   async function handle(
     request: IncomingMessage,
     response: ServerResponse,
@@ -126,18 +119,41 @@ export function createInterviewServer(deps: {
     }
     const pathname = url.pathname;
 
+    // Dashboard: root page listing all interviews
+    if (request.method === 'GET' && pathname === '/') {
+      try {
+        const { interviews, files } = await loadDashboardData();
+        sendHtml(
+          response,
+          renderDashboardPage(interviews, files, deps.outputFolder),
+        );
+      } catch {
+        sendJson(response, 500, { error: 'Failed to load interviews' });
+      }
+      return;
+    }
+
+    // API: list all interviews as JSON
+    if (request.method === 'GET' && pathname === '/api/interviews') {
+      try {
+        const { interviews, files } = await loadDashboardData();
+        sendJson(response, 200, { active: interviews, files });
+      } catch {
+        sendJson(response, 500, { error: 'Failed to load interviews' });
+      }
+      return;
+    }
+
     if (request.method === 'GET' && pathname.startsWith('/interview/')) {
-      sendHtml(
-        response,
-        renderInterviewPage(pathname.split('/').pop() ?? 'unknown'),
-      );
+      const rawId = decodeURIComponent(pathname.split('/').pop() ?? 'unknown');
+      sendHtml(response, renderInterviewPage(rawId, extractResumeSlug(rawId)));
       return;
     }
 
     const stateMatch = pathname.match(/^\/api\/interviews\/([^/]+)\/state$/);
     if (request.method === 'GET' && stateMatch) {
       try {
-        const state = await deps.getState(stateMatch[1]);
+        const state = await deps.getState(decodeURIComponent(stateMatch[1]));
         sendJson(response, 200, state);
       } catch (error) {
         const message =
@@ -148,13 +164,21 @@ export function createInterviewServer(deps: {
       return;
     }
 
+    // CSRF note: This endpoint intentionally sends no CORS headers.
+    // The browser's same-origin policy blocks cross-origin POST with
+    // Content-Type: application/json (it triggers a preflight, which
+    // 404s here). Do NOT add Access-Control-Allow-Origin without also
+    // adding an Origin check or CSRF token.
     const answersMatch = pathname.match(
       /^\/api\/interviews\/([^/]+)\/answers$/,
     );
     if (request.method === 'POST' && answersMatch) {
       try {
         const body = parseAnswersPayload(await readJsonBody(request));
-        await deps.submitAnswers(answersMatch[1], body.answers);
+        await deps.submitAnswers(
+          decodeURIComponent(answersMatch[1]),
+          body.answers,
+        );
         sendJson(response, 200, {
           ok: true,
           message: 'Answers submitted to the OpenCode session.',
@@ -171,6 +195,36 @@ export function createInterviewServer(deps: {
       return;
     }
 
+    // Nudge: ask more questions or confirm complete
+    const nudgeMatch = pathname.match(/^\/api\/interviews\/([^/]+)\/nudge$/);
+    if (request.method === 'POST' && nudgeMatch) {
+      try {
+        const body = (await readJsonBody(request)) as {
+          action?: string;
+        };
+        if (
+          body.action !== 'more-questions' &&
+          body.action !== 'confirm-complete'
+        ) {
+          sendJson(response, 400, {
+            error: 'action must be "more-questions" or "confirm-complete"',
+          });
+          return;
+        }
+        await deps.handleNudgeAction(
+          decodeURIComponent(nudgeMatch[1]),
+          body.action,
+        );
+        sendJson(response, 200, { ok: true, message: 'Nudge sent.' });
+      } catch (error) {
+        const message =
+          error instanceof Error ? error.message : 'Failed to nudge.';
+        const status = message === 'Interview not found' ? 404 : 500;
+        sendJson(response, status, { ok: false, message });
+      }
+      return;
+    }
+
     sendJson(response, 404, { error: 'Not found' });
   }
 

+ 212 - 205
src/interview/service.ts

@@ -1,5 +1,4 @@
 import { spawn } from 'node:child_process';
-import * as fsSync from 'node:fs';
 import * as fs from 'node:fs/promises';
 import * as path from 'node:path';
 import type { PluginInput } from '@opencode-ai/plugin';
@@ -9,6 +8,21 @@ import {
   hasInternalInitiatorMarker,
   log,
 } from '../utils';
+import {
+  appendInterviewAnswers,
+  createInterviewDirectoryPath,
+  createInterviewFilePath,
+  DEFAULT_OUTPUT_FOLDER,
+  ensureInterviewFile,
+  extractSummarySection,
+  extractTitle,
+  normalizeOutputFolder,
+  readInterviewDocument,
+  relativeInterviewPath,
+  resolveExistingInterviewPath,
+  rewriteInterviewDocument,
+  slugify,
+} from './document';
 import { buildFallbackState, findLatestAssistantState } from './parser';
 import {
   buildAnswerPrompt,
@@ -17,25 +31,17 @@ import {
 } from './prompts';
 import type {
   InterviewAnswer,
+  InterviewFileItem,
+  InterviewListItem,
   InterviewMessage,
-  InterviewQuestion,
   InterviewRecord,
   InterviewState,
 } from './types';
 
 const COMMAND_NAME = 'interview';
 const DEFAULT_MAX_QUESTIONS = 2;
-const DEFAULT_OUTPUT_FOLDER = 'interview';
 const DEFAULT_AUTO_OPEN_BROWSER = true;
 
-function slugify(value: string): string {
-  return value
-    .toLowerCase()
-    .replace(/[^a-z0-9]+/g, '-')
-    .replace(/^-+|-+$/g, '')
-    .slice(0, 48);
-}
-
 /**
  * Open a URL in the default browser.
  * Supports macOS, Linux, and Windows. Failures are logged but not thrown.
@@ -75,195 +81,6 @@ function nowIso(): string {
   return new Date().toISOString();
 }
 
-function normalizeOutputFolder(outputFolder: string): string {
-  const normalized = outputFolder.trim().replace(/^\/+|\/+$/g, '');
-  return normalized || DEFAULT_OUTPUT_FOLDER;
-}
-
-function createInterviewDirectoryPath(
-  directory: string,
-  outputFolder: string,
-): string {
-  return path.join(directory, normalizeOutputFolder(outputFolder));
-}
-
-function createInterviewFilePath(
-  directory: string,
-  outputFolder: string,
-  idea: string,
-): string {
-  const fileName = `${slugify(idea) || 'interview'}.md`;
-  return path.join(
-    createInterviewDirectoryPath(directory, outputFolder),
-    fileName,
-  );
-}
-
-function relativeInterviewPath(directory: string, filePath: string): string {
-  return path.relative(directory, filePath) || path.basename(filePath);
-}
-
-function extractHistorySection(document: string): string {
-  const marker = '## Q&A history\n\n';
-  const index = document.indexOf(marker);
-  return index >= 0 ? document.slice(index + marker.length).trim() : '';
-}
-
-function extractSummarySection(document: string): string {
-  const marker = '## Current spec\n\n';
-  const historyMarker = '\n\n## Q&A history';
-  const start = document.indexOf(marker);
-  if (start < 0) {
-    return '';
-  }
-  const summaryStart = start + marker.length;
-  const summaryEnd = document.indexOf(historyMarker, summaryStart);
-  return document
-    .slice(summaryStart, summaryEnd >= 0 ? summaryEnd : undefined)
-    .trim();
-}
-
-function extractTitle(document: string): string {
-  const match = document.match(/^#\s+(.+)$/m);
-  return match?.[1]?.trim() ?? '';
-}
-
-function buildInterviewDocument(
-  idea: string,
-  summary: string,
-  history: string,
-): string {
-  const normalizedSummary = summary.trim() || 'Waiting for interview answers.';
-  const normalizedHistory = history.trim() || 'No answers yet.';
-
-  return [
-    `# ${idea}`,
-    '',
-    '## Current spec',
-    '',
-    normalizedSummary,
-    '',
-    '## Q&A history',
-    '',
-    normalizedHistory,
-    '',
-  ].join('\n');
-}
-
-async function ensureInterviewFile(record: InterviewRecord): Promise<void> {
-  await fs.mkdir(path.dirname(record.markdownPath), { recursive: true });
-  try {
-    await fs.access(record.markdownPath);
-  } catch {
-    await fs.writeFile(
-      record.markdownPath,
-      buildInterviewDocument(record.idea, '', ''),
-      'utf8',
-    );
-  }
-}
-
-async function readInterviewDocument(record: InterviewRecord): Promise<string> {
-  try {
-    return await fs.readFile(record.markdownPath, 'utf8');
-  } catch (error) {
-    if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
-      // Path may have been updated by concurrent maybeRenameWithTitle
-      try {
-        return await fs.readFile(record.markdownPath, 'utf8');
-      } catch {
-        // Fall through to ensure + create
-      }
-    }
-  }
-  await ensureInterviewFile(record);
-  return fs.readFile(record.markdownPath, 'utf8');
-}
-
-async function rewriteInterviewDocument(
-  record: InterviewRecord,
-  summary: string,
-): Promise<string> {
-  const existing = await readInterviewDocument(record);
-  const history = extractHistorySection(existing);
-  const next = buildInterviewDocument(record.idea, summary, history);
-  await fs.writeFile(record.markdownPath, next, 'utf8');
-  return next;
-}
-
-async function appendInterviewAnswers(
-  record: InterviewRecord,
-  questions: InterviewQuestion[],
-  answers: InterviewAnswer[],
-): Promise<void> {
-  const existing = await readInterviewDocument(record);
-  const summary = extractSummarySection(existing);
-  const history = extractHistorySection(existing);
-  const questionMap = new Map(
-    questions.map((question) => [question.id, question]),
-  );
-  const appended = answers
-    .map((answer) => {
-      const question = questionMap.get(answer.questionId);
-      return question
-        ? `Q: ${question.question}\nA: ${answer.answer.trim()}`
-        : null;
-    })
-    .filter((value): value is string => value !== null)
-    .join('\n\n');
-  const nextHistory = [history === 'No answers yet.' ? '' : history, appended]
-    .filter(Boolean)
-    .join('\n\n');
-  await fs.writeFile(
-    record.markdownPath,
-    buildInterviewDocument(record.idea, summary, nextHistory),
-    'utf8',
-  );
-}
-
-function resolveExistingInterviewPath(
-  directory: string,
-  outputFolder: string,
-  value: string,
-): string | null {
-  const trimmed = value.trim();
-  if (!trimmed) {
-    return null;
-  }
-
-  const outputDir = createInterviewDirectoryPath(directory, outputFolder);
-  const candidates = new Set<string>();
-  const resolvedRoot = path.resolve(directory);
-
-  if (path.isAbsolute(trimmed)) {
-    candidates.add(trimmed);
-  } else {
-    candidates.add(path.resolve(directory, trimmed));
-    candidates.add(path.join(outputDir, trimmed));
-    if (!trimmed.endsWith('.md')) {
-      candidates.add(path.join(outputDir, `${trimmed}.md`));
-    }
-  }
-
-  for (const candidate of candidates) {
-    if (path.extname(candidate) !== '.md') {
-      continue;
-    }
-    const resolved = path.resolve(candidate);
-    if (
-      !resolved.startsWith(resolvedRoot + path.sep) &&
-      resolved !== resolvedRoot
-    ) {
-      continue;
-    }
-    if (fsSync.existsSync(candidate)) {
-      return candidate;
-    }
-  }
-
-  return null;
-}
-
 export function createInterviewService(
   ctx: PluginInput,
   config?: InterviewConfig,
@@ -272,6 +89,13 @@ export function createInterviewService(
   },
 ): {
   setBaseUrlResolver: (resolver: () => Promise<string>) => void;
+  setStatePushCallback: (
+    callback: (interviewId: string, state: InterviewState) => void,
+  ) => void;
+  setOnInterviewCreated: (
+    callback: (interview: InterviewRecord) => void,
+  ) => void;
+  getActiveInterviewId: (sessionID: string) => string | null;
   registerCommand: (config: Record<string, unknown>) => void;
   handleCommandExecuteBefore: (
     input: { command: string; sessionID: string; arguments: string },
@@ -281,10 +105,16 @@ export function createInterviewService(
     event: { type: string; properties?: Record<string, unknown> };
   }) => Promise<void>;
   getInterviewState: (interviewId: string) => Promise<InterviewState>;
+  listInterviewFiles: () => Promise<InterviewFileItem[]>;
+  listInterviews: () => InterviewListItem[];
   submitAnswers: (
     interviewId: string,
     answers: InterviewAnswer[],
   ) => Promise<void>;
+  handleNudgeAction: (
+    interviewId: string,
+    action: 'more-questions' | 'confirm-complete',
+  ) => Promise<void>;
 } {
   const maxQuestions = config?.maxQuestions ?? DEFAULT_MAX_QUESTIONS;
   const outputFolder = normalizeOutputFolder(
@@ -297,12 +127,32 @@ export function createInterviewService(
   const sessionBusy = new Map<string, boolean>();
   const browserOpened = new Set<string>(); // Track interviews that have opened browser
   let resolveBaseUrl: (() => Promise<string>) | null = null;
+  let onStateChange:
+    | ((interviewId: string, state: InterviewState) => void)
+    | null = null;
+  let onInterviewCreated: ((interview: InterviewRecord) => void) | null = null;
   let idCounter = 0;
 
   function setBaseUrlResolver(resolver: () => Promise<string>): void {
     resolveBaseUrl = resolver;
   }
 
+  function setStatePushCallback(
+    callback: (interviewId: string, state: InterviewState) => void,
+  ): void {
+    onStateChange = callback;
+  }
+
+  function setOnInterviewCreated(
+    callback: (interview: InterviewRecord) => void,
+  ): void {
+    onInterviewCreated = callback;
+  }
+
+  function getActiveInterviewId(sessionID: string): string | null {
+    return activeInterviewIds.get(sessionID) ?? null;
+  }
+
   async function ensureServer(): Promise<string> {
     if (!resolveBaseUrl) {
       throw new Error('Interview server is not attached');
@@ -413,6 +263,11 @@ export function createInterviewService(
     await ensureInterviewFile(record);
     activeInterviewIds.set(sessionID, record.id);
     interviewsById.set(record.id, record);
+    fileCache = null;
+
+    if (onInterviewCreated) {
+      onInterviewCreated(record);
+    }
     return record;
   }
 
@@ -447,6 +302,11 @@ export function createInterviewService(
 
     activeInterviewIds.set(sessionID, record.id);
     interviewsById.set(record.id, record);
+    fileCache = null;
+
+    if (onInterviewCreated) {
+      onInterviewCreated(record);
+    }
     return record;
   }
 
@@ -470,7 +330,7 @@ export function createInterviewService(
 
     const document = await rewriteInterviewDocument(interview, state.summary);
 
-    return {
+    const interviewState: InterviewState = {
       interview,
       url: `${await ensureServer()}/interview/${interview.id}`,
       markdownPath: relativeInterviewPath(
@@ -480,19 +340,28 @@ export function createInterviewService(
       mode:
         interview.status === 'abandoned'
           ? 'abandoned'
-          : parsed.latestAssistantError
-            ? 'error'
+          : parsed.state && state.questions.length === 0
+            ? 'completed'
             : sessionBusy.get(interview.sessionID) === true
               ? 'awaiting-agent'
               : state.questions.length > 0
                 ? 'awaiting-user'
-                : 'awaiting-agent',
+                : parsed.latestAssistantError
+                  ? 'error'
+                  : 'awaiting-agent',
       lastParseError: parsed.latestAssistantError,
       isBusy: sessionBusy.get(interview.sessionID) === true,
       summary: state.summary,
       questions: state.questions,
       document,
     };
+
+    // Push state to dashboard if callback is set (dashboard mode)
+    if (onStateChange) {
+      onStateChange(interview.id, interviewState);
+    }
+
+    return interviewState;
   }
 
   async function notifyInterviewUrl(
@@ -552,6 +421,23 @@ export function createInterviewService(
     return syncInterview(interview);
   }
 
+  function listInterviews(): InterviewListItem[] {
+    const result: InterviewListItem[] = [];
+    for (const interview of interviewsById.values()) {
+      if (interview.status !== 'active') continue;
+      result.push({
+        id: interview.id,
+        idea: interview.idea,
+        status: interview.status,
+        createdAt: interview.createdAt,
+      });
+    }
+    return result.sort(
+      (a, b) =>
+        new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
+    );
+  }
+
   async function submitAnswers(
     interviewId: string,
     answers: InterviewAnswer[],
@@ -603,7 +489,9 @@ export function createInterviewService(
       await appendInterviewAnswers(interview, state.questions, answers);
       const prompt = buildAnswerPrompt(answers, state.questions, maxQuestions);
 
-      await ctx.client.session.prompt({
+      // Use promptAsync for non-blocking — returns immediately, LLM
+      // processes in background. State push updates dashboard when done.
+      await ctx.client.session.promptAsync({
         path: { id: interview.sessionID },
         body: {
           parts: [createInternalAgentTextPart(prompt)],
@@ -707,6 +595,7 @@ export function createInterviewService(
       }
 
       interview.status = 'abandoned';
+      fileCache = null;
       activeInterviewIds.delete(deletedSessionId);
       log('[interview] session deleted, interview marked abandoned', {
         sessionID: deletedSessionId,
@@ -715,12 +604,130 @@ export function createInterviewService(
     }
   }
 
+  let fileCache: { items: InterviewFileItem[]; at: number } | null = null;
+  const FILE_CACHE_TTL = 10_000;
+
+  async function listInterviewFiles(): Promise<InterviewFileItem[]> {
+    if (fileCache && Date.now() - fileCache.at < FILE_CACHE_TTL) {
+      return fileCache.items;
+    }
+
+    const outputDir = createInterviewDirectoryPath(ctx.directory, outputFolder);
+    const activePaths = new Set(
+      [...interviewsById.values()]
+        .filter((i) => i.status === 'active')
+        .map((i) => path.resolve(i.markdownPath)),
+    );
+
+    let entries: string[];
+    try {
+      entries = await fs.readdir(outputDir);
+    } catch {
+      return [];
+    }
+
+    const items: InterviewFileItem[] = [];
+    for (const entry of entries) {
+      if (!entry.endsWith('.md')) continue;
+      const fullPath = path.join(outputDir, entry);
+      if (activePaths.has(path.resolve(fullPath))) continue;
+
+      let content: string;
+      try {
+        content = await fs.readFile(fullPath, 'utf8');
+      } catch {
+        continue;
+      }
+
+      const title = extractTitle(content) || entry.replace(/\.md$/, '');
+      const summary = extractSummarySection(content) || '';
+      const baseName = entry.replace(/\.md$/, '');
+
+      items.push({
+        fileName: entry,
+        resumeCommand: `/interview ${baseName}`,
+        title,
+        summary:
+          summary.length > 120 ? `${summary.slice(0, 120)}\u2026` : summary,
+      });
+    }
+
+    const sorted = items.sort((a, b) => a.title.localeCompare(b.title));
+    fileCache = { items: sorted, at: Date.now() };
+    return sorted;
+  }
+
+  async function handleNudgeAction(
+    interviewId: string,
+    action: 'more-questions' | 'confirm-complete',
+  ): Promise<void> {
+    const interview = getInterviewById(interviewId);
+    if (!interview) {
+      throw new Error('Interview not found');
+    }
+    if (interview.status === 'abandoned') {
+      throw new Error('Interview session is no longer active.');
+    }
+    if (sessionBusy.get(interview.sessionID) === true) {
+      throw new Error(
+        'Interview session is busy. Wait for the current response.',
+      );
+    }
+
+    sessionBusy.set(interview.sessionID, true);
+    let promptSent = false;
+
+    try {
+      const state = await getInterviewState(interviewId);
+
+      let prompt: string;
+      if (action === 'more-questions') {
+        prompt = [
+          `The user reviewed the completed interview spec and wants you to continue.`,
+          ``,
+          `Current spec summary: ${state.summary}`,
+          ``,
+          `Ask up to ${maxQuestions} new clarifying questions about aspects that are still unclear or underspecified.`,
+          `Include the structured <interview_state> block with new questions.`,
+        ].join('\n');
+      } else {
+        prompt = [
+          `The user confirmed the interview spec is complete.`,
+          ``,
+          `Current spec summary: ${state.summary}`,
+          ``,
+          `Produce a final, polished version of the full spec document.`,
+          `Do NOT include any <interview_state> block — just output the final spec as clean markdown.`,
+          `The spec should be comprehensive, well-structured, and ready for implementation.`,
+        ].join('\n');
+      }
+
+      await ctx.client.session.promptAsync({
+        path: { id: interview.sessionID },
+        body: {
+          parts: [createInternalAgentTextPart(prompt)],
+        },
+      });
+      promptSent = true;
+    } finally {
+      if (!promptSent) {
+        sessionBusy.set(interview.sessionID, false);
+      }
+    }
+  }
+
   return {
     setBaseUrlResolver,
+    setStatePushCallback,
+    setOnInterviewCreated,
+    getActiveInterviewId,
     registerCommand,
     handleCommandExecuteBefore,
     handleEvent,
     getInterviewState,
+    listInterviewFiles,
+    listInterviews,
     submitAnswers,
+    handleNudgeAction,
   };
 }

+ 73 - 1
src/interview/types.ts

@@ -1,3 +1,5 @@
+import { z } from 'zod';
+
 export interface InterviewQuestion {
   id: string;
   question: string;
@@ -16,6 +18,25 @@ export interface InterviewAssistantState {
   questions: InterviewQuestion[];
 }
 
+// ─── Zod Schemas (for validating untrusted LLM output) ─────────────
+
+/** Raw question object from LLM output — loose, everything optional. */
+export const RawQuestionSchema = z.object({
+  id: z.string().optional(),
+  question: z.string().optional(),
+  options: z.array(z.unknown()).optional(),
+  suggested: z.unknown().optional(),
+});
+
+/** Raw interview_state block from LLM output. */
+export const RawInterviewStateSchema = z.object({
+  summary: z.unknown().optional(),
+  title: z.unknown().optional(),
+  questions: z.array(z.unknown()).optional(),
+});
+
+// ─── Interfaces ─────────────────────────────────────────────────────
+
 export interface InterviewRecord {
   id: string;
   sessionID: string;
@@ -39,14 +60,65 @@ export interface InterviewMessage {
   parts?: InterviewMessagePart[];
 }
 
+export interface InterviewListItem {
+  id: string;
+  idea: string;
+  status: InterviewRecord['status'];
+  createdAt: string;
+}
+
+export interface InterviewFileItem {
+  fileName: string;
+  resumeCommand: string;
+  title: string;
+  summary: string;
+  sessionID?: string;
+  directory?: string;
+}
+
 export interface InterviewState {
   interview: InterviewRecord;
   url: string;
   markdownPath: string;
-  mode: 'awaiting-agent' | 'awaiting-user' | 'abandoned' | 'error';
+  mode:
+    | 'awaiting-agent'
+    | 'awaiting-user'
+    | 'abandoned'
+    | 'completed'
+    | 'error'
+    | 'session-disconnected';
   lastParseError?: string;
   isBusy: boolean;
   summary: string;
   questions: InterviewQuestion[];
   document: string;
 }
+
+/** Wire format for dashboard state cache entries. */
+export interface InterviewStateEntry {
+  interviewId: string;
+  sessionID: string;
+  idea: string;
+  mode:
+    | 'awaiting-agent'
+    | 'awaiting-user'
+    | 'abandoned'
+    | 'completed'
+    | 'error'
+    | 'session-disconnected';
+  summary: string;
+  title: string;
+  questions: Array<{
+    id: string;
+    question: string;
+    options?: string[];
+    suggested?: string;
+  }>;
+  pendingAnswers: Array<{
+    questionId: string;
+    answer: string;
+  }> | null;
+  lastUpdatedAt: number;
+  filePath: string;
+  nudgeAction: 'more-questions' | 'confirm-complete' | null;
+}

File diff suppressed because it is too large
+ 833 - 77
src/interview/ui.ts


Some files were not shown because too many files changed in this diff