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 and the removal of the interactive diff/merge review. 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 for exactly two things: 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.

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:1120 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:1136, 1187).
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:256, 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 system_prompt/prompt {tabId:T, taskId:"", early:true} :555–567 agent.run(…, _on_task_id_allocated=partial(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, fanned out stamped per subscriber (Fig 3) task ends — _run_task finally :409–446 status {running:false, tabId:T} :428–435; _restore_user_model_pick(T) :436 per viewer V: status {running:false, tabId:V} + 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

Every outbound event funnels 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 anywhere in the server: every payload is 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 only narrower channels are keyed by connection id, never by tab id.

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 pre-addressed / transient 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, recipients never narrow Key invariants · tabId-key presence (even "") = transient: delivered to clients, never recorded/persisted — except the durable 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). Every send goes to every WSS + UDS endpoint — the mirror model — and the frontend filters panes by the stamp.

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.

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 on the AgentState 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:1122–1123 (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 :1250 ① pop agent-local pending_user_messages (CLI steering channel, printer-less runs) ② 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 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 :1292 blocks the finish tool while the agent-local queue or 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 only, and Fig 3's tabId branch records/persists a 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 pushed on unattributed_prompt_echoes, and the agent's drain hook   later emits a recordOnly copy under the freshly allocated task id — durable, but never re-rendered live (already on screen since queueing time). · Steering state is wiped at task end: _run_task's finally clears pending_user_messages and unattributed_prompt_echoes (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() starts 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 live 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:233–260): 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:1502–1517: same finally contract — fan-out viewers + synthetic task-{parent}__sub_{idx} derived purely from the parent TASK id :1511–1513; no _tab_id read or assigned on this path; skipped entirely 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 routing stamp ② task_events {events: coalesced replay, tabId: sub_tab_id} — persisted rows are tab-free, stamped once here ③ race repair: finished between the liveness probe and now? → subagentDone {tab_id: sub_tab_id, tabId: ""} — without it the pane would spin forever (live subagentDone fired before this pane subscribed) :1221–1228 _resolve_parent_tab_id_for_sub server.py:1080–1156 — three tiers, skipping sub-agent states: ① registry match by parent_task_id :1122–1126 ② unique non-subagent chat-id match :1128–1139 ③ sub_tab_id.rsplit("__sub_", 1) prefix :1141–1145 The __sub_ contract, shared by three layers · Agent (chat_sorcar_agent.py:613, sorcar_agent.py:1511) 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 immediate 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…" :305–310 · "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 — unstamped events, no persistence 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 same event answers the user's 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 auto-discard under saved/restored is_merging, then _dispose_if_closed :575–585 → worktree_done {branch, worktreeDir, originalBranch, changedFiles, hasConflict: _check_merge_conflict(tab_id), tabId} :589–598 the tab shows Merge / Discard; a later worktreeAction {tabId} runs the executor above 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–279) The task id is the preferred routing key — the printer resolves ALL watching tabs from it; the tab id is only the fallback for calls near teardown, after the thread-local task id is cleared. Printers without the primitive get one plain broadcast({**event, "tabId": self._tab_id}) — the explicit stamp alone keeps the no-record semantics (:280–284), so a replayed conversation can never resurrect a toast. Shared notification_id updates one toast 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 (task_runner.py: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 at all: a global history-panel refresh ping (:1016) · ⑤ printer.cleanup_task(hist_id) with the 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:1120), stamps it on the run cmd :1136, filters the event stream by it :1187 — 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: every command's tabId lands in the connection's tabs_seen; UDS peers also refcount 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) (server.py:807–828) pops the state only when frontend_closed AND not busy 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 deliberately 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:1203–1219): 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:821–844, 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:836–840). 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:1631–1636) — 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:1064–1096).

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 worker queue as inert cargo and is echoed on the three replies — ghost, completions, files (:395–454, 627–670) — 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

Every command's tabId is recorded in the connection's tabs_seen; UDS (local VS Code) connections additionally refcount it 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:136 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
tabs_seen / conn_state["local_tabs"] set[tab_id] per connection first-seen dedupe feeding the refcount above web_server.py:3444/3509 (empty per connection); sorcar.py:660–665 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:234, 246, 254, 256 _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:1094 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:1511–1513 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:136 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:278, 283–284 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 drain via the printer bridge, §5); 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.