tab_id in kiss/server/ and kiss/agents/sorcar/

A code-level walkthrough of the frontend-tab routing key: where it comes from, what it keys, how it routes events, and how it is torn down. 731 occurrences across 34 files, distilled.

  1. The one idea
  2. Where tab ids come from
  3. What tab ids key (state maps)
  4. Routing: the single most important branch
  5. Task execution: how tab_id reaches the agent
  6. Viewers, sub-agents and synthetic tab ids
  7. Merge review: per-tab mutex and per-tab disk
  8. Lifecycle: claim, grace timer, deferred disposal
  9. camelCase on the wire, snake_case in Python
  10. Five things that are easy to get wrong

1. The one idea

A tab is one chat surface in a frontend: a chat tab inside the VS Code webview, a tab in the remote web app, a sorcar CLI REPL, or a synthetic api-<uuid> tab minted by the Python run() client. It is not a connection and not a task. One WebSocket/UDS connection carries many tabs; one task can be watched by many tabs.

The daemon juggles three orthogonal identifiers, and nearly every subtlety in these two packages comes from keeping them apart:

IdIdentifiesCanonical container
tabId One frontend chat surface — a routing key _RunningAgentState.running_agent_states: dict[tab_id, state]
taskId One unit of agent work (a task_history row) — a persistence key JsonPrinter._recordings / _persist_agents / _subscribers, all keyed by task id
connId One transport connection (a VS Code window's UDS socket, a browser's WSS socket) ApiContext.conn_state, _last_active_file[conn_id], …

The contract is stated verbatim in the source (server.py:467-471, docstring of _get_tab):

``tab_id`` is purely a frontend routing key (whatever uuid the
frontend allocated for this tab); ``chat_id`` is purely the
persistence key stored on :class:`_RunningAgentState` once a
run starts.
VS Code window (one connId) tab "t7" tab "t8" tab "t9" (viewer) Browser (another connId) tab "b3" JSON cmds running_agent_states "t7" → state(agent, chat_id…) "t8" → state(…) "t9" → (none: viewer only) keyed by tab_id printer._subscribers task 42 → {"t7","t9","b3"} keyed by task_id → set of tab_ids agent._tab_id = "t7" worktree, merge dir, pending_user_messages one stamped copy per subscriber tab
Two axes: tab_id selects live per-tab state; task_id selects the set of tabs that should see a task's event stream.

2. Where tab ids come from

The server never mints a normal tab id. The frontend does (media/main.js:487, crypto.randomUUID()), and the value arrives as the JSON field "tabId" on every command. Three exceptions produce server-side ids:

FormMinted atWhy
task-{parent_task_id}__sub_{idx}chat_sorcar_agent.py:687 live parallel sub-agent gets its own registry entry
{parent_tab_id}__sub_{sub_task_id}server.py:1303 replaying persisted sub-agents; deterministic so re-clicking a parent updates tabs in place
api-{uuid4().hex}sorcar.py:1299 the headless Python run() client needs an address on a shared event firehose

Inbound values are hardened in two places. server.py:505-508 coerces a non-str tabId to "" (a list would raise TypeError in a dict lookup and kill the whole connection), and _sanitized_restored_tabs (web_server.py:4883) does the same for the restoredTabs array, capped at 32 entries.

Empty string is a guard, not a wildcard — except on the wire. In Python, tab_id == "" means “no tab”, and every handler early-returns: _cmd_run (commands.py:314), _stop_task (task_runner.py:1407), _finish_merge (merge_flow.py:332), subscribe_tab/cleanup_tab (json_printer.py:211, 416). On the event wire, "tabId": "" means the opposite — “deliver to every client, do not tab-filter” (see subagentDone below).

3. What tab ids key

StructureDeclaredTypePurpose
_RunningAgentState.running_agent_statesrunning_agent_state.py:86 dict[tab_id, state]the live agent, chat id, stop event, answer queue, worktree flags
VSCodeServer._tab_chat_viewsserver.py:345 dict[tab_id, chat_id]“which chat is this tab looking at” — exists for pure viewer tabs
VSCodeServer._tab_opened_task_idsserver.py:346 dict[tab_id, task_id]tab opened from history; consumed once by resume_from_task_id
JsonPrinter._subscribersjson_printer.py:167 dict[task_id, set[tab_id]]inverse map: which tabs receive a task's stream
JsonPrinter._model_override_tabsjson_printer.py:172 set[tab_id]tabs whose model picker currently shows an agent override
RemoteAccessServer._merge_statesweb_server.py:3441 dict[tab_id, _WebMergeState]in-flight merge review per tab
RemoteAccessServer._merge_action_locksweb_server.py:3443 dict[tab_id, asyncio.Lock]serialises merge actions on one tab across clients
RemoteAccessServer._pending_tab_closesweb_server.py:3444 dict[tab_id, TimerHandle]deferred closeTab after disconnect
RemoteAccessServer._tab_conn_ownersweb_server.py:3442 dict[tab_id, conn_id]which live connection currently owns the tab
WebPrinter._local_uds_tab_counts / _cli_tab_countsweb_server.py:1817-1818 dict[tab_id, int]refcounts driving text-to-speech playback arbitration
ApiContext.tabs_seensorcar.py:490 set[tab_id]tabs touched on one connection; drives close-on-disconnect
on-disk {artifact_root}/merge_dir/{safe_tab}/diff_merge.py:482 directoryper-tab merge artifacts and pre-task base copies

The registry lives on the state class itself, not on the server, and the server merely aliases its lock (server.py:352: self._state_lock = _RunningAgentState._registry_lock). That is what lets sub-agent spawners deep inside kiss/agents/sorcar/ serialise against the server without importing it. register() logs a WARNING when it overwrites a different live entry, and unregister(tab_id, state) takes the state object so a stale owner cannot ABA-delete its replacement (running_agent_state.py:91-147).

4. Routing: the single most important branch

Everything about event delivery follows from thirteen lines in JsonPrinter.broadcast (json_printer.py:684-696):

stamp_event_ts(event)
event.pop("recordOnly", None)
if "tabId" in event:
    if event.get("type") in ("prompt", "result") and event.get("taskId"):
        record = {k: v for k, v in event.items() if k != "tabId"}
        with self._lock:
            self._record_event(record)
        self._persist_event(record)
    return
event = self._inject_task_id(event)
with self._lock:
    self._record_event(event)
self._persist_event(event)

Read literally, the presence of tabId flips the event's entire semantics:

The fan-out is in the subclass (WebPrinter._fanout_stamped, web_server.py:1941-1952). It serialises once and splices the stamp into the JSON string, because this path runs once per streamed token:

targets = self._fanout_targets(event.get("taskId"))
if not targets:
    return
if "tabId" in event:
    event = {k: v for k, v in event.items() if k != "tabId"}   # avoid duplicate JSON keys
base = json.dumps(event)[:-1]
for tab_id in targets:
    self._send_to_ws_clients(f'{base}, "tabId": {json.dumps(tab_id)}}}')
tabId does not address a socket. A stamped frame still goes to every connected client; the client filters on tabId (main.js:4830, isForActiveTab). True point-to-point delivery is done by connId — popped and consumed at web_server.py:1870 — or by direct _endpoint_send replies. Autocomplete uses both: connId narrows to one VS Code window, tabId narrows that window to the chat tab that typed.

One place varies the content per tab rather than just the stamp: text-to-speech arbitration. _fanout_talk (web_server.py:2053-2067) builds both a normal and a "muted": true payload and picks per target, so exactly one terminal speaks (playing_cli_tab is the lexicographically first CLI tab — a deterministic tie-break).

5. Task execution: how tab_id reaches the agent

_cmd_run (commands.py:300-377) rejects an empty tab id outright, then creates or reuses the registry entry, mints the per-run stop_event and queue.Queue(maxsize=1) answer queue, resolves the chat id (explicit chatId → resumed view → fresh uuid), installs the worker thread under the lock, broadcasts {"type":"clear","chat_id":…,"tabId":…}, and only then starts the thread.

A second run while a thread is installed does not spawn a second thread — the prompt is appended to tab.pending_user_messages and becomes mid-task steering input.

The handoff into the agent is a single line (task_runner.py:657):

tab_id = cmd.get("tabId", "")
tab = self._get_tab(tab_id)
assert tab.agent is not None
tab.agent._tab_id = tab_id
tab.agent._task_start_ms = start_ms

_tab_id is declared once, on WorktreeSorcarAgent.__init__ (worktree_sorcar_agent.py:136), and has exactly three production writers: the server line above, the sub-agent spawner, and the CLI steering daemon (which reuses the chat id, since the CLI has no webview tabs). Everything else reads it defensively as getattr(self, "_tab_id", ""). Inside the agent it powers:

Note what is not tab-keyed: ask_user_question and talk are task-scoped. The askUser broadcast carries no tab id at all; the answer queue is resolved in the opposite direction — task_id → subscriber tabs → state (task_runner.py:1653-1666) — so any viewer tab may answer, with tab_owns_answer_queue (helpers.py:25) disqualifying a co-subscriber that is busy with a different task.

Stopping

_stop_task(tab_id) reads the tab's stop_event; if the tab is a mere viewer it resolves the owner via _find_source_tab_for_viewer, which walks _subscribers for a peer tab with a live stop event whose agent._last_task_id matches the subscribed task (an anti-hijack rule). Async exception injection is guarded by _state_owns_thread(tab_id, state, thread) (task_runner.py:64-93), re-evaluated inside the registry lock before every injection, because a parallel sub-agent's task_thread is a reusable pool worker.

6. Viewers and sub-agents

“Tab B views the agent running in tab A” is implemented purely by adding B to _subscribers[task_id]. The source state keeps owning the task; every broadcast is simply duplicated with tabId=B. The entry points are:

Parallel sub-agents get synthetic ids and their own registry entries. The parent finds its own tab by reverse scan (state.agent is self), or — when the parent is itself a sub-agent whose key the frontend does not know — adopts the first real viewer tab of its own task:

# chat_sorcar_agent.py:656-668
with _RunningAgentState._registry_lock:
    for tid, state in _RunningAgentState.running_agent_states.items():
        if state.agent is self:
            parent_tab_id = tid
            break
if self._subagent_info is not None and printer is not None:
    viewer_ids = fanout(own_task_id)
    if viewer_ids:
        parent_tab_id = sorted(viewer_ids)[0]

# chat_sorcar_agent.py:687-710
sub_tab_id = f"task-{parent_task_id}__sub_{idx}"
agent._tab_id = sub_tab_id
agent._subagent_info = {"parent_task_id": …, "parent_tab_id": parent_tab_id}
sub_state = _RunningAgentState(sub_tab_id, model or "", agent=agent, chat_id=chat_id,
                               is_subagent=True, parent_task_id=…,
                               is_task_active=True, stop_event=sub_stop_event)
_RunningAgentState.register(sub_tab_id, sub_state)

The child announces itself with {"type":"new_tab","task_id":…,"parent_tab_id":…,"taskId":""}, which the webview uses to build the parent→child tab tree (and refuses if the named parent is unknown, main.js:5716). On completion it emits the deliberately dual-keyed event:

# sorcar_agent.py:255
broadcast({"type": "subagentDone", "tab_id": vid, "tabId": ""})

The two keys are not redundant. tab_id is the payload — “which tab is finishing”, read by main.js:5859 as getTab(ev.tab_id). tabId: "" is the transport field — “do not tab-filter, and do not let the per-task fan-out re-stamp and duplicate this”, because the loop already emits one copy per viewer. The same helper also calls printer.restore_model_pick(model, vid), undoing any set_model override the sub-agent applied to that tab's picker.

Resolving a sub-agent's parent tab uses a three-tier fallback that skips sub-agent states at every tier (server.py:1240-1262): match by live parent task id → unique non-subagent chat-id match → string surgery on the "__sub_" infix. Ambiguity deliberately bails out, and failure logs a WARNING rather than returning a silent "", because a blank parent_tab_id breaks the cascade-close chain.

7. Merge review: per-tab mutex, per-tab disk

There is no merge-session registry. “A merge is in progress” is one boolean on the per-tab state (running_agent_state.py:203, is_merging), and it feeds the shared busy predicate _tab_busy that gates task start, merge, discard and disposal.

Artifacts are isolated on disk by tab, and the id is hardened before it becomes a path component — because _cleanup_merge_data is a bare shutil.rmtree:

# diff_merge.py:452-460
safe = re.sub(r"[^A-Za-z0-9._-]", "_", tab_id)
if not safe.strip("."):
    safe = safe.replace(".", "_") or "_"
if safe != tab_id:
    digest = hashlib.md5(tab_id.encode("utf-8", "surrogatepass")).hexdigest()[:8]
    safe = f"{safe}-{digest}"          # keeps "a/b" and "a_b" distinct

# diff_merge.py:481-485
base = config_module._artifact_root() / "merge_dir"
return base / _safe_tab_component(tab_id) if tab_id else base

A hostile id such as "../victim" would otherwise escape the merge_dir root. Relatedly, _finish_merge("") is an explicit no-op (merge_flow.py:332) — an empty id resolves to the shared parent directory, so a frontend bug would rmtree every tab's live merge state.

Claims are taken inside the same locked section that observed the tab free, and handed back through a _PendingOutcome enum so the caller knows whether to release:

# merge_flow.py:727-745 (condensed)
with self._state_lock:
    tab = _RunningAgentState.running_agent_states.get(tab_id)
    if tab is None or not tab.use_worktree:   return _PendingOutcome.PRESENT
    if _tab_busy(tab):                        return _PendingOutcome.NOOP
    …
    if wt_agent._pending_review or not tab.auto_commit_mode:
        tab.is_merging = True
        return _PendingOutcome.PRESENT_CLAIMED

Everything merge-related is per-tab except the main working tree, which no tab owns. _any_non_wt_running() (server.py:501) is a whole-registry scan, and produces a distinct refusal message — “Another tab is running a task on the main working tree” versus “A merge … is already in progress on this tab” (merge_flow.py:1091-1106). The internal=True flag bypasses only the tab's own flags, never that cross-tab guard.

The web server adds a second layer for the same tab reached from two clients: _merge_action_locks[tab_id], acquired through a rotation-safe loop that refuses to mint a lock for an unknown tab — otherwise a client spamming random ids would grow the map without bound (web_server.py:5196-5218).

8. Lifecycle

any cmd with tabId _claim_tab(tab, conn) connection drops owner check (F4-01) 10 s grace timer _pending_tab_closes reconnect "ready" → cancel timer mergeAction "all-done" closeTab busy? → frontend_closed = True
Disconnect never closes a tab outright; it arms a timer, and only for tabs the dropping connection still owns.

Tab cleanup and task cleanup are deliberately asymmetric. cleanup_tab (json_printer.py:402-427) drops the tab from every subscriber set but touches nothing task-keyed, so a freshly opened tab can still pick up a running stream. cleanup_task keeps the subscriber set alive for a 300-second linger so post-task broadcasts — the async follow-up suggestion, for instance — still reach the right tabs.

9. camelCase on the wire, snake_case in Python

There is no conversion layer, helper, or middleware. Conversion is lexical, at the dict-key boundary, inline in each handler: inbound tab_id = cmd.get("tabId", ""), outbound a dict literal {…, "tabId": tab_id} or the string splice in the fan-out. grep '"tab_id"' in web_server.py returns zero matches.

The lone exception is subagentDone, which carries snake_case tab_id as a payload field alongside camelCase tabId as the transport field — see §6. Sub-agent metadata uses the same snake_case payload convention: new_tab carries parent_tab_id and task_id.

10. Five things that are easy to get wrong

  1. tabId is a filter token, not an address. Stamped frames are physically broadcast to all clients. Point-to-point is connId.
  2. The subscriber map is keyed by task, not by tab. _subscribers: dict[task_id, set[tab_id]] — the inverse direction of the state registry. Multi-viewer support lives entirely here.
  3. Adding a tabId to an event silently disables recording and persistence. If you want an event both routed to one tab and kept in the trajectory, it must be a prompt/result with a taskId — that is precisely why _echo_injected_prompt (commands.py:660-667) sets both.
  4. Closing a tab never stops an agent. It sets frontend_closed and waits for _tab_busy to go false. Forgetting a _dispose_if_closed call at a new lifecycle exit leaks the tab state and its worktree forever.
  5. Ownership checks are everywhere and are not paranoia. tab.agent is self, tab.task_thread is threading.current_thread(), unregister(tab_id, state), _tab_conn_owners — all guard against the same class of bug: a tab id is reusable, so the entity holding it a moment ago may no longer be the entity holding it now.

Appendix: occurrence density

FileHitsDominant role
server/web_server.py178fan-out stamping, tab claim/close lifecycle, merge state maps
server/server.py115_get_tab, tab↔chat maps, sub-agent parent resolution, disposal
server/task_runner.py109agent._tab_id handoff, status envelopes, stop, answer queues
server/merge_flow.py85is_merging claim/release, tab-stamped merge events
server/commands.py84parsing cmd["tabId"], per-command state lookup
server/json_printer.py34subscriber map, model-picker leases, cleanup_tab
server/sorcar.py31tabs_seen bookkeeping, run() demultiplexing
server/autocomplete.py28pure pass-through delivery label (state is conn_id-keyed)
server/diff_merge.py18path sanitisation, per-tab artifact directories
agents/sorcar/chat_sorcar_agent.py17synthetic sub-agent tab ids, _subscribe_tab_id
agents/sorcar/running_agent_state.py15the registry itself: register/unregister/_tab_busy
agents/sorcar/sorcar_agent.py14subagentDone, steering drain, picker
agents/sorcar/worktree_sorcar_agent.py3attribute declaration, auto-commit toast id + routing