Daemon-Authoritative TabsDrafted · 0.38.0 target

PRD + DB model reference · prepared by Claude with Rosson · 2026-05-17

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

TableOwnerPurposeRole in tab lifecycle
projectssharedTop-level workspaces (one row per project path)Identity for tab grouping. Holds agent_mode, pinned-chat preferences.
workspacessharedSub-workspaces (branches/worktrees) within a projectTab layouts are saved per (project_id, workspace_id) pair.
workspace_layoutsrendererJSON blob: tabs[], extraGroups[], activeTabId, splitCountThe corruption surface. Renderer-owned. Same JSON read in every viewer; drift in any viewer that saves rewrites it for all.
workspace_sessionsdaemonOne row per project — the canonical pinned-chat laneHolds session_id, terminal_id, active_terminal_id. Survives Tauri close.
workspace_heartbeatsdaemonHeartbeat agents (cron-style). One row per (project, agent_name).Holds last_session_id, active_terminal_id, schedule.
v2_session_mapdaemon RAMHash 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 attributeSourceNotes
Existence (does this tab exist?)daemon · v2_session_mapQueried via /cli/sessions/list-for-workspace. Already exists from 0.37.11.
Canonical key (the tab's stable id)daemon · agent_namee.g. tab-<UUID>, bare project UUID for pinned chat, heartbeat name for heartbeats.
Active CLI session id (Claude/Codex)daemonLive 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 groupworkspace_layoutsv2 schema: order: [canonical_key, …].
Custom title (user-renamed)workspace_layoutsv2 schema: titles: {canonical_key: "..."}.
Split positions / mosaic treeworkspace_layoutsPer-tab; v2 keeps it.
Active tab pointerworkspace_layoutsStored as canonical_key, not as ephemeral tab UUID.
PTY screen dimensionsdaemon · active subscriberAlready 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

TableChangeReason
workspace_layoutsschema v2 (above); same columnsDecouple from tab existence
code_migrations+1 row: 0.38.0-layout-v2Marker for one-shot boot pass
workspace_sessionsunchangedAlready correct.
workspace_heartbeatsunchangedAlready correct.
projects, workspacesunchangedIdentity 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

ConcernToday0.38.0 model
List tabs for a workspaceMobile would need a custom endpoint + custom rendering for the layout JSONOne call: /cli/sessions/list-for-workspace; renders identically to desktop
Open a new tabCustom flow per clientPOST /cli/sessions/v2/spawn; all viewers see session_added event
Close a tabCustom flowPOST /cli/sessions/v2/close; session_removed event
Active dimensionsAlready works via set_active (0.37.11)Unchanged — mobile is just another subscriber
Cross-window syncTauri-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

Commit 2 — Renderer queries daemon at mount

Commit 3 — Layout schema v2 + migration

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

Out of scope

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


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