Tab IDs in KISS Sorcar: How Workflows Route Around a Pane Identifier

A line-verified tour of every tab_id / tabId use in src/kiss/core/, src/kiss/agents/sorcar/, and src/kiss/server/ — branch bigrefactor, after the mirror-clients refactor, the removal of the interactive diff/merge review, and the removal of the interactive sorcar terminal CLI. All line numbers cite the current working tree.

1. What a tab id is — the four identifier forms

A tab id is the routing key of one pane in the frontend: a chat tab in the VS Code webview or in the kiss-web browser app. Under the mirror-clients model every connected client shows the same set of tabs, so a tab id names a globally shared pane, not a per-client one. The backend uses tab ids in two dominant roles: to stamp outbound events so clients know which pane renders them, and to look up the per-tab server state (the launched task, its model pick, its viewed chat) when a command arrives carrying a tab id. A few narrower roles also appear and are covered where they arise: the headless API client filters its shared input stream by tab id (sorcar.py:1188), the server parses the synthetic __sub_ id convention to find a sub-agent's parent tab (server.py:1141–1145), _fanout_talk tests target ids against the local-UDS tab set for talk-playback arbitration (web_server.py:1977–1989), and snake_case tab ids ride some payloads as plain data (the sub-agent tab tree, subagentDone).

Four identifier forms appear on the wire, distinguished only by convention:

FormExampleMinted byPurpose
Frontend UUID 3f2a…c9 The webview's createNewTab flow; the backend first sees it on a newChat / run command. An ordinary interactive chat tab. server.py:875–879 documents that newChat always carries a fresh, never-seen id.
Synthetic API tab api-{uuid.hex} The headless Python client kiss.server.sorcar.run(), one per call. sorcar.py:1121 Makes a headless UDS client indistinguishable from a frontend tab: the daemon fans events out stamped with this id and the client filters its own stream by it (sorcar.py:1137, 1188).
Live sub-agent tab task-{parent_task_id}__sub_{idx} The parent agent, in ChatSorcarAgent._run_tasks_parallel. chat_sorcar_agent.py:613 Gives each parallel child its own child pane; injected as the child's agent._tab_id (:614).
Replay sub-agent tab {parent_tab_id}__sub_{sub_task_id} The server, when re-opening persisted sub-agent runs on session resume. server.py:1188 Deterministic, so clicking the same parent task twice updates sub-tabs in place instead of stacking duplicates (server.py:1165–1171).

The __sub_ substring in the last two forms is a cross-layer contract: server.py's _resolve_parent_tab_id_for_sub parses it with rsplit("__sub_", 1) as its last-resort tier when inferring which tab a sub-agent pane should nest under (server.py:1141–1145) — the right-split keeps nested chains (A__sub_B__sub_C → parent prefix A__sub_B) correct. Section 6 covers the whole mechanism.

The one naming convention that explains most of this report: camelCase tabId on an event is a routing stamp — it tells the printer "this copy is pre-addressed; deliver it to clients (which filter panes by it) and do not record or persist it". Snake_case tab_id / parent_tab_id fields are plain payload data the webview stores (e.g. the tab-tree structure in openSubagentTab). An event can carry both at once — subagentDone ships {"tab_id": vid, "tabId": ""}: an all-clients broadcast (empty routing stamp) whose payload names the pane whose spinner should stop (sorcar_agent.py:247, server.py:1222–1228).

2. The layer map: who touches tab ids, and how much

A case-insensitive grep for tab_id/tabId over the three directories gives a strictly stratified picture: src/kiss/core/ has zero occurrences; the three agent files hold 22 lines of narrow UI hooks; and the nine server files own everything else — 507 lines. Commands flow down from clients carrying tabId; task events flow up from agents completely tab-free and are stamped per-tab only at the fan-out edge inside WebPrinter.

Frontend clients — VS Code webview / kiss-web browser every client shows the same tabs (mirror model); panes filter events by tabId commands flow down carrying cmd["tabId"] events go up: one copy per subscribed tab, tabId spliced at the edge (Fig 3) fan-out edge: WebPrinter._fanout_stamped :1890 src/kiss/server/ — 507 tab-id lines across 9 files server.py · 112 dispatch + sanitize · replay engine 3 tab dicts · closeTab teardown __sub_ parent resolution task_runner.py · 100 run lifecycle status bracketing agent._tab_id = tab_id :671 stop routing · viewer subscribe web_server.py · 66 WebPrinter.broadcast routing per-tab fan-out stamping local-UDS talk muting · ready commands.py · 61 _cmd_run guards · steering enqueue userAnswer resolution per-tab model pick writes json_printer.py · 56 _subscribers task→{tabs} table _task_ui · transient targeting model-pick override memory merge_flow.py · 54 autocommit events + persistence worktree_done presentation is_merging claim/release autocomplete.py · 28 pure reply-echo stamps on ghost / completions / files; zero tab-keyed state sorcar.py · 21 API catalog (closeTab requires tabId) _record_tab bookkeeping headless run(): api-{uuid} tabs agent_state.py · 9 AgentState.tab_id attribute find_by_tab two-pass lookup (registry itself is task-keyed) task events flow up tab-free (no tabId at emission) agent._tab_id injected per run (task_runner.py:671) src/kiss/agents/sorcar/ — 22 tab-id lines across 3 files chat_sorcar_agent.py · 10 sub-tab synthesis · new_tab sorcar_agent.py · 8 subagentDone · model-pick show worktree_sorcar_agent.py · 4 _tab_id field · commit toast src/kiss/core/ — 0 occurrences (deliberately tab-agnostic; see §13)
Figure 1. Layer map with per-file tab-id line counts (case-insensitive grep, current tree). Commands enter at the top carrying cmd["tabId"] and are sanitized in server.py:418–421; agents emit task events with no tab id at all; the per-tab tabId stamp is spliced onto each subscriber's copy only at the fan-out edge, WebPrinter._fanout_stamped (web_server.py:1890–1922). The single downward injection into the agent layer is agent._tab_id = tab_id (task_runner.py:671; the only other production assignment is the sub-agent synthesis at chat_sorcar_agent.py:614).

The stratification is deliberate. The printer base class is task-centric: "every piece of per-stream state … is keyed by task_id rather than by the frontend tab id" (json_printer.py:7–9); tab ids appear only at the edges — subscription (_subscribers[task_id] → {tab_id, …}) and transient targeting. Agents receive a tab id only where the UI structurally needs one (sub-agent panes, best-effort toasts); everything else — steering, persistence, recording — reaches them keyed by task id.

3. Launching a task from a tab

The run command is where a tab id does the most work. Between the frontend's click and the agent's first event, the launching tab's id is copied into four places: the tab-keyed guards in commands.py, the new AgentState.tab_id attribute (in a registry keyed by a uuid state key, not by tab id), the injected agent._tab_id, and the printer's _task_ui / _subscribers tables — wired up the moment the run's task_history row id exists.

Frontend tab tabId = T sorcar.py ServerApi.dispatch commands.py _cmd_run task_runner.py _run_task / _inner printer JsonPrinter/WebPrinter agent ChatSorcarAgent.run run {tabId:T, prompt, chatId, connId} validate_command; _record_tab(T) :635–666 _handle_command: sanitize tabId (server.py:418–421) tabId mandatory :306–309 find_by_tab(T) :316 — is_merging? busy? → queue prompt (Fig 4) :330–342 error {tabId:T} if merging :321–328 (refused) fresh run :343–378 AgentState(tab_id=T, server_owned=True) registry key = uuid4 state_key :356–361 _tab_chat_views[T] = chat_id :355 broadcast clear {chat_id, tabId:T} :386–390 (before thread starts) thread.start() → _run_task(cmd) status {running:true, tabId:T, startTs} :377–385 _resolve_run_state :569–615 model = cmd.model or _tab_model(T) :665 agent._tab_id = T :671 (sole server injection site) early prompts {tabId:T, taskId:"", early:true} :555–567 agent.run(…, on_task_id_allocated(source_tab_id=T)) :782–809 task_history row id exists → _on_run_task_id_allocated :617–653 register_task_ui(task_id, T, conn_id) :646 _task_ui[task] = (T, conn) :305 subscribe_tab(task, T) :306 → _subscribers _subscribe_chat_viewers :1304–1371 — per idle viewer V of chat: subscribe_tab(task, V) :1355 clear {chat_id, tabId:V} :1356–1362 status {running:true, tabId:V, startTs} :1363–1371 task events — NO tabId; recorded once, stamped per subscriber (Fig 3) task ends — _run_task finally :409–446 status {running:false, tabId:T} :428–435 · model restore :436 per viewer V: status running:false + model restore :470–523 busy-viewer guard: skip V if it owns a different active task :508–514 _dispose_if_closed(T) :442 — tear down now if the tab was closed while busy (§8)
Figure 2. Sequence of launching a task from tab T. Line numbers without a file refer to the lifeline's own file (commands.py for _cmd_run, task_runner.py for the run lifecycle, json_printer.py for the printer boxes). The launching tab id ends up in four places: the tab-keyed guards, AgentState.tab_id (in a uuid-keyed registry entry), agent._tab_id (:671), and the printer's _task_ui/_subscribers tables — the latter wired by _on_run_task_id_allocated before any agent event is broadcast, so neither the launcher nor pre-existing chat viewers can miss a live event.

Three details worth calling out:

4. Event routing: WebPrinter.broadcast

Events sent through the printer's broadcast API funnel through one method, and the presence of the tabId key (even with an empty value) is the routing discriminator. There is no per-connection tab filtering in the server: ordinary tab-addressed and global broadcasts are sent to every connected WSS and UDS endpoint (_send_to_ws_clients, web_server.py:2056–2071), and the frontend filters by the tabId stamp. The narrower channels are keyed by connection, never by tab: a connId-stamped broadcast goes to that single endpoint via _send_to_conn and returns before any tab routing (web_server.py:1857–1859), and some replies bypass broadcast entirely with direct _endpoint_send calls — the ready flow's tasks_updated / focusInput / openRunningTasks, plus fileContent and pathsExist (§10.5).

WebPrinter.broadcast(event) web_server.py:1806 stamp_event_ts · pop connId · pop recordOnly configData: fill default work_dir :1845–1856 connId was set? :1857 yes _send_to_conn(conn_id) one endpoint only :2073–2089 (request/reply; return) no "tabId" key in event? key presence, even "" — :1861 yes type ∈ {prompt, result} AND truthy taskId? :1862 yes record + persist a tabId-STRIPPED durable copy under the task :1863–1866 no recordOnly? → return :1867 else send VERBATIM to ALL clients frontend filters panes by tabId :1869 never fanned out per task; return no _inject_task_id(event) thread-local task id of the calling run :1872 taskId resolvable? :1874 no global system event tasks_updated, remote_url, … recordOnly? return : send verbatim to all; return :1875–1879 yes — task event record ONCE, tab-free :1880–1881 persist display events :1883 (_DISPLAY_EVENT_TYPES, json_printer.py:41–63) recordOnly? → return :1885 _fanout_stamped(event) :1890–1922 targets = _fanout_targets(taskId) — the task's subscriber tabs, kept alive 300 s after task end (json_printer.py: 462–481, 748–816) · strip any stale tabId stamp :1912–1913 type == "talk"? :1914 yes _fanout_talk :1954–1990 tabs in _local_uds_tab_counts = local VS Code webviews → daemon plays the clip natively and those tabs' copies ship "muted": true; remote tabs get the playable copy no per target tab: splice the stamp into the serialized JSON and send to ALL clients f'{base}, "tabId": {json.dumps(tab_id)}}}' :1917–1921 one copy per subscribed tab — copies multiply, never narrow Key invariants · tabId-key presence (even "") = transient: delivered to clients, never recorded/persisted — except the tab-stripped prompt/result copy. · Task events never carry a tabId at emission; the stamp is added per-copy at this edge. Persistence and recording are always tab-free. · recordOnly = record/persist but never emit live (the steering drain's durable echo, §5); dropped entirely when no task id resolves.
Figure 3. The routing decision tree of WebPrinter.broadcast (web_server.py:1806–1889; the recording rules mirror the base class JsonPrinter.broadcast, json_printer.py:984–1015). The tabId key is the discriminator between pre-addressed transients (delivered verbatim, unrecorded) and task events (recorded once tab-free, then stamped per subscriber tab by _fanout_stamped). Ordinary tab-addressed and global sends go to every WSS + UDS endpoint — the mirror model — and the frontend filters panes by the stamp; connId-stamped sends reach only their one endpoint (_send_to_conn, :1857–1859), and some direct replies bypass this method entirely via _endpoint_send (§10.5).

The transient primitives layered on top of this contract live in the base printer: broadcast_transient(event, task_id=None, tab_id="") resolves all watching tabs via _transient_targets (thread-local task id first, explicit argument as a near-teardown fallback, _subscribers as the target source — _task_ui is deliberately not consulted because it is dropped at task teardown) and broadcasts one stamped copy per tab (json_printer.py:483–553). Even when no watcher resolves, one copy stamped with the fallback tab_id — possibly "" — still goes out: the mere key presence preserves the no-record semantics (json_printer.py:552–553).

5. Steering: follow-up messages into a running task

Follow-ups typed while a task runs are addressed by tab but consumed by task. The server enqueues them on the owning AgentState found via the tab id (with a viewer-tab fallback through the printer's subscriber map); the agent drains them through a duck-typed printer bridge that resolves the state by the calling thread's task id — the agent never sees a tab id on the consumption side. Since the CLI removal this bridge is the only steering channel: the agent-local pending_user_messages queue that served the CLI steering box is gone.

ENQUEUE — by tab (server) CONSUME — by task (agent) user types in tab T while task runs appendUserMessage {tabId:T, prompt} or run {tabId:T} on the busy tab (:330–342) owner = find_by_tab(T) commands.py:712 → agent_state.py:202–221 two-pass scan under STATE_LOCK: server-owned states first, then any match not accepting input? (:713) viewer fallback _find_viewer_task_states(T) task_runner.py:1470–1506: scan printer's _subscribers {task → tabs} for tasks T views; resolve each by its OWN task id — no hijack neither → dropped, debug log (:719–725) owner.pending_user_messages .append(prompt) :726 no task row yet? also queue on unattributed_prompt_echoes :728–729 _echo_injected_prompt(T, prompt, task) commands.py:628–673 prompt {tabId:T [, taskId]} — the tabId routes it to the typed-into pane; the taskId (when the row exists) makes Fig 3 record a durable tab-stripped copy; without it the echo is transient-only queues live here AgentState (task-keyed registry) pending_user_messages: list[str] unattributed_prompt_echoes: list[str] tab_id is an attribute, not the key (agent_state.py:58, 96) drained by task id hooks installed unconditionally sorcar_agent.py:1105–1106 (perform_task) pre_step_hook = _drain_pending_user_messages tool_call_guard = _block_finish_when_…_pending self-guarding no-ops without a channel before every agent step _drain_pending_user_messages :1233 single channel since the CLI removal: duck-typed printer.drain_pending_user_messages() json_printer.py:399–433 — resolves the state via the calling THREAD's task id; no tab id in sight; printers without the bridge drain nothing queued text → injected into the step; for unattributed echoes the drain emits a recordOnly prompt copy — recorded + persisted under the now-known task, never re-rendered (:422) finish guard _block_finish_when_user_message_pending :1268 blocks the finish tool while the printer bridge's printer.has_pending_user_messages() (duck-typed, json_printer.py:435–446) still holds a follow-up Prompt-echo semantics (two paths to durability) · Task row known at queueing: the echo ships {tabId:T, taskId} — rendered live in tab T; Fig 3's tabId branch records a durable   tab-stripped copy under the task, so replay shows the follow-up exactly once (commands.py:665–672). · Task row NOT yet allocated: the echo ships {tabId:T} only (transient); the text is also queued on unattributed_prompt_echoes,   and the drain hook later emits a recordOnly copy under the fresh task id — durable, never re-rendered (already on screen). · Steering state is wiped at task end: _run_task's finally clears both queues (task_runner.py:416–417).
Figure 4. The steering pipeline. Left: the server resolves the tab id to the owning AgentState — directly via find_by_tab (two-pass, server-owned first) or through the printer's subscriber map for viewer tabs — and appends to task-owned queues. Right: the agent's self-guarding hooks drain those queues via the printer bridge keyed by the calling thread's task id, and refuse to finish while a follow-up is undrained. The tab id never crosses to the consumption side.

6. Parallel sub-agent tabs

When a task fans out into parallel sub-agents, each child gets its own pane. Live children are announced by the parent agent, which mints synthetic tab ids; persisted children are re-opened by the server on session resume with a different, deterministic id form. Both forms embed __sub_, the contract that lets the server re-derive the parent pane when it must.

A · LIVE — parent agent mints the tabs (chat_sorcar_agent.py) parent: _run_tasks_parallel parent_tab_id = own _tab_id :582 nested sub-agent parent (synthetic _tab_id): first sorted _fanout_targets(own task) viewer :585–590 — a renderable anchor parent_task_id = _last_task_id (or uuid4) :571–581 per child idx sub_tab_id = f"task-{parent_task_id}__sub_{idx}" :613 agent._tab_id = sub_tab_id :614 agent._subagent_info = {parent_task_id, parent_tab_id} :621–624 child run() new_tab broadcast :797–802 {type:"new_tab", task_id, parent_tab_id, taskId:""} all-clients; payload tells the frontend which tab to nest under frontend opens the child pane and issues resumeSession(sub_tab_id) → _reattach_running_chat subscribes the pane to the child's stream (server.py:1267–1354) registration with the printer bridge the child's run calls agent_task_allocated, which creates an AgentState(tab_id = getattr(agent, "_tab_id", "")) — the ONLY place json_printer.py reads agent._tab_id (json_printer.py:355) → registered with is_subagent semantics; steering and stop work on the child like on any tab-launched task child finally (success, failure, or stop) :655–668 viewer_ids = printer._fanout_targets(sub_task_id); append sub_tab_id if missing (dedup :662–663) _broadcast_subagent_done (sorcar_agent.py:224–251): per id → broadcast {type:"subagentDone", tab_id: vid, tabId: ""} + restore_model_pick(model, vid) payload tab_id names the pane to stop; empty routing tabId = all-clients broadcast; errors swallowed non-UI run_tasks_parallel sorcar_agent.py:1471–1496: same contract — fan-out viewers + task-{parent}__sub_{idx} from the parent TASK id alone :1486–1488; no _tab_id read or assigned; skipped for plain console runs (no parent key) B · REPLAY — server mints the tabs on session resume (server.py) parent tab resumes a chat _replay_session → _open_persisted_subagent_tabs( parent_task_id, parent_tab_id) :1074–1078 rows loaded by parent task id :1185 per row sub_tab_id = f"{parent_tab_id}__sub_{sub_task_id}" :1188 deterministic → re-clicking the parent updates the same sub-tabs in place, no duplicates :1165–1171 still running? subscribe first _reattach_running_chat(…, sub_tab_id, task_id=sub_task_id, is_subagent=True) :1192 exact task-id match required — never falls back to the parent's chat stream three broadcasts per sub-agent row :1198–1228 ① openSubagentTab {tab_id, parent_tab_id, taskIndex, isDone} — snake_case payload, no stamp ② task_events {events: coalesced replay, tabId: sub_tab_id} — rows are tab-free, stamped once ③ race repair: finished since the liveness probe? → subagentDone {tab_id: sub_tab_id, tabId: ""} without it the pane would spin forever (its live subagentDone predated this pane) :1221–1228 _resolve_parent_tab_id_for_sub server.py:1080–1156 · skips sub-agent states: ① registry match by parent_task_id :1122–1126 ② unique non-subagent chat-id match :1128–1139 ③ rsplit("__sub_", 1) prefix :1141–1145 The __sub_ contract, shared by three layers · Agent (chat_sorcar_agent.py:613, sorcar_agent.py:1486) mints task-{parent_task_id}__sub_{idx} for live children. · Server (server.py:1188) mints {parent_tab_id}__sub_{sub_task_id} for replayed children and parses both forms with rsplit("__sub_", 1) —   right-split so nested chains (A__sub_B__sub_C) resolve to their parent prefix. Failure to resolve logs a WARNING and returns "" :1147–1156. · Frontend nests the child pane under parent_tab_id and cascade-closes children when the parent closes; a blank parent link breaks the cascade,   which is why the resolution is three-tiered rather than trusting any single source (docstring server.py:1090–1096).
Figure 5. Sub-agent panes, live (top) and replayed (bottom). Live ids are minted by the parent agent from its task id and injected as the child's agent._tab_id (the second of the only two production assignment sites); replay ids are minted by the server from the parent tab id and the child task id, making re-resume idempotent. In both, the spinner-stopping subagentDone carries the pane in the snake_case payload and an empty camelCase routing stamp.

7. Post-task flows: autocommit and worktree presentation

With the interactive diff/merge review removed, what happens after a task is a short decision tree, and every branch is tab-addressed: the tab id stamps the progress and terminal events, keys the find_by_tab lookups that reach the tab's worktree agent, and anchors the per-tab is_merging mutual-exclusion claim.

agent.run returned task_runner.py _run_task_inner, completion section use_worktree? :960 / :1017 no _main_dirty_files(work_dir)? :966 — clean trees and non-git folders stay event-free dirty _autocommit_changes(tab_id, work_dir) task_runner.py:967 → merge_flow.py:249–357 autocommit_progress {tabId} × 3 stages: "Staging changes…" :286–290 · "Generating commit message…" :307–311 · "Committing…" :328–331 LLM context: find_by_tab(tab_id).last_user_prompt :312–316 terminal: _broadcast_autocommit_done {tabId} :216–242 persistence bridges tab → task: _append_chat_event(done, task_id = _state_task_key(find_by_tab(tab_id))) :342–346 tab_id="" still commits — events stamped tabId:"" (key presence ⇒ transient); done-event persistence skipped yes, _wt_pending auto-commit ON and task succeeded? :1018–1021 (failure ⇒ _pending_review) yes — silent finalize merge or discard directly changed = _get_worktree_changed_files(tab_id) :1022 action = changed ? "merge" : "discard" :1023–1025 _handle_worktree_action(action, tab_id, internal=True) :1026 (merge_flow.py:832: find_by_tab :871 · claim is_merging under _state_lock :915–916 · worktree_progress {tabId} :921–927 · release + _dispose_if_closed in finally :940–946) → worktree_result {tabId, **result} :1031–1037 also answers user worktreeAction clicks (commands.py:868–879) no — present buttons _present_pending_worktree(tab_id, discard_if_empty=False) task_runner.py:1039 → merge_flow.py:530–598 find_by_tab(tab_id) :561 · changed-files probe :567 empty branch PRESERVED, no event (discard_if_empty=False); the :568–585 auto-discard runs only on the discard_if_empty=True session-resume path → worktree_done {branch, worktreeDir, originalBranch, changedFiles, hasConflict: _check_merge_conflict(tab_id), tabId} :589–598 tab shows Merge/Discard; later worktreeAction {tabId} runs the executor session resume re-presentation (claim/release protocol) _replay_session tail calls _emit_pending_worktree(tab_id) (server.py:1072 → merge_flow.py:359–410): _finalize_pending_worktree (tab_id) :431 returns a _PendingOutcome — NOOP (busy) / PRESENT / PRESENT_CLAIMED / finalized. The is_merging claim is taken in the SAME _state_lock section that observed it clear — two simultaneous resumes cannot double-run the empty-branch discard — and released in try/finally via _release_present_claim(tab_id) :412–429, which ends with _dispose_if_closed(tab_id) The commit toast: transient by construction (agent side) The worktree agent's "Generating commit message…" → "Committed …" toast lifecycle is broadcast via the printer's duck-typed transient primitive: broadcast_transient(event, task_id=_last_task_id, tab_id=self._tab_id) (worktree_sorcar_agent.py:272–278) The thread-local task id is the preferred routing key (all watching tabs resolve from it); _last_task_id is the task-key fallback for calls off-thread or near teardown, after the thread-local id is cleared; _tab_id is always added as one extra, deduplicated target — the sole stamp only when no subscriber resolves (json_printer.py:483–517). Printers without the primitive get one plain broadcast({**event, "tabId": …}) — the stamp alone keeps no-record semantics (:279–283), so replay never resurrects a toast; notification_id updates in place. How completion reaches every pane (all branches converge) ① result — taskId-stamped when the row exists (task-keyed fan-out + durable record); tabId fallback only pre-allocation (:882–894, 939–943) ② task_done / task_error / task_stopped / task_interrupted {tabId, startTs, endTs} to the launcher pane (:1046–1055, fallback :1087–1096) ③ status running:false to the launcher (:428–435), then per viewer with a busy-viewer guard + per-pane model-pick restore (:470–523) ④ tasks_updated — no tabId: a global history-panel refresh ping (:1016) · ⑤ printer.cleanup_task(hist_id), 300 s subscriber linger (:1074)
Figure 6. Post-task decision tree (all line numbers task_runner.py unless noted). Non-worktree tasks always auto-commit when the tree is dirty; worktree tasks either finalize silently (auto-commit ON, success) or present Merge/Discard via worktree_done. Per-tab mutual exclusion is the is_merging flag on the tab's AgentState, always claimed and released under the daemon-wide _state_lock, with _dispose_if_closed(tab_id) after every release so a tab closed mid-flight is finally torn down.

8. The tab lifecycle: birth, life, disconnects, death

Since the mirror-clients refactor, tabs are global server state: no connection owns them, and a disconnect tears down nothing tab-related. The only death is an explicit closeTab — which is also the only command whose catalog entry requires a tabId (sorcar.py:322).

BIRTH — three mints frontend uuid createNewTab → newChat: always a fresh id; seeds _tab_models[T], broadcasts showWelcome {tabId, model} (server.py:872–905) headless api-{uuid} sorcar.run() mints one per call (sorcar.py:1121), stamps it on the run cmd :1137, filters the event stream by it :1188 — indistinguishable from a tab synthetic sub-agent tabs agent-minted task-…__sub_{idx} (live, :613) and server-minted {parent_tab}__sub_{task} (replay, server.py:1188) — see Fig 5 LIVE — global, shared by every client per-tab dicts on VSCodeServer (server.py:313–315): _tab_chat_views[T]=chat · _tab_models[T]=model · _tab_opened_task_ids[T]=task-to-resume printer membership: _subscribers[task] ∋ T (fan-out) · _model_override_tabs ∋ T (agent model showing) · _task_ui[task]=(T, conn) while running registry: at most one server-owned AgentState with .tab_id == T (a new run replaces it; find_by_tab resolves it) talk muting only: a UDS peer's command tabId lands in its conn_state["local_tabs"]; the peer also refcounts it in _local_uds_tab_counts via register_local_uds_tab (sorcar.py:649–666, web_server.py:1939–1952) — drives nothing but the muted-copy decision in _fanout_talk busy sub-state: task running (is_task_active / task_thread) or merge in flight (is_merging) WSS / UDS disconnect: NO tab teardown handlers' finally drops only connection state; UDS also decrements _local_uds_tab_ counts (web_server.py:3473–3476, 3538–3548). No deferred close, no ownership. explicit closeTab {tabId} — the ONLY trigger (catalog-required, sorcar.py:322) state.busy()? server.py:798–805 busy frontend_closed = True :800–801 — state kept; a running agent is never stopped by a pane close later, at a lifecycle transition (task end :442, merge release, presentation release): _dispose_if_closed(T) pops the state only when frontend_closed AND not busy (server.py:807–828) idle → unregister(state) :803–804 DEATH — _teardown_tab_resources(T) (server.py:830–870) ① pending worktree: preserve for review (_preserve_pending_worktree_for_review) or release,     then flush warnings while the printer can still deliver them :857–863 ② _printer_cleanup_tab(T) → JsonPrinter.cleanup_tab: drop T from every task's _subscribers set     and from _model_override_tabs; per-TASK state survives (json_printer.py:719–746) ③ purge all three per-tab dicts: _tab_chat_views · _tab_opened_task_ids · _tab_models :866–870 headless client parity sorcar.run()'s finally sends an explicit closeTab {tabId: api-…} on EVERY exit path (sorcar.py:1204–1220): finished task → disposed immediately; timeout → keeps running, flips frontend_closed, disposed at task end. Fixes the per-run state leak the mirror refactor exposed. Reopening cancels a pending disposal Any resumeSession into the tab clears frontend_closed (server.py:986, 1015) — a user who closes a busy pane and reopens it before the task ends keeps the state alive. Similarly, cleanup_tab is safe on a live tab: it also runs on re-subscription (session replay, new chat) to purge stale subscriptions before rebinding (server.py:954, 1005; json_printer.py:730–732).
Figure 7. The tab lifecycle. Three mints, one shared LIVE state (per-tab dicts, printer subscriptions, at most one server-owned AgentState, and the talk-muting refcount), and exactly one death path: explicit closeTab, deferred via frontend_closed + _dispose_if_closed when the tab is busy. Disconnects — WSS or UDS — never touch tab state; the headless client compensates by sending its own closeTab in a finally.

9. Session resume, replay, and the ready flow

9.1 resumeSession_replay_session (server.py:907–1078)

Clicking a history entry (or restoring a session) sends resumeSession {chatId, tabId [, taskId]}. The engine's tab-id behavior, in order:

9.2 ready / restoredTabs (sorcar.py:822–845, web_server.py:4256–4400)

A (re)loading webview announces the tabs it restored. _sanitized_restored_tabs caps the list, skips non-dict entries, and blanks non-string ids (web_server.py:4256–4315); each restored id then passes through _record_tab (talk-muting bookkeeping, sorcar.py:837–841). The web layer's _handle_ready replays each sanitized {tabId, chatId} pair with one backend resumeSession (:4369–4376) — the client supplies the tab ids, the server replays into them. For remote (non-UDS) clients only, it also pushes openRunningTasks built from _snapshot_running_task_rows() — rows carry chatId/taskId and no tab ids at all (:1499–1563, 4377–4399): a reconnecting browser mints fresh panes and issues its own resumeSessions. Init fan-out (getModels, getInputHistory, getConfig) is connId-scoped, and a focusInput {tabId} echo lands on the sender's active pane (:4345–4366).

10. Smaller flows

10.1 Ask-user: question out task-keyed, answer back tab-resolved

_ask_user_question broadcasts askUser {question} with no tabId (task_runner.py:1636–1641) — a task event that fans out to every subscribed pane, so any viewer can answer. The reply, userAnswer {tabId, answer}, is resolved in two tiers (commands.py:579–626): ① the state launched from the answering tab itself, when it has a live user_answer_queue; ② otherwise any task the tab is subscribed to, via the printer's _subscribers map — resolving through the task id makes a cross-task answer hijack structurally impossible. The queue is drain-then-put under _state_lock (a maxsize=1 queue cannot wedge on a double answer), and askUserDone {tabId} is broadcast once per subscriber tab of the answered task — exactly the panes showing that modal, not every tab the answerer ever touched (:536–577). The waiting side resolves its queue by the calling thread's task id, deliberately tab-free (task_runner.py:1607–1625).

10.2 Stop

_stop_task(tab_id) refuses an empty tab id with a warning — a missing tabId at this layer indicates a frontend bug that must not silently stop every tab's task (task_runner.py:1390–1397). Resolution: find_by_tab first; when the tab owns no live task, the viewer fallback _find_viewer_task_states scans _subscribers for tasks the tab views (:1398–1413, 1470–1506) — so a second client viewing a running task via history-click can stop it. stop_ack {accepted, tabId} is broadcast to the clicking pane before the stop event is set, so the click visibly lands even if the task dies on the next bytecode (:1431–1437, 1452–1468).

10.3 Model picker

The picker is per-tab: _tab_models[tab_id] is written by selectModel (empty tab id degrades to updating only the daemon default, commands.py:407–426) and read when a run omits a model (task_runner.py:665, server.py:394–404). When a running agent calls set_model, the display-only override reaches every watching pane via broadcast_agent_model_pick, which remembers the targets in _model_override_tabs and the model in _task_model_override[task] (json_printer.py:587–626); a pane joining mid-task catches up in subscribe_tab (:272–279). At task end, restore_model_pick hands each overridden pane its user's own pick back — a no-op for panes that never showed an override (:628–642; task_runner.py:448–468, 523). The agent side supplies only best-effort hints: _show_model_in_picker passes _tab_id/_last_task_id so the fan-out still works near teardown, after the printer's thread-local task id is cleared (sorcar_agent.py:1050–1084).

10.4 Autocomplete: the tab id as inert reply cargo

autocomplete.py holds zero tab-keyed state: staleness sequences and request tokens are keyed by connection id, the file cache by work_dir (:193–195). The request's tabId rides the asynchronous autocomplete work as inert cargo — through the _complete_queue worker tuple for ghost and completions (commands.py:801–805; :395–454), and as ordinary _get_files / _emit_files / refresh-closure arguments for files (:567–572, 627–670) — and is echoed on all three replies because one window shows several chat tabs over one connection but has a single ghost overlay and a single @-picker element: "the tab is what actually identifies the owner of the reply" (:656–659). The request side's only semantic use is in commands.py:_cmd_complete, which resolves the tab's chat context (find_by_tab, then _tab_chat_views) (commands.py:780–806). The post-task cache rescan deliberately emits no files event at all — an unsolicited reply with blank conn/tab/prefix would be accepted by every client showing a bare @ (autocomplete.py:576–625).

10.5 Direct tab-stamped utility replies

EventTriggerDeliveryWhere
filesgetFiles (@-mention) connId narrows to the window, tabId to the pane commands.py:463–488; autocomplete.py:627–670
commitMessagegenerateCommitMessage all four exit paths (not-a-repo, nothing staged, success, failure) stamp the requesting tabId server.py:1454–1520
fileContentopenFile (remote web) direct _endpoint_send reply; tabId is a pure request echo web_server.py:3943–4020
pathsExistcheckPaths (linkification probe) direct reply, tabId echo web_server.py:4019–4082
adjacent_task_eventsgetAdjacentTask (history arrows) coalesced replay envelope stamped with the requesting pane; chat resolved from the tab's own chat only — no global-latest fallback commands.py:812–843; server.py:1425–1452
error (unknown/invalid command)dispatcher echoes the command's tabId, sent to the issuing connection only server.py:427–431; sorcar.py:628–634

10.6 Talk muting: the one remaining per-connection tab structure

On a UDS (local VS Code) connection, every command's tabId is recorded in the connection's conn_state["local_tabs"] set and refcounted in WebPrinter._local_uds_tab_counts via register_local_uds_tab (sorcar.py:649–666; web_server.py:1939–1952). The sole consumer is _fanout_talk: Chromium autoplay policy keeps webviews from playing audio, so when a talk clip targets a locally-shown tab, the daemon plays it natively and stamps those tabs' copies muted; remote browser tabs get the playable copy (web_server.py:1954–1990). Since the mirror-clients refactor this bookkeeping drives nothing else — no filtering, no ownership, no lifecycle.

11. Inventory: every tab-keyed data structure

StructureTypePurposeCreated / writtenCleaned
_tab_chat_views dict[tab_id → chat_id] which chat each pane is viewing; viewer discovery at task start server.py:313; commands.py:355; server.py:988/1017 (replay) _teardown_tab_resources :868; _new_chat :895; sub-agent views popped :1019
_tab_opened_task_ids dict[tab_id → task_id] history click to resume; consumed once by the next run server.py:314, 943 popped at launch task_runner.py:741; teardown :869; new chat :896; no-task resume :945
_tab_models dict[tab_id → model] per-pane model pick server.py:315, 894 (seed); commands.py:422 _teardown_tab_resources :870
JsonPrinter._subscribers dict[task_id → set[tab_id]] the single fan-out truth: which panes receive a task's events subscribe_tab json_printer.py:266–271; register_task_ui :306 cleanup_tab :741–746; cleanup_task :809–814 (300 s linger via _subscriber_expiry, swept lazily :817–831)
JsonPrinter._task_ui dict[task_id → (tab_id, conn_id)] launch-time metadata only; never consulted for fan-out or transients register_task_ui :305 cleanup_task :808 (immediate)
JsonPrinter._model_override_tabs set[tab_id] panes currently showing a running agent's model instead of the user's pick broadcast_agent_model_pick :622; subscribe_tab catch-up :277 restore_model_pick :641; cleanup_tab :739
AgentState.tab_id attribute on task-keyed states the launching pane; searched by find_by_tab (two-pass, server-owned first) commands.py:361; task_runner.py:606; json_printer.py:355 (from agent._tab_id) dies with the state: closeTab unregister server.py:803–804; _dispose_if_closed :827; agent_task_finished for non-server-owned
agent._tab_id attribute on the agent object steering-registry bridge + sub-pane identity + toast/model-pick fallback hints task_runner.py:671 (per run); chat_sorcar_agent.py:614 (sub-agents); initialized "" worktree_sorcar_agent.py:135 overwritten on the tab's next run; never explicitly cleared
WebPrinter._local_uds_tab_counts dict[tab_id → refcount] talk-playback arbitration only (mute local webview copies) register_local_uds_tab web_server.py:1939–1946, driven by sorcar.py:662–666 unregister_local_uds_tabs :1948–1952 from _uds_handler finally :3539–3541; key deleted at zero
conn_state["local_tabs"] set[tab_id] per UDS connection deduplicates the talk-playback registration and later decrements the refcount above via unregister_local_uds_tabs; the canonical mirrored tab set itself lives in the daemon's TabRegistry (tabs.json, broadcast as tabs_state) web_server.py:3444/3509 (empty per connection); sorcar.py:649–666 local_tabs unregistered in the UDS handler's finally (web_server.py:3539–3541); both garbage-collected with the connection

For contrast, everything else is keyed by task id (_recordings, offsets, bash buffers, persistence, the agent-state registry itself), by connection id (_conn_endpoints, autocomplete staleness, _complete_seq_latest), by endpoint object (_send_locks, _pending_sends), or by work_dir (_file_cache).

12. The agent-side surface: all 22 occurrences

The three agent files touch tab ids on exactly 22 lines, all via duck-typed printer attributes (getattr with safe defaults) — the agent layer never imports server modules, and a printer-less console run degrades every site to a no-op or an empty stamp.

File · linesWhatWhy
chat_sorcar_agent.py:582, 590 parent_tab_id = own _tab_id, or the first sorted _fanout_targets viewer for nested sub-agents a renderable anchor for the frontend's tab tree (Fig 5A)
chat_sorcar_agent.py:613–614, 623 mint task-{parent_task_id}__sub_{idx}; inject agent._tab_id; store parent_tab_id in _subagent_info each parallel child streams into its own pane; the injected id is what the printer bridge copies into the child's AgentState (json_printer.py:355)
chat_sorcar_agent.py:662–663 dedupe sub_tab_id into the viewer list before subagentDone the synthetic pane may or may not already be a subscriber
chat_sorcar_agent.py:794–795, 800 new_tab broadcast carrying parent_tab_id (payload) and taskId: "" tells the frontend where to nest the child pane, at run start
sorcar_agent.py:224–225, 244, 247, 249 _broadcast_subagent_done(printer, tab_ids, model): {tab_id: vid, tabId: ""} + restore_model_pick per id stop each child pane's spinner and hand back its picker; best-effort, errors swallowed
sorcar_agent.py:1080 the file's only _tab_id read: passed with _last_task_id to broadcast_agent_model_pick fallback hints so the model-pick fan-out works off-thread / near teardown; the printer resolves watching panes itself
sorcar_agent.py:1486–1488 non-UI parallel path synthesizes task-{parent_key}__sub_{idx} from the parent task id alone closes out any child pane the frontend opened; no _tab_id read or assigned on this path
worktree_sorcar_agent.py:135 self._tab_id: str = "" field initializer safe default; set by task_runner.py:671 per run (or chat_sorcar_agent.py:614 for sub-agents), left empty for console runs
worktree_sorcar_agent.py:277, 283 commit toast via broadcast_transient(event, task_id=…, tab_id=self._tab_id), falling back to one broadcast({**event, "tabId": self._tab_id}) task id is the preferred routing key; the explicit stamp keeps the toast out of recording/persistence on the fallback path (Fig 6)

Notably absent since the recent refactors: the steering hooks read no tab id and touch no registry (they are installed unconditionally and, since the CLI removal, drain a single channel — the printer bridge, §5; the agent-local pending_user_messages queue that fed the CLI steering box is deleted); the old running_agent_state.py per-tab registry module is gone (its role is served by src/kiss/server/agent_state.py); and the old _subscribe_tab_id run-kwarg no longer exists in any agent file — launcher-tab subscription is entirely server-side.

13. Why core/ is tab-free, and what changed recently

src/kiss/core/ has zero tab-id occurrences (a case-insensitive grep over the tree returns nothing). This is by design, and it is the same design that keeps the agent surface at 22 lines: the core Printer interface and agent framework speak only in tasks, steps, and events. The tab — a purely presentational concept, "which pane of which frontend renders this" — is confined to the server layer, where the task-centric printer translates at exactly two edges: subscription (subscribe_tab) and stamping (_fanout_stamped / the transient primitives). Anything below those edges can run headless, in tests, or embedded without a frontend existing at all.

What changed recently (context for readers of older docs)

One naming misnomer (cosmetic only). In web_server.py:_stop_active_agent_tasks the shutdown sweep collects tuples of (task_id, stop_event, thread) (:5628–5632) but the unpacking variables in the log line and the join loops are named tab_id / _tab_id (:5654, 5657, 5662, and the timeout warning near :5677). The values logged are task ids. Behavior is correct — the values are only formatted into log messages — but readers (and diagrams) should label them as task ids. The signal-handler path is fine: it uses _snapshot_active_tabs(), which genuinely prints tabId(task=task_id) pairs (:1461–1497).

Summary

Tab ids in this codebase form a disciplined, single-purpose system: commands carry them down, events carry them up, and nothing in between thinks in tabs. The 507 server lines implement the two translation edges plus per-tab UI state (chat view, model pick, opened-task resume, steering queues, worktree claims); the 22 agent lines exist only where the UI structurally needs an agent's help (sub-agent panes, best-effort toasts and picker hints); and the core layer proves the abstraction holds by never mentioning tabs at all.

Generated from six line-verified audit notes (printer, task runner, server, commands, web server, agents/merge-flow) cross-checked against the working tree at branch bigrefactor. Line numbers reflect that tree; counts are from a case-insensitive grep for tab_id/tabId.