Browse Source

Merge pull request #968 from alvinunreal/remove-interview-feature

refactor: remove interview feature
Alvin 1 week ago
parent
commit
0db9bddf6b

+ 0 - 14
.slim/codemap.json

@@ -111,19 +111,6 @@
     "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",
@@ -210,7 +197,6 @@
     "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",

+ 0 - 6
CONTEXT.md

@@ -62,12 +62,6 @@ 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.

+ 0 - 1
README.ja-JP.md

@@ -612,7 +612,6 @@ 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 |
 
 
 ### ⚙️ 設定 & リファレンス
 ### ⚙️ 設定 & リファレンス

+ 0 - 1
README.ko-KR.md

@@ -650,7 +650,6 @@ 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 |
 
 
 ### ⚙️ 설정 & 레퍼런스
 ### ⚙️ 설정 & 레퍼런스

+ 0 - 1
README.md

@@ -653,7 +653,6 @@ 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

+ 0 - 1
README.zh-CN.md

@@ -604,7 +604,6 @@ 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 |
 
 
 ### ⚙️ 配置与参考
 ### ⚙️ 配置与参考

+ 2 - 4
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, interview/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, 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. |
 | `scripts/generate-schema.ts` | Generates `oh-my-opencode-slim.schema.json` from the Zod config schema. |
 | `scripts/generate-schema.ts` | Generates `oh-my-opencode-slim.schema.json` from the Zod config schema. |
@@ -41,7 +41,6 @@ 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) |
@@ -66,7 +65,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, interview support, task-session aliasing, and runtime preset handling are initialized.
+   - Delegation/council orchestration, multiplexer session mirroring, 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.
@@ -98,7 +97,6 @@ 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
 
 

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

@@ -1,151 +0,0 @@
-# Diagnosis: "build agent empty input" after orchestrator output
-
-**Status:** Diagnosis only — no code change yet.
-**Date:** 2026-07-19
-**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 (Issue #854):** the incomplete-todo continuation path now passes `agent: 'orchestrator'`, is an opt-in beta via `backgroundJobs.continueOnIdle: true`, and uses a process-local one-attempt gate. Remaining agent-less `promptAsync` call sites are interview/smartfetch (below).
-
-## 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.** Remaining agent-less sites:
-   - `src/interview/service.ts:622, 871, 933, 1007` — interview/skill flow injections (matches "brainstorm skill flow").
-   - Continuation nudge (`continuation-evaluator.ts`) previously matched "subagent completes" / "background task finishes"; it now 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:622` | User submits interview dashboard input | **Yes** | `sessionBusy` lock, interview active state |
-| `src/interview/service.ts:871` | User submits interview chat | **Yes** | same |
-| `src/interview/service.ts:933` | User submits interview answer | **Yes** | same |
-| `src/interview/service.ts:1007` | User submits interview comment | **Yes** | same |
-| `src/interview/service.ts:504` | Interview URL notification | **Yes** (but `noReply: true`, non-synthetic text) | 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.
-
-4. **Continuation path sets `agent: 'orchestrator'`.** The historical missing-
-   `agent` misroute on the continuation nudge is fixed in
-   `continuation-evaluator.ts`. Remaining agent-less `promptAsync` call sites
-   are outside that path (interview/smartfetch below).
-
-## 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`).
-
-This bug: other hooks still use the same `createInternalAgentTextPart` + `promptAsync` pattern, and additionally omit the `agent` field, so the invisible turn routes to `build` instead of the orchestrator. Same shape: a synthetic part starting an invisible turn. Different symptom: `build` agent instead of orchestrator.
-
-## Fix directions (not implemented — awaiting decision)
-
-### Minimal fix
-Continuation already passes `agent: 'orchestrator'`. Add the same to remaining
-agent-less `promptAsync` bodies:
-- `src/interview/service.ts` (dashboard/chat/answer/comment injectors)
-
-This ensures interview injections always route to the orchestrator regardless of
-opencode's `default_agent` resolution, eliminating the path to `build`.
-
-### 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`
-
-## Open questions for the fix
-
-1. Should the fix be a new PR, or amended into #818? (#818 is currently `MERGEABLE` / `CLEAN` / CI green; amending widens its scope and may delay merge.)
-2. Is the interview/skill path expected to always route to the orchestrator, or could it intentionally target a different agent in some flows?
-3. Should we also harden the `default_agent` application (fix direction #3) as part of this work, or track it separately under #799?
-
----
-
-This report is diagnostic only. No code was changed. The fix awaits the user's decision on scope and PR strategy.

+ 0 - 5
docs/configuration.md

@@ -167,11 +167,6 @@ 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 project root) 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). |

+ 0 - 292
docs/interview.md

@@ -1,292 +0,0 @@
-# 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.
-
-#### 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 |
-
-## 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
-- browser updates use polling, not realtime push
-- runtime interview state is in-memory; the markdown file is the durable artifact
-- the flow depends on the assistant returning valid `<interview_state>` blocks
-- dashboard mode answer delivery has a few seconds of latency (session polls the dashboard)
-
-## Related
-
-- [README.md](../README.md)
-- [tools.md](tools.md)
-- [configuration.md](configuration.md)

+ 0 - 1
docs/quick-reference.md

@@ -14,7 +14,6 @@
 |-----|----------|
 |-----|----------|
 | [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


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

@@ -981,37 +981,6 @@
         }
         }
       }
       }
     },
     },
-    "interview": {
-      "type": "object",
-      "properties": {
-        "maxQuestions": {
-          "default": 2,
-          "type": "integer",
-          "minimum": 1,
-          "maximum": 10
-        },
-        "outputFolder": {
-          "default": "interview",
-          "type": "string",
-          "minLength": 1
-        },
-        "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` → interview, preset, deepwork, and reflect command hooks
+   - `command.execute.before` → deepwork, reflect, and loop command hooks
 
 
 ## Integration
 ## Integration
 
 

+ 0 - 1
src/config/codemap.md

@@ -154,7 +154,6 @@ 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

+ 0 - 1
src/config/loader.ts

@@ -330,7 +330,6 @@ 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),

+ 0 - 16
src/config/schema.ts

@@ -131,21 +131,6 @@ 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>;
 
 
-export const InterviewConfigSchema = z.object({
-  maxQuestions: z.number().int().min(1).max(10).default(2),
-  outputFolder: z.string().min(1).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'])
@@ -414,7 +399,6 @@ 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(),

+ 0 - 19
src/index.ts

@@ -52,7 +52,6 @@ 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,
@@ -181,7 +180,6 @@ 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>;
@@ -449,7 +447,6 @@ 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,
@@ -931,7 +928,6 @@ 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);
@@ -1050,12 +1046,6 @@ 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'
@@ -1115,15 +1105,6 @@ 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;

+ 0 - 133
src/interview/codemap.md

@@ -1,133 +0,0 @@
-# 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`.
-
-- `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` 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 (`parseInterviewState`,
-    `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`.

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

@@ -1,545 +0,0 @@
-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 {
-  probeDashboard,
-  readDashboardAuthFile,
-  tryBecomeDashboard,
-} from './dashboard';
-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,
-): {
-  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>;
-} {
-  const interviewConfig = config.interview;
-  const service = createInterviewService(ctx, interviewConfig);
-
-  // Async init - resolves once we know our role (dashboard or session)
-  let initDone = false;
-  let isDashboard = false;
-  let dashboardBaseUrl = '';
-  let authToken = '';
-  let dashboard: Awaited<ReturnType<typeof tryBecomeDashboard>> | null = null;
-  const registeredSessions = new Set<string>();
-
-  // ── Timer-based fallback for nudge/answer polling ─────────────
-  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 (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: 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) => {
-          dashboard?.pushState(stateToEntry(id, state));
-        });
-
-        // Interview created: register in dashboard cache immediately
-        service.setOnInterviewCreated((interview) => {
-          dashboard?.pushState({
-            interviewId: interview.id,
-            sessionID: interview.sessionID,
-            idea: interview.idea,
-            mode: 'awaiting-agent',
-            summary: 'Interview created.',
-            title: interview.idea,
-            questions: [],
-            pendingAnswers: null,
-            lastUpdatedAt: Date.now(),
-            filePath: interview.markdownPath,
-            nudgeAction: null,
-            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 (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 (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);
-      const 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());
-      service.setStatePushCallback(() => {}); // no-op on fallback
-    } finally {
-      initDone = true;
-    }
-  })();
-
-  async function ensureInitialized() {
-    if (!initDone) {
-      await initPromise;
-    }
-  }
-
-  // ── Client Poll Implementations (polls dashboard server) ──────────
-  async function pollPendingAnswers(sessionID: string) {
-    const interviewId = service.getActiveInterviewId(sessionID);
-    if (!interviewId) return;
-
-    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;
-      };
-      if (res.ok && body.answers && body.answers.length > 0) {
-        log('[interview] delivering pending answers (HTTP poll)', {
-          interviewId,
-          count: body.answers.length,
-        });
-        await 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;
-
-    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;
-      };
-      if (res.ok && body.action) {
-        log('[interview] delivering nudge action (HTTP poll)', {
-          interviewId,
-          action: body.action,
-        });
-        await service.handleNudgeAction(interviewId, body.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;
-
-    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;
-      };
-      if (res.ok && body.section && body.comment) {
-        log('[interview] delivering block comment (HTTP poll)', {
-          interviewId,
-          section: body.section,
-        });
-        await 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;
-
-    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;
-      };
-      if (res.ok && body.message) {
-        log('[interview] delivering chat message (HTTP poll)', {
-          interviewId,
-        });
-        await service.submitChat(interviewId, body.message);
-      }
-    } catch (err) {
-      log('[interview] failed polling chat message:', {
-        error: err instanceof Error ? err.message : String(err),
-      });
-    }
-  }
-
-  return {
-    registerCommand: (c) => service.registerCommand(c),
-    handleCommandExecuteBefore: async (input, output) => {
-      await ensureInitialized();
-
-      // Register session so dashboard/fallback timers track it
-      const sessionID = input.sessionID;
-      registeredSessions.add(sessionID);
-
-      if (!isDashboard && dashboardBaseUrl) {
-        fetch(`${dashboardBaseUrl}/api/sessions`, {
-          method: 'POST',
-          headers: {
-            ...authHeaders(authToken),
-            'content-type': 'application/json',
-          },
-          body: JSON.stringify({
-            sessionID,
-            directory: ctx.directory,
-            pid: process.pid,
-          }),
-          signal: AbortSignal.timeout(3000),
-        }).catch(() => {});
-        startFallbackTimer();
-      }
-
-      await service.handleCommandExecuteBefore(input, output);
-    },
-    handleEvent: async (input) => {
-      await ensureInitialized();
-      const { event } = input;
-      const properties = event.properties ?? {};
-      const sessionID = (properties.sessionID as string | undefined) ?? 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 = dashboard.consumePendingAnswers(interviewId);
-            if (pending && pending.length > 0) {
-              log('[interview] delivering pending answers (in-process)', {
-                interviewId,
-                count: pending.length,
-              });
-              await service.submitAnswers(interviewId, pending);
-            }
-            const nudge = dashboard.consumeNudgeAction(interviewId);
-            if (nudge) {
-              log('[interview] delivering nudge action (in-process)', {
-                interviewId,
-                action: nudge,
-              });
-              await service.handleNudgeAction(interviewId, nudge);
-            }
-            const comment = dashboard.consumeBlockComment(interviewId);
-            if (comment) {
-              log('[interview] delivering block comment (in-process)', {
-                interviewId,
-                section: comment.section,
-              });
-              await service.submitBlockComment(
-                interviewId,
-                comment.section,
-                comment.comment,
-              );
-            }
-            const chat = dashboard.consumeChatMessage(interviewId);
-            if (chat) {
-              log('[interview] delivering chat message (in-process)', {
-                interviewId,
-              });
-              await service.submitChat(interviewId, chat);
-            }
-          }
-
-          // 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();
-        }
-        dashboard?.removeSession(sessionID);
-      }
-    },
-  };
-}
-
-// ─── Helpers ──────────────────────────────────────────────────────
-
-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);
-  await fetch(`${dashboardUrl}/api/interviews/${interviewId}/state`, {
-    method: 'POST',
-    headers: {
-      ...authHeaders(token),
-      'content-type': 'application/json',
-    },
-    body: JSON.stringify(entry),
-    signal: AbortSignal.timeout(5000),
-  });
-}
-
-async function registerInterviewViaHttp(
-  dashboardUrl: string,
-  token: string,
-  interview: InterviewRecord,
-): Promise<void> {
-  await fetch(`${dashboardUrl}/api/interviews`, {
-    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),
-  }).catch((err) => {
-    log('[interview] failed to register interview with dashboard via HTTP:', {
-      error: err instanceof Error ? err.message : String(err),
-    });
-  });
-}

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

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

+ 0 - 1532
src/interview/dashboard.ts

@@ -1,1532 +0,0 @@
-import crypto from 'node:crypto';
-import * as fsSync from 'node:fs';
-import fs from 'node:fs/promises';
-import {
-  createServer,
-  type IncomingMessage,
-  type Server,
-  type ServerResponse,
-} from 'node:http';
-import os from 'node:os';
-import path from 'node:path';
-import { URL } from 'node:url';
-import { log } from '../utils';
-import {
-  extractSummarySection,
-  extractTitle,
-  parseFrontmatter,
-  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;
-}
-
-// ─── Config ───────────────────────────────────────────────────────────
-
-export const DEFAULT_DASHBOARD_PORT = 43211;
-
-export interface DashboardConfig {
-  port: number;
-  outputFolder: string;
-  sessionClient?: {
-    list: (params?: Record<string, unknown>) => Promise<{
-      data?: Array<{
-        directory?: string;
-        time?: { updated?: number };
-      }>;
-    }>;
-  };
-}
-
-// ─── Dashboard Server ─────────────────────────────────────────────────
-
-export function createDashboardServer(config: DashboardConfig): {
-  start: () => Promise<string>;
-  close: () => void;
-  registerSession: (info: RegisteredSession) => void;
-  removeSession: (sessionID: string) => void;
-  pushState: (entry: InterviewStateEntry) => void;
-  getState: (interviewId: string) => InterviewStateEntry | undefined;
-  storeAnswers: (
-    interviewId: string,
-    answers: Array<{ questionId: string; answer: string }>,
-  ) => void;
-  getPendingAnswers: (interviewId: string) => Array<{
-    questionId: string;
-    answer: string;
-  }> | null;
-  consumePendingAnswers: (
-    interviewId: string,
-  ) => Array<{ questionId: string; answer: string }> | null;
-  consumeNudgeAction: (
-    interviewId: string,
-  ) => 'more-questions' | 'confirm-complete' | null;
-  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 baseUrl: string | null = null;
-
-  // Session registry
-  const sessions = new Map<string, RegisteredSession>();
-
-  // Interview state cache
-  const stateCache = new Map<string, InterviewStateEntry>();
-
-  // SSE client registry: interviewId → Set<ServerResponse>
-  const sseClients = new Map<string, Set<import('node:http').ServerResponse>>();
-
-  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 {
-        clients.delete(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({ limit: 500 });
-      const sessionList = result.data;
-      if (!sessionList) return;
-
-      const cutoff =
-        scanDays > 0 ? Date.now() - scanDays * 24 * 60 * 60 * 1000 : 0;
-
-      for (const session of sessionList) {
-        if (!session.directory) continue;
-        if (cutoff > 0 && session.time?.updated) {
-          if (session.time.updated < cutoff) continue;
-        }
-        // Add to discovered set (not manualFolders) so user removal
-        // of a manual folder isn't undone by the next scan
-        discoveredFolders.add(session.directory);
-      }
-    } catch {
-      // Session list not available - rely on registered sessions
-    }
-  }
-
-  // ─── File Scanning ───────────────────────────────────────────────
-
-  async function scanInterviewFiles(): Promise<InterviewFileItem[]> {
-    if (fileCache && Date.now() - fileCache.at < FILE_CACHE_TTL) {
-      return fileCache.items;
-    }
-
-    const directories = getKnownDirectories();
-    const items: InterviewFileItem[] = [];
-
-    for (const dir of directories) {
-      const interviewDir = path.join(dir, config.outputFolder);
-      let entries: string[];
-      try {
-        entries = await fs.readdir(interviewDir);
-      } catch {
-        continue;
-      }
-
-      for (const entry of entries) {
-        if (!entry.endsWith('.md')) continue;
-
-        let content: string;
-        try {
-          content = await fs.readFile(path.join(interviewDir, entry), 'utf8');
-        } catch {
-          continue;
-        }
-
-        // Extract title and summary using shared extractors
-        const title = extractTitle(content) || entry.replace(/\.md$/, '');
-        const summary = extractSummarySection(content);
-        const baseName = entry.replace(/\.md$/, '');
-        const fm = parseFrontmatter(content);
-
-        items.push({
-          fileName: entry,
-          resumeCommand: `/interview ${baseName}`,
-          title,
-          summary:
-            summary.length > 120 ? `${summary.slice(0, 120)}\u2026` : summary,
-          sessionID: fm?.sessionID,
-          directory: dir,
-        });
-      }
-    }
-
-    const sorted = items.sort((a, b) => a.title.localeCompare(b.title));
-    fileCache = { items: sorted, at: Date.now() };
-    return sorted;
-  }
-
-  // ─── Failover: rebuild state from .md frontmatter ──────────────
-
-  async function rebuildFromFiles(): Promise<void> {
-    const directories = getKnownDirectories();
-    let rebuilt = 0;
-
-    for (const dir of directories) {
-      const interviewDir = path.join(dir, config.outputFolder);
-      let entries: string[];
-      try {
-        entries = await fs.readdir(interviewDir);
-      } catch {
-        continue;
-      }
-
-      for (const entry of entries) {
-        if (!entry.endsWith('.md')) continue;
-
-        let content: string;
-        try {
-          content = await fs.readFile(path.join(interviewDir, entry), 'utf8');
-        } catch {
-          continue;
-        }
-
-        // Parse frontmatter for session ID
-        const fm = parseFrontmatter(content);
-        if (!fm?.sessionID) continue;
-
-        // Extract title and summary using shared extractors
-        const title = extractTitle(content) || entry.replace(/\.md$/, '');
-        const summary = extractSummarySection(content);
-
-        // Generate a stable interview ID from slugified filename
-        const baseName = entry.replace(/\.md$/, '');
-        const interviewId = `recovered-${slugify(baseName) || baseName}`;
-
-        // Only add if not already in cache (sessions may have re-pushed)
-        if (stateCache.has(interviewId)) continue;
-
-        // Also skip if a live interview already covers this slug.
-        const slug = slugify(baseName) || baseName;
-        if (hasLiveForSlug(slug, stateCache)) continue;
-
-        stateCache.set(interviewId, {
-          interviewId,
-          sessionID: fm.sessionID,
-          idea: title,
-          mode: 'session-disconnected',
-          summary,
-          title,
-          questions: [],
-          pendingAnswers: null,
-          lastUpdatedAt: fm.updatedAt
-            ? new Date(fm.updatedAt).getTime()
-            : Date.now(),
-          filePath: path.join(interviewDir, entry),
-          nudgeAction: null,
-          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: 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);
-
-      // Cleanup on disconnect
-      request.on('close', () => {
-        clearInterval(heartbeat);
-        clients?.delete(response);
-        if (clients && clients.size === 0) sseClients.delete(interviewId);
-      });
-      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: submit answers (browser → dashboard) ──────────────────
-    if (
-      request.method === 'POST' &&
-      pathname.startsWith('/api/interviews/') &&
-      pathname.endsWith('/answers')
-    ) {
-      const interviewId = pathname
-        .replace('/api/interviews/', '')
-        .replace('/answers', '');
-      if (!isValidId(interviewId)) {
-        sendJson(response, 400, { error: 'Invalid interview ID' });
-        return;
-      }
-      const entry = stateCache.get(interviewId);
-      if (!entry) {
-        sendJson(response, 404, { error: 'Interview not found' });
-        return;
-      }
-
-      let body: unknown;
-      try {
-        body = await readJsonBody(request);
-      } catch {
-        sendJson(response, 400, { error: 'Invalid JSON' });
-        return;
-      }
-
-      const { answers } = body as {
-        answers?: Array<{ questionId: string; answer: string }>;
-      };
-      if (
-        !Array.isArray(answers) ||
-        !answers.every(
-          (a) =>
-            typeof a === 'object' &&
-            a !== null &&
-            typeof a.questionId === 'string' &&
-            typeof a.answer === 'string',
-        )
-      ) {
-        sendJson(response, 400, {
-          error:
-            'answers array required, each item must have string questionId and answer',
-        });
-        return;
-      }
-
-      entry.pendingAnswers = answers;
-      entry.mode = 'awaiting-agent';
-      entry.lastUpdatedAt = Date.now();
-      sendJson(response, 200, { status: 'ok' });
-      return;
-    }
-
-    // ── API: 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;
-      }
-
-      entry.pendingBlockComment = { section, comment };
-      entry.mode = 'awaiting-agent';
-      entry.lastUpdatedAt = Date.now();
-      sendJson(response, 200, { status: 'ok' });
-      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;
-      if (val) {
-        entry.pendingBlockComment = 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;
-      }
-
-      entry.pendingChatMessage = message.trim();
-      entry.mode = 'awaiting-agent';
-      entry.lastUpdatedAt = Date.now();
-      sendJson(response, 200, { status: 'ok' });
-      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;
-      if (val) {
-        entry.pendingChatMessage = null;
-      }
-      sendJson(response, 200, { message: val || 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;
-      }
-      // Atomically consume pending answers (like nudge pattern)
-      const answers = entry.pendingAnswers;
-      if (answers) {
-        entry.pendingAnswers = null;
-      }
-      sendJson(response, 200, {
-        answers,
-      });
-      return;
-    }
-
-    // ── API: nudge agent (browser → dashboard) ────────────────────
-    if (
-      request.method === 'POST' &&
-      pathname.startsWith('/api/interviews/') &&
-      pathname.endsWith('/nudge')
-    ) {
-      if (!isAuthenticated(request)) {
-        sendJson(response, 401, { error: 'Unauthorized' });
-        return;
-      }
-
-      const interviewId = pathname
-        .replace('/api/interviews/', '')
-        .replace('/nudge', '');
-      if (!isValidId(interviewId)) {
-        sendJson(response, 400, { error: 'Invalid interview ID' });
-        return;
-      }
-      const entry = stateCache.get(interviewId);
-      if (!entry) {
-        sendJson(response, 404, { error: 'Interview not found' });
-        return;
-      }
-
-      let body: unknown;
-      try {
-        body = await readJsonBody(request);
-      } catch {
-        sendJson(response, 400, { error: 'Invalid JSON' });
-        return;
-      }
-
-      const { action } = body as {
-        action?: 'more-questions' | 'confirm-complete';
-      };
-      if (action !== 'more-questions' && action !== 'confirm-complete') {
-        sendJson(response, 400, {
-          error: 'action must be "more-questions" or "confirm-complete"',
-        });
-        return;
-      }
-
-      entry.nudgeAction = action;
-      entry.mode = 'awaiting-agent';
-      entry.lastUpdatedAt = Date.now();
-      sendJson(response, 200, { status: 'ok' });
-      return;
-    }
-
-    // ── API: get nudge action (session polls, auth required) ──────
-    if (
-      request.method === 'GET' &&
-      pathname.startsWith('/api/interviews/') &&
-      pathname.endsWith('/nudge')
-    ) {
-      if (!isAuthenticated(request)) {
-        sendJson(response, 401, { error: 'Unauthorized' });
-        return;
-      }
-      const interviewId = pathname
-        .replace('/api/interviews/', '')
-        .replace('/nudge', '');
-      if (!isValidId(interviewId)) {
-        sendJson(response, 400, { error: 'Invalid interview ID' });
-        return;
-      }
-      const entry = stateCache.get(interviewId);
-      if (!entry) {
-        sendJson(response, 404, { error: 'Interview not found' });
-        return;
-      }
-      const action = entry.nudgeAction;
-      if (action) {
-        entry.nudgeAction = null; // Clear after reading
-      }
-      sendJson(response, 200, { action });
-      return;
-    }
-
-    // ── Interview page ─────────────────────────────────────────────
-    if (request.method === 'GET' && pathname.startsWith('/interview/')) {
-      const interviewId = pathname.replace('/interview/', '');
-      if (!isValidId(interviewId)) {
-        sendJson(response, 400, { error: 'Invalid interview ID' });
-        return;
-      }
-      const entry = stateCache.get(interviewId);
-      if (!entry) {
-        sendJson(response, 404, { error: 'Interview not found' });
-        return;
-      }
-      const resumeSlug = extractResumeSlug(interviewId);
-      setSessionCookie(response);
-      sendHtml(response, renderInterviewPage(interviewId, resumeSlug));
-      return;
-    }
-
-    // ── 404 ────────────────────────────────────────────────────────
-    sendJson(response, 404, { error: 'Not found' });
-  }
-
-  // ─── Server Lifecycle ────────────────────────────────────────────
-
-  function start(): Promise<string> {
-    if (baseUrl) return Promise.resolve(baseUrl);
-
-    if (!cleanupTimer) {
-      cleanupTimer = createCleanupTimer();
-    }
-
-    return new Promise((resolve, reject) => {
-      const server = createServer((request, response) => {
-        handleRequest(request, response).catch((error: unknown) => {
-          sendJson(response, 500, {
-            error:
-              error instanceof Error ? error.message : 'Internal server error',
-          });
-        });
-      });
-
-      server.requestTimeout = 30_000;
-      server.headersTimeout = 10_000;
-
-      server.on('error', (error: NodeJS.ErrnoException) => {
-        server.close();
-        if (error.code === 'EADDRINUSE') {
-          reject(new Error(`Dashboard port ${config.port} is already in use.`));
-        } else {
-          reject(error);
-        }
-      });
-
-      server.listen(config.port, '127.0.0.1', () => {
-        const address = server.address();
-        if (!address || typeof address === 'string') {
-          reject(new Error('Failed to start dashboard server'));
-          return;
-        }
-        activeServer = server;
-        baseUrl = `http://127.0.0.1:${address.port}`;
-        writeAuthFile(config.port, authToken);
-        resolve(baseUrl);
-      });
-    });
-  }
-
-  function close(): void {
-    if (cleanupTimer) {
-      clearInterval(cleanupTimer);
-      cleanupTimer = null;
-    }
-
-    for (const clients of sseClients.values()) {
-      for (const response of clients) {
-        try {
-          if (!response.writableEnded && !response.destroyed) {
-            response.end();
-          }
-        } catch {
-          try {
-            response.destroy();
-          } catch {
-            // Ignore cleanup errors
-          }
-        }
-      }
-    }
-
-    sseClients.clear();
-
-    removeAuthFile(config.port);
-
-    if (activeServer) {
-      activeServer.closeAllConnections();
-      activeServer.close();
-      activeServer = null;
-      baseUrl = null;
-    }
-  }
-
-  // ─── Public API ──────────────────────────────────────────────────
-
-  return {
-    start,
-    close,
-    registerSession: (info) => {
-      const wasEmpty = sessions.size === 0;
-      sessions.set(info.sessionID, info);
-      fileCache = null;
-      // Rebuild from files when first session registers (failover recovery)
-      if (wasEmpty) {
-        rebuildFromFiles().catch(() => {});
-      }
-    },
-    removeSession: (sessionID: string) => {
-      sessions.delete(sessionID);
-      // Clean up stateCache entries belonging to this session
-      for (const [id, entry] of stateCache) {
-        if (entry.sessionID === sessionID) {
-          stateCache.delete(id);
-        }
-      }
-      fileCache = null;
-    },
-    pushState: (entry: InterviewStateEntry) => {
-      // Preserve browser-submitted data that the session doesn't know about
-      const existing = stateCache.get(entry.interviewId);
-      if (existing) {
-        if (existing.pendingAnswers)
-          entry.pendingAnswers ??= existing.pendingAnswers;
-        if (existing.nudgeAction) entry.nudgeAction ??= existing.nudgeAction;
-        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,
-    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')) {
-        // Another process won the race, wait with jitter and retry
-        if (attempt < maxAttempts - 1) {
-          await new Promise((resolve) => setTimeout(resolve, jitterMs()));
-          continue;
-        }
-        return null; // All retries exhausted - treat as session
-      }
-      throw error;
-    }
-  }
-
-  return null;
-}

+ 0 - 319
src/interview/document.ts

@@ -1,319 +0,0 @@
-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;
-}
-
-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;
-}

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

@@ -1,353 +0,0 @@
-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();
-    }
-  });
-});

+ 0 - 53
src/interview/helpers.ts

@@ -1,53 +0,0 @@
-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) : {};
-}

+ 0 - 1
src/interview/index.ts

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

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

@@ -1,2122 +0,0 @@
-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 prompt calls (v2 flat params: parts is top-level)
-function getPromptTexts(promptMock: {
-  mock: {
-    calls: Array<[{ parts?: Array<{ text?: string }> }]>;
-  };
-}): string[] {
-  return promptMock.mock.calls
-    .map((call) => call[0].parts?.[0]?.text ?? '')
-    .filter(Boolean);
-}
-
-// Helper to extract interview ID from the last prompt call
-function extractInterviewIdFromLastPrompt(promptMock: {
-  mock: {
-    calls: Array<[{ 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].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({
-        sessionID: 'session-rename',
-        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].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.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.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 });
-    }
-  });
-});

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

@@ -1,807 +0,0 @@
-import { 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 } 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,
-  }),
-}));
-
-// 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('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].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].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].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].parts?.[0]?.text ?? '';
-      const match = text.match(/interview\/([^\s]+)/);
-      expect(match).not.toBeNull();
-      const _interviewId = match?.[1];
-
-      // Give registration a moment
-      await new Promise((r) => setTimeout(r, 100));
-
-      // Delete session
-      await manager.handleEvent({
-        event: {
-          type: 'session.deleted',
-          properties: { sessionID: 'session-delete-reg' },
-        },
-      });
-
-      // Give cleanup a moment
-      await new Promise((r) => setTimeout(r, 50));
-
-      // Interview file should still exist
-      const interviewDir = `${tempDir}/interview`;
-      const files = await fs.readdir(interviewDir);
-      expect(files.length).toBe(1);
-      // Status is only tracked in memory, not written to markdown
-    } finally {
-      await fs.rm(tempDir, { recursive: true, force: true });
-    }
-  });
-
-  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: { sessionID: '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 });
-    }
-  });
-});
-
-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].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].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 });
-    }
-  });
-});
-
-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();
-    }
-  });
-});

+ 0 - 36
src/interview/manager.ts

@@ -1,36 +0,0 @@
-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>;
-} {
-  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);
-}

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

@@ -1,309 +0,0 @@
-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');
-  });
-});

+ 0 - 201
src/interview/parser.ts

@@ -1,201 +0,0 @@
-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,
-  };
-}

+ 0 - 143
src/interview/prompts.ts

@@ -1,143 +0,0 @@
-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');
-}

+ 0 - 373
src/interview/server.ts

@@ -1,373 +0,0 @@
-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;
-    },
-  };
-}

+ 0 - 1033
src/interview/service.ts

@@ -1,1033 +0,0 @@
-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 { getClient } from '../utils/opencode-client';
-import { parseModelReference } from '../utils/session';
-import {
-  appendInterviewAnswers,
-  createInterviewDirectoryPath,
-  createInterviewFilePath,
-  DEFAULT_OUTPUT_FOLDER,
-  ensureInterviewFile,
-  extractSummarySection,
-  extractTitle,
-  normalizeOutputFolder,
-  parseSpecBlocks,
-  readInterviewDocument,
-  relativeInterviewPath,
-  resolveExistingInterviewPath,
-  rewriteInterviewDocument,
-  slugify,
-} from './document';
-import { buildFallbackState, findLatestAssistantState } from './parser';
-import {
-  buildAnswerPrompt,
-  buildKickoffPrompt,
-  buildResumePrompt,
-} from './prompts';
-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;
-  },
-): {
-  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 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;
-
-  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[]> {
-    const result = await getClient(ctx).session.messages({
-      sessionID,
-    });
-    return result.data as InterviewMessage[];
-  }
-
-  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 parsed = 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);
-
-    // Skip rewrite when parsed.state is null - agent already wrote the final spec
-    let document: string;
-    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 getClient(ctx).session.prompt({
-      sessionID,
-      noReply: true,
-      parts: [
-        {
-          type: 'text',
-          text: [
-            '⎔ 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);
-
-      // Use promptAsync for non-blocking - returns immediately, LLM
-      // processes in background. State push updates dashboard when done.
-      const model = sessionModel.get(interview.sessionID);
-      await getClient(ctx).session.promptAsync({
-        sessionID: interview.sessionID,
-        agent: 'orchestrator',
-        parts: [createInternalAgentTextPart(prompt)],
-        ...(model ? { model: parseModelReference(model) ?? 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)}…`;
-    }
-    getClient(ctx)
-      .session.update({
-        sessionID: input.sessionID,
-        title: 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');
-      }
-      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;
-      }
-
-      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 getClient(ctx).session.promptAsync({
-        sessionID: interview.sessionID,
-        agent: 'orchestrator',
-        parts: [createInternalAgentTextPart(prompt)],
-        ...(model ? { model: parseModelReference(model) ?? 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 getClient(ctx).session.promptAsync({
-        sessionID: interview.sessionID,
-        agent: 'orchestrator',
-        parts: [createInternalAgentTextPart(prompt)],
-        ...(model ? { model: parseModelReference(model) ?? 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);
-      await getClient(ctx).session.promptAsync({
-        sessionID: interview.sessionID,
-        agent: 'orchestrator',
-        parts: [createInternalAgentTextPart(prompt)],
-        ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
-      });
-      promptSent = true;
-    } finally {
-      if (!promptSent) {
-        sessionBusy.set(interview.sessionID, false);
-      }
-    }
-  }
-
-  return {
-    setBaseUrlResolver,
-    setStatePushCallback,
-    setOnInterviewCreated,
-    getActiveInterviewId,
-    registerCommand,
-    handleCommandExecuteBefore,
-    handleEvent,
-    getInterviewState,
-    listInterviewFiles,
-    listInterviews,
-    submitAnswers,
-    submitBlockComment,
-    submitChat,
-    handleNudgeAction,
-  };
-}

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

@@ -1,45 +0,0 @@
-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>;
-} {
-  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());
-  return {
-    registerCommand: (c) => service.registerCommand(c),
-    handleCommandExecuteBefore: async (input, output) =>
-      service.handleCommandExecuteBefore(input, output),
-    handleEvent: async (input) => service.handleEvent(input),
-  };
-}

+ 0 - 140
src/interview/types.ts

@@ -1,140 +0,0 @@
-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[];
-}

+ 0 - 2027
src/interview/ui.ts

@@ -1,2027 +0,0 @@
-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>`;
-}