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.
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:
| Id | Identifies | Canonical 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.
tab_id selects live per-tab state; task_id selects the set of tabs
that should see a task's event stream.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:
| Form | Minted at | Why |
|---|---|---|
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.
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).
| Structure | Declared | Type | Purpose |
|---|---|---|---|
_RunningAgentState.running_agent_states | running_agent_state.py:86 |
dict[tab_id, state] | the live agent, chat id, stop event, answer queue, worktree flags |
VSCodeServer._tab_chat_views | server.py:345 |
dict[tab_id, chat_id] | “which chat is this tab looking at” — exists for pure viewer tabs |
VSCodeServer._tab_opened_task_ids | server.py:346 |
dict[tab_id, task_id] | tab opened from history; consumed once by resume_from_task_id |
JsonPrinter._subscribers | json_printer.py:167 |
dict[task_id, set[tab_id]] | inverse map: which tabs receive a task's stream |
JsonPrinter._model_override_tabs | json_printer.py:172 |
set[tab_id] | tabs whose model picker currently shows an agent override |
RemoteAccessServer._merge_states | web_server.py:3441 |
dict[tab_id, _WebMergeState] | in-flight merge review per tab |
RemoteAccessServer._merge_action_locks | web_server.py:3443 |
dict[tab_id, asyncio.Lock] | serialises merge actions on one tab across clients |
RemoteAccessServer._pending_tab_closes | web_server.py:3444 |
dict[tab_id, TimerHandle] | deferred closeTab after disconnect |
RemoteAccessServer._tab_conn_owners | web_server.py:3442 |
dict[tab_id, conn_id] | which live connection currently owns the tab |
WebPrinter._local_uds_tab_counts / _cli_tab_counts | web_server.py:1817-1818 |
dict[tab_id, int] | refcounts driving text-to-speech playback arbitration |
ApiContext.tabs_seen | sorcar.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 |
directory | per-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).
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:
tabId → targeted system event. Delivered verbatim, never recorded,
never persisted. This is why replaying a finished conversation cannot resurrect a stale spinner or picker label.
The one exception: prompt/result events that also carry a taskId are
recorded with the tab stamp stripped — the stamp is a delivery detail, not part of the task history.tabId → task event. taskId is injected from thread-local
state, the event is recorded and persisted once, and then fanned out with one stamped copy per
subscriber tab.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).
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:
sorcar_agent.py:997). _drain_pending_user_messages looks itself up in the registry and
refuses to drain if tab.agent is not self — a stale agent must never eat a replacement's input
(sorcar_agent.py:1164-1180). The companion tool_call_guard blocks finish
while messages are still queued.set_model calls show(model_name, self._tab_id)
(sorcar_agent.py:978); the picker is per-tab, so a pick in another window is none of this tab's
business.commit_run_id = f"autocommit-{self._tab_id}-{time.time_ns()}"
(worktree_sorcar_agent.py:197) is bound via functools.partial so the “Generating commit
message…” sticky toast and its “Committed …” replacement share one id and update in place; the event carries
"tabId": self._tab_id (:267) so it lands only on the owning chat tab.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.
_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.
“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:
ChatSorcarAgent.run(..., _subscribe_tab_id=tab_id) — popped at
chat_sorcar_agent.py:803, replayed as subscribe(task_id, subscribe_tab_id) the moment the
task_history row exists (:892-893). This is where tab-routing converts into
task-routing._reattach_running_chat (server.py:1384-1472) — matches a live state by task id first
(that is what disambiguates a running sub-agent from its parent, since they share a chat_id), then by
non-subagent chat id, then subscribe_tab(source_task_id, new_tab_id)._subscribe_chat_viewers (task_runner.py:1314) — at task start, mirrors the launcher's
clear + status running=True sequence to every idle tab already viewing the chat.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.
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).
_claim_tab(tab_id, conn_id)
(web_server.py:4164), which records ownership and cancels any close timer armed by a previous owner.
In parallel, ServerApi._record_tab (sorcar.py:708) adds the id to
ctx.tabs_seen and, for UDS peers, registers it as a local player for audio arbitration.finally arms a _TAB_CLOSE_GRACE = 10.0
second timer per owned tab. Browsers cannot reliably send closeTab before the socket dies
(beforeunload writes are commonly dropped), so the timer is the authoritative signal. The ownership
test (web_server.py:3841-3852) exists so a stale connection's sweep cannot tear down a tab a
replacement connection has already claimed.ready cancels pending closes for the tab and every restored tab,
re-claims them, replays resumeSession, and — because tab-stamped merge_data is never
persisted — manually replays any in-flight merge review with _replay_merge_review
(web_server.py:4999-5064)._close_tab (server.py:872) never interrupts a running agent:
if _tab_busy(tab) it only sets frontend_closed = True. Disposal is deferred to
_dispose_if_closed(tab_id), called at every point that lowers the last lifecycle flag — end of
_run_task, and five sites in merge_flow.py. Teardown then does
_printer_cleanup_tab, pops both tab maps, and rmtrees the tab's merge dir
(server.py:963-967)._stop_active_agent_tasks iterates the registry, sets
interrupted_by_shutdown = True, and reports tabs as "<tabId>(task=<task_id>)".
That flag is the sole thing distinguishing “Task interrupted by server restart” from “Task stopped by user”
(task_runner.py:1275-1312). At boot, a new VSCodeServer clears the process-global registry
but first harvests still-live task ids so the orphan sweep does not mislabel them.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.
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.
tabId is a filter token, not an address. Stamped frames are physically broadcast
to all clients. Point-to-point is connId._subscribers: dict[task_id,
set[tab_id]] — the inverse direction of the state registry. Multi-viewer support lives entirely here.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.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.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.| File | Hits | Dominant role |
|---|---|---|
server/web_server.py | 178 | fan-out stamping, tab claim/close lifecycle, merge state maps |
server/server.py | 115 | _get_tab, tab↔chat maps, sub-agent parent resolution, disposal |
server/task_runner.py | 109 | agent._tab_id handoff, status envelopes, stop, answer queues |
server/merge_flow.py | 85 | is_merging claim/release, tab-stamped merge events |
server/commands.py | 84 | parsing cmd["tabId"], per-command state lookup |
server/json_printer.py | 34 | subscriber map, model-picker leases, cleanup_tab |
server/sorcar.py | 31 | tabs_seen bookkeeping, run() demultiplexing |
server/autocomplete.py | 28 | pure pass-through delivery label (state is conn_id-keyed) |
server/diff_merge.py | 18 | path sanitisation, per-tab artifact directories |
agents/sorcar/chat_sorcar_agent.py | 17 | synthetic sub-agent tab ids, _subscribe_tab_id |
agents/sorcar/running_agent_state.py | 15 | the registry itself: register/unregister/_tab_busy |
agents/sorcar/sorcar_agent.py | 14 | subagentDone, steering drain, picker |
agents/sorcar/worktree_sorcar_agent.py | 3 | attribute declaration, auto-commit toast id + routing |