# BUILD_SPEC — Console v2 Audit Deferred Items

**Generated:** 2026-05-16
**Input:** `recoil/console-v2/audits/2026-05-14-walkthrough.md` (deferred from Phase 5 + Polish Pass 2)
**Detail level:** max
**Visual design:** no
**Phases:** 3
**Estimated build time:** 30-45 minutes

## Scope of this spec

Items from the 2026-05-14 walkthrough audit not yet addressed:

| ID | Severity | Description | This spec |
|----|----------|-------------|-----------|
| H1 | high     | `episode_id_derived_from_filename_prefix` fires ~167×/session | Phase 2 — backfill data |
| H2 | high     | `session id capture timeout` on terminal swap | Phase 1 — implicit fix via H4 |
| H4 | high     | ttyd processes accumulate, never torn down on project switch | Phase 1 — backend lifecycle fix |
| X1 | minor    | Terminal banner truncation `(1M context) with…` | **Deferred (out of scope)** — text comes from Claude Code CLI rendering inside the ttyd iframe; not React-controllable. Best future fix: widen / resize the right rail. |
| X2 | minor    | `BINDS CLICKS ↔ CHAT` label reads like tabs | Phase 3 |
| X4 | minor    | Display name collision on `afterimage` / `afterimage-anime` | Phase 3 |
| X5 | minor    | `TEST_I2V_CHAIN_SH34` visible in production hierarchy | Phase 3 |
| X6 | minor    | Stats bar mixes `0` and `—` | Phase 3 — hide `primary` cell when no primary set |

## Validation command

```bash
pnpm --filter desktop test && cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS && python -m pytest recoil/api/tests -x --tb=short -q
```

Expected: all desktop tests pass (124 baseline + any added). All API tests pass.

---

## Dependency Graph

```
Phase 1 (ttyd lifecycle):    none
Phase 2 (episode_id backfill): none
Phase 3 (X-tier polish):     none
```

All three phases are independent. They touch disjoint files. Run in any order; harness may parallelize if it wants.

---

## Phase 1: ttyd lifecycle — H4 (and H2 as a side effect)

**depends_on:** none

### Files to modify

1. `recoil/api/ttyd_routes.py` — add cleanup on project switch
2. `recoil/api/tests/test_ttyd_routes.py` — extend (or create) tests

### Root cause (from agent reconnaissance)

- `ttyd_start(project_id)` (lines 307–354) reuses an existing alive process for the SAME project_id, but does nothing about ttyd processes for OTHER projects when the frontend switches.
- After several project switches, multiple ttyd processes accumulate; old ports stay reserved; old websockets stay open.
- `_capture_session_id` (lines 177–203) polls for ~15s for a new JSONL file to appear; when the system is loaded with several spawning ttyds at once, the new one doesn't settle in time → WARNING `session id capture timeout` (H2).

### Approach: backend-driven single-active-project policy

The frontend already calls `/ttyd/start` on project switch. Make `/ttyd/start` itself kill any active ttyd for OTHER projects before allocating a new port. This keeps the contract simple — frontend doesn't need to know about cleanup.

Behavior: when `/ttyd/start` is called for project B, and there are live processes in `_PROCS` for any project ≠ B, those processes are SIGTERM'd, given a brief grace period, then SIGKILL'd if needed; their ports are released, their entries removed from `_PROCS`. THEN the new ttyd for project B is allocated and spawned.

This is a single-active-project policy. JT's workflow today is one active project at a time. If multi-project ttyd is needed later, add a per-project "pin" flag — out of scope here.

### Exact implementation

**In `recoil/api/ttyd_routes.py`, before the existing `ttyd_start` function, add this helper:**

```python
def _kill_other_project_ttyds_locked(active_project_id: str) -> list[str]:
    """Kill ttyd processes for any project ≠ active_project_id.

    Must be called with _PROCS_LOCK held. Returns the list of killed
    project_ids so the caller can log / emit events.
    """
    killed: list[str] = []
    for pid, proc in list(_PROCS.items()):
        if pid == active_project_id:
            continue
        try:
            if proc.proc.poll() is None:
                proc.proc.terminate()
                try:
                    proc.proc.wait(timeout=2.0)
                except subprocess.TimeoutExpired:
                    proc.proc.kill()
                    proc.proc.wait(timeout=1.0)
        except Exception as exc:
            logger.warning("ttyd cleanup for %s failed: %s", pid, exc)
        _PORTS_RESERVED.discard(proc.port)
        _PROCS.pop(pid, None)
        killed.append(pid)
    return killed
```

**In `recoil/api/ttyd_routes.py`, inside `ttyd_start`, add the cleanup call as the FIRST action inside the `_PROCS_LOCK` block, BEFORE the existing idempotency check:**

Find this section (around lines 314–321):
```python
    with _PROCS_LOCK:
        existing = _PROCS.get(project_id)
        if existing and existing.proc.poll() is None:
            _bump_last_used_safely(project_id)
            return {"port": existing.port, "session_id": existing.session_id}
        if existing:
            _PORTS_RESERVED.discard(existing.port)
            _PROCS.pop(project_id, None)
        port = _allocate_port_locked()
```

Replace with:
```python
    with _PROCS_LOCK:
        # Single-active-project policy: kill ttyd processes for any other
        # project before spawning / reusing for this one. Stops the
        # process accumulation reported in audit H4 and stabilizes the
        # session-id capture target (H2).
        killed_others = _kill_other_project_ttyds_locked(project_id)

        existing = _PROCS.get(project_id)
        if existing and existing.proc.poll() is None:
            _bump_last_used_safely(project_id)
            if killed_others:
                BUS.emit_sync(
                    "info",
                    "chat/ttyd",
                    "killed stale ttyds on project switch",
                    payload={"new_project": project_id, "killed": killed_others},
                )
            return {"port": existing.port, "session_id": existing.session_id}
        if existing:
            _PORTS_RESERVED.discard(existing.port)
            _PROCS.pop(project_id, None)
        port = _allocate_port_locked()
```

Then, AFTER the spawn completes and the new process is recorded (around line 348, after `_PROCS[project_id] = ...`), emit a single info event reporting what was killed (only if `killed_others` is non-empty). The new emit should land in `ttyd_start` after the `_PROCS` write and before the function returns. Look for the existing return at the end of `ttyd_start`:

```python
    return {"port": port, "session_id": session_id}
```

Replace with:
```python
    if killed_others:
        BUS.emit_sync(
            "info",
            "chat/ttyd",
            "killed stale ttyds on project switch",
            payload={"new_project": project_id, "killed": killed_others},
        )
    return {"port": port, "session_id": session_id}
```

Note: emit only on the SPAWN path, not the REUSE path (the reuse path already emits above when killed_others is non-empty).

### What already exists

- `_PROCS: dict[str, _ProcEntry]` — process registry
- `_PROCS_LOCK: threading.Lock` — guards the registry
- `_PORTS_RESERVED: set[int]` — port reservations
- `_allocate_port_locked()` — port allocator (lines ~92–110)
- `_kill_all_ttyds()` — atexit/signal cleanup (line ~262); do NOT change this
- `BUS.emit_sync(severity, scope, summary, payload=...)` — event emitter
- `logger` — module-level logger
- `subprocess` is already imported (check imports; add if missing)

### Tests

In `recoil/api/tests/test_ttyd_routes.py` (create if it doesn't exist; otherwise extend), add a test that:

1. Mocks `subprocess.Popen` so no real ttyd is spawned
2. Calls `POST /api/ttyd/start` with project_id="tartarus"
3. Calls `POST /api/ttyd/start` with project_id="afterimage"
4. Asserts that the mocked terminate() was called on the tartarus process exactly once
5. Asserts that `_PROCS` now contains only "afterimage"

If the test file doesn't exist, create it with the standard imports used by other recoil/api tests (TestClient pattern). Look at `recoil/api/tests/test_workspace_state.py` for the convention.

### Validation

```bash
# Syntax check
python -m py_compile recoil/api/ttyd_routes.py

# Structural check — helper exists
grep -n "_kill_other_project_ttyds_locked" recoil/api/ttyd_routes.py

# Functional check — run the ttyd tests
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS && python -m pytest recoil/api/tests/test_ttyd_routes.py -x --tb=short -q
```

### Scope boundary

- Do NOT change `_kill_all_ttyds()` (atexit handler)
- Do NOT modify `_capture_session_id`; the 15s timeout stays — H2 stabilizes as a side effect
- Do NOT add a new endpoint (e.g. `/ttyd/cleanup`); the policy is enforced inside `ttyd_start`
- Do NOT touch `TerminalIframe.tsx` (frontend); the contract stays the same

---

## Phase 2: episode_id backfill — H1

**depends_on:** none

### Files to modify

1. `recoil/pipeline/tools/backfill_episode_id.py` — NEW; one-shot migration script
2. `projects/tartarus/state/visual/shots/*.json` — every shot file under tartarus (and any other project missing `episode_id`)
3. `recoil/api/adapters/beats.py` — add a guard so the fallback emits AT MOST once per process per project (cadence tightening)

### Root cause

`_derive_episode_id` (recoil/api/adapters/beats.py:81–113) tries four stages: `shot["episode_id"]` → output path parse → filename match → filename prefix. Stage 4 emits the FALLBACK event and the fallback fires on every read because the shot JSON files don't have `episode_id` stored. Adapter regenerates per-read instead of writing it back.

### Approach

Two parts:

**A. Data backfill (one-shot).** Walk every `projects/*/state/visual/shots/*.json`, derive `episode_id` from the filename prefix (the same logic the adapter uses), write it back into the JSON if missing. This is the storage-side fix the audit recommends.

**B. Cadence guard.** Even after backfill, future shots created without `episode_id` will re-trigger the fallback. Add a per-(project, fallback_id) once-per-process emit guard so log volume stays sane.

### Exact implementation

#### B.1 — Create `recoil/pipeline/tools/backfill_episode_id.py`

```python
#!/usr/bin/env python3
"""One-shot migration: backfill `episode_id` into shot JSON files.

For every `projects/<project>/state/visual/shots/<SHOT>.json`, if the file
does not have `episode_id`, derive it from the filename prefix (substring
before the first underscore) and write the field back.

Idempotent: rerunning is a no-op for files that already have `episode_id`.

Usage:
    python recoil/pipeline/tools/backfill_episode_id.py [--dry-run] [--project <id>]
"""
from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parents[3]
SHOT_FILE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*\.json$")


def _derive(stem: str) -> str:
    # Same convention as `_derive_episode_id` stage 4: prefix before first _
    return stem.split("_")[0]


def _shot_dirs(project_filter: str | None) -> list[Path]:
    proj_root = REPO_ROOT / "projects"
    if not proj_root.exists():
        return []
    out: list[Path] = []
    for proj_dir in sorted(proj_root.iterdir()):
        if not proj_dir.is_dir():
            continue
        if project_filter and proj_dir.name != project_filter:
            continue
        shots = proj_dir / "state" / "visual" / "shots"
        if shots.is_dir():
            out.append(shots)
    return out


def backfill(dry_run: bool, project_filter: str | None) -> tuple[int, int, int]:
    """Return (scanned, updated, skipped_already_set)."""
    scanned = 0
    updated = 0
    skipped = 0
    for shots_dir in _shot_dirs(project_filter):
        for path in sorted(shots_dir.iterdir()):
            if not SHOT_FILE_RE.match(path.name):
                continue
            scanned += 1
            try:
                data = json.loads(path.read_text())
            except Exception as exc:
                print(f"  ! skip (read error) {path}: {exc}", file=sys.stderr)
                continue
            if data.get("episode_id"):
                skipped += 1
                continue
            stem = path.stem
            ep_id = _derive(stem)
            if not ep_id:
                print(f"  ! skip (no derivable id) {path}", file=sys.stderr)
                continue
            data["episode_id"] = ep_id
            if dry_run:
                print(f"  + would set episode_id={ep_id!r} in {path}")
            else:
                path.write_text(json.dumps(data, indent=2) + "\n")
                print(f"  + set episode_id={ep_id!r} in {path}")
            updated += 1
    return scanned, updated, skipped


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--dry-run", action="store_true")
    parser.add_argument("--project", default=None,
                        help="Only process this project (default: all)")
    args = parser.parse_args()
    scanned, updated, skipped = backfill(args.dry_run, args.project)
    verb = "would update" if args.dry_run else "updated"
    print(f"\nScanned {scanned} files; {verb} {updated}; skipped {skipped} "
          f"already-set.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
```

#### B.2 — Run the script

The harness sub-agent MUST run the script against all projects, then commit the resulting JSON changes. Run:

```bash
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS
python recoil/pipeline/tools/backfill_episode_id.py --dry-run | head -30
python recoil/pipeline/tools/backfill_episode_id.py
```

After the script runs, the projects/*/state/visual/shots/*.json files will have `episode_id` populated. Commit them as part of this phase.

#### B.3 — Cadence guard in `recoil/api/adapters/beats.py`

Find the `_derive_episode_id` function. At module level (top of file, after imports), add a once-per-(project, file) guard set:

```python
_FALLBACK_FILENAME_PREFIX_SEEN: set[tuple[str, str]] = set()
```

In `_derive_episode_id`, replace the existing emit block:
```python
    emit_fallback(
        "episode_id_derived_from_filename_prefix",
        scope="api/adapters/beats",
        payload={"project": project_id, "file": path.name},
    )
    return stem.split("_")[0]
```

With:
```python
    key = (project_id or "", path.name)
    if key not in _FALLBACK_FILENAME_PREFIX_SEEN:
        _FALLBACK_FILENAME_PREFIX_SEEN.add(key)
        emit_fallback(
            "episode_id_derived_from_filename_prefix",
            scope="api/adapters/beats",
            payload={"project": project_id, "file": path.name},
        )
    return stem.split("_")[0]
```

This way: if backfill leaves any stragglers, the fallback still fires ONCE per (project, file) per process so monitoring still surfaces it, but it can't flood at 167×/session.

### Tests

Add a test (or extend the existing beats test if there is one) that calls `_derive_episode_id` twice with the same `(project_id, path)` and asserts that `emit_fallback` was called only once. Mock or monkey-patch `emit_fallback` to count calls.

Look at existing `recoil/api/tests/` files to find the convention; if there's no `test_beats_adapter.py` create it.

### Validation

```bash
# Syntax check
python -m py_compile recoil/pipeline/tools/backfill_episode_id.py
python -m py_compile recoil/api/adapters/beats.py

# Structural — guard exists
grep -n "_FALLBACK_FILENAME_PREFIX_SEEN" recoil/api/adapters/beats.py

# Functional — backfilled files have episode_id
python -c "import json,glob; assert all(json.loads(open(f).read()).get('episode_id') for f in glob.glob('projects/tartarus/state/visual/shots/*.json'))"

# Run API tests
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS && python -m pytest recoil/api/tests -x --tb=short -q
```

### Scope boundary

- Backfill ONLY adds `episode_id` if missing. Do NOT modify other fields.
- Do NOT touch `_derive_episode_id` stages 1–3 (shot["episode_id"], output path, filename match); the only change is the emit-cadence guard
- Do NOT remove the fallback path; keep it as a safety net
- Do NOT add a CLI flag to disable the fallback log

---

## Phase 3: X-tier polish — X2, X4, X5, X6

**depends_on:** none

### Files to modify

1. `recoil/console-v2/packages/desktop/src/chat/ContextStrip.tsx` — X2 label
2. `recoil/console-v2/packages/desktop/src/shell/ProjectPicker.tsx` — X4 collision-aware slug
3. `projects/tartarus/state/visual/shots/TEST_I2V_CHAIN_SH34.json` — X5 relocate
4. `recoil/console-v2/packages/desktop/src/stage/TakesBrowser.tsx` — X6 hide `primary` cell when null

### X2 — ContextStrip label

In `recoil/console-v2/packages/desktop/src/chat/ContextStrip.tsx` line ~10:

```tsx
<div className="label">selected context · binds clicks ↔ chat</div>
```

Change to:

```tsx
<div className="label">Selected context — clicks bind to chat</div>
```

Sentence-cased, em-dash replaces the `·` mid-dot (which read like a chip separator), and the verb phrase makes the non-interactive meaning explicit.

### X4 — ProjectPicker collision-aware slug

In `recoil/console-v2/packages/desktop/src/shell/ProjectPicker.tsx` around lines 47–65, find the `projects.map` block.

Compute a name-collision set once, before the map. Then render the slug whenever the project's display name collides with any other project's name in the list. The CURRENT logic only shows the slug when `p.name !== pid` — which means the primary `afterimage` project (with `name === "afterimage"` and `id === "afterimage"`) hides its slug while `afterimage-anime` shows its slug, making them look more different than they should. After the fix, BOTH `afterimage` rows show their slug.

Replace the block (paraphrasing exact JSX once you've read the file):

```tsx
{projects.map((p) => {
  const pid = p.id as string;
  const isActive = pid === activeProjectId;
  return (
    <li key={pid}>
      <button className={`pp-row ${isActive ? "active" : ""}`}>
        <span className="pp-row-name">{p.name}</span>
        {p.name !== pid && <span className="pp-row-slug">{pid}</span>}
      </button>
    </li>
  );
})}
```

With:

```tsx
{(() => {
  const nameCounts = new Map<string, number>();
  for (const p of projects) {
    nameCounts.set(p.name, (nameCounts.get(p.name) ?? 0) + 1);
  }
  return projects.map((p) => {
    const pid = p.id as string;
    const isActive = pid === activeProjectId;
    const isAmbiguous = (nameCounts.get(p.name) ?? 0) > 1;
    const showSlug = isAmbiguous || p.name !== pid;
    return (
      <li key={pid}>
        <button className={`pp-row ${isActive ? "active" : ""}`}>
          <span className="pp-row-name">{p.name}</span>
          {showSlug && (
            <span className={`pp-row-slug ${isAmbiguous ? "ambiguous" : ""}`}>
              {pid}
            </span>
          )}
        </button>
      </li>
    );
  });
})()}
```

Add CSS for `.pp-row-slug.ambiguous` in the same file's stylesheet (find by `pp-row-slug` grep — it's likely in a `.css` or `.scss` adjacent file or inline style block). Make it bolder + slightly more vivid:

```css
.pp-row-slug.ambiguous {
  color: var(--fg-2);
  font-weight: 600;
}
```

If the slug class doesn't have an existing CSS file rule, the override may need to live in `recoil/console-v2/packages/desktop/src/styles/shell.css` — search there and place it next to other `.pp-row-*` rules.

### X5 — Relocate TEST_I2V_CHAIN_SH34

Move the file out of the production scan path:

```bash
mkdir -p projects/tartarus/state/visual/shots/_test
git mv projects/tartarus/state/visual/shots/TEST_I2V_CHAIN_SH34.json \
       projects/tartarus/state/visual/shots/_test/TEST_I2V_CHAIN_SH34.json
```

The adapter `_list_beat_files()` in `recoil/api/adapters/beats.py` already scans only direct children matching `^[A-Za-z][A-Za-z0-9_]*\.json$` (line 64). The `_test/` subdirectory will not be recursed into, so this single `git mv` is sufficient — no adapter change needed.

Verify the regex confirms this by reading the adapter. If `_list_beat_files()` uses `glob("**/*.json")` instead of direct iter, ALSO add an explicit skip of any path component starting with `_`.

### X6 — Hide `primary` cell when no primary set

In `recoil/console-v2/packages/desktop/src/stage/TakesBrowser.tsx` around lines 56–81 (the `takes-meta` div):

Current:
```tsx
const primaryTake = takes.find((t) => t.primary);
const primaryId = primaryTake ? (primaryTake.id as string) : "—";
```

And the JSX cell:
```tsx
<div className="kv">
  <span className="k">primary</span>
  <span className="v">{primaryId}</span>
</div>
```

Change to:
```tsx
const primaryTake = takes.find((t) => t.primary);
```

And conditionally render the cell only when `primaryTake` is set:

```tsx
{primaryTake && (
  <div className="kv">
    <span className="k">primary</span>
    <span className="v">{primaryTake.id as string}</span>
  </div>
)}
```

The remaining count cells (`takes`, `succeeded`, `in-flight`, `circled`) keep their `0` rendering — those are real zeros (we have N takes, 0 are in-flight). The visual mix `0` + `—` is gone because `—` no longer renders.

### Tests

For X4, add a test in `recoil/console-v2/packages/desktop/tests/ProjectPicker.test.tsx` (create if missing) that:
- Renders ProjectPicker with two projects sharing the same `name` but different `id`
- Asserts both rows' slug spans exist in the DOM with the `ambiguous` class

For X6, add a test in `recoil/console-v2/packages/desktop/tests/TakesBrowser.test.tsx` (create if missing) that:
- Renders TakesBrowser with takes where none has `primary: true`
- Asserts `screen.queryByText("primary")` is `null`
- Then re-renders with one take having `primary: true` and asserts the cell now renders with that take's id

Follow the existing test conventions in `recoil/console-v2/packages/desktop/tests/HierarchyNavigator.test.tsx`.

### Validation

```bash
# Frontend tests
cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/recoil/console-v2 && pnpm --filter desktop test

# Structural checks
grep -n "Selected context" recoil/console-v2/packages/desktop/src/chat/ContextStrip.tsx
grep -n "isAmbiguous" recoil/console-v2/packages/desktop/src/shell/ProjectPicker.tsx
grep -n "primaryTake &&" recoil/console-v2/packages/desktop/src/stage/TakesBrowser.tsx
test -f projects/tartarus/state/visual/shots/_test/TEST_I2V_CHAIN_SH34.json
test ! -f projects/tartarus/state/visual/shots/TEST_I2V_CHAIN_SH34.json
```

### Scope boundary

- X2: change ONE line of text + no other ContextStrip changes
- X4: keep existing `.pp-row` / `.pp-row-name` / `.pp-row-slug` classes; only add `.ambiguous` variant
- X5: move ONE file via `git mv`; do not delete; do not rename
- X6: hide the `primary` cell only; do not change other cells; do not change cost / eval cells

---

## What already exists (cross-phase)

- BUS emit API: `BUS.emit_sync(severity, scope, summary, payload=...)` from `recoil.api.eventbus`
- Fallback registry: `emit_fallback(id, scope=..., payload=...)` from `recoil.api.fallback_bridge`
- `_PROCS`, `_PROCS_LOCK`, `_PORTS_RESERVED` in `recoil/api/ttyd_routes.py`
- `Beat` type has `id`, `name`, `takes`, `primary` (from `@recoil/contracts/src/generated.ts`)
- TestClient pattern: `recoil/api/tests/test_workspace_state.py`
- Vitest pattern: `recoil/console-v2/packages/desktop/tests/HierarchyNavigator.test.tsx`

## Cross-machine sync

After Phase 3 lands, the harness's Final Step (`sync-machines.sh post-build`) propagates commits to MBP. JT will pull on wake.
