A macOS terminal that lives in Chrome tabs. Split panes, keep processes running, and pick up where you left off.
Get started · Screenshots · Documentation
One tab, multiple terminals. Name sessions, highlight output, and mark places worth returning to.
| Return to your work | Save your layout |
|---|---|
![]() |
![]() |
| Open a folder or reconnect to a running session. | Reuse pane arrangements and per-pane commands. |
Click a preview to see it at full size.
A background daemon owns the processes. Chrome owns only the views. Terminals are ordinary Chrome extension pages at stable URLs, so Chrome's own tab machinery works on them for free: reorder, pin, move between windows, group, close, restore.
Closing a tab does not kill a terminal. Reopening it restores the running process and the exact
screen, including a pane in the middle of a vim edit with unsaved changes.
Chrome extension ──┬── service worker (dispatch only)
├── offscreen document (control connection, notifications, tab actions)
└── terminal pages (one data connection each, per-pane streams)
│
authenticated loopback WebSocket
│
macOS daemon ── PTY sessions ── zsh / editors / shells / servers
│
launchd LaunchAgent (starts at login)
| Document | Contents |
|---|---|
| Overview | What this is, principles, non-goals |
| Architecture | Components, process model, connection classes |
| Protocol | Wire format, framing, auth, flow control |
| Data model | SQLite schema and shared types |
| Session lifecycle | Attach, detach, expiry, merge, restore |
| Security | Threat model, token bootstrap, untrusted input |
| Chrome integration | MV3 shape, tabs, groups, titles, favicons, keyboard |
| Terminal fidelity | VT state, serialization, renderers, resize |
| Shell integration | OSC 7 and OSC 133 |
| Agent integration | Agent CLI hook bridge and state surfacing |
| Limitations | What Chrome and macOS will not permit |
| Performance | Memory budgets, flow control, throttling |
| Testing | Fixtures, round-trip tests, verification |
| Packaging | App bundle, launchd, distribution, install |
| Decisions | Architecture decision records |
The specification is kept current with the code. A change that alters the protocol, the schema, or a documented behavior updates the corresponding document in the same commit.
daemon/ PTYs, VT state, sessions, SQLite, protocol server
extension/ MV3 extension: service worker, offscreen doc, terminal pages
shared/ Types and protocol codec, imported by both
native-host/ Native messaging host, token bootstrap only
shell/ zsh integration, OSC 7 and OSC 133
launchd/ LaunchAgent plist
scripts/ install, uninstall, dev, doctor
docs/ Specification and decision records
Everything runs locally and is built from source. Setup downloads source and npm dependencies, then builds the app bundle on your machine. No prebuilt TabTerm app needs to be notarized.
macOS 13 or newer, Chrome 120 or newer, Node 22 or newer, zsh.
Node 22 is a hard floor, not a preference: the daemon uses node:sqlite, which does not exist
before it. install.sh refuses to continue on an older runtime rather than failing later in a
way that looks like something else.
git clone https://github.com/halvis82/TabTerm.git
cd TabTerm
npm install
npm run typecheck
npm run build./scripts/install.shThat generates the auth token, registers the native messaging host, stages the daemon, installs a LaunchAgent so it starts at login, and runs a health check.
It never edits your .zshrc and never regenerates an existing token. Two steps are
offered rather than performed, because both are things you should decide: the shell integration
and the agent CLI hooks.
Chrome no longer honors --load-extension, so this step is manual:
- Open
chrome://extensions - Turn on Developer mode
- Load unpacked → select
extension/dist
The extension ID is pinned by the key in the manifest, so it stays the same across reloads and
reinstalls. That matters: every terminal tab is a chrome-extension://<id>/... URL, and a
changed ID would invalidate every one of them in your history.
If you installed it from the Chrome Web Store instead, the store gave it a different id from the
one the manifest key pins. Its ID is recorded in package.json under tabterm.publishedExtensionId.
Run ./scripts/install.sh from the current source to register it. Both ids are then registered
with the native messaging host, so a store install and an unpacked build work side by side and
nobody has to choose.
The first time a terminal reads ~/Documents, ~/Desktop or ~/Downloads, macOS asks. It asks
about TabTerm's daemon, not about Terminal.app, so permissions you granted other terminals do
not carry over.
Allowing is a one-time decision per folder. Denying is not fatal and not permanent: the
command fails with Operation not permitted the way any permission error does, and you can
re-allow it later in System Settings, Privacy & Security, Files and Folders. Restart the daemon
afterwards so the new grant is picked up:
launchctl kickstart -k gui/$(id -u)/com.tabterm.daemonThe one state worth knowing about is unanswered: while a prompt is pending the command blocks,
which looks like a frozen terminal. ./scripts/doctor.sh checks all three folders and tells the
three states apart.
Reaching your own network is a separate permission, and it does not fail like one. The first time something in a TabTerm terminal connects to a machine on your local network, macOS wants Local Network permission, and it asks about TabTerm rather than about the terminal you used before. Until it is granted the connection does not report a permission problem. It reports an ordinary network one:
ssh: connect to host 192.168.1.168 port 22: No route to host
That is indistinguishable from the other machine being asleep, so the first attempt reads as a
network fault and the second, after allowing, reads as a coincidence. Grant it at System Settings,
Privacy & Security, Local Network. Nothing can detect this for you: macOS keeps these grants
outside the database a process can read, so doctor.sh describes the symptom rather than reporting
a state.
Nothing here is required. TabTerm works fully without it, every terminal behaves the same, and no feature is lost. This section exists only to silence a prompt that repeats.
If you run Claude Code in TabTerm you may see this on every single launch:
"TabTerm.app" would like to access data from other apps.
TabTerm is not the one asking. Claude Code probes
~/Library/Application Support/Claude/org-plugins, which belongs to the Claude desktop app.
macOS attributes a file access to the responsible process, which for a shell is whatever
started it, which is TabTerm. So the prompt carries TabTerm's name for something the agent does.
It repeats because that directory usually does not exist. A probe that never finds anything never stops, so every launch asks again. Answering the prompt does not settle it: unlike the folder prompts above, this decision is re-asked rather than remembered.
You have three options.
1. Leave it and press "Don't Allow" each time. Nothing breaks. You lose the agent's org plugins, which most people do not use. This is the right choice if you find the prompt tolerable or if the trade below does not appeal.
2. Turn the probe off by not running that agent in TabTerm. The prompt belongs to the agent, not the terminal.
3. Give TabTerm Full Disk Access, which stops it:
System Settings → Privacy & Security → Full Disk Access → + →
TabTerm.app→ enable
The bundle lives at ~/.local/libexec/tabterm/TabTerm.app. ~/.local is hidden in Finder, so use
Command+Shift+G and paste that path, or run open -R ~/.local/libexec/tabterm/TabTerm.app. The
toggle takes effect immediately; no restart is needed.
Understand what that grants before you do it. Full Disk Access is broad: it lets the process read protected locations across your machine, including other applications' data, Mail, Messages and Safari. You are granting it to the process that runs your shells, which means anything you run in a TabTerm terminal inherits that reach. That is a real widening of what a terminal can touch, and it is why this is the third option rather than the first. It is the same grant people give iTerm and Terminal.app, and the same reasoning applies: convenient, and worth a moment's thought.
Confirmed on a real machine: with it enabled the prompt stops. Turning it off again brings the prompts back and breaks nothing else, so it is safe to try and safe to undo.
An update can undo this grant. macOS records the grant against the app's code signature, and
the bundle is signed ad hoc, so its signature is a hash of its own contents. Change what is inside
it, an icon included, and it is a different app as far as the privacy system is concerned: the
grant stops applying and the prompt returns. If that happens, remove TabTerm.app from the Full
Disk Access list with the - button and add it again. ./scripts/doctor.sh says which state you
are in, so check there first rather than guessing.
./scripts/doctor.sh reads the grant from the system privacy database and reports which of the
two states you are in.
Shift+Command+., or click the toolbar icon. That is the only shortcut the extension ships bound:
it is the suggested key on _execute_action and Chrome assigns it on install.
The other commands, including opening a terminal under a key of your own, are declared without a
suggested key. They appear at chrome://extensions/shortcuts waiting for one, and nothing happens
until you choose it. Everything there is rebindable, this one included.
They are left unbound on purpose. An extension shortcut is handled by the browser before the page sees it, so whatever it is bound to is taken away from every site you visit, and spending that on several commands nobody asked for is a poor trade. One key, and the rest are yours to assign.
./scripts/doctor.shReports per item, so a failure points at one thing rather than at "it does not work". If something is wrong, this is the first thing to run and usually the last.
./scripts/uninstall.sh # reverses all of itecho '[ -f ~/.local/share/tabterm/tabterm-integration.zsh ] && source ~/.local/share/tabterm/tabterm-integration.zsh' >> ~/.zshrcNot required. Command history, timing, pane status, and local server detection all work without it, because the daemon asks the OS what a shell is running instead of asking the shell.
What the integration adds: exit codes, shell builtins like cd and export, and commands that
finish faster than the fallback can see them. See
shell integration §5.
node scripts/install-agent-hooks.mjs # install
node scripts/install-agent-hooks.mjs --remove # reverseAdditive and reversible. It surfaces agent state — running, waiting for approval, finished — in the tab title and favicon, so a hidden tab waiting on you is visible.
Drop a .mjs file in ~/.config/tabterm/plugins/ and restart the daemon. See
plugins/README.md and plugins/example.mjs.
Chrome updates a store-installed extension after a new version passes review. For the companion, open TabTerm Settings, Updates at the bottom of the page, then Check for updates and Update companion. Automatic checking and compatible automatic installation are on unless you turn them off there. The updater downloads release source, builds it locally and preserves running terminal sessions. It needs Node/npm and the build tools from initial setup. Normal updates do not require sudo.
If your companion predates these controls, update your checkout, run npm ci and
npm run typecheck, then ./scripts/install.sh once. Development checkouts and incompatible updates use that manual path.
See companion updates for compatibility and recovery limits.
| Symptom | Cause |
|---|---|
| Nothing happens on the shortcut | Chrome may not have bound it. Check chrome://extensions/shortcuts |
| "TabTerm is not paired with the daemon yet" | Native messaging host is not registered, or points at a file Chrome cannot execute. doctor.sh says which |
| Terminals open but nothing runs | node-pty's spawn-helper lost its executable bit. doctor.sh checks this specifically |
| The daemon will not start | Almost always Node older than 22. doctor.sh reports the version it found |
| History is empty | Expected on a fresh install; it fills as you run commands |
A command touching ~/Documents, ~/Desktop or ~/Downloads hangs |
macOS is waiting on a privacy prompt you have not answered. Answer it; the command continues |
The same command fails with Operation not permitted |
You denied that folder. Re-allow it in System Settings, Privacy & Security, Files and Folders, then launchctl kickstart -k gui/$(id -u)/com.tabterm.daemon |
Anything else: node scripts/diagnostics.mjs writes a redacted bundle to
~/.local/state/tabterm/diagnostics/ and prints the path. It
contains no scrollback, command text, or environment values, and it says what it redacted.
PRIVACY.md is the policy. It is written once, in markdown, because that is what is readable here
and what has a public history. The Chrome Web Store needs a URL, so a page is generated from it:
node scripts/build-site.mjs # PRIVACY.md -> privacy.htmlTwo copies of a legal statement is the kind of thing nobody notices has drifted until it matters,
so site.test.ts fails when privacy.html is not what the markdown currently produces.
GitHub Pages serves these from this repository, not from the halvis82.github.io one, which only
serves the root address. In this repository: Settings, Pages, Deploy from a branch, main,
/ (root). That gives:
https://halvis82.github.io/TabTerm/ |
the landing page, for the store's homepage field |
https://halvis82.github.io/TabTerm/privacy.html |
the policy, for the store's privacy field |
.nojekyll is there on purpose. Pages would otherwise run these files through Jekyll, which
processes {{ and {% as template syntax, and the documentation here contains both inside code
blocks. Serving the files as they are removes a build that can fail for a reason nobody would guess.
The macOS side is built from source and needs no signing. A locally built bundle carries no
quarantine attribute, so Gatekeeper does not assess it, and the ad-hoc signature is enough to
give macOS a stable identity to attach privacy grants to. A Developer ID and notarization are
only needed to ship a prebuilt .app for download.
npm run package:app # dist/TabTerm.app, ad-hoc signedThe Chrome extension is different. If you publish to the Chrome Web Store, the store
assigns the extension ID and the manifest key cannot control it. A new ID breaks the native
messaging host allowlist and every stable tab URL, so it has to be recorded:
npm run package:extension -- --published # dist/tabterm-extension-<version>.zip
# upload the draft first, because the store assigns the ID during that upload, then:
# 1. put it in package.json under tabterm.publishedExtensionId
# NOT tabterm.extensionId, which pins the unpacked build and must not change
# 2. re-run ./scripts/install.sh, which allows both ids as native-messaging origins
# 3. commit that, and run the suites again before submitting for reviewpackage.json is the single place that records it; install.sh and doctor.sh both read it,
and TABTERM_EXT_ID=<id> overrides it for a one-off.
There is also a managed-policy route that needs no store listing at all:
npm run package:extension -- --policyThat writes a plist and prints how to apply it. It is written, never installed: applying it needs root and changes Chrome for every profile on the machine.
Complete. Terminals, splits, workspaces, restore across close and reopen with the process intact, project templates behind a trust prompt, history search, saved commands, agent integration, a local server dashboard, reboot restore, and a plugin API.
Plus a floating command menu with favorites, history, hotstrings and session statistics, tab status in the favicon and title, and desktop notifications when a long command or an agent turn finishes.
Terminals survive an update. PTYs live in a separate host process, so replacing the daemon does not end anything running, and a restarted daemon adopts the sessions it finds. History is kept on disk per session, bounded by a budget you set in megabytes.
664 unit tests and 124 browser checks. npm run verify gates every change.
Read limitations before filing anything: what Chrome and macOS will not permit is documented there with measurements, including the things that cannot be fixed.
MIT. See LICENSE.
Every terminal on the machine fails to start, including ones that have nothing to do with
TabTerm. TabTerm reports posix_spawnp failed and iTerm reports a session ending immediately.
macOS caps how many pseudo-terminals exist at once, and the cap is low:
sysctl kern.tty.ptmx_max # 511 by default
ls /dev/ttys* | wc -l # how many are allocated
When the second number reaches the first, nothing on the machine can open a terminal. Two ways out, and the first does not need a restart:
pkill -f 'libexec/tabterm/pty-host' # frees everything TabTerm was holding
sudo sysctl -w kern.tty.ptmx_max=999 # raises the cap, until the next reboot
Restarting the PTY host ends the terminals it was running, which is the price, and it is a smaller
one than rebooting. 999 rather than a round number: macOS rejects anything at or above 1024. To
make the cap survive a restart, put kern.tty.ptmx_max=999 in /etc/sysctl.conf.
A pseudo-terminal stays allocated while its master handle is open, and node-pty 1.1.0 never closed that handle. Every session cost one pseudo-terminal for the entire life of the PTY host, which is designed to run for months, so the supply drained a session at a time until nothing on the machine could open a terminal.
Measured on a machine at 827 allocated: TabTerm's host held 820 of them, and only 13 were open by any process. Fixed by moving to node-pty 1.2.0-beta.15, where spawning and killing twenty terminals moves the count by zero.
Other applications leak the same way. iTerm held 101 on the same machine, and only a reboot clears those.




