Просмотр исходного кода

feat: restore interview feature

Alvin Unreal 1 неделя назад
Родитель
Сommit
b7fcab5ab2
49 измененных файлов с 14466 добавлено и 11 удалено
  1. 14 0
      .slim/codemap.json
  2. 6 0
      CONTEXT.md
  3. 1 0
      README.ja-JP.md
  4. 1 0
      README.ko-KR.md
  5. 1 0
      README.md
  6. 1 0
      README.zh-CN.md
  7. 4 2
      codemap.md
  8. 148 0
      docs/agents/build-agent-empty-input-diagnosis.md
  9. 5 0
      docs/configuration.md
  10. 320 0
      docs/interview.md
  11. 9 0
      docs/opencode-v2-compatibility.md
  12. 1 0
      docs/quick-reference.md
  13. BIN
      img/interview-url.png
  14. BIN
      img/interview-website.png
  15. 32 0
      oh-my-opencode-slim.schema.json
  16. 1 1
      src/codemap.md
  17. 1 0
      src/config/codemap.md
  18. 34 0
      src/config/loader.test.ts
  19. 59 4
      src/config/loader.ts
  20. 70 1
      src/config/schema.test.ts
  21. 25 0
      src/config/schema.ts
  22. 20 0
      src/index.ts
  23. 152 0
      src/interview/codemap.md
  24. 956 0
      src/interview/dashboard-manager.ts
  25. 1708 0
      src/interview/dashboard.test.ts
  26. 1817 0
      src/interview/dashboard.ts
  27. 355 0
      src/interview/document.ts
  28. 87 0
      src/interview/finalization.test.ts
  29. 353 0
      src/interview/helpers.test.ts
  30. 53 0
      src/interview/helpers.ts
  31. 1 0
      src/interview/index.ts
  32. 2122 0
      src/interview/interview.test.ts
  33. 1101 0
      src/interview/manager.test.ts
  34. 37 0
      src/interview/manager.ts
  35. 309 0
      src/interview/parser.test.ts
  36. 201 0
      src/interview/parser.ts
  37. 143 0
      src/interview/prompts.ts
  38. 46 0
      src/interview/runtime.test.ts
  39. 57 0
      src/interview/runtime.ts
  40. 373 0
      src/interview/server.ts
  41. 1076 0
      src/interview/service.ts
  42. 53 0
      src/interview/session-server.ts
  43. 140 0
      src/interview/types.ts
  44. 2027 0
      src/interview/ui.ts
  45. 13 0
      src/v2/codemap.md
  46. 5 0
      src/v2/index.ts
  47. 206 0
      src/v2/interview-bridge.test.ts
  48. 275 0
      src/v2/interview-bridge.ts
  49. 47 3
      src/v2/setup.ts

+ 14 - 0
.slim/codemap.json

@@ -111,6 +111,19 @@
     "src/hooks/types.ts": "6915ec35709366ca261fefbdbf82d106",
     "src/hooks/types.ts": "6915ec35709366ca261fefbdbf82d106",
     "src/index-event.ts": "6b8a88cb4d46536145f2e97fbedfaf63",
     "src/index-event.ts": "6b8a88cb4d46536145f2e97fbedfaf63",
     "src/index.ts": "2fe60e0032400a31dcd8563a0c01bb35",
     "src/index.ts": "2fe60e0032400a31dcd8563a0c01bb35",
+    "src/interview/dashboard-manager.ts": "40475436feb2773a825db89827ab28f5",
+    "src/interview/dashboard.ts": "cfff71b7ca3a38b9e58e3ed96262ccea",
+    "src/interview/document.ts": "bfafc0e7874a61889c1555be4e8972a7",
+    "src/interview/helpers.ts": "b95a7e299bb4ab38ab66a272b3ba3612",
+    "src/interview/index.ts": "ab5c9a50b6c08826cfd53233cac75f38",
+    "src/interview/manager.ts": "c9c61d5a5914c6cffba33ff6962e93aa",
+    "src/interview/parser.ts": "aa6101cf5bebfafcbca845ba532856cf",
+    "src/interview/prompts.ts": "b94ef5117d4e720cb5045080b240d890",
+    "src/interview/server.ts": "fe5230962e2d44c6bec9909049c971f6",
+    "src/interview/service.ts": "0f202ad1189c858a47c6d75e14ce041f",
+    "src/interview/session-server.ts": "b1e0169aa0720bfc92c63c09007890db",
+    "src/interview/types.ts": "c09bb5def45c25f2a9add1327d88d881",
+    "src/interview/ui.ts": "91e40f593b56547aaae36cbe2d6ad29c",
     "src/loop/loop-session.ts": "f57ea3e3f0441443b8608e2d1af96414",
     "src/loop/loop-session.ts": "f57ea3e3f0441443b8608e2d1af96414",
     "src/mcp/context7.ts": "4e02e8ef204b6eb7e99a3209078428b5",
     "src/mcp/context7.ts": "4e02e8ef204b6eb7e99a3209078428b5",
     "src/mcp/grep-app.ts": "53dba799724a92e491b57c30cdbd471d",
     "src/mcp/grep-app.ts": "53dba799724a92e491b57c30cdbd471d",
@@ -197,6 +210,7 @@
     "src/hooks/post-file-tool-nudge": "0ea51ddc5b163ed1b1edf2e6f4c7258f",
     "src/hooks/post-file-tool-nudge": "0ea51ddc5b163ed1b1edf2e6f4c7258f",
     "src/hooks/reflect": "2a7700cf8e7f605836d535ebf186b732",
     "src/hooks/reflect": "2a7700cf8e7f605836d535ebf186b732",
     "src/hooks/task-session-manager": "b1fcd51c5db5dc7ee94c36cad4338d26",
     "src/hooks/task-session-manager": "b1fcd51c5db5dc7ee94c36cad4338d26",
+    "src/interview": "0a42662dfb4b414a15b8a64bc1bcfcb2",
     "src/loop": "366bf2141b605deb1849f9783c993810",
     "src/loop": "366bf2141b605deb1849f9783c993810",
     "src/mcp": "1db30ec46ae0b577ec22b74e2b4d19ea",
     "src/mcp": "1db30ec46ae0b577ec22b74e2b4d19ea",
     "src/multiplexer": "bba220d6226456e375acd9fa63a14273",
     "src/multiplexer": "bba220d6226456e375acd9fa63a14273",

+ 6 - 0
CONTEXT.md

@@ -62,6 +62,12 @@ A glossary of the terms used in this project's domain. Definitions describe what
 - **Verify agent** — The agent or strategy that verifies loop output (`oracle`, `observer`, or `test`).
 - **Verify agent** — The agent or strategy that verifies loop output (`oracle`, `observer`, or `test`).
 - **Success criterion** — A check that decides whether a loop iteration passed (test, build, lint, fileExists, command, oracle, observer, or manual).
 - **Success criterion** — A check that decides whether a loop iteration passed (test, build, lint, fileExists, command, oracle, observer, or manual).
 
 
+## Interview
+
+- **Interview** — A question/answer flow that builds a persistent specification document from an idea.
+- **Spec block** — A named section within a generated specification document.
+- **Interview dashboard** — The web UI for managing an interview and entering answers.
+
 ## Companion
 ## Companion
 
 
 - **Companion** — A native desktop mascot that reflects agent activity; launched and tracked by the companion manager.
 - **Companion** — A native desktop mascot that reflects agent activity; launched and tracked by the companion manager.

+ 1 - 0
README.ja-JP.md

@@ -612,6 +612,7 @@ bunx oh-my-opencode-slim@latest install --companion=yes
 | **[Clonedeps](docs/clonedeps.md)** | 選択した依存関係のソースを ignore 済みのローカルワークスペースにクローンし、調査できるようにします |
 | **[Clonedeps](docs/clonedeps.md)** | 選択した依存関係のソースを ignore 済みのローカルワークスペースにクローンし、調査できるようにします |
 | **[Worktrees](docs/worktrees.md)** | `.slim/worktrees/` lane を使い、隔離された並列または高リスクなコーディング作業を行います |
 | **[Worktrees](docs/worktrees.md)** | `.slim/worktrees/` lane を使い、隔離された並列または高リスクなコーディング作業を行います |
 | **[Preset Switching](docs/preset-switching.md)** | `/preset` で実行時にエージェントモデルのプリセットを切り替えます |
 | **[Preset Switching](docs/preset-switching.md)** | `/preset` で実行時にエージェントモデルのプリセットを切り替えます |
+| **[Interview](docs/interview.md)** | ブラウザベースの Q&A フローで、ざっくりとしたアイデアを構造化された Markdown 仕様に変換します |
 | **[Companion](docs/companion.md)** | 解析、ヘルプ、型情報のためのフローティングウィンドウ companion |
 | **[Companion](docs/companion.md)** | 解析、ヘルプ、型情報のためのフローティングウィンドウ companion |
 
 
 ### ⚙️ 設定 & リファレンス
 ### ⚙️ 設定 & リファレンス

+ 1 - 0
README.ko-KR.md

@@ -650,6 +650,7 @@ bunx oh-my-opencode-slim@latest install --companion=yes
 | **[Clonedeps](docs/clonedeps.md)** | 선택한 의존성 소스를 무시된 로컬 워크스페이스에 복제하여 검사 |
 | **[Clonedeps](docs/clonedeps.md)** | 선택한 의존성 소스를 무시된 로컬 워크스페이스에 복제하여 검사 |
 | **[Worktrees](docs/worktrees.md)** | `.slim/worktrees/` lane을 사용해 격리된 병렬 또는 고위험 코딩 작업 수행 |
 | **[Worktrees](docs/worktrees.md)** | `.slim/worktrees/` lane을 사용해 격리된 병렬 또는 고위험 코딩 작업 수행 |
 | **[Preset Switching](docs/preset-switching.md)** | `/preset`으로 런타임에 에이전트 모델 프리셋 전환 |
 | **[Preset Switching](docs/preset-switching.md)** | `/preset`으로 런타임에 에이전트 모델 프리셋 전환 |
+| **[Interview](docs/interview.md)** | 브라우저 기반 Q&A 흐름을 통해 거친 아이디어를 구조화된 마크다운 명세로 변환 |
 | **[Companion](docs/companion.md)** | 파싱, 도움말, 타입을 위한 플로팅 창 companion |
 | **[Companion](docs/companion.md)** | 파싱, 도움말, 타입을 위한 플로팅 창 companion |
 
 
 ### ⚙️ 설정 & 레퍼런스
 ### ⚙️ 설정 & 레퍼런스

+ 1 - 0
README.md

@@ -681,6 +681,7 @@ Use this section as a map: start with installation, then jump to features, confi
 | **[Clonedeps](docs/clonedeps.md)** | Clone selected dependency source into an ignored local workspace for inspection |
 | **[Clonedeps](docs/clonedeps.md)** | Clone selected dependency source into an ignored local workspace for inspection |
 | **[Worktrees](docs/worktrees.md)** | Use `.slim/worktrees/` lanes for isolated parallel or risky coding work |
 | **[Worktrees](docs/worktrees.md)** | Use `.slim/worktrees/` lanes for isolated parallel or risky coding work |
 | **[Preset Switching](docs/preset-switching.md)** | Switch agent model presets at runtime with `/preset` |
 | **[Preset Switching](docs/preset-switching.md)** | Switch agent model presets at runtime with `/preset` |
+| **[Interview](docs/interview.md)** | Turn rough ideas into a structured markdown spec through a browser-based Q&A flow |
 | **[Companion](docs/companion.md)** | Floating window companion for parsing, help, and types |
 | **[Companion](docs/companion.md)** | Floating window companion for parsing, help, and types |
 
 
 ### ⚙️ Config & Reference
 ### ⚙️ Config & Reference

+ 1 - 0
README.zh-CN.md

@@ -604,6 +604,7 @@ bunx oh-my-opencode-slim@latest install --companion=yes
 | **[Clonedeps](docs/clonedeps.md)** | 将选定的依赖源码克隆到被忽略的本地工作区中以供检查 |
 | **[Clonedeps](docs/clonedeps.md)** | 将选定的依赖源码克隆到被忽略的本地工作区中以供检查 |
 | **[Worktrees](docs/worktrees.md)** | 使用 `.slim/worktrees/` 通道进行隔离的并行或高风险编码工作 |
 | **[Worktrees](docs/worktrees.md)** | 使用 `.slim/worktrees/` 通道进行隔离的并行或高风险编码工作 |
 | **[预设切换](docs/preset-switching.md)** | 使用 `/preset` 在运行时切换智能体模型预设 |
 | **[预设切换](docs/preset-switching.md)** | 使用 `/preset` 在运行时切换智能体模型预设 |
+| **[Interview](docs/interview.md)** | 通过基于浏览器的问答流程,将粗略想法转成结构化 markdown 规格 |
 | **[Companion](docs/companion.md)** | 用于解析、帮助和类型信息的浮动窗口 companion |
 | **[Companion](docs/companion.md)** | 用于解析、帮助和类型信息的浮动窗口 companion |
 
 
 ### ⚙️ 配置与参考
 ### ⚙️ 配置与参考

+ 4 - 2
codemap.md

@@ -18,7 +18,7 @@ This codemap covers the plugin repository itself and excludes the nested `openco
 | Path | Role |
 | Path | Role |
 |---|---|
 |---|---|
 | `package.json` | Package manifest, dependency graph, release scripts, published file list. |
 | `package.json` | Package manifest, dependency graph, release scripts, published file list. |
-| `src/index.ts` | Main plugin bootstrap: wires agents, tools, MCPs, hooks, council managers, shared background job board, multiplexer session mirroring, preset managers, task-session tracking, and config merge behavior. |
+| `src/index.ts` | Main plugin bootstrap: wires agents, tools, MCPs, hooks, council managers, shared background job board, multiplexer session mirroring, interview/preset managers, task-session tracking, and config merge behavior. |
 | `src/cli/index.ts` | CLI entrypoint for installation/bootstrap workflows. |
 | `src/cli/index.ts` | CLI entrypoint for installation/bootstrap workflows. |
 | `src/config/schema.ts` | Source-of-truth runtime config schema used by validation and schema generation. |
 | `src/config/schema.ts` | Source-of-truth runtime config schema used by validation and schema generation. |
 | `src/config/runtime.ts` | Per-directory `RuntimeConfig` singleton: derived getters over the frozen plugin config, host-config capture, and runtime preset state. |
 | `src/config/runtime.ts` | Per-directory `RuntimeConfig` singleton: derived getters over the frozen plugin config, host-config capture, and runtime preset state. |
@@ -42,6 +42,7 @@ This codemap covers the plugin repository itself and excludes the nested `openco
 | `src/hooks/phase-reminder/` | Message-transform reminder enforcing orchestrator workflow phases. | [View Map](src/hooks/phase-reminder/codemap.md) |
 | `src/hooks/phase-reminder/` | Message-transform reminder enforcing orchestrator workflow phases. | [View Map](src/hooks/phase-reminder/codemap.md) |
 | `src/hooks/post-file-tool-nudge/` | Post-read/write reminder path that nudges delegation-aware next steps. | [View Map](src/hooks/post-file-tool-nudge/codemap.md) |
 | `src/hooks/post-file-tool-nudge/` | Post-read/write reminder path that nudges delegation-aware next steps. | [View Map](src/hooks/post-file-tool-nudge/codemap.md) |
 | `src/hooks/task-session-manager/` | Resumable `task` session tracking, short alias resolution, prompt injection, stale-session cleanup, and terminal task reconciliation. | [View Map](src/hooks/task-session-manager/codemap.md) |
 | `src/hooks/task-session-manager/` | Resumable `task` session tracking, short alias resolution, prompt injection, stale-session cleanup, and terminal task reconciliation. | [View Map](src/hooks/task-session-manager/codemap.md) |
+| `src/interview/` | `/interview` feature: per-session and dashboard prompt/state orchestration, persistence, local UI, and cross-process coordination. | [View Map](src/interview/codemap.md) |
 | `src/mcp/` | Built-in MCP registry and per-provider MCP definitions. | [View Map](src/mcp/codemap.md) |
 | `src/mcp/` | Built-in MCP registry and per-provider MCP definitions. | [View Map](src/mcp/codemap.md) |
 | `src/multiplexer/` | Terminal multiplexer abstraction layer with backend selection, session mirroring, polling fallback, and shutdown lifecycle orchestration. | [View Map](src/multiplexer/codemap.md) |
 | `src/multiplexer/` | Terminal multiplexer abstraction layer with backend selection, session mirroring, polling fallback, and shutdown lifecycle orchestration. | [View Map](src/multiplexer/codemap.md) |
 | `src/multiplexer/tmux/` | tmux backend implementation for pane lifecycle and layout management. | [View Map](src/multiplexer/tmux/codemap.md) |
 | `src/multiplexer/tmux/` | tmux backend implementation for pane lifecycle and layout management. | [View Map](src/multiplexer/tmux/codemap.md) |
@@ -67,7 +68,7 @@ This codemap covers the plugin repository itself and excludes the nested `openco
    - Agent definitions are produced by `src/agents/`.
    - Agent definitions are produced by `src/agents/`.
    - Tool factories from `src/tools/` and MCP definitions from `src/mcp/` are registered.
    - Tool factories from `src/tools/` and MCP definitions from `src/mcp/` are registered.
    - Hooks from `src/hooks/` are attached.
    - Hooks from `src/hooks/` are attached.
-   - Delegation/council orchestration, multiplexer session mirroring, task-session aliasing, and runtime preset handling are initialized.
+   - Delegation/council orchestration, multiplexer session mirroring, interview support, task-session aliasing, and runtime preset handling are initialized.
 
 
 2. **Interactive request handling**
 2. **Interactive request handling**
    - The orchestrator prompt drives routing decisions.
    - The orchestrator prompt drives routing decisions.
@@ -99,6 +100,7 @@ This codemap covers the plugin repository itself and excludes the nested `openco
 - `src/tools/preset-manager.ts` hooks command execution and updates runtime agent models from configured presets.
 - `src/tools/preset-manager.ts` hooks command execution and updates runtime agent models from configured presets.
 - `src/hooks/task-session-manager/` depends on `src/utils/background-job-board.ts` and `src/utils/task.ts` to support background task tracking, task output parsing, terminal task reconciliation, and safe alias reuse.
 - `src/hooks/task-session-manager/` depends on `src/utils/background-job-board.ts` and `src/utils/task.ts` to support background task tracking, task output parsing, terminal task reconciliation, and safe alias reuse.
 - `src/hooks/filter-available-skills/` and agent permission logic rely on shared skill names from the CLI/config layer.
 - `src/hooks/filter-available-skills/` and agent permission logic rely on shared skill names from the CLI/config layer.
+- `src/interview/` hooks into plugin command/event surfaces exposed by `src/index.ts`.
 
 
 ## Root Assets
 ## Root Assets
 
 

+ 148 - 0
docs/agents/build-agent-empty-input-diagnosis.md

@@ -0,0 +1,148 @@
+# Diagnosis: "build agent empty input" after orchestrator output
+
+**Status:** Resolved for interview injections.
+**Date:** 2026-08-10
+**Related PR:** #818 (`fix/preset-tui-slash-command`) — same root class as the original `/preset` fix.
+**Suspected sibling bug reported by user:** During `superpowers` / `brainstorm` skill conversations, when the orchestrator asks for confirmation or work is interrupted (subagent completes, background task finishes), a `build` agent turn sometimes appears with an empty user input.
+
+## TL;DR
+
+The `build` agent turn with empty input is **the same class of bug** as the original `/preset` issue fixed in #818: a plugin hook calls `sessionSdk.promptAsync({ body: { parts: [createInternalAgentTextPart(...)] } })` **without specifying an `agent` field**. opencode then resolves the agent via `agents.defaultInfo()`, which falls back to the built-in `build` agent whenever `default_agent` is unset, user-overridden, or not effectively applied. The `synthetic: true` flag hides the injected text from the TUI, so the user perceives the `build` turn as having "empty input."
+
+**Update:** the incomplete-todo continuation path passes `agent: 'orchestrator'`,
+and interview injections now use the interview-only runtime boundary, which
+always targets the orchestrator. Smartfetch remains a separate call site.
+
+## Root cause (causal chain, cross-validated)
+
+1. **Orchestrator enters input-wait.** After emitting a confirmation question (skill flow), the assistant turn finishes. opencode's per-session Runner transitions to `Idle` (`packages/opencode/src/effect/runner.ts:115-138`, `packages/opencode/src/session/run-state.ts:60-63`). The session is no longer "busy" from the Runner's perspective.
+
+2. **Plugin hook fires `promptAsync` with a synthetic part and historically no `agent` field.** The interview service now delegates these calls through `InterviewSessionRuntime`, whose v1 implementation supplies `agent: 'orchestrator'`; the v2 bridge uses the v2 orchestrator session API directly.
+   - Continuation nudge (`continuation-evaluator.ts`) sets `agent: 'orchestrator'` and only runs when `continueOnIdle` is enabled.
+
+3. **opencode does not guard `promptAsync` against busy/input-wait state.** The HTTP handler at `packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:311-329` does not call `assertNotBusy` and does not consult the Question service. It proceeds straight to `promptSvc.prompt`. The Runner, being `Idle`, immediately `startRun`s the new turn (`runner.ts:131-134`). No queue, no reject, no cancellation of the pending question.
+
+4. **Agent resolves to `build`.** `packages/opencode/src/session/prompt.ts:636-637`:
+   ```ts
+   const agentName = input.agent
+   const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo()
+   ```
+   When `input.agent` is omitted, opencode uses `agents.defaultInfo()` (`packages/opencode/src/agent/agent.ts:328-340`), which returns the first visible `mode: "primary"` agent — **`build`** (declared first in the agent registry, `agent.ts:141-155`). omos attempts to set `default_agent = "orchestrator"` via its `config` hook (`src/index.ts:546-551`), but only when `default_agent` is absent. `build` is selected whenever:
+   - `config.setDefaultAgent === false` (plugin config disables it)
+   - The user's `opencode.json` already sets a different `default_agent`
+   - The `config` hook didn't run or didn't apply (SDK/runtime version skew: plugin built against `@opencode-ai/sdk` v1.4.3, installed runtime v1.18.3)
+   - The orchestrator agent isn't registered at config-load time
+
+5. **"Empty input" is the synthetic flag's visual effect.** `synthetic: true` only controls TUI visibility. `packages/opencode/src/session/message-v2.ts:206` still includes synthetic text parts in model messages (the only filters are `!part.ignored` and `part.text !== ""`; there is no `synthetic` filter when building model messages). The user sees the `build` agent respond to a turn with no visible user message — perceived as "empty input." (`createInternalAgentTextPart` appends a `\n<!-- SLIM_INTERNAL_INITIATOR -->` marker, so the text is non-empty from the LLM's perspective.)
+
+6. **Session agent is durably corrupted.** `packages/opencode/src/session/prompt.ts:672-689` compares `current.agent !== info.agent` and, if they differ, calls `sessions.setAgentModel({ agent: info.agent, ... })`. A single agent-less `promptAsync` that resolves to `build` **permanently rewrites the session's agent to `build`** — every subsequent turn also routes to `build` until explicitly reset.
+
+## Confirmed-affected call sites
+
+| File:line | Trigger | Body omits `agent`? | Gate |
+|---|---|---|---|
+| `src/hooks/task-session-manager/continuation-evaluator.ts` (`promptAsync`) | `session.idle` / `session.status(idle)` on orchestrator session with incomplete todos when the opt-in beta `backgroundJobs.continueOnIdle` is `true` | **No** (`agent: 'orchestrator'`) | `continueOnIdle`, process-local one-attempt gate (reserve→commit), `hasInputWait`, `isCurrentContinuation`, `isFallbackInProgress`, `backgroundJobBoard.hasTerminalUnreconciled`, malformed/active SDK short-circuits |
+| `src/interview/service.ts` | User submits interview input | **No** — uses `InterviewSessionRuntime.continue()` | `sessionBusy` lock, interview active state |
+| `src/interview/service.ts` | Interview URL notification | **No routing ambiguity** — uses `InterviewSessionRuntime.notify()` | none |
+| `src/tools/smartfetch/secondary-model.ts:252` | Smartfetch secondary model query | **Yes** | none |
+
+## Correct pattern (for comparison)
+
+`src/hooks/foreground-fallback/index.ts:635-639` explicitly includes the agent:
+```ts
+const promptBody = {
+  parts: lastUser.parts,
+  model: ref,
+  ...(agentName ? { agent: agentName } : {}),
+};
+```
+This is the pattern every `promptAsync` caller in omos should follow.
+
+## Why the `hasInputWait` gate in task-session-manager is not sufficient
+
+The gate exists and works in the common case (see `continuation-evaluator.ts` and
+`task-session-manager/index.test.ts` continuation cases). Notes:
+
+1. **Continuation is opt-in beta.** `backgroundJobs.continueOnIdle` defaults
+   to `false`; set it to `true` to enable continuation SDK calls. Idle
+   reconciliation remains active either way. When enabled, a process-local
+   reserve/commit gate allows at most one `promptAsync` per session epoch
+   between real user messages.
+
+2. **Documented race window (when enabled).** `IDLE_RECONCILE_DELAY_MS = 2_000`.
+   The idle-reconciliation comment admits late completions can still race the
+   window. If `session.idle` fires and the timer expires before
+   `question.asked` is delivered, and the SDK liveness reads resolve before the
+   wait is tracked, a nudge can still fire.
+
+3. **Input-wait is not the only trigger.** The interview/skill path
+   (`src/interview/service.ts`) does **not** consult `hasInputWait` at all — it
+   injects on user dashboard actions, which can happen while the orchestrator is
+   mid-question. Its continuation routing is nevertheless explicit now.
+
+4. **Continuation path sets `agent: 'orchestrator'`.** The historical missing-
+   `agent` misroute on the continuation nudge is fixed in
+   `continuation-evaluator.ts`; interview uses its runtime boundary as well.
+   Smartfetch remains outside this interview fix.
+
+## Why this is the same class as the #818 `/preset` fix
+
+#818's original bug: `/preset` used `createInternalAgentTextPart()` to trigger an LLM turn that was invisible in the TUI (`synthetic: true`). The fix moved `/preset` to pure TUI dialogs (`src/tui-preset.ts` uses only `api.ui.dialog` / `DialogSelect` / `DialogPrompt` / `DialogConfirm` — no `promptAsync`).
+
+The historical bug used the same `createInternalAgentTextPart` + `promptAsync`
+pattern and omitted `agent`, so the invisible turn could route to `build` instead
+of the orchestrator. The interview path no longer makes those raw calls.
+
+## Fix implemented
+
+The interview service no longer owns raw session prompt calls. Its narrow
+`InterviewSessionRuntime` boundary routes all continuation prompts to the
+orchestrator. v1 uses the nested SDK client; v2 uses a marker/context bridge and
+the v2 session methods without expanding the global client shim.
+
+### Hardening (optional, larger scope)
+1. **Input-wait guard on the interview/skill path.** Consult `hasInputWait` (or an equivalent signal) before injecting in `src/interview/service.ts`. Do not inject while the orchestrator is waiting for user input.
+2. **Post-injection agent assertion.** After each `promptAsync`, assert `current.agent` was not changed out from under the orchestrator; if it was, restore it via `setAgentModel`.
+3. **Investigate the `default_agent` application reliability** on opencode v1.18.x. The plugin was built against `@opencode-ai/sdk` v1.4.3; the installed runtime is v1.18.3. The `config` hook's `default_agent = 'orchestrator'` mutation may not be applied reliably under this skew. (Note: #799 tracks the package upgrade.)
+4. **Shrink or eliminate the `IDLE_RECONCILE_DELAY_MS` race** for sessions that have a pending `question.asked` / `permission.asked`.
+
+## Evidence index
+
+### omos source
+- **Continuation nudge (fixed agent + opt-in beta + one-attempt gate):** `src/hooks/task-session-manager/continuation-evaluator.ts`, `continuation-attempt-gate.ts`, `backgroundJobs.continueOnIdle` in `src/config/schema.ts`
+- **Missing `agent` field (skill flow):** `src/interview/service.ts:622, 871, 933, 1007`
+- **Correct pattern for comparison:** `src/hooks/foreground-fallback/index.ts:635-639`
+- **omos sets `default_agent` only when absent:** `src/index.ts:546-551`
+- **`createInternalAgentTextPart` produces `synthetic: true`:** `src/utils/internal-initiator.ts:9-21`
+- **`CONTINUATION_NUDGE` is non-empty:** `src/hooks/task-session-manager/continuation-evaluator.ts`
+- **`hasInputWait` / continuation gates:** `continuation-evaluator.ts`, `input-wait-tracker.ts`
+- **`IDLE_RECONCILE_DELAY_MS` race window:** `src/hooks/task-session-manager/index.ts` (`IDLE_RECONCILE_DELAY_MS`)
+- **`disableDefaultAgents` preserves `build` and `plan`:** `src/cli/config-io.ts:564-600`
+
+### opencode source (`anomalyco/opencode` @ `dev`)
+- **`promptAsync` HTTP handler (no busy/input-wait guard):** `packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:311-329`
+- **`prompt` HTTP handler (no guard):** same file, `:295-309`
+- **Internal `prompt` always starts a new turn:** `packages/opencode/src/session/prompt.ts:1052-1071`
+- **Agent selection: `input.agent ?? defaultInfo()`:** `packages/opencode/src/session/prompt.ts:636-637`
+- **`defaultInfo()` → `default_agent` or first visible primary:** `packages/opencode/src/agent/agent.ts:328-340`
+- **`build` agent definition (default primary, first in registry):** `packages/opencode/src/agent/agent.ts:141-155`
+- **Destructive `setAgentModel` overwrite on agent change:** `packages/opencode/src/session/prompt.ts:672-689`
+- **Synthetic parts included in model messages:** `packages/opencode/src/session/message-v2.ts:206`
+- **Runner `ensureRunning` (Idle = run now, no queue):** `packages/opencode/src/effect/runner.ts:115-138`
+- **Runner Idle transition on turn end:** `packages/opencode/src/session/run-state.ts:60-63`
+- **`assertNotBusy` (NOT used by prompt/promptAsync):** `packages/opencode/src/session/run-state.ts:71-75`
+
+### SDK types
+- **`default_agent` doc: "Falls back to 'build' if not set or invalid":** `node_modules/@opencode-ai/sdk/dist/v2/gen/types.gen.d.ts:1255-1257`
+- **`SessionPromptAsyncData.body.agent?` is optional:** `node_modules/@opencode-ai/sdk/dist/v2/gen/types.gen.d.ts:3241-3269`
+- **`build` is a built-in agent:** `node_modules/@opencode-ai/sdk/dist/v2/gen/types.gen.d.ts:1273-1279`
+
+## Follow-up
+
+1. Smartfetch still has its own secondary-model prompt path and is outside the interview runtime boundary.
+2. Continue keeping cache-sensitive v2 interview rewriting restricted to the trailing marker message.
+
+---
+
+Regression coverage lives in `src/interview/runtime.test.ts`,
+`src/interview/finalization.test.ts`, and `src/v2/interview-bridge.test.ts`.

+ 5 - 0
docs/configuration.md

@@ -168,6 +168,11 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `council.presets.<name>.<councillor>.prompt` | string | - | Optional role guidance for the councillor See [Council configuration note](#council-configuration-note). |
 | `council.presets.<name>.<councillor>.prompt` | string | - | Optional role guidance for the councillor See [Council configuration note](#council-configuration-note). |
 | `council.default_preset` | string | `"default"` | Default preset when none is specified See [Council configuration note](#council-configuration-note). |
 | `council.default_preset` | string | `"default"` | Default preset when none is specified See [Council configuration note](#council-configuration-note). |
 | — | — | — | *Timeouts, execution mode, and retries are now handled by the orchestrator's council-mode prompt instructions; see `src/agents/council.ts`.* |
 | — | — | — | *Timeouts, execution mode, and retries are now handled by the orchestrator's council-mode prompt instructions; see `src/agents/council.ts`.* |
+| `interview.maxQuestions` | integer | `2` | Max questions per interview round (1–10) See [Interview configuration](interview.md). |
+| `interview.outputFolder` | string | `"interview"` | Directory where interview markdown files are written relative to the project root; absolute paths and `..` traversal are rejected See [Interview configuration](interview.md). |
+| `interview.autoOpenBrowser` | boolean | `true` | Automatically open the interview UI in your default browser during interactive runs; suppressed in tests and CI See [Interview configuration](interview.md). |
+| `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) See [Interview configuration](interview.md). |
+| `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 See [Interview configuration](interview.md). |
 | `companion.enabled` | boolean | `false` | Enable/disable the floating window Rust companion See [Desktop Companion App](#desktop-companion-app). |
 | `companion.enabled` | boolean | `false` | Enable/disable the floating window Rust companion See [Desktop Companion App](#desktop-companion-app). |
 | `companion.binaryPath` | string | - | Optional path to a custom companion binary to launch instead of the default install path See [Desktop Companion App](#desktop-companion-app). |
 | `companion.binaryPath` | string | - | Optional path to a custom companion binary to launch instead of the default install path See [Desktop Companion App](#desktop-companion-app). |
 | `companion.position` | string | `"bottom-right"` | The initial corner position of the companion window: `bottom-right`, `bottom-left`, `top-right`, or `top-left` See [Desktop Companion App](#desktop-companion-app). |
 | `companion.position` | string | `"bottom-right"` | The initial corner position of the companion window: `bottom-right`, `bottom-left`, `top-right`, or `top-left` See [Desktop Companion App](#desktop-companion-app). |

+ 320 - 0
docs/interview.md

@@ -0,0 +1,320 @@
+# Interview
+
+`/interview` opens a local browser UI for refining a feature idea inside the same OpenCode session.
+
+Use it when chat feels too loose and you want a cleaner question/answer flow plus a markdown spec saved in your repo.
+
+> Tip: `/interview` usually works well with a fast model. If the flow feels slower than it should, switch models in OpenCode with `Ctrl+X`, then `m`, and pick a faster one.
+
+## Quick start
+
+Start a new interview:
+
+```text
+/interview build a kanban app for design teams
+```
+
+What happens:
+
+1. OpenCode starts the interview in your current session
+2. a localhost page opens in your browser by default
+3. the UI shows the current questions and suggested answers
+4. answers are submitted back into the same session
+5. a markdown spec is updated in your repo
+
+OpenCode posts a localhost URL like this:
+
+![Interview URL](../img/interview-url.png)
+
+And the browser UI looks like this:
+
+![Interview website](../img/interview-website.png)
+
+Resume an existing interview:
+
+```text
+/interview interview/kanban-design-tool.md
+```
+
+You can also resume by basename if it exists in the configured output folder:
+
+```text
+/interview kanban-design-tool
+```
+
+## What the browser UI gives you
+
+- focused question flow instead of open-ended chat
+- suggested answers, clearly marked as recommended
+- keyboard-driven selection for the active question
+- custom freeform answers when needed
+- visible path to the markdown interview file
+- larger, more readable interview UI
+
+## Markdown output
+
+By default, interview files are written to:
+
+```text
+interview/
+```
+
+Example:
+
+```text
+interview/kanban-design-tool.md
+```
+
+The file contains three sections:
+
+- `Frontmatter` - session meta data for recovery
+- `Current spec` - 11-section structured specification doc (Introduction, Purpose, Requirements, Data Contracts, Acceptance Criteria, etc.)
+- `Q&A history` - append-only question/answer record
+
+Example:
+
+```md
+# Kanban App For Design Teams
+
+## Current spec
+
+# Introduction
+...
+
+## 1. Purpose & Scope
+...
+
+## Q&A history
+
+Q: Who is this for?
+A: Design teams
+
+Q: Is this web only or mobile too?
+A: Web first
+```
+
+### How filenames are chosen
+
+For new interviews, the assistant can suggest a concise title for the markdown filename.
+
+Example:
+
+- user input: `build a kanban app for design teams with lightweight reviews`
+- file: `interview/kanban-design-tool.md`
+
+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:
+
+- `1`, `2`, `3`, ... select options for the active question
+- the last number selects `Custom`
+- `↑` / `↓` move the active question
+- `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.
+
+Browser submissions use a claim/ack delivery protocol. A session claims an
+answer, chat message, block comment, or nudge before forwarding it to
+OpenCode; successful forwarding acknowledges the claim, while a rejected
+continuation rolls it back for a later retry. A failed poll therefore never
+silently drops a queued action.
+
+#### 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
+{
+  "oh-my-opencode-slim": {
+    "interview": {
+      "maxQuestions": 2,
+      "outputFolder": "interview",
+      "autoOpenBrowser": true,
+      "port": 0,
+      "dashboard": false
+    }
+  }
+}
+```
+
+### Options
+
+- `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 during interactive runs, default `true` (suppressed automatically in tests and CI)
+- `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 |
+
+The v2 bridge receives the resolved values for all five interview options, so
+`maxQuestions`, `outputFolder`, `autoOpenBrowser`, `port`, and `dashboard` are
+also honored when OpenCode loads the v2 plugin entry point.
+
+## Remote access
+
+The interview UI binds to `127.0.0.1`. To access it from a remote machine:
+
+### Tailscale Serve
+
+```text
+tailscale serve --bg --https=443 http://127.0.0.1:<port>
+```
+
+### Cloudflare Tunnel
+
+```text
+cloudflared tunnel --url http://127.0.0.1:<port>
+```
+
+### SSH tunnel
+
+```text
+ssh -L <port>:127.0.0.1:<port> your-server
+```
+
+## Good use cases
+
+- feature planning
+- requirement clarification before implementation
+- turning a rough idea into a spec the agent can build from
+- keeping a lightweight product brief in the repo while iterating
+
+## Current limitations
+
+- localhost UI only
+- per-session browser updates use polling; dashboard pages also receive SSE
+  state pushes with polling fallback
+- 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
+
+- [README.md](../README.md)
+- [tools.md](tools.md)
+- [configuration.md](configuration.md)
+
+## OpenCode v2
+
+On OpenCode v2, `/interview` uses an orchestrator-owned marker command and a
+context bridge. Only the trailing marker message is rewritten, preserving the
+provider cache prefix; streamed text events build the interview transcript in
+memory. The bridge uses the v2 session methods for notifications, continuation,
+and renaming without expanding the global client shim.
+
+Selecting **Complete** asks for clean final markdown. The next clean assistant
+response replaces only `Current spec`; frontmatter and append-only `Q&A
+history` are retained, so stale `<interview_state>` output cannot overwrite the
+final document.
+
+In dashboard mode, session clients register at `/api/register` and unregister
+at `/api/unregister` during cleanup. Browser submissions return HTTP `202` with
+`{ "status": "queued" }`; the owning session processes them when idle.

+ 9 - 0
docs/opencode-v2-compatibility.md

@@ -148,6 +148,15 @@ GitHub-wide code search; without them it still works via `webfetch`.
 
 
 ## Limitations
 ## Limitations
 
 
+### Interview
+
+`/interview` is supported on v2 through a marker command and a trailing-message
+context bridge. The bridge keeps an in-memory transcript projection from v2
+context and streamed text events, and uses the v2 session methods for prompts,
+notifications, and renames. The markdown document remains the durable source
+of truth; completion responses without `<interview_state>` rewrite the current
+spec while retaining frontmatter and Q&A history.
+
 These are **v2 API constraints**, not adapter gaps — they cannot be fixed in the
 These are **v2 API constraints**, not adapter gaps — they cannot be fixed in the
 plugin without v2 adding the corresponding capability:
 plugin without v2 adding the corresponding capability:
 
 

+ 1 - 0
docs/quick-reference.md

@@ -14,6 +14,7 @@
 |-----|----------|
 |-----|----------|
 | [Council Agent](council.md) | Multi-LLM consensus, presets, role prompts, timeouts |
 | [Council Agent](council.md) | Multi-LLM consensus, presets, role prompts, timeouts |
 | [Background Orchestration](background-orchestration.md) | Default scheduler-first orchestrator model for native background subagents |
 | [Background Orchestration](background-orchestration.md) | Default scheduler-first orchestrator model for native background subagents |
+| [Interview](interview.md) | `/interview` command, browser UI, dashboard mode, multi-session coordination |
 | [Multiplexer Integration](multiplexer-integration.md) | Real-time pane monitoring, layouts, troubleshooting |
 | [Multiplexer Integration](multiplexer-integration.md) | Real-time pane monitoring, layouts, troubleshooting |
 | [Preset Switching](preset-switching.md) | `/preset` command for runtime agent model switching |
 | [Preset Switching](preset-switching.md) | `/preset` command for runtime agent model switching |
 | [Codemap Skill](codemap.md) | Hierarchical codemap generation |
 | [Codemap Skill](codemap.md) | Hierarchical codemap generation |

BIN
img/interview-url.png


BIN
img/interview-website.png


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

@@ -981,6 +981,38 @@
         }
         }
       }
       }
     },
     },
+    "interview": {
+      "type": "object",
+      "properties": {
+        "maxQuestions": {
+          "default": 2,
+          "type": "integer",
+          "minimum": 1,
+          "maximum": 10
+        },
+        "outputFolder": {
+          "default": "interview",
+          "type": "string",
+          "minLength": 1,
+          "pattern": "^(?![\\\\/])(?![A-Za-z]:[\\\\/])(?!.*(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$)).+$"
+        },
+        "autoOpenBrowser": {
+          "default": true,
+          "description": "Automatically open the interview UI in your default browser during interactive runs. Disabled automatically in tests and CI.",
+          "type": "boolean"
+        },
+        "port": {
+          "default": 0,
+          "type": "integer",
+          "minimum": 0,
+          "maximum": 65535
+        },
+        "dashboard": {
+          "default": false,
+          "type": "boolean"
+        }
+      }
+    },
     "backgroundJobs": {
     "backgroundJobs": {
       "type": "object",
       "type": "object",
       "properties": {
       "properties": {

+ 1 - 1
src/codemap.md

@@ -102,7 +102,7 @@ Key event flows:
    - `experimental.chat.messages.transform` → phase reminders, skill filtering, image attachment processing
    - `experimental.chat.messages.transform` → phase reminders, skill filtering, image attachment processing
 
 
 5. **Command Execution**:
 5. **Command Execution**:
-   - `command.execute.before` → deepwork, reflect, and loop command hooks
+   - `command.execute.before` → interview, preset, deepwork, and reflect command hooks
 
 
 ## Integration
 ## Integration
 
 

+ 1 - 0
src/config/codemap.md

@@ -159,6 +159,7 @@ This allows consumers to import directly from `src/config` rather than individua
 - `disabled_skills`: List of skills to disable
 - `disabled_skills`: List of skills to disable
 - `multiplexer`: Unified pane management config (type, layout, sizes)
 - `multiplexer`: Unified pane management config (type, layout, sizes)
 - `tmux`: Legacy tmux configuration (migrated to multiplexer)
 - `tmux`: Legacy tmux configuration (migrated to multiplexer)
+- `interview`: Interview feature configuration
 - `backgroundJobs`: Background job configuration
 - `backgroundJobs`: Background job configuration
 - `fallback`: Failover/retry configuration
 - `fallback`: Failover/retry configuration
 - `council`: Council configuration with presets and execution modes
 - `council`: Council configuration with presets and execution modes

+ 34 - 0
src/config/loader.test.ts

@@ -93,6 +93,40 @@ describe('loadPluginConfig', () => {
     });
     });
   });
   });
 
 
+  test('retains user interview settings when project partially overrides them', () => {
+    const userConfigPath = path.join(userConfigDir, 'opencode');
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(userConfigPath, { recursive: true });
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(userConfigPath, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        interview: {
+          maxQuestions: 7,
+          outputFolder: 'user-interviews',
+          autoOpenBrowser: false,
+          port: 1234,
+          dashboard: true,
+        },
+      }),
+    );
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({ interview: { outputFolder: 'project-interviews' } }),
+    );
+
+    const config = loadPluginConfig(projectDir, { silent: true });
+
+    expect(config.interview).toEqual({
+      maxQuestions: 7,
+      outputFolder: 'project-interviews',
+      autoOpenBrowser: false,
+      port: 1234,
+      dashboard: true,
+    });
+  });
+
   test('does not let a defaulted project webfetch enabled override user false', () => {
   test('does not let a defaulted project webfetch enabled override user false', () => {
     const userConfigPath = path.join(userConfigDir, 'opencode');
     const userConfigPath = path.join(userConfigDir, 'opencode');
     const projectDir = path.join(tempDir, 'project');
     const projectDir = path.join(tempDir, 'project');

+ 59 - 4
src/config/loader.ts

@@ -4,6 +4,7 @@ import { stripJsonComments } from '../cli/config-io';
 import { getConfigSearchDirs } from '../cli/paths';
 import { getConfigSearchDirs } from '../cli/paths';
 import { DEFAULT_DISABLED_AGENTS } from './constants';
 import { DEFAULT_DISABLED_AGENTS } from './constants';
 import {
 import {
+  InterviewConfigSchema,
   LEGACY_FALLBACK_KEYS,
   LEGACY_FALLBACK_KEYS,
   type PluginConfig,
   type PluginConfig,
   PluginConfigSchema,
   PluginConfigSchema,
@@ -47,6 +48,51 @@ export interface LoadPluginConfigOptions {
 }
 }
 
 
 const PROMPTS_DIR_NAME = 'oh-my-opencode-slim';
 const PROMPTS_DIR_NAME = 'oh-my-opencode-slim';
+const INTERVIEW_CONFIG_KEYS = [
+  'maxQuestions',
+  'outputFolder',
+  'autoOpenBrowser',
+  'port',
+  'dashboard',
+] as const;
+
+function retainExplicitInterviewFields(
+  parsedConfig: PluginConfig,
+  rawConfig: unknown,
+): PluginConfig {
+  if (!parsedConfig.interview) {
+    return parsedConfig;
+  }
+
+  const rawInterview =
+    typeof rawConfig === 'object' &&
+    rawConfig !== null &&
+    !Array.isArray(rawConfig) &&
+    typeof (rawConfig as Record<string, unknown>).interview === 'object' &&
+    (rawConfig as Record<string, unknown>).interview !== null &&
+    !Array.isArray((rawConfig as Record<string, unknown>).interview)
+      ? ((rawConfig as Record<string, unknown>).interview as Record<
+          string,
+          unknown
+        >)
+      : undefined;
+
+  if (!rawInterview) {
+    return { ...parsedConfig, interview: undefined };
+  }
+
+  const interview: Record<string, unknown> = {};
+  for (const key of INTERVIEW_CONFIG_KEYS) {
+    if (Object.hasOwn(rawInterview, key)) {
+      interview[key] = parsedConfig.interview[key];
+    }
+  }
+
+  return {
+    ...parsedConfig,
+    interview: interview as PluginConfig['interview'],
+  };
+}
 
 
 /**
 /**
  * Load and validate plugin configuration from a specific file path.
  * Load and validate plugin configuration from a specific file path.
@@ -172,11 +218,16 @@ function loadConfigFromPath(
       return null;
       return null;
     }
     }
 
 
+    // Zod applies nested defaults while parsing each layer. Keep interview
+    // defaults from masquerading as explicitly configured overrides; the
+    // merged interview config is normalized after all layers are merged.
+    const layerConfig = retainExplicitInterviewFields(result.data, rawConfig);
+
     // Zod applies webfetch.enabled's default while parsing each layer. Keep
     // Zod applies webfetch.enabled's default while parsing each layer. Keep
     // that default from masquerading as an explicitly configured override;
     // that default from masquerading as an explicitly configured override;
     // the merged webfetch config is normalized after all layers are merged.
     // the merged webfetch config is normalized after all layers are merged.
     if (
     if (
-      result.data.webfetch &&
+      layerConfig.webfetch &&
       typeof rawConfig === 'object' &&
       typeof rawConfig === 'object' &&
       rawConfig !== null &&
       rawConfig !== null &&
       'webfetch' in rawConfig &&
       'webfetch' in rawConfig &&
@@ -185,14 +236,14 @@ function loadConfigFromPath(
       !Array.isArray(rawConfig.webfetch) &&
       !Array.isArray(rawConfig.webfetch) &&
       !Object.hasOwn(rawConfig.webfetch, 'enabled')
       !Object.hasOwn(rawConfig.webfetch, 'enabled')
     ) {
     ) {
-      const { enabled: _enabled, ...webfetch } = result.data.webfetch;
+      const { enabled: _enabled, ...webfetch } = layerConfig.webfetch;
       return {
       return {
-        ...result.data,
+        ...layerConfig,
         webfetch: webfetch as PluginConfig['webfetch'],
         webfetch: webfetch as PluginConfig['webfetch'],
       };
       };
     }
     }
 
 
-    return result.data;
+    return layerConfig;
   } catch (error) {
   } catch (error) {
     // File doesn't exist or isn't readable - this is expected and fine
     // File doesn't exist or isn't readable - this is expected and fine
     if (
     if (
@@ -330,6 +381,7 @@ export function mergePluginConfigs(
     agents: deepMerge(base.agents, override.agents),
     agents: deepMerge(base.agents, override.agents),
     presets: deepMerge(base.presets, override.presets),
     presets: deepMerge(base.presets, override.presets),
     multiplexer: deepMerge(base.multiplexer, override.multiplexer),
     multiplexer: deepMerge(base.multiplexer, override.multiplexer),
+    interview: deepMerge(base.interview, override.interview),
     backgroundJobs: deepMerge(base.backgroundJobs, override.backgroundJobs),
     backgroundJobs: deepMerge(base.backgroundJobs, override.backgroundJobs),
     fallback: deepMerge(base.fallback, override.fallback),
     fallback: deepMerge(base.fallback, override.fallback),
     council: deepMerge(base.council, override.council),
     council: deepMerge(base.council, override.council),
@@ -421,6 +473,9 @@ export function loadPluginConfig(
   if (config.webfetch) {
   if (config.webfetch) {
     config.webfetch = WebfetchConfigSchema.parse(config.webfetch);
     config.webfetch = WebfetchConfigSchema.parse(config.webfetch);
   }
   }
+  if (config.interview) {
+    config.interview = InterviewConfigSchema.parse(config.interview);
+  }
 
 
   // Override preset from environment variable if set
   // Override preset from environment variable if set
   const envPreset = process.env.OH_MY_OPENCODE_SLIM_PRESET;
   const envPreset = process.env.OH_MY_OPENCODE_SLIM_PRESET;

+ 70 - 1
src/config/schema.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it } from 'bun:test';
 import { describe, expect, it } from 'bun:test';
-import { PluginConfigSchema } from './schema';
+import { InterviewConfigSchema, PluginConfigSchema } from './schema';
 
 
 describe('PluginConfigSchema image_routing', () => {
 describe('PluginConfigSchema image_routing', () => {
   it('accepts image_routing: direct with observer disabled', () => {
   it('accepts image_routing: direct with observer disabled', () => {
@@ -64,6 +64,75 @@ describe('PluginConfigSchema webfetch', () => {
   });
   });
 });
 });
 
 
+describe('InterviewConfigSchema outputFolder', () => {
+  it('accepts relative output folders', () => {
+    expect(
+      InterviewConfigSchema.safeParse({ outputFolder: 'interviews/specs' })
+        .success,
+    ).toBe(true);
+    expect(
+      InterviewConfigSchema.safeParse({
+        outputFolder: String.raw`interviews\specs`,
+      }).success,
+    ).toBe(true);
+  });
+
+  it('rejects absolute and parent-directory output folders', () => {
+    const invalidOutputFolders = [
+      '/tmp/interviews',
+      String.raw`\tmp\interviews`,
+      'C:/tmp/interviews',
+      String.raw`C:\tmp\interviews`,
+      '..',
+      '../interviews',
+      String.raw`..\interviews`,
+      'interviews/../outside',
+      String.raw`interviews\..\outside`,
+    ];
+
+    for (const outputFolder of invalidOutputFolders) {
+      expect(InterviewConfigSchema.safeParse({ outputFolder }).success).toBe(
+        false,
+      );
+      expect(
+        PluginConfigSchema.safeParse({ interview: { outputFolder } }).success,
+      ).toBe(false);
+    }
+  });
+
+  it('rejects whitespace-wrapped parent-directory output folders', () => {
+    const invalidOutputFolders = [' ../outside ', String.raw` ..\outside `];
+
+    for (const outputFolder of invalidOutputFolders) {
+      expect(InterviewConfigSchema.safeParse({ outputFolder }).success).toBe(
+        false,
+      );
+      expect(
+        PluginConfigSchema.safeParse({ interview: { outputFolder } }).success,
+      ).toBe(false);
+    }
+  });
+
+  it('stores the trimmed output folder', () => {
+    const outputFolder = '  interviews/specs  ';
+    const interviewResult = InterviewConfigSchema.safeParse({ outputFolder });
+    const pluginResult = PluginConfigSchema.safeParse({
+      interview: { outputFolder },
+    });
+
+    expect(interviewResult.success).toBe(true);
+    expect(pluginResult.success).toBe(true);
+    if (interviewResult.success) {
+      expect(interviewResult.data.outputFolder).toBe('interviews/specs');
+    }
+    if (pluginResult.success) {
+      expect(pluginResult.data.interview?.outputFolder).toBe(
+        'interviews/specs',
+      );
+    }
+  });
+});
+
 describe('PluginConfigSchema backgroundJobs', () => {
 describe('PluginConfigSchema backgroundJobs', () => {
   it('defaults board injection to the legacy latest strategy', () => {
   it('defaults board injection to the legacy latest strategy', () => {
     const result = PluginConfigSchema.safeParse({ backgroundJobs: {} });
     const result = PluginConfigSchema.safeParse({ backgroundJobs: {} });

+ 25 - 0
src/config/schema.ts

@@ -131,6 +131,30 @@ export type Preset = z.infer<typeof PresetSchema>;
 export const McpNameSchema = z.enum(['context7', 'gh_grep']);
 export const McpNameSchema = z.enum(['context7', 'gh_grep']);
 export type McpName = z.infer<typeof McpNameSchema>;
 export type McpName = z.infer<typeof McpNameSchema>;
 
 
+const InterviewOutputFolderSchema = z
+  .string()
+  .trim()
+  .min(1)
+  .regex(
+    /^(?![\\/])(?![A-Za-z]:[\\/])(?!.*(?:^|[\\/])\.\.(?:[\\/]|$)).+$/,
+    'outputFolder must be a relative path without parent-directory traversal',
+  );
+
+export const InterviewConfigSchema = z.object({
+  maxQuestions: z.number().int().min(1).max(10).default(2),
+  outputFolder: InterviewOutputFolderSchema.default('interview'),
+  autoOpenBrowser: z
+    .boolean()
+    .default(true)
+    .describe(
+      'Automatically open the interview UI in your default browser during interactive runs. Disabled automatically in tests and CI.',
+    ),
+  port: z.number().int().min(0).max(65535).default(0),
+  dashboard: z.boolean().default(false),
+});
+
+export type InterviewConfig = z.infer<typeof InterviewConfigSchema>;
+
 export const BackgroundJobsConfigSchema = z.object({
 export const BackgroundJobsConfigSchema = z.object({
   strategy: z
   strategy: z
     .enum(['latest', 'checkpoint-compatible'])
     .enum(['latest', 'checkpoint-compatible'])
@@ -415,6 +439,7 @@ export const PluginConfigSchema = z
       ),
       ),
     // Multiplexer config
     // Multiplexer config
     multiplexer: MultiplexerConfigSchema.optional(),
     multiplexer: MultiplexerConfigSchema.optional(),
+    interview: InterviewConfigSchema.optional(),
     backgroundJobs: BackgroundJobsConfigSchema.optional(),
     backgroundJobs: BackgroundJobsConfigSchema.optional(),
     fallback: FailoverConfigSchema.optional(),
     fallback: FailoverConfigSchema.optional(),
     council: CouncilConfigSchema.optional(),
     council: CouncilConfigSchema.optional(),

+ 20 - 0
src/index.ts

@@ -33,6 +33,7 @@ import {
 import { processImageAttachments } from './hooks/image-hook';
 import { processImageAttachments } from './hooks/image-hook';
 import { isMessageWithParts, type MessageWithParts } from './hooks/types';
 import { isMessageWithParts, type MessageWithParts } from './hooks/types';
 import { handleTaskSessionEvent } from './index-event';
 import { handleTaskSessionEvent } from './index-event';
+import { createInterviewManager } from './interview';
 import { createBuiltinMcps } from './mcp';
 import { createBuiltinMcps } from './mcp';
 import {
 import {
   getMultiplexer,
   getMultiplexer,
@@ -163,6 +164,7 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let taskSessionManagerAfter: (i: unknown, o: unknown) => Promise<void>;
   let taskSessionManagerAfter: (i: unknown, o: unknown) => Promise<void>;
   let backgroundJobBoard: BackgroundJobBoard;
   let backgroundJobBoard: BackgroundJobBoard;
   let backgroundJobSupervisor: BackgroundJobSupervisor;
   let backgroundJobSupervisor: BackgroundJobSupervisor;
+  let interviewManager: ReturnType<typeof createInterviewManager>;
   let companionManager: CompanionManager;
   let companionManager: CompanionManager;
   let cancelTaskTools: ReturnType<typeof createCancelTaskTool>;
   let cancelTaskTools: ReturnType<typeof createCancelTaskTool>;
   let waitForUserTools: ReturnType<typeof createWaitForUserTool>;
   let waitForUserTools: ReturnType<typeof createWaitForUserTool>;
@@ -414,6 +416,7 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
     taskSessionManagerAfter = wrapPostToolHook('task-session-manager', (i, o) =>
     taskSessionManagerAfter = wrapPostToolHook('task-session-manager', (i, o) =>
       taskSessionManagerHook['tool.execute.after'](i as never, o as never),
       taskSessionManagerHook['tool.execute.after'](i as never, o as never),
     );
     );
+    interviewManager = createInterviewManager(ctx, config);
     companionManager = new CompanionManager(
     companionManager = new CompanionManager(
       `proc_${process.pid}`,
       `proc_${process.pid}`,
       ctx.directory,
       ctx.directory,
@@ -841,6 +844,7 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
         agentConfigEntry.permission = agentPermission;
         agentConfigEntry.permission = agentPermission;
       }
       }
 
 
+      interviewManager.registerCommand(opencodeConfig);
       deepworkCommandHook.registerCommand(opencodeConfig);
       deepworkCommandHook.registerCommand(opencodeConfig);
       reflectCommandHook.registerCommand(opencodeConfig);
       reflectCommandHook.registerCommand(opencodeConfig);
       loopCommandHook.registerCommand(opencodeConfig);
       loopCommandHook.registerCommand(opencodeConfig);
@@ -972,6 +976,12 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
       // Handle auto-update checking
       // Handle auto-update checking
       await autoUpdateChecker.event(input);
       await autoUpdateChecker.event(input);
 
 
+      await interviewManager.handleEvent(
+        input as {
+          event: { type: string; properties?: Record<string, unknown> };
+        },
+      );
+
       if (
       if (
         event.type === 'permission.asked' ||
         event.type === 'permission.asked' ||
         event.type === 'question.asked'
         event.type === 'question.asked'
@@ -1022,6 +1032,7 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
       await orchestratorWakeScheduler.event({
       await orchestratorWakeScheduler.event({
         event: { type: 'server.instance.disposed' },
         event: { type: 'server.instance.disposed' },
       });
       });
+      await interviewManager.dispose();
       await multiplexerSessionManager.cleanupOnInstanceDisposed();
       await multiplexerSessionManager.cleanupOnInstanceDisposed();
     },
     },
 
 
@@ -1034,6 +1045,15 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
     },
     },
 
 
     'command.execute.before': async (input, output) => {
     'command.execute.before': async (input, output) => {
+      await interviewManager.handleCommandExecuteBefore(
+        input as {
+          command: string;
+          sessionID: string;
+          arguments: string;
+        },
+        output as { parts: Array<{ type: string; text?: string }> },
+      );
+
       await deepworkCommandHook.handleCommandExecuteBefore(
       await deepworkCommandHook.handleCommandExecuteBefore(
         input as {
         input as {
           command: string;
           command: string;

+ 152 - 0
src/interview/codemap.md

@@ -0,0 +1,152 @@
+# src/interview/
+
+## Responsibility
+
+- Implement the `/interview` command flow:
+  - command registration and pre-exec interception,
+  - interactive stateful interview prompts,
+  - markdown document generation/persistence,
+  - local HTTP UI server and shared dashboard mode.
+- Keep interview lifecycle synchronized across:
+  - in-memory session/interview maps,
+  - markdown artifacts under `outputFolder`,
+  - dashboard cache used for cross-process recovery and browser polling.
+- Support two runtime modes:
+  - **per-session mode** (local interview server)
+  - **dashboard mode** (distributed cache + shared interview pages).
+
+## Design
+
+- `index.ts` exports `createInterviewManager`.
+
+- `runtime.ts` defines the interview-only session boundary (`messages`,
+  `notify`, `continue`, and `rename`). v1 uses nested SDK calls; v2 supplies a
+  context-backed implementation without expanding the global client shim.
+
+- `manager.ts` (composition root)
+  - Creates `createInterviewService(ctx, interviewConfig)` once.
+  - Chooses mode via
+    `interview.dashboard === true || interview.port > 0`.
+  - In dashboard mode:
+    - calls `tryBecomeDashboard(...)` to elect one process as dashboard,
+    - non-dashboard processes read auth token via `readDashboardAuthFile(port)`,
+    - sessions are registered with `/api/register`, unregister during cleanup via
+      `/api/unregister`, and sync state back via `/api/interviews/{id}/state`,
+    - 10-second fallback polling keeps answer/nudge delivery active if needed.
+  - Returns event hooks:
+    `registerCommand`, `handleCommandExecuteBefore`, `handleEvent`.
+
+- `createInterviewService` (`service.ts`)
+  - Manages interview domain maps:
+    - `interviewsById`, `activeInterviewIds`, `sessionBusy`, `sessionModel`.
+  - Creates and resumes interviews:
+    - `resolveExistingInterviewPath`, `createInterview`, `resumeInterview`.
+  - Syncs state from session messages:
+    - loads messages,
+    - extracts assistant state via `findLatestAssistantState`,
+    - fallbacks through `buildFallbackState` when needed,
+    - rewrites markdown with `rewriteInterviewDocument`.
+  - Injects prompts:
+    - kickoff (`buildKickoffPrompt`),
+    - resume (`buildResumePrompt`),
+    - answer/nudge handling (`buildAnswerPrompt`, `handleNudgeAction`).
+  - Handles events:
+    - `session.status` updates busy tracking,
+    - `session.deleted` marks interview abandoned and drains maps.
+  - Pushes updates:
+    - `onStateChange` callback for dashboard mode,
+    - `onInterviewCreated` callback for immediate registration,
+    - optional `openBrowser` for initial UI open.
+
+- `createInterviewServer` (`server.ts`)
+  - Owns the per-session HTTP endpoints and HTML renderer binding.
+  - Supports:
+    - `GET /`, `GET /api/interviews`, `GET /interview/{id}`
+    - `GET /api/interviews/{id}/state`
+    - `POST /api/interviews/{id}/answers`
+    - `POST /api/interviews/{id}/nudge`
+  - Maps domain errors to HTTP status in `getSubmissionStatus`.
+
+- `dashboard.ts`
+  - Implements a shared dashboard server and state cache.
+  - Auth path:
+    - random token written to `${XDG_DATA_HOME}/opencode/.dashboard-<port>.json`,
+    - validated via cookie, query token, or Bearer header.
+  - In-memory state/cache contracts:
+    - `sessions` registry,
+    - `stateCache` keyed by interview ID,
+    - pending answers and nudge actions with consume-on-read semantics.
+  - Recovery/scan:
+    - periodic `rebuildFromFiles()` from markdown frontmatter,
+    - session directory discovery via SDK + manual folders,
+    - file scanning in known directories and cached file lists.
+  - TTL cleanup removes terminal states after 24h.
+
+- Supporting modules:
+  - `document.ts`: markdown/file helpers (`slugify`, path resolution, frontmatter,
+    title/summary extraction).
+  - `parser.ts`: assistant state parse pipeline (`parseAssistantState`,
+    `findLatestAssistantState`, `buildFallbackState`).
+  - `prompts.ts`: prompt templates for create/resume/answer/nudge.
+  - `helpers.ts`: request parsing and HTML/JSON response helpers.
+  - `types.ts`: domain schemas and interview contracts.
+
+## Flow
+
+- `src/index.ts` wires this folder through
+  `createInterviewManager(ctx, config)`.
+
+- **Per-session mode**
+  - service created and bound to a lazy `createInterviewServer({ port: 0 })`,
+  - command hook calls flow directly into service.
+
+- **Dashboard mode**
+  1. `createInterviewManager` invokes `tryBecomeDashboard`.
+  2. Dashboard election succeeds:
+     - dashboard keeps local cache callbacks (`setStatePushCallback`,
+       `setOnInterviewCreated`),
+     - self-registers session directory and rebuilds file-derived state.
+  3. Election fails:
+     - process becomes client session,
+     - reads token file,
+     - registers with dashboard,
+     - pushes state + interview creation over HTTP,
+     - polls `/pending` and `/nudge` on idle.
+  4. If probe+fallback fails twice, manager falls back to per-session server.
+
+- `handleCommandExecuteBefore`
+  - blank input with no active interview starts ideation,
+  - matching slug/path resumes an existing interview,
+  - otherwise creates a new interview and injects kickoff prompt.
+
+- `handleEvent`
+  - on `session.status: idle`:
+    - consume dashboard pending answers/nudge first,
+    - then refresh interview state so `sessionBusy` is reflected accurately.
+  - on `session.deleted`:
+    - unregisters session from the dashboard and local bookkeeping.
+
+## Integration
+
+- Used by `src/index.ts` as the interview plugin module.
+- Uses OpenCode SDK session APIs for messages, prompts, and status events.
+- Uses local HTTP server contracts for:
+  - dashboard browsing,
+  - browser ↔ session sync endpoints,
+  - manual file/discovery settings.
+- Existing tests cover service, parser, manager, server, dashboard, and helpers
+  under `src/interview/*.test.ts`.
+
+## Repaired lifecycle details
+
+- The interview-only `runtime.ts` boundary owns message reads, notifications,
+  orchestrator continuation, and session rename operations. The v2 bridge uses
+  this boundary instead of expanding the global client shim.
+- After `confirm-complete`, the next clean assistant response is persisted with
+  `rewriteInterviewDocumentWithFinalSpec` only after an idle/text-ended
+  completion event; a stream prefix cannot overwrite the final markdown.
+- Dashboard clients register at `/api/register`, unregister at
+  `/api/unregister`, and receive `202 {"status":"queued"}` for deferred
+  browser submissions. Session polling claims each queued value and must
+  acknowledge it after service delivery; rejected deliveries roll the claim
+  back without clearing the answer, chat message, block comment, or nudge.

+ 956 - 0
src/interview/dashboard-manager.ts

@@ -0,0 +1,956 @@
+import path from 'node:path';
+import type { PluginInput } from '@opencode-ai/plugin';
+import type { PluginConfig } from '../config';
+import { log } from '../utils';
+import { getClient } from '../utils/opencode-client';
+import {
+  type DashboardConfig,
+  probeDashboard,
+  readDashboardAuthFile,
+  tryBecomeDashboard,
+} from './dashboard';
+import type { InterviewSessionRuntime } from './runtime';
+import { createInterviewServer } from './server';
+import { createInterviewService } from './service';
+import type {
+  InterviewRecord,
+  InterviewState,
+  InterviewStateEntry,
+} from './types';
+
+export function createDashboardManager(
+  ctx: PluginInput,
+  config: PluginConfig,
+  dashboardPort: number,
+  outputFolder: string,
+  options: {
+    runtime?: InterviewSessionRuntime;
+    sessionClient?: DashboardConfig['sessionClient'];
+  } = {},
+): {
+  service: ReturnType<typeof createInterviewService>;
+  registerCommand: (config: Record<string, unknown>) => void;
+  handleCommandExecuteBefore: (
+    input: { command: string; sessionID: string; arguments: string },
+    output: { parts: Array<{ type: string; text?: string }> },
+  ) => Promise<void>;
+  handleEvent: (input: {
+    event: { type: string; properties?: Record<string, unknown> };
+  }) => Promise<void>;
+  dispose: () => Promise<void>;
+} {
+  const interviewConfig = config.interview;
+  const service = createInterviewService(
+    ctx,
+    interviewConfig,
+    options.runtime ? { runtime: options.runtime } : undefined,
+  );
+
+  // 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;
+  let fallbackServer: ReturnType<typeof createInterviewServer> | null = null;
+  const registeredSessions = new Set<string>();
+  let disposed = false;
+
+  type HttpDeliveryKind = 'pending' | 'nudge' | 'block-comment' | 'chat';
+  type PendingAck = {
+    interviewId: string;
+    kind: HttpDeliveryKind;
+    claimId: string;
+  };
+  const pendingAcks = new Map<string, PendingAck>();
+  const inFlightDeliveries = new Set<string>();
+  const nonRedeliverableClaims = new Set<string>();
+  type InProcessKind = 'answers' | 'nudge' | 'block-comment' | 'chat';
+  const pendingInProcessAcks = new Map<
+    string,
+    { interviewId: string; kind: InProcessKind; claimId: string }
+  >();
+  const nonRedeliverableInProcessClaims = new Set<string>();
+
+  function canClaimInProcess(
+    interviewId: string,
+    kind: InProcessKind,
+  ): boolean {
+    if (!dashboard) return true;
+    for (const [claimId, pendingAck] of pendingInProcessAcks) {
+      if (pendingAck.interviewId !== interviewId || pendingAck.kind !== kind) {
+        continue;
+      }
+      if (dashboard.acknowledgePending(interviewId, kind, claimId)) {
+        pendingInProcessAcks.delete(claimId);
+      }
+    }
+    return true;
+  }
+
+  function rememberInProcessAck(
+    interviewId: string,
+    kind: InProcessKind,
+    claimId: string,
+  ): void {
+    pendingInProcessAcks.set(claimId, {
+      interviewId,
+      kind,
+      claimId,
+    });
+    nonRedeliverableInProcessClaims.add(claimId);
+  }
+
+  async function retryPendingAck(claimId: string): Promise<boolean> {
+    const pendingAck = pendingAcks.get(claimId);
+    if (!pendingAck) return true;
+
+    try {
+      await settleHttpDelivery(
+        dashboardBaseUrl,
+        authToken,
+        pendingAck.interviewId,
+        pendingAck.kind,
+        pendingAck.claimId,
+        'ack',
+      );
+      pendingAcks.delete(claimId);
+      return true;
+    } catch (error) {
+      // A 409 means the dashboard already accepted this claim but the
+      // previous response was lost.  It is still non-redeliverable locally.
+      if (error instanceof DeliverySettlementError && error.status === 409) {
+        pendingAcks.delete(claimId);
+        nonRedeliverableClaims.add(claimId);
+        return false;
+      }
+      return false;
+    }
+  }
+
+  async function deliverHttpClaim(
+    interviewId: string,
+    kind: HttpDeliveryKind,
+    claimId: string,
+    deliver: () => Promise<void>,
+  ): Promise<void> {
+    if (
+      nonRedeliverableClaims.has(claimId) ||
+      inFlightDeliveries.has(claimId)
+    ) {
+      return;
+    }
+
+    inFlightDeliveries.add(claimId);
+    try {
+      try {
+        await deliver();
+      } catch (error) {
+        await settleHttpDelivery(
+          dashboardBaseUrl,
+          authToken,
+          interviewId,
+          kind,
+          claimId,
+          'rollback',
+        );
+        throw error;
+      }
+
+      try {
+        await settleHttpDelivery(
+          dashboardBaseUrl,
+          authToken,
+          interviewId,
+          kind,
+          claimId,
+          'ack',
+        );
+      } catch (error) {
+        // Delivery already happened.  Never roll the claim back here: doing
+        // so would allow a later poll to deliver the same user input again.
+        pendingAcks.set(claimId, { interviewId, kind, claimId });
+        nonRedeliverableClaims.add(claimId);
+        throw error;
+      }
+    } finally {
+      inFlightDeliveries.delete(claimId);
+    }
+  }
+
+  async function retryPendingAcks(
+    interviewId: string,
+    kind: HttpDeliveryKind,
+  ): Promise<void> {
+    const claims = [...pendingAcks.values()].filter(
+      (pendingAck) =>
+        pendingAck.interviewId === interviewId && pendingAck.kind === kind,
+    );
+    await Promise.all(claims.map((claim) => retryPendingAck(claim.claimId)));
+  }
+
+  // ── Timer-based fallback for nudge/answer polling ─────────────
+  const FALLBACK_POLL_INTERVAL = 10_000;
+  let fallbackTimer: ReturnType<typeof setInterval> | null = null;
+  const stopFallbackTimer = () => {
+    if (!fallbackTimer) return;
+    clearInterval(fallbackTimer);
+    fallbackTimer = null;
+  };
+  const startFallbackTimer = () => {
+    if (fallbackTimer) return;
+    fallbackTimer = setInterval(() => {
+      if (disposed || isDashboard || !dashboardBaseUrl) return;
+      for (const sessionID of registeredSessions) {
+        const interviewId = service.getActiveInterviewId(sessionID);
+        if (!interviewId) continue;
+        pollPendingAnswers(sessionID).catch(() => {});
+        pollNudgeAction(sessionID).catch(() => {});
+        pollBlockComment(sessionID).catch(() => {});
+        pollChat(sessionID).catch(() => {});
+      }
+    }, FALLBACK_POLL_INTERVAL);
+    fallbackTimer?.unref();
+  };
+
+  const initPromise = (async () => {
+    try {
+      dashboard = await tryBecomeDashboard({
+        port: dashboardPort,
+        outputFolder,
+        sessionClient: options.sessionClient ?? getClient(ctx).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) => {
+          if (disposed) return;
+          dashboard?.pushState(stateToEntry(id, state));
+        });
+
+        // Interview created: register in dashboard cache immediately
+        service.setOnInterviewCreated((interview) => {
+          if (disposed) return;
+          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,
+            pendingBlockComment: null,
+            pendingChatMessage: 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 probe failed twice, falling back to local server',
+            );
+            throw new Error('Dashboard not reachable');
+          }
+        }
+
+        const creds = await readDashboardAuthFile(dashboardPort);
+        if (!creds) {
+          throw new Error('Dashboard credentials file missing');
+        }
+
+        dashboardBaseUrl = `http://127.0.0.1:${dashboardPort}`;
+        authToken = creds.token;
+
+        service.setBaseUrlResolver(() => Promise.resolve(dashboardBaseUrl));
+
+        // State push: across HTTP to the dashboard process
+        service.setStatePushCallback((id, state) => {
+          if (disposed) return;
+          if (dashboardBaseUrl && authToken) {
+            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) => {
+          if (disposed) return;
+          if (dashboardBaseUrl && authToken) {
+            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: registered as session client', {
+          dashboardUrl: dashboardBaseUrl,
+        });
+      }
+    } catch (err) {
+      log(
+        '[interview] dashboard election failed or unreachable. Falling back to per-session server.',
+        { error: err instanceof Error ? err.message : String(err) },
+      );
+      // Fallback: wire up a local per-session server for the manager's
+      // service, exactly like the non-dashboard mode would.
+      isDashboard = false;
+      const resolvedOutputPath = path.join(ctx.directory, outputFolder);
+      fallbackServer = createInterviewServer({
+        getState: async (interviewId) => service.getInterviewState(interviewId),
+        listInterviewFiles: async () => service.listInterviewFiles(),
+        listInterviews: () => service.listInterviews(),
+        submitAnswers: async (interviewId, answers) =>
+          service.submitAnswers(interviewId, answers),
+        submitBlockComment: async (interviewId, section, comment) =>
+          service.submitBlockComment(interviewId, section, comment),
+        submitChat: async (interviewId, message) =>
+          service.submitChat(interviewId, message),
+        handleNudgeAction: async (interviewId, action) =>
+          service.handleNudgeAction(interviewId, action),
+        outputFolder: resolvedOutputPath,
+        port: 0,
+      });
+      service.setBaseUrlResolver(
+        () =>
+          fallbackServer?.ensureStarted() ??
+          Promise.reject(new Error('Interview manager is disposed.')),
+      );
+      service.setStatePushCallback(() => {}); // no-op on fallback
+    } finally {
+      initDone = true;
+    }
+  })();
+
+  async function ensureInitialized() {
+    if (!initDone) {
+      await initPromise;
+    }
+  }
+
+  let disposePromise: Promise<void> | null = null;
+  function dispose(): Promise<void> {
+    if (disposePromise) return disposePromise;
+
+    disposed = true;
+    stopFallbackTimer();
+    const sessionsToUnregister = [...registeredSessions];
+    registeredSessions.clear();
+    pendingAcks.clear();
+    pendingInProcessAcks.clear();
+    inFlightDeliveries.clear();
+    nonRedeliverableClaims.clear();
+    nonRedeliverableInProcessClaims.clear();
+    service.setStatePushCallback(() => {});
+    service.setOnInterviewCreated(() => {});
+
+    disposePromise = (async () => {
+      await initPromise;
+      stopFallbackTimer();
+      if (!isDashboard && dashboardBaseUrl && authToken) {
+        await Promise.all(
+          sessionsToUnregister.map((sessionID) =>
+            unregisterSessionViaHttp(
+              dashboardBaseUrl,
+              authToken,
+              sessionID,
+            ).catch(() => {}),
+          ),
+        );
+      }
+      if (isDashboard && dashboard) {
+        for (const sessionID of sessionsToUnregister) {
+          dashboard.removeSession(sessionID);
+        }
+      }
+      dashboard?.close();
+      dashboard = null;
+      fallbackServer?.close();
+      fallbackServer = null;
+    })();
+    return disposePromise;
+  }
+
+  // ── Client Poll Implementations (polls dashboard server) ──────────
+  async function pollPendingAnswers(sessionID: string) {
+    const interviewId = service.getActiveInterviewId(sessionID);
+    if (!interviewId) return;
+    await retryPendingAcks(interviewId, 'pending');
+
+    try {
+      const res = await fetch(
+        `${dashboardBaseUrl}/api/interviews/${interviewId}/pending`,
+        {
+          headers: authHeaders(authToken),
+          signal: AbortSignal.timeout(3000),
+        },
+      );
+      const body = (await res.json()) as {
+        answers?: Array<{ questionId: string; answer: string }> | null;
+        claimId?: string | null;
+      };
+      if (res.ok && body.claimId && body.answers) {
+        log('[interview] delivering pending answers (HTTP poll)', {
+          interviewId,
+          count: body.answers.length,
+        });
+        await deliverHttpClaim(interviewId, 'pending', body.claimId, () =>
+          service.submitAnswers(interviewId, body.answers ?? []),
+        );
+      }
+    } catch (err) {
+      log('[interview] failed polling pending answers:', {
+        error: err instanceof Error ? err.message : String(err),
+      });
+    }
+  }
+
+  async function pollNudgeAction(sessionID: string) {
+    const interviewId = service.getActiveInterviewId(sessionID);
+    if (!interviewId) return;
+    await retryPendingAcks(interviewId, 'nudge');
+
+    try {
+      const res = await fetch(
+        `${dashboardBaseUrl}/api/interviews/${interviewId}/nudge`,
+        {
+          headers: authHeaders(authToken),
+          signal: AbortSignal.timeout(3000),
+        },
+      );
+      const body = (await res.json()) as {
+        action?: 'more-questions' | 'confirm-complete' | null;
+        claimId?: string | null;
+      };
+      if (res.ok && body.claimId && body.action) {
+        const action = body.action;
+        log('[interview] delivering nudge action (HTTP poll)', {
+          interviewId,
+          action,
+        });
+        await deliverHttpClaim(interviewId, 'nudge', body.claimId, () =>
+          service.handleNudgeAction(interviewId, action),
+        );
+      }
+    } catch (err) {
+      log('[interview] failed polling nudge action:', {
+        error: err instanceof Error ? err.message : String(err),
+      });
+    }
+  }
+
+  async function pollBlockComment(sessionID: string) {
+    const interviewId = service.getActiveInterviewId(sessionID);
+    if (!interviewId) return;
+    await retryPendingAcks(interviewId, 'block-comment');
+
+    try {
+      const res = await fetch(
+        `${dashboardBaseUrl}/api/interviews/${interviewId}/block-comment`,
+        {
+          headers: authHeaders(authToken),
+          signal: AbortSignal.timeout(3000),
+        },
+      );
+      const body = (await res.json()) as {
+        section?: string;
+        comment?: string;
+        claimId?: string | null;
+      };
+      if (res.ok && body.claimId && body.section && body.comment) {
+        log('[interview] delivering block comment (HTTP poll)', {
+          interviewId,
+          section: body.section,
+        });
+        await deliverHttpClaim(interviewId, 'block-comment', body.claimId, () =>
+          service.submitBlockComment(
+            interviewId,
+            body.section ?? '',
+            body.comment ?? '',
+          ),
+        );
+      }
+    } catch (err) {
+      log('[interview] failed polling block comment:', {
+        error: err instanceof Error ? err.message : String(err),
+      });
+    }
+  }
+
+  async function pollChat(sessionID: string) {
+    const interviewId = service.getActiveInterviewId(sessionID);
+    if (!interviewId) return;
+    await retryPendingAcks(interviewId, 'chat');
+
+    try {
+      const res = await fetch(
+        `${dashboardBaseUrl}/api/interviews/${interviewId}/chat`,
+        {
+          headers: authHeaders(authToken),
+          signal: AbortSignal.timeout(3000),
+        },
+      );
+      const body = (await res.json()) as {
+        message?: string | null;
+        claimId?: string | null;
+      };
+      if (res.ok && body.claimId && body.message) {
+        log('[interview] delivering chat message (HTTP poll)', {
+          interviewId,
+        });
+        await deliverHttpClaim(interviewId, 'chat', body.claimId, () =>
+          service.submitChat(interviewId, body.message ?? ''),
+        );
+      }
+    } catch (err) {
+      log('[interview] failed polling chat message:', {
+        error: err instanceof Error ? err.message : String(err),
+      });
+    }
+  }
+
+  return {
+    service,
+    registerCommand: (c) => service.registerCommand(c),
+    handleCommandExecuteBefore: async (input, output) => {
+      await ensureInitialized();
+      if (disposed) return;
+
+      // Register session so dashboard/fallback timers track it
+      const sessionID = input.sessionID;
+      registeredSessions.add(sessionID);
+
+      if (!isDashboard && dashboardBaseUrl) {
+        fetch(`${dashboardBaseUrl}/api/register`, {
+          method: 'POST',
+          headers: {
+            ...authHeaders(authToken),
+            'content-type': 'application/json',
+          },
+          body: JSON.stringify({
+            sessionID,
+            directory: ctx.directory,
+            pid: process.pid,
+          }),
+          signal: AbortSignal.timeout(3000),
+        })
+          .then((response) => {
+            if (!response.ok) {
+              throw new Error(
+                `session registration failed (${response.status})`,
+              );
+            }
+          })
+          .catch((err) => {
+            log('[interview] failed to register session with dashboard:', {
+              error: err instanceof Error ? err.message : String(err),
+            });
+          });
+        startFallbackTimer();
+      }
+
+      await service.handleCommandExecuteBefore(input, output);
+    },
+    handleEvent: async (input) => {
+      await ensureInitialized();
+      if (disposed) return;
+      const { event } = input;
+      const properties = event.properties ?? {};
+      const info = properties.info;
+      const sessionID =
+        typeof info === 'object' &&
+        info !== null &&
+        'id' in info &&
+        typeof info.id === 'string'
+          ? info.id
+          : typeof properties.sessionID === 'string'
+            ? properties.sessionID
+            : null;
+
+      await service.handleEvent(input);
+
+      // Event hook: Session is idle. Check for any pending user submissions
+      // queued on the dashboard and deliver them to OpenCode.
+      if (event.type === 'session.status' || event.type === 'session.idle') {
+        const status = properties.status as { type?: string } | undefined;
+        const isIdleEvent =
+          event.type === 'session.idle' || status?.type === 'idle';
+        if (sessionID && isIdleEvent) {
+          const interviewId = service.getActiveInterviewId(sessionID);
+          if (!isDashboard && dashboardBaseUrl) {
+            // Session mode: HTTP poll the dashboard
+            await pollPendingAnswers(sessionID);
+            await pollNudgeAction(sessionID);
+            await pollBlockComment(sessionID);
+            await pollChat(sessionID);
+          } else if (interviewId && dashboard) {
+            // Dashboard mode: read directly from in-process cache
+            const pending = canClaimInProcess(interviewId, 'answers')
+              ? dashboard.claimPendingAnswers(interviewId)
+              : null;
+            if (
+              pending &&
+              !nonRedeliverableInProcessClaims.has(pending.claimId)
+            ) {
+              log('[interview] delivering pending answers (in-process)', {
+                interviewId,
+                count: pending.answers.length,
+              });
+              try {
+                await service.submitAnswers(interviewId, pending.answers);
+                if (
+                  !dashboard.acknowledgePending(
+                    interviewId,
+                    'answers',
+                    pending.claimId,
+                  )
+                ) {
+                  rememberInProcessAck(interviewId, 'answers', pending.claimId);
+                  log('[interview] answer delivery acknowledgement failed', {
+                    interviewId,
+                  });
+                }
+              } catch (error) {
+                dashboard.rollbackPending(
+                  interviewId,
+                  'answers',
+                  pending.claimId,
+                );
+                log('[interview] answer delivery rolled back', {
+                  error: error instanceof Error ? error.message : String(error),
+                });
+              }
+            }
+            const nudge = canClaimInProcess(interviewId, 'nudge')
+              ? dashboard.claimNudgeAction(interviewId)
+              : null;
+            if (nudge) {
+              log('[interview] delivering nudge action (in-process)', {
+                interviewId,
+                action: nudge.action,
+              });
+              try {
+                await service.handleNudgeAction(interviewId, nudge.action);
+                if (
+                  !dashboard.acknowledgePending(
+                    interviewId,
+                    'nudge',
+                    nudge.claimId,
+                  )
+                ) {
+                  rememberInProcessAck(interviewId, 'nudge', nudge.claimId);
+                  log('[interview] nudge delivery acknowledgement failed', {
+                    interviewId,
+                  });
+                }
+              } catch (error) {
+                dashboard.rollbackPending(interviewId, 'nudge', nudge.claimId);
+                log('[interview] nudge delivery rolled back', {
+                  error: error instanceof Error ? error.message : String(error),
+                });
+              }
+            }
+            const comment = canClaimInProcess(interviewId, 'block-comment')
+              ? dashboard.claimBlockComment(interviewId)
+              : null;
+            if (comment) {
+              log('[interview] delivering block comment (in-process)', {
+                interviewId,
+                section: comment.comment.section,
+              });
+              try {
+                await service.submitBlockComment(
+                  interviewId,
+                  comment.comment.section,
+                  comment.comment.comment,
+                );
+                if (
+                  !dashboard.acknowledgePending(
+                    interviewId,
+                    'block-comment',
+                    comment.claimId,
+                  )
+                ) {
+                  rememberInProcessAck(
+                    interviewId,
+                    'block-comment',
+                    comment.claimId,
+                  );
+                  log(
+                    '[interview] block comment delivery acknowledgement failed',
+                    { interviewId },
+                  );
+                }
+              } catch (error) {
+                dashboard.rollbackPending(
+                  interviewId,
+                  'block-comment',
+                  comment.claimId,
+                );
+                log('[interview] block comment delivery rolled back', {
+                  error: error instanceof Error ? error.message : String(error),
+                });
+              }
+            }
+            const chat = canClaimInProcess(interviewId, 'chat')
+              ? dashboard.claimChatMessage(interviewId)
+              : null;
+            if (chat) {
+              log('[interview] delivering chat message (in-process)', {
+                interviewId,
+              });
+              try {
+                await service.submitChat(interviewId, chat.message);
+                if (
+                  !dashboard.acknowledgePending(
+                    interviewId,
+                    'chat',
+                    chat.claimId,
+                  )
+                ) {
+                  rememberInProcessAck(interviewId, 'chat', chat.claimId);
+                  log('[interview] chat delivery acknowledgement failed', {
+                    interviewId,
+                  });
+                }
+              } catch (error) {
+                dashboard.rollbackPending(interviewId, 'chat', chat.claimId);
+                log('[interview] chat delivery rolled back', {
+                  error: error instanceof Error ? error.message : String(error),
+                });
+              }
+            }
+          }
+
+          // Refresh state: calls getInterviewState → syncInterview →
+          // onStateChange. 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);
+        if (!isDashboard && registeredSessions.size === 0) {
+          stopFallbackTimer();
+        }
+        if (dashboard) {
+          dashboard.removeSession(sessionID);
+        } else if (dashboardBaseUrl && authToken) {
+          unregisterSessionViaHttp(
+            dashboardBaseUrl,
+            authToken,
+            sessionID,
+          ).catch((err) => {
+            log('[interview] failed to unregister deleted session:', {
+              error: err instanceof Error ? err.message : String(err),
+            });
+          });
+        }
+      }
+    },
+    dispose,
+  };
+}
+
+// ─── Helpers ──────────────────────────────────────────────────────
+
+class DeliverySettlementError extends Error {
+  constructor(
+    readonly status: number,
+    operation: 'ack' | 'rollback',
+  ) {
+    super(`delivery ${operation} failed (${status})`);
+    this.name = 'DeliverySettlementError';
+  }
+}
+
+function authHeaders(token: string): Record<string, string> {
+  return { authorization: `Bearer ${token}` };
+}
+
+function stateToEntry(
+  interviewId: string,
+  state: InterviewState,
+): InterviewStateEntry {
+  return {
+    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,
+    pendingBlockComment: null,
+    pendingChatMessage: null,
+    document: state.document,
+    blocks: state.blocks,
+  };
+}
+
+async function pushStateViaHttp(
+  dashboardUrl: string,
+  token: string,
+  interviewId: string,
+  state: InterviewState,
+): Promise<void> {
+  const entry = stateToEntry(interviewId, state);
+  const response = await fetch(
+    `${dashboardUrl}/api/interviews/${interviewId}/state`,
+    {
+      method: 'POST',
+      headers: {
+        ...authHeaders(token),
+        'content-type': 'application/json',
+      },
+      body: JSON.stringify(entry),
+      signal: AbortSignal.timeout(5000),
+    },
+  );
+  if (!response.ok) {
+    throw new Error(`state push failed (${response.status})`);
+  }
+}
+
+async function registerInterviewViaHttp(
+  dashboardUrl: string,
+  token: string,
+  interview: InterviewRecord,
+): Promise<void> {
+  const response = await fetch(`${dashboardUrl}/api/interviews`, {
+    method: 'POST',
+    headers: {
+      ...authHeaders(token),
+      'content-type': 'application/json',
+    },
+    body: JSON.stringify({
+      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,
+      pendingBlockComment: null,
+      pendingChatMessage: null,
+    }),
+    signal: AbortSignal.timeout(3000),
+  });
+  if (!response.ok) {
+    throw new Error(`interview registration failed (${response.status})`);
+  }
+}
+
+async function unregisterSessionViaHttp(
+  dashboardUrl: string,
+  token: string,
+  sessionID: string,
+): Promise<void> {
+  const response = await fetch(`${dashboardUrl}/api/unregister`, {
+    method: 'POST',
+    headers: {
+      ...authHeaders(token),
+      'content-type': 'application/json',
+    },
+    body: JSON.stringify({ sessionID }),
+    signal: AbortSignal.timeout(3000),
+  });
+  if (!response.ok) {
+    throw new Error(`session unregister failed (${response.status})`);
+  }
+}
+
+async function settleHttpDelivery(
+  dashboardUrl: string,
+  token: string,
+  interviewId: string,
+  kind: 'pending' | 'nudge' | 'block-comment' | 'chat',
+  claimId: string,
+  operation: 'ack' | 'rollback',
+): Promise<void> {
+  const response = await fetch(
+    `${dashboardUrl}/api/interviews/${interviewId}/${kind}/${operation}`,
+    {
+      method: 'POST',
+      headers: {
+        ...authHeaders(token),
+        'content-type': 'application/json',
+      },
+      body: JSON.stringify({ claimId }),
+      signal: AbortSignal.timeout(3000),
+    },
+  );
+  if (!response.ok) {
+    throw new DeliverySettlementError(response.status, operation);
+  }
+}

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

@@ -0,0 +1,1708 @@
+import { describe, expect, mock, test } from 'bun:test';
+import * as fs from 'node:fs/promises';
+import { createServer, get } 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;
+}
+
+async function openSseConnection(
+  baseUrl: string,
+  authToken: string,
+  interviewId: string,
+) {
+  return new Promise<{
+    firstChunk: Promise<string>;
+    closed: Promise<void>;
+  }>((resolve, reject) => {
+    let responded = false;
+    let sawStateEvent = false;
+
+    let firstChunkResolve!: (value: string) => void;
+    let firstChunkReject!: (reason: Error) => void;
+    const firstChunk = new Promise<string>((resolveFirst, rejectFirst) => {
+      firstChunkResolve = resolveFirst;
+      firstChunkReject = rejectFirst;
+    });
+
+    let closedResolve!: () => void;
+    let closedReject!: (reason: Error) => void;
+    const closed = new Promise<void>((resolveClosed, rejectClosed) => {
+      closedResolve = resolveClosed;
+      closedReject = rejectClosed;
+    });
+
+    const rejectStreams = (error: Error) => {
+      firstChunkReject(error);
+      closedReject(error);
+    };
+
+    const onError = (error: Error) => {
+      rejectStreams(error);
+      if (!responded) {
+        responded = true;
+        reject(error);
+      }
+    };
+
+    const request = get(
+      `${baseUrl}/api/interviews/${interviewId}/events?token=${authToken}`,
+      (response) => {
+        responded = true;
+        response.setEncoding('utf8');
+
+        let buffer = '';
+        response.on('data', (chunk: string) => {
+          buffer += chunk;
+          if (!sawStateEvent && buffer.includes('event: state')) {
+            sawStateEvent = true;
+            firstChunkResolve(buffer);
+          }
+        });
+
+        response.once('close', () => {
+          if (!sawStateEvent) {
+            firstChunkReject(
+              new Error('SSE response closed before initial state event'),
+            );
+          }
+          closedResolve();
+        });
+
+        response.once('error', onError);
+        resolve({ firstChunk, closed });
+      },
+    );
+
+    request.once('error', onError);
+  });
+}
+
+describe('dashboard server', () => {
+  describe('server lifecycle', () => {
+    test('close is safe before start and repeated', () => {
+      const dashboard = createDashboardServer({
+        port: 0,
+        outputFolder: 'interview',
+      });
+
+      expect(() => dashboard.close()).not.toThrow();
+      expect(() => dashboard.close()).not.toThrow();
+    });
+
+    test('closes active SSE responses on shutdown', async () => {
+      const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
+      try {
+        dashboard.pushState({
+          interviewId: 'lifecycle-sse',
+          sessionID: 'session-lifecycle',
+          idea: 'Lifecycle SSE',
+          mode: 'awaiting-user',
+          summary: 'Test',
+          title: 'Lifecycle SSE',
+          questions: [],
+          pendingAnswers: null,
+          lastUpdatedAt: Date.now(),
+          filePath: '',
+          nudgeAction: null,
+        });
+
+        const { firstChunk, closed } = await openSseConnection(
+          baseUrl,
+          authToken,
+          'lifecycle-sse',
+        );
+
+        expect(await firstChunk).toContain('event: state');
+
+        dashboard.close();
+        dashboard.close();
+
+        await closed;
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('rejects SSE firstChunk when response closes before state', async () => {
+      const { baseUrl, authToken, cleanup } = await startDashboard();
+      try {
+        const connection = await openSseConnection(
+          baseUrl,
+          authToken,
+          'missing-lifecycle-sse',
+        );
+
+        await expect(connection.firstChunk).rejects.toThrow(
+          'SSE response closed before initial state event',
+        );
+        await connection.closed;
+      } finally {
+        cleanup();
+      }
+    });
+  });
+
+  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();
+      }
+    });
+
+    test('unregisters a session and removes its cached interviews', async () => {
+      const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
+      try {
+        dashboard.registerSession({
+          sessionID: 'session-unregister',
+          directory: '/test/directory',
+          pid: 12345,
+          registeredAt: Date.now(),
+        });
+        dashboard.pushState({
+          interviewId: 'unregister-interview',
+          sessionID: 'session-unregister',
+          idea: 'Unregister me',
+          mode: 'awaiting-user',
+          summary: 'Test',
+          title: 'Unregister me',
+          questions: [],
+          pendingAnswers: null,
+          lastUpdatedAt: Date.now(),
+          filePath: '',
+          nudgeAction: null,
+        });
+
+        const response = await fetch(`${baseUrl}/api/unregister`, {
+          method: 'POST',
+          headers: {
+            authorization: `Bearer ${authToken}`,
+            'content-type': 'application/json',
+          },
+          body: JSON.stringify({ sessionID: 'session-unregister' }),
+        });
+        expect(response.status).toBe(200);
+        expect((await response.json()).status).toBe('unregistered');
+        expect(dashboard.getState('unregister-interview')).toBeUndefined();
+      } finally {
+        cleanup();
+      }
+    });
+  });
+
+  describe('session discovery', () => {
+    test('uses the nested SDK query shape', async () => {
+      const list = mock(async () => ({
+        data: [
+          {
+            directory: '/discovered/project',
+            time: { updated: Date.now() },
+          },
+        ],
+      }));
+      const dashboard = createDashboardServer({
+        port: 0,
+        outputFolder: 'interview',
+        sessionClient: { list },
+      });
+
+      try {
+        await dashboard.discoverSessionDirectories();
+
+        expect(list).toHaveBeenCalledWith({ query: {} });
+      } finally {
+        dashboard.close();
+      }
+    });
+  });
+
+  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(202);
+        expect((await response.json()).status).toBe('queued');
+
+        // 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,
+        });
+
+        const response = 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' },
+          },
+        );
+        expect(response.status).toBe(202);
+
+        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('rolls a claim back without losing the queued answer', async () => {
+      const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
+      try {
+        dashboard.pushState({
+          interviewId: 'rollback-test',
+          sessionID: 'session-rollback',
+          idea: 'Rollback Test',
+          mode: 'awaiting-user',
+          summary: 'Test',
+          title: 'Rollback Test',
+          questions: [],
+          pendingAnswers: null,
+          lastUpdatedAt: Date.now(),
+          filePath: '',
+          nudgeAction: null,
+        });
+        await fetch(
+          `${baseUrl}/api/interviews/rollback-test/answers?token=${authToken}`,
+          {
+            method: 'POST',
+            headers: { 'content-type': 'application/json' },
+            body: JSON.stringify({
+              answers: [{ questionId: 'q-1', answer: 'A' }],
+            }),
+          },
+        );
+        const claimed = await fetch(
+          `${baseUrl}/api/interviews/rollback-test/pending?token=${authToken}`,
+        );
+        const claim = (await claimed.json()) as { claimId: string };
+        const rollback = await fetch(
+          `${baseUrl}/api/interviews/rollback-test/pending/rollback?token=${authToken}`,
+          {
+            method: 'POST',
+            headers: { 'content-type': 'application/json' },
+            body: JSON.stringify({ claimId: claim.claimId }),
+          },
+        );
+        expect(rollback.status).toBe(200);
+        expect(dashboard.getState('rollback-test')?.pendingAnswers).toEqual([
+          { questionId: 'q-1', answer: 'A' },
+        ]);
+      } finally {
+        cleanup();
+      }
+    });
+
+    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;
+          claimId: string;
+        };
+        expect(data1.answers).toEqual([{ questionId: 'q-1', answer: 'A' }]);
+
+        const ack = await fetch(
+          `${baseUrl}/api/interviews/pending-test/pending/ack?token=${authToken}`,
+          {
+            method: 'POST',
+            headers: { 'content-type': 'application/json' },
+            body: JSON.stringify({ claimId: data1.claimId }),
+          },
+        );
+        expect(ack.status).toBe(200);
+
+        // 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}`,
+        );
+
+        const first = await fetch(
+          `${baseUrl}/api/interviews/consume-test/pending?token=${authToken}`,
+        );
+        const firstData = (await first.json()) as { claimId: string };
+        await fetch(
+          `${baseUrl}/api/interviews/consume-test/pending/ack?token=${authToken}`,
+          {
+            method: 'POST',
+            headers: { 'content-type': 'application/json' },
+            body: JSON.stringify({ claimId: firstData.claimId }),
+          },
+        );
+
+        // 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(202);
+        expect((await response.json()).status).toBe('queued');
+
+        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;
+          claimId: string;
+        };
+        expect(data.action).toBe('more-questions');
+
+        const ack = await fetch(
+          `${baseUrl}/api/interviews/consume-nudge-test/nudge/ack?token=${authToken}`,
+          {
+            method: 'POST',
+            headers: { 'content-type': 'application/json' },
+            body: JSON.stringify({ claimId: data.claimId }),
+          },
+        );
+        expect(ack.status).toBe(200);
+
+        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}`,
+        );
+
+        const first = await fetch(
+          `${baseUrl}/api/interviews/nudge-second-test/nudge?token=${authToken}`,
+        );
+        const firstData = (await first.json()) as { claimId: string };
+        await fetch(
+          `${baseUrl}/api/interviews/nudge-second-test/nudge/ack?token=${authToken}`,
+          {
+            method: 'POST',
+            headers: { 'content-type': 'application/json' },
+            body: JSON.stringify({ claimId: firstData.claimId }),
+          },
+        );
+
+        // 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('in-process claims can roll back and then acknowledge delivery', async () => {
+      const { dashboard, cleanup } = await startDashboard();
+      try {
+        dashboard.pushState({
+          interviewId: 'claim-direct',
+          sessionID: 'session-claim-direct',
+          idea: 'Claim Direct',
+          mode: 'awaiting-agent',
+          summary: 'Claim',
+          title: 'Claim Direct',
+          questions: [],
+          pendingAnswers: [{ questionId: 'q-1', answer: 'Test' }],
+          lastUpdatedAt: Date.now(),
+          filePath: '',
+          nudgeAction: null,
+        });
+
+        const first = dashboard.claimPendingAnswers('claim-direct');
+        expect(first?.answers).toEqual([{ questionId: 'q-1', answer: 'Test' }]);
+        expect(
+          dashboard.rollbackPending(
+            'claim-direct',
+            'answers',
+            first?.claimId ?? '',
+          ),
+        ).toBe(true);
+
+        const second = dashboard.claimPendingAnswers('claim-direct');
+        expect(second?.answers).toEqual(first?.answers);
+        expect(
+          dashboard.acknowledgePending(
+            'claim-direct',
+            'answers',
+            second?.claimId ?? '',
+          ),
+        ).toBe(true);
+        expect(dashboard.getState('claim-direct')?.pendingAnswers).toBeNull();
+      } finally {
+        cleanup();
+      }
+    });
+
+    test('rollback preserves every queued action kind', async () => {
+      const { dashboard, cleanup } = await startDashboard();
+      try {
+        dashboard.pushState({
+          interviewId: 'all-claims',
+          sessionID: 'session-all-claims',
+          idea: 'All Claims',
+          mode: 'awaiting-agent',
+          summary: 'Claims',
+          title: 'All Claims',
+          questions: [],
+          pendingAnswers: [{ questionId: 'q-1', answer: 'A' }],
+          lastUpdatedAt: Date.now(),
+          filePath: '',
+          nudgeAction: 'confirm-complete',
+          pendingBlockComment: { section: 'Purpose', comment: 'Clarify' },
+          pendingChatMessage: 'Please revise this.',
+        });
+
+        const answers = dashboard.claimPendingAnswers('all-claims');
+        const nudge = dashboard.claimNudgeAction('all-claims');
+        const comment = dashboard.claimBlockComment('all-claims');
+        const chat = dashboard.claimChatMessage('all-claims');
+        expect(answers && nudge && comment && chat).toBeTruthy();
+        expect(
+          dashboard.rollbackPending(
+            'all-claims',
+            'answers',
+            answers?.claimId ?? '',
+          ),
+        ).toBe(true);
+        expect(
+          dashboard.rollbackPending(
+            'all-claims',
+            'nudge',
+            nudge?.claimId ?? '',
+          ),
+        ).toBe(true);
+        expect(
+          dashboard.rollbackPending(
+            'all-claims',
+            'block-comment',
+            comment?.claimId ?? '',
+          ),
+        ).toBe(true);
+        expect(
+          dashboard.rollbackPending('all-claims', 'chat', chat?.claimId ?? ''),
+        ).toBe(true);
+
+        const state = dashboard.getState('all-claims');
+        expect(state?.pendingAnswers).toEqual([
+          { questionId: 'q-1', answer: 'A' },
+        ]);
+        expect(state?.nudgeAction).toBe('confirm-complete');
+        expect(state?.pendingBlockComment).toEqual({
+          section: 'Purpose',
+          comment: 'Clarify',
+        });
+        expect(state?.pendingChatMessage).toBe('Please revise this.');
+      } 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();
+      }
+    });
+  });
+});

+ 1817 - 0
src/interview/dashboard.ts

@@ -0,0 +1,1817 @@
+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 type { PluginInput } from '@opencode-ai/plugin';
+import { log } from '../utils';
+import {
+  extractSummarySection,
+  extractTitle,
+  parseFrontmatter,
+  parseSpecBlocks,
+  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;
+}
+
+type PendingKind = 'answers' | 'nudge' | 'block-comment' | 'chat';
+
+interface PendingClaim {
+  claimId: string;
+  kind: PendingKind;
+}
+
+// ─── Config ───────────────────────────────────────────────────────────
+
+export const DEFAULT_DASHBOARD_PORT = 43211;
+
+export interface DashboardConfig {
+  port: number;
+  outputFolder: string;
+  sessionClient?: Pick<PluginInput['client']['session'], 'list'>;
+}
+
+// ─── 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;
+  claimPendingAnswers: (interviewId: string) => {
+    claimId: string;
+    answers: Array<{ questionId: string; answer: string }>;
+  } | null;
+  claimNudgeAction: (interviewId: string) => {
+    claimId: string;
+    action: 'more-questions' | 'confirm-complete';
+  } | null;
+  claimBlockComment: (interviewId: string) => {
+    claimId: string;
+    comment: { section: string; comment: string };
+  } | null;
+  claimChatMessage: (interviewId: string) => {
+    claimId: string;
+    message: string;
+  } | null;
+  acknowledgePending: (
+    interviewId: string,
+    kind: PendingKind,
+    claimId: string,
+  ) => boolean;
+  rollbackPending: (
+    interviewId: string,
+    kind: PendingKind,
+    claimId: string,
+  ) => boolean;
+  consumePendingAnswers: (
+    interviewId: string,
+  ) => Array<{ questionId: string; answer: string }> | null;
+  consumeNudgeAction: (
+    interviewId: string,
+  ) => 'more-questions' | 'confirm-complete' | null;
+  consumeBlockComment: (
+    interviewId: string,
+  ) => { section: string; comment: string } | null;
+  consumeChatMessage: (interviewId: string) => string | 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 pendingServer: Server | null = null;
+  let baseUrl: string | null = null;
+  let startPromise: Promise<string> | null = null;
+  let closed = false;
+
+  // Session registry
+  const sessions = new Map<string, RegisteredSession>();
+
+  // Interview state cache
+  const stateCache = new Map<string, InterviewStateEntry>();
+  const pendingClaims = new Map<string, PendingClaim>();
+  let claimCounter = 0;
+
+  function claimKey(interviewId: string, kind: PendingKind): string {
+    return `${interviewId}:${kind}`;
+  }
+
+  function newClaim(interviewId: string, kind: PendingKind): string {
+    const claimId = `delivery-${++claimCounter}`;
+    pendingClaims.set(claimKey(interviewId, kind), { claimId, kind });
+    return claimId;
+  }
+
+  function acknowledgePending(
+    interviewId: string,
+    kind: PendingKind,
+    claimId: string,
+  ): boolean {
+    const key = claimKey(interviewId, kind);
+    const claim = pendingClaims.get(key);
+    if (!claim || claim.claimId !== claimId) return false;
+    const entry = stateCache.get(interviewId);
+    if (!entry) return false;
+    pendingClaims.delete(key);
+    if (kind === 'answers') entry.pendingAnswers = null;
+    if (kind === 'nudge') entry.nudgeAction = null;
+    if (kind === 'block-comment') entry.pendingBlockComment = null;
+    if (kind === 'chat') entry.pendingChatMessage = null;
+    entry.lastUpdatedAt = Date.now();
+    return true;
+  }
+
+  function rollbackPending(
+    interviewId: string,
+    kind: PendingKind,
+    claimId: string,
+  ): boolean {
+    const key = claimKey(interviewId, kind);
+    const claim = pendingClaims.get(key);
+    if (!claim || claim.claimId !== claimId) return false;
+    pendingClaims.delete(key);
+    return true;
+  }
+
+  // SSE client registry: interviewId → Set<ServerResponse>
+  const sseClients = new Map<string, Set<import('node:http').ServerResponse>>();
+  const sseHeartbeats = new Map<
+    import('node:http').ServerResponse,
+    ReturnType<typeof setInterval>
+  >();
+
+  function removeSseClient(
+    interviewId: string,
+    response: ServerResponse,
+  ): void {
+    const heartbeat = sseHeartbeats.get(response);
+    if (heartbeat) {
+      clearInterval(heartbeat);
+      sseHeartbeats.delete(response);
+    }
+
+    const clients = sseClients.get(interviewId);
+    clients?.delete(response);
+    if (clients && clients.size === 0) sseClients.delete(interviewId);
+  }
+
+  function formatSseState(entry: InterviewStateEntry) {
+    const markdownPath = entry.filePath;
+    const displayPath = markdownPath
+      ? markdownPath.split('/').pop() || markdownPath
+      : 'interview.md';
+    const document = entry.document ?? '';
+
+    return {
+      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,
+      },
+      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,
+      blocks: entry.blocks ?? parseSpecBlocks(document),
+    };
+  }
+
+  function broadcastSse(interviewId: string, entry: InterviewStateEntry) {
+    const clients = sseClients.get(interviewId);
+    if (!clients || clients.size === 0) return;
+    const payload = `event: state\ndata: ${JSON.stringify(formatSseState(entry))}\n\n`;
+    for (const res of clients) {
+      try {
+        res.write(payload);
+      } catch {
+        removeSseClient(interviewId, res);
+      }
+    }
+    if (clients.size === 0) sseClients.delete(interviewId);
+  }
+
+  // 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;
+  function createCleanupTimer(): ReturnType<typeof setInterval> {
+    const timer = 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);
+    timer.unref();
+    return timer;
+  }
+
+  let cleanupTimer: ReturnType<typeof setInterval> | null = null;
+
+  // 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({ query: {} });
+      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,
+          pendingBlockComment: null,
+          pendingChatMessage: 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: unregister session ────────────────────────────────────
+    if (request.method === 'POST' && pathname === '/api/unregister') {
+      let body: unknown;
+      try {
+        body = await readJsonBody(request);
+      } catch {
+        sendJson(response, 400, { error: 'Invalid JSON' });
+        return;
+      }
+      const { sessionID } = body as { sessionID?: string };
+      if (!sessionID || !isValidId(sessionID)) {
+        sendJson(response, 400, { error: 'sessionID required' });
+        return;
+      }
+      sessions.delete(sessionID);
+      for (const [id, entry] of stateCache) {
+        if (entry.sessionID === sessionID) stateCache.delete(id);
+      }
+      fileCache = null;
+      sendJson(response, 200, { status: 'unregistered' });
+      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,
+        pendingBlockComment: null,
+        pendingChatMessage: null,
+      });
+      dedupRecovered(interviewId, stateCache);
+      fileCache = null;
+
+      const interviewUrl = `${baseUrl}/interview/${interviewId}`;
+      sendJson(response, 200, {
+        interviewId,
+        url: interviewUrl,
+      });
+      return;
+    }
+
+    // ── API: SSE stream (browser → dashboard, real-time push) ────────
+    if (
+      request.method === 'GET' &&
+      pathname.startsWith('/api/interviews/') &&
+      pathname.endsWith('/events')
+    ) {
+      const interviewId = pathname
+        .replace('/api/interviews/', '')
+        .replace('/events', '');
+      if (!interviewId || !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;
+      }
+
+      response.writeHead(200, {
+        'content-type': 'text/event-stream',
+        'cache-control': 'no-cache',
+        connection: 'keep-alive',
+        'access-control-allow-origin': '*',
+      });
+
+      // Register this client
+      let clients = sseClients.get(interviewId);
+      if (!clients) {
+        clients = new Set();
+        sseClients.set(interviewId, clients);
+      }
+      clients.add(response);
+
+      // Send initial state immediately
+      response.write(
+        `event: state\ndata: ${JSON.stringify(formatSseState(entry))}\n\n`,
+      );
+
+      // Heartbeat every 15s to keep connection alive
+      const heartbeat = setInterval(() => {
+        try {
+          response.write(': hb\n\n');
+        } catch {
+          // will be cleaned up on close
+        }
+      }, 15000);
+      sseHeartbeats.set(response, heartbeat);
+
+      // Cleanup on disconnect
+      request.on('close', () => {
+        removeSseClient(interviewId, response);
+      });
+      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;
+        if (state.document !== undefined) existing.document = state.document;
+        if (state.blocks !== undefined) existing.blocks = state.blocks;
+        existing.lastUpdatedAt = Date.now();
+        dedupRecovered(interviewId, stateCache);
+        broadcastSse(interviewId, existing);
+      } else {
+        // New entry
+        const entry: InterviewStateEntry = {
+          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,
+          pendingBlockComment: state.pendingBlockComment ?? null,
+          pendingChatMessage: state.pendingChatMessage ?? null,
+          document: state.document,
+          blocks: state.blocks,
+        };
+        stateCache.set(interviewId, entry);
+        broadcastSse(interviewId, entry);
+      }
+
+      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,
+        blocks: parseSpecBlocks(document),
+      });
+      return;
+    }
+
+    // ── API: acknowledge or roll back a claimed delivery ────────────
+    const deliveryMatch = pathname.match(
+      /^\/api\/interviews\/([^/]+)\/(pending|nudge|block-comment|chat)\/(ack|rollback)$/,
+    );
+    if (request.method === 'POST' && deliveryMatch) {
+      const interviewId = deliveryMatch[1];
+      const kind =
+        deliveryMatch[2] === 'pending'
+          ? 'answers'
+          : (deliveryMatch[2] as PendingKind);
+      const operation = deliveryMatch[3];
+      if (!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 claimId = (body as { claimId?: unknown }).claimId;
+      if (typeof claimId !== 'string' || !claimId) {
+        sendJson(response, 400, { error: 'claimId required' });
+        return;
+      }
+      const accepted =
+        operation === 'ack'
+          ? acknowledgePending(interviewId, kind, claimId)
+          : rollbackPending(interviewId, kind, claimId);
+      if (!accepted) {
+        sendJson(response, 409, { error: 'Unknown or stale delivery claim' });
+        return;
+      }
+      sendJson(response, 200, {
+        status: operation === 'ack' ? 'acked' : 'rolled-back',
+      });
+      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.length === 0 ||
+        !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;
+      }
+
+      if (
+        entry.pendingAnswers ||
+        pendingClaims.has(claimKey(interviewId, 'answers'))
+      ) {
+        sendJson(response, 409, { error: 'Answers are already queued.' });
+        return;
+      }
+      entry.pendingAnswers = answers;
+      entry.mode = 'awaiting-agent';
+      entry.lastUpdatedAt = Date.now();
+      sendJson(response, 202, { status: 'queued' });
+      return;
+    }
+
+    // ── API: submit block comment (browser → dashboard) ────────────
+    if (
+      request.method === 'POST' &&
+      pathname.startsWith('/api/interviews/') &&
+      pathname.endsWith('/block-comment')
+    ) {
+      if (!isAuthenticated(request)) {
+        sendJson(response, 401, { error: 'Unauthorized' });
+        return;
+      }
+      const interviewId = pathname
+        .replace('/api/interviews/', '')
+        .replace('/block-comment', '');
+      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 { section, comment } = body as {
+        section?: string;
+        comment?: string;
+      };
+      if (typeof section !== 'string' || typeof comment !== 'string') {
+        sendJson(response, 400, {
+          error: 'section and comment must be strings',
+        });
+        return;
+      }
+
+      if (
+        entry.pendingBlockComment ||
+        pendingClaims.has(claimKey(interviewId, 'block-comment'))
+      ) {
+        sendJson(response, 409, { error: 'Block feedback is already queued.' });
+        return;
+      }
+      entry.pendingBlockComment = { section, comment };
+      entry.mode = 'awaiting-agent';
+      entry.lastUpdatedAt = Date.now();
+      sendJson(response, 202, { status: 'queued' });
+      return;
+    }
+
+    // ── API: get pending block comment (session polls, auth required) ─
+    if (
+      request.method === 'GET' &&
+      pathname.startsWith('/api/interviews/') &&
+      pathname.endsWith('/block-comment')
+    ) {
+      if (!isAuthenticated(request)) {
+        sendJson(response, 401, { error: 'Unauthorized' });
+        return;
+      }
+      const interviewId = pathname
+        .replace('/api/interviews/', '')
+        .replace('/block-comment', '');
+      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 val = entry.pendingBlockComment
+        ? {
+            claimId:
+              pendingClaims.get(claimKey(interviewId, 'block-comment'))
+                ?.claimId ?? newClaim(interviewId, 'block-comment'),
+            section: entry.pendingBlockComment.section,
+            comment: entry.pendingBlockComment.comment,
+          }
+        : null;
+      sendJson(response, 200, val ?? {});
+      return;
+    }
+
+    // ── API: submit chat message (browser → dashboard) ──────────────
+    if (
+      request.method === 'POST' &&
+      pathname.startsWith('/api/interviews/') &&
+      pathname.endsWith('/chat')
+    ) {
+      if (!isAuthenticated(request)) {
+        sendJson(response, 401, { error: 'Unauthorized' });
+        return;
+      }
+      const interviewId = pathname
+        .replace('/api/interviews/', '')
+        .replace('/chat', '');
+      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 { message } = body as { message?: string };
+      if (typeof message !== 'string' || !message.trim()) {
+        sendJson(response, 400, {
+          error: 'message must be a non-empty string',
+        });
+        return;
+      }
+
+      if (
+        entry.pendingChatMessage ||
+        pendingClaims.has(claimKey(interviewId, 'chat'))
+      ) {
+        sendJson(response, 409, { error: 'Chat message is already queued.' });
+        return;
+      }
+      entry.pendingChatMessage = message.trim();
+      entry.mode = 'awaiting-agent';
+      entry.lastUpdatedAt = Date.now();
+      sendJson(response, 202, { status: 'queued' });
+      return;
+    }
+
+    // ── API: get pending chat message (session polls, auth required) ─
+    if (
+      request.method === 'GET' &&
+      pathname.startsWith('/api/interviews/') &&
+      pathname.endsWith('/chat')
+    ) {
+      if (!isAuthenticated(request)) {
+        sendJson(response, 401, { error: 'Unauthorized' });
+        return;
+      }
+      const interviewId = pathname
+        .replace('/api/interviews/', '')
+        .replace('/chat', '');
+      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 val = entry.pendingChatMessage
+        ? {
+            claimId:
+              pendingClaims.get(claimKey(interviewId, 'chat'))?.claimId ??
+              newClaim(interviewId, 'chat'),
+            message: entry.pendingChatMessage,
+          }
+        : null;
+      sendJson(response, 200, val ?? { message: null });
+      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;
+      }
+      const claimed = entry.pendingAnswers
+        ? {
+            claimId:
+              pendingClaims.get(claimKey(interviewId, 'answers'))?.claimId ??
+              newClaim(interviewId, 'answers'),
+            answers: entry.pendingAnswers,
+          }
+        : null;
+      sendJson(response, 200, {
+        claimId: claimed?.claimId ?? null,
+        answers: claimed?.answers ?? null,
+      });
+      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;
+      }
+
+      if (
+        entry.nudgeAction ||
+        pendingClaims.has(claimKey(interviewId, 'nudge'))
+      ) {
+        sendJson(response, 409, { error: 'Nudge action is already queued.' });
+        return;
+      }
+      entry.nudgeAction = action;
+      entry.mode = 'awaiting-agent';
+      entry.lastUpdatedAt = Date.now();
+      sendJson(response, 202, { status: 'queued' });
+      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 claimed = entry.nudgeAction
+        ? {
+            claimId:
+              pendingClaims.get(claimKey(interviewId, 'nudge'))?.claimId ??
+              newClaim(interviewId, 'nudge'),
+            action: entry.nudgeAction,
+          }
+        : null;
+      sendJson(response, 200, {
+        claimId: claimed?.claimId ?? null,
+        action: claimed?.action ?? null,
+      });
+      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 closeServer(server: Server): void {
+    server.closeAllConnections();
+    try {
+      server.close();
+    } catch {
+      // The server may still be in the listen transition.
+    }
+  }
+
+  function start(): Promise<string> {
+    if (baseUrl) return Promise.resolve(baseUrl);
+    if (closed) return Promise.reject(new Error('Dashboard server is closed.'));
+    if (startPromise) return startPromise;
+
+    if (!cleanupTimer) {
+      cleanupTimer = createCleanupTimer();
+    }
+
+    startPromise = 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) => {
+        closeServer(server);
+        pendingServer = null;
+        if (error.code === 'EADDRINUSE') {
+          reject(new Error(`Dashboard port ${config.port} is already in use.`));
+        } else {
+          reject(error);
+        }
+      });
+
+      pendingServer = server;
+      server.listen(config.port, '127.0.0.1', () => {
+        const address = server.address();
+        if (!address || typeof address === 'string') {
+          pendingServer = null;
+          reject(new Error('Failed to start dashboard server'));
+          return;
+        }
+        activeServer = server;
+        pendingServer = null;
+        baseUrl = `http://127.0.0.1:${address.port}`;
+        if (closed) {
+          activeServer = null;
+          baseUrl = null;
+          closeServer(server);
+          reject(new Error('Dashboard server is closed.'));
+          return;
+        }
+        writeAuthFile(config.port, authToken);
+        resolve(baseUrl);
+      });
+    });
+
+    startPromise.catch(() => {
+      startPromise = null;
+    });
+    return startPromise;
+  }
+
+  function close(): void {
+    if (closed) return;
+    closed = true;
+
+    if (cleanupTimer) {
+      clearInterval(cleanupTimer);
+      cleanupTimer = null;
+    }
+
+    for (const clients of sseClients.values()) {
+      for (const response of clients) {
+        const heartbeat = sseHeartbeats.get(response);
+        if (heartbeat) {
+          clearInterval(heartbeat);
+          sseHeartbeats.delete(response);
+        }
+        try {
+          if (!response.writableEnded && !response.destroyed) {
+            response.end();
+          }
+        } catch {
+          try {
+            response.destroy();
+          } catch {
+            // Ignore cleanup errors
+          }
+        }
+      }
+    }
+
+    sseClients.clear();
+    pendingClaims.clear();
+
+    removeAuthFile(config.port);
+
+    const server = activeServer ?? pendingServer;
+    activeServer = null;
+    pendingServer = null;
+    baseUrl = null;
+    if (server) {
+      closeServer(server);
+    }
+  }
+
+  // ─── 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);
+          for (const kind of [
+            'answers',
+            'nudge',
+            'block-comment',
+            'chat',
+          ] as PendingKind[]) {
+            pendingClaims.delete(claimKey(id, kind));
+          }
+        }
+      }
+      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;
+        if (existing.pendingBlockComment)
+          entry.pendingBlockComment ??= existing.pendingBlockComment;
+        if (existing.pendingChatMessage)
+          entry.pendingChatMessage ??= existing.pendingChatMessage;
+        if (entry.document === undefined && existing.document !== undefined) {
+          entry.document = existing.document;
+        }
+        if (entry.blocks === undefined && existing.blocks !== undefined) {
+          entry.blocks = existing.blocks;
+        }
+      }
+      stateCache.set(entry.interviewId, entry);
+      dedupRecovered(entry.interviewId, stateCache);
+      broadcastSse(entry.interviewId, entry);
+    },
+    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,
+    claimPendingAnswers: (id) => {
+      const entry = stateCache.get(id);
+      if (!entry?.pendingAnswers) return null;
+      const key = claimKey(id, 'answers');
+      const existing = pendingClaims.get(key);
+      const claimId = existing?.claimId ?? newClaim(id, 'answers');
+      return { claimId, answers: entry.pendingAnswers };
+    },
+    claimNudgeAction: (id) => {
+      const entry = stateCache.get(id);
+      if (!entry?.nudgeAction) return null;
+      const key = claimKey(id, 'nudge');
+      const existing = pendingClaims.get(key);
+      const claimId = existing?.claimId ?? newClaim(id, 'nudge');
+      return { claimId, action: entry.nudgeAction };
+    },
+    claimBlockComment: (id) => {
+      const entry = stateCache.get(id);
+      if (!entry?.pendingBlockComment) return null;
+      const key = claimKey(id, 'block-comment');
+      const existing = pendingClaims.get(key);
+      const claimId = existing?.claimId ?? newClaim(id, 'block-comment');
+      return { claimId, comment: entry.pendingBlockComment };
+    },
+    claimChatMessage: (id) => {
+      const entry = stateCache.get(id);
+      if (!entry?.pendingChatMessage) return null;
+      const key = claimKey(id, 'chat');
+      const existing = pendingClaims.get(key);
+      const claimId = existing?.claimId ?? newClaim(id, 'chat');
+      return { claimId, message: entry.pendingChatMessage };
+    },
+    acknowledgePending,
+    rollbackPending,
+    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;
+    },
+    consumeBlockComment: (id: string) => {
+      const entry = stateCache.get(id);
+      if (!entry?.pendingBlockComment) return null;
+      const comment = entry.pendingBlockComment;
+      entry.pendingBlockComment = null;
+      return comment;
+    },
+    consumeChatMessage: (id: string) => {
+      const entry = stateCache.get(id);
+      if (!entry?.pendingChatMessage) return null;
+      const message = entry.pendingChatMessage;
+      entry.pendingChatMessage = null;
+      return message;
+    },
+    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')) {
+        dashboard.close();
+        // 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
+      }
+      dashboard.close();
+      throw error;
+    }
+  }
+
+  return null;
+}

+ 355 - 0
src/interview/document.ts

@@ -0,0 +1,355 @@
+import * as fsSync from 'node:fs';
+import * as fs from 'node:fs/promises';
+import * as path from 'node:path';
+import { parseFrontmatter as sharedParseFrontmatter } from '../utils/frontmatter';
+import type {
+  InterviewAnswer,
+  InterviewQuestion,
+  InterviewRecord,
+  SpecBlock,
+} 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/i;
+  const match = document.match(marker);
+  if (!match || match.index === undefined) return '';
+  return document.slice(match.index + match[0].length).trim();
+}
+
+export function extractSummarySection(document: string): string {
+  const marker = '## Current spec\n\n';
+  const start = document.indexOf(marker);
+  if (start < 0) {
+    return '';
+  }
+  const summaryStart = start + marker.length;
+  const historyMarker = /\n\n## Q&A history/i;
+  const historyMatch = document.slice(summaryStart).match(historyMarker);
+  const summaryEnd =
+    historyMatch?.index !== undefined
+      ? summaryStart + historyMatch.index
+      : undefined;
+  return document.slice(summaryStart, summaryEnd).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;
+    owner?: string;
+    tags?: string[];
+  },
+): string {
+  const normalizedSummary = summary.trim() || 'Waiting for interview answers.';
+  const normalizedHistory = history.trim() || 'No answers yet.';
+
+  const now = new Date();
+  const dateStr = now.toISOString().split('T')[0];
+
+  const owner = meta?.owner ?? 'agent';
+  const tags = meta?.tags ?? ['spec', 'diagnostic'];
+
+  const frontmatter = meta?.sessionID
+    ? [
+        '---',
+        `sessionID: ${meta.sessionID}`,
+        `baseMessageCount: ${meta.baseMessageCount ?? 0}`,
+        `updatedAt: ${now.toISOString()}`,
+        `version: 1.0`,
+        `date_created: ${dateStr}`,
+        `owner: ${owner}`,
+        `tags: [${tags.join(', ')}]`,
+        '---',
+        '',
+      ].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 const parseFrontmatter = sharedParseFrontmatter;
+
+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;
+}
+
+/**
+ * Replace only the current specification with a clean completion response.
+ * The response is deliberately not passed through the interview-state parser:
+ * a previous structured response must never win over the final markdown.
+ */
+export async function rewriteInterviewDocumentWithFinalSpec(
+  record: InterviewRecord,
+  finalMarkdown: string,
+): Promise<string> {
+  const existing = await readInterviewDocument(record);
+  const history = extractHistorySection(existing);
+  const frontmatter = existing.match(/^---[\s\S]*?---\s*/)?.[0] ?? '';
+  const withoutFrontmatter = finalMarkdown
+    .trim()
+    .replace(/^---[\s\S]*?---\s*/i, '')
+    .trim();
+  const summary =
+    extractSummarySection(withoutFrontmatter) || withoutFrontmatter;
+  const title = extractTitle(existing) || record.idea;
+  const next = [
+    frontmatter,
+    `# ${title}`,
+    '',
+    '## Current spec',
+    '',
+    summary.trim() || 'Specification completed.',
+    '',
+    '## Q&A history',
+    '',
+    history || 'No answers yet.',
+    '',
+  ].join('\n');
+  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',
+  );
+}
+
+export function parseSpecBlocks(markdown: string): SpecBlock[] {
+  const blocks: SpecBlock[] = [];
+  const lines = markdown.split('\n');
+
+  let currentBlockId: string | null = null;
+  let currentBlockTitle: string | null = null;
+  let currentBlockLines: string[] = [];
+
+  const flush = () => {
+    if (currentBlockId) {
+      blocks.push({
+        id: currentBlockId,
+        title: currentBlockTitle || currentBlockId,
+        content: currentBlockLines.join('\n').trim(),
+      });
+    }
+  };
+
+  for (const line of lines) {
+    if (/^##\s+Q&A history\s*$/i.test(line)) {
+      break;
+    }
+
+    const headerMatch = line.match(/^##\s+(\d+)\.\s+(.+)$/);
+    if (headerMatch) {
+      flush();
+      const num = headerMatch[1];
+      const name = headerMatch[2].trim();
+      currentBlockId = `section-${num}`;
+      currentBlockTitle = `${num}. ${name}`;
+      currentBlockLines = [];
+    } else if (line.startsWith('# ') && !line.startsWith('## ')) {
+      // Intro section before ## 1.
+      if (currentBlockId === null) {
+        currentBlockId = 'section-0';
+        currentBlockTitle = 'Introduction';
+        currentBlockLines = [];
+      }
+    } else if (line.startsWith('## ') && !headerMatch) {
+      // Any other H2
+      flush();
+      const name = line.replace(/^##\s+/, '').trim();
+      currentBlockId = `section-${slugify(name)}`;
+      currentBlockTitle = name;
+      currentBlockLines = [];
+    }
+
+    if (currentBlockId !== null) {
+      currentBlockLines.push(line);
+    }
+  }
+
+  flush();
+  return blocks;
+}

+ 87 - 0
src/interview/finalization.test.ts

@@ -0,0 +1,87 @@
+import { describe, expect, test } from 'bun:test';
+import * as fs from 'node:fs/promises';
+import * as path from 'node:path';
+import type { InterviewSessionRuntime } from './runtime';
+import { createInterviewService } from './service';
+import type { InterviewMessage } from './types';
+
+describe('interview finalization', () => {
+  test('persists clean completion markdown without using stale interview state', async () => {
+    const directory = await fs.mkdtemp('/tmp/interview-finalization-');
+    const messages: InterviewMessage[] = [];
+    const runtime: InterviewSessionRuntime = {
+      messages: async () => messages,
+      notify: async () => {},
+      continue: async () => {},
+      rename: async () => {},
+    };
+    const service = createInterviewService({ directory } as never, undefined, {
+      runtime,
+      openBrowser: () => {},
+    });
+    service.setBaseUrlResolver(async () => 'http://127.0.0.1:43211');
+
+    await service.handleCommandExecuteBefore(
+      { command: 'interview', sessionID: 'ses_final', arguments: 'Final app' },
+      { parts: [] },
+    );
+    const interviewID = service.getActiveInterviewId('ses_final');
+    expect(interviewID).not.toBeNull();
+
+    messages.push({
+      info: { role: 'assistant' },
+      parts: [
+        {
+          type: 'text',
+          text: '<interview_state>{"summary":"Draft","questions":[{"id":"q-1","question":"Platform?","options":["Web"]}]}</interview_state>',
+        },
+      ],
+    });
+    await service.getInterviewState(interviewID as string);
+    await service.submitAnswers(interviewID as string, [
+      { questionId: 'q-1', answer: 'Web' },
+    ]);
+    await service.handleEvent({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'ses_final', status: { type: 'idle' } },
+      },
+    });
+    await service.handleNudgeAction(interviewID as string, 'confirm-complete');
+
+    messages.push({
+      info: { role: 'assistant' },
+      parts: [
+        {
+          type: 'text',
+          text: '# Introduction\n\nA polished final specification.',
+        },
+      ],
+    });
+    const beforeCompletion = await service.getInterviewState(
+      interviewID as string,
+    );
+    expect(beforeCompletion.document).not.toContain(
+      'A polished final specification.',
+    );
+    await service.handleEvent({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'ses_final', status: { type: 'idle' } },
+      },
+    });
+    const state = await service.getInterviewState(interviewID as string);
+    const document = await fs.readFile(
+      path.join(directory, 'interview', 'final-app.md'),
+      'utf8',
+    );
+
+    expect(state.document).toContain('A polished final specification.');
+    expect(document).toContain('sessionID: ses_final');
+    expect(document).toContain('Q: Platform?');
+    expect(document).toContain('A: Web');
+    expect(document).not.toContain('<interview_state>');
+
+    await fs.rm(directory, { recursive: true, force: true });
+  });
+});

+ 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) : {};
+}

+ 1 - 0
src/interview/index.ts

@@ -0,0 +1 @@
+export { createInterviewManager } from './manager';

+ 2122 - 0
src/interview/interview.test.ts

@@ -0,0 +1,2122 @@
+import { describe, expect, mock, test } from 'bun:test';
+import * as fs from 'node:fs/promises';
+import { createServer } from 'node:http';
+import * as path from 'node:path';
+import { InterviewConfigSchema } from '../config/schema';
+import { INTERNAL_INITIATOR_METADATA_KEY } from '../utils';
+import { createInterviewServer } from './server';
+import {
+  createInterviewService as createRealInterviewService,
+  MAX_RETAINED_ABANDONED,
+} from './service';
+import type { InterviewAnswer } from './types';
+import { renderInterviewPage } from './ui';
+
+// Intercept getClient calls so service code uses the same session mocks
+// that test assertions inspect.
+mock.module('../utils/opencode-client', () => ({
+  getClient: (ctx: any) => ({
+    session: ctx._sessionMock ?? ctx.client.session,
+  }),
+}));
+
+// Mock the plugin context with mutable message array
+function createMockContext(overrides?: {
+  directory?: string;
+  messagesData?: Array<{
+    info?: { role: string };
+    parts?: Array<{ type: string; text?: string }>;
+  }>;
+  promptImpl?: (args: any) => Promise<unknown>;
+}) {
+  // Use a mutable array that can be updated after creation
+  const messagesData = overrides?.messagesData ?? [];
+
+  const sessionMock = {
+    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 {};
+    }),
+    update: mock(async () => ({})),
+  };
+
+  return {
+    client: {
+      session: sessionMock,
+    },
+    directory: overrides?.directory ?? '/test/directory',
+    _sessionMock: sessionMock,
+  } as any;
+}
+
+// Helper to extract text from current v1 prompt calls (parts is in body)
+function getPromptTexts(promptMock: {
+  mock: {
+    calls: Array<[{ body?: { parts?: Array<{ text?: string }> } }]>;
+  };
+}): string[] {
+  return promptMock.mock.calls
+    .map((call) => call[0].body?.parts?.[0]?.text ?? '')
+    .filter(Boolean);
+}
+
+// Helper to extract interview ID from the last prompt call
+function extractInterviewIdFromLastPrompt(promptMock: {
+  mock: {
+    calls: Array<[{ body?: { parts?: Array<{ text?: string }> } }]>;
+  };
+}): string | null {
+  const calls = promptMock.mock.calls;
+  if (calls.length === 0) return null;
+
+  // Get the last call
+  const lastCall = calls[calls.length - 1];
+  const text = lastCall[0].body?.parts?.[0]?.text ?? '';
+  const match = text.match(/interview\/([^\s]+)/);
+  return match ? match[1] : null;
+}
+
+// Helper to extract text from output parts (kickoff/resume prompts go here)
+function extractOutputText(output: {
+  parts: Array<{ type: string; text?: string }>;
+}): string {
+  const textPart = output.parts.find((part) => part.type === 'text');
+  return textPart?.text ?? '';
+}
+
+function requireInterviewId(value: string | null): string {
+  expect(value).not.toBeNull();
+  return value as string;
+}
+
+function createInterviewService(
+  ctx: ReturnType<typeof createMockContext>,
+  config?: Partial<Parameters<typeof createRealInterviewService>[1]>,
+  deps?: Parameters<typeof createRealInterviewService>[2],
+) {
+  const resolvedConfig = config
+    ? InterviewConfigSchema.parse(config)
+    : undefined;
+
+  return createRealInterviewService(ctx, resolvedConfig, {
+    openBrowser: mock((_url: string) => {}),
+    ...deps,
+  });
+}
+
+function createTestService(
+  ctx: ReturnType<typeof createMockContext>,
+  config?: Partial<Parameters<typeof createRealInterviewService>[1]>,
+  deps?: Parameters<typeof createRealInterviewService>[2],
+) {
+  const openBrowserMock = mock((_url: string) => {});
+  const resolvedConfig = config
+    ? InterviewConfigSchema.parse(config)
+    : undefined;
+  const service = createRealInterviewService(ctx, resolvedConfig, {
+    openBrowser: openBrowserMock,
+    ...deps,
+  });
+
+  return {
+    service,
+    openBrowserMock,
+  };
+}
+
+function createRuntimeEnv(
+  overrides: NodeJS.ProcessEnv = {},
+): NodeJS.ProcessEnv {
+  return { ...overrides };
+}
+
+describe('interview service', () => {
+  describe('/interview <idea> command', () => {
+    test('creates interview and sends kickoff prompt with UI notification', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+      const ctx = createMockContext({ directory: tempDir });
+      const service = createInterviewService(ctx);
+      // Set up base URL resolver to avoid server error
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const output = {
+        parts: [] as Array<{
+          type: string;
+          text?: string;
+          synthetic?: boolean;
+          metadata?: Record<string, unknown>;
+        }>,
+      };
+
+      await service.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>');
+      expect(output.parts[0]).toMatchObject({
+        synthetic: true,
+        metadata: { [INTERNAL_INITIATOR_METADATA_KEY]: true },
+      });
+
+      // Should send UI notification prompt to session
+      expect(ctx.client.session.prompt).toHaveBeenCalled();
+      const promptTexts = getPromptTexts(ctx.client.session.prompt);
+      expect(
+        promptTexts.some((text) => text.includes('Interview UI ready')),
+      ).toBe(true);
+      expect(promptTexts.some((text) => text.includes('/interview/'))).toBe(
+        true,
+      );
+
+      // Cleanup
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+
+    test('renames session with interview title on creation', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+      const ctx = createMockContext({ directory: tempDir });
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-rename',
+          arguments: 'build a task manager',
+        },
+        output,
+      );
+
+      expect(ctx.client.session.update).toHaveBeenCalledTimes(1);
+      expect(ctx.client.session.update.mock.calls[0][0]).toEqual({
+        path: { id: 'session-rename' },
+        body: { title: 'Interview: build a task manager' },
+      });
+
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+
+    test('truncates session title to 50 chars with ellipsis', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+      const ctx = createMockContext({ directory: tempDir });
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+      const longIdea = 'a'.repeat(60);
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-truncate',
+          arguments: longIdea,
+        },
+        output,
+      );
+
+      const title = ctx.client.session.update.mock.calls[0][0].body.title;
+      expect(title.length).toBe(50);
+      expect(title.endsWith('…')).toBe(true);
+
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+
+    test('creates markdown file with slug-only filename (no timestamp prefix)', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+      const ctx = createMockContext({ directory: tempDir });
+
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-456',
+          arguments: 'Test Idea',
+        },
+        output,
+      );
+
+      // Check that interview directory and file were created
+      const interviewDir = path.join(tempDir, 'interview');
+      const files = await fs.readdir(interviewDir);
+      expect(files.length).toBe(1);
+      // Filename should be slug-only, no timestamp prefix
+      expect(files[0]).toBe('test-idea.md');
+      expect(files[0]).not.toMatch(/^\d+-/);
+
+      // Check file content structure
+      const content = await fs.readFile(
+        path.join(interviewDir, files[0]),
+        'utf8',
+      );
+      expect(content).toContain('# Test Idea');
+      expect(content).toContain('## Current spec');
+      expect(content).toContain('## Q&A history');
+
+      // Cleanup
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+  });
+
+  describe('answer submission', () => {
+    test('appends only Q/A history to markdown document', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+
+      // Start with empty messages, then add questions after interview creation
+      const messagesData: Array<{
+        info?: { role: string };
+        parts?: Array<{ type: string; text?: string }>;
+      }> = [];
+
+      const ctx = createMockContext({
+        directory: tempDir,
+        messagesData,
+      });
+
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+
+      // Create interview first (with empty messages, so baseMessageCount = 0)
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-789',
+          arguments: 'Platform App',
+        },
+        output,
+      );
+
+      // Get the interview ID from the prompt calls
+      const interviewId = extractInterviewIdFromLastPrompt(
+        ctx.client.session.prompt,
+      );
+      const requiredInterviewId = requireInterviewId(interviewId);
+
+      // Now add the questions to messages (simulating agent response)
+      messagesData.push({
+        info: { role: 'assistant' },
+        parts: [
+          {
+            type: 'text',
+            text: 'Here are some questions.\n<interview_state>\n{\n  "summary": "Building a test app",\n  "questions": [\n    {\n      "id": "q-1",\n      "question": "What platform?",\n      "options": ["Web", "Mobile"],\n      "suggested": "Web"\n    }\n  ]\n}\n</interview_state>',
+          },
+        ],
+      });
+
+      // Submit an answer
+      const answers: InterviewAnswer[] = [{ questionId: 'q-1', answer: 'Web' }];
+      await service.submitAnswers(requiredInterviewId, answers);
+
+      // Read the markdown file
+      const interviewDir = path.join(tempDir, 'interview');
+      const files = await fs.readdir(interviewDir);
+      const content = await fs.readFile(
+        path.join(interviewDir, files[0]),
+        'utf8',
+      );
+
+      // Verify Q/A was appended to history section
+      expect(content).toContain('## Q&A history');
+      expect(content).toContain('Q: What platform?');
+      expect(content).toContain('A: Web');
+
+      // Verify the Current spec section exists (even if empty after submission)
+      expect(content).toContain('## Current spec');
+
+      // Cleanup
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+
+    test('preserves existing history when appending new answers', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+
+      // Start with messages that include one answered question and one pending
+      const messagesData: Array<{
+        info?: { role: string };
+        parts?: Array<{ type: string; text?: string }>;
+      }> = [
+        // First question and answer
+        {
+          info: { role: 'assistant' },
+          parts: [
+            {
+              type: 'text',
+              text: 'First question.\n<interview_state>\n{\n  "summary": "Building an app",\n  "questions": [\n    {\n      "id": "q-1",\n      "question": "What is the name?",\n      "options": ["App1", "App2"],\n      "suggested": "App1"\n    }\n  ]\n}\n</interview_state>',
+            },
+          ],
+        },
+        { info: { role: 'user' }, parts: [{ type: 'text', text: 'App1' }] },
+        // Second question (current)
+        {
+          info: { role: 'assistant' },
+          parts: [
+            {
+              type: 'text',
+              text: 'Second question.\n<interview_state>\n{\n  "summary": "Building App1",\n  "questions": [\n    {\n      "id": "q-2",\n      "question": "What color?",\n      "options": ["Red", "Blue"],\n      "suggested": "Blue"\n    }\n  ]\n}\n</interview_state>',
+            },
+          ],
+        },
+      ];
+
+      const ctx = createMockContext({
+        directory: tempDir,
+        messagesData,
+      });
+
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+
+      // Create interview (baseMessageCount will be 3)
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-abc',
+          arguments: 'Multi Round App',
+        },
+        output,
+      );
+
+      // Get interview ID from prompt calls
+      const interviewId = extractInterviewIdFromLastPrompt(
+        ctx.client.session.prompt,
+      );
+      const requiredInterviewId = requireInterviewId(interviewId);
+
+      // Add a new message simulating agent response after interview creation
+      // This ensures baseMessageCount (3) < current messages length
+      messagesData.push({
+        info: { role: 'assistant' },
+        parts: [
+          {
+            type: 'text',
+            text: 'Acknowledged.\n<interview_state>\n{\n  "summary": "Building App1",\n  "questions": [\n    {\n      "id": "q-2",\n      "question": "What color?",\n      "options": ["Red", "Blue"],\n      "suggested": "Blue"\n    }\n  ]\n}\n</interview_state>',
+          },
+        ],
+      });
+
+      // Submit second answer (q-2 is the active question now)
+      await service.submitAnswers(requiredInterviewId, [
+        { questionId: 'q-2', answer: 'Blue' },
+      ]);
+
+      // Read file after submission
+      const interviewDir = path.join(tempDir, 'interview');
+      const files = await fs.readdir(interviewDir);
+      const content = await fs.readFile(
+        path.join(interviewDir, files[0]),
+        'utf8',
+      );
+
+      // Verify Q/A is in history
+      expect(content).toContain('Q: What color?');
+      expect(content).toContain('A: Blue');
+
+      // Cleanup
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+
+    test('replaces placeholder history on first answer submission', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+
+      const messagesData: Array<{
+        info?: { role: string };
+        parts?: Array<{ type: string; text?: string }>;
+      }> = [];
+
+      const ctx = createMockContext({
+        directory: tempDir,
+        messagesData,
+      });
+
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-placeholder',
+          arguments: 'Placeholder Test',
+        },
+        output,
+      );
+
+      const interviewId = extractInterviewIdFromLastPrompt(
+        ctx.client.session.prompt,
+      );
+      const requiredInterviewId = requireInterviewId(interviewId);
+
+      messagesData.push({
+        info: { role: 'assistant' },
+        parts: [
+          {
+            type: 'text',
+            text: 'Here are some questions.\n<interview_state>\n{\n  "summary": "Building a test app",\n  "questions": [\n    {\n      "id": "q-1",\n      "question": "What platform?",\n      "options": ["Web", "Mobile"],\n      "suggested": "Web"\n    }\n  ]\n}\n</interview_state>',
+          },
+        ],
+      });
+
+      await service.submitAnswers(requiredInterviewId, [
+        { questionId: 'q-1', answer: 'Web' },
+      ]);
+
+      const interviewDir = path.join(tempDir, 'interview');
+      const files = await fs.readdir(interviewDir);
+      const content = await fs.readFile(
+        path.join(interviewDir, files[0]),
+        'utf8',
+      );
+
+      expect(content).not.toContain('## Q&A history\n\nNo answers yet.\n\nQ:');
+      expect(content).toContain('## Q&A history\n\nQ: What platform?\nA: Web');
+
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+
+    test('rejects concurrent submission when first request holds busy lock', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+
+      // Start with empty messages
+      const messagesData: Array<{
+        info?: { role: string };
+        parts?: Array<{ type: string; text?: string }>;
+      }> = [];
+
+      // Create a prompt that delays to hold the lock
+      let promptStarted = false;
+      const ctx = createMockContext({
+        directory: tempDir,
+        messagesData,
+        promptImpl: async () => {
+          promptStarted = true;
+          // Delay to hold the lock during test
+          await new Promise((resolve) => setTimeout(resolve, 200));
+          return {};
+        },
+      });
+
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+
+      // Create interview first (baseMessageCount = 0)
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-concurrent',
+          arguments: 'Concurrent Test',
+        },
+        output,
+      );
+
+      const interviewId = extractInterviewIdFromLastPrompt(
+        ctx.client.session.prompt,
+      );
+      const requiredInterviewId = requireInterviewId(interviewId);
+
+      // Now add the agent response with questions
+      messagesData.push({
+        info: { role: 'assistant' },
+        parts: [
+          {
+            type: 'text',
+            text: 'Here are some questions.\n<interview_state>\n{\n  "summary": "Building a test app",\n  "questions": [\n    {\n      "id": "q-1",\n      "question": "What platform?",\n      "options": ["Web", "Mobile"],\n      "suggested": "Web"\n    }\n  ]\n}\n</interview_state>',
+          },
+        ],
+      });
+
+      // Start first submission (will hold lock due to slow prompt)
+      const firstSubmissionPromise = service.submitAnswers(
+        requiredInterviewId,
+        [{ questionId: 'q-1', answer: 'Web' }],
+      );
+
+      // Wait for prompt to start (indicates lock is acquired)
+      while (!promptStarted) {
+        await new Promise((resolve) => setTimeout(resolve, 10));
+      }
+
+      // Second submission should be rejected immediately (busy lock held)
+      await expect(
+        service.submitAnswers(requiredInterviewId, [
+          { questionId: 'q-1', answer: 'Mobile' },
+        ]),
+      ).rejects.toThrow('Interview session is busy');
+
+      // Wait for first submission to complete (it will succeed after 200ms delay)
+      await firstSubmissionPromise;
+
+      // Cleanup
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+
+    test('busy lock released when validation fails after lock acquired', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+
+      // Start with empty messages
+      const messagesData: Array<{
+        info?: { role: string };
+        parts?: Array<{ type: string; text?: string }>;
+      }> = [];
+
+      const ctx = createMockContext({
+        directory: tempDir,
+        messagesData,
+      });
+
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+
+      // Create interview first (baseMessageCount = 0)
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-retry',
+          arguments: 'Retry Test',
+        },
+        output,
+      );
+
+      const interviewId = extractInterviewIdFromLastPrompt(
+        ctx.client.session.prompt,
+      );
+      const requiredInterviewId = requireInterviewId(interviewId);
+
+      // Add agent response with questions
+      messagesData.push({
+        info: { role: 'assistant' },
+        parts: [
+          {
+            type: 'text',
+            text: 'Here are some questions.\n<interview_state>\n{\n  "summary": "Building a test app",\n  "questions": [\n    {\n      "id": "q-1",\n      "question": "What platform?",\n      "options": ["Web", "Mobile"],\n      "suggested": "Web"\n    }\n  ]\n}\n</interview_state>',
+          },
+        ],
+      });
+
+      // First submission with invalid answer (wrong question ID)
+      await expect(
+        service.submitAnswers(requiredInterviewId, [
+          { questionId: 'invalid-id', answer: 'Web' },
+        ]),
+      ).rejects.toThrow('Answers do not match the current interview questions');
+
+      // Second submission with correct answer should succeed (lock was released)
+      await expect(
+        service.submitAnswers(requiredInterviewId, [
+          { questionId: 'q-1', answer: 'Web' },
+        ]),
+      ).resolves.toBeUndefined();
+
+      // Cleanup
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+
+    test('busy lock released when no active questions validation fails', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+
+      // Start with empty messages (no questions)
+      const messagesData: Array<{
+        info?: { role: string };
+        parts?: Array<{ type: string; text?: string }>;
+      }> = [
+        {
+          info: { role: 'assistant' },
+          parts: [
+            {
+              type: 'text',
+              text: 'Waiting.\n<interview_state>\n{\n  "summary": "Test",\n  "questions": []\n}\n</interview_state>',
+            },
+          ],
+        },
+      ];
+
+      const ctx = createMockContext({
+        directory: tempDir,
+        messagesData,
+      });
+
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+
+      // Create interview (baseMessageCount will be 1)
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-no-questions',
+          arguments: 'No Questions Test',
+        },
+        output,
+      );
+
+      const interviewId = extractInterviewIdFromLastPrompt(
+        ctx.client.session.prompt,
+      );
+      const requiredInterviewId = requireInterviewId(interviewId);
+
+      // Submission with no active questions should fail and release lock
+      await expect(
+        service.submitAnswers(requiredInterviewId, [
+          { questionId: 'q-1', answer: 'Web' },
+        ]),
+      ).rejects.toThrow('There are no active interview questions to answer');
+
+      // Verify state is not busy after the failed submission
+      const state = await service.getInterviewState(requiredInterviewId);
+      expect(state.isBusy).toBe(false);
+
+      // Cleanup
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+  });
+
+  describe('session interview lifecycle', () => {
+    test('starting /interview with different idea creates fresh interview', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+      const ctx = createMockContext({ directory: tempDir });
+
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const sessionID = 'session-reuse-test';
+
+      // First interview with "Idea One"
+      const output1 = { parts: [] as Array<{ type: string; text?: string }> };
+      await service.handleCommandExecuteBefore(
+        { command: 'interview', sessionID, arguments: 'Idea One' },
+        output1,
+      );
+
+      const interviewId1 = extractInterviewIdFromLastPrompt(
+        ctx.client.session.prompt,
+      );
+      const requiredInterviewId1 = requireInterviewId(interviewId1);
+
+      // Second interview with "Idea Two" - should create fresh interview
+      const output2 = { parts: [] as Array<{ type: string; text?: string }> };
+      await service.handleCommandExecuteBefore(
+        { command: 'interview', sessionID, arguments: 'Idea Two' },
+        output2,
+      );
+
+      // Get the second interview ID (should be the last prompt call)
+      const interviewId2 = extractInterviewIdFromLastPrompt(
+        ctx.client.session.prompt,
+      );
+      const requiredInterviewId2 = requireInterviewId(interviewId2);
+
+      // Should be different interview IDs
+      expect(interviewId1).not.toBe(interviewId2);
+
+      // First interview should be marked as abandoned
+      const state1 = await service.getInterviewState(requiredInterviewId1);
+      expect(state1.interview.status).toBe('abandoned');
+
+      // Second interview should be active
+      const state2 = await service.getInterviewState(requiredInterviewId2);
+      expect(state2.interview.idea).toBe('Idea Two');
+      expect(state2.interview.status).toBe('active');
+
+      // Cleanup
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+
+    test('/interview with no idea and no active interview asks for idea', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+      const ctx = createMockContext({ directory: tempDir });
+
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+      await service.handleCommandExecuteBefore(
+        { command: 'interview', sessionID: 'fresh-session', arguments: '' },
+        output,
+      );
+
+      expect(output.parts).toHaveLength(1);
+      expect(output.parts[0].text).toContain('Ask them for the product idea');
+
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+
+    test('reusing same idea in same session returns existing interview', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+      const ctx = createMockContext({ directory: tempDir });
+
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const sessionID = 'session-same-idea';
+
+      // First call with "Same Idea"
+      const output1 = { parts: [] as Array<{ type: string; text?: string }> };
+      await service.handleCommandExecuteBefore(
+        { command: 'interview', sessionID, arguments: 'Same Idea' },
+        output1,
+      );
+
+      const interviewId1 = extractInterviewIdFromLastPrompt(
+        ctx.client.session.prompt,
+      );
+      expect(interviewId1).not.toBeNull();
+
+      // Second call with same idea - should reuse
+      const output2 = { parts: [] as Array<{ type: string; text?: string }> };
+      await service.handleCommandExecuteBefore(
+        { command: 'interview', sessionID, arguments: 'Same Idea' },
+        output2,
+      );
+
+      const interviewId2 = extractInterviewIdFromLastPrompt(
+        ctx.client.session.prompt,
+      );
+      expect(interviewId2).not.toBeNull();
+
+      // Should be the same interview ID
+      expect(interviewId1).toBe(interviewId2);
+
+      // Cleanup
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+
+    test('session.deleted event marks interview as abandoned', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+      const ctx = createMockContext({ directory: tempDir });
+
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const sessionID = 'session-delete-test';
+
+      // Create interview
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+      await service.handleCommandExecuteBefore(
+        { command: 'interview', sessionID, arguments: 'Delete Test' },
+        output,
+      );
+
+      const interviewId = extractInterviewIdFromLastPrompt(
+        ctx.client.session.prompt,
+      );
+      const requiredInterviewId = requireInterviewId(interviewId);
+
+      // Verify interview is active
+      const stateBefore = await service.getInterviewState(requiredInterviewId);
+      expect(stateBefore.interview.status).toBe('active');
+
+      // Simulate session deletion
+      await service.handleEvent({
+        event: {
+          type: 'session.deleted',
+          properties: { sessionID },
+        },
+      });
+
+      // Interview should now be abandoned
+      const stateAfter = await service.getInterviewState(requiredInterviewId);
+      expect(stateAfter.interview.status).toBe('abandoned');
+
+      // Cleanup
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+  });
+
+  describe('session status handling', () => {
+    test('session.status busy marks interview as awaiting-agent', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+
+      // Start with no questions (awaiting-agent state)
+      const messagesData: Array<{
+        info?: { role: string };
+        parts?: Array<{ type: string; text?: string }>;
+      }> = [
+        {
+          info: { role: 'assistant' },
+          parts: [
+            {
+              type: 'text',
+              text: 'Waiting for response.\n<interview_state>\n{\n  "summary": "Test",\n  "questions": []\n}\n</interview_state>',
+            },
+          ],
+        },
+      ];
+
+      const ctx = createMockContext({
+        directory: tempDir,
+        messagesData,
+      });
+
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const sessionID = 'session-busy-test';
+
+      // Create interview
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+      await service.handleCommandExecuteBefore(
+        { command: 'interview', sessionID, arguments: 'Busy Test' },
+        output,
+      );
+
+      const interviewId = extractInterviewIdFromLastPrompt(
+        ctx.client.session.prompt,
+      );
+      const requiredInterviewId = requireInterviewId(interviewId);
+
+      // Initially should be awaiting-agent (no questions)
+      const stateBefore = await service.getInterviewState(requiredInterviewId);
+      expect(stateBefore.mode).toBe('awaiting-agent');
+
+      // Simulate busy status
+      await service.handleEvent({
+        event: {
+          type: 'session.status',
+          properties: { sessionID, status: { type: 'busy' } },
+        },
+      });
+
+      // Should still be awaiting-agent and marked busy
+      const stateAfter = await service.getInterviewState(requiredInterviewId);
+      expect(stateAfter.mode).toBe('awaiting-agent');
+      expect(stateAfter.isBusy).toBe(true);
+
+      // Cleanup
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+
+    test('message.updated stores current session model for interview follow-ups', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+      const ctx = createMockContext({ directory: tempDir });
+
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const sessionID = 'session-model-track';
+
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+      await service.handleCommandExecuteBefore(
+        { command: 'interview', sessionID, arguments: 'Model Track Test' },
+        output,
+      );
+
+      const interviewId = requireInterviewId(
+        extractInterviewIdFromLastPrompt(ctx.client.session.prompt),
+      );
+
+      await service.handleEvent({
+        event: {
+          type: 'message.updated',
+          properties: {
+            info: {
+              sessionID,
+              providerID: 'openai',
+              modelID: 'gpt-5.6-luna',
+            },
+          },
+        },
+      });
+
+      ctx.client.session.promptAsync.mock.calls.length = 0;
+      await service.handleNudgeAction(interviewId, 'more-questions');
+
+      const call = ctx.client.session.promptAsync.mock.calls[0]?.[0];
+      expect(call.body.model).toEqual({
+        providerID: 'openai',
+        modelID: 'gpt-5.6-luna',
+      });
+
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+  });
+
+  describe('configurable output folder', () => {
+    test('creates interview in configured output folder', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+      const ctx = createMockContext({ directory: tempDir });
+
+      // Create service with custom output folder config
+      const service = createInterviewService(ctx, {
+        maxQuestions: 2,
+        outputFolder: 'custom-interviews',
+        autoOpenBrowser: true,
+      });
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-custom-folder',
+          arguments: 'Custom Folder Idea',
+        },
+        output,
+      );
+
+      // Check that file was created in custom folder
+      const customDir = path.join(tempDir, 'custom-interviews');
+      const files = await fs.readdir(customDir);
+      expect(files.length).toBe(1);
+      expect(files[0]).toBe('custom-folder-idea.md');
+
+      // Verify the markdownPath in state points to custom folder
+      const interviewId = extractInterviewIdFromLastPrompt(
+        ctx.client.session.prompt,
+      );
+      const requiredInterviewId = requireInterviewId(interviewId);
+      const state = await service.getInterviewState(requiredInterviewId);
+      expect(state.markdownPath).toContain('custom-interviews');
+
+      // Cleanup
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+
+    test('handles nested output folder paths', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+      const ctx = createMockContext({ directory: tempDir });
+
+      // Create service with nested output folder path
+      const service = createInterviewService(ctx, {
+        maxQuestions: 2,
+        outputFolder: 'docs/interviews',
+        autoOpenBrowser: true,
+      });
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-nested',
+          arguments: 'Nested Path Idea',
+        },
+        output,
+      );
+
+      // Check that file was created in nested folder
+      const nestedDir = path.join(tempDir, 'docs', 'interviews');
+      const files = await fs.readdir(nestedDir);
+      expect(files.length).toBe(1);
+      expect(files[0]).toBe('nested-path-idea.md');
+
+      // Cleanup
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+  });
+
+  describe('resuming with existing markdown file', () => {
+    test('resumes existing file and sends resume prompt instead of kickoff', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+      const ctx = createMockContext({ directory: tempDir });
+
+      // Pre-create an existing interview file
+      const interviewDir = path.join(tempDir, 'interview');
+      await fs.mkdir(interviewDir, { recursive: true });
+      const existingFilePath = path.join(interviewDir, 'existing-idea.md');
+      await fs.writeFile(
+        existingFilePath,
+        '# Existing Idea\n\n## Current spec\n\nExisting spec content.\n\n## Q&A history\n\nQ: What platform?\nA: Web\n',
+        'utf8',
+      );
+
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+      // Resume by referencing the existing file basename
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-resume',
+          arguments: 'existing-idea',
+        },
+        output,
+      );
+
+      // Should send resume prompt (references existing document)
+      const outputText = extractOutputText(output);
+      expect(outputText).toContain('Resume the interview');
+      expect(outputText).toContain('Existing Idea');
+      expect(outputText).toContain('Existing spec content');
+
+      // Should NOT send kickoff prompt
+      expect(outputText).not.toContain(
+        'You are running an interview q&a session',
+      );
+      expect(outputText).not.toContain('Initial idea:');
+
+      // Cleanup
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+
+    test('resumes by full relative path to existing file', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+      const ctx = createMockContext({ directory: tempDir });
+
+      // Pre-create an existing interview file in custom location
+      const customDir = path.join(tempDir, 'docs');
+      await fs.mkdir(customDir, { recursive: true });
+      const existingFilePath = path.join(customDir, 'my-project.md');
+      await fs.writeFile(
+        existingFilePath,
+        '# My Project\n\n## Current spec\n\nProject spec here.\n\n## Q&A history\n\nNo answers yet.\n',
+        'utf8',
+      );
+
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+      // Resume by referencing the relative path
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-resume-path',
+          arguments: 'docs/my-project.md',
+        },
+        output,
+      );
+
+      // Should send resume prompt
+      const outputText = extractOutputText(output);
+      expect(outputText).toContain('Resume the interview');
+      expect(outputText).toContain('My Project');
+
+      // Cleanup
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+
+    test('reuses same file when resuming multiple times', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+      const ctx = createMockContext({ directory: tempDir });
+
+      // Pre-create an existing interview file
+      const interviewDir = path.join(tempDir, 'interview');
+      await fs.mkdir(interviewDir, { recursive: true });
+      const existingFilePath = path.join(interviewDir, 'reusable.md');
+      await fs.writeFile(
+        existingFilePath,
+        '# Reusable Interview\n\n## Current spec\n\nOriginal content.\n\n## Q&A history\n\n',
+        'utf8',
+      );
+
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+
+      // First resume
+      const output1 = { parts: [] as Array<{ type: string; text?: string }> };
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-reuse-1',
+          arguments: 'reusable',
+        },
+        output1,
+      );
+
+      const interviewId1 = extractInterviewIdFromLastPrompt(
+        ctx.client.session.prompt,
+      );
+
+      // Second resume (different session, same file)
+      const output2 = { parts: [] as Array<{ type: string; text?: string }> };
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-reuse-2',
+          arguments: 'reusable',
+        },
+        output2,
+      );
+
+      const interviewId2 = extractInterviewIdFromLastPrompt(
+        ctx.client.session.prompt,
+      );
+
+      // Both should reference the same file
+      const state1 = await service.getInterviewState(
+        requireInterviewId(interviewId1),
+      );
+      const state2 = await service.getInterviewState(
+        requireInterviewId(interviewId2),
+      );
+      expect(state1.markdownPath).toBe(state2.markdownPath);
+      expect(state1.markdownPath).toContain('reusable.md');
+
+      // Cleanup
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+  });
+
+  describe('configurable maxQuestions', () => {
+    test('kickoff prompt references configured maxQuestions count', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+      const ctx = createMockContext({ directory: tempDir });
+
+      // Create service with custom maxQuestions
+      const service = createInterviewService(ctx, {
+        maxQuestions: 5,
+        outputFolder: 'interview',
+        autoOpenBrowser: true,
+      });
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-max-q',
+          arguments: 'Max Questions Test',
+        },
+        output,
+      );
+
+      // Kickoff prompt should reference the configured maxQuestions
+      const outputText = extractOutputText(output);
+      expect(outputText).toContain('at most 5 questions');
+      expect(outputText).toContain('Return 0 to 5 questions');
+      expect(outputText).toContain('Do not ask more than 5 questions');
+
+      // Cleanup
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+
+    test('resume prompt references configured maxQuestions count', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+      const ctx = createMockContext({ directory: tempDir });
+
+      // Pre-create an existing file to trigger resume
+      const interviewDir = path.join(tempDir, 'interview');
+      await fs.mkdir(interviewDir, { recursive: true });
+      await fs.writeFile(
+        path.join(interviewDir, 'resume-max.md'),
+        '# Resume Max\n\n## Current spec\n\nSpec.\n\n## Q&A history\n\n',
+        'utf8',
+      );
+
+      // Create service with custom maxQuestions
+      const service = createInterviewService(ctx, {
+        maxQuestions: 3,
+        outputFolder: 'interview',
+        autoOpenBrowser: true,
+      });
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-resume-max',
+          arguments: 'resume-max',
+        },
+        output,
+      );
+
+      // Resume prompt should reference the configured maxQuestions
+      const outputText = extractOutputText(output);
+      expect(outputText).toContain('up to 3 at a time');
+
+      // Cleanup
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+
+    test('state exposes at most configured maxQuestions questions', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+
+      // Start with empty messages
+      const messagesData: Array<{
+        info?: { role: string };
+        parts?: Array<{ type: string; text?: string }>;
+      }> = [];
+
+      const ctx = createMockContext({
+        directory: tempDir,
+        messagesData,
+      });
+
+      // Create service with maxQuestions = 2
+      const service = createInterviewService(ctx, {
+        maxQuestions: 2,
+        outputFolder: 'interview',
+        autoOpenBrowser: true,
+      });
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+      // Create interview first (baseMessageCount = 0)
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-parse-max',
+          arguments: 'Parse Max Test',
+        },
+        output,
+      );
+
+      const interviewId = extractInterviewIdFromLastPrompt(
+        ctx.client.session.prompt,
+      );
+      const requiredInterviewId = requireInterviewId(interviewId);
+
+      // Now add the agent response with more questions than maxQuestions
+      messagesData.push({
+        info: { role: 'assistant' },
+        parts: [
+          {
+            type: 'text',
+            text: 'Questions.\n<interview_state>\n{\n  "summary": "Test",\n  "questions": [\n    {"id": "q-1", "question": "Q1?", "options": ["A", "B"]},\n    {"id": "q-2", "question": "Q2?", "options": ["A", "B"]},\n    {"id": "q-3", "question": "Q3?", "options": ["A", "B"]},\n    {"id": "q-4", "question": "Q4?", "options": ["A", "B"]}\n  ]\n}\n</interview_state>',
+          },
+        ],
+      });
+
+      // State should only expose at most maxQuestions questions
+      const state = await service.getInterviewState(requiredInterviewId);
+      expect(state.questions.length).toBeLessThanOrEqual(2);
+      expect(state.questions.length).toBe(2);
+
+      // Cleanup
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+
+    test('answer prompt references configured maxQuestions count', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+
+      // Start with empty messages
+      const messagesData: Array<{
+        info?: { role: string };
+        parts?: Array<{ type: string; text?: string }>;
+      }> = [];
+
+      const ctx = createMockContext({
+        directory: tempDir,
+        messagesData,
+      });
+
+      // Create service with custom maxQuestions
+      const service = createInterviewService(ctx, {
+        maxQuestions: 4,
+        outputFolder: 'interview',
+        autoOpenBrowser: true,
+      });
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+
+      // Create interview first (baseMessageCount = 0)
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-answer-max',
+          arguments: 'Answer Max Test',
+        },
+        output,
+      );
+
+      const interviewId = extractInterviewIdFromLastPrompt(
+        ctx.client.session.prompt,
+      );
+      const requiredInterviewId = requireInterviewId(interviewId);
+
+      // Now add the agent response with a question
+      messagesData.push({
+        info: { role: 'assistant' },
+        parts: [
+          {
+            type: 'text',
+            text: 'Question.\n<interview_state>\n{\n  "summary": "Test",\n  "questions": [{"id": "q-1", "question": "What?", "options": ["A", "B"]}]\n}\n</interview_state>',
+          },
+        ],
+      });
+
+      // Clear previous prompt calls to capture the answer prompt
+      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.promptAsync,
+      ).join('\n');
+      expect(lastPromptText).toContain('Return 0 to 4 questions');
+
+      // Cleanup
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+
+    test('reuses observed session model when submitting interview answers', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+
+      const messagesData: Array<{
+        info?: { role: string };
+        parts?: Array<{ type: string; text?: string }>;
+      }> = [];
+
+      const ctx = createMockContext({
+        directory: tempDir,
+        messagesData,
+      });
+
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const sessionID = 'session-answer-model';
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID,
+          arguments: 'Answer Model Test',
+        },
+        output,
+      );
+
+      const interviewId = requireInterviewId(
+        extractInterviewIdFromLastPrompt(ctx.client.session.prompt),
+      );
+
+      messagesData.push({
+        info: { role: 'assistant' },
+        parts: [
+          {
+            type: 'text',
+            text: 'Question.\n<interview_state>\n{\n  "summary": "Test",\n  "questions": [{"id": "q-1", "question": "What?", "options": ["A", "B"]}]\n}\n</interview_state>',
+          },
+        ],
+      });
+
+      await service.handleEvent({
+        event: {
+          type: 'message.updated',
+          properties: {
+            info: {
+              sessionID,
+              providerID: 'anthropic',
+              modelID: 'claude-sonnet-4-6',
+            },
+          },
+        },
+      });
+
+      ctx.client.session.promptAsync.mock.calls.length = 0;
+      await service.submitAnswers(interviewId, [
+        { questionId: 'q-1', answer: 'A' },
+      ]);
+
+      const call = ctx.client.session.promptAsync.mock.calls[0]?.[0];
+      expect(call.body.model).toEqual({
+        providerID: 'anthropic',
+        modelID: 'claude-sonnet-4-6',
+      });
+
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+  });
+
+  describe('agent-provided title', () => {
+    test('renames file when assistant provides title in interview_state', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+
+      // Start with empty messages
+      const messagesData: Array<{
+        info?: { role: string };
+        parts?: Array<{ type: string; text?: string }>;
+      }> = [];
+
+      const ctx = createMockContext({
+        directory: tempDir,
+        messagesData,
+      });
+
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+      // Create interview with user's idea
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-title-test',
+          arguments: 'My Great App Idea With Long Description',
+        },
+        output,
+      );
+
+      // Initial file should use slugified user input
+      const interviewDir = path.join(tempDir, 'interview');
+      let files = await fs.readdir(interviewDir);
+      expect(files.length).toBe(1);
+      expect(files[0]).toBe('my-great-app-idea-with-long-description.md');
+
+      const interviewId = extractInterviewIdFromLastPrompt(
+        ctx.client.session.prompt,
+      );
+      const requiredInterviewId = requireInterviewId(interviewId);
+
+      // Now add agent response with a concise title
+      messagesData.push({
+        info: { role: 'assistant' },
+        parts: [
+          {
+            type: 'text',
+            text: 'Here are some questions.\n<interview_state>\n{\n  "summary": "Building a task management app",\n  "title": "task-manager",\n  "questions": [{"id": "q-1", "question": "What platform?", "options": ["Web", "Mobile"]}]\n}\n</interview_state>',
+          },
+        ],
+      });
+
+      // Sync interview (this triggers the rename)
+      const state = await service.getInterviewState(requiredInterviewId);
+
+      // File should be renamed to use assistant-provided title
+      files = await fs.readdir(interviewDir);
+      expect(files.length).toBe(1);
+      expect(files[0]).toBe('task-manager.md');
+      expect(state.markdownPath).toContain('task-manager.md');
+
+      // Cleanup
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+
+    test('keeps original filename when assistant omits title', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+
+      const messagesData: Array<{
+        info?: { role: string };
+        parts?: Array<{ type: string; text?: string }>;
+      }> = [];
+
+      const ctx = createMockContext({
+        directory: tempDir,
+        messagesData,
+      });
+
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-no-title',
+          arguments: 'Simple Idea',
+        },
+        output,
+      );
+
+      const interviewDir = path.join(tempDir, 'interview');
+      let files = await fs.readdir(interviewDir);
+      expect(files[0]).toBe('simple-idea.md');
+
+      const interviewId = extractInterviewIdFromLastPrompt(
+        ctx.client.session.prompt,
+      );
+      const requiredInterviewId = requireInterviewId(interviewId);
+
+      // Agent response without title field
+      messagesData.push({
+        info: { role: 'assistant' },
+        parts: [
+          {
+            type: 'text',
+            text: 'Questions.\n<interview_state>\n{\n  "summary": "Building an app",\n  "questions": [{"id": "q-1", "question": "What?", "options": ["A", "B"]}]\n}\n</interview_state>',
+          },
+        ],
+      });
+
+      const state = await service.getInterviewState(requiredInterviewId);
+
+      // Filename should remain unchanged
+      files = await fs.readdir(interviewDir);
+      expect(files[0]).toBe('simple-idea.md');
+      expect(state.markdownPath).toContain('simple-idea.md');
+
+      // Cleanup
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+
+    test('does not rename if target filename already exists', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+
+      const messagesData: Array<{
+        info?: { role: string };
+        parts?: Array<{ type: string; text?: string }>;
+      }> = [];
+
+      const ctx = createMockContext({
+        directory: tempDir,
+        messagesData,
+      });
+
+      // Pre-create a file with the target name
+      const interviewDir = path.join(tempDir, 'interview');
+      await fs.mkdir(interviewDir, { recursive: true });
+      await fs.writeFile(
+        path.join(interviewDir, 'target-name.md'),
+        '# Existing\n\n## Current spec\n\nExisting.\n\n## Q&A history\n\n',
+        'utf8',
+      );
+
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-existing',
+          arguments: 'Original Idea',
+        },
+        output,
+      );
+
+      let files = await fs.readdir(interviewDir);
+      expect(files).toContain('original-idea.md');
+      expect(files).toContain('target-name.md');
+
+      const interviewId = extractInterviewIdFromLastPrompt(
+        ctx.client.session.prompt,
+      );
+      const requiredInterviewId = requireInterviewId(interviewId);
+
+      // Agent suggests a title that matches existing file
+      messagesData.push({
+        info: { role: 'assistant' },
+        parts: [
+          {
+            type: 'text',
+            text: 'Questions.\n<interview_state>\n{\n  "summary": "Building an app",\n  "title": "target-name",\n  "questions": [{"id": "q-1", "question": "What?", "options": ["A", "B"]}]\n}\n</interview_state>',
+          },
+        ],
+      });
+
+      const state = await service.getInterviewState(requiredInterviewId);
+
+      // Should not rename (would overwrite existing file)
+      files = await fs.readdir(interviewDir);
+      expect(files).toContain('original-idea.md');
+      expect(files).toContain('target-name.md');
+      expect(state.markdownPath).toContain('original-idea.md');
+
+      // Cleanup
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+  });
+
+  describe('autoOpenBrowser config', () => {
+    test('does not open a browser during automated test runtimes', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+      const ctx = createMockContext({ directory: tempDir });
+
+      const { service, openBrowserMock } = createTestService(
+        ctx,
+        {
+          maxQuestions: 2,
+          outputFolder: 'interview',
+          autoOpenBrowser: true,
+        },
+        {
+          env: createRuntimeEnv({
+            NODE_ENV: 'test',
+            CI: '0',
+          }),
+        },
+      );
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-browser-test-env',
+          arguments: 'Browser Test Env',
+        },
+        output,
+      );
+
+      expect(openBrowserMock).not.toHaveBeenCalled();
+
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+
+    test('does not open a browser in CI even when auto-open is enabled', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+      const ctx = createMockContext({ directory: tempDir });
+
+      const { service, openBrowserMock } = createTestService(
+        ctx,
+        {
+          maxQuestions: 2,
+          outputFolder: 'interview',
+          autoOpenBrowser: true,
+        },
+        {
+          env: createRuntimeEnv({
+            CI: 'true',
+          }),
+        },
+      );
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-browser-ci-env',
+          arguments: 'Browser CI Env',
+        },
+        output,
+      );
+
+      expect(openBrowserMock).not.toHaveBeenCalled();
+
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+
+    test('uses injected browser opener instead of opening a real browser in tests', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+      const ctx = createMockContext({ directory: tempDir });
+
+      const { service, openBrowserMock } = createTestService(
+        ctx,
+        {
+          maxQuestions: 2,
+          outputFolder: 'interview',
+          autoOpenBrowser: true,
+        },
+        {
+          env: createRuntimeEnv({
+            NODE_ENV: 'development',
+            CI: '0',
+          }),
+        },
+      );
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-browser-open',
+          arguments: 'Browser Open Test',
+        },
+        output,
+      );
+
+      expect(openBrowserMock).toHaveBeenCalledTimes(1);
+
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+
+    test('kickoff prompt includes title field guidance', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+      const ctx = createMockContext({ directory: tempDir });
+
+      const service = createInterviewService(ctx, {
+        maxQuestions: 2,
+        outputFolder: 'interview',
+        autoOpenBrowser: true,
+      });
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-browser-config',
+          arguments: 'Browser Config Test',
+        },
+        output,
+      );
+
+      // Kickoff prompt should mention title field
+      const outputText = extractOutputText(output);
+      expect(outputText).toContain('"title":');
+      expect(outputText).toContain('concise-kebab-case-title-for-filename');
+
+      // Cleanup
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
+  });
+});
+
+describe('renderInterviewPage', () => {
+  test('escapes HTML special characters in interviewId for title', () => {
+    const maliciousId = '<script>alert("xss")</script>';
+    const html = renderInterviewPage(maliciousId, maliciousId);
+
+    // Should not contain raw script tags in title
+    expect(html).not.toContain('<title>Interview <script>');
+
+    // Should contain escaped version in title
+    expect(html).toContain(
+      '<title>Interview &lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;</title>',
+    );
+  });
+
+  test('escapes ampersand in interviewId', () => {
+    const idWithAmpersand = 'A&B Test';
+    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>');
+  });
+
+  test('escapes single quotes in interviewId', () => {
+    const idWithQuote = "test'quote";
+    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, 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, idWithQuotes);
+
+    // The interviewId in the JavaScript should be JSON.stringify'd
+    // JSON.stringify escapes quotes as \"
+    expect(html).toContain('const interviewId = ');
+    // The actual output has escaped quotes for JavaScript string
+    expect(html).toContain('"test\\"onclick\\"evil"');
+  });
+
+  test('does not inject raw interviewId into HTML title', () => {
+    const xssAttempt = '<img src=x onerror=alert(1)>';
+    const html = renderInterviewPage(xssAttempt, xssAttempt);
+
+    // Title should be escaped
+    expect(html).not.toContain(`<title>Interview ${xssAttempt}</title>`);
+    expect(html).toContain(
+      '<title>Interview &lt;img src=x onerror=alert(1)&gt;</title>',
+    );
+  });
+
+  test('renders the hosted brand logo', () => {
+    const html = renderInterviewPage('brand-test', 'brand-test');
+
+    expect(html).toContain('<img class="brand-mark"');
+    expect(html).toContain(
+      'https://ohmyopencodeslim.com/android-chrome-512x512.png',
+    );
+  });
+
+  test('shows explicit Enter guidance for option questions', () => {
+    const html = renderInterviewPage('enter-hint-test', 'enter-hint-test');
+
+    expect(html).toContain('.question-hint {');
+    expect(html).toContain('.hint-chip {');
+    expect(html).toContain(
+      '<kbd>Enter</kbd><span>Accept selected answer</span>',
+    );
+    expect(html).toContain('<kbd>1-9</kbd><span>Choose an option</span>');
+  });
+});
+
+/** Discover a free port by briefly binding to port 0, then closing. */
+async function findFreePort(): Promise<number> {
+  return new Promise((resolve, reject) => {
+    const srv = createServer();
+    srv.listen(0, '127.0.0.1', () => {
+      const addr = srv.address();
+      srv.close(() => {
+        if (!addr || typeof addr === 'string') {
+          reject(new Error('Failed to find free port'));
+          return;
+        }
+        resolve(addr.port);
+      });
+    });
+    srv.on('error', reject);
+  });
+}
+
+describe('interview server port configuration', () => {
+  const noopDeps = {
+    getState: mock(
+      async (_id: string) =>
+        ({
+          interview: {
+            id: 'x',
+            idea: 'x',
+            status: 'active',
+            markdownPath: 'x',
+          },
+          questions: [],
+          mode: 'awaiting-agent' as const,
+          isBusy: false,
+        }) as any,
+    ),
+    listInterviewFiles: mock(async () => []),
+    listInterviews: mock(() => []),
+    submitAnswers: mock(async (_id: string, _answers: InterviewAnswer[]) => {}),
+    handleNudgeAction: mock(
+      async (_id: string, _action: 'more-questions' | 'confirm-complete') => {},
+    ),
+    outputFolder: 'interview',
+  };
+
+  test('server starts on a specific port when port is non-zero', async () => {
+    const freePort = await findFreePort();
+    const server = createInterviewServer({ ...noopDeps, port: freePort });
+    try {
+      const baseUrl = await server.ensureStarted();
+      expect(baseUrl).toBe(`http://127.0.0.1:${freePort}`);
+    } finally {
+      server.close();
+    }
+  });
+
+  test('server starts on a random port when port is 0', async () => {
+    const server = createInterviewServer({ ...noopDeps, port: 0 });
+    try {
+      const baseUrl = await server.ensureStarted();
+      expect(baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
+      const portStr = baseUrl.split(':').pop();
+      const port = Number.parseInt(portStr ?? '0', 10);
+      expect(port).toBeGreaterThan(0);
+    } finally {
+      server.close();
+    }
+  });
+
+  test('baseUrl contains the correct port number for fixed port', async () => {
+    const freePort = await findFreePort();
+    const server = createInterviewServer({ ...noopDeps, port: freePort });
+    try {
+      const baseUrl = await server.ensureStarted();
+      const portStr = baseUrl.split(':').pop();
+      const port = Number.parseInt(portStr ?? '0', 10);
+      expect(port).toBe(freePort);
+    } finally {
+      server.close();
+    }
+  });
+
+  test('baseUrl contains a valid port number for random port', async () => {
+    const server = createInterviewServer({ ...noopDeps, port: 0 });
+    try {
+      const baseUrl = await server.ensureStarted();
+      const portStr = baseUrl.split(':').pop();
+      const port = Number.parseInt(portStr ?? '0', 10);
+      expect(port).toBeGreaterThanOrEqual(1);
+      expect(port).toBeLessThanOrEqual(65535);
+    } finally {
+      server.close();
+    }
+  });
+
+  test('rejects with friendly error when port is already in use', async () => {
+    // Occupy a port first
+    const blocker = createServer();
+    const occupiedPort = await new Promise<number>((resolve, reject) => {
+      blocker.listen(0, '127.0.0.1', () => {
+        const addr = blocker.address();
+        if (!addr || typeof addr === 'string') {
+          reject(new Error('Failed to bind blocker'));
+          return;
+        }
+        resolve(addr.port);
+      });
+      blocker.on('error', reject);
+    });
+
+    const server = createInterviewServer({
+      ...noopDeps,
+      port: occupiedPort,
+    });
+    try {
+      await expect(server.ensureStarted()).rejects.toThrow(
+        `Interview server port ${occupiedPort} is already in use`,
+      );
+    } finally {
+      server.close();
+      blocker.close();
+    }
+  });
+});
+
+describe('InterviewConfigSchema port validation', () => {
+  test('accepts valid port 0', () => {
+    const result = InterviewConfigSchema.parse({ port: 0 });
+    expect(result.port).toBe(0);
+  });
+
+  test('accepts valid port 8080', () => {
+    const result = InterviewConfigSchema.parse({ port: 8080 });
+    expect(result.port).toBe(8080);
+  });
+
+  test('accepts valid port 65535', () => {
+    const result = InterviewConfigSchema.parse({ port: 65535 });
+    expect(result.port).toBe(65535);
+  });
+
+  test('defaults port to 0 when omitted', () => {
+    const result = InterviewConfigSchema.parse({});
+    expect(result.port).toBe(0);
+  });
+
+  test('rejects negative port', () => {
+    expect(() => InterviewConfigSchema.parse({ port: -1 })).toThrow();
+  });
+
+  test('rejects port above 65535', () => {
+    expect(() => InterviewConfigSchema.parse({ port: 70000 })).toThrow();
+  });
+
+  test('rejects float port', () => {
+    expect(() => InterviewConfigSchema.parse({ port: 3.5 })).toThrow();
+  });
+});
+
+describe('interview service abandoned-record retention', () => {
+  const RETENTION_CAP = MAX_RETAINED_ABANDONED;
+
+  async function createInterviewOnSession(
+    service: ReturnType<typeof createInterviewService>,
+    ctx: ReturnType<typeof createMockContext>,
+    index: number,
+  ): Promise<string> {
+    const output = { parts: [] as Array<{ type: string; text?: string }> };
+    await service.handleCommandExecuteBefore(
+      {
+        command: 'interview',
+        sessionID: `session-${index}`,
+        arguments: `Idea ${index}`,
+      },
+      output,
+    );
+    return requireInterviewId(
+      extractInterviewIdFromLastPrompt(ctx.client.session.prompt),
+    );
+  }
+
+  test('evicts oldest abandoned records once the retention cap is exceeded', async () => {
+    const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+    try {
+      const ctx = createMockContext({ directory: tempDir });
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+
+      const ids: string[] = [];
+      for (let i = 0; i < RETENTION_CAP + 2; i++) {
+        ids.push(await createInterviewOnSession(service, ctx, i));
+        // Deleting the session abandons the interview, triggering pruning.
+        await service.handleEvent({
+          event: {
+            type: 'session.deleted',
+            properties: { sessionID: `session-${i}` },
+          },
+        });
+      }
+
+      // The two oldest abandoned records are evicted from the registry.
+      await expect(service.getInterviewState(ids[0])).rejects.toThrow(
+        'Interview not found',
+      );
+      await expect(service.getInterviewState(ids[1])).rejects.toThrow(
+        'Interview not found',
+      );
+
+      // The most recent abandoned record is retained and still renders.
+      const retained = await service.getInterviewState(ids[ids.length - 1]);
+      expect(retained.mode).toBe('abandoned');
+    } finally {
+      await fs.rm(tempDir, { recursive: true, force: true });
+    }
+  });
+
+  test('prunes by abandonment order instead of creation order', async () => {
+    const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+    try {
+      const ctx = createMockContext({ directory: tempDir });
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+
+      const oldActiveId = await createInterviewOnSession(service, ctx, 0);
+      const abandonedIds: string[] = [];
+
+      for (let i = 1; i <= RETENTION_CAP; i++) {
+        const id = await createInterviewOnSession(service, ctx, i);
+        abandonedIds.push(id);
+        await service.handleEvent({
+          event: {
+            type: 'session.deleted',
+            properties: { sessionID: `session-${i}` },
+          },
+        });
+      }
+
+      // Abandoning the old active interview after the cap is full should retain
+      // that newly abandoned record and prune the earliest previously abandoned
+      // record. Its older createdAt must not make it the eviction candidate.
+      await service.handleEvent({
+        event: {
+          type: 'session.deleted',
+          properties: { sessionID: 'session-0' },
+        },
+      });
+
+      await expect(service.getInterviewState(abandonedIds[0])).rejects.toThrow(
+        'Interview not found',
+      );
+
+      const oldActiveState = await service.getInterviewState(oldActiveId);
+      expect(oldActiveState.mode).toBe('abandoned');
+
+      const latestPreviouslyAbandoned = await service.getInterviewState(
+        abandonedIds[abandonedIds.length - 1],
+      );
+      expect(latestPreviouslyAbandoned.mode).toBe('abandoned');
+    } finally {
+      await fs.rm(tempDir, { recursive: true, force: true });
+    }
+  });
+
+  test('retains abandoned records that stay within the cap', async () => {
+    const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+    try {
+      const ctx = createMockContext({ directory: tempDir });
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+
+      const id = await createInterviewOnSession(service, ctx, 0);
+      await service.handleEvent({
+        event: {
+          type: 'session.deleted',
+          properties: { sessionID: 'session-0' },
+        },
+      });
+
+      // Below the cap, the abandoned record is kept so an open tab can still
+      // render its final state.
+      const state = await service.getInterviewState(id);
+      expect(state.mode).toBe('abandoned');
+    } finally {
+      await fs.rm(tempDir, { recursive: true, force: true });
+    }
+  });
+});

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

@@ -0,0 +1,1101 @@
+import { afterEach, describe, expect, mock, test } from 'bun:test';
+import * as fs from 'node:fs/promises';
+import { createServer as createHttpServer } from 'node:http';
+import { createServer as createNetServer } from 'node:net';
+import type { PluginConfig } from '../config';
+import { readDashboardAuthFile } from './dashboard';
+import { createInterviewManager as createInterviewManagerImpl } from './manager';
+
+// Intercept getClient so the manager's service uses the same session mocks.
+mock.module('../utils/opencode-client', () => ({
+  getClient: (ctx: any) => ({
+    session: ctx._sessionMock ?? ctx.client.session,
+  }),
+}));
+
+const managers = new Set<ReturnType<typeof createInterviewManagerImpl>>();
+
+function createInterviewManager(
+  ...args: Parameters<typeof createInterviewManagerImpl>
+): ReturnType<typeof createInterviewManagerImpl> {
+  const manager = createInterviewManagerImpl(...args);
+  managers.add(manager);
+  return manager;
+}
+
+afterEach(async () => {
+  const pending = [...managers];
+  managers.clear();
+  await Promise.all(pending.map((manager) => manager.dispose()));
+});
+
+// Helper to find a free port (matches interview.test.ts pattern)
+async function findFreePort(): Promise<number> {
+  return new Promise((resolve, reject) => {
+    const server = createHttpServer();
+    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 ?? [];
+  const sessionMock = {
+    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 {};
+    }),
+    update: mock(async () => ({})),
+  };
+  return {
+    client: {
+      session: sessionMock,
+    },
+    directory: overrides?.directory ?? '/test/directory',
+    _sessionMock: sessionMock,
+  } as any;
+}
+
+function createTestConfig(
+  overrides: Partial<NonNullable<PluginConfig['interview']>> = {},
+): PluginConfig {
+  return {
+    interview: {
+      autoOpenBrowser: false,
+      ...overrides,
+    },
+  } as PluginConfig;
+}
+
+// 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 = createTestConfig({ port: 0 });
+
+      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 = createTestConfig({ port: 0 });
+
+      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 = createTestConfig({ port: 0 });
+
+      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('disposes the per-session server idempotently', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/manager-test-');
+      const manager = createInterviewManager(
+        createMockContext({ directory: tempDir }),
+        createTestConfig({ port: 0 }),
+      );
+
+      try {
+        await manager.handleCommandExecuteBefore(
+          {
+            command: 'interview',
+            sessionID: 'session-dispose-local',
+            arguments: 'Dispose Local Test',
+          },
+          { parts: [] },
+        );
+        await manager.dispose();
+        await manager.dispose();
+      } finally {
+        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 = createTestConfig({
+        port: freePort,
+        dashboard: true,
+      });
+
+      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 = createTestConfig({
+        port: freePort,
+        dashboard: true,
+      });
+
+      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 = createTestConfig({
+      port: freePort,
+      dashboard: true,
+    });
+
+    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 = createTestConfig({ port: 0 });
+
+    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 = createTestConfig({
+      port: freePort,
+      dashboard: true,
+    });
+
+    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 = createTestConfig({
+      port: freePort,
+      dashboard: true,
+    });
+
+    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));
+      const auth = await readDashboardAuthFile(freePort);
+      expect(auth).not.toBeNull();
+
+      // Delete session
+      await manager.handleEvent({
+        event: {
+          type: 'session.deleted',
+          properties: { info: { id: 'session-delete-reg' } },
+        },
+      });
+
+      // Give cleanup a moment
+      await new Promise((r) => setTimeout(r, 50));
+
+      const stateResponse = await fetch(
+        `http://127.0.0.1:${freePort}/api/interviews/${_interviewId}` +
+          `/state?token=${auth?.token}`,
+      );
+      expect(stateResponse.status).toBe(404);
+
+      // 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 });
+    }
+  });
+
+  test('clears fallback timer when last registered session is deleted', async () => {
+    const dashboardDir = await fs.mkdtemp('/tmp/manager-test-');
+    const clientDir = await fs.mkdtemp('/tmp/manager-test-');
+    const dashboardCtx = createMockContext({ directory: dashboardDir });
+    const clientCtx = createMockContext({ directory: clientDir });
+
+    const freePort = await findFreePort();
+    const config = createTestConfig({
+      port: freePort,
+      dashboard: true,
+    });
+
+    const originalSetInterval = globalThis.setInterval;
+    const originalClearInterval = globalThis.clearInterval;
+    const intervalHandles: Array<{ unref: ReturnType<typeof mock> }> = [];
+    const setIntervalSpy = mock(() => {
+      const handle = { unref: mock(() => {}) };
+      intervalHandles.push(handle);
+      return handle;
+    });
+    const clearIntervalSpy = mock(() => {});
+
+    try {
+      (globalThis as any).setInterval = setIntervalSpy;
+      (globalThis as any).clearInterval = clearIntervalSpy;
+
+      createInterviewManager(dashboardCtx, config);
+
+      // Wait for dashboard init
+      await new Promise((r) => setTimeout(r, 100));
+
+      const clientManager = createInterviewManager(clientCtx, config);
+
+      // Wait for client init to connect to the dashboard
+      await new Promise((r) => setTimeout(r, 100));
+
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+      await clientManager.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-fallback-cleanup',
+          arguments: 'Fallback Cleanup Test',
+        },
+        output,
+      );
+
+      expect(intervalHandles.length).toBeGreaterThan(0);
+      const fallbackTimerHandle = intervalHandles.at(-1);
+      expect(fallbackTimerHandle).toBeDefined();
+
+      await clientManager.handleEvent({
+        event: {
+          type: 'session.deleted',
+          properties: { info: { id: 'session-fallback-cleanup' } },
+        },
+      });
+
+      expect(clearIntervalSpy).toHaveBeenCalledTimes(1);
+      expect(clearIntervalSpy).toHaveBeenCalledWith(fallbackTimerHandle);
+      expect(fallbackTimerHandle?.unref).toHaveBeenCalledTimes(1);
+    } finally {
+      (globalThis as any).setInterval = originalSetInterval;
+      (globalThis as any).clearInterval = originalClearInterval;
+      await fs.rm(dashboardDir, { recursive: true, force: true });
+      await fs.rm(clientDir, { recursive: true, force: true });
+    }
+  });
+
+  test('disposes dashboard resources idempotently', async () => {
+    const tempDir = await fs.mkdtemp('/tmp/manager-test-');
+    const ctx = createMockContext({ directory: tempDir });
+    const freePort = await findFreePort();
+    const manager = createInterviewManager(
+      ctx,
+      createTestConfig({ port: freePort, dashboard: true }),
+    );
+
+    try {
+      await manager.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-dispose',
+          arguments: 'Dispose Test',
+        },
+        { parts: [] },
+      );
+
+      expect(await readDashboardAuthFile(freePort)).not.toBeNull();
+      await manager.dispose();
+      await manager.dispose();
+      expect(await readDashboardAuthFile(freePort)).toBeNull();
+    } 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 = createTestConfig({ port: 0 });
+
+    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 = createTestConfig({ port: 0 });
+
+    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 = createTestConfig({ port: 0 });
+
+    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 = createTestConfig({ port: 0 });
+
+    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 = createTestConfig({
+      port: freePort,
+      dashboard: true,
+    });
+
+    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);
+
+      // Trigger active event poll to register session2/interviewId2 explicitly in dashboard
+      await manager2.handleEvent({
+        event: {
+          type: 'session.status',
+          properties: {
+            sessionID: 'session-2',
+            status: { type: 'idle' },
+          },
+        },
+      });
+
+      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 });
+    }
+  });
+
+  test('does not redeliver after a successful delivery loses its ACK response', async () => {
+    const tempDir1 = await fs.mkdtemp('/tmp/manager-test-');
+    const tempDir2 = await fs.mkdtemp('/tmp/manager-test-');
+    const port = await findFreePort();
+    const config = createTestConfig({ port, dashboard: true });
+    createInterviewManager(createMockContext({ directory: tempDir1 }), config);
+    await new Promise((resolve) => setTimeout(resolve, 100));
+    const ctx2 = createMockContext({ directory: tempDir2 });
+    const manager2 = createInterviewManager(ctx2, config);
+    const originalFetch = globalThis.fetch;
+    let droppedAck = false;
+
+    globalThis.fetch = async (input, init) => {
+      const url = typeof input === 'string' ? input : input.toString();
+      if (!droppedAck && url.includes('/nudge/ack')) {
+        droppedAck = true;
+        throw new Error('simulated ACK transport failure');
+      }
+      return originalFetch(input, init);
+    };
+
+    try {
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+      await manager2.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-exactly-once',
+          arguments: 'Exactly Once Test',
+        },
+        output,
+      );
+      await new Promise((resolve) => setTimeout(resolve, 100));
+
+      const promptCalls = ctx2.client.session.prompt.mock.calls;
+      const kickoff =
+        promptCalls[promptCalls.length - 1]?.[0].body?.parts?.[0]?.text;
+      const interviewId = kickoff?.match(/interview\/([^\s]+)/)?.[1];
+      expect(interviewId).toBeTruthy();
+
+      const auth = await readDashboardAuthFile(port);
+      expect(auth).not.toBeNull();
+      const nudgeResponse = await originalFetch(
+        `http://127.0.0.1:${port}/api/interviews/${interviewId}/nudge?token=${auth?.token}`,
+        {
+          method: 'POST',
+          headers: { 'content-type': 'application/json' },
+          body: JSON.stringify({ action: 'more-questions' }),
+        },
+      );
+      expect(nudgeResponse.status).toBe(202);
+
+      await manager2.handleEvent({
+        event: {
+          type: 'session.status',
+          properties: {
+            sessionID: 'session-exactly-once',
+            status: { type: 'idle' },
+          },
+        },
+      });
+      expect(droppedAck).toBe(true);
+      expect(ctx2.client.session.promptAsync).toHaveBeenCalledTimes(1);
+
+      await manager2.handleEvent({
+        event: {
+          type: 'session.status',
+          properties: {
+            sessionID: 'session-exactly-once',
+            status: { type: 'idle' },
+          },
+        },
+      });
+      expect(ctx2.client.session.promptAsync).toHaveBeenCalledTimes(1);
+    } finally {
+      globalThis.fetch = originalFetch;
+      await fs.rm(tempDir1, { recursive: true, force: true });
+      await fs.rm(tempDir2, { recursive: true, force: true });
+    }
+  });
+
+  test('delivers a new answer claim after an earlier claim ACK is uncertain', async () => {
+    const tempDir1 = await fs.mkdtemp('/tmp/manager-test-');
+    const tempDir2 = await fs.mkdtemp('/tmp/manager-test-');
+    const port = await findFreePort();
+    const config = createTestConfig({ port, dashboard: true });
+    createInterviewManager(createMockContext({ directory: tempDir1 }), config);
+    await new Promise((resolve) => setTimeout(resolve, 100));
+    const messagesData: Array<{
+      info?: { role: string };
+      parts?: Array<{ type: string; text?: string }>;
+    }> = [];
+    const ctx2 = createMockContext({ directory: tempDir2, messagesData });
+    const manager2 = createInterviewManager(ctx2, config);
+    const originalFetch = globalThis.fetch;
+    let failedFirstAck = false;
+
+    globalThis.fetch = async (input, init) => {
+      const url = typeof input === 'string' ? input : input.toString();
+      if (!failedFirstAck && url.includes('/pending/ack')) {
+        failedFirstAck = true;
+        return new Response(JSON.stringify({ error: 'already accepted' }), {
+          status: 409,
+          headers: { 'content-type': 'application/json' },
+        });
+      }
+      return originalFetch(input, init);
+    };
+
+    try {
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+      await manager2.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-answer-claims',
+          arguments: 'Answer Claims Test',
+        },
+        output,
+      );
+      await new Promise((resolve) => setTimeout(resolve, 100));
+
+      const promptCalls = ctx2.client.session.prompt.mock.calls;
+      const kickoff =
+        promptCalls[promptCalls.length - 1]?.[0].body?.parts?.[0]?.text;
+      const interviewId = kickoff?.match(/interview\/([^\s]+)/)?.[1];
+      expect(interviewId).toBeTruthy();
+
+      messagesData.push({
+        info: { role: 'assistant' },
+        parts: [
+          {
+            type: 'text',
+            text:
+              '<interview_state>\n' +
+              '{"summary":"Answer questions","questions":' +
+              '[{"id":"q-1","question":"What?"}]}\n' +
+              '</interview_state>',
+          },
+        ],
+      });
+
+      const auth = await readDashboardAuthFile(port);
+      expect(auth).not.toBeNull();
+      const interviewUrl = `http://127.0.0.1:${port}/api/interviews/${interviewId}`;
+      const authQuery = `?token=${auth?.token}`;
+      const firstAnswer = await originalFetch(
+        `${interviewUrl}/answers${authQuery}`,
+        {
+          method: 'POST',
+          headers: { 'content-type': 'application/json' },
+          body: JSON.stringify({
+            answers: [{ questionId: 'q-1', answer: 'First answer' }],
+          }),
+        },
+      );
+      expect(firstAnswer.status).toBe(202);
+
+      const firstClaimResponse = await originalFetch(
+        `${interviewUrl}/pending${authQuery}`,
+      );
+      const firstClaim = (await firstClaimResponse.json()) as {
+        claimId: string;
+        answers: Array<{ questionId: string; answer: string }> | null;
+      };
+      expect(firstClaim.claimId).toBeTruthy();
+      expect(firstClaim.answers).toEqual([
+        { questionId: 'q-1', answer: 'First answer' },
+      ]);
+
+      await manager2.handleEvent({
+        event: {
+          type: 'session.status',
+          properties: {
+            sessionID: 'session-answer-claims',
+            status: { type: 'idle' },
+          },
+        },
+      });
+      expect(failedFirstAck).toBe(true);
+      expect(ctx2.client.session.promptAsync).toHaveBeenCalledTimes(1);
+
+      const recoveredAck = await originalFetch(
+        `${interviewUrl}/pending/ack${authQuery}`,
+        {
+          method: 'POST',
+          headers: { 'content-type': 'application/json' },
+          body: JSON.stringify({ claimId: firstClaim.claimId }),
+        },
+      );
+      expect(recoveredAck.status).toBe(200);
+
+      const secondAnswer = await originalFetch(
+        `${interviewUrl}/answers${authQuery}`,
+        {
+          method: 'POST',
+          headers: { 'content-type': 'application/json' },
+          body: JSON.stringify({
+            answers: [{ questionId: 'q-1', answer: 'Second answer' }],
+          }),
+        },
+      );
+      expect(secondAnswer.status).toBe(202);
+
+      await manager2.handleEvent({
+        event: {
+          type: 'session.status',
+          properties: {
+            sessionID: 'session-answer-claims',
+            status: { type: 'idle' },
+          },
+        },
+      });
+      expect(ctx2.client.session.promptAsync).toHaveBeenCalledTimes(2);
+    } finally {
+      globalThis.fetch = originalFetch;
+      await fs.rm(tempDir1, { recursive: true, force: true });
+      await fs.rm(tempDir2, { recursive: true, force: true });
+    }
+  });
+});
+
+describe('interview manager - dashboard election failure fallback', () => {
+  test('falls back to per-session mode when tryBecomeDashboard fails and dashboard is unreachable', async () => {
+    // Create a TCP server that blocks a port but immediately destroys
+    // connections. This simulates a port in use by a non-dashboard
+    // process, causing:
+    //   1. tryBecomeDashboard → probes fail, bind fails (EADDRINUSE),
+    //      returns null after retries
+    //   2. probeDashboard × 2 → fails (no valid HTTP response)
+    //   3. Throws → caught → falls back via createPerSessionInterviewServer
+    const tcpServer = createNetServer((socket) => {
+      socket.destroy();
+    });
+
+    const port = await new Promise<number>((resolve) => {
+      tcpServer.listen(0, () => {
+        const address = tcpServer.address();
+        if (address && typeof address !== 'string') {
+          resolve(address.port);
+        } else {
+          resolve(0);
+        }
+      });
+    });
+
+    const tempDir = await fs.mkdtemp('/tmp/manager-test-');
+    const ctx = createMockContext({ directory: tempDir });
+    const config = createTestConfig({
+      port,
+      dashboard: true,
+    });
+
+    try {
+      const manager = createInterviewManager(ctx, config);
+
+      // handleCommandExecuteBefore calls ensureInitialized internally,
+      // which awaits initPromise. This naturally waits for all retries,
+      // probes, and fallback logic to complete before proceeding.
+      const output = {
+        parts: [] as Array<{ type: string; text?: string }>,
+      };
+      await manager.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID: 'session-fallback',
+          arguments: 'Fallback Test Idea',
+        },
+        output,
+      );
+
+      // Verify interview was created in per-session fallback mode
+      expect(output.parts.length).toBe(1);
+      expect(output.parts[0].type).toBe('text');
+      expect(output.parts[0].text).toContain('Fallback Test Idea');
+      expect(output.parts[0].text).toContain('<interview_state>');
+
+      // Verify interview file was created (per-session mode writes markdown)
+      const interviewDir = `${tempDir}/interview`;
+      const files = await fs.readdir(interviewDir);
+      expect(files.length).toBe(1);
+    } finally {
+      await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
+      tcpServer.close();
+    }
+  });
+});

+ 37 - 0
src/interview/manager.ts

@@ -0,0 +1,37 @@
+import type { PluginInput } from '@opencode-ai/plugin';
+import type { PluginConfig } from '../config';
+import { DEFAULT_DASHBOARD_PORT } from './dashboard';
+import { createDashboardManager } from './dashboard-manager';
+import { createPerSessionInterviewServer } from './session-server';
+
+export function createInterviewManager(
+  ctx: PluginInput,
+  config: PluginConfig,
+): {
+  registerCommand: (config: Record<string, unknown>) => void;
+  handleCommandExecuteBefore: (
+    input: { command: string; sessionID: string; arguments: string },
+    output: { parts: Array<{ type: string; text?: string }> },
+  ) => Promise<void>;
+  handleEvent: (input: {
+    event: { type: string; properties?: Record<string, unknown> };
+  }) => Promise<void>;
+  dispose: () => Promise<void> | void;
+} {
+  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) {
+    return createPerSessionInterviewServer(ctx, interviewConfig, outputFolder);
+  }
+
+  // ─── Dashboard mode ─────────────────────────────────────────────
+  const dashboardPort =
+    effectivePort > 0 ? effectivePort : DEFAULT_DASHBOARD_PORT;
+
+  return createDashboardManager(ctx, config, dashboardPort, outputFolder);
+}

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

@@ -0,0 +1,309 @@
+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);
+  });
+
+  test('repairs unescaped newlines inside strings and handles backslash escapes correctly', () => {
+    // Let's use raw unescaped newlines inside the JSON string to test the parser's repair:
+    const textWithLiteralNewlines = [
+      '<interview_state>',
+      '{',
+      '  "summary": "This is a summary',
+      'with a newline and escaped \\"quotes\\" and \\\\ backslash.",',
+      '  "questions": []',
+      '}',
+      '</interview_state>',
+    ].join('\n');
+    const result = parseAssistantState(textWithLiteralNewlines);
+
+    expect(result.state).not.toBeNull();
+    expect(result.state?.summary).toBe(
+      'This is a summary\nwith a newline and escaped "quotes" and \\ backslash.',
+    );
+  });
+});
+
+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');
+  });
+});

+ 201 - 0
src/interview/parser.ts

@@ -0,0 +1,201 @@
+import type {
+  InterviewAssistantState,
+  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: unknown,
+  index: number,
+): InterviewQuestion | null {
+  // Validate raw question object with Zod
+  const result = RawQuestionSchema.safeParse(value);
+  if (!result.success) {
+    return null;
+  }
+  const question =
+    typeof result.data.question === 'string' ? result.data.question.trim() : '';
+  if (!question) {
+    return null;
+  }
+
+  const options = Array.isArray(result.data.options)
+    ? result.data.options
+        .filter((option): option is string => typeof option === 'string')
+        .map((option) => option.trim())
+        .filter(Boolean)
+        .slice(0, 4)
+    : [];
+
+  return {
+    id:
+      typeof result.data.id === 'string' && result.data.id.trim().length > 0
+        ? result.data.id.trim()
+        : `q-${index + 1}`,
+    question,
+    options,
+    suggested:
+      typeof result.data.suggested === 'string' &&
+      result.data.suggested.trim().length > 0
+        ? result.data.suggested.trim()
+        : undefined,
+  };
+}
+
+function repairJsonNewlines(json: string): string {
+  let result = '';
+  let inString = false;
+  let escaped = false;
+  for (let i = 0; i < json.length; i++) {
+    const char = json[i];
+    if (inString) {
+      if (escaped) {
+        result += char;
+        escaped = false;
+      } else if (char === '\\') {
+        result += char;
+        escaped = true;
+      } else if (char === '"') {
+        result += char;
+        inString = false;
+      } else if (char === '\n') {
+        result += '\\n';
+      } else if (char === '\r') {
+        result += '\\r';
+      } else {
+        result += char;
+      }
+    } else {
+      if (char === '"') {
+        inString = true;
+      }
+      result += char;
+    }
+  }
+  return result;
+}
+
+export function flattenMessage(message: InterviewMessage): string {
+  return (message.parts ?? [])
+    .map((part) => part.text ?? '')
+    .join('\n')
+    .trim();
+}
+
+export function buildFallbackState(
+  messages: InterviewMessage[],
+): InterviewAssistantState {
+  const answerCount = messages.filter(
+    (message) => message.info?.role === 'user',
+  ).length;
+
+  return {
+    summary:
+      answerCount > 0
+        ? 'Interview in progress.'
+        : 'Waiting for the first interview response.',
+    questions: [],
+  };
+}
+
+export function parseAssistantState(
+  text: string,
+  maxQuestions = 2,
+): {
+  state: InterviewAssistantState | null;
+  error?: string;
+} {
+  const match = text.match(INTERVIEW_BLOCK_REGEX);
+  if (!match) {
+    return { state: null };
+  }
+
+  // Pre-process match[1] to repair common JSON escaping issues (e.g. unescaped newlines inside strings)
+  let rawJson = match[1].trim();
+
+  // A robust heuristic to escape literal carriage returns/newlines inside JSON string values
+  // so JSON.parse doesn't throw "JSON Parse error: Expected '}'" or "Unexpected token".
+  // This is safe because it only targets characters within quotes.
+  try {
+    // If it parses directly, great!
+    JSON.parse(rawJson);
+  } catch {
+    // Try to normalize literal newlines inside string values:
+    rawJson = repairJsonNewlines(rawJson);
+  }
+
+  try {
+    const raw = JSON.parse(rawJson);
+    // 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 =
+      typeof parsed.title === 'string' && parsed.title.trim().length > 0
+        ? parsed.title.trim()
+        : undefined;
+    const questions = Array.isArray(parsed.questions)
+      ? parsed.questions
+          .map((value, index) => normalizeQuestion(value, index))
+          .filter((value): value is InterviewQuestion => value !== null)
+          .slice(0, maxQuestions)
+      : [];
+
+    return {
+      state: {
+        summary,
+        title,
+        questions,
+      },
+    };
+  } catch (error) {
+    return {
+      state: null,
+      error:
+        error instanceof Error
+          ? error.message
+          : 'Failed to parse interview state',
+    };
+  }
+}
+
+export function findLatestAssistantState(
+  messages: InterviewMessage[],
+  maxQuestions = 2,
+): {
+  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') {
+      continue;
+    }
+
+    const parsed = parseAssistantState(flattenMessage(message), maxQuestions);
+    if (parsed.state) {
+      return {
+        state: parsed.state,
+        latestAssistantError,
+      };
+    }
+
+    if (!latestAssistantError) {
+      latestAssistantError = parsed.error ?? 'Missing <interview_state> block';
+    }
+  }
+
+  return {
+    state: null,
+    latestAssistantError,
+  };
+}

+ 143 - 0
src/interview/prompts.ts

@@ -0,0 +1,143 @@
+import type { InterviewQuestion } from './types';
+
+function formatQuestionContext(questions: InterviewQuestion[]): string {
+  if (questions.length === 0) {
+    return 'No current interview questions were parsed.';
+  }
+
+  return questions
+    .map((question, index) => {
+      const options = question.options.length
+        ? `Options: ${question.options.join(' | ')}`
+        : 'Options: freeform';
+      const suggested = question.suggested
+        ? `Suggested: ${question.suggested}`
+        : 'Suggested: none';
+      return `${index + 1}. ${question.question}\n${options}\n${suggested}`;
+    })
+    .join('\n\n');
+}
+
+const SPECIFICATION_TEMPLATE_GUIDELINE = `
+Your target document MUST be structured strictly using the following 11-section template (exclude the frontmatter itself from the summary JSON field, as the tool handles frontmatter automatically):
+
+# Introduction
+[Short intro to the spec and goals]
+
+## 1. Purpose & Scope
+[Intended audience, boundaries, and assumptions]
+
+## 2. Definitions
+[Acronyms, terms defined]
+
+## 3. Requirements, Constraints & Guidelines
+[Explicitly list requirements using:
+- **REQ-001**: Description
+- **SEC-001**: Security constraints
+- **CON-001**: System constraints/technologies
+- **GUD-001**: Guidelines]
+
+## 4. Interfaces & Data Contracts
+[APIs, JSON schemas, protocol buffers, or class/data structures]
+
+## 5. Acceptance Criteria
+[Define testable criteria in Given-When-Then format:
+- **AC-001**: Given [context], When [action], Then [expected outcome]]
+
+## 6. Test Automation Strategy
+[Mocking approach, test framework, unit/integration details]
+
+## 7. Rationale & Context
+[Trade-offs and architectural decisions]
+
+## 8. Dependencies & External Integrations
+[Conceptual integrations or external dependencies:
+- **EXT-001**: Dependency details]
+
+## 9. Examples & Edge Cases
+[Concrete code examples, settings, or JSON structures]
+
+## 10. Validation Criteria
+[Testing or validation check logic]
+
+## 11. Related Specifications / Further Reading
+[Internal doc references]
+
+ANTI-ASSUMPTION & SUB-AGENT DELEGATION RULE:
+Do not invent file structures, API signatures, package lists, or library behaviors. If you need to trace local code, verify file paths, or check configurations, you MUST call the sub-agent '@explorer' or search files directly. If you need to search documentation or web info for external APIs/libraries, you MUST call the sub-agent '@librarian' or search the web. Do not guess. Pause, run discovery, and integrate facts into the spec.
+`;
+
+export function buildKickoffPrompt(idea: string, maxQuestions: number): string {
+  return [
+    'You are running an interview q&a session for the user inside their repository.',
+    `Initial idea: ${idea}`,
+    `Goal: Iteratively generate and populate a highly structured Specification document.`,
+    SPECIFICATION_TEMPLATE_GUIDELINE,
+    `Clarify the idea through short rounds of at most ${maxQuestions} questions at a time.`,
+    'When useful, each question may include 2 to 4 answer options and one suggested option.',
+    'Be practical. Focus on the highest-ambiguity and highest-risk decisions first.',
+    'After any short human-friendly preface, you MUST include a machine-readable block in this exact format:',
+    '<interview_state>',
+    '{',
+    '  "summary": "Full specification markdown (strictly matching the 11 section titles above)",',
+    '  "title": "concise-kebab-case-title-for-filename",',
+    '  "questions": [',
+    '    {',
+    '      "id": "short-kebab-id-2",',
+    '      "question": "question text",',
+    '      "options": ["option 1", "option 2", "option 3"],',
+    '      "suggested": "best suggested option"',
+    '    }',
+    '  ]',
+    '}',
+    '</interview_state>',
+    'Rules:',
+    `- Return 0 to ${maxQuestions} questions.`,
+    '- If there are no more useful questions or the specification is complete, return zero questions.',
+    `- Do not ask more than ${maxQuestions} questions in one round.`,
+    '- Provide a concise "title" field (kebab-case, 3-6 words) suitable for a filename.',
+  ].join('\n');
+}
+
+export function buildResumePrompt(
+  document: string,
+  maxQuestions: number,
+): string {
+  return [
+    'Resume the interview from this existing markdown document.',
+    'Use the current spec and Q&A history as ground truth so far.',
+    SPECIFICATION_TEMPLATE_GUIDELINE,
+    'Do not restart from scratch.',
+    '',
+    document,
+    '',
+    `Ask the next highest-value clarifying questions, up to ${maxQuestions} at a time.`,
+    'If there are no more useful questions or the spec is complete, return zero questions.',
+    'Return the same <interview_state> JSON block format as before.',
+  ].join('\n');
+}
+
+export function buildAnswerPrompt(
+  answers: Array<{ questionId: string; answer: string }>,
+  questions: InterviewQuestion[],
+  maxQuestions: number,
+): string {
+  const answerText = answers
+    .map(
+      (answer, index) =>
+        `${index + 1}. ${answer.questionId}: ${answer.answer.trim()}`,
+    )
+    .join('\n');
+
+  return [
+    'Continue the same interview.',
+    SPECIFICATION_TEMPLATE_GUIDELINE,
+    'These were the active questions:',
+    formatQuestionContext(questions),
+    'The user answered:',
+    answerText,
+    'Now update the specification summary document and ask the next highest-value clarifying questions.',
+    `Return 0 to ${maxQuestions} questions. If there are no more useful questions or the spec is complete, return zero questions.`,
+    'Return the same <interview_state> JSON block format as before.',
+  ].join('\n\n');
+}

+ 46 - 0
src/interview/runtime.test.ts

@@ -0,0 +1,46 @@
+import { describe, expect, mock, test } from 'bun:test';
+import { createV1InterviewSessionRuntime } from './runtime';
+
+describe('v1 interview session runtime', () => {
+  test('keeps SDK calls nested under client.session', async () => {
+    const session = {
+      messages: mock(async () => ({ data: [{ info: { role: 'user' } }] })),
+      prompt: mock(async () => ({})),
+      promptAsync: mock(async () => ({})),
+      update: mock(async () => ({})),
+    };
+    const client = { session };
+    const runtime = createV1InterviewSessionRuntime({
+      client,
+    } as never);
+
+    await expect(runtime.messages('ses_1')).resolves.toEqual([
+      { info: { role: 'user' } },
+    ]);
+    await runtime.notify('ses_1', 'ready');
+    await runtime.continue('ses_1', 'next', {
+      providerID: 'openai',
+      modelID: 'gpt-5',
+    });
+    await runtime.rename('ses_1', 'Interview: app');
+
+    expect(session.messages).toHaveBeenCalledWith({ path: { id: 'ses_1' } });
+    expect(session.prompt).toHaveBeenCalledWith({
+      path: { id: 'ses_1' },
+      body: { noReply: true, parts: [{ type: 'text', text: 'ready' }] },
+    });
+    expect(session.promptAsync).toHaveBeenCalledWith(
+      expect.objectContaining({
+        path: { id: 'ses_1' },
+        body: expect.objectContaining({
+          agent: 'orchestrator',
+          model: { providerID: 'openai', modelID: 'gpt-5' },
+        }),
+      }),
+    );
+    expect(session.update).toHaveBeenCalledWith({
+      path: { id: 'ses_1' },
+      body: { title: 'Interview: app' },
+    });
+  });
+});

+ 57 - 0
src/interview/runtime.ts

@@ -0,0 +1,57 @@
+import type { PluginInput } from '@opencode-ai/plugin';
+import { createInternalAgentTextPart } from '../utils/internal-initiator';
+import type { InterviewMessage } from './types';
+
+export interface InterviewSessionRuntime {
+  messages(sessionID: string): Promise<InterviewMessage[]>;
+  notify(sessionID: string, text: string): Promise<void>;
+  continue(
+    sessionID: string,
+    text: string,
+    model?: { providerID: string; modelID: string },
+  ): Promise<void>;
+  rename(sessionID: string, title: string): Promise<void>;
+}
+
+/** The v1 implementation deliberately stays inside the interview boundary. */
+export function createV1InterviewSessionRuntime(
+  ctx: PluginInput,
+): InterviewSessionRuntime {
+  const client = ctx.client;
+
+  return {
+    async messages(sessionID) {
+      const result = await client.session.messages({
+        path: { id: sessionID },
+      });
+      return result.data as InterviewMessage[];
+    },
+    async notify(sessionID, text) {
+      await client.session.prompt({
+        path: { id: sessionID },
+        body: {
+          noReply: true,
+          parts: [{ type: 'text', text }],
+        },
+      });
+    },
+    async continue(sessionID, text, model) {
+      await client.session.promptAsync({
+        path: { id: sessionID },
+        body: {
+          agent: 'orchestrator',
+          parts: [createInternalAgentTextPart(text)],
+          ...(model ? { model } : {}),
+        },
+      });
+    },
+    async rename(sessionID, title) {
+      await client.session.update({
+        path: { id: sessionID },
+        body: { title },
+      });
+    },
+  };
+}
+
+export const createInterviewSessionRuntime = createV1InterviewSessionRuntime;

+ 373 - 0
src/interview/server.ts

@@ -0,0 +1,373 @@
+import {
+  createServer,
+  type IncomingMessage,
+  type Server,
+  type ServerResponse,
+} from 'node:http';
+import { URL } from 'node:url';
+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) {
+    return 400;
+  }
+
+  const message = error instanceof Error ? error.message : '';
+  if (message === 'Interview not found') {
+    return 404;
+  }
+  if (message.includes('busy')) {
+    return 409;
+  }
+  if (
+    message.includes('waiting for a valid agent update') ||
+    message.includes('There are no active interview questions') ||
+    message.includes('Answer every active interview question') ||
+    message.includes('Answers do not match') ||
+    message.includes('Request body too large') ||
+    message.includes('Invalid answers payload') ||
+    message.includes('no longer active')
+  ) {
+    return 400;
+  }
+
+  return 500;
+}
+
+function parseAnswersPayload(value: unknown): { answers: InterviewAnswer[] } {
+  if (!value || typeof value !== 'object') {
+    throw new Error('Invalid answers payload.');
+  }
+  const answersRaw = (value as { answers?: unknown }).answers;
+  if (!Array.isArray(answersRaw)) {
+    throw new Error('Invalid answers payload.');
+  }
+
+  return {
+    answers: answersRaw.map((answer) => {
+      if (!answer || typeof answer !== 'object') {
+        throw new Error('Invalid answers payload.');
+      }
+      const record = answer as { questionId?: unknown; answer?: unknown };
+      if (
+        typeof record.questionId !== 'string' ||
+        typeof record.answer !== 'string'
+      ) {
+        throw new Error('Invalid answers payload.');
+      }
+      return {
+        questionId: record.questionId.trim(),
+        answer: record.answer.trim(),
+      };
+    }),
+  };
+}
+
+export function createInterviewServer(deps: {
+  getState: (interviewId: string) => Promise<InterviewState>;
+  listInterviewFiles: () => Promise<InterviewFileItem[]>;
+  listInterviews: () => InterviewListItem[];
+  submitAnswers: (
+    interviewId: string,
+    answers: InterviewAnswer[],
+  ) => Promise<void>;
+  submitBlockComment: (
+    interviewId: string,
+    section: string,
+    comment: string,
+  ) => Promise<void>;
+  submitChat: (interviewId: string, message: string) => Promise<void>;
+  handleNudgeAction: (
+    interviewId: string,
+    action: 'more-questions' | 'confirm-complete',
+  ) => Promise<void>;
+  outputFolder: string;
+  port: number;
+}): {
+  ensureStarted: () => Promise<string>;
+  close: () => void;
+} {
+  let baseUrl: string | null = null;
+  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,
+  ): Promise<void> {
+    let url: URL;
+    try {
+      url = new URL(request.url ?? '/', 'http://127.0.0.1');
+    } catch {
+      sendJson(response, 400, { error: 'Invalid request URL' });
+      return;
+    }
+    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/')) {
+      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(decodeURIComponent(stateMatch[1]));
+        sendJson(response, 200, state);
+      } catch (error) {
+        const message =
+          error instanceof Error ? error.message : 'Interview not found';
+        const status = message === 'Interview not found' ? 404 : 500;
+        sendJson(response, status, { error: message });
+      }
+      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(
+          decodeURIComponent(answersMatch[1]),
+          body.answers,
+        );
+        sendJson(response, 200, {
+          ok: true,
+          message: 'Answers submitted to the OpenCode session.',
+        });
+      } catch (error) {
+        const message =
+          error instanceof Error ? error.message : 'Failed to submit answers.';
+        const status = getSubmissionStatus(error);
+        sendJson(response, status, {
+          ok: false,
+          message,
+        });
+      }
+      return;
+    }
+
+    const blockCommentMatch = pathname.match(
+      /^\/api\/interviews\/([^/]+)\/block-comment$/,
+    );
+    if (request.method === 'POST' && blockCommentMatch) {
+      try {
+        const body = (await readJsonBody(request)) as {
+          section?: string;
+          comment?: string;
+        };
+        if (
+          typeof body.section !== 'string' ||
+          typeof body.comment !== 'string'
+        ) {
+          sendJson(response, 400, {
+            error: 'section and comment must be strings',
+          });
+          return;
+        }
+        await deps.submitBlockComment(
+          decodeURIComponent(blockCommentMatch[1]),
+          body.section,
+          body.comment,
+        );
+        sendJson(response, 200, {
+          ok: true,
+          message: 'Block feedback forwarded.',
+        });
+      } catch (error) {
+        const message =
+          error instanceof Error
+            ? error.message
+            : 'Failed to submit block comment.';
+        const status = getSubmissionStatus(error);
+        sendJson(response, status, { ok: false, message });
+      }
+      return;
+    }
+
+    // ── Chat: freeform message to agent ─────────────────────────────
+    const chatMatch = pathname.match(/^\/api\/interviews\/([^/]+)\/chat$/);
+    if (request.method === 'POST' && chatMatch) {
+      try {
+        const body = (await readJsonBody(request)) as {
+          message?: string;
+        };
+        if (typeof body.message !== 'string' || !body.message.trim()) {
+          sendJson(response, 400, {
+            error: 'message must be a non-empty string',
+          });
+          return;
+        }
+        await deps.submitChat(
+          decodeURIComponent(chatMatch[1]),
+          body.message.trim(),
+        );
+        sendJson(response, 200, {
+          ok: true,
+          message: 'Chat message forwarded to agent.',
+        });
+      } catch (error) {
+        const message =
+          error instanceof Error
+            ? error.message
+            : 'Failed to submit chat message.';
+        const status = getSubmissionStatus(error);
+        sendJson(response, status, { ok: false, message });
+      }
+      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' });
+  }
+
+  async function ensureStarted(): Promise<string> {
+    if (baseUrl) {
+      return baseUrl;
+    }
+
+    if (startPromise) {
+      return startPromise;
+    }
+
+    startPromise = new Promise((resolve, reject) => {
+      const server = createServer((request, response) => {
+        handle(request, response).catch((error) => {
+          sendJson(response, 500, {
+            error:
+              error instanceof Error ? error.message : 'Internal server error',
+          });
+        });
+      });
+      server.requestTimeout = 30_000;
+      server.headersTimeout = 10_000;
+
+      activeServer = server;
+
+      server.on('error', (error: NodeJS.ErrnoException) => {
+        server.close();
+        activeServer = null;
+        startPromise = null;
+        if (error.code === 'EADDRINUSE') {
+          reject(
+            new Error(
+              `Interview server port ${deps.port} is already in use. Choose a different port or set port to 0 for an OS-assigned port.`,
+            ),
+          );
+        } else {
+          reject(error);
+        }
+      });
+
+      server.listen(deps.port, '127.0.0.1', () => {
+        const address = server.address();
+        if (!address || typeof address === 'string') {
+          startPromise = null;
+          reject(new Error('Failed to start interview server'));
+          return;
+        }
+
+        baseUrl = `http://127.0.0.1:${address.port}`;
+        resolve(baseUrl);
+      });
+    });
+
+    return startPromise;
+  }
+
+  return {
+    ensureStarted,
+    close: () => {
+      if (activeServer) {
+        activeServer.closeAllConnections();
+        activeServer.close();
+        activeServer = null;
+      }
+      baseUrl = null;
+      startPromise = null;
+    },
+  };
+}

+ 1076 - 0
src/interview/service.ts

@@ -0,0 +1,1076 @@
+import { spawn } from 'node:child_process';
+import * as fs from 'node:fs/promises';
+import * as path from 'node:path';
+import type { PluginInput } from '@opencode-ai/plugin';
+import type { InterviewConfig } from '../config';
+import {
+  createInternalAgentTextPart,
+  isInternalInitiatorPart,
+  log,
+} from '../utils';
+import { parseModelReference } from '../utils/session';
+import {
+  appendInterviewAnswers,
+  createInterviewDirectoryPath,
+  createInterviewFilePath,
+  DEFAULT_OUTPUT_FOLDER,
+  ensureInterviewFile,
+  extractSummarySection,
+  extractTitle,
+  normalizeOutputFolder,
+  parseSpecBlocks,
+  readInterviewDocument,
+  relativeInterviewPath,
+  resolveExistingInterviewPath,
+  rewriteInterviewDocument,
+  rewriteInterviewDocumentWithFinalSpec,
+  slugify,
+} from './document';
+import {
+  buildFallbackState,
+  findLatestAssistantState,
+  flattenMessage,
+} from './parser';
+import {
+  buildAnswerPrompt,
+  buildKickoffPrompt,
+  buildResumePrompt,
+} from './prompts';
+import {
+  createV1InterviewSessionRuntime,
+  type InterviewSessionRuntime,
+} from './runtime';
+import type {
+  InterviewAnswer,
+  InterviewFileItem,
+  InterviewListItem,
+  InterviewMessage,
+  InterviewRecord,
+  InterviewState,
+} from './types';
+
+const COMMAND_NAME = 'interview';
+const DEFAULT_MAX_QUESTIONS = 2;
+
+/**
+ * Cap on retained abandoned interview records. Abandoned interviews are kept
+ * briefly so a still-open browser tab can render their final state, but
+ * without a bound the `interviewsById` and `browserOpened` collections grow
+ * for the life of a long-running session/dashboard process.
+ */
+export const MAX_RETAINED_ABANDONED = 50;
+
+function isTruthyEnvFlag(value: string | undefined): boolean {
+  if (!value) {
+    return false;
+  }
+
+  return value !== '0' && value.toLowerCase() !== 'false';
+}
+
+function isAutomatedRuntime(env: NodeJS.ProcessEnv): boolean {
+  return (
+    env.NODE_ENV === 'test' ||
+    isTruthyEnvFlag(env.CI) ||
+    isTruthyEnvFlag(env.BUN_TEST) ||
+    isTruthyEnvFlag(env.VITEST) ||
+    env.JEST_WORKER_ID !== undefined
+  );
+}
+
+function shouldAutoOpenBrowser(
+  config: InterviewConfig | undefined,
+  env: NodeJS.ProcessEnv,
+): boolean {
+  const requested = config?.autoOpenBrowser ?? true;
+  return requested && !isAutomatedRuntime(env);
+}
+
+/**
+ * Open a URL in the default browser.
+ * Supports macOS, Linux, and Windows. Failures are logged but not thrown.
+ */
+function openBrowser(url: string): void {
+  const platform = process.platform;
+  let command: string;
+  let args: string[];
+
+  if (platform === 'darwin') {
+    command = 'open';
+    args = [url];
+  } else if (platform === 'win32') {
+    command = 'cmd';
+    args = ['/c', 'start', '', url];
+  } else {
+    // Linux and other Unix-like systems
+    command = 'xdg-open';
+    args = [url];
+  }
+
+  try {
+    const child = spawn(command, args, { detached: true, stdio: 'ignore' });
+    child.on('error', (error) => {
+      log('[interview] failed to open browser:', { error: error.message, url });
+    });
+    child.unref();
+  } catch (error) {
+    log('[interview] failed to spawn browser opener:', {
+      error: error instanceof Error ? error.message : String(error),
+      url,
+    });
+  }
+}
+
+function nowIso(): string {
+  return new Date().toISOString();
+}
+
+export function createInterviewService(
+  ctx: PluginInput,
+  config?: InterviewConfig,
+  deps?: {
+    openBrowser?: (url: string) => void;
+    env?: NodeJS.ProcessEnv;
+    runtime?: InterviewSessionRuntime;
+  },
+): {
+  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 },
+    output: {
+      parts: Array<{
+        type: string;
+        text?: string;
+        synthetic?: boolean;
+        metadata?: Record<string, unknown>;
+      }>;
+    },
+  ) => Promise<void>;
+  handleEvent: (input: {
+    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>;
+  submitBlockComment: (
+    interviewId: string,
+    section: string,
+    comment: string,
+  ) => Promise<void>;
+  submitChat: (interviewId: string, message: string) => Promise<void>;
+  handleNudgeAction: (
+    interviewId: string,
+    action: 'more-questions' | 'confirm-complete',
+  ) => Promise<void>;
+} {
+  const maxQuestions = config?.maxQuestions ?? DEFAULT_MAX_QUESTIONS;
+  const outputFolder = normalizeOutputFolder(
+    config?.outputFolder ?? DEFAULT_OUTPUT_FOLDER,
+  );
+  const autoOpenBrowser = shouldAutoOpenBrowser(
+    config,
+    deps?.env ?? process.env,
+  );
+  const browserOpener = deps?.openBrowser ?? openBrowser;
+  const sessionRuntime = deps?.runtime ?? createV1InterviewSessionRuntime(ctx);
+  const activeInterviewIds = new Map<string, string>();
+  const interviewsById = new Map<string, InterviewRecord>();
+  const activeSyncs = new Map<string, Promise<InterviewState>>();
+  const sessionBusy = new Map<string, boolean>();
+  const sessionModel = new Map<string, string>();
+  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;
+  let abandonedOrderCounter = 0;
+  const finalizationPending = new Set<string>();
+  const finalizationReady = new Set<string>();
+
+  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');
+    }
+    return resolveBaseUrl();
+  }
+
+  function maybeOpenBrowser(interviewId: string, url: string): void {
+    if (!autoOpenBrowser) {
+      return;
+    }
+    if (browserOpened.has(interviewId)) {
+      return;
+    }
+    browserOpened.add(interviewId);
+    browserOpener(url);
+  }
+
+  async function maybeRenameWithTitle(
+    interview: InterviewRecord,
+    assistantTitle: string | undefined,
+  ): Promise<void> {
+    if (!assistantTitle) {
+      return;
+    }
+    const newSlug = slugify(assistantTitle);
+    if (!newSlug) {
+      return;
+    }
+
+    const currentFileName = path.basename(interview.markdownPath, '.md');
+    // If already matches (or user-provided idea matches), skip
+    if (currentFileName === newSlug) {
+      return;
+    }
+
+    const dir = path.dirname(interview.markdownPath);
+    const newPath = path.join(dir, `${newSlug}.md`);
+
+    // Don't overwrite existing files
+    try {
+      await fs.access(newPath);
+      // File exists, don't rename
+      return;
+    } catch {
+      // File doesn't exist, safe to rename
+    }
+
+    try {
+      await fs.rename(interview.markdownPath, newPath);
+      interview.markdownPath = newPath;
+      log('[interview] renamed file with assistant title:', {
+        from: currentFileName,
+        to: newSlug,
+      });
+    } catch (error) {
+      log('[interview] failed to rename file:', {
+        error: error instanceof Error ? error.message : String(error),
+      });
+    }
+  }
+
+  async function loadMessages(sessionID: string): Promise<InterviewMessage[]> {
+    return sessionRuntime.messages(sessionID);
+  }
+
+  async function loadMessagesWithRetry(
+    sessionID: string,
+  ): Promise<InterviewMessage[]> {
+    for (let i = 0; i < 8; i++) {
+      const messages = await loadMessages(sessionID);
+      if (messages.length > 0) {
+        const last = messages[messages.length - 1];
+        if (last?.info?.role === 'assistant') {
+          return messages;
+        }
+      }
+      await new Promise((resolve) => setTimeout(resolve, 250));
+    }
+    return loadMessages(sessionID);
+  }
+
+  function isUserVisibleMessage(message: InterviewMessage): boolean {
+    return !(message.parts ?? []).some((part) => isInternalInitiatorPart(part));
+  }
+
+  function getInterviewById(interviewId: string): InterviewRecord | null {
+    return interviewsById.get(interviewId) ?? null;
+  }
+
+  /**
+   * Mark an interview abandoned and prune the oldest abandoned records so the
+   * in-memory registry (and its browser-open tracking) stays bounded.
+   */
+  function abandonInterview(interview: InterviewRecord): void {
+    if (interview.status !== 'abandoned') {
+      interview.abandonedAt = nowIso();
+      interview.abandonedOrder = ++abandonedOrderCounter;
+    }
+    interview.status = 'abandoned';
+    pruneAbandonedInterviews();
+  }
+
+  function pruneAbandonedInterviews(): void {
+    const abandoned = [...interviewsById.values()].filter(
+      (record) => record.status === 'abandoned',
+    );
+    const overflow = abandoned.length - MAX_RETAINED_ABANDONED;
+    if (overflow <= 0) return;
+    abandoned
+      .sort((a, b) => {
+        const timeDelta =
+          new Date(a.abandonedAt ?? a.createdAt).getTime() -
+          new Date(b.abandonedAt ?? b.createdAt).getTime();
+        if (timeDelta !== 0) return timeDelta;
+        return (a.abandonedOrder ?? 0) - (b.abandonedOrder ?? 0);
+      })
+      .slice(0, overflow)
+      .forEach((record) => {
+        interviewsById.delete(record.id);
+        browserOpened.delete(record.id);
+      });
+  }
+
+  async function createInterview(
+    sessionID: string,
+    idea: string,
+  ): Promise<InterviewRecord> {
+    const normalizedIdea = idea.trim();
+    const activeId = activeInterviewIds.get(sessionID);
+    if (activeId) {
+      const active = interviewsById.get(activeId);
+      if (active && active.status === 'active') {
+        if (active.idea === normalizedIdea) {
+          return active;
+        }
+
+        abandonInterview(active);
+      }
+    }
+
+    const messages = await loadMessages(sessionID);
+    const record: InterviewRecord = {
+      id: `${Date.now()}-${++idCounter}-${slugify(idea) || 'interview'}`,
+      sessionID,
+      idea: normalizedIdea,
+      markdownPath: createInterviewFilePath(ctx.directory, outputFolder, idea),
+      createdAt: nowIso(),
+      status: 'active',
+      baseMessageCount: messages.length,
+    };
+
+    await ensureInterviewFile(record);
+    activeInterviewIds.set(sessionID, record.id);
+    interviewsById.set(record.id, record);
+    fileCache = null;
+
+    if (onInterviewCreated) {
+      onInterviewCreated(record);
+    }
+    return record;
+  }
+
+  async function resumeInterview(
+    sessionID: string,
+    markdownPath: string,
+  ): Promise<InterviewRecord> {
+    const activeId = activeInterviewIds.get(sessionID);
+    if (activeId) {
+      const active = interviewsById.get(activeId);
+      if (active && active.status === 'active') {
+        if (active.markdownPath === markdownPath) {
+          return active;
+        }
+
+        abandonInterview(active);
+      }
+    }
+
+    const document = await fs.readFile(markdownPath, 'utf8');
+    const messages = await loadMessages(sessionID);
+    const title = extractTitle(document);
+    const record: InterviewRecord = {
+      id: `${Date.now()}-${++idCounter}-${slugify(path.basename(markdownPath, '.md')) || 'interview'}`,
+      sessionID,
+      idea: title || path.basename(markdownPath, '.md'),
+      markdownPath,
+      createdAt: nowIso(),
+      status: 'active',
+      baseMessageCount: messages.length,
+    };
+
+    activeInterviewIds.set(sessionID, record.id);
+    interviewsById.set(record.id, record);
+    fileCache = null;
+
+    if (onInterviewCreated) {
+      onInterviewCreated(record);
+    }
+    return record;
+  }
+
+  function syncInterview(interview: InterviewRecord): Promise<InterviewState> {
+    const existing = activeSyncs.get(interview.id);
+    if (existing) {
+      return existing;
+    }
+
+    const sync = performSyncInterview(interview).finally(() => {
+      activeSyncs.delete(interview.id);
+    });
+    activeSyncs.set(interview.id, sync);
+    return sync;
+  }
+
+  async function performSyncInterview(
+    interview: InterviewRecord,
+  ): Promise<InterviewState> {
+    const allMessages = await loadMessagesWithRetry(interview.sessionID);
+    const interviewMessages = allMessages
+      .slice(interview.baseMessageCount)
+      .filter(isUserVisibleMessage);
+    const latestAssistant = [...interviewMessages]
+      .reverse()
+      .find((message) => message.info?.role === 'assistant');
+    const latestAssistantText = latestAssistant
+      ? flattenMessage(latestAssistant)
+      : '';
+    const isCleanFinalResponse =
+      finalizationPending.has(interview.id) &&
+      finalizationReady.has(interview.id) &&
+      latestAssistantText.length > 0 &&
+      !/<interview_state>/i.test(latestAssistantText);
+    const parsed = isCleanFinalResponse
+      ? { state: null, latestAssistantError: undefined }
+      : findLatestAssistantState(interviewMessages, maxQuestions);
+    const existingDocument = await readInterviewDocument(interview);
+    const fallbackState = buildFallbackState(interviewMessages);
+    const state = parsed.state ?? {
+      ...fallbackState,
+      summary: extractSummarySection(existingDocument) || fallbackState.summary,
+    };
+
+    // Rename file if assistant provided a title (and file hasn't been renamed yet)
+    await maybeRenameWithTitle(interview, state.title);
+
+    let document: string;
+    if (isCleanFinalResponse) {
+      document = await rewriteInterviewDocumentWithFinalSpec(
+        interview,
+        latestAssistantText,
+      );
+      finalizationPending.delete(interview.id);
+    } else if (parsed.state) {
+      document = await rewriteInterviewDocument(interview, state.summary);
+    } else {
+      document = await readInterviewDocument(interview);
+    }
+    const blocks = parseSpecBlocks(document);
+
+    const interviewState: InterviewState = {
+      interview,
+      url: `${await ensureServer()}/interview/${interview.id}`,
+      markdownPath: relativeInterviewPath(
+        ctx.directory,
+        interview.markdownPath,
+      ),
+      mode:
+        interview.status === 'abandoned'
+          ? 'abandoned'
+          : parsed.state && state.questions.length === 0
+            ? 'completed'
+            : sessionBusy.get(interview.sessionID) === true
+              ? 'awaiting-agent'
+              : state.questions.length > 0
+                ? 'awaiting-user'
+                : parsed.latestAssistantError
+                  ? 'error'
+                  : !parsed.state &&
+                      sessionBusy.get(interview.sessionID) === false
+                    ? 'completed'
+                    : 'awaiting-agent',
+      lastParseError: parsed.latestAssistantError,
+      isBusy: sessionBusy.get(interview.sessionID) === true,
+      summary: state.summary,
+      questions: state.questions,
+      document,
+      blocks,
+    };
+
+    // Push state to dashboard if callback is set (dashboard mode)
+    if (onStateChange) {
+      onStateChange(interview.id, interviewState);
+    }
+
+    return interviewState;
+  }
+
+  async function notifyInterviewUrl(
+    sessionID: string,
+    interview: InterviewRecord,
+  ): Promise<void> {
+    const baseUrl = await ensureServer();
+    const url = `${baseUrl}/interview/${interview.id}`;
+
+    // Auto-open browser on initial creation (not on every poll/refresh)
+    maybeOpenBrowser(interview.id, url);
+
+    await sessionRuntime.notify(
+      sessionID,
+      [
+        '⎔ Interview UI ready',
+        '',
+        `Open: ${url}`,
+        `Document: ${relativeInterviewPath(ctx.directory, interview.markdownPath)}`,
+        '',
+        '[system status: continue without acknowledging this notification]',
+      ].join('\n'),
+    );
+  }
+
+  function registerCommand(opencodeConfig: Record<string, unknown>): void {
+    const configCommand = opencodeConfig.command as
+      | Record<string, unknown>
+      | undefined;
+    if (!configCommand?.[COMMAND_NAME]) {
+      if (!opencodeConfig.command) {
+        opencodeConfig.command = {};
+      }
+      (opencodeConfig.command as Record<string, unknown>)[COMMAND_NAME] = {
+        template: 'Start an interview and write a live markdown spec',
+        description:
+          'Open a localhost interview UI linked to the current OpenCode session',
+      };
+    }
+  }
+
+  async function getInterviewState(
+    interviewId: string,
+  ): Promise<InterviewState> {
+    const interview = getInterviewById(interviewId);
+    if (!interview) {
+      throw new Error('Interview not found');
+    }
+    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[],
+  ): 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.',
+      );
+    }
+
+    // Acquire busy lock immediately before any async operations to prevent race
+    sessionBusy.set(interview.sessionID, true);
+    let promptSent = false;
+
+    try {
+      const state = await getInterviewState(interviewId);
+      if (state.mode === 'error') {
+        throw new Error('Interview is waiting for a valid agent update.');
+      }
+
+      const activeQuestionIds = new Set(
+        state.questions.map((question) => question.id),
+      );
+      if (activeQuestionIds.size === 0) {
+        throw new Error('There are no active interview questions to answer.');
+      }
+      if (answers.length !== activeQuestionIds.size) {
+        throw new Error(
+          'Answer every active interview question before submitting.',
+        );
+      }
+      const invalidAnswer = answers.find(
+        (answer) =>
+          !activeQuestionIds.has(answer.questionId) || !answer.answer.trim(),
+      );
+      if (invalidAnswer) {
+        throw new Error(
+          'Answers do not match the current interview questions.',
+        );
+      }
+
+      await appendInterviewAnswers(interview, state.questions, answers);
+      const prompt = buildAnswerPrompt(answers, state.questions, maxQuestions);
+
+      const model = sessionModel.get(interview.sessionID);
+      await sessionRuntime.continue(
+        interview.sessionID,
+        prompt,
+        model ? (parseModelReference(model) ?? undefined) : undefined,
+      );
+      promptSent = true;
+    } finally {
+      if (!promptSent) {
+        sessionBusy.set(interview.sessionID, false);
+      }
+    }
+  }
+
+  async function handleCommandExecuteBefore(
+    input: { command: string; sessionID: string; arguments: string },
+    output: { parts: Array<{ type: string; text?: string }> },
+  ): Promise<void> {
+    if (input.command !== COMMAND_NAME) {
+      return;
+    }
+
+    const idea = input.arguments.trim();
+    output.parts.length = 0;
+
+    if (!idea) {
+      const activeId = activeInterviewIds.get(input.sessionID);
+      const interview = activeId ? interviewsById.get(activeId) : null;
+      if (interview?.status !== 'active') {
+        output.parts.push(
+          createInternalAgentTextPart(
+            'The user ran /interview without an idea. Ask them for the product idea in one sentence.',
+          ),
+        );
+        return;
+      }
+
+      await notifyInterviewUrl(input.sessionID, interview);
+      output.parts.push(
+        createInternalAgentTextPart(
+          `The interview UI was reopened for the current session. If your latest interview turn already contains unanswered questions, do not repeat them. Otherwise continue the interview with up to ${maxQuestions} clarifying questions and include the structured <interview_state> block.`,
+        ),
+      );
+      return;
+    }
+
+    const resumePath = resolveExistingInterviewPath(
+      ctx.directory,
+      outputFolder,
+      idea,
+    );
+    if (resumePath) {
+      const interview = await resumeInterview(input.sessionID, resumePath);
+      const document = await fs.readFile(interview.markdownPath, 'utf8');
+      await notifyInterviewUrl(input.sessionID, interview);
+      output.parts.push(
+        createInternalAgentTextPart(buildResumePrompt(document, maxQuestions)),
+      );
+      return;
+    }
+
+    const interview = await createInterview(input.sessionID, idea);
+    await notifyInterviewUrl(input.sessionID, interview);
+    output.parts.push(
+      createInternalAgentTextPart(buildKickoffPrompt(idea, maxQuestions)),
+    );
+
+    // best-effort: rename the session so it's identifiable in the session list.
+    // never block interview creation if the rename fails.
+    let sessionTitle = `Interview: ${idea}`;
+    if (sessionTitle.length > 50) {
+      sessionTitle = `${sessionTitle.slice(0, 49)}…`;
+    }
+    sessionRuntime.rename(input.sessionID, sessionTitle).catch(() => {});
+  }
+
+  async function handleEvent(input: {
+    event: { type: string; properties?: Record<string, unknown> };
+  }): Promise<void> {
+    const { event } = input;
+    const properties = event.properties ?? {};
+
+    if (event.type === 'session.status') {
+      const sessionID = properties.sessionID as string | undefined;
+      const status = properties.status as { type?: string } | undefined;
+      if (sessionID) {
+        sessionBusy.set(sessionID, status?.type === 'busy');
+        if (status?.type === 'idle') {
+          const interviewId = activeInterviewIds.get(sessionID);
+          if (interviewId && finalizationPending.has(interviewId)) {
+            finalizationReady.add(interviewId);
+          }
+        }
+      }
+      return;
+    }
+
+    if (
+      event.type === 'session.next.text.ended' ||
+      event.type === 'session.idle'
+    ) {
+      const sessionID =
+        (properties.sessionID as string | undefined) ??
+        (properties.info as { id?: string } | undefined)?.id ??
+        undefined;
+      if (sessionID) {
+        sessionBusy.set(sessionID, false);
+        const interviewId = activeInterviewIds.get(sessionID);
+        if (interviewId && finalizationPending.has(interviewId)) {
+          finalizationReady.add(interviewId);
+        }
+      }
+      return;
+    }
+
+    if (event.type === 'message.updated') {
+      const info = properties as
+        | {
+            info?: {
+              sessionID?: string;
+              providerID?: string;
+              modelID?: string;
+            };
+          }
+        | undefined;
+      const sessionID = info?.info?.sessionID;
+      const providerID = info?.info?.providerID;
+      const modelID = info?.info?.modelID;
+      if (sessionID && providerID && modelID) {
+        sessionModel.set(sessionID, `${providerID}/${modelID}`);
+      }
+      return;
+    }
+
+    if (event.type === 'session.deleted') {
+      const deletedSessionId =
+        ((properties.info as { id?: string } | undefined)?.id ??
+          (properties.sessionID as string | undefined)) ||
+        null;
+      if (!deletedSessionId) {
+        return;
+      }
+
+      sessionBusy.delete(deletedSessionId);
+      sessionModel.delete(deletedSessionId);
+      const interviewId = activeInterviewIds.get(deletedSessionId);
+      if (!interviewId) {
+        return;
+      }
+      finalizationReady.delete(interviewId);
+
+      const interview = interviewsById.get(interviewId);
+      if (!interview) {
+        return;
+      }
+
+      abandonInterview(interview);
+      fileCache = null;
+      activeInterviewIds.delete(deletedSessionId);
+      log('[interview] session deleted, interview marked abandoned', {
+        sessionID: deletedSessionId,
+        interviewId,
+      });
+    }
+  }
+
+  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 submitBlockComment(
+    interviewId: string,
+    sectionTitle: string,
+    comment: string,
+  ): 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);
+      if (state.mode === 'error') {
+        throw new Error('Interview is waiting for a valid agent update.');
+      }
+
+      const relativePath = relativeInterviewPath(
+        ctx.directory,
+        interview.markdownPath,
+      );
+
+      const prompt = [
+        `You are updating the active interview specification document at "${relativePath}".`,
+        `The current document content on disk is:`,
+        `\`\`\`markdown`,
+        state.document,
+        `\`\`\``,
+        ``,
+        `The user submitted specific feedback/comments for the section "${sectionTitle}".`,
+        `Feedback: ${comment}`,
+        ``,
+        `Update the specification summary (focusing heavily on making changes to the "${sectionTitle}" section) to address this feedback.`,
+        `If this feedback implies other parts of the spec should change, update them too.`,
+        `Include the updated 11-section specification and ask the next highest-value clarifying questions as questions (up to ${maxQuestions} questions) if needed.`,
+        `Return the same <interview_state> JSON block format as before.`,
+      ].join('\n');
+
+      const model = sessionModel.get(interview.sessionID);
+      await sessionRuntime.continue(
+        interview.sessionID,
+        prompt,
+        model ? (parseModelReference(model) ?? undefined) : undefined,
+      );
+      promptSent = true;
+    } finally {
+      if (!promptSent) {
+        sessionBusy.set(interview.sessionID, false);
+      }
+    }
+  }
+
+  async function submitChat(
+    interviewId: string,
+    message: string,
+  ): 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);
+      if (state.mode === 'error') {
+        throw new Error('Interview is waiting for a valid agent update.');
+      }
+
+      const relativePath = relativeInterviewPath(
+        ctx.directory,
+        interview.markdownPath,
+      );
+
+      const prompt = [
+        `You are continuing the interview for the specification document at "${relativePath}".`,
+        `The current document content on disk is:`,
+        `\`\`\`markdown`,
+        state.document,
+        `\`\`\``,
+        ``,
+        `The user sent a freeform message via the dashboard chat panel:`,
+        `${message}`,
+        ``,
+        `Process this request - it may be a request to add a new section, revise existing content, ask clarifying questions, or make structural changes.`,
+        `Update the specification document accordingly and include the updated 11-section specification.`,
+        `Ask up to ${maxQuestions} clarifying questions if needed using the same <interview_state> JSON block format as before.`,
+      ].join('\n');
+
+      const model = sessionModel.get(interview.sessionID);
+      await sessionRuntime.continue(
+        interview.sessionID,
+        prompt,
+        model ? (parseModelReference(model) ?? undefined) : undefined,
+      );
+      promptSent = true;
+    } finally {
+      if (!promptSent) {
+        sessionBusy.set(interview.sessionID, false);
+      }
+    }
+  }
+
+  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);
+
+      const relativePath = relativeInterviewPath(
+        ctx.directory,
+        interview.markdownPath,
+      );
+
+      let prompt: string;
+      if (action === 'more-questions') {
+        prompt = [
+          `You are continuing the interview for the specification document at "${relativePath}".`,
+          `The current document content on disk is:`,
+          `\`\`\`markdown`,
+          state.document,
+          `\`\`\``,
+          ``,
+          `The user reviewed the completed interview spec and wants you to continue.`,
+          ``,
+          `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 = [
+          `You are finishing the interview for the specification document at "${relativePath}".`,
+          `The current document content on disk is:`,
+          `\`\`\`markdown`,
+          state.document,
+          `\`\`\``,
+          ``,
+          `The user confirmed the interview spec is complete.`,
+          ``,
+          `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');
+      }
+
+      const model = sessionModel.get(interview.sessionID);
+      if (action === 'confirm-complete') {
+        finalizationPending.add(interview.id);
+        finalizationReady.delete(interview.id);
+      }
+      await sessionRuntime.continue(
+        interview.sessionID,
+        prompt,
+        model ? (parseModelReference(model) ?? undefined) : undefined,
+      );
+      promptSent = true;
+    } finally {
+      if (!promptSent) {
+        if (action === 'confirm-complete') {
+          finalizationPending.delete(interview.id);
+          finalizationReady.delete(interview.id);
+        }
+        sessionBusy.set(interview.sessionID, false);
+      }
+    }
+  }
+
+  return {
+    setBaseUrlResolver,
+    setStatePushCallback,
+    setOnInterviewCreated,
+    getActiveInterviewId,
+    registerCommand,
+    handleCommandExecuteBefore,
+    handleEvent,
+    getInterviewState,
+    listInterviewFiles,
+    listInterviews,
+    submitAnswers,
+    submitBlockComment,
+    submitChat,
+    handleNudgeAction,
+  };
+}

+ 53 - 0
src/interview/session-server.ts

@@ -0,0 +1,53 @@
+import path from 'node:path';
+import type { PluginInput } from '@opencode-ai/plugin';
+import type { InterviewConfig } from '../config';
+import { createInterviewServer } from './server';
+import { createInterviewService } from './service';
+
+export function createPerSessionInterviewServer(
+  ctx: PluginInput,
+  interviewConfig: InterviewConfig | undefined,
+  outputFolder: string,
+): {
+  registerCommand: (config: Record<string, unknown>) => void;
+  handleCommandExecuteBefore: (
+    input: { command: string; sessionID: string; arguments: string },
+    output: { parts: Array<{ type: string; text?: string }> },
+  ) => Promise<void>;
+  handleEvent: (input: {
+    event: { type: string; properties?: Record<string, unknown> };
+  }) => Promise<void>;
+  dispose: () => void;
+} {
+  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),
+    submitBlockComment: async (interviewId, section, comment) =>
+      service.submitBlockComment(interviewId, section, comment),
+    submitChat: async (interviewId, message) =>
+      service.submitChat(interviewId, message),
+    handleNudgeAction: async (interviewId, action) =>
+      service.handleNudgeAction(interviewId, action),
+    outputFolder: resolvedOutputPath,
+    port: 0,
+  });
+  service.setBaseUrlResolver(() => server.ensureStarted());
+  let disposed = false;
+
+  return {
+    registerCommand: (c) => service.registerCommand(c),
+    handleCommandExecuteBefore: async (input, output) =>
+      service.handleCommandExecuteBefore(input, output),
+    handleEvent: async (input) => service.handleEvent(input),
+    dispose: () => {
+      if (disposed) return;
+      disposed = true;
+      server.close();
+    },
+  };
+}

+ 140 - 0
src/interview/types.ts

@@ -0,0 +1,140 @@
+import { z } from 'zod';
+
+export interface InterviewQuestion {
+  id: string;
+  question: string;
+  options: string[];
+  suggested?: string;
+}
+
+export interface InterviewAnswer {
+  questionId: string;
+  answer: string;
+}
+
+export interface InterviewAssistantState {
+  summary: string;
+  title?: string;
+  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;
+  idea: string;
+  markdownPath: string;
+  createdAt: string;
+  abandonedAt?: string;
+  abandonedOrder?: number;
+  status: 'active' | 'abandoned';
+  baseMessageCount: number;
+}
+
+export interface InterviewMessagePart {
+  type?: string;
+  text?: string;
+}
+
+export interface InterviewMessage {
+  info?: {
+    role?: string;
+    [key: string]: unknown;
+  };
+  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 SpecBlock {
+  id: string;
+  title: string;
+  content: string;
+}
+
+export interface InterviewState {
+  interview: InterviewRecord;
+  url: string;
+  markdownPath: string;
+  mode:
+    | 'awaiting-agent'
+    | 'awaiting-user'
+    | 'abandoned'
+    | 'completed'
+    | 'error'
+    | 'session-disconnected';
+  lastParseError?: string;
+  isBusy: boolean;
+  summary: string;
+  questions: InterviewQuestion[];
+  document: string;
+  blocks: SpecBlock[];
+}
+
+/** 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;
+  pendingBlockComment?: {
+    section: string;
+    comment: string;
+  } | null;
+  pendingChatMessage?: string | null;
+  document?: string;
+  blocks?: SpecBlock[];
+}

+ 2027 - 0
src/interview/ui.ts

@@ -0,0 +1,2027 @@
+import { escapeHtml } from '../utils/escape-html';
+import type { InterviewFileItem, InterviewListItem } from './types';
+
+interface DashboardInterviewItem extends InterviewListItem {
+  url: string;
+  mode: string;
+  resumeSlug: string;
+  sessionID?: string;
+  directory?: string;
+}
+
+const BRAND_LOGO_URL =
+  'https://ohmyopencodeslim.com/android-chrome-512x512.png';
+
+// ─── Shared client-side helpers ────────────────────────────────────
+
+function clipboardHelperJs(): string {
+  return `
+      function copyCommand(text, btn) {
+        var clip = navigator.clipboard && navigator.clipboard.writeText;
+        var useFallback = function() {
+          var ta = document.createElement('textarea');
+          ta.value = text;
+          ta.style.position = 'fixed';
+          ta.style.left = '-9999px';
+          document.body.appendChild(ta);
+          ta.select();
+          document.execCommand('copy');
+          document.body.removeChild(ta);
+        };
+        if (clip) {
+          clip.call(navigator.clipboard, text).catch(useFallback);
+        } else {
+          useFallback();
+        }
+        btn.textContent = 'Copied!';
+        btn.classList.add('copied');
+        setTimeout(function() {
+          btn.textContent = 'Copy';
+          btn.classList.remove('copied');
+        }, 2000);
+      }`;
+}
+
+// ─── Shared styles ─────────────────────────────────────────────────
+
+function sharedStyles(): string {
+  return `
+      :root { color-scheme: dark; }
+      body {
+        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
+        margin: 0;
+        background: #000000;
+        color: #ffffff;
+        line-height: 1.6;
+        -webkit-font-smoothing: antialiased;
+        font-size: 16px;
+      }
+      .wrap { max-width: 680px; margin: 0 auto; padding: 56px 24px; }
+      .brand-header {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        gap: 16px;
+        margin-bottom: 32px;
+        text-align: center;
+      }
+      .brand-mark {
+        object-fit: contain;
+        filter: drop-shadow(0 10px 30px rgba(255,255,255,0.1));
+      }
+      h1 { font-size: 28px; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 8px; line-height: 1.2; }
+      .muted { color: rgba(255,255,255,0.5); font-size: 16px; }
+      code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; background: rgba(255,255,255,0.08); padding: 2px 6px; border-radius: 4px; font-size: 14px; }
+      .resume-row {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+      }
+      .resume-cmd {
+        flex: 1;
+        padding: 8px 12px;
+        font-size: 13px;
+        border-radius: 6px;
+        background: rgba(255,255,255,0.05);
+        border: 1px solid rgba(255,255,255,0.1);
+        white-space: nowrap;
+        overflow-x: auto;
+      }
+      .copy-btn {
+        flex-shrink: 0;
+        padding: 8px 16px;
+        border: 1px solid rgba(255,255,255,0.2);
+        border-radius: 6px;
+        background: transparent;
+        color: rgba(255,255,255,0.8);
+        font-size: 13px;
+        font-weight: 500;
+        cursor: pointer;
+        transition: all 0.2s ease;
+      }
+      .copy-btn:hover {
+        background: rgba(255,255,255,0.1);
+        border-color: rgba(255,255,255,0.4);
+        color: #ffffff;
+      }
+      .copy-btn.copied {
+        background: rgba(52, 211, 153, 0.15);
+        border-color: #34d399;
+        color: #34d399;
+      }
+      .footer { margin-top: 32px; text-align: center; font-size: 13px; color: rgba(255,255,255,0.4); }`;
+}
+
+// ─── Dashboard brand image ─────────────────────────────────────────
+
+function brandImage(size: number): string {
+  return `<img class="brand-mark" src="${BRAND_LOGO_URL}" alt="Oh My Opencode Slim" width="${size}" height="${size}" />`;
+}
+
+export function renderDashboardPage(
+  interviews: DashboardInterviewItem[],
+  files: InterviewFileItem[],
+  outputFolder: string,
+): string {
+  const activeHtml =
+    interviews.length === 0
+      ? ''
+      : interviews
+          .map((item) => {
+            const isDisconnected = item.mode === 'session-disconnected';
+            const statusLabel = isDisconnected ? 'disconnected' : item.status;
+            const statusClass = isDisconnected
+              ? 'status-file'
+              : `status-${item.status}`;
+            const resumeHint = isDisconnected
+              ? `<div class="resume-row" style="margin-top:8px"><code class="resume-cmd">/interview ${escapeHtml(item.resumeSlug)}</code><button class="copy-btn" data-cmd="/interview ${escapeHtml(item.resumeSlug)}" title="Copy command">Copy</button></div>`
+              : '';
+            return `<a class="interview-card" href="${escapeHtml(item.url)}">
+          <div class="card-header">
+            <span class="card-idea">${escapeHtml(item.idea)}</span>
+            <span class="card-status ${statusClass}">${statusLabel}</span>
+          </div>
+          ${item.directory ? `<div class="card-dir">${escapeHtml(item.directory)}</div>` : ''}
+          <div class="card-meta">
+            <span>${escapeHtml(new Date(item.createdAt).toLocaleString())}</span>
+          </div>
+          ${resumeHint}
+        </a>`;
+          })
+          .join('\n');
+
+  // Dedup: skip files whose sessionID matches an active/recovered interview.
+  // These are already visible in the "Interviews" section above.
+  const activeSessionIDs = new Set(
+    interviews
+      .map((i) => i.sessionID)
+      .filter((sid): sid is string => typeof sid === 'string'),
+  );
+  const unrecoveredFiles = files.filter((f) => {
+    if (f.sessionID && activeSessionIDs.has(f.sessionID)) return false;
+    return true;
+  });
+
+  const filesHtml =
+    unrecoveredFiles.length === 0
+      ? ''
+      : unrecoveredFiles
+          .map(
+            (item) =>
+              `<div class="interview-card file-card">
+          <div class="card-header">
+            <span class="card-idea">${escapeHtml(item.title)}</span>
+            <span class="card-status status-file">saved</span>
+          </div>
+          ${item.directory ? `<div class="card-dir">${escapeHtml(item.directory)}</div>` : ''}
+          <div class="card-summary">${escapeHtml(item.summary)}</div>
+          <div class="resume-row">
+            <code class="resume-cmd">${escapeHtml(item.resumeCommand)}</code>
+            <button class="copy-btn" data-cmd="${escapeHtml(item.resumeCommand)}" title="Copy command">Copy</button>
+          </div>
+        </div>`,
+          )
+          .join('\n');
+
+  const totalCount = interviews.length + unrecoveredFiles.length;
+  const emptyState =
+    totalCount === 0
+      ? '<p class="muted" style="text-align:center;padding:48px 0">No interviews yet. Start one with <code>/interview</code> in your OpenCode session.</p>'
+      : '';
+
+  const activeSection =
+    interviews.length > 0 ? `<h2>Interviews</h2>${activeHtml}` : '';
+
+  const fileSection =
+    unrecoveredFiles.length > 0
+      ? `<h2>Files without session</h2><p class="muted file-hint">Resume with the command in any OpenCode session.</p>${filesHtml}`
+      : '';
+
+  return `<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <title>Interviews</title>
+    <style>
+      ${sharedStyles()}
+      .brand-mark { width: 96px; height: 96px; }
+      h2 { font-size: 18px; font-weight: 500; letter-spacing: 0.05em; text-transform: uppercase; color: rgba(255,255,255,0.4); margin-bottom: 16px; margin-top: 40px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 12px; }
+      h2:first-of-type { margin-top: 0; }
+      .file-hint { font-size: 14px; margin-bottom: 16px; margin-top: -8px; }
+
+      .interview-card {
+        display: block;
+        background: rgba(255,255,255,0.02);
+        border: 1px solid rgba(255,255,255,0.1);
+        border-radius: 8px;
+        padding: 20px 24px;
+        margin-bottom: 12px;
+        text-decoration: none;
+        color: inherit;
+        transition: all 0.2s ease;
+      }
+      .interview-card:hover {
+        background: rgba(255,255,255,0.06);
+        border-color: rgba(255,255,255,0.3);
+        transform: translateX(4px);
+      }
+      .file-card {
+        cursor: default;
+      }
+      .file-card:hover {
+        transform: none;
+      }
+      .card-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
+      .card-idea { font-size: 17px; font-weight: 500; line-height: 1.4; flex: 1; }
+      .card-status {
+        font-size: 11px;
+        text-transform: uppercase;
+        letter-spacing: 0.05em;
+        padding: 4px 10px;
+        border-radius: 999px;
+        font-weight: 600;
+        flex-shrink: 0;
+      }
+      .status-active { background: rgba(52, 211, 153, 0.15); color: #34d399; }
+      .status-abandoned { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.4); }
+      .status-file { background: rgba(96, 165, 250, 0.15); color: #60a5fa; }
+      .card-meta { font-size: 13px; color: rgba(255,255,255,0.4); }
+      .card-summary { font-size: 14px; color: rgba(255,255,255,0.5); margin-bottom: 12px; line-height: 1.5; }
+      .card-dir { font-size: 12px; color: rgba(255,255,255,0.3); margin-bottom: 8px; font-family: monospace; word-break: break-all; }
+
+      .update-banner {
+        position: fixed;
+        top: -56px;
+        left: 0; right: 0;
+        background: rgba(96, 165, 250, 0.95);
+        color: #fff;
+        text-align: center;
+        padding: 14px 24px;
+        font-size: 14px;
+        font-weight: 500;
+        cursor: pointer;
+        z-index: 200;
+        transition: top 0.3s ease;
+        backdrop-filter: blur(8px);
+      }
+      .update-banner.visible { top: 0; }
+      .update-banner:hover { background: rgba(96, 165, 250, 1); }
+      .info-box {
+        font-size: 13px;
+        color: rgba(255,255,255,0.45);
+        background: rgba(255,255,255,0.03);
+        border: 1px solid rgba(255,255,255,0.08);
+        border-radius: 8px;
+        padding: 16px 20px;
+        margin-bottom: 40px;
+        line-height: 1.7;
+      }
+      .info-box strong { color: rgba(255,255,255,0.65); font-weight: 500; }
+      .info-box code {
+        font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+        background: rgba(255,255,255,0.08);
+        padding: 2px 6px;
+        border-radius: 4px;
+        font-size: 12px;
+        word-break: break-all;
+      }
+      .settings-row {
+        display: flex;
+        align-items: center;
+        gap: 12px;
+        margin-top: 12px;
+        flex-wrap: wrap;
+      }
+      .settings-row label {
+        font-size: 13px;
+        color: rgba(255,255,255,0.5);
+        white-space: nowrap;
+      }
+      .settings-row select, .settings-row input[type="text"] {
+        background: rgba(255,255,255,0.05);
+        border: 1px solid rgba(255,255,255,0.15);
+        color: #fff;
+        border-radius: 4px;
+        padding: 6px 10px;
+        font-size: 13px;
+        font-family: inherit;
+      }
+      .settings-row input[type="text"] {
+        flex: 1;
+        min-width: 200px;
+      }
+      .settings-row select:focus, .settings-row input[type="text"]:focus {
+        border-color: rgba(255,255,255,0.4);
+        outline: none;
+      }
+      .small-btn {
+        padding: 6px 14px;
+        border: 1px solid rgba(255,255,255,0.2);
+        border-radius: 4px;
+        background: transparent;
+        color: rgba(255,255,255,0.8);
+        font-size: 13px;
+        cursor: pointer;
+        transition: all 0.2s ease;
+      }
+      .small-btn:hover {
+        background: rgba(255,255,255,0.1);
+        border-color: rgba(255,255,255,0.4);
+        color: #fff;
+      }
+      .folder-list {
+        display: flex;
+        flex-direction: column;
+        gap: 4px;
+        margin-top: 8px;
+      }
+      .folder-item {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+        font-size: 13px;
+        color: rgba(255,255,255,0.5);
+        font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+      }
+      .folder-item button {
+        padding: 2px 8px;
+        border: 1px solid rgba(255,255,255,0.15);
+        border-radius: 3px;
+        background: transparent;
+        color: rgba(255,255,255,0.4);
+        font-size: 11px;
+        cursor: pointer;
+      }
+      .folder-item button:hover {
+        border-color: rgba(255,100,100,0.5);
+        color: rgba(255,100,100,0.8);
+      }
+    </style>
+  </head>
+  <body>
+    <div class="wrap">
+      <div class="brand-header">
+        ${brandImage(96)}
+        <h1>Interviews</h1>
+        <p class="muted">${totalCount} item${totalCount === 1 ? '' : 's'}</p>
+      </div>
+      <div class="info-box">
+        <strong>Interviews</strong> - live sessions and recovered files. State is pushed from OpenCode sessions to this dashboard.<br>
+        <strong>Files without session</strong> - <code>.md</code> files in <code>${escapeHtml(outputFolder)}</code> with no frontmatter. Resume with <code>/interview &lt;name&gt;</code>.
+      </div>
+      <details style="margin-bottom:24px">
+        <summary style="cursor:pointer;font-size:13px;color:rgba(255,255,255,0.4);user-select:none">Settings</summary>
+        <div style="margin-top:12px">
+          <div class="settings-row">
+            <label for="scanDays">Scan sessions from last</label>
+            <select id="scanDays">
+              <option value="1">1 day</option>
+              <option value="7">7 days</option>
+              <option value="30" selected>30 days</option>
+              <option value="90">90 days</option>
+              <option value="0">All time</option>
+            </select>
+            <button class="small-btn" id="rescanBtn">Rescan</button>
+          </div>
+          <div class="settings-row">
+            <input type="text" id="addFolderInput" placeholder="/path/to/project" />
+            <button class="small-btn" id="addFolderBtn">Add Folder</button>
+          </div>
+          <div class="folder-list" id="folderList"></div>
+        </div>
+      </details>
+      ${emptyState}
+      ${activeSection}
+      ${fileSection}
+      <div class="footer">OH MY OPENCODE SLIM</div>
+    </div>
+    <div class="update-banner" id="updateBanner">Dashboard updated - tap to refresh</div>
+    <script>
+      ${clipboardHelperJs()}
+      document.querySelectorAll('.copy-btn').forEach(btn => {
+        btn.addEventListener('click', (e) => {
+          e.stopPropagation();
+          e.preventDefault();
+          copyCommand(btn.dataset.cmd, btn);
+        });
+      });
+
+      let lastSig = '';
+      let bannerTimer = null;
+      // Use health endpoint for change detection (no auth needed, shows counts)
+      (async () => { try { const r = await fetch('/api/health'); if (r.ok) lastSig = await r.text(); } catch {} })();
+      setInterval(async () => {
+        try {
+          const res = await fetch('/api/health');
+          if (!res.ok) return;
+          const sig = await res.text();
+          if (sig !== lastSig) {
+            lastSig = sig;
+            const banner = document.getElementById('updateBanner');
+            banner.classList.add('visible');
+            if (bannerTimer) clearTimeout(bannerTimer);
+            bannerTimer = setTimeout(() => banner.classList.remove('visible'), 30000);
+          }
+        } catch {}
+      }, 15000);
+      document.getElementById('updateBanner').addEventListener('click', () => location.reload());
+
+      // Settings: load current state
+      (async () => {
+        try {
+          const r = await fetch('/api/settings');
+          if (!r.ok) return;
+          const s = await r.json();
+          const sel = document.getElementById('scanDays');
+          if (sel && s.scanDays !== undefined) sel.value = String(s.scanDays);
+          renderFolderList(s.folders || []);
+        } catch {}
+      })();
+
+      function renderFolderList(folders) {
+        const list = document.getElementById('folderList');
+        if (!list) return;
+        list.innerHTML = '';
+        folders.forEach(function(dir) {
+          const item = document.createElement('div');
+          item.className = 'folder-item';
+          const span = document.createElement('span');
+          span.textContent = dir;
+          span.style.flex = '1';
+          const btn = document.createElement('button');
+          btn.textContent = 'Remove';
+          btn.addEventListener('click', async function() {
+            await fetch('/api/settings', {
+              method: 'POST',
+              headers: { 'content-type': 'application/json' },
+              body: JSON.stringify({ removeFolder: dir })
+            });
+            location.reload();
+          });
+          item.appendChild(span);
+          item.appendChild(btn);
+          list.appendChild(item);
+        });
+      }
+
+      document.getElementById('rescanBtn')?.addEventListener('click', async function() {
+        const days = parseInt(document.getElementById('scanDays').value, 10);
+        await fetch('/api/settings', {
+          method: 'POST',
+          headers: { 'content-type': 'application/json' },
+          body: JSON.stringify({ scanDays: days, discover: true })
+        });
+        location.reload();
+      });
+
+      document.getElementById('addFolderBtn')?.addEventListener('click', async function() {
+        const input = document.getElementById('addFolderInput');
+        const dir = input.value.trim();
+        if (!dir) return;
+        await fetch('/api/settings', {
+          method: 'POST',
+          headers: { 'content-type': 'application/json' },
+          body: JSON.stringify({ addFolder: dir })
+        });
+        input.value = '';
+        location.reload();
+      });
+    </script>
+  </body>
+</html>`;
+}
+
+export function renderInterviewPage(
+  interviewId: string,
+  resumeSlug: string,
+): string {
+  const safeTitle = escapeHtml(interviewId);
+
+  return `<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <title>Interview ${safeTitle}</title>
+    <style>
+      ${sharedStyles()}
+      .brand-mark { width: 144px; height: 144px; }
+      .spec-block-card {
+        background: rgba(255,255,255,0.01);
+        border: 1px solid rgba(255,255,255,0.05);
+        border-radius: 8px;
+        padding: 24px;
+        margin-bottom: 20px;
+        text-align: left;
+        transition: all 0.2s ease;
+      }
+      .spec-block-card:hover {
+        border-color: rgba(255,255,255,0.15);
+        background: rgba(255,255,255,0.02);
+      }
+      .spec-block-card h3 {
+        margin-top: 0;
+        font-size: 18px;
+        color: #ffffff;
+        border-bottom: 1px solid rgba(255,255,255,0.08);
+        padding-bottom: 8px;
+        margin-bottom: 14px;
+      }
+      .spec-block-content {
+        font-size: 15px;
+        line-height: 1.6;
+        color: rgba(255,255,255,0.8);
+      }
+      .spec-block-comment-box {
+        margin-top: 16px;
+        padding-top: 16px;
+        border-top: 1px dashed rgba(255,255,255,0.08);
+        display: flex;
+        flex-direction: column;
+        gap: 10px;
+      }
+      .spec-block-comment-input {
+        min-height: 60px !important;
+        font-size: 14px !important;
+        padding: 10px !important;
+      }
+      .comment-submit-btn {
+        align-self: flex-end;
+        background: transparent;
+        border: 1px solid rgba(255,255,255,0.2);
+        color: rgba(255,255,255,0.8);
+        padding: 8px 16px;
+        border-radius: 6px;
+        font-size: 13px;
+        cursor: pointer;
+        transition: all 0.2s ease;
+      }
+      .comment-submit-btn:hover:not(:disabled) {
+        border-color: #34d399;
+        color: #34d399;
+        background: rgba(52,211,153,0.05);
+      }
+      .comment-submit-btn:disabled {
+        opacity: 0.3;
+        cursor: not-allowed;
+      }
+      h1 { font-size: 32px; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 12px; line-height: 1.2; }
+      h2 { font-size: 18px; font-weight: 500; letter-spacing: 0.05em; text-transform: uppercase; color: rgba(255,255,255,0.4); margin-bottom: 24px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 12px; }
+      h3 { font-size: 18px; font-weight: 500; margin-bottom: 16px; line-height: 1.4; }
+      p { margin-top: 0; }
+      .meta { display: flex; align-items: center; justify-content: space-between; font-size: 13px; color: rgba(255,255,255,0.4); margin-bottom: 16px; letter-spacing: 0.05em; text-transform: uppercase; }
+
+       .file-path-container {
+         font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+         font-size: 13px;
+         color: rgba(255,255,255,0.6);
+         background: rgba(255,255,255,0.05);
+         padding: 8px 12px;
+         border-radius: 6px;
+         margin-bottom: 12px;
+         display: flex;
+         align-items: center;
+         gap: 8px;
+         border: 1px solid rgba(255,255,255,0.08);
+       }
+      .file-path-icon {
+        opacity: 0.5;
+      }
+      .download-btn {
+        margin-left: auto;
+        background: transparent;
+        border: 1px solid rgba(255,255,255,0.2);
+        color: rgba(255,255,255,0.6);
+        border-radius: 4px;
+        padding: 4px 10px;
+        font-size: 12px;
+        cursor: pointer;
+        transition: all 0.2s ease;
+      }
+      .download-btn:hover {
+        border-color: rgba(255,255,255,0.5);
+        color: #ffffff;
+       }
+
+       .question {
+        background: rgba(255,255,255,0.02);
+        border: 1px solid rgba(255,255,255,0.1);
+        border-left: 1px solid rgba(255,255,255,0.1);
+        border-radius: 6px;
+        padding: 28px;
+        margin-bottom: 32px;
+        transition: all 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
+      }
+      .question:focus-within {
+        border-color: rgba(255,255,255,0.3);
+      }
+
+      /* Make active question much clearer */
+      .question.active-question {
+        background: rgba(255,255,255,0.04);
+        border-color: rgba(255,255,255,0.4);
+        border-left: 4px solid #ffffff;
+        box-shadow: 0 8px 32px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.1);
+        transform: translateX(4px);
+      }
+
+      .options { display: flex; flex-direction: column; gap: 10px; margin-bottom: 20px; }
+      .question-hint {
+        display: flex;
+        flex-wrap: wrap;
+        gap: 8px;
+        margin: -4px 0 16px;
+        color: rgba(255,255,255,0.5);
+        font-size: 13px;
+        line-height: 1.5;
+        transition: color 0.2s ease;
+      }
+      .active-question .question-hint {
+        color: rgba(255,255,255,0.78);
+      }
+      .hint-chip {
+        display: inline-flex;
+        align-items: center;
+        gap: 6px;
+        padding: 4px 10px;
+        border-radius: 999px;
+        background: rgba(255,255,255,0.06);
+        border: 1px solid rgba(255,255,255,0.08);
+      }
+      .hint-chip kbd {
+        font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+        font-size: 12px;
+        padding: 2px 6px;
+        border-radius: 6px;
+        background: rgba(255,255,255,0.12);
+        border: 1px solid rgba(255,255,255,0.08);
+        color: rgba(255,255,255,0.95);
+      }
+
+      .option {
+        border: 1px solid rgba(255,255,255,0.1);
+        background: transparent;
+        color: inherit;
+        border-radius: 6px;
+        padding: 14px 18px;
+        cursor: pointer;
+        text-align: left;
+        font-size: 16px;
+        transition: all 0.2s ease;
+        display: flex;
+        align-items: center;
+      }
+      .option:hover {
+        background: rgba(255,255,255,0.06);
+        border-color: rgba(255,255,255,0.3);
+      }
+      .option.selected {
+        background: #ffffff;
+        color: #000000;
+        border-color: #ffffff;
+        font-weight: 500;
+      }
+
+      .shortcut {
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+        background: rgba(255,255,255,0.1);
+        color: rgba(255,255,255,0.8);
+        border-radius: 4px;
+        min-width: 20px;
+        height: 20px;
+        padding: 0 4px;
+        font-size: 12px;
+        margin-right: 12px;
+        font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+      }
+      .option.selected .shortcut {
+        background: rgba(0,0,0,0.15);
+        color: rgba(0,0,0,0.9);
+      }
+
+      .option-text {
+        flex: 1;
+        line-height: 1.4;
+      }
+
+      .recommended-badge {
+        font-size: 11px;
+        text-transform: uppercase;
+        letter-spacing: 0.05em;
+        background: rgba(255,255,255,0.15);
+        color: rgba(255,255,255,0.9);
+        padding: 4px 8px;
+        border-radius: 999px;
+        margin-left: 12px;
+        font-weight: 600;
+      }
+      .option.selected .recommended-badge {
+        background: rgba(0,0,0,0.15);
+        color: rgba(0,0,0,0.8);
+      }
+
+      .submit-shortcut {
+        display: inline-block;
+        margin-left: 10px;
+        padding: 3px 8px;
+        border-radius: 999px;
+        background: rgba(0,0,0,0.08);
+        color: rgba(0,0,0,0.7);
+        font-size: 12px;
+        font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+      }
+
+      textarea {
+        width: 100%;
+        box-sizing: border-box;
+        min-height: 140px;
+        border-radius: 6px;
+        border: 1px solid rgba(255,255,255,0.15);
+        background: rgba(0,0,0,0.6);
+        color: inherit;
+        padding: 16px;
+        font-family: inherit;
+        font-size: 16px;
+        line-height: 1.5;
+        resize: vertical;
+        outline: none;
+        transition: border-color 0.2s ease;
+      }
+      textarea:focus {
+        border-color: rgba(255,255,255,0.5);
+        box-shadow: 0 0 0 1px rgba(255,255,255,0.1);
+      }
+
+      .hidden-textarea {
+        display: none;
+      }
+
+      button.primary {
+        background: #ffffff;
+        color: #000000;
+        border: 0;
+        border-radius: 6px;
+        padding: 16px 24px;
+        font-size: 16px;
+        font-weight: 600;
+        cursor: pointer;
+        width: 100%;
+        transition: opacity 0.2s ease, transform 0.1s ease;
+      }
+      button.primary:hover:not(:disabled) {
+        opacity: 0.9;
+        transform: translateY(-1px);
+      }
+      button.primary:active:not(:disabled) {
+        transform: translateY(1px);
+      }
+      button.primary:disabled {
+        opacity: 0.3;
+        cursor: not-allowed;
+      }
+
+      .footer {
+        margin-top: 32px;
+        text-align: center;
+        font-size: 13px;
+        color: rgba(255,255,255,0.4);
+      }
+      .back-link {
+        display: inline-flex;
+        align-items: center;
+        gap: 6px;
+        color: rgba(255,255,255,0.5);
+        text-decoration: none;
+        font-size: 14px;
+        padding: 8px 0;
+        transition: color 0.2s ease;
+        position: relative;
+        z-index: 200;
+      }
+      .back-link:hover { color: #ffffff; }
+      .status-completed { color: #34d399; }
+
+      /* Loading State Overlay */
+      .loading-overlay {
+        position: fixed;
+        top: 0; left: 0; right: 0; bottom: 0;
+        background: rgba(0, 0, 0, 0.85);
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        justify-content: center;
+        z-index: 100;
+        opacity: 0;
+        pointer-events: none;
+        backdrop-filter: blur(8px);
+        transition: opacity 0.3s ease;
+      }
+      .loading-overlay.active {
+        opacity: 1;
+        pointer-events: all;
+      }
+
+      .loading-overlay .status-text {
+        font-size: 15px;
+        letter-spacing: 0.1em;
+        text-transform: uppercase;
+        color: #ffffff;
+        font-weight: 500;
+      }
+
+      /* ── Completed Mode ─────────────────────────────────────────── */
+      .section-label {
+        font-size: 11px;
+        font-weight: 700;
+        letter-spacing: 0.12em;
+        text-transform: uppercase;
+        color: rgba(255,255,255,0.28);
+        margin-bottom: 14px;
+      }
+      .spec-block {
+        background: rgba(255,255,255,0.02);
+        border: 1px solid rgba(255,255,255,0.08);
+        border-radius: 6px;
+        padding: 20px 24px;
+        margin-bottom: 32px;
+        color: rgba(255,255,255,0.72);
+        font-size: 15px;
+        line-height: 1.75;
+      }
+      .spec-block p { margin: 0 0 10px; }
+      .spec-block p:last-child { margin-bottom: 0; }
+      .spec-block strong { color: rgba(255,255,255,0.95); font-weight: 600; }
+      .spec-block code {
+        font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+        font-size: 13px;
+        background: rgba(255,255,255,0.08);
+        padding: 1px 5px;
+        border-radius: 3px;
+      }
+      .qa-list { margin-bottom: 12px; }
+      .qa-card {
+        background: rgba(255,255,255,0.02);
+        border: 1px solid rgba(255,255,255,0.08);
+        border-radius: 6px;
+        padding: 18px 22px;
+        margin-bottom: 10px;
+      }
+      .qa-row { display: flex; gap: 14px; align-items: flex-start; }
+      .qa-row + .qa-row { margin-top: 12px; }
+      .qa-badge {
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+        width: 22px;
+        height: 22px;
+        border-radius: 50%;
+        font-size: 10px;
+        font-weight: 700;
+        flex-shrink: 0;
+        margin-top: 2px;
+        font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+      }
+      .qa-badge-q { background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.85); }
+      .qa-badge-a { background: rgba(52,211,153,0.15); color: #34d399; }
+      .qa-divider { height: 1px; background: rgba(255,255,255,0.05); margin: 12px 0; }
+      .qa-text { font-size: 15px; line-height: 1.55; padding-top: 2px; }
+      .qa-text-q { color: rgba(255,255,255,0.82); }
+      .qa-text-a { color: rgba(255,255,255,0.52); }
+      .qa-empty {
+        color: rgba(255,255,255,0.25);
+        font-size: 14px;
+        text-align: center;
+        padding: 20px 0;
+        font-style: italic;
+      }
+      .nudge-actions {
+        display: flex;
+        gap: 12px;
+        margin-top: 32px;
+        margin-bottom: 8px;
+      }
+      .nudge-btn {
+        flex: 1;
+        background: transparent;
+        border: 1px solid rgba(255,255,255,0.15);
+        color: rgba(255,255,255,0.65);
+        border-radius: 6px;
+        padding: 13px 16px;
+        font-size: 14px;
+        font-weight: 500;
+        cursor: pointer;
+        transition: all 0.2s ease;
+        letter-spacing: 0.01em;
+      }
+      .nudge-btn:hover:not(:disabled) {
+        border-color: rgba(255,255,255,0.4);
+        color: #ffffff;
+        background: rgba(255,255,255,0.04);
+      }
+      .nudge-btn.nudge-confirm {
+        border-color: rgba(52,211,153,0.25);
+        color: rgba(52,211,153,0.8);
+      }
+      .nudge-btn.nudge-confirm:hover:not(:disabled) {
+        border-color: rgba(52,211,153,0.6);
+        color: #34d399;
+        background: rgba(52,211,153,0.05);
+      }
+      .nudge-btn:disabled { opacity: 0.3; cursor: not-allowed; }
+
+      /* ── TOC Sidebar ──────────────────────────────────────────────── */
+      .toc-sidebar {
+        position: fixed;
+        top: 0; left: 0; bottom: 0;
+        width: 240px;
+        background: rgba(255,255,255,0.02);
+        border-right: 1px solid rgba(255,255,255,0.06);
+        padding: 24px 0 80px;
+        overflow-y: auto;
+        z-index: 50;
+        display: none;
+      }
+      .toc-sidebar.visible { display: block; }
+      .toc-header {
+        font-size: 11px;
+        font-weight: 700;
+        letter-spacing: 0.12em;
+        text-transform: uppercase;
+        color: rgba(255,255,255,0.28);
+        padding: 0 20px 14px;
+        border-bottom: 1px solid rgba(255,255,255,0.05);
+        margin-bottom: 8px;
+      }
+      .toc-item {
+        display: block;
+        padding: 7px 20px;
+        font-size: 13px;
+        color: rgba(255,255,255,0.5);
+        text-decoration: none;
+        cursor: pointer;
+        transition: all 0.15s ease;
+        border-left: 2px solid transparent;
+        line-height: 1.4;
+      }
+      .toc-item:hover {
+        color: rgba(255,255,255,0.85);
+        background: rgba(255,255,255,0.03);
+      }
+      .toc-item.active {
+        color: #ffffff;
+        border-left-color: #ffffff;
+        background: rgba(255,255,255,0.04);
+      }
+      body.toc-visible .wrap {
+        margin-left: 240px;
+      }
+
+      /* ── Chat Panel ───────────────────────────────────────────────── */
+      .chat-panel {
+        position: fixed;
+        bottom: 0; left: 0; right: 0;
+        background: rgba(0,0,0,0.92);
+        backdrop-filter: blur(12px);
+        border-top: 1px solid rgba(255,255,255,0.08);
+        padding: 12px 20px;
+        z-index: 60;
+        display: none;
+        gap: 10px;
+        align-items: center;
+      }
+      .chat-panel.visible { display: flex; }
+      body.chat-visible .wrap { padding-bottom: 72px; }
+      body.toc-visible .chat-panel { left: 240px; }
+      .chat-input {
+        flex: 1;
+        background: rgba(255,255,255,0.06);
+        border: 1px solid rgba(255,255,255,0.1);
+        border-radius: 8px;
+        color: #ffffff;
+        font-family: inherit;
+        font-size: 14px;
+        padding: 10px 14px;
+        outline: none;
+        transition: border-color 0.2s ease;
+      }
+      .chat-input:focus { border-color: rgba(255,255,255,0.35); }
+      .chat-input::placeholder { color: rgba(255,255,255,0.3); }
+      .chat-send {
+        flex-shrink: 0;
+        background: #ffffff;
+        color: #000000;
+        border: 0;
+        border-radius: 8px;
+        padding: 10px 18px;
+        font-size: 14px;
+        font-weight: 600;
+        cursor: pointer;
+        transition: opacity 0.2s ease;
+      }
+      .chat-send:hover:not(:disabled) { opacity: 0.85; }
+      .chat-send:disabled { opacity: 0.3; cursor: not-allowed; }
+    </style>
+  </head>
+  <body>
+    <nav id="tocSidebar" class="toc-sidebar"></nav>
+    <div class="wrap">
+      <a href="/" class="back-link">← All Interviews</a>
+      <div class="brand-header">
+        ${brandImage(144)}
+      </div>
+      <h1 id="idea">Connecting...</h1>
+      <p class="muted" id="summary">Preparing interview session</p>
+
+      <div class="meta">
+        <span id="status">INITIALIZING</span>
+        <span>OH MY OPENCODE SLIM</span>
+      </div>
+
+      <div id="filePathContainer" class="file-path-container" style="display: none;">
+        <span class="file-path-icon">📄</span>
+        <span id="markdownPath"></span>
+        <button id="downloadBtn" class="download-btn" type="button">Download</button>
+      </div>
+
+      <div id="resumeRow" class="resume-row" style="display:none;">
+        <code id="resumeCmd" class="resume-cmd"></code>
+        <button class="copy-btn" id="copyResumeBtn" type="button">Copy</button>
+      </div>
+
+      <div id="questions"></div>
+
+      <div id="nudgeActions" class="nudge-actions" style="display:none;">
+        <button class="nudge-btn" id="moreQuestionsBtn" type="button">Ask more questions</button>
+        <button class="nudge-btn nudge-confirm" id="confirmCompleteBtn" type="button">Confirm complete ✓</button>
+      </div>
+
+       <button class="primary" id="submitButton" disabled>Submit Answers <span class="submit-shortcut">⌘↵</span></button>
+
+      <div class="footer" id="submitStatus"></div>
+    </div>
+
+    <div class="loading-overlay" id="loadingOverlay">
+      <div class="status-text" id="loadingText">Processing...</div>
+    </div>
+
+    <div class="chat-panel" id="chatPanel">
+      <input type="text" id="chatInput" class="chat-input" placeholder="Send a message to the agent - add a section, revise content, ask questions..." autocomplete="off" />
+      <button class="chat-send" id="chatSendBtn" type="button" disabled>Send</button>
+    </div>
+
+    <script>
+      ${clipboardHelperJs()}
+      const interviewId = ${JSON.stringify(interviewId).replace(/</g, '\\u003c')};
+      const resumeSlug = ${JSON.stringify(resumeSlug).replace(/</g, '\\u003c')};
+      const state = {
+        data: null,
+        answers: {},
+        activeQuestionIndex: 0,
+        lastQuestionIds: [],
+        lastSig: null,
+        customMode: {},
+        isSubmitting: false,
+      };
+
+      function updateSubmitButton() {
+        const button = document.getElementById('submitButton');
+        if (!state.data) {
+          button.disabled = true;
+          return;
+        }
+
+        const questions = state.data.questions || [];
+        const allAnswered = questions.every((question) =>
+          (state.answers[question.id] || '').trim().length > 0,
+        );
+        button.disabled =
+          state.data.isBusy ||
+          state.isSubmitting ||
+          !questions.length ||
+          !allAnswered;
+        const hideSubmit = ['completed', 'session-disconnected'];
+        button.style.display = hideSubmit.includes(state.data.mode) ? 'none' : '';
+
+        const overlay = document.getElementById('loadingOverlay');
+        const overlayText = document.getElementById('loadingText');
+        if (state.data.isBusy) {
+          overlay.classList.add('active');
+          overlayText.textContent = "Agent Thinking...";
+        } else {
+          overlay.classList.remove('active');
+        }
+      }
+
+      function getOptionButtonId(questionId, index) {
+        return 'opt-' + questionId + '-' + index;
+      }
+
+      function createOption(question, option, index, isCustom) {
+        const button = document.createElement('button');
+        button.type = 'button';
+        button.className = 'option';
+        button.id = getOptionButtonId(question.id, index);
+
+        const shortcut = index < 9 ? (index + 1) : '';
+        if (shortcut) {
+          const kbd = document.createElement('span');
+          kbd.className = 'shortcut';
+          kbd.textContent = shortcut;
+          button.appendChild(kbd);
+        }
+
+        const text = document.createElement('span');
+        text.className = 'option-text';
+        text.textContent = isCustom ? 'Custom' : option;
+        button.appendChild(text);
+
+        // Visual marking for suggested/recommended answers
+        if (!isCustom && question.suggested === option) {
+          const badge = document.createElement('span');
+          badge.className = 'recommended-badge';
+          badge.textContent = 'Recommended';
+          button.appendChild(badge);
+        }
+
+        button.addEventListener('click', () => {
+          const questions = state.data?.questions || [];
+          const qIdx = questions.findIndex(q => q.id === question.id);
+          if (qIdx !== -1) {
+             state.activeQuestionIndex = qIdx;
+             updateActiveQuestionFocus();
+          }
+          handleOptionSelect(question, option, isCustom);
+        });
+
+        return button;
+      }
+
+      function handleOptionSelect(question, option, isCustom) {
+        const textarea = document.getElementById('answer-' + question.id);
+
+        if (isCustom) {
+          state.customMode[question.id] = true;
+          state.answers[question.id] = state.customMode[question.id]
+            ? state.answers[question.id] || ''
+            : '';
+          updateTextareaVisibility(question.id);
+          updateOptionsDOM(question.id);
+          if (textarea) {
+            textarea.focus();
+          }
+        } else {
+          state.customMode[question.id] = false;
+          state.answers[question.id] = option;
+          updateTextareaVisibility(question.id);
+          advanceToNextQuestion(question.id);
+        }
+
+        updateSubmitButton();
+        updateOptionsDOM(question.id);
+      }
+
+      function updateTextareaVisibility(questionId) {
+        const textarea = document.getElementById('answer-' + questionId);
+        if (!textarea) return;
+        if (state.customMode[questionId]) {
+          textarea.classList.remove('hidden-textarea');
+        } else {
+          textarea.classList.add('hidden-textarea');
+        }
+      }
+
+      function advanceToNextQuestion(currentQuestionId) {
+        const questions = state.data?.questions || [];
+        const currentIndex = questions.findIndex(q => q.id === currentQuestionId);
+
+        if (currentIndex >= 0 && currentIndex < questions.length - 1) {
+          state.activeQuestionIndex = currentIndex + 1;
+          updateActiveQuestionFocus();
+          const nextQuestion = questions[currentIndex + 1];
+          const nextEl = document.getElementById('question-' + nextQuestion.id);
+          if (nextEl) {
+            nextEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
+          }
+        } else if (currentIndex === questions.length - 1) {
+          const submitBtn = document.getElementById('submitButton');
+          if (submitBtn && !submitBtn.disabled) {
+            submitBtn.scrollIntoView({ behavior: 'smooth', block: 'center' });
+          }
+        }
+      }
+
+      function updateOptionsDOM(questionId) {
+        const question = (state.data?.questions || []).find(q => q.id === questionId);
+        if (!question) return;
+
+        const predefined = question.options || [];
+        const currentAnswer = state.answers[question.id];
+
+        predefined.forEach((opt, idx) => {
+           const btn = document.getElementById(getOptionButtonId(questionId, idx));
+           if (btn) {
+              if (currentAnswer === opt) btn.classList.add('selected');
+              else btn.classList.remove('selected');
+           }
+        });
+
+        const customBtn = document.getElementById(getOptionButtonId(questionId, predefined.length));
+        if (customBtn) {
+           if (state.customMode[questionId]) {
+               customBtn.classList.add('selected');
+            } else {
+               customBtn.classList.remove('selected');
+           }
+        }
+      }
+
+      function updateActiveQuestionFocus() {
+         const questions = state.data?.questions || [];
+         questions.forEach((q, idx) => {
+            const wrapper = document.getElementById('question-' + q.id);
+            if (wrapper) {
+               if (idx === state.activeQuestionIndex) {
+                  wrapper.classList.add('active-question');
+               } else {
+                  wrapper.classList.remove('active-question');
+               }
+            }
+         });
+      }
+
+      function scrollToActiveQuestion(behavior) {
+        const questions = state.data?.questions || [];
+        const activeQ = questions[state.activeQuestionIndex];
+        if (!activeQ) return;
+
+        const wrapper = document.getElementById('question-' + activeQ.id);
+        if (wrapper) {
+          wrapper.scrollIntoView({ behavior, block: 'center' });
+        }
+      }
+
+      function syncActiveQuestionIndex(questions) {
+        if (!questions.length) {
+          state.activeQuestionIndex = 0;
+          state.lastQuestionIds = [];
+          return;
+        }
+
+        const nextQuestionIds = questions.map((question) => question.id);
+        const previousQuestionIds = state.lastQuestionIds || [];
+        const activeQuestionId = previousQuestionIds[state.activeQuestionIndex];
+        const nextActiveIndex = activeQuestionId
+          ? nextQuestionIds.indexOf(activeQuestionId)
+          : -1;
+
+        if (nextActiveIndex >= 0) {
+          state.activeQuestionIndex = nextActiveIndex;
+        } else {
+          state.activeQuestionIndex = 0;
+        }
+
+        state.lastQuestionIds = nextQuestionIds;
+      }
+
+      function isTextEntryTarget(target) {
+        return target &&
+          (target.tagName === 'TEXTAREA' ||
+            target.tagName === 'INPUT' ||
+            target.isContentEditable);
+      }
+
+      function isShortcutBlockedTarget(target) {
+        if (!target) return false;
+        return !!target.closest(
+          'button, a, select, summary, textarea, input, [contenteditable="true"]',
+        );
+      }
+
+      document.addEventListener('keydown', (e) => {
+        const isSubmitShortcut =
+          (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) ||
+          (e.key === 's' && (e.metaKey || e.ctrlKey));
+        if (isSubmitShortcut) {
+          const submitBtn = document.getElementById('submitButton');
+          if (submitBtn && !submitBtn.disabled) {
+            submitBtn.click();
+            e.preventDefault();
+          }
+          return;
+        }
+
+        if (isTextEntryTarget(e.target)) return;
+        if (e.ctrlKey || e.metaKey || e.altKey) return;
+
+        const questions = state.data?.questions || [];
+        if (!questions.length) return;
+
+        if (e.key === 'Enter') {
+          if (e.repeat) {
+            e.preventDefault();
+            return;
+          }
+          if (isShortcutBlockedTarget(e.target)) return;
+          if (state.data.isBusy || state.isSubmitting) return;
+
+          const activeQ = questions[state.activeQuestionIndex];
+          if (!activeQ) return;
+
+          const answer = (state.answers[activeQ.id] || '').trim();
+          if (!answer) return;
+
+          const isLastQuestion =
+            state.activeQuestionIndex === questions.length - 1;
+          if (isLastQuestion) {
+            const submitBtn = document.getElementById('submitButton');
+            if (submitBtn && !submitBtn.disabled) {
+              submitBtn.click();
+            }
+          } else {
+            advanceToNextQuestion(activeQ.id);
+          }
+
+          e.preventDefault();
+          return;
+        }
+
+         const num = parseInt(e.key, 10);
+         if (num >= 1 && num <= 9) {
+          const activeQ = questions[state.activeQuestionIndex];
+          if (!activeQ) return;
+
+          const options = activeQ.options || [];
+          if (!options.length) return;
+
+          const idx = num - 1;
+
+          if (idx < options.length) {
+            handleOptionSelect(activeQ, options[idx], false);
+            e.preventDefault();
+          } else if (idx === options.length) {
+            handleOptionSelect(activeQ, 'Custom', true);
+            e.preventDefault();
+         }
+
+        }
+
+        if (e.key === 'ArrowDown') {
+           if (state.activeQuestionIndex < questions.length - 1) {
+              state.activeQuestionIndex++;
+              updateActiveQuestionFocus();
+              const wrapper = document.getElementById('question-' + questions[state.activeQuestionIndex].id);
+              if (wrapper) wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
+              e.preventDefault();
+           }
+        }
+        if (e.key === 'ArrowUp') {
+           if (state.activeQuestionIndex > 0) {
+              state.activeQuestionIndex--;
+              updateActiveQuestionFocus();
+              const wrapper = document.getElementById('question-' + questions[state.activeQuestionIndex].id);
+              if (wrapper) wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
+              e.preventDefault();
+           }
+        }
+      });
+
+      // ── Completed-mode helpers ───────────────────────────────────
+
+      function parseDocument(doc) {
+        if (!doc) return { spec: '', qaPairs: [] };
+        // Strip frontmatter (---...---) to prevent frontmatter lines from
+        // matching section headers or being parsed as content
+        let content = doc;
+        const fmMatch = content.match(/^---\\n[\\s\\S]*?\\n---\\n/);
+        if (fmMatch) content = content.slice(fmMatch[0].length);
+        const lines = content.split('\\n');
+        const specLines = [];
+        const qaLines = [];
+        let section = null;
+        for (const line of lines) {
+          if (/^## Current spec\\b/i.test(line)) { section = 'spec'; continue; }
+          if (/^## Q&A history\\b/i.test(line)) { section = 'qa'; continue; }
+          if (/^#{1,6} /.test(line)) { section = null; continue; }
+          if (section === 'spec') specLines.push(line);
+          else if (section === 'qa') qaLines.push(line);
+        }
+        const qaPairs = [];
+        let current = null;
+        for (const line of qaLines) {
+          const trimmed = line.trim();
+          if (trimmed.startsWith('Q: ')) {
+            if (current) qaPairs.push(current);
+            current = { q: trimmed.slice(3), a: '' };
+          } else if (trimmed.startsWith('A: ') && current) {
+            current.a = trimmed.slice(3);
+          } else if (trimmed && current && current.a) {
+            current.a += ' ' + trimmed;
+          }
+        }
+        if (current) qaPairs.push(current);
+        return { spec: specLines.join('\\n').trim(), qaPairs };
+      }
+
+      // simpleMarkdown: safe because escaping happens BEFORE markdown
+      // processing. Adding raw HTML output from user content would break
+      // this safety - always use textContent for user data.
+      function simpleMarkdown(text) {
+        if (!text) return '';
+        const escaped = text
+          .replace(/&/g, '&amp;')
+          .replace(/</g, '&lt;')
+          .replace(/>/g, '&gt;');
+        const formatted = escaped
+          .replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>')
+          .replace(/\`([^\`]+)\`/g, '<code>$1</code>');
+        const paragraphs = formatted.split(/\\n\\n+/).filter(p => p.trim());
+        return paragraphs.map(p => \`<p>\${p.replace(/\\n/g, '<br>')}</p>\`).join('');
+      }
+
+      async function submitBlockComment(sectionTitle, commentText, button) {
+        if (!commentText.trim()) return;
+        button.disabled = true;
+        const submitStatus = document.getElementById('submitStatus');
+        submitStatus.textContent = '';
+
+        try {
+          const res = await fetch('/api/interviews/' + encodeURIComponent(interviewId) + '/block-comment', {
+            method: 'POST',
+            headers: { 'content-type': 'application/json' },
+            body: JSON.stringify({ section: sectionTitle, comment: commentText }),
+          });
+          const payload = await res.json();
+          if (res.ok) {
+            submitStatus.textContent = 'Feedback queued for block: ' + sectionTitle;
+            const input = button.previousElementSibling;
+            if (input) input.value = '';
+            refresh().catch(() => {});
+          } else {
+            submitStatus.textContent = payload.error || 'Failed to submit comment.';
+          }
+        } catch (err) {
+          submitStatus.textContent = 'Error submitting block connection feedback.';
+        } finally {
+          button.disabled = false;
+        }
+      }
+
+      function renderCompletedView(data) {
+        const container = document.getElementById('questions');
+        const frag = document.createDocumentFragment();
+
+        const blocks = data.blocks || [];
+        if (blocks.length > 0) {
+          const specLabel = document.createElement('div');
+          specLabel.className = 'section-label';
+          specLabel.textContent = 'Interactive Specifications (11 Sections)';
+          frag.appendChild(specLabel);
+
+          for (const block of blocks) {
+            const card = document.createElement('div');
+            card.className = 'spec-block-card';
+
+            const header = document.createElement('h3');
+            header.textContent = block.title;
+            card.appendChild(header);
+
+            const content = document.createElement('div');
+            content.className = 'spec-block-content';
+            content.innerHTML = simpleMarkdown(block.content);
+            card.appendChild(content);
+
+            // Per-block comment integration for micro annotations
+            const commentBox = document.createElement('div');
+            commentBox.className = 'spec-block-comment-box';
+
+            const textarea = document.createElement('textarea');
+            textarea.className = 'spec-block-comment-input';
+            textarea.placeholder = 'Provide review comment/revision for ' + block.title + '...';
+            commentBox.appendChild(textarea);
+
+            const sendBtn = document.createElement('button');
+            sendBtn.className = 'comment-submit-btn';
+            sendBtn.type = 'button';
+            sendBtn.textContent = 'Revise Section';
+            sendBtn.addEventListener('click', () => {
+              submitBlockComment(block.title, textarea.value, sendBtn);
+            });
+            commentBox.appendChild(sendBtn);
+
+            card.appendChild(commentBox);
+            frag.appendChild(card);
+          }
+        } else {
+          // Fallback legacy parse if blocks array isn't populated
+          const { spec, qaPairs } = parseDocument(data.document);
+
+          // Spec section
+          if (spec) {
+            const specLabel = document.createElement('div');
+            specLabel.className = 'section-label';
+            specLabel.textContent = 'Current Spec';
+            frag.appendChild(specLabel);
+            const specBlock = document.createElement('div');
+            specBlock.className = 'spec-block';
+            specBlock.innerHTML = simpleMarkdown(spec);
+            frag.appendChild(specBlock);
+          }
+        }
+
+        // Q&A section
+        const qaLabel = document.createElement('div');
+        qaLabel.className = 'section-label';
+        qaLabel.textContent = 'Q&A History';
+        frag.appendChild(qaLabel);
+
+        const { qaPairs } = parseDocument(data.document);
+        if (!qaPairs.length) {
+          const empty = document.createElement('p');
+          empty.className = 'qa-empty';
+          empty.textContent = 'No Q&A recorded.';
+          frag.appendChild(empty);
+        } else {
+          const qaList = document.createElement('div');
+          qaList.className = 'qa-list';
+          for (const pair of qaPairs) {
+            const card = document.createElement('div');
+            card.className = 'qa-card';
+
+            const qRow = document.createElement('div');
+            qRow.className = 'qa-row';
+            const qBadge = document.createElement('span');
+            qBadge.className = 'qa-badge qa-badge-q';
+            qBadge.textContent = 'Q';
+            const qText = document.createElement('span');
+            qText.className = 'qa-text qa-text-q';
+            qText.textContent = pair.q;
+            qRow.appendChild(qBadge);
+            qRow.appendChild(qText);
+            card.appendChild(qRow);
+
+            const divider = document.createElement('div');
+            divider.className = 'qa-divider';
+            card.appendChild(divider);
+
+            const aRow = document.createElement('div');
+            aRow.className = 'qa-row';
+            const aBadge = document.createElement('span');
+            aBadge.className = 'qa-badge qa-badge-a';
+            aBadge.textContent = 'A';
+            const aText = document.createElement('span');
+            aText.className = 'qa-text qa-text-a';
+            aText.textContent = pair.a || '-';
+            aRow.appendChild(aBadge);
+            aRow.appendChild(aText);
+            card.appendChild(aRow);
+
+            qaList.appendChild(card);
+          }
+          frag.appendChild(qaList);
+        }
+
+        container.replaceChildren(frag);
+      }
+
+      // ── TOC Sidebar ──────────────────────────────────────────────
+      function updateTocSidebar(data) {
+        const sidebar = document.getElementById('tocSidebar');
+        const blocks = data.blocks || [];
+        const isDone = ['completed', 'session-disconnected'].includes(data.mode);
+        if (!isDone || !blocks.length) {
+          sidebar.classList.remove('visible');
+          document.body.classList.remove('toc-visible');
+          return;
+        }
+        sidebar.classList.add('visible');
+        document.body.classList.add('toc-visible');
+        sidebar.innerHTML = '';
+        const header = document.createElement('div');
+        header.className = 'toc-header';
+        header.textContent = 'Sections';
+        sidebar.appendChild(header);
+        blocks.forEach((block) => {
+          const item = document.createElement('a');
+          item.className = 'toc-item';
+          item.textContent = block.title;
+          item.addEventListener('click', () => {
+            const cards = document.querySelectorAll('.spec-block-card h3');
+            for (const h3 of cards) {
+              if (h3.textContent === block.title) {
+                h3.closest('.spec-block-card').scrollIntoView({ behavior: 'smooth', block: 'start' });
+                break;
+              }
+            }
+            document.querySelectorAll('.toc-item').forEach((el) => el.classList.remove('active'));
+            item.classList.add('active');
+          });
+          sidebar.appendChild(item);
+        });
+      }
+
+      // ── Chat Panel ───────────────────────────────────────────────
+      function updateChatPanel(data) {
+        const panel = document.getElementById('chatPanel');
+        const input = document.getElementById('chatInput');
+        const sendBtn = document.getElementById('chatSendBtn');
+        const isDone = ['completed', 'session-disconnected'].includes(data.mode);
+        if (!isDone) {
+          panel.classList.remove('visible');
+          document.body.classList.remove('chat-visible');
+          return;
+        }
+        panel.classList.add('visible');
+        document.body.classList.add('chat-visible');
+        const busy = data.isBusy || false;
+        sendBtn.disabled = busy;
+        input.disabled = busy;
+        if (busy) {
+          input.placeholder = 'Agent is processing...';
+        } else {
+          input.placeholder = 'Send a message to the agent - add a section, revise content, ask questions...';
+        }
+      }
+
+      async function sendChatMessage() {
+        const input = document.getElementById('chatInput');
+        const sendBtn = document.getElementById('chatSendBtn');
+        const message = input.value.trim();
+        if (!message) return;
+        sendBtn.disabled = true;
+        input.value = '';
+        const submitStatus = document.getElementById('submitStatus');
+        submitStatus.textContent = '';
+        try {
+          const res = await fetch('/api/interviews/' + encodeURIComponent(interviewId) + '/chat', {
+            method: 'POST',
+            headers: { 'content-type': 'application/json' },
+            body: JSON.stringify({ message }),
+          });
+          const payload = await res.json();
+          if (res.ok) {
+            submitStatus.textContent = 'Chat message sent to agent.';
+            refresh().catch(() => {});
+            schedulePoll();
+          } else {
+            submitStatus.textContent = payload.message || payload.error || 'Failed to send chat message.';
+            sendBtn.disabled = false;
+          }
+        } catch (_err) {
+          submitStatus.textContent = 'Network error sending chat message.';
+          sendBtn.disabled = false;
+        }
+      }
+
+      document.getElementById('chatSendBtn').addEventListener('click', sendChatMessage);
+      document.getElementById('chatInput').addEventListener('keydown', (e) => {
+        if (e.key === 'Enter' && !e.shiftKey) {
+          e.preventDefault();
+          sendChatMessage();
+        }
+      });
+
+      function renderQuestions(questions) {
+        const sig = JSON.stringify([questions, state.data?.mode]);
+        const container = document.getElementById('questions');
+        const previousActiveQuestionId =
+          state.lastQuestionIds[state.activeQuestionIndex];
+
+        syncActiveQuestionIndex(questions);
+
+        if (state.lastSig === sig) {
+          questions.forEach((q) => updateOptionsDOM(q.id));
+          updateActiveQuestionFocus();
+          return;
+        }
+
+        state.lastSig = sig;
+        container.replaceChildren();
+
+        if (!questions.length && !state.data?.isBusy) {
+          const doneModes = ['completed', 'session-disconnected'];
+          if (doneModes.includes(state.data?.mode)) {
+            renderCompletedView(state.data);
+          } else {
+            const empty = document.createElement('p');
+            empty.className = 'muted';
+            empty.style.textAlign = 'center';
+            empty.style.padding = '48px 0';
+            empty.textContent = 'No active questions right now.';
+            container.appendChild(empty);
+          }
+          return;
+        }
+
+        questions.forEach((question, idx) => {
+          const wrapper = document.createElement('div');
+          wrapper.className = 'question';
+          wrapper.id = 'question-' + question.id;
+
+           if (question.suggested && !state.answers[question.id]) {
+             state.answers[question.id] = question.suggested;
+             state.customMode[question.id] = false;
+            }
+
+          const title = document.createElement('h3');
+          title.textContent = question.question;
+          wrapper.appendChild(title);
+
+          const predefined = question.options || [];
+          if (predefined.length) {
+            const hint = document.createElement('div');
+            hint.className = 'question-hint';
+            hint.innerHTML =
+              '<span class="hint-chip"><kbd>1-9</kbd><span>Choose an option</span></span>' +
+              '<span class="hint-chip"><kbd>Enter</kbd><span>Accept selected answer</span></span>';
+            wrapper.appendChild(hint);
+          }
+
+          if (predefined.length) {
+            const options = document.createElement('div');
+            options.className = 'options';
+            predefined.forEach((option, optIdx) => {
+              options.appendChild(createOption(question, option, optIdx, false));
+            });
+            options.appendChild(createOption(question, 'Custom', predefined.length, true));
+            wrapper.appendChild(options);
+          }
+
+           const textarea = document.createElement('textarea');
+           textarea.id = 'answer-' + question.id;
+           textarea.placeholder = 'Type your answer here...';
+           textarea.value = state.customMode[question.id] ? (state.answers[question.id] || '') : '';
+           if (!state.customMode[question.id]) {
+             textarea.classList.add('hidden-textarea');
+           }
+
+           textarea.addEventListener('focus', () => {
+              state.activeQuestionIndex = idx;
+            updateActiveQuestionFocus();
+          });
+
+          textarea.addEventListener('input', () => {
+            state.answers[question.id] = textarea.value;
+            updateSubmitButton();
+            updateOptionsDOM(question.id);
+          });
+          wrapper.appendChild(textarea);
+
+          container.appendChild(wrapper);
+        });
+
+        updateActiveQuestionFocus();
+        questions.forEach(q => updateOptionsDOM(q.id));
+        const currentActiveQuestionId = questions[state.activeQuestionIndex]?.id;
+        if (
+          questions.length > 0 &&
+          previousActiveQuestionId !== currentActiveQuestionId
+        ) {
+          scrollToActiveQuestion('smooth');
+        }
+      }
+
+      function render(data) {
+        state.data = data;
+        document.getElementById('idea').textContent = data.interview.idea || 'Interview';
+        const doneModes = ['completed', 'session-disconnected'];
+        const isDone = doneModes.includes(data.mode);
+        const summaryEl = document.getElementById('summary');
+        if (isDone) {
+          summaryEl.style.display = 'none';
+        } else {
+          summaryEl.style.display = '';
+          summaryEl.textContent = data.summary || 'Session in progress.';
+        }
+        const statusEl = document.getElementById('status');
+        statusEl.textContent = data.mode.toUpperCase();
+        statusEl.className = isDone ? 'status-completed' : '';
+
+        // Render Markdown Path - always visible in completed mode
+        const pathContainer = document.getElementById('filePathContainer');
+        const pathElement = document.getElementById('markdownPath');
+        const mdPath = data.markdownPath || (data.interview && data.interview.markdownPath);
+        if (mdPath || isDone) {
+          if (mdPath) {
+            // Show just the filename for absolute paths
+            pathElement.textContent = mdPath.startsWith('/')
+              ? mdPath.split('/').pop() || mdPath
+              : mdPath;
+          } else {
+            pathElement.textContent = 'interview.md';
+          }
+          pathContainer.style.display = 'flex';
+        } else {
+          pathContainer.style.display = 'none';
+        }
+
+        // Resume command - only in session-disconnected mode
+        var resumeRow = document.getElementById('resumeRow');
+        if (resumeRow) {
+          var showResume = data.mode === 'session-disconnected';
+          resumeRow.style.display = showResume ? 'flex' : 'none';
+         if (showResume) {
+             document.getElementById('resumeCmd').textContent = '/interview ' + resumeSlug;
+          }
+        }
+
+        // Nudge actions - only for completed (live session)
+        // session-disconnected can't nudge (no session to receive it)
+        const nudgeActions = document.getElementById('nudgeActions');
+        const moreBtn = document.getElementById('moreQuestionsBtn');
+        const confirmBtn = document.getElementById('confirmCompleteBtn');
+        const canNudge = data.mode === 'completed';
+        if (nudgeActions) nudgeActions.style.display = canNudge ? 'flex' : 'none';
+        if (moreBtn) moreBtn.disabled = data.isBusy;
+        if (confirmBtn) confirmBtn.disabled = data.isBusy;
+
+        renderQuestions(data.questions || []);
+        updateSubmitButton();
+        updateTocSidebar(data);
+        updateChatPanel(data);
+
+        // If we transitioned to a non-terminal state and connection/polling is stopped, restart them
+        const terminalModes = ['abandoned', 'completed', 'session-disconnected'];
+        if (!terminalModes.includes(data.mode)) {
+          if (!sseConnected && !activeEs) {
+            connectSse();
+          }
+          if (!sseConnected && !pollFallbackTimer) {
+            schedulePoll();
+          }
+        }
+      }
+
+      async function refresh() {
+        const url = '/api/interviews/' + encodeURIComponent(interviewId) + '/state';
+        const response = await fetch(url);
+        const data = await response.json();
+        if (!response.ok) throw new Error(data.error || 'Failed to load state');
+        render(data);
+      }
+
+      function scrollToTop() {
+        window.scrollTo({ top: 0, left: 0, behavior: 'smooth' });
+      }
+
+      document.getElementById('submitButton').addEventListener('click', async () => {
+        if (!state.data || state.isSubmitting) return;
+        document.getElementById('submitButton').blur();
+        const answers = (state.data.questions || []).map((question) => {
+          return {
+            questionId: question.id,
+            answer: (state.answers[question.id] || '').trim(),
+          };
+        });
+
+        state.isSubmitting = true;
+        updateSubmitButton();
+
+        const overlay = document.getElementById('loadingOverlay');
+        const overlayText = document.getElementById('loadingText');
+        overlay.classList.add('active');
+        overlayText.textContent = "Submitting Answers...";
+        scrollToTop();
+
+        try {
+          const response = await fetch('/api/interviews/' + encodeURIComponent(interviewId) + '/answers', {
+            method: 'POST',
+            headers: { 'content-type': 'application/json' },
+            body: JSON.stringify({ answers }),
+          });
+          const payload = await response.json();
+          document.getElementById('submitStatus').textContent = payload.message || (response.ok ? 'Answers submitted successfully.' : 'Submission failed.');
+        } catch (err) {
+          document.getElementById('submitStatus').textContent = 'Error submitting answers.';
+        }
+        state.isSubmitting = false;
+        updateSubmitButton();
+        try {
+          await refresh();
+        } catch (_error) {
+          overlay.classList.remove('active');
+        }
+      });
+
+      document.getElementById('downloadBtn').addEventListener('click', () => {
+        if (!state.data || !state.data.document) return;
+        const blob = new Blob([state.data.document], { type: 'text/markdown' });
+        const url = URL.createObjectURL(blob);
+        const a = document.createElement('a');
+        a.href = url;
+        const mdPath = state.data.markdownPath || 'interview.md';
+        a.download = mdPath.split('/').pop() || 'interview.md';
+        document.body.appendChild(a);
+        a.click();
+        document.body.removeChild(a);
+        URL.revokeObjectURL(url);
+      });
+
+      document.getElementById('copyResumeBtn').addEventListener('click', function(e) {
+        e.stopPropagation();
+        e.preventDefault();
+        var cmd = document.getElementById('resumeCmd').textContent;
+        if (cmd) copyCommand(cmd, this);
+      });
+
+      async function sendNudge(action) {
+        const moreBtn = document.getElementById('moreQuestionsBtn');
+        const confirmBtn = document.getElementById('confirmCompleteBtn');
+        if (moreBtn) moreBtn.disabled = true;
+        if (confirmBtn) confirmBtn.disabled = true;
+        document.getElementById('submitStatus').textContent = '';
+        try {
+          const url =
+            '/api/interviews/' +
+            encodeURIComponent(interviewId) +
+            '/nudge';
+          const res = await fetch(url, {
+            method: 'POST',
+            headers: { 'content-type': 'application/json' },
+            body: JSON.stringify({ action }),
+          });
+          if (!res.ok) {
+            const err = await res.json().catch(() => ({}));
+            document.getElementById('submitStatus').textContent =
+              err.error || 'Request failed.';
+            if (moreBtn) moreBtn.disabled = false;
+            if (confirmBtn) confirmBtn.disabled = false;
+          } else {
+            try { await refresh(); } catch (_) {}
+            schedulePoll(); // Restart polling - nudge may reactivate interview
+          }
+        } catch (_err) {
+          document.getElementById('submitStatus').textContent = 'Network error.';
+          if (moreBtn) moreBtn.disabled = false;
+          if (confirmBtn) confirmBtn.disabled = false;
+        }
+      }
+
+      document.getElementById('moreQuestionsBtn').addEventListener('click', () => sendNudge('more-questions'));
+      document.getElementById('confirmCompleteBtn').addEventListener('click', () => sendNudge('confirm-complete'));
+
+      // ── Real-time updates via SSE ─────────────────────────────────
+      let sseConnected = false;
+      let pollFallbackTimer = null;
+      let activeEs = null;
+
+      function connectSse() {
+        if (activeEs) {
+          activeEs.close();
+        }
+        const sseUrl = '/api/interviews/' + encodeURIComponent(interviewId) + '/events';
+        const es = new EventSource(sseUrl);
+        activeEs = es;
+
+        es.addEventListener('state', (e) => {
+          sseConnected = true;
+          if (pollFallbackTimer) {
+            clearTimeout(pollFallbackTimer);
+            pollFallbackTimer = null;
+          }
+          try {
+            const data = JSON.parse(e.data);
+            render(data);
+          } catch (_) {}
+        });
+
+        es.onerror = () => {
+          sseConnected = false;
+          es.close();
+          activeEs = null;
+          const terminalModes = ['abandoned', 'completed', 'session-disconnected'];
+          if (state.data && terminalModes.includes(state.data.mode)) {
+            return;
+          }
+          // Retry SSE after 3s, fall back to polling in the meantime
+          if (!pollFallbackTimer) schedulePoll();
+          setTimeout(() => connectSse(), 3000);
+        };
+      }
+
+      function schedulePoll() {
+        // Only poll if SSE is not connected
+        if (sseConnected) return;
+        const terminalModes = ['abandoned', 'completed', 'session-disconnected'];
+        if (state.data && terminalModes.includes(state.data.mode)) {
+          return;
+        }
+        pollFallbackTimer = setTimeout(async () => {
+          try { await refresh(); } catch (_) {}
+          pollFallbackTimer = null;
+          if (!sseConnected) {
+            schedulePoll();
+          }
+        }, 2500);
+      }
+
+      refresh().catch((error) => {
+        document.getElementById('submitStatus').textContent = error.message || 'Failed to load interview.';
+      });
+      connectSse();
+    </script>
+  </body>
+</html>`;
+}

+ 13 - 0
src/v2/codemap.md

@@ -19,6 +19,7 @@ v2 registrations. v1 behavior is unchanged.
 | `types.ts` | v2 plugin context surface (`V2Context` + draft/event types), mirrored locally (v2 plugin package is not a build-time dependency). |
 | `types.ts` | v2 plugin context surface (`V2Context` + draft/event types), mirrored locally (v2 plugin package is not a build-time dependency). |
 | `client-shim.ts` | `buildPluginInput`: constructs a v1-shaped `PluginInput` (shimmed `client`, `process.cwd()` directory) for the v1 factory. |
 | `client-shim.ts` | `buildPluginInput`: constructs a v1-shaped `PluginInput` (shimmed `client`, `process.cwd()` directory) for the v1 factory. |
 | `adapters.ts` | Shape adapters: `parseModelRef`, `adaptPermissions` (v1 map → v2 Rule[] + v2 permissive base + `task`→`subagent`/`bash`→`execute` mapping), `rewritePromptForV2` (`task(`→`subagent(`), `adaptTool`, `applyAgentToDraft`. |
 | `adapters.ts` | Shape adapters: `parseModelRef`, `adaptPermissions` (v1 map → v2 Rule[] + v2 permissive base + `task`→`subagent`/`bash`→`execute` mapping), `rewritePromptForV2` (`task(`→`subagent(`), `adaptTool`, `applyAgentToDraft`. |
+| `interview-bridge.ts` | v2-only `/interview` marker command, trailing-message context bridge, v2 interview runtime, and per-session transcript projections. |
 
 
 ## Data Flow
 ## Data Flow
 
 
@@ -35,11 +36,16 @@ v2 registrations. v1 behavior is unchanged.
      Message.content ↔ v1 `{info,parts}` conversion + `rewritePromptForV2`)
      Message.content ↔ v1 `{info,parts}` conversion + `rewritePromptForV2`)
    - `tool.execute.before/after` → `ctx.tool.hook`
    - `tool.execute.before/after` → `ctx.tool.hook`
    - `event` → `ctx.event.subscribe()` loop
    - `event` → `ctx.event.subscribe()` loop
+    - interview marker/context/events → `interview-bridge.ts`
 5. Returns a cleanup that disposes every v2 registration + the v1 `dispose`.
 5. Returns a cleanup that disposes every v2 registration + the v1 `dispose`.
 
 
 Each bridge in step 4 is independently try/catch-guarded so one failure cannot
 Each bridge in step 4 is independently try/catch-guarded so one failure cannot
 disable the rest.
 disable the rest.
 
 
+The interview bridge is intentionally separate from `client-shim.ts`: it uses
+v2 session methods and in-memory context/text event projections rather than
+expanding the global v2 client surface.
+
 ## Key Decisions
 ## Key Decisions
 
 
 - **No v2 type imports.** The v2 plugin package is not a build-time dependency
 - **No v2 type imports.** The v2 plugin package is not a build-time dependency
@@ -50,6 +56,13 @@ disable the rest.
 - **Permission base.** v1 permission maps list only explicit entries (unlisted
 - **Permission base.** v1 permission maps list only explicit entries (unlisted
   → implicit default-allow); v2 has no implicit default, so `adaptPermissions`
   → implicit default-allow); v2 has no implicit default, so `adaptPermissions`
   prepends v2's standard permissive base before overlaying v1 entries.
   prepends v2's standard permissive base before overlaying v1 entries.
+- **Interview configuration.** `setup` resolves the current plugin config and
+  passes the complete `interview` object to the v2 interview bridge. The bridge
+  uses its `maxQuestions`, `outputFolder`, `autoOpenBrowser`, `port`, and
+  `dashboard` values rather than rebuilding defaults at the boundary.
+- **Interview cache boundary.** The interview context hook only rewrites the
+  current trailing command message; prior messages remain unchanged for
+  provider prompt-cache prefix reuse.
 
 
 ## Integration Points
 ## Integration Points
 
 

+ 5 - 0
src/v2/index.ts

@@ -12,6 +12,11 @@
  * the peer modules. See `codemap.md`.
  * the peer modules. See `codemap.md`.
  */
  */
 
 
+export type { V2InterviewBridge } from './interview-bridge';
+export {
+  createV2InterviewBridge,
+  INTERVIEW_COMMAND_MARKER,
+} from './interview-bridge';
 export { createV2Setup } from './setup';
 export { createV2Setup } from './setup';
 export type {
 export type {
   ModelRef,
   ModelRef,

+ 206 - 0
src/v2/interview-bridge.test.ts

@@ -0,0 +1,206 @@
+import { describe, expect, mock, test } from 'bun:test';
+import * as fs from 'node:fs/promises';
+import { createServer } from 'node:http';
+import {
+  createV2InterviewBridge,
+  INTERVIEW_COMMAND_MARKER,
+} from './interview-bridge';
+
+function createContext(overrides?: {
+  synthetic?: (input: Record<string, unknown>) => Promise<unknown>;
+  update?: (input: Record<string, unknown>) => Promise<unknown>;
+}): any {
+  return {
+    session: {
+      hook: mock(async () => ({ dispose() {} })),
+      synthetic: overrides?.synthetic,
+      update: overrides?.update,
+    },
+  };
+}
+
+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') {
+        server.close(() => reject(new Error('Failed to get free port')));
+        return;
+      }
+      const port = address.port;
+      server.close(() => resolve(port));
+    });
+  });
+}
+
+describe('v2 interview bridge', () => {
+  test('registers an orchestrator-owned marker command and rewrites only the tail', async () => {
+    const directory = `.tmp-v2-interview-${Date.now()}`;
+    const synthetic = mock(async () => ({}));
+    const update = mock(async () => ({}));
+    const bridge = createV2InterviewBridge(
+      createContext({ synthetic, update }),
+      {
+        outputFolder: directory,
+      } as never,
+    );
+    const commands: Record<string, Record<string, unknown>> = {};
+    bridge.registerCommand({
+      update(name, apply) {
+        commands[name] = {};
+        apply(commands[name]);
+      },
+    });
+
+    expect(commands.interview).toMatchObject({
+      agent: 'orchestrator',
+      template: INTERVIEW_COMMAND_MARKER,
+    });
+
+    const earlier = {
+      id: 'old',
+      role: 'user',
+      content: [{ type: 'text', text: 'Earlier context' }],
+    };
+    const event = {
+      sessionID: 'ses_v2',
+      agent: 'orchestrator',
+      model: {},
+      system: [],
+      tools: {},
+      messages: [
+        earlier,
+        {
+          id: 'tail',
+          role: 'user',
+          content: [
+            {
+              type: 'text',
+              text: '<omos-interview-command>build a notes app</omos-interview-command>',
+            },
+          ],
+        },
+      ],
+    };
+    const earlierBefore = structuredClone(earlier);
+    await bridge.handleContext(event);
+
+    expect(earlier).toEqual(earlierBefore);
+    expect(event.messages[1].content[0].text).toContain('build a notes app');
+    expect(event.messages[1].content[0].text).toContain('<interview_state>');
+    expect(synthetic).toHaveBeenCalled();
+    expect(update).toHaveBeenCalledWith({
+      sessionID: 'ses_v2',
+      title: 'Interview: build a notes app',
+    });
+
+    bridge.dispose();
+    await fs.rm(`${process.cwd()}/${directory}`, {
+      recursive: true,
+      force: true,
+    });
+  });
+
+  test('projects text events and removes a deleted session', async () => {
+    const bridge = createV2InterviewBridge(createContext());
+    await bridge.handleContext({
+      sessionID: 'ses_text',
+      agent: 'orchestrator',
+      model: {},
+      system: [],
+      tools: {},
+      messages: [
+        {
+          id: 'u',
+          role: 'user',
+          content: [{ type: 'text', text: 'hello' }],
+        },
+      ],
+    });
+    await bridge.handleEvent({
+      type: 'session.next.text.started',
+      properties: { sessionID: 'ses_text' },
+    });
+    await bridge.handleEvent({
+      type: 'session.next.text.delta',
+      properties: { sessionID: 'ses_text', delta: 'one' },
+    });
+    await bridge.handleEvent({
+      type: 'session.next.text.delta',
+      properties: { sessionID: 'ses_text', delta: ' two' },
+    });
+    expect(bridge.getTranscript('ses_text').at(-1)?.parts?.[0]?.text).toBe(
+      'one two',
+    );
+
+    await bridge.handleEvent({
+      type: 'session.deleted',
+      properties: { sessionID: 'ses_text' },
+    });
+    expect(bridge.getTranscript('ses_text')).toEqual([]);
+    bridge.dispose();
+  });
+
+  test('shares one configured dashboard across multiple v2 sessions', async () => {
+    const directory = `.tmp-v2-dashboard-${Date.now()}`;
+    const port = await findFreePort();
+    const config = {
+      outputFolder: directory,
+      dashboard: true,
+      port,
+    } as never;
+    const synthetic1 = mock(async () => ({}));
+    const synthetic2 = mock(async () => ({}));
+    const bridge1 = createV2InterviewBridge(
+      createContext({ synthetic: synthetic1 }),
+      config,
+    );
+    await new Promise((resolve) => setTimeout(resolve, 100));
+    const bridge2 = createV2InterviewBridge(
+      createContext({ synthetic: synthetic2 }),
+      config,
+    );
+
+    try {
+      const createEvent = (sessionID: string, idea: string) => ({
+        sessionID,
+        agent: 'orchestrator',
+        model: {},
+        system: [],
+        tools: {},
+        messages: [
+          {
+            id: `${sessionID}-command`,
+            role: 'user',
+            content: [
+              {
+                type: 'text',
+                text: `<omos-interview-command>${idea}</omos-interview-command>`,
+              },
+            ],
+          },
+        ],
+      });
+
+      const event1 = createEvent('v2-session-1', 'first dashboard idea');
+      const event2 = createEvent('v2-session-2', 'second dashboard idea');
+      await bridge1.handleContext(event1);
+      await bridge2.handleContext(event2);
+
+      expect(synthetic1.mock.calls[0]?.[0].text).toContain(
+        `http://127.0.0.1:${port}/interview/`,
+      );
+      expect(synthetic2.mock.calls[0]?.[0].text).toContain(
+        `http://127.0.0.1:${port}/interview/`,
+      );
+    } finally {
+      await bridge1.dispose();
+      await bridge2.dispose();
+      await fs.rm(`${process.cwd()}/${directory}`, {
+        recursive: true,
+        force: true,
+      });
+    }
+  });
+});

+ 275 - 0
src/v2/interview-bridge.ts

@@ -0,0 +1,275 @@
+import type { InterviewConfig, PluginConfig } from '../config';
+import { DEFAULT_DASHBOARD_PORT } from '../interview/dashboard';
+import { createDashboardManager } from '../interview/dashboard-manager';
+import type { InterviewSessionRuntime } from '../interview/runtime';
+import { createInterviewServer } from '../interview/server';
+import { createInterviewService } from '../interview/service';
+import type { InterviewMessage } from '../interview/types';
+import { log } from '../utils/logger';
+import type { V2Context, V2SessionContextEvent } from './types';
+
+export const INTERVIEW_COMMAND_MARKER =
+  '<omos-interview-command>$ARGUMENTS</omos-interview-command>';
+
+const MARKER_PATTERN =
+  /<omos-interview-command>\s*([\s\S]*?)\s*<\/omos-interview-command>/i;
+
+type V2SessionMethods = {
+  prompt?: (input: Record<string, unknown>) => Promise<unknown>;
+  promptAsync?: (input: Record<string, unknown>) => Promise<unknown>;
+  synthetic?: (input: Record<string, unknown>) => Promise<unknown>;
+  update?: (input: Record<string, unknown>) => Promise<unknown>;
+};
+
+function textFromContent(content: Array<Record<string, unknown>>): string {
+  return content
+    .filter((part) => part.type === 'text')
+    .map((part) => (typeof part.text === 'string' ? part.text : ''))
+    .join('');
+}
+
+function toInterviewMessages(event: V2SessionContextEvent): InterviewMessage[] {
+  return event.messages.map((message) => ({
+    info: { role: message.role, id: message.id },
+    parts: message.content.map((part) => ({
+      type: typeof part.type === 'string' ? part.type : undefined,
+      text: typeof part.text === 'string' ? part.text : undefined,
+    })),
+  }));
+}
+
+export interface V2InterviewBridge {
+  readonly service: ReturnType<typeof createInterviewService>;
+  readonly runtime: InterviewSessionRuntime;
+  registerCommand(draft: {
+    update(
+      name: string,
+      update: (command: Record<string, unknown>) => void,
+    ): void;
+  }): void;
+  handleContext(event: V2SessionContextEvent): Promise<void>;
+  handleEvent(event: Record<string, unknown>): Promise<void>;
+  getTranscript(sessionID: string): InterviewMessage[];
+  dispose(): void;
+}
+
+export function createV2InterviewBridge(
+  ctx: V2Context,
+  config?: InterviewConfig,
+): V2InterviewBridge {
+  const transcripts = new Map<string, InterviewMessage[]>();
+  const activeText = new Map<string, string>();
+  const methods = ctx.session as V2SessionMethods;
+
+  const runtime: InterviewSessionRuntime = {
+    messages: async (sessionID) => transcripts.get(sessionID) ?? [],
+    notify: async (sessionID, text) => {
+      if (methods.synthetic) {
+        await methods.synthetic({ sessionID, text });
+        return;
+      }
+      if (methods.prompt) {
+        await methods.prompt({
+          sessionID,
+          noReply: true,
+          parts: [{ type: 'text', text }],
+        });
+      }
+    },
+    continue: async (sessionID, text) => {
+      const input = {
+        sessionID,
+        agent: 'orchestrator',
+        parts: [{ type: 'text', text, synthetic: true }],
+      };
+      if (methods.promptAsync) {
+        await methods.promptAsync(input);
+        return;
+      }
+      if (methods.prompt) await methods.prompt(input);
+    },
+    rename: async (sessionID, title) => {
+      if (methods.update) await methods.update({ sessionID, title });
+    },
+  };
+
+  const dashboardEnabled =
+    config?.dashboard === true || (config?.port ?? 0) > 0;
+  const outputFolder = config?.outputFolder ?? 'interview';
+  const dashboardPort =
+    (config?.port ?? 0) > 0 ? (config?.port ?? 0) : DEFAULT_DASHBOARD_PORT;
+  const pluginContext = { directory: process.cwd() } as never;
+  const dashboardManager = dashboardEnabled
+    ? createDashboardManager(
+        pluginContext,
+        { interview: config } as PluginConfig,
+        dashboardPort,
+        outputFolder,
+        {
+          runtime,
+          sessionClient: {
+            list: async () => ({ data: [] }),
+          } as never,
+        },
+      )
+    : null;
+  const service =
+    dashboardManager?.service ??
+    createInterviewService(pluginContext, config, { runtime });
+  const server = dashboardManager
+    ? null
+    : createInterviewServer({
+        getState: (interviewID) => service.getInterviewState(interviewID),
+        listInterviewFiles: () => service.listInterviewFiles(),
+        listInterviews: () => service.listInterviews(),
+        submitAnswers: (interviewID, answers) =>
+          service.submitAnswers(interviewID, answers),
+        submitBlockComment: (interviewID, section, comment) =>
+          service.submitBlockComment(interviewID, section, comment),
+        submitChat: (interviewID, message) =>
+          service.submitChat(interviewID, message),
+        handleNudgeAction: (interviewID, action) =>
+          service.handleNudgeAction(interviewID, action),
+        outputFolder,
+        port: 0,
+      });
+  if (server) service.setBaseUrlResolver(() => server.ensureStarted());
+
+  function registerCommand(draft: {
+    update(
+      name: string,
+      update: (command: Record<string, unknown>) => void,
+    ): void;
+  }): void {
+    draft.update('interview', (command) => {
+      command.name = 'interview';
+      command.agent = 'orchestrator';
+      command.description = 'Open a localhost interview UI for a feature idea';
+      command.template = INTERVIEW_COMMAND_MARKER;
+    });
+  }
+
+  async function handleContext(event: V2SessionContextEvent): Promise<void> {
+    const messages = toInterviewMessages(event);
+    transcripts.set(event.sessionID, messages);
+
+    const trailing = event.messages.at(-1);
+    if (!trailing || trailing.role !== 'user') return;
+    const text = textFromContent(trailing.content);
+    const match = text.match(MARKER_PATTERN);
+    if (!match) return;
+
+    const output = {
+      parts: [] as Array<{
+        type: string;
+        text?: string;
+        synthetic?: boolean;
+        metadata?: Record<string, unknown>;
+      }>,
+    };
+    await (dashboardManager ?? service).handleCommandExecuteBefore(
+      {
+        command: 'interview',
+        sessionID: event.sessionID,
+        arguments: match[1].trim(),
+      },
+      output,
+    );
+
+    // Only replace the current command message. Earlier messages are left
+    // byte-for-byte untouched so provider prompt prefixes remain cacheable.
+    trailing.content = output.parts.map((part) => ({ ...part }));
+    transcripts.set(event.sessionID, toInterviewMessages(event));
+  }
+
+  function appendText(sessionID: string, text: string): void {
+    const messages = transcripts.get(sessionID) ?? [];
+    const last = messages.at(-1);
+    if (last?.info?.role === 'assistant') {
+      const part = last.parts?.find((item) => item.type === 'text');
+      if (part) {
+        part.text = text;
+      } else {
+        last.parts = [{ type: 'text', text }];
+      }
+    } else {
+      messages.push({
+        info: { role: 'assistant' },
+        parts: [{ type: 'text', text }],
+      });
+    }
+    transcripts.set(sessionID, messages);
+  }
+
+  function beginText(sessionID: string): void {
+    const messages = transcripts.get(sessionID) ?? [];
+    messages.push({
+      info: { role: 'assistant' },
+      parts: [{ type: 'text', text: '' }],
+    });
+    transcripts.set(sessionID, messages);
+  }
+
+  async function handleEvent(event: Record<string, unknown>): Promise<void> {
+    const type = typeof event.type === 'string' ? event.type : '';
+    const properties = (event.properties ?? {}) as Record<string, unknown>;
+    const sessionID =
+      (typeof properties.sessionID === 'string' && properties.sessionID) ||
+      ((properties.info as { id?: string } | undefined)?.id ?? '');
+    if (!sessionID) return;
+
+    if (type === 'session.next.text.started') {
+      activeText.set(sessionID, '');
+      beginText(sessionID);
+      return;
+    }
+    if (type === 'session.next.text.delta') {
+      const text = `${activeText.get(sessionID) ?? ''}${typeof properties.delta === 'string' ? properties.delta : ''}`;
+      activeText.set(sessionID, text);
+      appendText(sessionID, text);
+      return;
+    }
+    if (type === 'session.next.text.ended') {
+      const text =
+        typeof properties.text === 'string'
+          ? properties.text
+          : (activeText.get(sessionID) ?? '');
+      activeText.delete(sessionID);
+      appendText(sessionID, text);
+      await (dashboardManager ?? service).handleEvent({
+        event: { type, properties },
+      });
+      return;
+    }
+    if (type === 'session.deleted') {
+      activeText.delete(sessionID);
+      transcripts.delete(sessionID);
+      await (dashboardManager ?? service).handleEvent({
+        event: { type: 'session.deleted', properties: { sessionID } },
+      });
+      return;
+    }
+
+    if (type === 'session.status') {
+      await (dashboardManager ?? service).handleEvent({
+        event: { type, properties },
+      });
+    }
+  }
+
+  return {
+    service,
+    runtime,
+    registerCommand,
+    handleContext,
+    handleEvent,
+    getTranscript: (sessionID) => transcripts.get(sessionID) ?? [],
+    dispose: async () => {
+      if (dashboardManager) await dashboardManager.dispose();
+      server?.close();
+      activeText.clear();
+      transcripts.clear();
+      log('[v2][interview] bridge disposed');
+    },
+  };
+}

+ 47 - 3
src/v2/setup.ts

@@ -8,10 +8,13 @@
  * and the event stream. Each bridge is independently try/catch-guarded.
  * and the event stream. Each bridge is independently try/catch-guarded.
  */
  */
 
 
+import { loadPluginConfig } from '../config/loader';
+import { InterviewConfigSchema } from '../config/schema';
 import { OhMyOpenCodeLite } from '../index';
 import { OhMyOpenCodeLite } from '../index';
 import { initLogger, log } from '../utils/logger';
 import { initLogger, log } from '../utils/logger';
 import { adaptTool, applyAgentToDraft } from './adapters';
 import { adaptTool, applyAgentToDraft } from './adapters';
 import { buildPluginInput } from './client-shim';
 import { buildPluginInput } from './client-shim';
+import { createV2InterviewBridge } from './interview-bridge';
 import type {
 import type {
   V2Cleanup,
   V2Cleanup,
   V2Context,
   V2Context,
@@ -53,6 +56,12 @@ export function createV2Setup(): (ctx: V2Context) => Promise<V2Cleanup> {
 
 
     if (!v1Hooks) return async () => {};
     if (!v1Hooks) return async () => {};
 
 
+    const interviewConfig = InterviewConfigSchema.parse(
+      loadPluginConfig(directory).interview ?? {},
+    );
+    const interviewBridge = createV2InterviewBridge(ctx, interviewConfig);
+    disposers.push(() => interviewBridge.dispose());
+
     // Resolve agents/commands via the v1 config() hook (model resolution etc.).
     // Resolve agents/commands via the v1 config() hook (model resolution etc.).
     let resolvedAgents: Record<string, Record<string, unknown>> | undefined;
     let resolvedAgents: Record<string, Record<string, unknown>> | undefined;
     let synthCommands:
     let synthCommands:
@@ -182,6 +191,32 @@ export function createV2Setup(): (ctx: V2Context) => Promise<V2Cleanup> {
       log('[v2] command.transform failed', String(err));
       log('[v2] command.transform failed', String(err));
     }
     }
 
 
+    // `/interview` is a v2 command marker. The context bridge consumes the
+    // rendered marker and delegates the actual behavior to the interview
+    // service without expanding the global v2 client shim.
+    try {
+      const reg = await ctx.command.transform((draft) => {
+        try {
+          interviewBridge.registerCommand(draft);
+        } catch (err) {
+          log('[v2] interview command adapt failed', String(err));
+        }
+      });
+      disposers.push(() => reg.dispose());
+    } catch (err) {
+      log('[v2] interview command registration failed', String(err));
+    }
+
+    try {
+      const reg = await ctx.session.hook('context', async (event) =>
+        interviewBridge.handleContext(event),
+      );
+      disposers.push(() => reg.dispose());
+      log('[v2] interview context bridge registered');
+    } catch (err) {
+      log('[v2] interview context bridge failed', String(err));
+    }
+
     // ── System + messages transforms (session context hook) ──
     // ── System + messages transforms (session context hook) ──
     try {
     try {
       const systemTransform = v1Hooks['experimental.chat.system.transform'] as
       const systemTransform = v1Hooks['experimental.chat.system.transform'] as
@@ -342,13 +377,18 @@ export function createV2Setup(): (ctx: V2Context) => Promise<V2Cleanup> {
       const eventHook = v1Hooks.event as
       const eventHook = v1Hooks.event as
         | ((i: { event: Record<string, unknown> }) => Promise<void>)
         | ((i: { event: Record<string, unknown> }) => Promise<void>)
         | undefined;
         | undefined;
-      if (eventHook) {
+      if (eventHook || interviewBridge) {
         const iter = ctx.event.subscribe();
         const iter = ctx.event.subscribe();
+        const eventIterator = iter[Symbol.asyncIterator]();
+        let eventStopped = false;
         void (async () => {
         void (async () => {
           try {
           try {
-            for await (const ev of iter) {
+            while (!eventStopped) {
+              const next = await eventIterator.next();
+              if (next.done) break;
               try {
               try {
-                await eventHook({ event: ev });
+                await interviewBridge.handleEvent(next.value);
+                if (eventHook) await eventHook({ event: next.value });
               } catch (err) {
               } catch (err) {
                 log('[v2] event handler failed', String(err));
                 log('[v2] event handler failed', String(err));
               }
               }
@@ -357,6 +397,10 @@ export function createV2Setup(): (ctx: V2Context) => Promise<V2Cleanup> {
             log('[v2] event stream ended', String(err));
             log('[v2] event stream ended', String(err));
           }
           }
         })();
         })();
+        disposers.push(async () => {
+          eventStopped = true;
+          await eventIterator.return?.();
+        });
         log('[v2] event stream subscribed');
         log('[v2] event stream subscribed');
       }
       }
     } catch (err) {
     } catch (err) {