Node client for live-driving a running Zana Command Center over loopback
product HTTP. Tests, one-off scripts, and the operator CLI (zcc thread /
zcc agent) use the same client.
It is not a Playwright replacement, not part of plugin-sdk, and not
a new zcc control namespace. Plugin origin stays plugin. This client always
stamps origin: 'sdk'.
Operator surface: docs/cli.md. Coupling note: AGENTS.md
(“Verify threads / CLI Agents still launch” → pnpm live:mode-reasoning
then pnpm live:memory).
| Is | Is not |
|---|---|
Attach to a running app (:8780 / :8781) |
Playwright / Electron E2E |
| Isolated server + host-daemon for threads | Isolated CLI Agent (needs Electron) |
Shared client for live tests and zcc thread / zcc agent / zcc browser |
Plugin SDK (definePluginApp) |
Tagged live-test janitor ([zcc-live:<runId>]) |
Operator titles (CLI uses tagged: false) |
| Modern threads + CLI Agents + desktop-browser product HTTP | teams.launch, shell PTY (POST /api/v1/terminals), in-app chrome (find-in-page, Take over) |
import { Zcc } from '@zana-ai/zcc-control';
const zcc = await Zcc.connect();
const project = await zcc.projects.ensureLiveSandbox();
await zcc.harness.preflight({ surface: 'thread', providerId: 'claude-code' });
const thread = await zcc.threads.spawn({
projectId: project.id,
prompt: 'reply with PONG and stop',
permissionMode: 'accept-edits'
});
await thread.wait({ until: 'idle', onInteraction: 'fail' });
await thread.send('second turn: reply PONG2 and stop');
await thread.wait({ until: 'idle' });
await zcc.cleanup();Zcc.connect() attaches to a running app. It probes
http://127.0.0.1:8780 (packaged / pnpm start) then :8781
(pnpm dev + ~/.zcc-dev). If both answer, set ZCC_SERVER_URL.
Zcc.launch({ isolated: true }) starts server + enrolled host-daemon in a
temp data dir (threads only). CLI Agent cannot run here until host-rpc
harness.start exists. Parallel CLI Agent in v1 = many sessions on one
desktop, not N Electron-less workers.
Do not call Zcc.connect() from operator CLI if that would mint a live-run
journal. zcc thread spawn / zcc agent launch construct ProductHttpClient
and call spawnThread / launchCliAgent with tagged: false.
Live tests and mutating CLI must run from a host shell, not a ZCC agent
terminal. ZCC_SESSION_ID (injected by the app) marks the caller as an agent:
mutating product HTTP returns FORBIDDEN_AGENT (CLI exit 5). Unset it before
live scripts (unset ZCC_SESSION_ID).
CLI Agent HTTP (POST /api/v1/cli-agents) uses a boot-injected
product-server credential in Electron main. That credential is not on
Electron process.env. Human zcc over UDS still confirms. Unattended HTTP
does not mint consent — some executionState values return a clean DENIED.
provider: 'fake' cannot rewrite an already-running Electron PATH.
Zcc.connect({ provider: 'fake' }) throws. Use Zcc.launch({ isolated: true, fake: true })
or start the app with fake binaries already on PATH / config *Binary keys.
Helpers live in @zana-ai/zcc-control/testing.
| Surface | HTTP | Backend |
|---|---|---|
| Modern thread | POST /api/v1/threads |
host-daemon ACP |
| CLI Agent | POST /api/v1/cli-agents |
Electron createTerminalConfined via product-server credential (no native confirm) |
| Desktop browser | POST /api/v1/desktop-browsers/* |
Electron BrowserView broker (attach only; isolated stacks skip) |
Do not overload POST /api/v1/terminals (shell PTY). Team launch is a product
HTTP + control-plane confirm path, not an SDK verb. In-app browser chrome
(find-in-page, Take over, plugin-page Browser tab) stays Playwright
(e2e/desktop-browser-broker.spec.ts). This client never imports cookies in
live tests — listing import sources is enough to prove cookie values stay in
main.
| Method | Notes |
|---|---|
Zcc.connect(opts?) |
Attach. Probes 8780 then 8781. Mints a runId. |
Zcc.launch({ isolated: true }) |
Isolated stack, threads only. |
health() |
Product health + whether a host is connected. |
projects.list() |
Registered projects. |
projects.ensureLiveSandbox() |
Dedicated live-sandbox project. |
hosts.list() |
Registered machines (GET /api/v1/hosts). |
harness.preflight({ surface, providerId?, profile? }) |
Fail fast if the harness is missing. |
threads.spawn(spec) |
Tagged live spawn (visibility: 'hidden' unless set). |
cliAgents.launch(spec) |
Tagged live launch. Throws ISOLATED_CLI_AGENT on isolated stacks. |
browsers.pickInstance({ hostId? }) |
First connected desktop window, or { skip, reason } (isolated / no host / no BrowserView). |
browsers.session(scope) |
Thread-scoped tab/lease handle. |
browsers.listInstances(hostId) / listImportSources / importCookies |
Same HTTP as zcc browser. |
cleanup({ runId?, stale? }) |
Stop journaled ids for this run, or all stale journals. |
close() |
Cleanup then stop an isolated stack. |
| Method | Notes |
|---|---|
snapshot() / status |
Last fetched row. |
refresh() |
GET /api/v1/threads/:id |
wait({ until, timeoutMs, onInteraction }) |
idle (default) / quiet / error / needs_you. onInteraction: fail (default) / deny / approve-safe. |
waitForEvent(type) |
Long-poll thread events. |
| send(text, { mode, model, acpMode }?) | Follow-up turn. |
| stop() | Stop the thread. |
| fork() | Fork history into a new thread. |
| timeline() | Timeline snapshot. |
| interactions() / resolveInteraction(id, resolution) | Pending permission prompts. |
| assertHealthy() | Throws UNHEALTHY if status === 'error'. |
Raw product HTTP history (GET /api/v1/threads/:id/events) is paginated: the
default is the latest 500 events, with limit capped at 1,000 and a 16 MiB
payload budget. Events are ordered oldest to newest within each page. When
hasOlder is true, pass the returned nextBeforeSeq as beforeSeq to read the
preceding page. A single event exceeding the budget returns HTTP 413
(history-read-too-large); the stored conversation is preserved. Event waits
filter by type and sequence inside SQLite, and timeout diagnostics request only
the latest 20 events.
| Method | Notes |
|---|---|
snapshot() / status |
Last fetched row. |
refresh() |
GET /api/v1/cli-agents/:id |
wait({ until, timeoutMs }) |
idle (default) / working / done / exited. idle also accepts done/exited. |
reply(text) |
Inject a follow-up at the live prompt. |
stop() |
Stop the PTY session. |
Thread-scoped native tabs on an attached Electron window. Isolated
Zcc.launch({ isolated: true }) cannot run this — there is no BrowserView.
const picked = await zcc.browsers.pickInstance();
if ('skip' in picked) return;
const thread = await zcc.threads.spawn({ projectId: project.id, prompt: 'stop immediately' });
const browser = zcc.browsers.session({ ...picked.instance, threadId: thread.id });
const cycle = await runDesktopBrowserLeaseCycle(browser, { probeCdp: true });| Method | Notes |
|---|---|
listTabs() / create({ url, presentation }) |
Hidden about:blank automation profile by default. |
acquire({ tabIds, controllerLabel, ttlMs?, allowPersonal? }) |
Exclusive lease. Personal tabs need allowPersonal. |
connection(leaseId) |
Loopback ws://127.0.0.1 only; non-loopback throws. |
capture(tabId) |
Bounded JPEG (no focus required). |
reveal(tabId) / close(tabId) / release(leaseId) |
Reveal waits until that thread is focused; it never switches threads. |
runDesktopBrowserLeaseCycle (create → acquire → CDP Browser.getVersion →
capture → release → close) is the live-test helper.
runDesktopBrowserImportProbe then POSTs importCookies into the tagged
thread's automation partition (never personal). The default probe uses a
missing source profile so it does not copy signed-in cookies; set
ZCC_LIVE_BROWSER_IMPORT=1 to import a listed profile when one is available.
ctx: { runId: string; dataDir: string; tagged?: boolean }
// tagged defaults trueTagged (Zcc.threads.spawn, live tests) |
Untagged (zcc thread spawn / zcc agent launch) |
|
|---|---|---|
| Title | [zcc-live:<runId>] … |
User title as given |
| Thread visibility | hidden unless set |
visible unless set |
| Journal | {dataDir}/live-runs/ |
none |
origin |
'sdk' |
'sdk' |
| Role XOR model | assertRoleXorModel |
same |
| Harness | Thread providerId |
CLI Agent profile |
|---|---|---|
| Claude Code | claude-code |
claude |
| Cursor | acp-cursor |
cursor |
| Codex | codex |
codex |
| OpenCode | acp-opencode |
opencode |
| Harness | How to set mode |
|---|---|
| Claude Code | Leading /plan in the prompt (slash Plan). Default agent has no acpMode. |
| Codex | Same /plan prompt convention. |
| Cursor | acpMode: 'agent' | 'plan' |
| OpenCode | acpMode: 'build' | 'plan' |
Thread reasoning is reasoningLevel: low / medium / high.
Thread permission: permissionMode accept-edits / auto / full.
Trusted harnessRouting.byAdapter[profile]:
| Field | Values | Notes |
|---|---|---|
executionState |
plan / interactive / accept-edits / autonomous |
Write-allow uses trusted autonomous (Claude bypassPermissions, Cursor --force, Codex sandbox+never, OpenCode --auto). |
modelLevel |
low / medium / high / extra-high |
Cursor has no low / extra-high mapping. |
roleTargetId |
OpenCode build / plan |
XOR catalog model (modelTargetId / modelLevel / compatibility.model). A native role pins its own model. |
assertRoleXorModel rejects roleTargetId together with any catalog model.
OpenCode roleTargetId + executionState is also denied at the product server.
Unattended product-server HTTP cannot mint consent. accept-edits sets
unattendedAllowed: false on every harness target, so those launches return a
clean DENIED. Some interactive mappings do the same. That is policy, not
a crash.
Do not pass --extra-args from the operator CLI. Denied flags would be
stripped (sanitizeExtraArgs) and look like they worked.
Stripped tokens include --force, --permission-mode, -s, --auto,
--dangerously-skip-permissions, --mcp-config, --agent, and the rest of
DENIED_LAUNCH_FLAGS in @zana-ai/zcc-domain. Write-allow belongs on
trusted harnessRouting.executionState: 'autonomous'.
Tagged launches are hidden, titled [zcc-live:<runId>] …, and journaled under
{dataDir}/live-runs/. zcc live cleanup --stale (or zcc.cleanup({ stale: true }))
stops leftover tagged threads/agents and project processes after a killed Vitest.
Operator CLI launches are not tagged and are not journaled — live cleanup
will not reap them.
pnpm live:smoke # attach: threads + CLI Agent
pnpm live:matrix
pnpm live:cli-scenarios # second turn, file edit, stop mid-run
pnpm live:mode-reasoning # required after spawn / mode / reasoning changes
pnpm live:browser # desktop-browser product HTTP (attach Electron)
pnpm live:memory # required with mode-reasoning; Memory catalog injection
zcc live cleanup --staleRun live scripts from a host shell: unset ZCC_SESSION_ID && pnpm live:mode-reasoning && pnpm live:memory.
Against Claude Code, Cursor, Codex, and OpenCode:
- Second turn — launch → idle →
reply()→ idle. - File edit — write
zcc-live-<runId>-<profile>.txtunder live-sandbox with trustedexecutionState: 'autonomous'. - Stop mid-run —
stop()once working; session leaves the live list and its pid is dead.
OpenCode launches with native role build and no catalog --model.
Launches Claude Code, Cursor, Codex, and OpenCode on both surfaces (Modern
threads and CLI Agents) under each distinct mode and reasoning value.
Additive (each mode once, each reasoning once), not cartesian. Asserts the spawn
stays alive then stop() — no PONG, file write, or wait-until-idle. Missing
harnesses console.warn and skip — an empty verify that finishes in
milliseconds is a false green. Unattended DENIED for accept-edits / some
interactive mappings is expected, not a crash. Keep this suite off
pnpm live:matrix.
Prerequisites: attached app on :8780 (or :8781), Electron + matching
product-server credential, enrolled host-daemon, host shell
(unset ZCC_SESSION_ID).
Required after spawn / mode / reasoning / stop / plugin catalog injection
changes on threads or CLI Agents (see AGENTS.md): thread
create/send/wait/execution options (acpMode, reasoningLevel,
permissionMode), CLI Agent HTTP (/api/v1/cli-agents, term.create,
harnessRouting / executionState / modelLevel / native roles), host-daemon
harness providers, PtyManager.create(), Control SDK launch/wait, plugin
session instructions, and zcc thread / zcc agent commands. Follow with
pnpm live:memory on the same attach.
Against an attached Electron app (not an isolated stack): pick a connected
desktop window, spawn a tagged hidden thread, then
runDesktopBrowserLeaseCycle — create a hidden automation tab, acquire
control, probe loopback CDP Browser.getVersion, capture a JPEG, release,
close. Then runDesktopBrowserImportProbe POSTs importCookies into that
thread's automation partition (never personal) and asserts cookie values
never leave main. The default probe does not copy signed-in cookies; set
ZCC_LIVE_BROWSER_IMPORT=1 to import a listed profile when one is available.
Missing host / no registered desktop window / harness console.warn and skip.
A connected host that answers desktop_browser_unavailable is a failure
(product HTTP cannot see Electron's BrowserView broker). Keep this off
pnpm live:matrix. Native chrome (find-in-page, Take over)
stays e2e/desktop-browser-broker.spec.ts.
Playwright clients attach to the private endpoint returned by connection with
await chromium.connectOverCDP(wsEndpoint, { noDefaults: true }) (Playwright
1.60+). This preserves Electron's existing download and presentation policy;
the scoped bridge does not expose browser-wide settings or create additional
browser contexts. Use browser.contexts()[0] for the leased pages and
context.newPage() to create pages inside the same lease. The endpoint is
loopback-only and must remain private on the desktop host. The built-Electron
broker spec verifies input, evaluation, screenshots, new pages, and Take over.
Required after desktop-browser product HTTP, broker lease/CDP, capture,
cookie import-source redaction, or zcc browser / experimental_desktopBrowsers
changes (see AGENTS.md).
Against an attached app with the Memory plugin installed and running
(Zcc.launch({ isolated: true }) is not enough — the plugin must already be
installed): product-HTTP CLI via POST /api/v1/plugins/memory/cli (add /
catalog / search / get / isolate / forget), then a
tagged hidden Claude Code thread that must quote a seeded catalog summary
from contributeInstructions (no tools). Missing plugin or harness
console.warn and skip — an empty verify that finishes in milliseconds is a
false green. Keep this off pnpm live:matrix.
Required after Memory plugin CLI, catalog injection, or zcc memory changes,
and after thread / CLI Agent spawn changes (see AGENTS.md).
Each SDK verb has a zcc counterpart. Operator commands pass tagged: false.
| SDK | CLI |
|---|---|
spawnThread |
zcc thread spawn |
ThreadHandle.wait (idle / quiet) |
zcc thread wait / spawn --wait (--until turn → idle, quiet → quiet) |
ThreadHandle.send |
zcc thread tell |
ThreadHandle.stop |
zcc thread stop |
ThreadHandle.fork |
zcc thread fork |
ThreadHandle.timeline |
zcc thread log |
ThreadHandle.interactions |
zcc thread interactions |
launchCliAgent |
zcc agent launch |
CliAgentHandle.wait |
zcc agent wait / launch --wait |
CliAgentHandle.reply |
zcc agent reply |
CliAgentHandle.stop |
zcc agent stop |
browsers.listInstances |
zcc browser instances |
browsers.session().listTabs |
zcc browser tabs |
browsers.session().create |
zcc browser create |
browsers.session().acquire |
zcc browser acquire |
browsers.session().connection |
zcc browser connection |
browsers.session().release |
zcc browser release |
browsers.session().reveal / close / capture |
zcc browser reveal / close / capture |
browsers.listImportSources / importCookies |
zcc browser import-sources / import-cookies |
cleanupRun / cleanupStale |
zcc live cleanup --tag / --stale |
zcc thread spawn --project <id> --prompt "…" [--provider claude-code] [--model <id>] \
[--acp-mode <mode>] [--reasoning-level low|medium|high] [--permission-mode accept-edits|auto|full] [--wait]
zcc agent launch --project <id> --prompt "…" [--profile claude] [--persona <id>] [--title] \
[--execution-state plan|interactive|accept-edits|autonomous] \
[--model-level low|medium|high|extra-high] [--role <id>] [--wait] [--timeout]
zcc agent wait <id> [--until idle|working|done] [--timeout]
zcc agent reply <id> "…"
zcc agent stop <id>--role XOR --model-level. zcc agent ls and zcc term reply stay on the
control plane.
teams.launch— types exist (TeamLaunchSpec); the SDK has nozcc.teams.launch. Usezcc team launch(product HTTP).- Shell PTY —
zcc terminal create/POST /api/v1/terminals. Not a harness launch. - Isolated CLI Agent — rejected until host-rpc
harness.startexists.