## 1. Summary

Every K2SO viewer (main window, focus window, "New Window", future mobile companion) currently keeps a private tab list in its Zustand store and persists it to `workspace_layouts.layout_json`. The lists drift between viewers. When a viewer with a bloated list saves, the JSON inherits the bloat permanently. Concrete instance discovered today:

TestingK2SO right now:
Daemon has 3 PTYs in `v2_session_map`.
`workspace_layouts.layout_json` contains 6 tab entries — four of them are identical "Claude" tabs pointing to the same paneGroup ID `15521cbd-…` and the same `sessionId acef6309-…`.
Every restore in every viewer faithfully recreates the corrupt 6, all attaching to the same 3 PTYs.

The fix is architectural, not patchwork: the daemon's session map becomes authoritative for tab identity. The renderer queries the daemon for "what tabs exist" and overlays `workspace_layouts` only for positioning, ordering, and custom titles. Corrupt tab counts become structurally inexpressible because a viewer cannot manifest a tab that has no daemon session backing it.

## 2. The Invariant (conceptual model)

1. Daemon owns sessions. For workspace W, `v2_session_map` has N entries. That number is canonical.
2. Tabs are a 1:1 projection of sessions. N sessions ⇒ exactly N tabs in every viewer. Never N+1, never N×2.
3. Thin clients are viewers, not owners. Main / focus / new-window / mobile all query the daemon for the session list. No private tab inventory. No tab-list saves.
4. Multiple viewers, one PTY. Two viewers can render the same tab; the PTY does not fork. Same grid, same scrollback, same input.
5. Active viewer drives grid dimensions. Exactly one viewer at a time is "active". Its `(cols, rows)` apply to the PTY. Trigger to become active: mouse / focus / keystroke on desktop; tap / app-foreground / keystroke on mobile. Already implemented in 0.37.11; unchanged by this PRD.
6. `workspace_layouts` stores metadata only. Tab order, split positions, active-tab pointer, custom titles. Never "does this tab exist." Existence is the daemon's question.

## 3. How tabs flow today

### Tables that touch sessions / tabs

Table | Owner | Purpose | Role in tab lifecycle
--- | --- | --- | ---
projects | shared | Top-level workspaces (one row per project path) | Identity for tab grouping. Holds `agent_mode`, pinned-chat preferences.
workspaces | shared | Sub-workspaces (branches/worktrees) within a project | Tab layouts are saved per (project_id, workspace_id) pair.
workspace_layouts | renderer | JSON blob: tabs[], extraGroups[], activeTabId, splitCount | The corruption surface. Renderer-owned. Same JSON read in every viewer; drift in any viewer that saves rewrites it for all.
workspace_sessions | daemon | One row per project — the canonical pinned-chat lane | Holds `session_id`, `terminal_id`, `active_terminal_id`. Survives Tauri close.
workspace_heartbeats | daemon | Heartbeat agents (cron-style). One row per (project, agent_name). | Holds `last_session_id`, `active_terminal_id`, schedule.
v2_session_map | daemon RAM | Hash map: canonical_key → `Arc<DaemonPtySession>` | The truth. Not in SQLite — lives in daemon memory. Survives Tauri close, dies on daemon restart.

### Today's flow (simplified)

flowchart LR subgraph DB[SQLite] A[projects] B[workspaces] L[workspace_layouts
layout_json: tabs+] WS[workspace_sessions
session_id, terminal_id] HB[workspace_heartbeats] end subgraph DAEMON[K2SO daemon] M[v2_session_map
canonical_key to PTY] end subgraph THIN1[Thin client: main window] S1[Zustand TabsStore
private tabs] R1[Render tabs] end subgraph THIN2[Thin client: focus window] S2[Zustand TabsStore
private tabs] R2[Render tabs] end L -.read on mount.-> S1 L -.read on mount.-> S2 S1 -.save on close.-> L S2 -.save on close.-> L S1 -- sync:tabs --> S2 S2 -- sync:tabs --> S1 S1 -- WS attach --> M S2 -- WS attach --> M M --- WS M --- HB style L fill:#3b1f1f,stroke:#ef6f6f style S1 fill:#3b1f1f,stroke:#ef6f6f style S2 fill:#3b1f1f,stroke:#ef6f6f style M fill:#1f3b25,stroke:#57c98a
The drift point: `S1` and `S2` can each save to `workspace_layouts`. Last write wins. If `S2` mounted with bloated state (e.g. via `sync:tabs-request` race), its save corrupts `L` for everyone.

## 4. The corruption (live evidence)

Pulled from `k2so.db` for project `b16f79b5-…` (TestingK2SO):

    Total tabs in layout_json: 6

      [0] tabId=a62336d8 title="Chat"    mosaicTree=9e1abfdb  items=[agent:b285c020]
      [1] tabId=9f52d21e title="Manager" mosaicTree=a4164034  items=[agent:-]
      [2] tabId=cf283a53 title="Claude"  mosaicTree=15521cbd  items=[terminal:acef6309]
      [3] tabId=ca15c045 title="Claude"  mosaicTree=15521cbd  items=[terminal:acef6309]   ← duplicate
      [4] tabId=f9f775cc title="Claude"  mosaicTree=15521cbd  items=[terminal:acef6309]   ← duplicate
      [5] tabId=83d6c494 title="Claude"  mosaicTree=15521cbd  items=[terminal:acef6309]   ← duplicate

Daemon for the same workspace:

    3 live session(s):
      TERMINAL_ID                              AGENT                              CMD
      2207ede7-99ef-4b2b-9f03-267ea78400ab     b16f79b5-…  (bare uuid)            claude  (pinned chat)
      47b774ff-200a-462f-9519-c75282a9a591     tab-15521cbd-…                     claude  (Cmd+T tab)
      b74d5e47-fc43-4647-9c91-3f81b78ebda2     tab-a415418c-…                     claude  (Cmd+T tab)

Four "Claude" tab entries → all point at paneGroup `15521cbd-…` → all attach to daemon agent_name `tab-15521cbd-…` → daemon has one PTY for that key. The PTY count is correct; the renderer's tab count is wrong by 3.

## 5. The new model

flowchart LR subgraph DB[SQLite] A[projects] B[workspaces] L2[workspace_layouts v2
order, titles, splits only] WS[workspace_sessions] HB[workspace_heartbeats] end subgraph DAEMON[K2SO daemon] M[v2_session_map
canonical_key to PTY] API[/cli/sessions/list-for-workspace/] EV[session_added / removed / renamed
WS event stream] M --> API M --> EV end subgraph THIN1[Thin client: main] S1[TabsStore
view of daemon list] end subgraph THIN2[Thin client: focus] S2[TabsStore
view of daemon list] end subgraph THIN3[Thin client: mobile] S3[TabsStore
view of daemon list] end API --> S1 API --> S2 API --> S3 EV --> S1 EV --> S2 EV --> S3 L2 -.overlay: order/titles.-> S1 L2 -.overlay: order/titles.-> S2 style M fill:#1f3b25,stroke:#57c98a style API fill:#1f3b25,stroke:#57c98a style EV fill:#1f3b25,stroke:#57c98a style L2 fill:#1f2a3b,stroke:#7c9eff
What changed: arrows from `workspace_layouts` now carry only positioning, not existence. The daemon's `v2_session_map` is the sole source of "tabs exist". Every viewer renders exactly what the daemon reports — no more, no less.

### Where each piece of tab data lives in the new model

Tab attribute | Source | Notes
--- | --- | ---
Existence (does this tab exist?) | daemon · v2_session_map | Queried via `/cli/sessions/list-for-workspace`. Already exists from 0.37.11.
Canonical key (the tab's stable id) | daemon · agent_name | e.g. `tab-<UUID>`, bare project UUID for pinned chat, `heartbeat name` for heartbeats.
Active CLI session id (Claude/Codex) | daemon | Live from PTY argv scan + `workspace_sessions.session_id` / `workspace_heartbeats.last_session_id`.
Kind (chat / inbox / terminal / heartbeat) | daemon (annotation) | Derived from agent_name pattern + workspace_sessions / heartbeats lookup.
Tab order within group | workspace_layouts | v2 schema: `order: [canonical_key, …]`.
Custom title (user-renamed) | workspace_layouts | v2 schema: `titles: {canonical_key: "..."}`.
Split positions / mosaic tree | workspace_layouts | Per-tab; v2 keeps it.
Active tab pointer | workspace_layouts | Stored as canonical_key, not as ephemeral tab UUID.
PTY screen dimensions | daemon · active subscriber | Already enforced in 0.37.11 by SetActive WS frame.

## 6. Schema diff — single-shot 0.38.0

No new tables. Two existing structures change. Everything else holds.

### `workspace_layouts.layout_json` — schema v1 → v2

#### v1 (today)

    {
      "tabs": [
        {
          "id": "<tab uuid>",
          "title": "Claude",
          "mosaicTree": "<paneGroup uuid>",
          "paneGroups": {
            "<paneGroup uuid>": {
              "items": [
                { "type": "terminal",
                  "cwd": "...",
                  "command": "claude",
                  "args": [...],
                  "sessionId": "...",
                  "heartbeatName": "...",
                  "attachAgentName": "..." }
              ]
            }
          }
        }, ...
      ],
      "activeTabId": "<tab uuid>",
      "extraGroups": [...]
    }

Tab existence + payload + positioning all mixed. Corrupts easily.

#### v2 (target)

    {
      "version": 2,
      "groups": [
        {
          "order": [
            "b16f79b5-...",        // pinned chat
            "tab-15521cbd-...",    // terminal tab
            "tab-a415418c-..."     // terminal tab
          ],
          "activeKey": "tab-15521cbd-...",
          "splitCount": 1,
          "titles": {
            "tab-15521cbd-...": "Claude"
          },
          "splitPositions": {
            "tab-15521cbd-...": <mosaicTree...>
          }
        }
      ],
      "activeGroupIndex": 0
    }

Pure positioning. Existence delegated to the daemon. Cannot corrupt — duplicates in `order[]` still resolve to one daemon session.

### Migration path

1. On read: if JSON has top-level `tabs` field (v1) and no `version`, run an in-place v1→v2 conversion. Group by paneGroupId; drop duplicates; preserve titles; preserve order of first occurrence.
2. On boot: one-shot pass over every `workspace_layouts` row, applying v1→v2 + dedup, logging `project_id: N → M tabs`. Marked complete via a row in `code_migrations` with id `0.38.0-layout-v2`.
3. On save: only v2 emitted. v1 is read-only legacy.

### Other table changes

Table | Change | Reason
--- | --- | ---
workspace_layouts | schema v2 (above); same columns | Decouple from tab existence
code_migrations | +1 row: `0.38.0-layout-v2` | Marker for one-shot boot pass
workspace_sessions | unchanged | Already correct.
workspace_heartbeats | unchanged | Already correct.
projects, workspaces | unchanged | Identity untouched.

## 7. Interaction flows

### 7.1 — Open a workspace (any viewer)

sequenceDiagram autonumber participant U as User participant T as Thin client participant D as Daemon participant DB as SQLite U->>T: open workspace W T->>D: GET /cli/sessions/list-for-workspace?path=W D-->>T: [session_a, session_b, session_c] (canonical keys + kinds) T->>DB: load workspace_layouts.layout_json DB-->>T: {order, titles, splits} (v2) T->>T: build tabs by intersecting daemon list with layout order T->>T: render N tabs (= daemon list size) T->>D: WS attach for each tab (canonical agent_name) D-->>T: WS grid stream (no new PTYs spawned)

### 7.2 — User opens new tab (Cmd+T)

sequenceDiagram autonumber participant U as User participant T as Thin client participant D as Daemon participant DB as SQLite U->>T: Cmd+T T->>D: POST /cli/sessions/v2/spawn (canonical_key = tab-<new uuid>) D->>D: register in v2_session_map D-->>T: 200 OK + session_id D->>D: emit session_added event D--)T: WS push: session_added(canonical_key, kind=terminal) D--)Other viewers: WS push: session_added(...) T->>T: append tab to local view Other viewers->>Other viewers: append tab to local view T->>DB: workspace_layout_save (v2: new order)

### 7.3 — User closes a tab

sequenceDiagram autonumber participant U as User participant T as Thin client (main) participant T2 as Thin client (focus) participant D as Daemon participant DB as SQLite U->>T: close tab T->>D: POST /cli/sessions/v2/close (canonical_key) D->>D: kill PTY, remove from v2_session_map D-->>T: 200 OK D--)T: WS push: session_removed(canonical_key) D--)T2: WS push: session_removed(canonical_key) T->>T: drop tab from view T2->>T2: drop tab from view (now in sync) T->>DB: workspace_layout_save (v2: order without canonical_key)

### 7.4 — Focus changes between viewers (active-viewer protocol, 0.37.11)

sequenceDiagram autonumber participant V1 as Viewer A (was active) participant V2 as Viewer B (becoming active) participant D as Daemon participant P as PTY V2->>V2: window focused / tap / keystroke V2->>D: WS send {action: set_active, active: true} (subscriber_id_B) D->>D: CAS active_subscriber: any → B V1->>D: WS send {action: set_active, active: false} (subscriber_id_A) D->>D: CAS active_subscriber: A → 0 (only if still A) V2->>D: WS send {action: resize, cols: 120, rows: 40} D->>D: gate: active == B → apply D->>P: PTY.resize(120, 40) P-->>D: redraw at new dimensions D-->>V1: WS grid stream (rendering at B's dims) D-->>V2: WS grid stream (rendering at B's dims)

### 7.5 — Mobile companion attaches (future)

sequenceDiagram autonumber participant U as User on phone participant Mo as Mobile companion participant D as Daemon (over WAN/LAN) U->>Mo: open companion → choose workspace W Mo->>D: GET /cli/sessions/list-for-workspace?path=W D-->>Mo: [session_a, session_b, session_c] Mo->>Mo: render tab list (identical to desktop) U->>Mo: tap a tab Mo->>D: WS attach + {action: set_active, active: true} D->>D: active_subscriber := mobile's subscriber_id Mo->>D: WS send {action: resize, cols: 80, rows: 24} (phone dims) D->>D: gate passes (mobile is active) → PTY.resize D-->>Mo: WS grid stream at phone dimensions D--)Desktop viewers: WS grid stream at phone dimensions (they see the small grid)
Mobile parity is essentially free in the new model: the companion uses the same `list-for-workspace` endpoint, the same WS attach, the same `set_active` frame. Nothing mobile-specific in the protocol.

## 8. Mobile companion fit

Concern | Today | 0.38.0 model
--- | --- | ---
List tabs for a workspace | Mobile would need a custom endpoint + custom rendering for the layout JSON | One call: `/cli/sessions/list-for-workspace`; renders identically to desktop
Open a new tab | Custom flow per client | POST `/cli/sessions/v2/spawn`; all viewers see `session_added` event
Close a tab | Custom flow | POST `/cli/sessions/v2/close`; `session_removed` event
Active dimensions | Already works via `set_active` (0.37.11) | Unchanged — mobile is just another subscriber
Cross-window sync | Tauri-only event bus (mobile excluded) | Daemon WS event stream — mobile is a first-class viewer

## 9. The PRD (full text)

### TL;DR

Today each renderer (Zustand `TabsStore`) keeps its own private tab list and persists it to `workspace_layouts.layout_json`. Multiple Tauri windows for the same workspace each maintain their own copy. The copies drift, sync-on-mount creates duplicates, and the resulting state gets saved back, corrupting `workspace_layouts` over time.

Concrete evidence: TestingK2SO's `workspace_layouts` row contains 6 tab entries, but 4 of them are the same Claude tab (identical `mosaicTree`/paneGroup ID `15521cbd-…`, identical `sessionId acef6309-…`). The daemon correctly reports 3 PTYs. Every restore faithfully recreates the 6 corrupt tab entries.

Flip the source of truth: the daemon's `v2_session_map` is the tab list. Renderers query the daemon, render exactly that many tabs, and overlay `workspace_layouts` only for positioning / ordering / titles. The thin clients (main window, focus window, "New Window", mobile companion) become read-projecting viewers of one canonical list.

This is the same architectural move that 0.37.0 made for sessions (daemon-first) and 0.37.11 made for resize (active-viewer claim) — extended to tab identity.

### Conceptual model (the invariant we're enforcing)

See section 2 above.

### Single-release plan (0.38.0)

Ships as one atomic release — internal commits for review safety, but no intermediate user-visible state.

#### Commit 1 — Heal-on-read + plug the leak

- `restoreLayout` gains a dedup pass: tabs sharing the same `mosaicTree` paneGroup ID or the same `agentName + section` tuple collapse to the first occurrence. The cleaned layout is immediately re-saved so the fix is permanent for the workspace.
- `useWindowSync` generalises the focus-window guard to all non-main windows. Mount-time `sync:tabs-request` is gone for new/focus windows entirely.
- One-time DB cleanup pass at daemon boot. A SQL migration iterates `workspace_layouts`, parses each JSON, runs the same dedup, and re-saves. Logs `N → M` per workspace.

#### Commit 2 — Renderer queries daemon at mount

- New helper `buildTabsFromDaemon(projectPath)` in `tabs.ts`:
  - Call `k2so_sessions_list_for_workspace` (exists from 0.37.11).
  - For each daemon session, construct a Tab whose `paneGroup.id` matches the session's canonical paneGroup key.
  - Annotate `kind` (`chat | inbox | terminal | heartbeat`).
- `loadLayoutForWorkspace` rewrites:
  1. `buildTabsFromDaemon` → returns N tabs (the canonical set).
  2. Load `workspace_layouts` JSON.
  3. Overlay: re-order tabs per layout's `order: [canonical_key,…]`; apply custom titles; restore split positions; restore `activeTabId` by canonical key. Drop layout entries with no matching daemon session.
  4. If a daemon session is missing from layout overlay, append at the end with default title.
- Ensure-system-tabs for agent-mode workspaces remains: if daemon has no pinned-chat session yet, spawn one. Same idempotent logic as today.

#### Commit 3 — Layout schema v2 + migration

- Emit v2 format on save: pure positioning metadata.
- v1→v2 migration on read.
- One-shot boot pass marker in `code_migrations`.

#### Commit 4 — Daemon session events + remove cross-window tab sync

- Daemon emits `session_added / session_removed / session_renamed` on a single WS channel.
- Every viewer subscribes.
- Retire: `sync:tabs-request`, `sync:tabs`, `broadcastAllTabs`, `applyRemoteTabChange`.
- Same wire format the mobile companion uses — mobile parity = free.

### Out of scope

- Persisting renderer-specific layout per thin-client (mobile vs desktop ordering).
- Migrating the legacy `session_map` (Kessel) sessions. Out of scope per A9 — still Kessel-as-explicit-opt-in.
- Active-viewer resize protocol changes. 0.37.11 protocol stands.
- Manager (inbox) tabs that have no PTY backing. Stays ensure-on-open in the renderer; not daemon-driven.
- File-viewer tabs (markdown/PDF/DOCX). Not PTY-backed — stay renderer-local.

### Definition of done

1. Opening a workspace in main, focus window, "New Window", or mobile companion shows identical tab counts, always equal to the daemon's `v2_session_map` for that workspace.
2. `workspace_layouts` JSON contains no tab existence data, only positioning. Reading the JSON yields zero tabs by itself.
3. Closing a tab in main makes it disappear in focus window within ~50 ms via daemon event, not via Tauri broadcast.
4. Mobile companion can list / focus / spawn / close tabs by talking to the daemon over WS, with no special code paths different from desktop.
5. No code path in the renderer can produce a tab without a matching daemon session — structurally enforced by `buildTabsFromDaemon` being the only entry point.

### Open questions

- Q1: Should `paneGroup.id` BE the canonical agent_name (e.g. `tab-15521cbd-…`), or stay a separate UUID with the agent_name as a property? Today it's the latter. Canonicalising would eliminate one indirection.
- Q2: What happens if daemon is restarting or briefly unreachable at mount? Render an empty state with retry (avoid falling back to layout JSON).
- Q3: Pinned-chat sessionId-stamping (0.37.12) currently lives in layout JSON. Move to daemon side and drop the layout mirror.

K2SO · daemon-first architecture · open-source IDE shell + proprietary agent orchestration · 2026