The web app's page held 145 lines that were not about being a page: read
the registry, fold sessions and sandboxes into machines, mint a url per
pane, poll on the plane's own clock. With three hosts for the workspace
that is three answers to how often a machine goes stale, so it is a hook
now and the page is 22 lines.
What a host still owns is the token and where its cloud is. Each gets the
token its own way — a browser mints it with PKCE, the desktop already
holds the signed-in user's — and the hook has no business knowing which.
TerminalHost had two definitions after the first draft of this, so it
moved to the pure vocabulary both sides read. A lib module importing a
component module would invert the layering, and a second copy of the type
is two answers to what a machine is.
One defect fell out of the move: the signed-out screen showed the
REGISTRY's error, which is always null when there is no token — so a
refused sign-in said nothing at all. The two failures have different
remedies and now have different states.
Co-authored-by: Hanzo Dev <[email protected]>
tabs.hanzo.ai is one host for it. The desktop shell is another, and
hanzo.ai a third, and the alternative to exporting it is three pane trees
that look alike — a second set of answers to what a split does, which
then drift.
Almost nothing had to change, because the component was already pure: it
takes `hosts` and `mint` as props, holds no token and calls no server. So
this is packaging. Its own `@/lib` imports become relative, since a
consumer has no such alias; the control plane's origin becomes a
parameter defaulting to where the web app already pointed, so a host aims
it at whichever cloud its identity belongs to without module state a
second host must remember to set; and `index.ts` names what a host
mounts.
Source is published rather than a build. Both hosts compile TypeScript,
so a build step here would be a second toolchain making something neither
needs and one more thing to be stale. React, @hanzo/ui, @hanzo/gui and
lucide are peers for the same reason a shell must not get a second copy
of a context in one tree.
Two things were already broken on this branch and are fixed with it.
@hanzo/ui hard-imports react-hook-form, which it declares an OPTIONAL
peer — a bundler resolves it statically either way, so the module has to
be there and `next build` failed on every path without it. And Next 16
builds with Turbopack by default, which dies here with a worker error
while webpack compiles in 9.6s; Next's own message asks callers to name
the bundler, so the script names the one that works.
Co-authored-by: Hanzo Dev <[email protected]>
tabs framed two of a machine's faces — its shells and its screen — and the
web was the one surface you had to leave for. A pane can now be a page:
same tree, same splits, same phone paging, with the address where the
title goes because for a page the address IS the title.
It is the only pane with no machine, so there is nothing to link, nothing
to start and no credential to mint. The url is the binding, which is also
why it survives a reload.
Two rules the other kinds did not need. A typed address reaches an iframe
src, where javascript: and data: are script in this document rather than
a page in the frame, so the scheme is checked and everything but http and
https is refused. And silence now means "did not come up" only for a pane
that promised to speak: a site on the open web never posts hanzo-term, so
judged by the old rule every browser pane was covered by a rescue six
seconds in, offering to reconnect a page already working.
Back and forward walk a trail this workspace keeps, because a
cross-origin document's history is not ours to touch. A link followed
inside the page is the page's own navigation and never reaches that
trail — a parent cannot observe that kind at all.
Co-authored-by: Hanzo Dev <[email protected]>
118 tailwind sites become gui style props and @hanzo/ui components: 110
class attributes across four screens, plus the seven class names the DOT
map exported from lib/panes. Tailwind, its postcss plugin and the design
tailwind bridge leave with them; `<Hanzo>` carries the tokens instead.
Six pane covers were the same absolutely-positioned centred box written
six times, and eleven icons carried the same two classes — those fold to
Cover/Note/Dot/Chip and a `size` prop.
The `sm:` breakpoints go to `paging`, which the workspace already
measures. gui emits nothing for a breakpoint prop without its optimizing
compiler, measured on a background that never painted, so a breakpoint
written that way would have been absent rather than redundant.
react-native pins to 0.83: 0.84 renamed @react-native/assets-registry and
react-native-svg 15 still imports the old name.
One word stood for several: the MCP server, one address that accepts requests,
the single way in, and the boundary a credential is validated at. Each site now
names its own sense — endpoint, entry point, the MCP server, the edge, address,
relay — or, where none of those fit, takes the word the surrounding code already
uses.
Prose and comments only, 1:1 line for line. Identifiers keep their names: CSS
classes, DOM ids, struct fields, enum variants, config values and the doc
comments that must open with the identifier they document.
The fleet answers a refusal two ways. Most doors send `{error:"..."}`; MONEY
sends `{error:{code,message}}` — 402 insufficient_balance, 402
spend_cap_exceeded, 503 balance_unavailable — because every Hanzo surface reads
that one contract by `error.code`. This read only the flat shape, so the nested
object was coerced by `new Error(obj)` and the ONE refusal a person can act on
rendered as `[object Object]` beside a button that had just failed.
It reads both now, and keeps the CODE. That is the part worth keeping: being out
of credit wants a different answer from a full node or an image that will not
pull, and telling them apart by matching the sentence breaks the first time
somebody rewords it.
So the pane says the thing the person can do RIGHT NOW: a cloud machine is ours
and costs credit, one of theirs costs nothing, and `hanzo link` puts it in this
same workspace. No billing link — tabs has no backend and no billing surface, and
the workspace's own gate forbids opening another domain in a tab, which is the
rule that caught the first draft of this and was right to.
Latent until now, because `SANDBOX_FEE_CENTS` is unset and the gate is skipped
whenever the fee is zero, so nothing has ever been refused for money. It would
have surfaced on the first launch after somebody set a price.
The `answer` helper takes a status, so a 402 can be driven rather than described.
A workspace read `tabs · hanzo`, `tabs-2 · hanzo`, `tabs-2 · shell-2`. Two bare
nouns per pane and neither one told you which box or which shell: `tabs` was the
product that opened the machine, and `hanzo` was the company standing where the
session's name goes.
The shape is now `<machine> · <face>`, and both halves count from one.
Machines are `cloud-1, cloud-2` — what the machine IS, one you shell into —
and `desk-1, desk-2` for the ones with a screen. The first carries its number
like the rest, because `cloud` beside `cloud-2` reads as a different kind of
thing rather than the one before it.
Two stems and not one: machines are counted against those RUNNING, and the
project keys the disk, so a stopped `cloud-2` still owns its home — a desktop
later handed that name would mount a shell machine's disk and call it a screen.
Faces are `shell-1, shell-2` and `desk`. Only NEW shells take the new name; a
pane already open was saved with its binding and comes back on the session it
was on, so nothing running is left behind.
The pane framing a linked machine's shell asked you to sign in again. Its
tunnel is gated on hanzo.id, hanzo.id refuses to be framed — correctly, a login
page inside someone else's document is clickjacking — so the gate's redirect
could not run in the frame, and the workspace fell back to opening a second
sign-in in a second tab. For the same person, already signed in, on the same
identity. Per machine, and again every three hours.
The tunnel is now asked directly, with the token this page already reads the
registry with, and answers with the session the redirect would have set. What
that removes is a whole shape: a pane URL is MINTED for every machine now,
never derived, so the workspace no longer knows whether a pane is a sandbox we
started or a machine you linked — they differ only in who serves the page and
what credential opens it, and neither is a layout's business.
The rescue collapses with it. One card, one button, for any credential that
stopped working: ask for another. A mint that never lands offers it too, rather
than leaving a pane waiting on a frame that was never made.
Starting a cloud machine took tens of seconds and drew nothing for all of
them: `launch` awaited `onLaunch` before it called `open`, so the only sign
the click had landed was a word change on the button that caused it. The
workspace itself sat unchanged, which reads as a dead button.
The pane is opened by the CLICK and settled by the ANSWER. A binding can now
say `starting` — a machine is being made for this box, and here is the loader
— and `failed`, which keeps the reason in the space the machine was for.
`open` answers with the pane's id so `launch` can settle that one box, and
`settle` no-ops on a pane that was closed while its machine was coming: an
answer arriving late must not reopen a box someone shut.
Neither state survives a reload. The promise died with the page, so a restored
`starting` would spin for a machine nobody is waiting on and a restored
`failed` would state a reason about a moment that has passed; `restore` brings
both back as the pane that ASKS, by which time the machine may well be in the
list. `gone` still survives — tmux is holding that session, so that pane is
still about something.
With the wait and the failure both in the pane, the button needs neither, so
`Launch` collapses into the plain button `New shell` already was and all four
share it.
A pane shows a machine. Until now there was one way to look at one — a
shell — so the binding that says WHAT a pane shows only ever said which
tmux session. A desktop sandbox has an X server, and nothing here could
show it.
So a binding has a second face. `{kind:'screen', machine}` carries no
name because a machine has one display where it has as many shells as you
ask for, and the machine's own class decides whether it is offered at all
— `desktop` runs an X server, the other classes do not, and a name is
never read as a capability.
ONE MINT FOR BOTH DOORS. The ticket answers the address it opens, so
`frameUrl` asks for a door and frames what the server named, rather than
composing a URL a client could spell wrong. The bearer still never leaves
lib/api; what a pane holds is the minted URL.
One button, one intention: Desktop opens a screen where a desktop is
running and starts one where none is. A disabled button beside a second
button that makes it work would be two controls for one thought.
New cloud machine ordered a DigitalOcean droplet through visor, waited a
minute for it to boot and run `hanzo link`, and — with no name in the body —
answered 422 "Droplet must have a name", so in production it ordered nothing
at all.
It leases a dev sandbox now. POST /v1/sandboxes returns when the pod is
RUNNING, so the machine and the news of it arrive together, and the button
opens its first shell rather than adding a row to a list. The project counts
up against what is already live, because the server keeps one live sandbox
per project — which is what makes a second press a second machine instead of
a second attempt at the first.
That leaves one plane where there were two: visor's launch, list, and the
merge that held a place for a box still booting all go, along with the
pending statuses that existed only for that gap. A failed start now carries
the server's own reason to the button that caused it.
Third machine source beside linked tunnels and visor boxes: dev sandboxes,
framed through the one terminal contract every Hanzo surface uses
(/v1/sandboxes/:id/terminal, single-use thirty-second ticket, ?arg= names
the tmux session exactly as ttyd's --url-arg does). A sandbox host carries
an id instead of a published base; the workspace mints the URL when a pane
binds — once per pane, because the frame's first load spends the ticket —
and a refused pane offers Reconnect (a fresh mint), never the sign-in tab,
which for a spent ticket would open a dead door.
Co-authored-by: Hanzo Dev <[email protected]>
Tabs could only ever show machines you had already linked yourself, which
leaves someone with a browser and no laptop nothing to open. Visor already
provisions boxes and already has a `tab` kind — one that runs `hanzo link` and
stops there, with no agent runtime to pay for — so the missing half was in the
browser.
Two planes, kept apart. Visor owns provisioning; api.hanzo.ai owns the live
registry; they meet at the machine, because a launched box links itself exactly
as a laptop does and then arrives through the ordinary registry with no special
case anywhere. `withCloud` covers only the gap before that, merging by host with
the registry winning, so the placeholder disappears the moment the real machine
registers. A visor that is down costs you the boxes it launched and nothing
else: the linked machines still read, and the launch button is where the failure
is reported, because a banner about a plane you may not be using is noise on a
workspace that works.
A booting box has no tunnel yet and is admitted anyway — invisible for the
minute it takes to boot is a launch button that looks broken — so a pane can be
bound to one. That pane says it is waiting. It must, because the branch it would
otherwise fall through to is the OAuth rescue, which would offer to sign you in
to a terminal at `#`.
The counterpart is hanzoai/universe 1d2de447: visor.hanzo.ai had to be named in
connect-src or the fetch never leaves the browser.
Co-authored-by: Hanzo Dev <[email protected]>
Every gated pane rendered as an opaque black rectangle with the way out hidden
underneath it, and the detection that was supposed to prevent that reported the
pane as fine.
The rule was inferred from the DOM: a refused frame stays at `about:blank` —
same-origin, readable, empty — while a real load throws on contentDocument, so
the throw is the good case. There is a third case and it is the common one. The
frame follows the tunnel to its OAuth gate, the gate redirects to hanzo.id,
hanzo.id answers `frame-ancestors 'none'`, and the browser swaps in
`chrome-error://chromewebdata/`. That throws EXACTLY like a healthy cross-origin
load, so a blocked terminal and a working one were indistinguishable.
They are indistinguishable to any parent, which is why this stops guessing. Our
terminal page posts `hanzo-term: ready` once xterm has opened, and that message
is the only thing treated as proof — trusted only from the window of a frame this
workspace rendered. Silence past the deadline is the rescue, whatever the cause:
gate, CSP refusal, dead tunnel, offline machine. One signal, one meaning, and no
case analysis to get wrong the next time a browser invents a fourth way to fail.
The contract lives in lib/panes because it is a wire value shared with another
repository (hanzo/cli assets/term/client.js) — one side changing it silently is a
workspace where every pane looks dead. Both halves are pinned by tests.
Co-authored-by: Hanzo Dev <[email protected]>
An access token lasts an hour. Tabs is a window you leave open all day watching
agents work, so an hour-old token is the normal case on returning to the tab —
without a renew you are dropped at a sign-in screen with panes still on screen,
and a workspace left open quietly fails every registry poll until someone
notices the machines have gone stale.
@hanzo/iam will attempt OIDC's `prompt=none` hidden iframe, and it cannot work
here: hanzo.id answers `frame-ancestors 'none'`, which is the right policy for a
login page and refuses the frame regardless of what this app's CSP allows.
Widening frame-src to permit the origin changes nothing, so it is not widened.
The refresh grant is the mechanism that does work for a public client, needs no
frame and no third-party cookie. Renewed once on load when the stored token has
aged out, and every ten minutes while the workspace is open — well inside the
hour, so the renew never races the expiry it exists to prevent.
A failed renew is not an error: an expired or revoked refresh token means the
session is simply over, and the sign-in screen is the correct thing to show.
Co-authored-by: Hanzo Dev <[email protected]>
The flow was sixty lines of correct-looking OIDC that nothing else in the
estate shared, which is the whole problem: it had its own endpoint literals,
its own token store, and its own idea of when a session is expired. Every one
of those has to move in lockstep with IAM, and none of them had a reason to.
`@hanzo/iam/browser` is the flow every Hanzo app already signs in with. It
resolves endpoints from OIDC discovery rather than hard-coding paths, and it
guards the code exchange against a repeated call — a single-use code whose
verifier is already consumed fails on the second attempt, which is exactly the
shape of a login that "sometimes" does not complete.
This file now configures an issuer and a client id and nothing else.
Tabs stays a PUBLIC client: a static site cannot keep a secret, so PKCE is what
replaces one. That is what `type: spa` means in provision.yaml, and it is load
bearing — a registration that holds a secret the browser cannot present makes
the token endpoint demand client auth, and the exchange fails there rather than
anywhere the app can see.
Co-authored-by: Hanzo Dev <[email protected]>
UNIFIED IDENTITY. A pasted token was the honest shape for a backendless app on day
one and the wrong one to keep: it is a credential handled by hand, and it is not
the identity the rest of Hanzo uses. Sign-in is OIDC authorization code + PKCE
against hanzo.id now — the same login as everything else — in about sixty lines
against their own discovery document, with no dependency. A static site cannot
keep a secret, so PKCE is not a convenience here; it IS what replaces the secret,
and `hanzo-tabs` is registered as a genuine public client.
The token still lives in this browser, because there is nowhere else it could:
inventing a session store would mean inventing the server this product exists
without. The difference is that it is now short-lived, refreshable, and issued to
this origin rather than copied from a terminal.
A PURE STATIC EXPORT. `output: 'export'` — no server at request time, not even a
Node one. Tabs reads the control plane from the browser with the caller's own
token and frames terminals the machines themselves serve over the zero-trust
fabric; both ends of that conversation are already authenticated, so a server
here would be a third party to it. The Dockerfile goes with the same reasoning:
what ships is a directory of files.
Deployed at tabs.hanzo.ai from S3 via hanzoai/static. Two things had to be fixed
to get there and both were ours: the S3 adapter refused directory requests
outright (fixed in hanzoai/static v0.5.4 — every static site was 404ing at its own
root), and the default CSP is `default-src 'none'`, which blocks every script, so
the page rendered and nothing ran.
Verified in a real browser against the live IdP: the sign-in button lands on
hanzo.id/login/oauth/authorize carrying the PKCE challenge, with no console
errors.
Co-authored-by: Hanzo Dev <[email protected]>
A standalone terminal workspace for the machines you have linked. Split, tile and
sweep through every shell your coding agents are working in, from a laptop or a
phone.
THERE IS NO BACKEND, and that is the design rather than an omission. `hanzo link`
already publishes a terminal from the machine over a zero-trust tunnel, already
authenticated and already gated; the browser frames it directly and reads the
machine registry with the caller's own token. A server here would be a third
party to a conversation that has two, one more thing to keep running, and one
more place a credential could sit. The whole site is static.
A SHELL IS A URL. ttyd runs with --url-arg behind a wrapper that names a tmux
session, so ?arg=build IS a shell called build. One link serves as many
independent shells as asked for, and closing a pane loses nothing because
reopening the name reopens the session with its scrollback.
CARRIED OVER WHOLE, with its tests: the layout tree (split, close with
sibling-promotion, resize, sweep order, derived geometry, and the phone
projection where side-by-side splits become swipeable pages) and the binding
model that separates where a pane sits from what runs in it. 39 tests, no browser
required.
NOT A FORK OF GGPrompts/Tabz. That repo carries NO LICENSE — no LICENSE file, and
GitHub's license API 404s — so it is all-rights-reserved and cannot be forked into
an OSS product. Its good ideas are ideas and are used freely: a tab strip, tmux
operations without the Ctrl+B prefix, and agent status badges. Its architecture is
not: it needs its own Node PTY backend, and `hanzo link` already is one.
Verified against the live control plane in Chromium at 1440x900 and as an iPhone
14: the workspace renders a pane bound to the real machine (`ra · hanzo`), with
New shell and both splits present, and no page errors.
Co-authored-by: Hanzo Dev <[email protected]>