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:
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-….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.
v2_session_map has N entries. That number is canonical.(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.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.| 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. |
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.
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)
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.
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.
| 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. |
No new tables. Two existing structures change. Everything else holds.
workspace_layouts.layout_json — schema v1 → v2{
"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.
{
"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.
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.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.| 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. |
list-for-workspace endpoint, the same WS attach, the same set_active frame. Nothing mobile-specific in the protocol.
| 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 |
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.
See section 2 above.
Ships as one atomic release — internal commits for review safety, but no intermediate user-visible state.
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.workspace_layouts, parses each JSON, runs the same dedup, and re-saves. Logs N → M per workspace.buildTabsFromDaemon(projectPath) in tabs.ts:
k2so_sessions_list_for_workspace (exists from 0.37.11).paneGroup.id matches the session's canonical paneGroup key.kind (chat | inbox | terminal | heartbeat).loadLayoutForWorkspace rewrites:
buildTabsFromDaemon → returns N tabs (the canonical set).workspace_layouts JSON.order: [canonical_key,…]; apply custom titles; restore split positions; restore activeTabId by canonical key. Drop layout entries with no matching daemon session.code_migrations.session_added / session_removed / session_renamed on a single WS channel.sync:tabs-request, sync:tabs, broadcastAllTabs, applyRemoteTabChange.session_map (Kessel) sessions. Out of scope per A9 — still Kessel-as-explicit-opt-in.v2_session_map for that workspace.workspace_layouts JSON contains no tab existence data, only positioning. Reading the JSON yields zero tabs by itself.buildTabsFromDaemon being the only entry point.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.K2SO · daemon-first architecture · open-source IDE shell + proprietary agent orchestration · 2026