@x-cite/zgentic
v0.38.4
Published
zgentic terminal client and local tool bridge (#174). Bun is NOT required to run the published package: the CLI ships as a single pre-bundled Node script (built with Bun at publish time), so plain `node`/`npx` works. Bun is only needed to develop or build
Keywords
Readme
tui/ — the zgentic terminal client and local tool bridge (#174)
A terminal client for the platform and the bridge that lets the platform call tools on this machine. Q8: TypeScript / Bun / Ink.
bun install
bun test
bun x tsc --noEmit
bun run src/cli.ts login # sign in (browser redirect, or --no-launch-browser to paste)
bun run src/cli.ts attach # attach this machine, open a session, render the TUI
bun run src/cli.ts undo # revert the last local change from its git snapshotInstalling and running it
Today: from this repository (the only supported path)
The client is Bun-based (#174 Q8 — there is no pnpm/Node fallback) and src/cli.ts carries a
#!/usr/bin/env bun shebang, so Bun is a hard requirement rather than a preference.
curl -fsSL https://bun.sh/install | bash # if you do not have it
cd tui && bun install
bun run src/cli.ts # or: bun run startTo get a zgentic command on your PATH without publishing anything. bin points at the built
bundle (see the publishing section), so build it first:
cd tui && bun run build # writes dist/cli.js — gitignored, never committed
bun link # then `zgentic` works anywhereNote for anyone scripting this: Bun's installer appends its PATH line to your shell rc, which a
non-interactive shell never reads — make tui-check looks in $HOME/.bun/bin explicitly for exactly
that reason. If a script cannot find bun, that is usually why rather than a missing install.
The client needs a server to talk to, and a deployment's API base is not its bare host —
see "Connecting to a deployment" below before you set ZGENTIC_SERVER.
Publishing to npm as @x-cite/zgentic
Operator decisions (#308, 2026-08-22): scope @x-cite, name @x-cite/zgentic (so the
package and the zgentic command agree), dist-tag latest, and the package version tracks
the platform version (backend/app/__init__.py) rather than continuing its own 0.1.0 line.
The number is deliberately not restated here — a version written into prose goes stale on every
release; read it from package.json, and read what is actually on the registry with
npm view @x-cite/zgentic version rather than from a status line in this file.
package.json carries that name, no private flag, a files allowlist, license, repository,
engines, a bin pointing at the built bundle, a prepack that copies the licence in (see below)
and a prepublishOnly that builds the bundle.
What a publish ships — four files, measured with npm pack on 2026-08-23:
LICENSE 1.8 kB
README.md ~29 kB
dist/cli.js 2.1 MB (~403 kB packed, 2.1 MB unpacked)
package.json ~1.5 kBsrc/, test/, scripts/ and tsconfig.json are excluded by the files allowlist. Before that
allowlist existed, a publish would have shipped the test suite and the tsconfig. Re-measure after
any dependency change — npm pack --dry-run prints the whole listing without writing a tarball.
LICENSE is copied in at pack time, not committed (#329). The repository has exactly one
licence document, at the repo ROOT, and npm auto-includes a licence file only when it sits in the
package directory — a files allowlist cannot reach ../LICENSE. So both manifests declared
"SEE LICENSE IN LICENSE" while neither tarball contained it: for a closed-beta product on a
public registry, the terms missing from the artefact is the one place that bites.
prepack → scripts/copy-license.mjs copies the root file in (resolving from the script's own
location, since npm runs a lifecycle script with the package directory as cwd), the copy is
gitignored, and test/packaging-license.test.ts asserts on npm pack --dry-run --json — the
tarball, not the file on disk, because the file on disk is the same proxy that let this ship.
Bun is NOT required to run the published package. bun build --target node bundles every
dependency in — including react-devtools-core, whose static import inside [email protected] was the
original blocker (resolved by declaring it a real dependency, 33b657e3). The packed artefact runs
under plain node with no node_modules at all:
tar xzf "$(npm pack --silent)" # the name carries the version — never type it
cd package && ./dist/cli.js --help # exit 0, with bun absent from PATHOne trap worth knowing, because it stays invisible until a Node-only user hits it. bun build
copies the source shebang into the bundle, so dist/cli.js came out carrying
#!/usr/bin/env bun even under --target node. node dist/cli.js --help passes anyway — the
interpreter is named on the command line — but npx and node_modules/.bin execute the file
through its shebang, so the published command would have died with
env: 'bun': No such file or directory for precisely the users the bundle exists to serve. The
build script therefore rewrites line 1 to #!/usr/bin/env node after bundling (portably, via
node -e, not sed -i). Verify it with head -1 dist/cli.js, never with node dist/cli.js.
Publishing, when the operator decides to:
export NPM_TOKEN=… # from the environment, NEVER a tracked file
npm publish --access public --tag latest # prepublishOnly builds dist/cli.js first--access public because a scoped package is private by default. .gitleaks.toml gates committed
secrets and make sec runs it, so a token written into a tracked file fails the security gate
rather than reaching origin. A token pasted anywhere shared should be rotated regardless.
Open points, stated because a publish settles them irreversibly:
- The grant is the closed-beta T&C, and it is settled.
licenseisSEE LICENSE IN LICENSE, pointing at the rootLICENSEthat 0.37.1 added on the operator's instruction ("License add the T&C of the beta") — document versionbeta-2026-08-12, https://docs.zgentic.io/terms/. It is deliberately not an OSS licence, which cannot be withdrawn once published.backend/tests/unit/test_license_cites_current_terms.pyguards the document's content; the packaging test above guards that it is in the tarball. - The declared
dependenciesare redundant at runtime. All four are bundled intodist/cli.js, sonpm ipulls ~2 MB the published CLI never loads. Left alone deliberately — thereact-devtools-coredeclaration is the recorded #308 resolution — but it is dead weight in the install. - #298 —
make e2e-tuihas no suite, so everything currently proven about this client is proven against fakes. That was an internal matter; claiming@latestis the moment it stops being one. - npm never allows republishing a version. If what goes out is wrong, the only way forward is the next patch — there is no amend.
Connecting to a deployment
A deployment's API base carries an /api suffix. The bare host serves the web app; the
platform's HTTP API is mounted under /api. Measured against the closed beta on 2026-08-23:
| request | answer |
|---|---|
| GET https://beta.zgentic.io/auth/cli/start | 404, content-type: text/html — the web app's not-found page |
| GET https://beta.zgentic.io/api/auth/cli/start | 405 — the route exists; POST is the verb |
| GET https://beta.zgentic.io/api/healthz | 200 |
So the closed beta is:
zgentic login --server https://beta.zgentic.io/apiThat base is also the built-in default (DEFAULT_SERVER, #327), so a plain zgentic login targets
the beta — the flag above is what you use to point somewhere else, and what the rest of this
section is about.
Verify a base before debugging anything else. One request, no credential:
curl -fsS https://<host>/api/healthz # 200 + JSON => that is the API baseLocal development is the exception, not the pattern: make dev-up publishes the backend itself on
:8000, mounted at the root, so it takes no suffix — that is what --server and
ZGENTIC_SERVER are for.
zgentic login --server http://localhost:8000Omitting /api is the mistake worth recognising, because the symptom does not look like a wrong
address (#328): the web app answers an HTML 404, and the client used to report only
HTTP 404: <!DOCTYPE html>… — accurate and useless. It now names the cause, "that is a web page,
not this API's answer, so … does not look like an API root", and prints the --server to try.
The platform's own uniform 404 — its answer to "not yours", "off" and "no such thing" — is JSON
and is deliberately left alone: telling a user who was correctly refused that their address is wrong
would be worse than saying nothing. The discriminator is the response content-type
(src/api/http.ts), and both halves are pinned by test/api-root-hint.test.ts.
You give the server once. zgentic login records which server the credential belongs to, and
every command resolves one order — --server > ZGENTIC_SERVER > the stored session's server >
the platform default — so zgentic attach afterwards needs no flag. A resolved server that
disagrees with the stored credential's is refused rather than guessed: a token minted by one
origin is never sent to another (#327).
Signing in
zgentic login is the gcloud shape (#316): one grant, two ways the authorization code
comes home.
zgentic login # opens your browser; it redirects straight back here
zgentic login --no-launch-browser # prints a URL; you paste the code it shows you- Loopback (the default). The client binds a random port on
127.0.0.1before it asks the server for a URL, so the redirect target is a port that is already listening. Your browser opens, you approve, the browser is redirected to that port with the code, and the terminal finishes on its own. Nothing to copy. - Paste (
--no-launch-browser). For SSH, a container, or any machine with no browser. The URL is printed, you authorize it wherever you like, and you paste the code back. The keystrokes are not echoed — the code is a credential.
A browser that cannot be opened is not a failure: the listener stays armed and the URL is printed, so opening it by hand still completes the sign-in.
The paste path is not the weaker path. Both mint the same PKCE-bound, one-time, short-lived authorization code and exchange it at the same endpoint with the same verifier. The only difference is how the code travels: a socket, or a person. There is deliberately no "headless clients skip PKCE" branch.
The flow itself lives in desktop/src/client-auth/ and is shared verbatim with the
desktop app — PKCE, the state check, the loopback constraint, the one-shot listener and the
timeout are written once and neither client can opt out of them. It sits under desktop/
rather than in packages/ because the desktop app publishes unbundled tsc output and
could not import a workspace package at runtime, while this client publishes a Bun bundle
and can reach in through a tsconfig paths alias (desktop/src/client-auth/README.md has
the full reasoning). This directory owns only src/api/cli-auth.ts — the two HTTP calls —
and the terminal UX in src/cli.ts.
The RFC 8628 device-code flow this replaced is deleted in full, client and server: no second sign-in mechanism survives.
Where this sits
The agent loop is hosted — authorize(), billing, permission-aware RAG, connectors,
HITL and the audit trail all live server-side, and #174 opens by saying that moving the loop
to the laptop would move all of those with it. So this client contributes two things: a
terminal surface on the ordinary product API, and local tools the platform can call.
The bridge is consumed through the ordinary connector path — an attached TUI becomes an
McpConnector row in the user's own tenant and every call rides mcp_tools_service, the
manifest, the per-chat activation and the trust refusals, exactly as a third-party MCP
server would (the DOGFOOD PRINCIPLE; backend/app/services/runtime_bridge_connector.py).
Nothing here is a bespoke port.
Layout
| path | what it owns |
|---|---|
| src/bridge/protocol.ts | the wire contract, mirrored from runtime_bridge.py (and pinned by a test that reads that file) |
| src/bridge/sse.ts | the SSE reader — fetch + header auth, never EventSource (a credential must not ride a URL) |
| src/bridge/ledger.ts | the client half of at-most-once: running / done / foreign |
| src/bridge/channel.ts | the state machine: sync-first, cursor-on-state-only, heartbeat, deregistered is final |
| src/api/* | http (bearer + JSON + redaction), bridge (/runtimes), cli-auth (the sign-in transport, #316), chat (northbound) |
| src/tools/boundary.ts | the hard boundaries: root containment (resolve, then check) and the credential denial |
| src/tools/git.ts | Q7's undo: snapshot commits in refs/zgentic/undo/*, never git stash |
| src/tools/{read,write,vcs,exec}.ts | the twelve tools |
| src/tools/browser.ts | browser_use (#311): a CONNECTION to the browser instance already on this machine — no Playwright, no CDP, no new dependency |
| src/tools/registry.ts | the tool table + the opencode capability mapping |
| src/approvals.ts | how a server verdict is PRESENTED. No policy. |
| src/ui/* | the Ink surface and copy.ts (two strings are asserted by tests) |
| src/session.ts | registration (persistent) vs session (ephemeral, and it IS a chat) |
Decisions worth knowing before you edit
It is NOT in a pnpm workspace, and it does not join make lint
There is no root pnpm-workspace.yaml in this repo: web/, mobile/, packages/,
excel/ and desktop/ each carry their own. This directory follows desktop/'s
precedent — its own package manifest and lockfile, its own gate target — with one
difference: it is Bun, so pnpm-shaped tooling (pnpm outdated, pnpm audit in
make deps-check) does not reach it. Use make tui-check. Adding tui/ to
make lint's existing legs would have made the whole gate depend on a Bun install that
may not be present, and a silent skip in a gate is worse than a separate target.
What is shared with web/mobile, and how
packages/src is consumed live, through tsconfig paths — not as a dependency:
"paths": {
"@platform/shared/schemas": ["../packages/src/schemas.ts"],
"@platform/shared/chat-elements": ["../packages/src/chat-elements.ts"],
"@zgentic/client-auth/*": ["../desktop/src/client-auth/*.ts"]
}The third alias reaches into desktop/src/ on purpose — see "Signing in" above.
Bun's link: protocol means a globally linked package, so link:../packages does not
resolve here, and file:../packages copies — which is excel/'s trap ("edits are not
live; re-install after editing packages/") and the issue explicitly warns against it. A
path alias reads the real source on every run, so it cannot go stale. packages/ is not
modified by this directory.
Two modules are imported and the choice is deliberate: ChatElementUpdateSchema (the
single source of validation truth) and applyElementUpdate (the fold that a client
must apply — the backend ships pure deltas for confirmations, tool calls and plan
steps, and #72 records mobile losing its confirmation card by reading state alone).
packages/src/client.ts is not consumed: it carries no /runtimes and no CLI-grant
methods, and its fetch typing collides with Bun's. The schemas are the part that must not
drift; a fetch wrapper is not.
The tool set: twelve, and they are not opencode's twelve
runtime_bridge_tools.BRIDGE_TOOLS declares twelve and this client implements exactly
those (a test reads the Python file and fails on any divergence). The mapping to opencode's
gated capability list, since "match opencode" is the stated bar:
| opencode | here |
|---|---|
| read | read_file |
| edit (edit/write/patch) | write_file, apply_patch, move_file |
| glob | list_files (its glob argument) |
| grep | search_code |
| bash | run_command |
| — | delete_file, project_tree, git_status, git_diff, git_log |
| task, skill, question, webfetch, websearch | platform-side already — agents / skills / the one HITL primitive / the Perplexity path. Local re-implementations would be second paths to existing capabilities. |
| external_directory | not a tool — a hard BOUNDARY. opencode makes it an ask; #174 makes it a refusal ("a hard boundary, not a prompt"). |
| doom_loop | not a client concern — the platform already owns it. TurnPolicy.max_identical_tool_failures (default 3, turn.max_identical_tool_failures) is the server-side doom-loop guard: a repeated identical FAILURE class halts the loop with a stated reason, a different failure class resets the streak, and a repeated successful call never trips it. Not a gap; a client-side copy would be a second policy layer with no audit trail. See the note below on which paths enforce it. |
| lsp | out of scope — Q6 answered NO, deferred to 0.39 as #236. |
The browser capability (#311) — twelve tools plus one platform capability
browser_use is served from this machine but it is not a thirteenth bridge tool, and the
distinction is the whole design. A name in BRIDGE_TOOLS is served through this bridge's
connector row and therefore reaches the model NAMESPACED (tui_ab12cd34__read_file). The
desktop app has offered a platform tool called plain browser_use since 0.32 — so declaring
it here would give one capability two spellings for the model to learn, which is the
two-mechanisms-for-one-concept defect #309 deleted in this same release. It rides the same
SSE channel as a platform capability instead (runtime_bridge_tools.PLATFORM_BRIDGE_TOOLS),
so the platform tool, its schema and its result rendering are shared with the desktop path and
cannot drift: test/contract.test.ts reads the name out of browser_use_service.py rather
than transcribing it.
There is no Playwright here — the operator's decision (2026-08-22): "For the TUI consider that there's a desktop / browser instance to connect to, so there's no playwright necessary." This client connects to a browser instance that is already running and forwards one task. The dependency list is unchanged and so is the packed size #308 measured.
Setting the variable is the consent. Nothing is probed, guessed or auto-started:
export ZGENTIC_BSK_URL=http://127.0.0.1:49152 # a bare `bsk daemon --port 49152`
export ZGENTIC_BSK_TOKEN=… # ALSO set -> it is a desktop shell's bridgeWith the token unset it speaks the daemon's own contract (GET /health, POST /run); with it
set it speaks the shell's (GET /bsk/capability, POST /bsk/run, bearer) — both are contracts
this repository already implements in desktop/src/main/bsk-bridge.ts, neither is invented.
The URL must be loopback, for the reason the backend pins its own capability endpoint to
loopback: a remote URL would make this client an egress proxy carrying model-authored task text
to an arbitrary host. A refused URL is reported, never silently dropped.
Three consequences worth knowing before you wonder why the model cannot see it:
- No variable, no capability. The claim is sent per ATTACH (
{capabilities: [...]}) and derived from the resolved target, so attaching a terminal client does not silently expose a browser — and a reconnect after the browser went away drops the claim. It is deliberately not a registration column: a stored row claiming a browser would be a lie every time the daemon is down. - It obeys the same per-chat activation as the other twelve. The platform offers it only in a chat where THIS machine's connector is switched on (a TUI session switches it on when it opens). A browser that ignored activation would be strictly broader than the file tools of the same laptop — you would turn a machine on for one chat and find its browser drivable from every other one, including from your phone.
- The
syncframe therefore lists thirteen names when a browser is attached: twelve connector tools plus the capability. The status bar's count is still "what this machine advertised", so13 toolsthere and twelve rows in the #142 inventory is correct, not a mismatch.
An absent instance is a STATED absence. The interesting case is the user who set the
variable and then quit the browser. This client answers noInstance: true, which the backend
renders as its own sentence naming the missing thing, the fix, and the fact that retrying
alone will not help (browser_use_service.NO_BROWSER_INSTANCE) — never a bare transport
error, which is #246's defect class: a refusal with no stated reason becomes an invented
outage the model then offers to retry.
What this client does NOT do: start, stop, supervise or restart the daemon. That lifecycle (detect the binary, guided install, health-check, restart with backoff) is the desktop app's and a second copy of it here would be exactly the duplication the paragraph above warns about.
Q7 — undo, and what happens outside a git repository
Mutating tools REFUSE outside a git work tree. write_file, apply_patch,
move_file, delete_file and run_command all call git.requireWorkTree() before they
resolve a path, so the refusal names the real reason ("there is no undo here") rather than
a downstream symptom. The read tier is unaffected and useful on its own. Silently
proceeding is the one option that is not defensible; a shadow repo would hide state
somewhere the user never looks.
Inside a work tree, every mutation is preceded by a snapshot:
- a throwaway index (
GIT_INDEX_FILE→ a temp file) pluscommit-tree, so the user's staging area is untouched and no branch or HEAD moves; - the commit is reachable only from
refs/zgentic/undo/<epoch-ms>, a namespace no ordinary git command lists; git stashis never used. It is repo-global; on 2026-08-08 in this repo a concurrentpopdestroyed another worktree (7 files, +510/-12), and on a user's machine the same hazard exists against their own stashes;- the touched paths are added with
git add -fon top ofgit add -A, so a gitignored target is captured too — otherwise a write to an ignored file would be a mutation with no undo, which is the hole Q7 exists to close; - if the snapshot cannot be written, the mutation does not happen.
zgentic undo restores the snapshot's paths --worktree only (never --staged) and
deletes files the snapshot did not contain, so a created file is removed rather than left
behind.
At-most-once is enforced on both sides
The server answers a known call_id with duplicate and re-resolves nothing. That
protects its state. src/bridge/ledger.ts is the local half:
- a redelivered
tool_callfor adoneid re-POSTs the recorded result and runs nothing; - an id the
syncframe lists ascompletedis markedforeignand never executed — that is the crash-restart case a "have I seen this?" check gets wrong; - an id the server lists as
pendingthat this process has no memory of is answered with an error, not re-run. Zero executions is the correct number.
Gating is the server's, and this client presents and obeys
#142 owns the policy (auto/hitl/off, pattern matching, last-match-wins, per-agent
overrides that may only tighten) and runtime_bridge_tools.classify is the floor beneath
it. There is no rule table, no allowlist and no configuration here. The only asymmetry is
safe by construction: a verdict annotation on a dispatch can move this client from
"execute" to "do not execute" and never the reverse.
Two consequences worth knowing before you attach and wonder why nothing happens:
- A dispatch carries no verdict today.
runtime_bridge.dispatchbuilds the frame as{call_id, tool, arguments}, and a gated call is refused server-side inruntime_bridge_tools.admitbefore it could become a dispatch at all. Sosrc/approvals.tsis the client's half of a contract whose other half is not written yet: an approval reaches a TUI user through the assistant's own message (mcp_tool_hitl'sConsentOutcome.reasontells the model to say one is waiting) and through the Tasks inbox — not through the card in this client. - "Twelve advertised" is not "twelve callable". The bridge's connector row is created
fresh on first attach, so #142's inventory records each of the twelve at
off(mcp_tool_inventory.reconcile:policy=LEVEL_AUTO if grandfathering else LEVEL_OFF, and a row created after migration 0066 never grandfathers) andmcp_tools_service._withheld_from_manifestkeeps an always-offtool out of the offered manifest entirely. Until a tenant Owner sets the per-tool policy, the model is offered none of them — including the read tier, whoseclassifyverdict isauto. That is deny-by-default working as designed; it is also why the status bar's tool count (what this machine ADVERTISED, straight off thesyncframe) can read12 toolswhile the model can call zero.
An approval does not resume the turn, and the UI says so
#142 OQ-1 settled that a CONNECTOR-tool approval blocks — after a human approves,
nothing re-drives. So the approval card carries, verbatim and asserted by
test/approvals.test.ts:
Approving does NOT resume this turn. Once a reviewer approves, ask again in the chat to continue — nothing is running or waiting in the background.
A spinner there would be a lie by omission.
Two precisions the server's own code insists on. It is per gate, not platform-wide:
mcp_tool_hitl.require_human_consent's comment says engine-run gates DO resume and that is
exactly why the note belongs on this gate — so the sentence above is only ever true for a
connector/bridge tool, which is the only gate a bridge dispatch can reach. And the server
already authors that sentence, as after_approval on the approval payload, deliberately
"rather than added to each client … so web, admin and mobile all render it with no new field
and no chance of the three disagreeing". src/ui/copy.ts is therefore a fourth hand-kept
copy of a server-owned string: correct today, but the right end state is rendering the
server's field.
Security properties this client must not undermine
- Heartbeat: 15s, 45s grace (three missed beats).
stop()clears the timer and POSTs/detach; ctrl-c, SIGINT and SIGTERM all route through it. A dead client whose tools stay registered is what the transport design exists to prevent. deregisteredis final — no reconnect, no further dispatch, and the UI states why.- Symlinks are resolved before the containment check, including the deepest existing ancestor of a not-yet-created write target.
.envand credential shapes are refused even forread, andsearch_codeskips them too (a grep that prints a matching line out of.envhas read.env).- The session credential lives 0600 in a 0700 state dir, rides an
Authorizationheader (never a URL, which is why the SSE reader isfetch-based), is redacted out of every error and log line, and is stripped from the environment of any child process. - Tool output is data: control characters are stripped before rendering, and nothing interprets a result.
Known gaps
doom_loopis not a gap — see the tool table. The platform enforces it viaTurnPolicy.max_identical_tool_failures. One precise caveat, frompolicy.py's own comment: that dimension is "enforced today by the one sandbox-codegen retry engine (task_codegen.run_codegen_loop)", so a bridge tool failing identically is bounded by the turn'smax_tool_calls(16) and the 60 s dispatch bound rather than by the identical-failure streak. Extending the existing guard to the bridge dispatch path is a small server-side follow-up — the policy dimension, the config key and the failure-classing helpers all already exist. It is not something the client should grow.- No LSP (#236, 0.39).
run_command's snapshot records no paths (a command is not declarative), sozgentic undocannot list what it changed; the snapshot ref is returned in the tool result andgit restore --source=<ref> --worktree -- <path>reverts a specific file.- Bun's single-file
--compilepackaging (Q5'scurl | shstory) is not wired up yet.
