What Tau core is
Tau core is Pi in a window, plus threads. This document is the list. Anything not on it is an extension, and safe mode (npm run start:safe) must show exactly this list and nothing more.
In core#
Transcript
- user, assistant and notice messages, streamed
- how much of a turn's work is shown:
transcriptDetail, one offocused,detailedandeverything.focusedreads a settled turn as prose — one "Worked for 2m 14s" row that opens in place, one self-replacing line while it runs, and a sentence per run of tools ("Read 3 files and ran 2 commands");detailedstops folding, opens every group and shows thinking open;everythingadds full tool output, arguments and timestamps. The default is a preference (Settings → General); the palette sets it, ⇧⌘T cycles it, and a level set from the palette belongs to the thread on screen until another thread takes the override.showThinkingmigrated todetailed - thinking as one "Thought" row before the answer ("Thinking…" while it streams), folded in
focusedand open fromdetailedupwards; the transcript keeps the reader's toggle when the row scrolls out and back - tool calls with live output, elapsed time and stop; presentation of a tool is an extension concern, its existence is not, and so is which rows fold: a failure never folds, and a batch a tool card claims (
registerToolCard) is never folded, grouped or hidden. A runtime that keeps no journal Tau reads gets its cards back after a restart when its backend offersactivityHistory(src/main/turn-activity-store.tsis the store it may use) - Pi extension dialogs: select, confirm, input, editor; notifications. A question that takes typed text takes the files waiting in the composer with its answer; the host names them by path in the answer (
src/main/answer-attachments.ts) - bounded history paging
- a thread whose runtime does not start still opens: when a backend's
openfails (its CLI is missing, say), the thread opens read-only from what its provider keeps (src/main/unavailable-thread-backend.ts), its index entry carriesruntimeError, and a banner above the transcript says why, with Try again (the next switch tries the runtime again) and Providers - a failed turn says so where it happened: the host keeps why a thread's last turn failed (
turnErroron its index entry, from Pi's error stop or a backend'sturn-settlederror) until the next prompt; the transcript ends with that error line and the rail row reads "Failed", beside the toast
What a turn's rows are is derived, not decided in a component:
src/workbench/transcript-folding.ts turns one turn's tool records into fold,
live, group and card rows and holds every rule about them — the fold's label
and duration, the trailing-tool exemption, the action classes a summary counts,
the tense the live line speaks in. WorkRows.tsx draws those rows and owns
only what a reader toggled.
Composer
- text, images, queued steering and follow-up messages, abort. A message sent during a run waits at the end of the transcript as a dashed bubble (
QueuedMessages.tsx): the arrow sends it now, the X returns it to the composer, Stop returns the whole queue,thread.steerQueuedMessagesends the oldest. The host keeps the queue (src/main/queued-messages.ts,<userData>/queued-messages.json) and sends its head when the thread settles, so it outlives the window and a restart; a queue restored on start, stopped or stopped by a limit is held until the user sends from it, and one of a thread the restart continues follows that continuation - a turn a provider's usage or rate limit stopped (
src/main/provider-limits.tsreads the message; a runtime may name the limit and its reset itself) leaves the thread Limited instead of Failed: the rail badge and the end of the transcript say when the limit resets, "Resume at reset" continues it then by itself (src/main/thread-limits.ts, kept in<userData>/thread-limits.json), "Resume now" continues it at once, and its queue waits meanwhile - the interaction mode a thread's turns run in:
defaultor one its runtime offers (plan), a capability of the backend (mode) like the thinking level; the catalog carriesmodeandmodes,set-modechanges it, a draft keeps the one its thread starts in. What a mode means and how it is shown are kits' (Plan Kit) - Edit from here under a prompt of the user's, where the runtime keeps a tree: the conversation goes back to before it and the prompt returns to the composer
- which chord sends:
sendShortcut, one of ↵, ⌘↵ once the draft has several lines, and ⌘↵, a preference of this client (Settings → General → Send with). It is core because safe mode has to be able to send; what the send chord does while a turn runs (queue or steer) is a prompt hook's answer (streamingDelivery), queueing when nobody answers - the slot inside the input frame that extensions fill with typed context (
registerComposerInline), and file attachments beside images for a runtime whose adapter declares them - chips inside the text (
ComposerChipLayer.tsx, a chunk of its own loaded after the textarea is up, withcomposer-chips.tsanduseComposerChips.tsx;composer-chip-token.tsis what the send path needs): the editor stays a textarea. A chip is a token in the draft (U+2063, two figure spaces for the icon, the label with no-break spaces,U+2063, so no line breaks inside it); while the text holds a chip, a mention or the selected skill, the textarea's glyphs turn transparent and a mirror behind it draws the same characters, one block per line, with the chips styled, so both wrap alike. Images and an inline contribution'schipsbecome tokens at the caret; Backspace takes a chip whole, the caret steps over it, a click opens its popover (details, Remove, a kit'sDetail), a chip whose token was deleted is dropped at the send, and a chip is sent as its label. Vim, Readline, history,!shell and the menus keep working on the text as before; during IME composition the textarea draws its own text - folding while scrolling: scrolling back through the transcript folds an idle one-line composer to its text line (
useComposerCollapse.ts), a key, a press or the transcript's end opens it; this client's own choice (Settings → General, client storagetau:composer-fold) - the footer under the prompt, one slim row as in the workbench design (
ComposerFooterControls.tsx,composer-footer-layout.ts,ComposerMenu.tsx): the model as a filled pill with its marks inside, the reasoning level as text (a click opens the model picker at its thinking column), the kits' row controls, one "…" menu, and the round send (K91). The menu holds "Attach files" (drag and paste take files too), "Compact context" with the share used, and everyplacement: "menu"control (access, mode, stash, service tier); the context dial comes back into the row from 75 % on. Send is always there, resting in a paler accent while there is nothing to send; while a turn runs it steers or queues (its tooltip names the chords: ↵, ⌘↵, ⌥↑, ⇧↵, $ skills, / commands, @ files), and a quiet stop (ring and square, red under the pointer) sits before it, apart from it; Esc and "Stop the run" in the palette stop too. Placeholders are short: "Ask anything, or hand it work…", "Steer, or queue a follow-up…" while a turn runs, "Answer in text…" for a question. As the composer narrows, the kits' chips drop their labels and then move into the menu; model and reasoning stay longest, so the model's name is cut only when nothing else is left. The menu's trigger answers to thedata-composer-shortcutids of what it holds. The thread's cost is in the thread's head, and there is no checkout row under the composer: a running thread's branch is a menu in its head (Workspace Kit), and a new thread chooses where it runs in "Run on" and "Branch" (K84) - a new thread is a chat (design 1k, 1o): the empty conversation says "What should <project> do next?" with one sentence in the middle, the composer sits where a thread has it (at the bottom, on a phone too), and under the heading and its sentence a row of pills (
draft-actions, K98): core's project (a click opens the project picker at the pill and moves the draft), Machines Kit's "Run on" and Workspace Kit's branch, each opening its popover (a sheet on a phone or tablet); the draft's footer holds the model, the level, "…" and send. "New thread" (⌘N, the rail's "+", the palette, a phone's button) asks for the project first, so a thread never starts in the wrong one: a popover-style picker (a bottom sheet on touch) with search, the project in context first and selected, then the most recently used, each with its icon and machine, and "Add project…" last; ↑↓ ↵ Esc. One project starts at once; none opens "Add project". The head reads "New thread" alone (K104): the pills already say project, machine and branch. Every project mark (pill, picker, rows, the phone's lists) draws the icon a kit published withsetProjectIcons(Workspace Kit's Project settings) before the host's own - questions and approvals as one card on the composer (
ExtensionPrompt.tsx, design 1n): head with the kind, who asks and "pick one/any", the question, options with a second line, "Or type an answer below" and the primary action in the card; the composer's placeholder is "Answer in text…". While a turn waits on one, its live line says so with a spinner: "Waiting for permission to edit" (or to run a command) for an approval, "Waiting for your answer" for a question - the slash,
$and@menus: one list of 32 px rows, name and description on one line, the source as a quiet note - Pi commands: extension commands,
/skill:and prompt templates, as Pi reports them - model, thinking level, context usage and compaction; what the thread has spent so far, and apart from it what a subscription covered with its API value (
UiThreadUsage.subscription, priced by core with the user'smodelPrices)
Threads
- the thread index across projects, and one live runtime per open thread
- new, resume, fork, duplicate, rename, tree navigation, recovery of a broken thread
- a new thread's draft is a row in the thread list:
src/workbench/draft-threads.tslists the draft on screen from the moment it opens, and every draft left with text or images in it, newest first;threadStore.getDrafts()reads them andDraftRowdraws one (the project line marked with a quiet "draft", the first line typed as its title, else "New thread"). Leaving an empty draft drops it; one with something in it stays until it is opened again (openDraft), discarded (discardDraft) or sent, and a new thread beside it starts a fresh draft. The first message turns it into the thread's own row in the same update. Kept drafts live in this client's storage (tau.kept-drafts.v1, text only; images stay in memory), so they are this device's and do not travel to another. The desktop rail, a tablet's list and a phone's list show them above the active threads; a phone's Back from an empty draft closes it - deleting a thread is reversible for a while:
sessions.removemoves it into the host's trash (src/main/thread-trash.ts,<userData>/thread-trash/) — a Pi session file moves there, a thread of another backend leaves the shell record its provider hands over (removeThread), never the CLI's own history — and the index drops it.sessions.restoreputs it back; the host purges an entry after 30 days (TAU_THREAD_TRASH_RETENTION_MSfor a test instance) or onsessions.purge, touches nothing outside the trash, and runsthreadDeletedonly then. What a thread is called, where it sits in the rail and whether it is archived are a kit's (Thread Rail) - the current project (working directory) as Pi sees it
- which threads were mid-turn when the host stopped: the host writes one
marker per thread to
<userData>/turns-in-flight.jsonwhen it accepts a prompt ({ sessionId, cwd, turnId, backend, startedAt, prompt }, atomically, never into the session file, which is the runtime's) and drops it when the turn ends or is cancelled. At the next start each marker is dropped before its thread is touched, so nothing is ever continued twice, and then: with Continue threads after restarts on (Settings → General,threads.continueAfterRestart, off by default) the thread is reopened off screen, its dangling tool calls are closed and it is sent "Continue the interrupted work…"; with the setting off it is repaired, told so in its own transcript, and markedinterruptedin the index until its next prompt. What "continue" means is the runtime's answer: theresumecapability says whether the continuation can be delivered without reading as the user's own message, and a runtime without that capability is only marked. An external shell a kit owns is always interrupted; the kit says so through its ownclosedhook
Workbench
- the window's frame, after the workbench design (K75): no window-wide title bar and no panel rail. The left sidebar (256 px, dragged or keyed between 208 px and the window less 640 px, kept per client) runs to the window's top edge, where macOS draws its traffic lights over a 40 px strip that is the window's handle; Windows and Linux frame the window themselves. Right of it the conversation carries its own header (
components/ThreadHeader.tsx, the drag region there): the thread's title menu, a line with branch · model mark and name · turn · cost, thetitle-barregion's kit actions (Workspace Kit's project actions, "N files changed ›", the Git action) and the stage's toggle; right of that the stage, whose tab strip starts at the top edge too. A drawer below the conversation (280 px, kept per client) holds a panel that asks for it. Shared modals. A panel moves between the drawer and a stage tab without remounting (src/renderer/use-panel-layout.ts,components/PanelHosts.tsx). A phone keeps a bar of its own over its chat (components/TitleBar.tsx): back, the title over machine (the host's name from its hello) · branch · model, whole, with turn and cost only where the bar is wider than 600 px, the kits' region and the panels it opens as sheets - the order of runtimes: Pi, then backends by the provider's
order; the host'sruntimeBackendslist is the one every picker, Providers and onboarding follow - which token set the window paints with:
system,darkorlight, applied asdata-themeon<html>; a theme package may replace the values, never the mechanism - the client profile the window draws for (
desktop,web,compact): a contribution declares which clients render it, and the workbench leaves out what this one cannot draw (ADR 0016) - the type scale per device class (ADR 0029,
src/renderer/type-scale.ts,tokens.css): a desktop draws the design's sizes; a touch client marks<html>data-device="tablet"or"phone"by its screen before the first render, which sets each text role larger, and keeps--text-scaleat the system's text size (Dynamic Type through WebKit's-apple-system-body; Android's font scale from the app's plugin, whose web view's own text zoom is off), clamped to 1–1.5 - the stage: the document area right of the conversation, what the chat's 380 px leave until its divider is dragged (the chat keeps at least 360 px), whose tabs hold files, threads, panels and the surfaces kits register kinds for. Every panel opens as a tab and comes to the front (
toolPlace,src/workbench/center-layout.ts); a panel that asks for the drawer is the one exception. The strip's right end holds a button for each panel withstageButton(Files, Terminal), thestage-barregion (Workspace Kit's "Open in"), "More tools" for the others, and the maximize. Chat and stage are side by side whenever both are open (a window short of room narrows the chat to 360 px first); only the user's own choices show one alone: the header's toggle hides the stage and shows it again as it was, and on an empty stage opens the tool last picked in the project, else Files; the maximize (the strip's button,mod+alt+shift+b, or dragging the divider well past the chat's minimum) gives the stage the whole centre, with no strip for the chat, until the strip's toggle, the keyboard or a click on a thread in the sidebar brings the chat back beside it. While the stage is hidden its tools sit in the thread header. A link opens or focuses a tab, never a second one: a file chip in a reply, an@filemention, a read or write tool row, "N files changed" (Files on its Changed view); a relative and an absolute path inside the project are one tab (stageFilePath). Each thread and each draft has a stage of its own (K72, "The stage" below): picking a thread (the rail, the palette, a thread shortcut, a phone's thread list) shows the stage it was left with beside its chat (the chat in front where only one of the two fits), and picking the one on screen brings the chat back beside a maximized stage and leaves the tabs as they are; a compact client closes its panel sheet instead. A new thread (⌘N, the rail's button, the palette, the start card, a phone's list) starts with an empty stage and puts the caret in its composer once the chat is in view - the UI primitives everything else draws with (
src/renderer/components/ui/, ontausince API 1.11.0):Menuwith arrows, Home/End, typeahead, submenus and focus back to its trigger; oneTooltipLayerfor everydata-tooltipelement (600 ms rest, instant within a 400 ms group, keyboard focus); the toast stack (ToastStoreinsrc/workbench/toast-store.ts: top right, three visible, five seconds of being seen, held while the pointer or focus is on it or the window is hidden, type icons, actions, copy; F6 moves focus into it), which every notice and the update restart use;Dialog,ConfirmDialogandPopoverwith focus trap and return;Skeleton,EmptyandSpinnersizes;MiddleTruncatefor branches and paths; anduseContextMenu, the OS's menu where the platform has one. They are Tau's own code rather than@base-ui/reactfor size (PERFORMANCE.md); the palette, the model picker and the project picker give focus back when they close, the palette to the composer when nothing had it - the window's title: what a Pi extension sets with
ctx.ui.setTitleis awindow-titlepush to every client, the host keeps the last one for a client that attaches later, and each client puts it on its page — Electron shows the page's title as the window's, a browser as the tab's. The host may run in another process than the window (ADR 0021), so nothing sets a native title directly - the app around the workbench, owned by the window's process and spoken to its page over
window-shellpushes and the client-sidewindow-actionmethod (src/shared/window-shell.ts,src/main/app-shell.ts,src/renderer/use-window-shell.tsx): the app menu (src/main/app-menu.ts: About, Check for Updates, Settings, Paste as Text, and zoom that always zooms the workbench's own page); ⌘Q held or pressed twice (src/main/quit-shortcut.ts,confirm.quit) and a question before a quit stops working threads (confirm.quitWhileRunning, not asked when the host keeps running); the updater's hourly check; on Linux, an AppImage without Chromium's sandbox offering the.debof its release and restarting from/opt/Tau(src/main/appimage-install.ts, see docs/RELEASE.md); and the release notes of the version that just started, shown once (src/main/release-notes.ts). Settings → About shows the version and the licences of every package Tau ships, from thethird-party-licenses.jsonthe build writes beside the page (vite/third-party-licenses.ts) - command palette and keybinding dispatch, with
whenclauses read from focus contexts the page marks (src/renderer/keybinding-when.ts,keybinding-context.ts) andeditableFocus, which holds while any text field has the keyboard; the chords themselves are contributions — Runtime Controls binds core's own, each kit its own,kits/keybindings/replaces them from~/.pi/agent/keybindings.jsonand adds one command per Pi extension shortcut. Settings → Keybindings records chords and hands them to the user keymap a kit registers (registerUserKeymap; Keybindings Kit writes the file), showing beforehand which live bindings would share the keys (findKeybindingCollisions, the same platform andwhenrules as the dispatcher). The defaults are indocs/keybindings.md - what the palette finds besides commands: sources extensions register (
registerPaletteSource), asked per keystroke with the thread index and a signal that aborts when the query moves on, and core's own Settings rows. The merge issrc/renderer/palette-results.ts: label matches among the commands, then the sources in order, then the Settings rows, then commands that matched only by their group - levels in the palette, a drill-down: a command or a row with a
submenuopens a level with its own search (PaletteMenu.items, asked per keystroke like a source), a breadcrumb over the field, a back button, and Backspace in an empty field to go back, each level getting its query back; rows may carry an icon, search words they do not show and a "Current" mark (menuRowsinpalette-results.ts).openCommandPalette({ menu })opens on a command's level. Core's own levels are Set model… (every runtime's models from the host's catalog cache, the thread's runtime first; another runtime's model starts a thread there), Change theme… and New thread on…, where runtimes and providers are their icons, named in the icon's label (src/renderer/settings/palette-menus.tsx) - Settings as a page of its own (
src/renderer/settings/SettingsScreen.tsx): it covers the whole window — the section column with the search and Back on the left, a bar withSettings / <page> / <scope>, the page at a readable width built from sections of rows (settings-layout.tsx:SettingsSection,SettingRow,useSetting, ontau). Escape, Back andmod+,return; the workbench stays mounted underneath,inert.openSettings(page)opens a page by id - Settings search: a field at the top of the Settings column over core's rows (a row it finds is scrolled to and marked), the pages extensions added (by label and
keywords), each extension's page and every live keybinding; arrows walk the results,/focuses it. The index and its ranking aresrc/renderer/settings/settings-search.ts, and the palette reads the same one - the levels a setting is read from: the built-in default, the host (
~/.tau/config.json) and the project (<project>/.tau/config.json), first set wins from the project down.HostConfigManager.readLayersandclear(host methodsget-config-layers,clear-config) keep the two files apart,src/shared/config-layers.tsresolves a key's value and origin, andsrc/workbench/config-layers-store.tsis the client's copy that writes to the level being edited. A row shows where its value comes from and resets or overrides it; a page withscope"project" or "both" offers the project in its head. Pi's own keys stay Pi's: they have Pi's global and project files A host half reads its own keys resolved for a project withservices.settings(cwd). - extension lifecycle on both sides: desktop extensions in the renderer, host extensions in the host
- one versioned request/push protocol between client and host, with reconnect, heartbeats, replay and per-client subscriptions (a client is sent the streams of the threads and topics it shows); Electron IPC is one transport of it, and the one generic method host extensions use travels on it
- who may connect to the host (ADR 0023, ADR 0024,
src/main/host-access.ts): the host token is the owner's; any other device asks to pair over the socket — with a single-use link's code or, for discovery, without one — and gets a token of its own only once the owner allows it on the host after comparing six digits both screens show (bound to the pinned key when the device pins; ADR 0026). Tokens are kept as hashes in<userData>/paired-clients.jsonand end after 30/90/365 days unused or never; each device is Full or Read only, andsrc/main/host-method-access.tsclassifies every host method so a Read-only device is refused every change on every call (kit commands declareaccess: "read"). Every owner window asks about a waiting device at once (src/renderer/pairing/); Settings → Connections (src/renderer/settings/ConnectionsPage.tsx) lists the host's addresses, waiting requests, the paired devices with their preset, last change and expiry, and the host-token connections, makes links (every address, the key pin and the fingerprint in the fragment, each address a CA vouches for marked, a QR code for a network address), renames a device, changes its preset or timeout, revokes one or all others — open connections close at once — and rotates the host token. Only a connection with the host token may do any of it. Network access on the same page opens listeners beside the host's own loopback one, in the running host (src/main/host-network.ts,<userData>/network.json): Local network (every interface) and Tailscale (its addresses, plus a loopback listener a proxy such astailscale serveforwards to), on fixed ports, TLS only beyond loopback, with the self-signed certificate or the user's own, re-read when it changes. Each listener carries a trust —loopback,networkorproxy— and only the loopback one grants local files and the local window; endpoints are labelled by kind (LAN,.local, Tailscale, MagicDNS, IPv6) so a device picks the one it reaches (src/main/host-endpoints.ts, host-protocol.md). A package takes part throughservices.network(src/main/host-network-contributions.ts): it holds the proxy listener open, for this run or across restarts (<userData>/network-kept.json), and publishes endpoints only it knows, which join the list, the pairing links and the accepted page origins; behind the proxy listener the tailnet user Tailscale Serve names is shown beside the client. The page draws packages' sections below Network access (registerSettingsSection), and a host command that changes who can reach the host is registered withaccess: "owner", which holds it to the host token on this machine like theconnections-*methods. While Local network listens, the host announces_tau._tcpwith its id and fingerprint through the system's own responder (dns-sd, Avahi, Windows' DNS-SD API;src/main/host-discovery.ts, the record's contract insrc/shared/discovery.ts), andconnections-discoverlooks for other hosts only when the owner asks (host-protocol.md) - the host as a system service of its machine (
src/main/host-service.ts, units inhost-service-units.ts): a LaunchAgent, a systemd user unit or a Task Scheduler task per userData that runs the sameheadless.jsa window starts. Such a host writeshost.jsonitself (service), takes over from the host it names on that host's port, and is the one a window adopts, never stops and follows to a restart; a host of another version is restarted, then repaired, once each. Settings → Connections (host methodsservice-statusfor any client,service-install,service-uninstallandservice-allow-sandboxthe owner's, classified inhost-method-access.ts) andtau servicemanage it. On Linux,install --display(service-installwith[{ display }]) adds an invisible display: an Xvfb unit the host unit wants, with a cookie,DISPLAY/XAUTHORITYin the host's environment for every shell it starts and a desktop session's Wayland variables out of it (UnsetEnvironment=in the host and window units,leaveDesktopSessionin the host for a unit an older Tau wrote; the window also runs with--ozone-platform=x11), and a window unit bound to Xvfb and the host thatClientCallsstarts throughDisplayWindow(src/main/display-window.ts) when a window-half call finds no window, and stops after 10 minutes without a call;UiHostService.displayreports it. Where the kernel restricts user namespaces, the window needs an AppArmor profile for Tau's binary (src/main/linux-sandbox.ts: detected from the process's own AppArmor label or the profile file, written and loaded by onepkexeccall in a host and onesudocall intau service, never--no-sandbox). Beside it,hostKeepAwakeholds the machine awake while any thread runs a turn (src/main/keep-awake.ts) - the machines a window knows (ADR 0025,
src/main/window-environments.ts): beside its own host, a window keeps the machines the user paired it with in<userData>/environments.json(0600, each token encrypted withsafeStorage; nothing is saved where that cannot encrypt). Adding one pairs through ADR 0024 from the window's process (environment-pairing.ts: a link's key pin, or a bare address's key pinned for the attempt, digits bound to it; an address the host flagged for a CA is checked by chain and name instead, ADR 0026). One small connection per machine and one to its own host (environment-monitor.ts: auxiliary, subscribed to nothing but the threads a stage tab looks in on, pinging) report status and recent threads, and move a saved certificate pin to the key after a hello that pin let in. The page shows one machine at a time: opening a thread or starting one on another machine attaches aWindowHostto it and loads the page again with its address, token andenvironment=<id>, with the thread or the draft's text waiting. Chromium accepts the saved machines' pinned keys for their names, and the page's own sockets to them carry noOrigin(environment-session.ts). A Bonjour search from the window's own host adds a machine without a link, pinned to its record; a saved machine follows the addresses its hello or its record names, each with its CA flag; the window can show the machine it showed last again at start; and core's "Back to this computer" returns from any machine, whatever kits it serves. The page reads it all through the client-sideenvironments-*methods and theenvironmentswindow event (Platform.environments,context.environments); the host keys of client storage get a copy per machine - the machines a host reaches for its agents (ADR 0027,
src/main/host-machines.ts): a pairing may ask for a second device, the machine's agents (companionin thepairframe); the other owner allows both in one step and revokes each alone. The window hands the agents' token to its own host (machines-add, owner only), which keeps it in<userData>/host-machines.json(0600, in the clear likehost-token) and holds its own connection per machine (EnvironmentMonitor, no index). Kits act there throughservices.machines(machinespermission):list,subscribe,callfor a kit command,requestfor the core methods inMACHINE_REQUEST_METHODSonly, andwatchfor a kit's topic events. Absent on a host in the window's process and in a worker - how busy a machine is and what it could run (
src/main/host-resources.ts,src/shared/host-resources.ts):host-resources(cores, CPU use over a few seconds, available memory, running turns, battery) andreadiness(each runtime's state from the runtime catalogs, Git and whether it hasmerge-tree, free space where worktrees go, the display), bothread, both inMACHINE_REQUEST_METHODS, answered only when asked.services.machines.requestwith the host's own id answers them here - files between hosts (
src/main/host-blobs.ts):services.machines.uploadsends a file to another machine in 8 MB pieces (blob-put,blob-commitwith size and sha256,blob-abort; Full access only), and a kit there takes it once withservices.blobs.take(machinespermission), after which it is deleted. The receiver keeps blobs in<userData>/blobs/for at most an hour: 2 GB each, 4 GB per device, never below 512 MB free disk. Absent on a host in the window's process and in a worker - which clients are attached: every transport reports its clients to one registry, so the host knows how many there are and what each claims to be; the count is published, and
services.clientson the host seam is where a kit reads it sessions.starton the host seam: an extension has core create a thread for a project, index it and deliver its first prompt, without ever taking the screen;sessions.import(API 1.15.0,src/main/session-import.ts) takes over a Pi session another machine sent: a new id, this machine's cwd in the header, and atau.remote-work/originentry after it that the index reads asUiSession.origin, the rest of the history byte for byte- Tau's tools for every runtime (ADR 0022): a local MCP endpoint in the host process (
src/main/mcp-endpoint.ts, Streamable HTTP on127.0.0.1, stateless) where kits offer the same Pi tool definitions they give Pi (services.mcp.registerTools) and gate every call (services.mcp.gate). A runtime backend asksservices.mcp.connectfor a thread's server entry — one bearer credential per thread, revoked when that thread's runtime closes — and puts it into its own MCP configuration; the credential decides which thread a call belongs to, so no tool reaches across threads - media on a turn (
src/main/turn-attachments.ts): which extension provides pictures or recordings of a thread's turns, and a list and read by thread for any other; core keeps no bytes and knows only the media type, and draws nothing - what a project's commands may reach (
src/main/host-execution-policy.ts, new in API 1.14.0): extensions provide a rule per folder (any, orloopbackwith the hosts still allowed and a reason), core merges them to the strictest — a provider that fails counts asloopback— and hands the result to extensions (services.executionPolicy.for) and to each runtime backend with its thread (HostBackendOpenContext.executionPolicy()). Core enforces nothing and knows no reason for a limit: the runtime kits apply it to their own sandboxes or refuse, and the kit that provides a limit holds Pi'sbashto it - the login shell's environment for everything the host spawns (on Windows the registry's PATH instead, docs/windows.md), and
findCommandon the seam for extensions that need a tool from the machine - watching the files the host itself reads — the package folders, the theme folders,
keybindings.json,config.json— and reloading only what changed: the one package that was edited, the themes, the config. Core re-reads nothing on anyone else's behalf:observeConfigChangeson the seam and aconfig-changedpush say what moved, and whoever owns those files decides. Tau's ownupdate-configandclear-configwrites reachobserveConfigChangeseven while watching is off, so a kit that reads its settings on the host follows them. Watching is off underextensions.watch: false(Settings → General → Reload files when they change, which applies at once),TAU_NO_WATCH=1and safe mode (src/main/config-watcher.ts,src/main/workspace-watch.ts) - enough persisted state to restore the workbench: the window puts each
thread's and each draft's stage back the way it was left — its tabs, the
active one, their order, the maximize, whether the stage was hidden, which panel
it showed and the drawer (
tau.stage.v2:thread:<id>andtau.stage.v2:draft:<draftId>, kept bysrc/workbench/thread-stages.ts) — and each project's tool last picked (tau.dock.v1:<workspace>,src/workbench/workbench-layout-state.ts); the chat's width stays per client. Tabs are stored as they are held, so a tab kind a kit adds round-trips and one that cannot be read is dropped; a file of another project and a thread the index has forgotten are dropped silently. The stage of a deleted thread goes with it, a settled thread's once it has not been opened for 30 days, a draft's once the draft is gone, and beyond 200 stages the ones opened longest ago; a page showing another machine keeps that machine's stages apart - applying a rebuild:
reloadExtensions()loads kits and packages again and the client reloads its page, without touching a single runtime and without waiting for anything, whilereloadRuntime()is the heavier path that rediscovers Pi's resources and is the only one that asks about running threads. The build result chooses: a kit's runtime half (pi.cjs) takes the runtime path, the main process or preload takes a restart, everything else takes the light one. A package refresh never replaces the runtime extensions of a runtime that is already built —registerRuntimeExtensionapplies to every runtime from then on — so a turn in flight keeps the code it started with while host commands and panels change at once
The workbench and its client#
The workbench is always a client of a host in another process. The window
starts that host, watches it and connects the renderer to its socket; the
threads belong to the host, so the window may close, crash or reload without
stopping one (ADR 0021). Thirteen
methods stay on this side — clipboard, image preview, a workspace file shared
with the page by URL (tau-ext://files/…, src/main/shared-files.ts: PDFs,
images, audio and video inside the open workspace, with byte ranges), the kit
bundles the renderer imports, the workbench rebuild, relaunch and update, a system
notification, the app icon's badge, a right-click menu the OS draws and the app around
the workbench (window-action: menu, quit, release notes) — and a kit that
needs the window's process for a native view ships a window half the host
calls with callClient.
src/workbench/ is Tau's client without a window: the thread index, the thread
on screen, transcript pages, composer scopes and drafts, notices and the host
connection, plus WorkbenchStore — the one place a host update becomes client
state — and ThreadCommands, everything the workbench does to a thread that is
one host call and a notice. It imports no React, no Electron and no browser
global, and src/workbench/workbench-boundary.test.ts fails on any of them.
Where it needs the view side it names a port, never a class.
src/renderer/ is the React binding of that client, and Platform
(src/workbench/platform.ts) is what the client needs of the machine it runs
on: clipboard, openExternal, files (only where the host's paths are this
machine's), storage, importModule, attention — a notification the OS
draws and a count on the app's icon, absent where the client has neither —
and contextMenu, a right-click menu the OS draws at a point of the page
(src/main/window-context-menu.ts: Menu.popup in the window's process);
without it, or when it refuses, the page draws its own.
Electron answers it in
src/renderer/platform-electron.ts and a browser tab in
src/web/platform-web.ts. The sandboxed Electron renderer holds no permission
to notify, so its attention asks the window's own process
(src/main/window-attention.ts: Notification, app.setBadgeCount); a tab
uses the page's Notification API and draws the count into its icon. App.tsx is bootstrap, store wiring and layout; the
three facts it cannot work out for itself — which client this is, whether kits
were left out, and how to build the platform — arrive as a ClientEnvironment
from the entry point.
There are two such entry points in src/. src/renderer/main.tsx is the Electron window;
src/web/ is the browser client a listening host serves, which reuses every
component and adds only its entry, its platform and the token handling. Below
720 px either of them lays itself out compactly — the composer at the bottom
edge, and on a phone the thread list as its home page, with a project filter as an
icon in its header and a floating New thread button that starts a draft in the filtered
project, else in the one the host last worked in (lastUsedProject, never /),
else asks; a tablet's list beside a thread starts it in that thread's project
when no filter is set, as ⌘N does everywhere (newThreadProject in
src/workbench/new-thread-project.ts). A chat is a screen over the list with Back in its bar. The list, the
app pages that claim compact (registerPage, three at most, in their order)
and Settings are the phone's main pages, with a bottom navigation (PhoneNav)
as their last row (it remembers its pages, so they are there at the next start
before the packages that register them have loaded); a chat, a page's view and a Settings section are sub-pages
without it. usePhoneNavigation derives the route (src/workbench/phone-route.ts)
from the workbench's state, and TouchLayer keeps the browser's history as the
path from the list to it (phone-history.ts), so the system's back (Android back, the iOS edge swipe
of the native shell) steps out one level and stops at the list; the address
names the route (?thread=, ?page=, ?settings=) for a reload or a link —
through body[data-profile] and src/renderer/profile-compact.css, not a second
component tree. A browser on a touch screen claims compact at any width. A
compact client on a tablet's screen (shorter side at least 600 px) and at least
720 px wide gets the desktop's arrangement instead (compactFormFor, with a
40 px hysteresis, never from height or content, and not while the page is
hidden): the thread list as a sidebar, the chat, the panels that claim
compact as stage tabs beside the chat (one of the two, with "Show chat" in
the strip, where there is no room for both), no bottom navigation. The touch pieces live in
src/renderer/touch/, a chunk only a compact layout loads: the thread list with
a swipe to settle and a long press for every action (TouchThreadList,
SwipeRow, ActionSheet), the list's header with search and More as popovers
and a floating New thread button (TouchThreadBrowser), on a phone panels that claim
compact as sheets over the thread (PanelSheet, opened from the phone's bar —
the first two by their glyphs, from three on the rest from its More menu —
and by actions.openPanel/closePanel, which reach the sheet there),
sheets that close on a pull down (sheet-drag.ts), and TouchLayer — the
height the on-screen keyboard leaves (visualViewport), a tap that shows a
message's actions, and the open thread in the address (?thread=<id>, which a
push notification opens; on a tablet back moves between the threads it held).
A touch keyboard's return key writes a newline; the send button sends. A hardware
keyboard on a touch screen (no on-screen keyboard over the page, body[data-keyboard]
unset) sends on Return and writes a newline on Shift+Return, as the desktop does. Settings
on a phone is stacked: the section list, then a page.
The hello tells a device paired Read only (ADR 0024) so, and useHostCapabilities().readOnly
carries it to kits, which disable a write with that reason instead of offering one the host refuses.
Core's own writes follow it: the composer is a note, setting rows are inert with the reason, the
palette, the title menu, the compact list and a file tab disable what changes the host (a command
or palette row stays enabled with access: "read"; the palette leaves out a group or a source
with no such row), a chord for such a command says why instead of running it, thread commands
refuse with the reason, and preferences stay on the device.
mobile/ is a third entry point, the native app (Capacitor), which puts the compact
client in a shell of its own: saved hosts, pairing by QR code or Bonjour, and a choice
of address each time it connects (mobile/src/endpoints.ts). It asks two things of the
workbench and nothing of the host: SocketTransportOptions.createSocket, so every socket
is a native one that pins the host's key (or checks an address the host flagged for a
CA), and ClientEnvironment.shell, the
host's name above the thread list and the shell's own actions (Hosts) in its More menu.
Each host gets its own view of the page's store (mobile/src/storage.ts), so drafts and
the bootstrap cache of one never show up for another.
The model picker (src/renderer/components/ModelPicker.tsx) opens as a popover
at the control that opened it (the composer's model chip, a settings field),
without a scrim; on a phone or an iPad it is a bottom sheet
with the same logic. Escape gives focus back to that control, and a choice made
from the composer hands it to the prompt. It keys everything on what the
catalog says, in three columns under one search field:
- Runtimes, as marks only (
ProviderIconStack; a second instance wears its initials): Favourites, then every runtime the host offers, each instance of one included, with a dot for a state other than ready (model-picker-rail.ts). A tooltip names each and says its state and model count. A runtime that cannot run a thread (not installed, sign-in needed, unavailable) is dimmed, not hidden; choosing it says why and offers "Install…" or "Sign in…", which opens its card under Settings → Providers. For a new thread's draft one click on a runtime that can run it is the choice: the draft moves there with what it last chose there. - Models of that runtime, one line each (
model-offerings.ts): pinned first (the favourites, the model in use and the one last chosen on that runtime, which the cursor starts on), the rest behind "Show all N" once something is pinned or the runtime lists more than eight; each runtime keeps one fold for legacy generations. A runtime with several providers (Pi) shows their marks as filters above the list. A row across runtimes (search, Favourites, Recent) wears its route's mark, a runtime's own list only the access mark, and only where it mixes providers. Context, how a model is paid and its price per million tokens (a plan's shows what the same model costs over its API; a price the user set inmodelPricesreplaces the catalog's) are no columns: a quiet detail line under the list says them for the row under the cursor. - Thinking: the levels of the model in use (or the one just chosen, from the catalog, until the thread reports it), Pi's default marked. Choosing a model keeps the picker open only while a level is still to choose; choosing a level closes it. A runtime that sets thinking itself says so here.
"Recent" (the client's own list) sits at the bottom, one click each, beside a
link to pin models in Settings. The search runs across every runtime and groups a
model's offerings under it. One menu in the search field holds the sorts
(relevance; price, every plan before every API offering, each group by its API
price; context; newest, by models.dev's releasedAt), the filters (billing,
what a model can do), "Show hidden models" and adding a provider. Keys: ↑↓ move
in a column, ← and → move between the columns (from the search field at its
start and end), ↵ chooses, ⌥↵ pins, ⌘1–9 reach the first nine favourites of any
runtime, ⌘⇧↑↓ step through the runtimes, and typing anywhere goes to the search.
A row hides its model (with a pointer; a phone shows only the star). What a
runtime hides and the order it lists its models in is modelPreferences.<runtime>
in Tau's config, a record of the config levels that Settings → Providers →
Models edits per runtime and project; favourites stay one list across runtimes
(offeringKey: provider/id for Pi, <runtime>:provider/id otherwise). The host
keeps every runtime's catalog, Pi's too, whether or not a thread of it ran
(src/main/runtime-catalogs.ts, stale-while-revalidate, on disk across
restarts, with price, context, inputs and billing filled from Pi's model
data), and the picker lists another runtime's models from it
(src/workbench/runtime-catalog-store.ts); a runtime that cannot run says why
(not installed, not signed in) instead of listing nothing. A runtime whose
program changed since its answer (programKey, the executable's fingerprint)
is asked again at once, and the picker's Refresh asks every runtime; Tau's
signed model catalog adds models Pi does not know yet
(src/main/model-catalog.ts), and src/main/runtime-tool-updates.ts keeps the
agent CLIs current from Settings → Runtimes (runtimes.md). Picking another
runtime's model binds a new thread's draft to that runtime, and the draft
keeps model, level and mode per runtime (runtimeSelections), so switching back
and forth loses nothing; for a thread that exists it starts a new thread in the
same project on that model, because a thread keeps its runtime, and offers the
runtime-switch commands kits register beside it (Handoff Kit's "Continue in"). A
draft bound for a runtime other than the thread on screen chooses from that
runtime's own catalog (runtime-catalog, which a backend answers through
newThreadCatalog without opening a thread): the composer's
model and thinking pickers work as they do for Pi, the draft keeps the choice
with the runtime it was made for (src/workbench/runtime-catalog-store.ts),
and newSession hands model and level to the new thread through
catalogWrite before its first prompt. A runtime that can only name its
models inside a session (Antigravity before its first one) says so, and the
thread starts on its default. One rule picks the marks everywhere
(providerMarks in src/renderer/runtime-marks.ts), at most two, and never
the model's maker: a runtime with a provider it owns shows its own mark alone
(Codex with OpenAI, the Agent SDK runtime with Anthropic, Grok with xAI,
Antigravity with Google). The runtime says which providers those are
(homeProviders on its backend, published on runtimeBackends); core knows
only that a runtime owns the provider of its own name (Cursor's cursor). Any
other pair, Pi included, shows
the access mark and the runtime's side by side, the runtime's a shade quieter
(ProviderIconStack); a subscription plan wears its product's mark (Pi's
openai-codex is Codex's; an Anthropic plan login the Agent SDK runtime's),
unless the runtime says its plans are its own (ownPlan: Cursor, Antigravity),
where Claude on the Cursor plan stays "Cursor via Anthropic".
The tooltip names both. A list that is one runtime's (Pi's settings, the
Pi-providers list, a runtime's own list in the picker) leaves the runtime's
mark out. What the
catalog does not say — which generations are legacy, which model wears a "new"
badge for a while — lives in src/renderer/model-manifest.ts, hand maintained
and dated; an unmatched model is current. A model behind a subscription login
the runtime performs (UiModel.login, billing: "subscription") reads "incl." in the price column
(the row's accessible name says Plan); whether
that is worth a warning is a policy, and policies reach the picker and the
composer through registerModelBadge and registerComposerGate. In a new
thread's picker, Shift-click hands the model to the set an extension keeps
(registerModelSelection) instead of choosing it; without one it chooses.
The stage#
The stage is the column right of the conversation, starting at the window's
top edge with its tab strip (the design's 40 px on the side surface, the tab in
front joined to its content, an "M" on a changed file, a dot on unsaved work, a
panel's count, "All tabs" once some scroll out of view, then the tools, the
stage-bar region and the maximize). Core owns the tab strip, the
placement, the preview and pin rules and the strip's own gestures — double-click
pins a preview, the middle button and Escape close, mod+w closes the active
tab, ctrl+tab and ctrl+shift+tab move through them, and the right-click menu
offers close, close others, close to the right and pin/unpin. Two kinds of tab
are core's own, and a third belongs to whoever registered it.
A file tab shows source or the working-tree diff; core owns the tab strip
and the placement, and whoever registered the document source loads the
content. openFile(path, { line }) opens the source scrolled to a line and
marks it; the tab keeps the line with a request count, so asking for it again
scrolls there again. The header draws the commands extensions offer on the
file-tab surface (Files Kit's "Edit file"); they read the tab from
actions.activeStageTab(). A thread tab shows another thread's transcript, read-only, drawn
with the same VirtualTranscript the conversation uses and headed by the title,
status and cost the thread index carries. It exists so a sub-agent's chat can be
read without becoming the thread the composer talks to; "Take over" is the one
button that does switch — and even then the child stays out of the rail, which
never lists a thread with a parent. It reloads when the index republishes that thread's
entry — which is what the host does when a background turn settles — and polls
every two seconds only while the index says the thread is streaming. A tab whose
session the index no longer knows shows an empty state rather than an error.
WorkbenchActions.openThread(sessionId) is how an extension opens one; the
Agents Kit panel is the caller that motivated it. With { machine } (API
1.15.0) the tab reads a thread of another machine the window knows, over the
window's own connection there (RemoteThreadDocument,
environment-thread-watch.ts): subscribed only while the tab is open, read
again at each change, and "Open on <machine>" in place of "Take over". It reads whether or not a
runtime holds the thread: a released Pi thread is projected from its session
file, a thread of another backend from the shell that backend keeps for the
index, and neither opens a runtime.
An extension tab is a kind a desktop extension registered with
registerStageTab: the kind supplies the title, the glyph and the content,
core everything else. The tab is { id, kind: "extension", tabKind, params, title } with params plain JSON, so it is the same tab whatever draws it and
it survives being written to storage; the content talks back through a handle
(setTitle, setDirty, onClose), and StageTabController
(src/renderer/stage-tab-controller.ts) is the one door that closes a tab of
any kind — it asks about unsaved work, runs that tab's listeners and forgets
its handle. A kit that goes away takes its tabs with it. Terminal Kit's "open
as tab" is the shipped caller; docs/EXTENSIONS.md is the guide.
The stage belongs to the thread (K72). Switching threads writes the stage
being left at once and shows the next one's (useWorkbenchLayoutState over
ThreadStages); a draft has its own, which becomes its thread's when the first
message makes the thread (the draft's composer scope moving to the thread's is
the signal), and a draft moved to another project starts empty. A new thread
starts with an empty stage; its header's toggle opens the tool last picked in
the project. Hiding a thread's stage unmounts its tabs' content but closes nothing:
no onClose runs, handles stay, and what a tab shows lives on — a terminal's
shells keep running in the host (they end with their thread), the Preview page
is the window's one browser and is neither closed nor reloaded, and a Files Kit
buffer with unsaved work stays with its document. Each tab still reads and
writes in its own project: the project is the one its thread or draft belongs
to (stageWorkspace), as in K68 and K71. The first thread shown after the
update to per-thread stages, and otherwise a project's most recent thread,
takes the stage its project kept before (tau.stage.v1:<workspace>), with the
dock's open state and drawer; no other thread does.
Not in core#
These were extension work still inside core files when Phase 1b in PLAN.md started the move; Phase 6 finished it, and each row below names the kit under kits/ that owns it now.
| Feature | Owner today | Belongs to |
|---|---|---|
| Git status, staging, commit, push, worktrees, file tree, file reading, editors, branch labels and repository names in the thread index | Workspace Kit: kits/workspace/, a package Tau ships (ADR 0014). Host commands, its own Git cache and the project facts it supplies through describeProjects (ADR 0007); core lends the title-bar region (where its actions fold to the room they get, title-collapse.ts: labels become icons, then the project actions and "Open in" move under More, the Git action's label goes last), the header's thread-branch slot (the branch menu), the transcript footer and the document source seam for the stage. A thread's own worktree is the kit's too: a new thread's Branch section (in "Run on" and behind the draft header's branch) chooses the mode, the branch and its base before the first turn and the kit creates it through core's beforeNewThread gate, which knows nothing of Git (ADR 0017). Servers writes Git in a project only through it: repo-from-tree, commit-files-to-branch and merge-branch name tau.servers as their only caller (ADR 0020) and run one write per project at a time in git-coordinator.ts |
Workspace Kit |
| Turn checkpoints and restore | Workspace Kit on both sides: capture, restore, recovery and ref upkeep in kits/workspace/host-lifecycle.ts through the seam's lifecycle hooks and turn observer; status and the restore dialog in kits/workspace/checkpoints.tsx, and the turn's changes as a pill (kits/workspace/turn-changes.tsx): the running or latest turn's centred over the composer beside Jump to latest (region composer-controls), every earlier turn's in the transcript under its answer; hovering or clicking one opens its files, Open diff and Rewind in a popover, and a file opens the turn's diff. Each snapshot keeps HEAD; when a branch switch, pull or reset moved it during the turn, the files that step brought are left out (read from HEAD's reflog, kits/workspace/turn-attribution.ts; the turn's own commits still count), and a turn whose files cannot be split says "Branch changed" instead of a count. Records written before HEAD was kept are read again the same way. The branch menu asks before switching a checkout a turn is running in, and the header's "N files changed" counts the thread's own: a worktree's branch against its base, else the uncommitted files its turns changed (thread-changes.ts). The Git and lease engine it drives lives with it (kits/workspace/workspace-git.ts, workspace-checkpoint-lease.ts, workspace-kit-checkpoints.ts, the turn-checkpoint-* family), and kits/workspace/pi.ts is the half that captures a turn inside a Pi TUI Tau is only attached to. Core keeps only what an anchor needs: a pinned text-empty assistant entry stays in the transcript |
Workspace Kit |
| Thread rail: the sidebar, its search, the settled shelf and the project switcher | Workspace Kit's kits/workspace/navigation.tsx, filled through registerSidebar. Core owns threads and draws a row with ThreadRow from the tau API; a thread nobody has written to is a draft, drawn with DraftRow above the active threads until its first message makes it a thread; without the kit the window has no sidebar and still shows a composer and a transcript. The store's registerThreadRailOrganizer lets another kit decide the sections, the row menu and what a drag does (Thread Rail, below). A row's hover card (kits/workspace/thread-card.tsx) is the kit's too: one layer on the list opens it after a rest or on keyboard focus, and other kits add lines and sections to it through registerThreadCardSection (Machines Kit the machine, Terminal Kit running programs, Review Kit the pull requests); core only lends ThreadRow's hoverCard, which drops the row's own tooltip |
Workspace Kit |
| Review mode, diff viewer | Review Kit overlay over the store Workspace Kit publishes as tau.workspace/store; the Files and Changes panels and the turn-changes pills are Workspace Kit's (the Files panel, its documents and the follower also on a phone or tablet); with Review Kit present the Changes entry opens the review (a panel's redirect), which carries the panel's staging and Changes sections |
Review Kit |
| Stage tabs, file viewer | tabs and placement stay core (the document area); loading, changed markers and editors come from the registered document source. A thread tab is core's own content: it reads the transcript through transcript-page and draws it with core's VirtualTranscript. Any other content is a kit's: registerStageTab is the seam, and core never learns what a kind draws |
core placement, Workspace Kit file content, kits for their own kinds |
| Thread title generation | Thread Title Generator: kits/thread-titles/, a package Tau ships (ADR 0014) |
Thread Title Generator |
Installing, updating and removing extension packages: /install, /remove, /update and the Settings → Packages page |
Packages Kit: kits/packages/, a package Tau ships. The installer itself (npm, git, packages.json, signatures) stays core behind the packages permission; the kit owns the verbs, the wording and the page |
Packages Kit |
| Signals: host events, live counts and the shell-run presentation | Signals: kits/signals/, a package Tau ships (ADR 0014); a Settings page for debugging Tau itself (mod+alt+o), not a tool beside the chat; core lends the page and the useObservatory hook it reads |
Signals |
| Access gate (read-only, ask, full) | Access Kit: kits/access/, a package Tau ships (ADR 0014); the gate itself is the Pi extension in kits/access/gate.ts, and its thread-level command, which only Agents Kit may call, narrows one thread below the workbench's level; thread-level-of, which only Servers Kit may call, answers the level a thread runs at. The same decision (gateToolCall) gates Tau's tools over MCP (services.mcp.gate), so a tool asks alike whichever runtime calls it; tau_apply_thread_changes asks like an edit. On Settings → Runtimes it adds the "Before a runtime may…" cards: what each level lets a runtime do, with the level to choose |
Access Kit, on by default; approvals are Pi ctx.ui.confirm questions, over MCP the same confirm in the thread |
Preview browser: the panel, the WebContentsView over it and the preview_* tools the agent drives it with |
Preview Kit: kits/preview/, a package Tau ships (ADR 0014) — host.ts with view.ts and page-script.ts, desktop.tsx with panel.tsx, store.ts and overlay-watch.ts. Core lends the panel slot, one composer region, and src/renderer/reserved-region.ts — the rectangle a native view owns, which core's own floats keep clear of; the kit publishes it through reserveRegion on tau (ADR 0012). The panel's tool row picks an element (page-overlay.ts, run in an isolated world: selector, tag, text, box, trimmed markup and a cut-out image) or annotates the page (numbered rectangles, arrows and notes, sent as the page with them drawn and a numbered list); both land in the composer as a text-excerpt chip through Composer Context's chip service with the image beside the draft's images. It records the view to webm under the kit's stateDir (recorder.ts: a hidden page asks for display media and the session hands it the preview's frame; one-second chunks, 10 minutes or 500 MB at most) and attaches the file as a chip. Named profiles each get their own partition (persist:tau-preview-<name>; the default keeps persist:tau-preview), and the host suggests local dev servers under the address bar (ports.ts: lsof, else netstat, through findCommand; listeners under the workspace, on dev ports or run by dev programs, probed over HTTP). mod+shift+j toggles the panel. The preview_* tools reach Codex, Agent SDK and Antigravity threads over the host's MCP endpoint too (ADR 0022). While Computer Use is on, the panel switches between Browser and Screen: screen-view.tsx (loaded lazily) draws Computer Use's tau.computer-use/screen stream — the active thread's driven window, live or as its latest screenshot, under the agent's cursor from agent-cursor.tsx. The tool row's cookie button copies chosen sites' cookies from Chrome, Arc, Brave, Edge, Vivaldi, Chromium, Firefox or Safari into a profile (cookie-*.ts, run in the window half; Chromium keys from the keychain only on Import; browser-cookie-import.md has the security rules). The page has a zoom, a viewport (fill, or a fixed CSS size scaled to fit: viewport.ts) and an emulated colour scheme of its own, set from the tool row's menu, by the agent (preview_resize, preview_set_appearance) and by defaults in Settings → Preview; the page's ⌘R, ⇧⌘R, ⌘+, ⌘− and ⌘0 are taken in its before-input-event, ahead of the app menu. The agent's cursor is drawn into the page where each action landed (page-cursor.ts with E24's marks), and a recording (preview_recording_start/_stop too) draws a clean pointer, clicks and keys into the page at the frame rate Settings names. Profiles have names apart from their ids and are renamed or deleted (which clears the partition); recent pages fill the empty panel; a plain click on a web link in a reply opens here when Settings says so. While an agent drives the page or a Computer Use window and the panel is out of sight, a floating preview (mini-player.tsx) shows a picture of it above the composer: display only, larger on hover, moved between corners and resized, with jumps to the app and to the Preview; the host holds who drives (until that turn ends), so every client draws the same, and each device keeps the player's corner and width in its own client storage (mini-prefs.ts). The view lives in one Tau window on the host's machine, pinned through callClient's window option, so the panel, the agent's tools and every device reach the same page. Whatever that window shows (Settings, another thread, no Preview panel, minimized), the page keeps its size and paints for a capture: view-placement.ts sizes a hidden view by showing and hiding it in one task and, while the window is minimized or hidden, keeps the view in a window that is never shown; a view off screen is captured with DevTools' Page.captureScreenshot, since capturePage has no frame of it. A client away from the host (web, compact, a window showing another machine) gets remote-view.tsx instead: frames from the host command live-frame (JPEG at the width the client asks, only an id while unchanged, shared between clients asking at once; live-frames.ts asks only while the view is on screen and paces and sizes itself to the link) and, with Full access, input — a tap as a click, a drag as a scroll, text into the focused field (the device's field turns into a password field for a secret one) and Enter, Tab, Backspace, Escape and the arrows, as trusted DevTools input to the page (remote-input.ts; the user's decision 7). On a phone a strip above the composer (remote-panel.tsx) shows what an agent drives and opens the Preview sheet. A device's frame requests describe its screen (the view's CSS size, pixel ratio, touch; viewer.ts), and the page is laid out for it with DevTools' device metrics and touch emulation while the viewport is Fill (device-layout.ts): a device that watches takes the layout only while the host window does not show the page, takes it any time with "Fit this screen", and the host window takes it back when it shows the page again, from its own note, or 15 s after the device stopped asking; a Read-only device watches at whatever layout the page has. A page laid out for a device is never drawn in the host window (Chromium would size its surface past the panel): it paints in the never-shown stage, and the host's panel shows its pictures under a note that gives it back. The host's state says when no Tau window on its machine can draw the page (noWindow): a device then shows "<machine> has no display", pointing a Linux host without a display to tau service install --display and any other to the Tau app there, and looks again every 5 s. A tab that looks in on a thread of another machine shows that machine's page small and view only (look-in.tsx in the look-in region, through environments.readExtension: state and live-frame only) |
Preview Kit |
| Service tier, questionnaire | Service Tier is kits/service-tier/ and Questionnaires is kits/questionnaire/, packages Tau ships (ADR 0014); the questionnaire pages through its questions — the ask-user tool's, and a runtime's several questions or form fields — with its own prompt renderer over core's prompt frame |
separate packages |
| Computer use: the desktop-automation tools, how a run of them reads in the transcript, and the window the agent drives | Computer Use: kits/computer-use/, a package Tau ships (ADR 0014). The host loads the @amaster.ai/pi-computer-use npm package through loadRuntimeExtension — its driver binaries have to stay where npm put them — and the kit contributes it to every runtime unless the user configured the Pi package themselves. A second runtime extension watches every computer_use_* call and result either way (screen-feed.ts): per thread the driven window, its last window-scoped driver screenshots (never a desktop screenshot or a zoom crop) and the agent's recent inputs, placed in screenshot pixels. The desktop half publishes that stream as tau.computer-use/screen; the window half (window.ts) reads the Screen Recording status without prompting, records the one driven window by its id when the system already allows it, and draws the app's icon. A device away from the host drives that window through remote-control.ts: screen-input runs the thread's own click, scroll, type_text or press_key at the pid and window the feed names, then looks again; screen-view-frame answers the live picture or the driver's screenshot scaled down in the window half |
Computer Use |
| Spawning sub-agents | Agents Kit: kits/agents/, a package Tau ships (ADR 0014). Its host half contributes tau_spawn_thread, tau_get_thread_status, tau_wait_for_thread, tau_send_to_thread (modes auto, queue, steer, restart), tau_cancel_thread, tau_apply_thread_changes and tau_list_threads to every runtime (spawn, send and cancel take a clientRequestId, so a retry does nothing twice; a child that finishes a turn a tool gave it while nobody waits for it wakes its parent with one message carrying its answer, through sessions.send) — to Pi as a runtime extension, to the others over the host's MCP endpoint (ADR 0022); a spawned thread works in a worktree of its own, branched from the parent's state, and the parent takes that work back with one apply (ADR 0017), through the one Workspace Kit module the kit imports; a sub-agent is an ordinary thread in the same project (ADR 0013). Its desktop half owns the Agents stage tab ("Agents N", views Running / Asks / Done: a title and a mono line per agent — model, time, its tool now or what it changed — a held question in amber as "Question", finished agents under "Done · N — from turn X", the parent's prompt count the host records at spawn), the one-line spawn card the transcript shows for a tau_spawn_thread batch ("Started 6 agents · 5 running · 1 question", through registerToolCard), which opens that tab, and publishes the lineage the navigator folds away and counts; a spawned Pi thread's commands run at a lower priority through the shellCommandPrefix option of registerRuntimeExtension, so a sandbox around them does not undo it (below, Out of sight); the kit reads its running budget and that level (priority) from the user's ~/.tau/agents.json and keeps its own link index in <userData>/kit-state/tau.agents/agents-links.json (services.stateDir) so the rail hides agent threads from the first paint. Core keeps the record the index reads: sessions.start({ parent }) writes the link entry and src/main/session-lineage.ts turns it into UiSession.parentThreadId. A project's agent definitions (.tau/agents/*.md, agent-definitions.md) are the kit's too: it reads them, lists them in the panel and in the Inspector, and starts a thread from one with its system prompt, model, tools, access and workspace — on another runtime through sessions.start({ backend }). A child may also run on another machine (machine on the tool or the definition, else Settings → Agents): it is a link of Remote Work's tau.remote-work/threads (ADR 0027), carries a "rex" chip in the panel, counts against that machine's budget (its cores), comes back as a branch through the same apply, and its thread there moves to that machine's trash once settled |
Agents Kit |
What Pi extensions draw through ctx.ui: statuses, the working message and text widgets around the composer |
Pi UI: kits/pi-ui/, a package Tau ships (ADR 0014); core lends the presentUi seam, the status line and the two composer regions |
Pi UI |
| Claude Code backend | Claude Code: kits/claude-code/, a package Tau ships (ADR 0014), registering a runtime backend through registerRuntimeBackend and driving the installed claude through the Agent SDK; core knows only Pi and offers the backend-neutral routes onEvent and ask (ADR 0005 amendments) |
Claude Code kit, shipped by default |
| Model for a kit's small job | HostExtensionServices.complete (src/main/host-completion.ts): one short answer on the user's own ~/.pi/agent model configuration, for a title, a branch name or a commit message. Core neither writes those prompts nor picks the model; the kit names one (smallCompletionModel picks a small one) or takes the user's default |
Title generator, Worktree Names, Review Kit |
| Antigravity backend | Gemini through Google's own agent: kits/antigravity/ registers a runtime backend that downloads Google's Antigravity ACP server from the official registry URL (or takes TAU_ANTIGRAVITY_ACP_COMMAND), speaks the Agent Client Protocol to it over stdio, lets the user sign in with Google inside that server, forwards the user's own MCP servers and skills, answers the agent's elicitation forms field by field, keeps tool cards across restarts, and fills its card on Settings → Providers; the same onEvent and ask routes |
Antigravity kit, shipped by default |
| Project sources: folder browsing, native folder picker, Git clone | Workspace Kit host entry (kits/workspace/host.ts) and its two sources in kits/workspace/navigation.tsx; core keeps the sources modal as the placement for registerProjectSource (openProjectSources(source) opens one source's view), and assertAllowedCloneSource stays core's because the package installer clones too. The palette's Add project… (kits/workspace/add-project-menu.tsx) browses the host's folders one level per folder, picks one natively or opens the clone form |
Workspace Kit |
| Terminals: a shell per workspace or thread, as a stage tab (or in the drawer, by the kit's setting) on desktop, web and a tablet, and as a sheet on a phone | Terminal Kit: kits/terminal/, a package Tau ships (ADR 0014). Its host half holds one node-pty session per terminal (kits/terminal/host.ts), loaded through loadDependency so the native addon stays where npm put it; zsh, bash and fish there get a pi that loads the host's session lock extension (kits/terminal/pi-session-lock.ts, startup files under the kit's state folder, the user's own files untouched), filed under the workspace the host has open and, when a thread asked, started in that thread's worktree; input and resize are commands, output and exit are pushes with byte offsets, so a reloaded client replays without drawing twice; output goes under a per-shell topic only to clients drawing that shell. Each shell keeps its last 5,000 lines for a client that reattaches, and the host names a program running in its foreground so closing can ask first. Terminals die with the workspace (afterWorkspaceClose) or the kit, never with the window. Its desktop half is the Terminal panel over xterm.js, a stage tab from the strip's Terminal button: tabs of split panes (kits/terminal/layout.ts, kept in client storage and reconciled with the host's shells), scoped to the thread on screen: its own and the project's shells are tabs (one row of chrome in the drawer and on the stage: a stage-like strip with an × per tab that scrolls in itself, then the actions, whose split buttons fold into the More menu where it is narrow; on the stage with one tab and no other threads' shells the strip is left out, the tab naming the shell, and the actions float at the corner; a pane has a header of its own only in a split or once its shell exited, and the path is the tab's tooltip), and another thread's shells keep running there behind one "in other threads" button whose menu shows one here on request. Opening the panel (the strip's Terminal button, mod+j, the phone's sheet) starts a shell when the thread and the project have none (kits/terminal/scope.ts). Split dividers drag (or move with the arrow keys), and the shares are kept with the layout. "Open as tab" moves a shell to the stage through registerStageTab; a stage tab splits like a panel tab, and closing it gives its shells back to the panel as one tab, never ending them. The host follows the directory each shell reports with OSC 7 (currentCwd): a pane and a shell without a given name are titled by it, a split starts there, an excerpt names it, and a pane inside the followed project opens it through Workspace Kit's "Open in". The terminal chords (mod+d, mod+shift+d, mod+n, mod+w, mod+], mod+[) are keybindings under terminalFocus, so they reach the command before the shell and can be rebound; mod+j puts the keyboard in the last shell of the thread on screen (showing the panel or bringing it forward, starting a shell when there is none) and, pressed in a shell, hides the panel and hands the keyboard to the composer. The xterm palette is read from the theme's tokens and read again when the theme changes; on a light ground its ANSI colours are ones that read on paper (kits/terminal/palette.ts). A selection goes to the composer as an excerpt through Composer Context's chip service, a localhost URL opens in the Preview through Preview Kit's service, and the font follows the kit's settings, else the user's Ghostty config (read only), else the platform's monospace faces; its tau.terminal/font service lets Appearance Kit show and set it under Typography. Other kits run a command in a shell the user sees through its tau.terminal/run service. A compact client registers its own panel for the same shells (kits/terminal/compact.tsx), which the phone's bar opens as a sheet: one shell at a time with a swipeable strip of tabs to switch (the active one carries its ×; other threads' shells are under ⋯), a key bar (esc, Ctrl and Alt that apply to the next key only, tab, arrows in the program's cursor mode, `~ |
/ -, paste, Ctrl-C, show or hide the keyboard; touch-keys.ts`) whose presses keep focus in the shell, a text size of the client's own, input with autocorrect, capitals and suggestions off, and a drag that scrolls the scrollback (arrow keys in a full-screen program). The sheet follows the visual viewport, so the pty resizes when the on-screen keyboard opens or closes |
| Markdown export, clipboard, image preview | src/main/index.ts, PiHost |
core (Pi has /export and /copy) |
| Project scripts: quick actions a repository checks in, the worktree setup, a script's preview | Project Scripts: kits/project-scripts/, a package Tau ships (ADR 0014). Its host half reads .tau/project.json per checkout (project-file.md, schema in docs/schemas/), runs a script with /bin/sh -c as a job whose output and exit code are pushes, runs the runOnWorktreeCreate scripts Workspace Kit asks for (worktree-created, callers tau.workspace) and watches the file itself, because core watches only what the host reads. Its desktop half is the bar above the composer with run cards, one script.<id>.run command and chord per script, problems through setProblems, the preview through Preview Kit's tau.preview/browser service and "run in a terminal" through Terminal Kit's host commands |
Project Scripts |
| Pull and merge requests: create (generated title and body, draft, template), edit, merge (deleting the branch when asked), auto-merge on and off, revert, status and checks on the rail row | Review Kit: kits/review/requests-host.ts asks the source-control provider the remote belongs to (kits/review/provider.ts is the interface, provider-registry.ts finds the provider by the remote's host or the user's choice for a self-hosted server in <stateDir>/source-hosts.json): GitHub through gh, GitLab through glab, Forgejo and Gitea through tea api, Bitbucket Cloud through its REST API with the credential Git's own helper holds, Azure DevOps through az repos; each CLI found with findCommand, no credential stored by Tau. What a provider cannot do is in its capability table (PROVIDERS in protocol.ts) and hidden on the desktop; Settings → Review lists each provider's setup and the self-hosted servers. The Git it needs (push with upstream, branch context, template from the base tree) is Workspace Kit's, reached through commands that name tau.review as caller (ADR 0020); the other way round, Workspace Kit asks branch-request (callers tau.workspace) for a branch's request, with the branch and remote it read, to base the branch diff on it and to count a squash or rebase merge for its cleanup. Auto-merge (GitHub, GitLab, Azure DevOps), revert (GitHub) and deleting the branch on merge (every provider; on GitHub afterwards, never a branch another open request is based on or the default branch) are rows of the capability table like the rest. Settings → Review holds "Delete the branch after merging", the box a merge's question starts with. The desktop half fills the Changes section and the rail-row mark Workspace Kit's store lends; core lends only ThreadRow's accessory |
Review Kit |
Chips in the composer — files by @, pull requests by #, excerpts other kits hand over — file attachments of any type, and large pastes folded into a text file |
Composer Context: kits/composer-context/, a package Tau ships (ADR 0014). Its desktop half fills core's inline slot (registerComposerInline) and publishes the chip service tau.composer-context/chips for the kits that have context to give; its host half stores attachments in <userData>/kit-state/tau.composer-context/attachments/<thread>/ and drops a thread's folder when the host purges the thread (threadDeleted), reads what a file chip points at and lists files and pull requests (gh, glab through findCommand). A chip becomes text before the prompt when it is sent; an attachment goes as a file to a runtime that opens files and as text or a path to one that does not |
Composer Context |
| Usage overview: what the threads Tau ran have used, by period, project, runtime and model; each subscription's limits; the user's model prices | Usage: kits/usage/, a package Tau ships (ADR 0014). Its host half runs in a worker and reads only what runtimes wrote down: every response in Pi's session files under services.sessionsDir (a response a fork copied counts once), summed per model and quarter hour and cached per file by size and mtime in services.stateDir (a grown file is read on from where the last read stopped), and the turns Codex, the Agent SDK runtime and Antigravity keep per thread, through a usage command each of them grants to tau.usage (ADR 0020); a thread kept before turns were counts whole by its last activity. Work the CLIs logged outside Tau counts as well: Codex, the Agent SDK runtime and OpenCode name each instance's log folders through usage-logs, and the worker reads Codex rollouts, the CLI's project files and OpenCode's database there (counts only, summed per quarter hour and cached per file in outside-usage.json, grown logs read on from their last offset, a first read in the background; bounded memory, see docs/EXTENSIONS.md); a session a Tau thread ran as counts once, with its thread, and the rest is marked outside Tau. It prices its rows through services.priceUsage, so billed cost and a subscription's API value stay apart, and asks usage-limits of Codex, the Agent SDK runtime and Pi Limits for each plan's windows (read at most every five minutes, never changing the account). A backend that does not answer is shown as not available. Its desktop half is the Usage page (registerPage, in the sidebar's foot as juicebars — a thin upright bar per plan window with what is left in the provider's colour, grouped by account, one account once across machines, its windows chosen per device with "Show in sidebar" on the page, read two seconds after start, every five minutes and after each run, a card on hover and the limits on a click; on a phone or tablet the same bars as a touch-sized strip on top of the thread list (thread-list-head), each account with its lowest rest, chosen with "Show in thread list" — a phone's bottom navigation and behind the "Usage" command; its page sidebar has this month's figure against the month before and at its pace, the filters and links to the sections, which a phone and a tablet draw on top of the page): it draws what it read last at once (kept by the client per machine, last-state.ts) and asks for the last 90 of the client's days (days, so a remote host counts the user's midnights) and the host answers them split by day, thread and model (entries, priced like the rows); the page shows what today, the last 7 and 30 days cost, billed and a plan's value side by side, how much of each plan window is left and when it resets, as Juicebar shows it (the figure and the bar are the rest, the diamond the target: what would be left now at an even pace), and what the last readings say (limit reached, runs out before the reset, reaches the target soon, below it, lasts; the host keeps 24 hours of readings in limit-history.json and the last windows of a source whose read failed), runtimes signed in to one account (by the hashed identity their kits read locally) as one entry with its costs summed per runtime, a 90-day activity calendar beside the days as columns, and the projects, models and threads that used the most, each provider in its own colour (--provider-*), with a filter for work in Tau or outside it; in a desktop window it adds the connected machines' usage and limits (environments.readExtension, one account once across machines) with a filter per machine, with the model-price editor (modelPrices in Tau's config) and the sources as views of the page. Core lends the pricing (UsagePricing: the user's prices, the runtime's, Pi's API list; UiThreadUsage.subscription) that the composer's thread cost and the rail's hover card use as well |
Usage |
| Organising the rail: pinned, active, snoozed and settled threads, dragging them between sections and within one, snooze until a time, auto-settle rules (quiet for N days, request merged or closed), archiving and deleting threads with an undo notice (and a question first: deleting asks by default, archiving and unpinning when turned on), the thread commands and chords, starting a new thread in the background (⌘↵) and one prompt to several models | Thread Rail: kits/thread-rail/, a package Tau ships (ADR 0014). Its host half keeps the meta per thread in <userData>/kit-state/tau.thread-rail/thread-meta.json, pushes each change and sweeps every five minutes, while no window is open too: a snooze that ran out wakes, and an idle thread settles by the rules in its Settings page, the request state asked of Review Kit's pr-status (callers tau.thread-rail) for a thread that has its worktree to itself and, for every thread, the requests it links (thread-requests): once all of them merged or closed the thread settles, and one still open or unknown keeps it active. Its desktop half is the organizer Workspace Kit's rail lends (registerThreadRailOrganizer), claims a new thread's prompt through claimNewThread for ⌘↵ and for a model set built with registerModelSelection, starts those threads with services.sessions.start in worktrees prepareThreadWorktree makes, and publishes the sibling groups as tau.thread-rail/siblings for the Agents panel. Core's old pin and settle lists in the preferences are handed over once and then mirrored, so the title menu's Pin and Settle keep working. Archive and Delete sit in the row menu and the title menu (the row menu is the OS's own where the client has one, useContextMenu, with the snooze presets in a submenu): an archived thread (archivedAt in the meta; a running one is refused, new work brings it back) leaves the rail and is listed under Settings → Archived by project, and Delete sends the thread to core's trash (sessions.remove), leaving the thread on screen for the newest other thread of its project first. Unpin, settle, snooze, archive and delete leave a toast with Undo on core's stack for five seconds (undo.ts: consecutive actions of one kind are one notice and are undone together; a later action of the kind, or the opposite by hand, spends the earlier undo); thread.undo takes the group back and is bound to mod+z under !terminalFocus && !editableFocus. Settings → Archived also lists the threads in core's trash, to restore or delete for good. Over a settled thread's composer a quiet bar says so and offers Un-settle (composer-above region). Settling or snoozing the thread on screen by hand opens the next active thread in the rail (nextActiveThread in meta.ts); the host's automatic settles move nobody |
Thread Rail |
| Prompt tools: stashing a draft, recalling earlier prompts, citing a reply, queue or steer while a turn runs | Prompt Tools: kits/prompt-tools/, a package Tau ships (ADR 0014). Its desktop half binds mod+s to "Stash the draft" and draws the Stash control with its count in the composer toolbar: an entry keeps the text, Composer Context's chips (through tau.composer-context/chips) and the images, per project, twenty at most, and restoring one stashes what the composer held first. ↑ in an empty composer recalls the thread's prompts, then the project's other threads' (keyDown on registerComposerInline); "Cite" on an assistant reply (registerMessageAction) puts the selection, or the reply, into the composer as a quote chip, or as a > block without Composer Context; its "While a turn runs" option answers streamingDelivery. Its host half runs in a worker and keeps the stash in <userData>/kit-state/tau.prompt-tools/stash/, and reads the project's prompts from the session files sessions.list names. Core lends those three seams, actions for a composer control and a draft's images (composerImages, setComposerImages) |
Prompt Tools |
| Line comments on a diff as context for the next prompt, split or unified diffs, hidden whitespace, files that start collapsed; rewinding to a checkpoint with or without its files | Review Kit fills the seams core's ReviewMode lends (lines, layout, ignoreWhitespace, filesStartCollapsed, toolbar, aside; DiffView takes the same line seam): a comment opens under the line its gutter button belongs to, the kit keeps them per workspace in client storage, lists them beside the diffs and hands them to Composer Context's chip service as text-excerpt chips — as text in the draft when that kit is off. Core draws the gutter button and the row under a line and knows no comment; its own review notes are gone, and the kit takes over the ones a user left once. Workspace Kit answers ignoreWhitespace with git diff --ignore-all-space, and its turn-changes pill asks how to rewind: "Keep changes" branches the conversation at the checkpoint's answer through its own rewind command and touches no file, "Revert files too" is the restore above, backup thread first |
Review Kit, Workspace Kit |
| The warning before a subscription login a vendor forbids outside its own apps (Anthropic, Google) | Subscription Login Warning: kits/subscription-login/, a package Tau ships (ADR 0014) with a desktop half and no host half. A shield before the thread title (thread-title region), a badge and a line in the model picker (registerModelBadge), and one question per provider before such a model is first chosen or sent to (registerComposerGate); the acknowledgements are the kit's own preference value. Core keeps only the UiModel.login fact and the neutral "incl." price |
Subscription Login Warning |
| Search: the project's files by content, a file by name, threads and projects from the palette | Search: kits/search/, a package Tau ships (ADR 0014). Its host half runs in a worker: ⇧⌘F's content search runs the machine's rg found with findCommand (--json, .gitignore honoured in a Git checkout or not, hidden files but never .git, 500 hits) and stops the running search when the next query arrives, and without ripgrep it walks the project itself, reading each folder's .gitignore; ⌘P ranks the project's file list, cached per project and forgotten when Workspace Kit's store reports a new Git status, with its own fuzzy scorer; the palette's thread search reads the user and assistant text of the newest session files, the thread on screen through services.transcript when it has none, and the threads of Codex, the Agent SDK runtime, Antigravity and OpenCode from an index it keeps of their kits' thread-texts answers (runtime-threads.ts: at most every five seconds, only what changed, four million characters at most, the oldest threads' text forgotten first). Its desktop half draws both dialogs from a title-bar region over the window (a phone's and a tablet's too, with finger-sized rows and a close button), opens a hit with openFile(path, { line }), offers "Go to file" to other kits as tau.search/files, and registers three palette sources: threads by title, threads by what was said in them, projects. Core lends registerPaletteSource, the settings keywords and the file tab's line |
Search |
| Notifications: a system notification, a sound and the app icon's badge when a thread finishes, fails or asks something while nobody looks at it; its in-window toasts carry the rail's status mark (done, failed, a question, a permission) | Notifications: kits/notifications/, a package Tau ships (ADR 0014). Its host half follows turns through the turn observer and questions through decorateUiPrompt (so runtime:extend, in-process), skips sub-agents, and keeps the threads with unseen news in memory (attention.ts); each client reports whether its window has focus and which thread it shows, a thread on screen in a focused window counts nothing, and otherwise one client hears of it — the one that had focus last. News that finds no client waits for the first one to report; a thread's second piece of news within five seconds notifies nobody. A question answered on any client leaves the list (the decorator's return). Its desktop half draws it through context.attention (core's Platform.attention), synthesises its two sounds with Web Audio, toasts above the composer when opted in and owns Settings → Notifications, whose rows show and reset the level each choice comes from (Tau's config, this machine's) |
Notifications |
| Codex backend, and a runtime's CLI version | Codex: kits/codex/, a package Tau ships (ADR 0014), registering a runtime backend that drives the installed codex through codex app-server over stdio (JSON-RPC, one process per live thread): threads start and resume by Codex's own thread id, text, reasoning and commands, file changes, MCP calls and web searches stream as runtime events, Codex's approval requests, questions and MCP servers' forms go through ask (a form field by field, paged by Questionnaires; a permission request as the lines it grants), tool cards are kept across restarts in codex-activity/ beside the store, Tau's access levels become Codex's approval policy and sandbox, models and reasoning efforts come from model/list. It refuses a CLI its version policy calls broken — every release older than the protocol it was built against — and warns about one it calls unsafe, keeps its store beside Pi's sessions, answers Usage Kit's usage and usage-logs and fills its card on Settings → Providers, where Claude Code and Antigravity have theirs: a settings page that names a runtime is drawn as that runtime's card there, with the CLI, its version and update, the login and a path override, instead of a nav entry of its own. Any backend may report the program it drives through version() on registerRuntimeBackend; core asks once a day, publishes the answer on runtimeBackends and says in the picker's runtime tab and in Settings → Runtimes when an update is out — Claude Code and Codex compare with npm (npmLatestVersion, cached a day), Antigravity with the release it pins. Codex and the Agent SDK runtime run several instances of their CLI — each with its own executable, home, environment and launch arguments, registered as a backend of its own (codex@work), so a thread keeps its instance and the picker shows one tab per instance; RuntimeInstanceSettings, the version policy and the lazily loaded instance setup, dialog and banner are the seams they share |
Codex kit, shipped by default |
| A thread's pull or merge request on the stage: summary, timeline, code with review threads, checks, reviewers and labels, editing the title and description, viewed files, replies and new line comments | Review Kit: kits/review/pull-request-host.ts reads and writes one request by its URL through the provider its URL names (the other hosts' shapes are in provider-forgejo.ts, provider-bitbucket.ts and provider-azure.ts; their viewed marks are the kit's own) and, for GitHub and GitLab, gh or glab, found with findCommand — gh pr view --json, gh pr diff, one GraphQL query for review threads and viewed marks, markFileAsViewed, addPullRequestReviewThreadReply, the REST line comment, gh pr comment and gh pr edit, bodies on stdin; GitLab through glab api with JSON on stdin. Reads are cached for a minute per request and dropped by any write; GitLab keeps no viewed marks, so the kit stores them in its stateDir with a fingerprint of each file's change. The desktop half registers the review.pull-request stage-tab kind (registerStageTab), opened from the Changes section's PR #n or "Open the thread's pull request"; it draws the diff with DiffView and its line seam and the text with Markdown, polls only while the tab is on screen, hands comments to Composer Context's chip service and feeds what it read back to the rail row's mark. One composer comments or submits a review (verdict, summary and the line comments held for it in client storage, one POST …/reviews on GitHub; on GitLab the comments, then approve); conversations resolve and reopen, the signed-in account's own comments are edited, reviewers and labels are added and removed. Conversations, viewed marks and GitLab's diffs and discussions are paged past 100, and a diff gh pr diff refuses (over 300 files, too large) falls back to pulls/N/files. The code view's whitespace switch is the kit's "Hide whitespace changes" option, applied to the fetched hunks. The view's header has a merge control (merge with the chosen method, arm auto-merge while checks run, or the armed merge's badge), a menu with the rest (merge now, auto-merge off, the method, link to a thread picked by search, revert) and, for a GitHub stack (/repos/…/stacks, github-stacks.ts), the layer as "2/8" with Merge stack and Rebase stack, each refused when a layer moved since the user confirmed it; pr-action, pr-stack and pr-stack-action are its commands. "Linked from N threads" lists the threads that keep the request (pr-linked-threads). The Remote tab of the Reviews page (below; opened from the sidebar's foot, a phone's bottom navigation and "Pull requests in all projects") lists every project's requests across hosts (pr-list-many, each repository once), grouped by involvement or by project, and opens a row's view as a view of the page; a thread's review.pull-requests tab lists its own project's (pr-list: gh pr list or glab api …/merge_requests) and links to the page. Both put the viewer's own first, sort by merge readiness or "Blocked on me" and narrow by state, involvement, typed qualifiers and a filter menu with the host; an open GitHub row names its stack layer. A thread keeps the requests it links in <userData>/kit-state/tau.review/thread-pull-requests.json: the Changes section lists them, a dialog links one (in the palette a level lists the project's open requests, read once a minute, and takes a typed URL or number too), the rail row counts them, and link_pull_request, unlink_pull_request and list_thread_pull_requests give the agent the same in every runtime (Pi runtime extension and MCP, ADR 0022), with a pull_request_linking section in the system prompt that asks it to link each request it works on (Pi's before_agent_start, the MCP endpoint's instructions elsewhere). Thread Rail asks thread-requests for the linked requests before it settles a thread. With "Proactive panels" on (Settings → Review, off by default), a request the thread on screen links opens as its tab, and a turn that changed at least 3 files or 50 lines opens the Changes view (the review) unless the thread links requests. Over the composer (composer-above, order 80: under the runtime banners, quick actions and Pi's widgets, over Thread Rail's settled note) a strip shows the thread's request, the branch's own or a linked one (open before ended, "+N" for the rest), tinted by its state with the --merged token for a merged one, the host as a mark with a tooltip; it reads only what the rail row already asked for, a click opens the tab and its × hides it in that thread until the request or its state changes (client storage). "Show the pull request above the composer" in Settings → Review turns it off; the strip is its own lazily evaluated module |
Review Kit |
| Editing a workspace file on the stage: save, a dot for unsaved work, a change on disk as a conflict; Markdown, HTML and CSV/TSV rendered or as source; PDFs, images, audio and video | Files: kits/files/, a package Tau ships (ADR 0014). Its desktop half registers the stage tab kind tau.files.editor, opened by "Edit file" on a file tab (the file-tab command surface), by a double-click in the Files panel (registerFileEditor on Workspace Kit's store) and restored with the stage. The editor is CodeMirror 6 in the kit's own bundle (code-editor.tsx, editor-view.ts), which scripts/build-kits.mjs builds into dist-kits/ and the renderer's budgets do not count; it is evaluated on the first open of a file, and each language mode (languages.ts: TS/JS/JSX/TSX, JSON, CSS, HTML, Markdown, Python, Rust, Go, YAML, shell, TOML) on the first file that needs it. It has line numbers, folding, bracket matching, several cursors, search and replace on mod+f, and a soft-wrap switch in the header that is also the kit's wordWrap option; its colours are Tau's tokens (editor-theme.ts). The buffer lives in the kit (document.ts), so a background tab keeps unsaved work; mod+s is "Save file" under editorFocus and stays Prompt Tools' stash everywhere else; the tab on screen asks the disk every two seconds, on window focus and after the agent's edit, write and bash tools — a clean buffer reloads, a dirty one shows "Reload from disk" or "Keep my version", and a save that names a stale mtime is refused as a conflict. Autosave after a second is an option, off by default. Markdown renders with core's Markdown, HTML in a sandbox="" frame from srcdoc, CSV/TSV as a table of 100 rows and 30 columns; the choice is kept per kind on the client. PDFs, images, audio and video load from actions.shareFile. "Open in" lists every editor Workspace Kit found and reveals in Finder, Explorer or Files, on a client whose host's files are on its own machine. A tablet (a compact client laid out as the desktop) gets the same tab, with its header at a finger's size and the text at 16 px; a phone, which draws no stage, reads a file in Workspace Kit's Files sheet instead (placement: "sheet", core's FileSource) and does not edit. Its host half runs in a worker and only forwards read, stat and write to Workspace Kit's read-file, file-stat and write-file, which name tau.files as caller (ADR 0020) and check every path. A document is keyed by project and path, and its project is the one whose stage the tab is on (render's from), never the host's open one: every call names it, and a tab without one opens and saves nothing |
Files |
Worktree operations: cleanup rules, Settings → Storage, the setup of a new thread's worktree step by step, tau app <path> from a terminal |
Workspace Kit and Project Scripts. Workspace Kit writes every worktree it creates to <userData>/kit-state/tau.workspace/worktrees.json (worktree-storage.ts) and sweeps them hourly, soon after a thread deletion a rule answers (threadDeleted) and on "Clean up now": four rules — inactive for N days, merged into the default branch, the last thread deleted (a thread in the host's trash still counts), no commits beyond the base — host-wide or per repository (Inherit/Off/Custom) in cleanup-policy.json beside it. It removes only a recorded worktree inside the repository's worktrees folder, through git worktree remove without --force, keeps the branch, and never one with uncommitted or ignored files other than node_modules, unpushed commits, an open thread or the host's own workspace (worktree-cleanup.ts is that judgement, and the Storage page shows it as the dry run: size, age, threads, what the next cleanup does, Remove by hand with a confirmation). Project Scripts tracks a worktree's setup (setup.ts): Workspace Kit reports fetching the base and creating the checkout (worktree-setup-begin/-step/-failed, callers tau.workspace), every runOnWorktreeCreate script is a step with its last four lines, and the card under the transcript (setup-card.tsx) cancels the scripts or starts the thread now. bin/tau.mjs app [path] reads <userData>/host.json, says hello with the host's token as an auxiliary client and asks Workspace Kit's app-open: an attached window opens the project with a new thread's draft (newSession({ workspace })) and the kit's window half brings it to the front; without a window the request waits for the app the command line starts |
Workspace Kit, Project Scripts |
| Appearance: a theme for the light and the dark scheme, density, contrast, panel animations, the text size, the interface, prompt and code faces, the timestamp format, a theme editor and VS Code theme import | Appearance Kit: kits/appearance/, a package Tau ships (ADR 0014). Text size (Small, Default, Large) is this device's, in client storage, and moves every role of core's type scale (--text-xs to --text-title, and code) a pixel down or up through data-text-size on <html> (--text-step, on top of the system's text size, ADR 0029); zoom (View → Zoom In, Zoom Out, Actual Size) scales the whole window. Its desktop half is Settings → Appearance (scope: "both", so density can be a project's own): the mode as three tiles that draw the window in miniature with the theme each would paint (System split in half), every theme as a card with a swatch per scheme it has — a swatch gives it that scheme, the name every scheme it has, Tau's own card resets (previews.tsx; Tau's colours are read with the kit's own sheets switched off for the moment) — the panel animation duration (0–400 ms, 0 by default) with a preview that replays it, and a Terminal font row drawn from Terminal Kit's tau.terminal/font while that kit is on, and applies the values to <html>: data-density (its stylesheet turns it into --density), data-timestamps, the --prompt-font-*, --code-font-* and --panel-motion properties core's rules read, and one stylesheet with the chosen user themes per scheme and the contrast (hairlines and quiet inks mixed toward --ink on <body>, from the tokens <html> keeps under another name). The theme editor floats over the window from a title-bar region and paints its draft there: three colours derive a palette (palette.ts), every token can be set on its own, and Save writes <themesDir>/<id>.css through the host half (a worker, save-theme), which Tau then lists like any user theme. A VS Code colour theme is read as JSONC and mapped onto the tokens with fallbacks (vscode-import.ts). Core lends themesDir, userThemes, the rows and the font properties |
Appearance Kit |
| First start: which runtimes (every registered runtime backend, instances included) and, as an optional group, which pull-request CLIs the machine has, adding the folders the agent CLIs worked in as projects, importing their earlier conversations as threads | Onboarding: kits/onboarding/, a package Tau ships (ADR 0014). Its host half runs in a worker: it finds gh and glab with findCommand and asks their version and login, asks each backend kit for the sessions its CLI kept (import-scan) and hands an import on in batches of ten (import-sessions, callers tau.onboarding), pushing progress; reading a CLI's files, checking a path against its home and writing the threads are the backend kit's. A runtime's own state comes from its kit's status (tau.<kind>, { instance } for a <kind>@<id> backend; probe when status has no login), so a path override counts. Its desktop half is a wizard overlay (registerOverlay) in three steps — Agents, Projects, Conversations — which an empty title-bar region opens on a first start (no thread, setup never finished) and /welcome or "Set up Tau…" opens again; a chosen folder is admitted with services.workspaceRef and opened with actions.openWorkspace, and "Add a folder…" is the project sources. The scan groups clones by their origin remote and leaves out linked worktrees, home, temporary folders, ~/Downloads, Codex's scratch folders and TAU_WORKTREES_DIR (folders.ts, reading .git without spawning Git). An install or a sign-in (gh, glab, a runtime's CLI, a kit's terminal sign-in step) runs through Terminal Kit's tau.terminal/run; since the overlay replaces the workbench, the wizard closes for it, a title-bar button leads back, and it reopens and asks again when the shell ends. Core lends nothing new |
Onboarding |
| Plan mode: the Build/Plan chip, a proposed plan as a card, Plan ready with Implement | Plan Kit: kits/plan/, a package Tau ships (ADR 0014). Its host half gives Pi threads the plan mode through a runtime extension (registerRuntimeExtension(…, { modes })): while a thread plans, its system prompt asks for exploration and a proposed_plan block and edit and write are refused; it also starts the thread "Implement in a new thread" hands a plan to (sessions.start). Its desktop half draws the chip where the runtime offers the mode, draws proposed_plan blocks as cards (registerMessageBlock) and shows Plan ready above the composer once a plan-mode turn settled on a plan; Implement switches the thread back to default (actions.setMode) and sends the plan (actions.submitPrompt). Codex and the Agent SDK runtime implement the mode in their own kits. Core lends the mode itself: the mode capability, the catalog's mode and modes, set-mode and the new-thread configuration's mode |
Plan Kit |
| Continuing a thread on another runtime, and bringing a fork or a sub-agent back to its parent | Handoff: kits/handoff/, a package Tau ships (ADR 0014). "Continue in…" (the title menu, the palette) opens a runtime menu before the thread title; the fork is a new thread with a lineage link, so a thread never changes its runtime (ADR 0005). Where the runtime forks its own history (Pi, the fork capability) the kit asks core to duplicate the thread and links the copy in afterFork; anywhere else it opens a draft on the chosen runtime whose handoff is a chip, and the summary is written by a small model (smallCompletionModel, never the user's default) only when the first prompt is sent, from the conversation as it stood when it was continued, and goes before that prompt as a handoff_context block. "Bring back to parent" writes what a fork or a sub-agent did since it started or was last brought back (done, decisions, the files its edits and writes named) into the parent's composer as a merge_back_context block for the user to review and send. Both blocks are drawn as folded cards in user messages (registerMessageBlock({ roles: ["user"] })); the links, the pending handoffs and the files live in <userData>/kit-state/tau.handoff/lineage.json, and the parent and the forks show before the title (thread-title region). "Continue in…" and the palette also offer the machines this host's agents may work on with Full access (machines), disabled with the reason where the thread's runtime is not ready there (readiness): the thread goes on there as an ordinary thread through Remote Work Kit's tau.remote-work/threads, from the working copy's state including uncommitted work — Pi with its history (sessions.import there), another runtime with a handoff block before the composer's draft. The window stays where it is and a toast says where the thread went; the thread here stays usable, and a banner above its composer shows the other machine's status with "Open" (the window shows that machine, ADR 0025), "Bring back" (its branch here, and the merge-back block the kit there writes on a small model, remote-merge-back over services.machines.call, into this composer) and "Merge" (only when clean) |
Handoff |
| Git odds and ends: submodules in a new worktree, keeping the default branch current, a base folder for new projects and clones that show progress and can be cancelled, publishing a repository, the user's own instructions for commit and request text, diff colours and wrapping | Workspace Kit and Review Kit. Settings → Source control (Workspace Kit, source-control-page.tsx, a project may override) chooses how a new worktree fills its submodules — the setting, else worktreeSubmodules in the checked-out branch's .tau/project.json, else recursive (worktree-submodules.ts; a failure is a failed step on the setup card and keeps the worktree) — and whether the default branch is kept current: on a project switch, a window focus and every five minutes the host fast-forwards the project and its main checkout when each is on the default branch, has an upstream, no changed or untracked file and no local commit, through merge --ff-only and nothing else (default-branch-pull.ts, once a minute per checkout at most). A clone is a host job (clone-jobs.ts: git clone --progress, stages and percent as clone-progress pushes, cancel) that writes only into a destination that did not exist and removes it again after a failure or cancel when it holds nothing but git's own files; its toast (clone-toasts.ts) cancels it and opens the project, and the folder browser and the clone start in the base folder the page sets. Review Kit's Changes section offers "Publish repository" for a checkout without a remote (publish-host.ts, publish-form.tsx): gh repo create or GitLab's API through glab, only after the form's last step sends the confirmation the command requires, then Workspace Kit's add-remote (the first remote only, callers tau.review) and a push. Settings → Review holds the commit format, the user's own instructions (added to commit and request prompts), whether a request follows the repository's template, and the diff display: data-diff-colors="blue-orange" on <html>, which the kit's stylesheet turns into diff tokens mixed from the theme's --info and --working, and wrapping through ReviewMode's wordWrap and DiffView's wrap |
Workspace Kit, Review Kit |
| OpenCode backend | OpenCode: kits/opencode/, a package Tau ships (ADR 0014), registering a runtime backend that starts opencode serve for each live thread on 127.0.0.1 with a password of its own (or connects to a server the user runs, by URL and password) and speaks its HTTP API: one OpenCode session per thread, created on the first turn and resumed by id, text, reasoning and tool parts from the event stream as runtime events until the session is idle, a steer joining the running turn, permission requests and questions (also those of OpenCode's sub-agents) through ask, Tau's access levels as the session's permission rules, the plan mode as OpenCode's plan agent, tool cards kept in opencode-activity/. The models of every provider OpenCode reports connected fill its catalog, with price, context and how each is paid for; Tau's tools reach it as a remote MCP entry in the thread server's config. Instances (opencode@work) take a home that becomes OpenCode's four XDG folders; the version is judged by a policy; Onboarding imports OpenCode's own sessions through a server over the user's data |
OpenCode kit, shipped by default |
Signing in from the window: Pi's model providers, the way Pi's /login does, and every runtime's own login on its Providers card |
Pi Providers: kits/pi-providers/, a package Tau ships (ADR 0014). Its host half lists Pi's providers through services.modelAuth (core hands a login or a key straight to Pi's auth.json, reads none back, and asks Pi's catalog again afterwards) and runs each provider's sign-in with registerSignIn: Pi's questions, its consent page and its device code become the flow's steps. Its desktop half is Pi's card on Providers, first in the runtime order: the providers Pi reaches now and from where, the ones it can sign in to or take a key for, each row opening the account rows core lends (loadSignInUi). A provider without a mark of Tau's own gets a Logo row there: its site's icon, which the host half fetches once it is set up (only the origin of its base URL, http(s) only, 5 s and 128 KiB at most, images typed by their bytes, SVG only when it only draws, no private address unless the provider is local, cached per origin in the kit's state folder), or a picture the user chooses; both are drawn into a 64 px PNG, kept in the kit's values and published with setProviderIcons. The runtime kits register the same sign-in commands for their programs: Codex over its app server (browser, device code, API key) or codex login in a terminal, the Agent SDK runtime's CLI login in a terminal (Terminal Kit's tau.terminal/run), Antigravity's four methods through the agent's authenticate |
Pi Providers; each runtime kit for its own program |
| Cursor backend | The Cursor CLI: kits/cursor/, a package Tau ships (ADR 0014), registering a runtime backend that runs cursor-agent acp for each live thread and speaks the Agent Client Protocol through kits/_acp/, the ACP client it shares with Antigravity: one Cursor session per thread, created on the first turn and loaded by id after a restart, the CLI's own login, model and reasoning effort set before each turn, the plan mode and Tau's access levels as Cursor's plan, ask and agent modes (full access answers approvals itself, read-only refuses them), Cursor's questions as a questionnaire, a created plan as a plan card, to-do lists and sub-agent tasks as tool cards, tool cards kept in cursor-activity/, Tau's tools as an ACP http MCP server. The catalog comes from cursor/list_available_models, every offer billed to the Cursor plan. Instances (cursor@work) take a home that stands for ~/.cursor with a file login; a CLI older than 2026.04.08 is never started with acp |
Cursor kit, shipped by default |
| Quota windows Pi's subscription providers report | Pi Limits: kits/pi-limits/, a package Tau ships (ADR 0014), host half only. A Pi runtime extension reads the rate-limit headers Pi hands to after_provider_response (the ChatGPT backend's x-codex-primary-*/-secondary-*, Anthropic's anthropic-ratelimit-unified-5h-*/-7d-*), keeps the latest windows per provider in services.stateDir and answers them to Usage's usage-limits, each with the hashed account its login belongs to (Pi's auth.json, Anthropic's anthropic-organization-id header). No request of its own |
Pi Limits |
| Grok backend | Grok Build's CLI: kits/grok/, a package Tau ships (ADR 0014), registering a runtime backend that runs grok agent stdio for each live thread and speaks the Agent Client Protocol through kits/_acp/ (client, thread backend and session store shared with Antigravity and Cursor): one Grok session per thread, created on the first turn and loaded by id after a restart, the CLI's own login or an XAI_API_KEY from Tau's environment, model and reasoning effort set with session/set_model before each turn, xAI's prompt_complete notification as a second way a prompt ends, Tau's access levels as the permission mode the agent starts with (full access --always-approve; ask and read-only --permission-mode default, read-only and plan mode refusing every request; a change restarts the agent and loads the session), plan mode as an instruction with the prompt and Grok's own exit_plan_mode as a plan card, its questions as a questionnaire, each turn's turn_completed usage kept per model, tool cards in grok-activity/, Tau's tools as an ACP http MCP server. The catalog comes from initialize without a sign-in (grok models for the login), billed to the Grok plan or to the API key. Instances (grok@work) take a home that becomes GROK_HOME; the plan's credit window reaches the Usage page |
Grok kit, shipped by default |
Evidence: pictures of what a turn did — the Preview's page and the window the agent drives — under the turn's answer, a viewer that plays them and saves a short video, and attach_evidence |
Evidence: kits/evidence/, a package Tau ships (ADR 0014). Its host half follows every thread's turns (registerTurnObserver): the Preview's page at a turn's start and end, after each Preview call and every ten seconds (Preview Kit's evidence-frame, granted by callers; nothing while a password field has focus), and each new driver screenshot of the driven window (Computer Use's screen-state/screen-frame). Never the whole screen. The window half (window.ts) shrinks each picture to a 960 px JPEG, a thumbnail and a grey copy the host compares, so an unchanged picture is dropped and a turn that changed nothing keeps none. Frames live under the kit's stateDir, 60 a turn and a size per thread at most, swept after the retention a project sets (services.settings) and deleted by threadDeleted. The kit offers them to other kits through core's turnAttachments, gives the agent attach_evidence for Pi and over MCP, and lends tau.evidence/capture with pause/resume for a hand-over |
Evidence |
| SnapShots: a global shortcut captures the window in front — its picture, app, title and accessibility tree — into the composer as a chip | SnapShots: kits/snapshots/, a package Tau ships (ADR 0014). The window half (window.ts) registers the shortcut with Electron's globalShortcut (off until the user turns it on in Settings → SnapShots; the host half drops it when the kit goes), reads the Screen Recording and Accessibility status without asking, finds the window in front with @crowecawcaw/xa11y loaded through the window context's loadDependency, records that one window by its id with the capture it shares with Computer Use (kits/_window-capture/) and reads its accessibility tree with bounds in the picture's pixels (limits: 10,000 nodes, 32,000 characters, 3 s). The host half keeps unsent captures under the kit's stateDir (a week at most) and hands each to the first client that shows a composer; the desktop half draws it as an inline chip with a thumbnail and sends the picture as an image and the tree as data the model is told not to obey. macOS only for now |
SnapShots |
| The local pull request: a branch against its base before a request exists, with the pictures of the turns that did the work, a description written on request, and pictures uploaded when the request is created | Review Kit: the stage tab review.local-pull-request (kits/review/local-request-*.tsx, loaded on first open), opened from the Changes section or the palette. It reads the branch through Workspace Kit's review-request-context (commits with id and time, and when the branch left its base), the checkout's turn pictures through services.turnAttachments and gives each turn to the first commit made after it ended; the checks are Project Scripts' own commands. The description is written only on a click, with the user's Review model or a small one near the thread's (smallCompletionModel), kept per checkout and branch in client storage, and holds chosen pictures as tau-evidence:// placeholders. pr-create and pr-attach-evidence upload them only after the user saw where they go: GitLab through its uploads API (glab api --form), a public GitHub repository as files on refs/tau/evidence/<branch> written through the Git data API and linked by commit (GitHub has no upload API for descriptions); a private GitHub repository and every other host keep them on this machine and they leave the text (kits/review/evidence-upload.ts) |
Review Kit |
| Handing over to the user: a sign-in, a two-factor code or a captcha the agent cannot do, and control back afterwards | Takeover: kits/takeover/, a package Tau ships (ADR 0014). Its host half gives Pi and every MCP runtime request_takeover({ reason, target?, url? }) and keeps one waiting request per thread until the user presses Done or Cancel (30 minutes at most); it publishes the list to every client (state event and command), asks Evidence Kit to pause the thread first (EVIDENCE_PAUSE_CALLERS), and blocks every Computer Use and Preview tool of any thread and runtime while one waits (Pi's tool_call hook, services.mcp.gate). Its desktop half draws the Your turn card above the composer — one button for the target (Preview Kit's jump, the Preview moved onto the stage; the driven app raised by its driver; the page in the user's own browser), a key icon with the two ways to a password (type it in the Preview, or sign in in the user's browser and bring the site's cookies over through tau.preview/cookie-import), Done and Cancel — a mark on the rail row, and a notification or toast where the card is out of sight. Away from the host's machine the card shows a live picture of the target (Preview Kit's watch) and "Take over here" opens the Preview on that device to tap and type; a Read-only device sees the card but cannot answer it |
Takeover |
| Reviewing on a phone or tablet: a recorded turn's diff, the uncommitted changes and the branch's, a comment on a line or a range into the composer, and commit, push or a new pull request only after a question | Review Kit: a panel for compact only (kits/review/compact-review.tsx, evaluated when its sheet first opens), opened from the phone's bar as a sheet (on a tablet as a stage tab), or from the pill over the composer that sums up the latest turn that changed files (kits/review/turn-pill.tsx, region composer-controls beside Jump to latest, hidden while a turn runs), which opens the sheet on that turn's files. A picker in its header chooses the latest turn (the default when the thread has one that changed files), any earlier turn, the uncommitted changes or the branch against its base; turns come from Workspace Kit's checkpoints, turn-files and turn-file-diff, the rest through the kit's own changes and file-diff. A file opens alone in DiffView, unified and wrapped, with previous and next; the line seam's button covers the whole row, so a tap selects a line, a second tap in the same file a range, and a bar names the selection and opens the comment. The comment goes into the composer as text (the compact composer has no chip strip) and the sheet closes through actions.closePanel; an unsent comment, a commit message and a request's text survive leaving the page or the sheet. Commit (and push), push and a new request (with a title and description written from the commits on request) each ask in a dialog that says what happens; a device paired Read only sees the diffs, no line can be selected, and the writes are disabled with the reason (useHostCapabilities().readOnly) |
Review Kit |
| Other machines: their threads among this machine's in the rail, each with its machine's icon before the provider marks, "Run on" for a new thread's draft, Settings → Machines (add by pairing link, address or a Bonjour search, rename, remove, show the last one again at start), the shown machine in the thread header with the way back | Machines: kits/environments/, a package Tau ships (ADR 0014), in-process. Its host half (machines, ADR 0027) reports which machines this host's agents reach and how each connection is doing, and answers whoami for another machine's agents; Settings → Machines has a switch per machine, "This computer's agents may work on …", and one in the add form, and under this computer and each machine its agents reach shows cores, CPU, free memory, running turns, each runtime's mark with its readiness, Git, free space and display (host-resources, readiness), asked when the page opens and on "Check again". choose-machine picks where new work goes when it is left to Tau (plan H §7: weight × cores × idle CPU × free memory, then limits, readiness and a turn per core), with a weight per machine under Settings → Machines → Automatic (this computer 5, others 50, 0 = never); Agents Kit asks it for machine: "auto", and "Run on: Automatic" asks it when a new thread's first prompt is sent and carries the prompt to the chosen machine, which sends it there. The desktop half draws what context.environments gives — the window's process pairs, pins, connects and moves the page (ADR 0025) — lists their threads in Workspace Kit's rail through registerRailThreads on tau.workspace/store, mounts its arrival there through registerRailSection, and on arrival opens the thread or draft the page was sent for. A search's hosts come in core's NearbyMachineList; "Run on" disables a machine that paired this window Read only. In the phone app (K106) context.environments is the app's own list of paired hosts (mobile/src/machines.ts: the shown one live, the others in short visits every two minutes and on coming to the front); the kit lists their threads in the compact thread list (registerThreadListSource), says over it which is out of reach (thread-list-head, Retry or Pair again) and offers "Run on" as a sheet |
Machines |
Tailscale HTTPS: finding Tailscale, tailscale serve for Tau with consent, the MagicDNS address with a certificate browsers trust, the warning that the machine's name becomes public |
Tailscale: kits/tailscale/, a package Tau ships (ADR 0014), in a worker. Its host half finds tailscale with findCommand (then the macOS app's and the Windows installer's paths; TAU_TAILSCALE_COMMAND names one outright), reads status --json (state, MagicDNS name, whether CertDomains covers it) and, only once HTTPS can work or a mapping exists, serve status --json. On the owner's word (commands with access: "owner") it runs serve --bg --https=<port> http://127.0.0.1:<proxyPort>, never over a port another program is served on, and serve --https=<port> --set-path=/ off to remove only Tau's path; while the mapping stands it keeps the proxy listener (keepProxy, so the host opens it at its next start before any kit runs, a service host included) and publishes https://<machine>.<tailnet>.ts.net/ through services.network, remembered in its stateDir so both come back at start without running the CLI. The CLI's output is matched, never shown or logged. Its desktop half is a section on Settings → Connections (registerSettingsSection): the state and what to do next, a switch that asks first, a consent step that names the machine that goes into the public Certificate Transparency logs with a link to rename it, and the machine name. fixtures/fake-tailscale.mjs stands in for the CLI and Serve in tests |
Tailscale |
| Push notifications to the phone app: a thread that finished or failed, a question, an agent's "your turn", while nobody is at a Tau client | Push: kits/push/, a package Tau ships (ADR 0014). The host sends them itself with the user's own keys — APNs over HTTP/2 with an ES256 provider token, FCM's HTTP v1 API with a service account's OAuth token — no relay. A paired device registers its token with register (the command learns the device from HostCommandCall.device); a revoked or expired device loses its registration through clients.devices() and devicesChanged. It follows turns and questions like Notifications does, takes "your turn" from Takeover (notify, callers tau.takeover), and asks Notifications (attended) whether someone is at a focused client that was used in the last three minutes; then it stays quiet. Content per values.tau.push.content: the title only, or the title with the first line of the agent's last message, the reason or the question. The keys live in <userData>/kit-state/tau.push/keys.json, mode 0600, not encrypted (the host has no keychain); Settings → Push takes them, owner only, and lists the devices with a test push |
Push |
Servers as a work target: a project deployed over SSH/SFTP or FTP, its targets from .vscode/sftp.json or the SSH config, deployments with rollback, server drift as a branch |
Servers: kits/servers/, a package Tau ships (ADR 0014), in-process (ADR 0028). No agent on the server: the agent works on the local copy and only the user uploads. Its state lives in <userData>/kit-state/tau.servers/targets/<workspaceId of the main checkout>/<targetId>/ (files 0600, written atomically), never in the project, and its settings exist on this machine's level only. Targets are read from .vscode/sftp.json (every variant of the VS Code extension and its forks, profiles chosen per machine in targets/<workspaceId>/profiles.json), ssh -G aliases and typed-in addresses; Settings → Servers lists a project's targets. A server project's agent commands reach only this machine and the package sources (npm, Packagist, GitHub, PyPI, …) plus hosts the user allows, or everything once the user lifts the limit (targets/<workspaceId>/network.json): the kit provides that as services.executionPolicy and runs Pi's bash under @anthropic-ai/sandbox-runtime, loaded through loadDependency (network-policy.ts, pi-network.ts). In a server project's threads the agent reads the server with server_status, server_list, server_read and server_diff, runs commands with server_exec and writes ~/tmp with server_put_tmp behind the stricter of the target's level and the thread's (Access Kit's thread-level-of); Git writes on the server are refused at every level, a local ssh/scp/rsync/lftp/curl to a target counts as server_exec, and server_propose_upload only draws a card whose click uploads (agent-tools.ts, agent-gate.ts, agent-cards.tsx). SSH and SFTP run over the system ssh with a ControlMaster per login under /tmp/tau-<uid>/, a small SFTP v3 client of the kit's own over ssh -s sftp, and an askpass bridge that turns passwords, passphrases, codes and host keys into Tau dialogs (transport-ssh.ts, sftp-client.ts, askpass.ts); FTP and FTPS run over basic-ftp, bundled into the host half, with the certificate pinned per server and plain FTP only after a question (transport-ftp.ts). Every path is checked on the server (below remotePath or ~/tmp, never .git). Passwords follow the VS Code fork: its keychain items (read once the user allows each), a passwordCommand allowed per project, or the kit's own tau-servers item (credentials.ts, keychain.ts). The mirror state is a bare repository in the target's folder (sync/mirror.ts, refs/tau/server); scan, compare and download are in sync/. "From a server…" in Add project downloads a site into a new repository whose first commit is the server state, and a folder with an sftp.json gets that commit without its files being touched (projects.ts, through Workspace Kit's repo-from-tree). Server drift is checked when the project opens, on request and before a thread's first prompt (registerComposerGate), and imported as a commit on server-drift/<date> that only a click merges (drift.ts, through Workspace Kit's commit-files-to-branch and merge-branch). An upload reads every chosen file on the server again, compares three ways against the mirror state, never overwrites a server change unasked, keeps the server's file first (refs/tau/deploy/<n>) and writes through a temporary name and a rename (deploy.ts, sync/deploy.ts); each upload and each rollback is a deployment in deployments.json, rolled back per deployment, marked committed once HEAD holds what went up, and cleaned up by age and count (rollback.ts, commit-mark.ts, cleanup.ts). Files with live credentials start unticked, and an override file the user sets up never goes up (secrets-scan.ts, live-config.ts, override.ts). The desktop half has the stage tab servers.target (not uploaded, changed on the server, history), a title-bar chip, a Changes section, the rail's targets, a mark on threads with a deployment not committed, Settings → Servers, and a compact sheet with the same upload and rollback in 44 px targets (disabled with the reason on a Read-only device) |
Servers |
| A project's work on another machine: its state (commits and uncommitted work) to a worktree there, chosen ignored files along, the result back as a branch that merges only when clean | Remote Work: kits/remote-work/, a package Tau ships (ADR 0014), in-process, on both machines (ADR 0027, plan-H §2). The sending side drives every step through services.machines; the other machine never reaches back. The checkout's state becomes one commit under refs/tau/transfer/<id> (a private index, as a spawned thread's start); the other machine keeps one bare mirror per project under ~/.tau/remote-work/repos/<key>.git (the key is the normalized origin URL, else the root commit), cloned from origin when it can read it with its own credentials, else empty, and gets a Git bundle of only what its mirror lacks (services.machines.upload, taken with services.blobs, git bundle verify before any fetch). A worktree per transfer under worktrees/<project>/<slug> on tau/<sending machine>/<slug> with tau-base (the slug is the work's name — a thread's title, an agent's name — else the transfer id; numbered when taken), admitted as a workspace under the sending side's project name (rememberProjectName, again at every start: Git alone would name a worktree of the bare mirror after repos), set up by Project Scripts' worktree-created (or the old runOnWorktreeCreate line); no hook runs there (core.hooksPath points at an empty folder) and no checkout of that machine's user is touched. Long steps are operations there, followed by topic and poll. Ignored files never go by default: Settings → Remote work offers small text, .env* and note folders per project and remembers the ticks in <userData>/kit-state/tau.remote-work/ignored-files.json; they travel inside the call. The result comes back as a bundle this side pulls piece by piece into tau/<machine>/<slug>; merge-tree --write-tree checks it and only a clean one merges (Workspace Kit's mergeBranchIntoCheckout), a conflict or uncommitted work in the way leaves the checkout as it was. Nothing is ever pushed. Threads there (H06, service tau.remote-work/threads): thread-start sends the state and starts an ordinary thread in that worktree (a prompt, or a Pi session taken over with sessions.import and its origin); here only a link stays (<userData>/kit-state/tau.remote-work/remote-links.json: machine, thread id there, transfer, base, status, cost). The other machine derives the status from the runtime, turn events and ui_prompt_* and emits it under hosted-thread/<id>; this side watches it and asks once after a reconnect, reads offline while the machine is unreachable, and refuses a machine without the thread commands with both Tau versions named. thread-send/-abort/-wait/-result/-settle (apply merges when clean, then the worktree there goes). Agents Kit (sub-agents there) and Handoff ("Continue on") start their threads through it. A thread there that asks a question raises a toast here with "Open on <machine>" and "Look in" (a stage tab read over the window's connection, openThread(id, { machine })), and a system notification while the window is not focused |
Remote Work |
| Resume with less context: an offer above the composer to compact a long thread whose prompt cache has gone cold (100k tokens, 70 minutes idle), "Keep full history", and Settings → Resume compaction to turn it back on for a runtime the user said "Don't ask again" to | Resume Compaction: kits/resume-compaction/, a package Tau ships (ADR 0014) with a desktop half and no host half. It reads contextUsage.updatedAt and promptCacheTtlMs from the thread's snapshot, which Pi reports for a Claude model and the Agent SDK runtime for its threads, compacts through actions.compactContext(), keeps its answers in Tau's config, and publishes tau.resume-compaction/opt-out for a runtime kit's own resume question |
Resume Compaction |
| Reviews: a finished thread's worktree branch as a local merge request across projects, Ready / Changes requested / Conflicts / Merged with counts and per project, Merge, "Ask thread to rebase" and a note back to the thread, the month's merges and what they cost, remote pull requests under Remote | Review Kit: the app page review.reviews (kits/review/reviews-page.tsx, loaded on first open; layout: "fill", desktop and compact, so a phone's bottom navigation reads Threads · Reviews · Usage · Settings). On a desktop its Sidebar takes the thread list's place: "Filter reviews", the states with their counts, the projects with their open reviews and Remote pull requests; the page is the table alone. Without the sidebar (hidden, a tablet, a phone) tabs and a project filter over the table pick what it lists. The branches and the merge are Workspace Kit's Git (kits/workspace/thread-branches.ts, commands thread-branches and merge-thread-branch, callers tau.review): each workspace a thread names that is a linked worktree on a branch is read against the branch its main checkout has out — commits ahead and behind, diff --numstat from the fork point, uncommitted files, merge-tree --write-tree for the conflicts, merged once the target holds commits of its own (the branch's oldest reflog entry tells a merged branch from one that never moved) — and Merge is mergeBranchIntoCheckout from agent-worktrees.ts, the path Remote Work's apply takes: merge-tree first, then merge --no-ff, refused when the tip moved since the page read it or the worktree holds work not committed. Work that came back from another machine (Remote Work's links with a result branch here) is a review too, checked with its preview and merged with its thread-settle. A branch counts once a thread works in its worktree and none there is busy; the window joins the threads (title, model, cost, age) by workspace id, and Project Scripts' last runs in the worktree are the Checks. The kit's host half keeps what Git does not know in <userData>/kit-state/tau.review/local-reviews.json: the asks sent back to a thread (services.sessions.send, or Remote Work's thread-send) — "Changes requested" until the branch moves — and the merges made from the page with what their threads cost, for the footer "Merged · N this month, $X". The page's entry counts ready and conflicting reviews plus the open pull requests of the rail's threads (useBadge); the Pull Requests page is its Remote tab |
Review Kit, Workspace Kit |
Runtime Controls is core, not a kit. The Settings screen with its
General, Models, Pi, Keybindings, Connections, Extensions and Inspector pages, its search field and the controls every page is built from, and the contributions that reach
core's own actions — the command palette, mod+, for Settings, escape to abort, mod+n,
/reload, /tree, /fork, /clone, "Set model…", "Set thinking level…" —
are the workbench itself. A window that cannot pick a model is not a usable
window, and safe mode has to be one. They live in src/renderer/settings/
(runtimeControls, activated through registry.activateCore, so it is on in
safe mode too and carries no switch). The host entry that reads and writes Pi's
keybindings.json and its extension shortcuts is the Keybindings kit's
kits/keybindings/host.ts, not core; Runtime Controls only keeps the id
tau.runtime-settings, the name Pi's keybindings arrive under.
The Pi page draws the settings Pi owns. HostConfigManager splits an
update-config patch: the keys Tau applies itself go to ~/.tau/config.json,
and the keys Pi reads and applies go to Pi's own ~/.pi/agent/settings.json
(or the project's .pi/settings.json), merging into what is already there. A
value in Tau's file that Pi owns is therefore never accepted, persisted and
then ignored — Pi's file is the one authority for those, and read lets it
win. The names differ on one point: Tau calls a model provider/modelId in
models.default, Pi wants defaultProvider and defaultModel apart.
The one thing safe mode loses with the kits is /install and the Packages
page — the package manager is a kit like any other now. Recovering from a
window that cannot install is still npm run start or an edit to
~/.tau/packages.json.
Out of sight on this machine#
Agents that work in the background should neither take the machine nor pop up in front of the user. What Tau does about it on the machine the user sits at:
-
Lower priority. A sub-agent's commands (Agents Kit) yield to the user's work; everything a command starts inherits it.
priorityin~/.tau/agents.jsonpicks the level:"low"(default):renice -n 10, on Linux plusionice -c 2 -n 7. The commands still use every core when the machine is idle. macOS has no softer clamp for a running shell (taskpolicy -c utility -pdoes nothing), so there it is the nice value alone."background": on macOStaskpolicy -bplusrenice -n 10(lowest CPU band, throttled disk and network; on Apple silicon the efficiency cores only), on Linuxrenice -n 10plusionice -c 3. Under load a command can wait minutes, long enough to run into a bash timeout."normal": unchanged."lowPriority": false, the older switch, means the same.
Threads the user runs are untouched, and so are sub-agents on a runtime other than Pi, whose commands Tau does not start.
-
No window of Tau's in front. The preview page keeps painting for the agent whatever the window shows, in a view that is never shown when it is out of sight (Preview Kit, above).
TAU_NO_FOCUS=1(src/main/background-mode.ts) runs Tau itself without a Dock icon and without ever taking the focus; test instances use it.
What macOS cannot keep out of sight, because it has no virtual display a
program may use (no Xvfb; CGVirtualDisplay is private API):
- Computer Use. It works on the real screen in the user's session and
moves the real pointer (
kits/computer-use/runs on darwin only anyway). - Apps an agent starts with
open(or any GUI program it launches). They appear on screen and take the focus; a lower priority does not change that. - Windows placed off screen. macOS moves them back onto a display.
For work that must be invisible, move it to another machine: a Linux host runs without a window, and a display there is Xvfb's.
The stylesheet#
src/renderer/tokens.css is the palette, and it is a contract: every colour,
font, radius, elevation and motion value Tau draws with is named there and
nowhere else, in two sets — light-dark(light, dark), chosen by the
color-scheme that data-theme on <html> sets. The theme preference
(system, dark, light; system is the default and follows
prefers-color-scheme) is core's, in Settings → Appearance (General without that page) and in the palette,
and the client writes it onto <html>: no component in the workbench knows a
colour. index.html links the file rather than importing it, so the first
paint is already themed.
The palette is the workbench design's (K69), with a warm grey in the light
scheme and a near-black with a faint blue cast in the dark (K77), on two
grounds — the document area and the side surface, the latter the lighter one
in the dark scheme — one blue accent for Tau's own actions and selection,
green and red for diffs and checks, amber for a question, short light shadows,
and Figtree as the interface face. Figtree ships with Tau as local woff2
files (src/renderer/assets/fonts/figtree/, SIL OFL 1.1, listed in the
third-party licences) that tokens.css declares, so no font is fetched from a
server and the first paint stays offline. The Tau mark is a warm-white τ on
that blue (--brand, artwork in assets/icon/): the app icon, the reload
curtain and the onboarding mark.
Spacing is a token scale too: --space-1 to --space-8 (2 to 32 px), each
multiplied by --density, which is 1 unless a client sets it on <html>.
The structural spacing — the thread rail, the thread row, the transcript, the
headers, the composer, the Settings screen — reads the scale; detail rules keep
their pixels until a visual pass moves them. Core applies no density of its
own: Appearance Kit sets it.
The table of those names is in EXTENSIONS.md §8, because it is
what a theme package — a manifest with styles and no code — may set. A
theme's stylesheet is linked after core's tokens and after every kit, so its
names win on order alone. Nothing about a layout class is API.
A kit that draws a surface of its own carries the rules for it: a styles
entry in its manifest, kits/<name>/styles.css, linked while the kit is active
and gone with it (see EXTENSIONS.md). Workspace, Agents,
Preview, Signals, Packages, Questionnaires, Pi UI and Review have one. They
name tokens like everything else and define no colour of their own.
src/renderer/styles.css keeps the classes core itself draws, which is the
whole of the test: a rule stays if a core component renders the element, even
when only a kit ever mounts that component.
| Stayed in core | Why |
|---|---|
The window and its slots: .app-shell, .workbench-center, .instrument-dock, .panel-rail, .panel-stage, .panel-header, .panel-body, .dock-resizer, .stage*, .thread-document |
Core's layout and the frame a panel contribution is drawn into. Four kits fill it; none of them owns it. |
The thread row: .thread-row, .thread-main, .thread-title, .thread-branch, .thread-project-icon, .activity-*, .thread-cost*, .thread-agent-count, .thread-row-actions, .thread-settle, .provider-icon* |
ThreadRow is core's component, published on tau (ADR 0014): a thread is core's and its row is how core draws one. The rail around it is Workspace Kit's and moved. |
The menu: .menu, .menu-anchor, .menu-label, .menu-heading, .menu-scrim, .menu-hint, .menu-sub-anchor, .chev |
Menu on tau; Workspace Kit, Access Kit and Service Tier all open core's menu. |
The other primitives: .tooltip, .popover, .skeleton, .empty-state*, .spinner-* |
Drawn by TooltipLayer, Popover, Skeleton, Empty and Spinner on tau. The toast stack's rules (.toast-stack, .toast-*) are the one exception to this file: they live in src/renderer/components/ui/toasts.css and load with the stack's chunk on the first toast, because the initial stylesheet budget had no room for them. |
Buttons and chips: .chrome-button, .chrome-ghost, .icon-button, .text-button, .mini-button, .control-pill, .chip, .runtime-chip, .switch, .segmented, .primary, .danger, .accent |
The shared vocabulary of the workbench. Access Kit and Service Tier draw their composer chips entirely with it; Service Tier has no stylesheet at all, and Access Kit's holds only its cards on Settings → Runtimes. |
The prompt frame: .extension-prompt*, .extension-option*, .option-row |
ExtensionPromptFrame and OptionRow on tau. Only Questionnaires' own pager (.extension-pager) moved. |
Review and diff: .review-*, .diff-*, .changes-tree-*, .commit-bar*, .source-*, .stat-add, .stat-del |
ReviewMode, ChangesTree and DiffView are core components published on tau; the rules for the rows DiffView draws load with its chunk (components/diff-view.css), not with the first paint; Review Kit mounts core's overlay rather than drawing one. Workspace Kit's own dock around them (.changed-file*, .commit-box) moved, including the rules that resize core's .stat-add/.stat-del inside it, and so did what Review Kit draws into the line seam (.review-comment-*, .review-comments). |
Settings: .settings-screen, .settings-nav*, .settings-topbar, .settings-crumb*, .settings-scope, .settings-content, .settings-section*, .settings-group*, .settings-row*, .setting-origin*, .setting-reset, .settings-search-*, .settings-field, .settings-input, .settings-select, .settings-filter, .settings-label, .settings-note, .settings-page, .install-extension, .extension-grant-box, .inspector-*, .keybinding-row, .provider-card* |
Core keeps the Settings screen, its rows (which kits build their pages from) and the pages safe mode needs. Only .packages-* — the install form, its log and its actions — moved to Packages Kit. |
Terminal Kit's .terminal-* and the xterm.js rules under .terminal-view |
Terminal Kit's own panel and the stylesheet xterm.js needs, scoped under the kit's surface; its colors come from the tokens (--sunken, --ink) so a theme reaches the shell too. |
The status line and the regions: .status-line, .status-item, .status-side, .workbench-region, .region-* |
Placements core publishes. Only what Pi extensions draw inside them (.pi-ui-*) moved. |
Transcript, composer, palette and modals: .transcript*, .message*, .markdown, .hljs-*, .tool-*, .work-fold*, .work-live*, .task-progress*, .composer-*, .command-palette, .model-picker, .approval, .reload-*, .project-picker, .project-modal, .thread-tree* |
Core's own surfaces, on the list above. |
The tokens (now tokens.css), .spinner and the keyframes |
The palette and the animations every kit's own rules refer to (var(--ink-2), blink, spin). A kit stylesheet uses them and defines none. |
The rules for classes nothing renders any more are gone (.approval-mark,
.image-placeholder, .palette-group, .reasoning-toggle, .reasoning-body,
.typing-mark, .thread-virtual-spacer, .tool-group-header,
.turn-activity-stack, .title-auto-toggle, .review-file-read,
.review-files-virtual, .tier-mark, .title-generator-actions,
.review-file-select, .review-comment-composer, .review-notes).
How to check#
- Every colour is a token:
src/renderer/tokens.test.tsfails on one written outsidetokens.css, on avar()naming a token nothing defines, and on a text token that misses WCAG AA in either scheme. A package that brings only a stylesheet is a theme — no grant, loaded last, marked "Theme" in Settings → Packages. - Settings → Packages lists the two sets apart: the kits Tau ships (
scope: "bundled"frominspectBundledKits, granted by construction), headed by the distribution they came in (@tau/kitsand its version, fromdist-kits/manifest.json), above the packages a source installed. A rescan after an install never touches the first set — the activator only ever loads what it scanned from the package folders, and refuses a package claiming a kit's id. - The kits Tau ships live under
kits/<name>/with atau-extension.jsonand load through the package loaders, fromdist-kits/when the app was built and from the sources otherwise. There is no other door in either direction: a kit reaches core throughtau(src/renderer/extension-api.ts),tau/host-extension(src/main/host-extension-api.ts) andtau/host(src/main/host-extension-worker-protocol.ts), plus the two test harnesses its own tests may use; core reaches a kit through the contributions the kit registers, never by path.src/shared/kits-boundary.test.tsfails on any other reach, andsrc/shared/core-boundary.test.tsfails if anextensions/directory reappears undersrc/. A kit that also runs inside a Pi runtime Tau does not own ships apientry, prebuilt todist-kits/<id>/pi.cjs, which.pi/extensions/tau-session-bridge.tsloads throughPiKitBridge— the bridge itself names no kit. start:safeshows the core list and nothing more. That includes no access gate: safe mode runs tools the way Pi does.- Removing a bundled kit removes its behavior on both sides without editing core.
src/shared/contracts.tsandsrc/main/index.tsname no feature. Feature commands travel throughinvokeHostExtension. Core operations are the method table insrc/main/host-methods.ts;src/main/ipc-contract.test.tsfails when the client and the table stop naming the same methods.- Thread lineage is an extension's claim, not a core field: a desktop extension publishes
setThreadLineage, and the navigator hides spawned threads and counts a parent's working children from it.UiSessionhas no parent. PiHostorchestrates and owns almost nothing itself. Attached-Pi ownership, transcript projection, client-message correlation, extension questions and session-event translation delegate to focused modules, and so do the project facts a provider answers with (project-facts-cache.ts), the thread index and its shells (thread-index.ts), extension binding (thread-binding.ts), a runtime from build to teardown (thread-runtime-lifecycle.ts), the spare runtime and thread prewarming (runtime-prewarm.ts), a prompt's runtime spelling and its guards (prompt-preparation.ts), steering, follow-up and adapter turns (turn-delivery.ts) and what the host looks like right now — the snapshot, the model catalogs behind it, and every update and result that publishes them (host-publication.ts). What is left inpi-host.tsis the sequencing: activation epochs, the lifecycle queue and what each operation publishes. A thread's runtime answers throughThreadRuntimeBackend(src/main/runtime-types.ts): twenty required members in Tau's own vocabulary plus capability groups, withrequireCapabilityas the one place that refuses what a runtime cannot do. The Pi terminal Tau attaches to is one of those backends (attached-thread-backend.ts), its collaborators receive named ports (host-ports.ts), and thread lifecycle work is serialised by a reentrantLifecycleQueue(lifecycle-queue.ts). The renderer delegates layout toWorkbench, its client state tosrc/workbench/, and keeps per-thread composer data inComposerScopeStore.- Tests keep this true:
src/shared/core-boundary.test.tsfails on a feature name in those two files outside a listed debt and capspi-host.tsat 2,080 lines andApp.tsxat 700;src/workbench/workbench-boundary.test.tsfails on React, Electron or a browser global inside the client.src/main/pi-host-safe-mode.test.tsproves safe mode loads no host or Pi extension, andkits/kit-lifecycle.test.tsxactivates and removes every kit against the core slots. - The client reaches the desktop host only through
HostClient(see "Renderer host client" in host-protocol.md) and the machine only throughPlatform;src/renderer/host-client-boundary.test.tsfails on any module outsidemain.tsxtouching the preload bridge, andclient-storage-boundary.test.tson any outsideplatform-electron.tsreading the browser's own store. - An extension package is not core and is not trusted: it declares
permissions, waits for the user's grant in~/.tau/extension-grants.jsonbefore either half starts, and reaches host services through the proxy inguardedServices.src/main/extension-packages.test.tsproves an unapproved package is never even imported, in the global folder as much as in a project's. docs/EXTENSIONS.md is the guide for writing one. - The renderer's own boundary is checked, not assumed: the window runs sandboxed with
webSecurity, no webview tag and no permission of any kind (src/main/index.ts), and desktop bundles arrive over thetau-extscheme instead of a blob URL, so the page's CSP isscript-src 'self' tau-ext:.src/main/extension-bundle-server.test.tsfails if the scheme serves anything it was not given or ifblob:returns to the CSP.
This page is docs/CORE.md in Tau's repository.