Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions docs/ZCODE_LIGHT_MODE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# ZCode Light Mode — headless per-account workers (2026-10-28)

Run a clone's ZCode account **without its desktop window**: one node process
instead of a full Electron instance. Motivation: the 16 GB box cannot hold all
six desktop instances at once (the 2026-09-25 renderer-OOM freeze was commit
limit exhaustion), and the Start Plan provider throttles above ~3 concurrent
instances anyway (providerCode 3009 — more windows never bought more
throughput).

Everything below was **live-verified on 2026-10-28** against the installed
0.17.0 CLI (`resources\glm\zcode.cjs`) and the ZCodeSecondHome clone profile.

## What was measured

| Shape | Commit footprint |
| --- | --- |
| ZCode desktop instance | ~1.6 GB private commit each (3 running instances = 4.7 GB across 37 processes) |
| Headless CLI worker (`-p` one-shot) | ~0.35 GB main + ~0.58 GB across its 5 plugin-host children ≈ **0.9 GB total** |

Env isolation is complete: with the four levers set, `cli\config.json`,
`cli\db\db.sqlite`, `v2\credentials.json`, `v2\provider_config.json`,
`cli\plugins` and `cli\log` all resolve inside the clone home. Sessions a
light worker creates are visible to that clone's desktop app (same home =
same store) and vice versa.

## What works and what does not

**Works** — `zcode.cjs -p "<task>"` (one-shot, `--mode build|edit|plan|yolo`)
and `zcode.cjs app-server` (long-lived stdio protocol for orchestrators). Both
run under the system node; no Electron needed. `--resume sess_...` / `-c`
continue persisted sessions.

**Does not work** — the interactive TUI standalone. It imports `@zcode/tui`,
which only exists inside the installed `app.asar`, and `ELECTRON_RUN_AS_NODE=1`
disables asar resolution, so `zcode tui` cannot boot outside the app's own
runtime. Light mode is therefore **headless-only**; interactive chat stays on
the desktop app (keep it under the RAM guard instead).

**Model caveat (important)** — a clone home without a *standalone CLI login*
resolves its model to the FREE `opencode-zen` gateway model
(`muse-spark-1.3-contributor-free`), not the account's Start Plan. The desktop
app injects the plan adapter (`account:zai-start-plan/GLM-5.3-Flash` — the
working namespace, per the Primary's `model_usage` history) into its own
children only (`hasInjectedModelAdapter`), and a hand-written
`defaultModelSelection` in `provider_config.json` is normalized away by the
CLI on the next run. The sanctioned fix is one `zcode login zai` **per clone
home** — the login flow itself writes the correct default model selection.
`launch_zcode_cli_light.ps1` detects the missing default and prints the exact
commands.

## Usage

```powershell
# Interactive console worker for the Second account (type a task, empty = exit)
powershell -NoProfile -ExecutionPolicy Bypass -File "src\windows\launch_zcode_cli_light.ps1" -Instance Second

# One-shot task on the Fifth account
powershell -NoProfile -ExecutionPolicy Bypass -File "src\windows\launch_zcode_cli_light.ps1" -Instance Fifth -Prompt "summarize the TODOs in this repo" -Cwd "C:\path\to\repo"

# Long-lived stdio worker for an orchestrator (the caller owns the pipe)
powershell -NoProfile -ExecutionPolicy Bypass -File "src\windows\launch_zcode_cli_light.ps1" -Instance Third -Server
```

GHOST.bat ZCode submenu **[7]** launches the interactive variant in its own
window. `-Mode` maps to the CLI's permission modes (default `build`).

## One-time login per clone home (enables the Start Plan)

The one-click way: **GHOST.bat ZCode [28]** — pick the clone, a browser opens,
sign in with THAT clone's account, and the launcher verifies
`defaultModelSelection` landed (exit 0 = done). Manually:

```bat
set HOME=%USERPROFILE%\ZCodeSecondHome
set USERPROFILE=%USERPROFILE%\ZCodeSecondHome
set ZCODE_DATA_BASE_DIR=%USERPROFILE%\ZCodeSecondHome
set ZCODE_STORAGE_DIR=%USERPROFILE%\ZCodeSecondHome\.zcode
node "%LOCALAPPDATA%\Programs\ZCode\resources\glm\zcode.cjs" login zai
```

Log in with **that clone's** account in the browser window. The login writes
`defaultModelSelection` itself; the light launcher's warning (and the T3
bridge's) disappears after that. One credential = one instance — never share
a login across clone homes.

## RAM discipline for the desktop instances

- Soft guard: `launch_zcode_second_instance.ps1` now counts running ZCode
instances and, at `-MaxConcurrent` (default 3), prints live commit numbers
and asks. Non-interactive callers (watchdog relaunch, test harness) warn and
continue instead of hanging. Relaunching an already-running clone just
focuses it and never prompts.
- `-LowRam` (experimental): launches the app with `--disable-gpu`, dropping the
per-instance GPU process. Off by default — rendering falls back to software.
- Pagefile: the OOM incident happened at a 16 GB RAM + ~16 GB pagefile commit
limit. A fixed pagefile of ~32 GB on the fastest drive is the cheap insurance
(System Properties → Performance → Advanced → Virtual memory → set Custom
size 32768/32768 on the SSD, then reboot).
- The Start Plan admission control (see `tools/diagnose_zcode_throttle.ps1`)
is the other ceiling: even with infinite RAM, 4-6 concurrent instances fight
over provider concurrency. Two or three active instances is the sweet spot.

## T3 Code (pingdotgg) — evaluated 2026-10-28, deferred

T3 Code is an open-source control surface for headless agent CLIs (Claude
Code, Codex, OpenCode, Grok, ...) with desktop/web/mobile clients. Verdict for
this fleet:

- **No ZCode/Z.ai provider** — it cannot drive the Start Plan accounts at all,
and multi-account for a single provider is still an open feature request
(issues #2111 / #1444).
- It DOES support "Local ACP command" providers (arbitrary executable +
per-provider environment overrides) — but the zcode CLI speaks its own
`app-server` stdio protocol, not ACP (verified: zero ACP markers in the
bundle), so wiring it in means writing and maintaining an ACP ↔ app-server
bridge against two fast-moving early-stage projects. Deferred until the
unified-window + mobile-control benefit justifies that maintenance.
- The zero-effort T3 win is elsewhere: the OpenCode fleet (`opencode` CLI) is
natively supported and could move into a single T3 window today.

The light worker here (`launch_zcode_cli_light.ps1`, especially `-Server`) is
the building block any such bridge would sit on.
193 changes: 193 additions & 0 deletions src/windows/launch_zcode_cli_light.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
#Requires -Version 5.1
# ZCode Light Worker Launcher (headless, per-clone-account).
# Runs a clone's ZCode account WITHOUT its desktop window: one node process
# (~0.9 GB commit with plugin hosts) instead of a full Electron instance
# (~1.6 GB+ private commit measured across the running fleet, 2026-10-28).
#
# Mechanism (live-verified 2026-10-28 on ZCodeSecondHome):
# - The CLI bundle is resources\glm\zcode.cjs; headless = -p "<prompt>" and
# long-lived = the "app-server" stdio subcommand. Both run fine under the
# system node (v24 verified); the interactive TUI does NOT - @zcode/tui
# only resolves inside the app's own Electron runtime (app.asar), so a
# standalone TUI is impossible (see docs/ZCODE_LIGHT_MODE.md).
# - Per-account isolation rides the env levers:
# HOME + USERPROFILE -> <CloneHome> (redirects cli\config.json +
# cli\db\db.sqlite via os.homedir())
# ZCODE_DATA_BASE_DIR -> <CloneHome> (redirects v2\credentials.json +
# telemetry + provider_config.json)
# ZCODE_STORAGE_DIR -> <CloneHome>\.zcode (cli\exec, clipboard, caches)
# Sessions land in the clone's own db.sqlite and share history with that
# clone's desktop app (same home = same store).
#
# MODEL CAVEAT (verified): a home without a standalone CLI login runs headless
# turns on the FREE opencode-zen model (muse-spark-1.3-contributor-free), NOT
# the account's Start Plan - the desktop app injects the plan adapter into its
# own children (hasInjectedModelAdapter), and a hand-written
# defaultModelSelection in provider_config.json is normalized away by the CLI.
# The sanctioned fix is ONE `zcode login zai` per clone home (this script
# prints the exact command when it detects the default is missing); the login
# writes the correct defaultModelSelection itself. Until then, light workers
# burn the free model, not the plan.
#
# Modes:
# default interactive REPL: type a task per line, empty = exit
# -Prompt "text" one-shot: run the task and exit (verify-after on exit code)
# -Server foreground `app-server` stdio worker for orchestrators
# (the caller owns the pipe; never detach this mode)
param(
[ValidateSet('Second', 'Third', 'Fourth', 'Fifth', 'Sixth')]
[string]$Instance = 'Second',
[string]$Prompt = '',
[ValidateSet('build', 'edit', 'plan', 'yolo', 'guarded')]
[string]$Mode = 'build',
[string]$Cwd = '',
[switch]$Server,
# One-time interactive `zcode login zai` for THIS clone home: the browser
# OAuth writes defaultModelSelection itself, which is what routes light
# workers AND the T3 bridge to the account's Start Plan (verify-after).
[switch]$Login,
[switch]$NoLoginWarn
)
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8

$UserProfile = $env:USERPROFILE
if ([string]::IsNullOrWhiteSpace($UserProfile)) { $UserProfile = [Environment]::GetFolderPath('UserProfile') }
$LocalAppData = $env:LOCALAPPDATA
if ([string]::IsNullOrWhiteSpace($LocalAppData)) { $LocalAppData = Join-Path $UserProfile 'AppData\Local' }

# CLI bundle path: same install locations the desktop launcher probes.
$ZcodeCliCandidates = @(
(Join-Path $LocalAppData 'Programs\ZCode\resources\glm\zcode.cjs'),
'C:\Program Files\ZCode\resources\glm\zcode.cjs',
'C:\Program Files (x86)\ZCode\resources\glm\zcode.cjs'
)
$ZcodeCli = $ZcodeCliCandidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
if (-not $ZcodeCli) { throw "zcode.cjs not found. Checked: $($ZcodeCliCandidates -join '; ')" }

$InstanceMap = @{
'Second' = @{ Home = 'ZCodeSecondHome'; AccentHex = '#066BCB'; DisplayName = 'Blue' }
'Third' = @{ Home = 'ZCodeThirdHome'; AccentHex = '#D4AA10'; DisplayName = 'Yellow' }
'Fourth' = @{ Home = 'ZCodeFourthHome'; AccentHex = '#13826A'; DisplayName = 'Green' }
'Fifth' = @{ Home = 'ZCodeFifthHome'; AccentHex = '#CA0607'; DisplayName = 'Red' }
'Sixth' = @{ Home = 'ZCodeSixthHome'; AccentHex = '#CB5D06'; DisplayName = 'Orange' }
}
$cfg = $InstanceMap[$Instance]
$CloneHome = Join-Path $UserProfile $cfg.Home
$CloneZCode = Join-Path $CloneHome '.zcode'

if (-not (Test-Path -LiteralPath (Join-Path $CloneZCode 'v2\credentials.json'))) {
throw "Clone home $Instance has no profile yet ($CloneZCode). Launch its desktop instance once (GHOST.bat ZCode [1]-[5]) to seed it, then retry."
}

# Per-account env levers. USERPROFILE is set alongside HOME because the CLI's
# os.homedir() follows USERPROFILE on Windows while one bundle helper reads
# HOME - both is the only shape verified end-to-end (db, config, credentials,
# plugins and logs all landed in the clone home).
$env:HOME = $CloneHome
$env:USERPROFILE = $CloneHome
$env:ZCODE_DATA_BASE_DIR = $CloneHome
$env:ZCODE_STORAGE_DIR = $CloneZCode

# Node probe = functional (matches the repo's Python rule): a candidate only
# counts when --version exits 0 reporting a version.
$NodeExe = $null
$nodeCandidates = @()
try {
$cmd = Get-Command node -ErrorAction SilentlyContinue
if ($cmd) { $nodeCandidates += $cmd.Source }
} catch { }
$nodeCandidates += 'C:\Program Files\nodejs\node.exe'
foreach ($candidate in $nodeCandidates) {
if ([string]::IsNullOrWhiteSpace($candidate) -or -not (Test-Path -LiteralPath $candidate)) { continue }
try {
$v = & $candidate --version 2>$null
if ($LASTEXITCODE -eq 0 -and ("$v") -match '^v\d+') { $NodeExe = $candidate; break }
} catch { }
}
if (-not $NodeExe) { throw "node.exe not found or non-functional. Install Node.js (https://nodejs.org) and re-run." }

# Standalone-login check: provider_config.json -> config.defaultModelSelection
# is the signature the CLI's own `login` flow writes. Missing = headless runs
# resolve to the free zen model, so warn with the exact fix (unless silenced).
if (-not $NoLoginWarn) {
$provCfgPath = Join-Path $CloneZCode 'v2\provider_config.json'
$hasDefaultModel = $false
if (Test-Path -LiteralPath $provCfgPath) {
try {
$provRaw = Get-Content -LiteralPath $provCfgPath -Raw
if ($provRaw -match '"defaultModelSelection"') { $hasDefaultModel = $true }
} catch { $hasDefaultModel = $false }
}
if (-not $hasDefaultModel) {
Write-Host "[model] WARNING: $Instance has no standalone CLI login - headless runs use the FREE" -ForegroundColor Yellow
Write-Host '[model] opencode-zen model, NOT this account''s Start Plan (the desktop app injects the' -ForegroundColor Yellow
Write-Host '[model] plan adapter only into its own children; hand-edited defaults are ignored).' -ForegroundColor Yellow
Write-Host '[model] One-time fix - in a terminal run:' -ForegroundColor Yellow
Write-Host ('[model] set HOME={0}' -f $CloneHome) -ForegroundColor Yellow
Write-Host ('[model] set USERPROFILE={0}' -f $CloneHome) -ForegroundColor Yellow
Write-Host ('[model] set ZCODE_DATA_BASE_DIR={0}' -f $CloneHome) -ForegroundColor Yellow
Write-Host ('[model] set ZCODE_STORAGE_DIR={0}' -f $CloneZCode) -ForegroundColor Yellow
Write-Host ('[model] node "{0}" login zai' -f $ZcodeCli) -ForegroundColor Yellow
Write-Host '[model] (log in with THIS clone''s account; the login writes the plan default itself).' -ForegroundColor Yellow
}
}

if ($Login) {
Write-Host "[login] One-time standalone login for $Instance (home: $CloneHome)." -ForegroundColor Cyan
Write-Host "[login] A browser window will open - sign in with THIS clone's account." -ForegroundColor Cyan
& $NodeExe $ZcodeCli login zai
if ($LASTEXITCODE -ne 0) {
Write-Host "[login] FAILED - exit code $LASTEXITCODE (see output above)." -ForegroundColor Red
exit 1
}
# Verify-after: the login flow itself writes defaultModelSelection into the
# home's provider_config.json - its presence is the success signature.
$provCfgPath = Join-Path $CloneZCode 'v2\provider_config.json'
$hasDefaultModel = $false
try {
$provRaw = Get-Content -LiteralPath $provCfgPath -Raw
if ($provRaw -match '"defaultModelSelection"') { $hasDefaultModel = $true }
} catch { $hasDefaultModel = $false }
if ($hasDefaultModel) {
Write-Host '[login] OK - defaultModelSelection written; light workers and the T3 bridge now run on this account''s plan.' -ForegroundColor Green
exit 0
}
Write-Host '[login] FAILED - login exited 0 but defaultModelSelection is still missing from provider_config.json.' -ForegroundColor Red
exit 1
}

if ([string]::IsNullOrWhiteSpace($Cwd)) { $Cwd = $UserProfile }

function Invoke-LightPrompt {
param([string]$Text)
& $NodeExe $ZcodeCli -p $Text --mode $Mode --cwd $Cwd
if ($LASTEXITCODE -ne 0) {
Write-Host ("[light] FAILED - exit code {0} (output above; see {1}\.zcode\cli\log)." -f $LASTEXITCODE, $CloneHome) -ForegroundColor Red
}
return ($LASTEXITCODE -eq 0)
}

if ($Server) {
Write-Host "[light] $Instance ($($cfg.DisplayName)) app-server on stdio - the CALLER owns this pipe." -ForegroundColor Cyan
Write-Host "[light] home=$CloneHome cli=$ZcodeCli"
& $NodeExe $ZcodeCli app-server
exit $LASTEXITCODE
}

if (-not [string]::IsNullOrWhiteSpace($Prompt)) {
Write-Host "[light] $Instance ($($cfg.DisplayName)) one-shot task (mode=$Mode, cwd=$Cwd)..." -ForegroundColor Cyan
$ok = Invoke-LightPrompt -Text $Prompt
if (-not $ok) { exit 1 }
exit 0
}

# Interactive REPL: one node process per task, per-account isolation everywhere.
Write-Host ("[light] ZCode {0} light worker - account isolated to {1}" -f $Instance, $cfg.Home) -ForegroundColor Cyan
Write-Host '[light] Type a task and press Enter. Empty input exits. Each task is a fresh session'
Write-Host '[light] in the clone''s own store (shareable with its desktop app).'
while ($true) {
$task = Read-Host '[light] task'
if ([string]::IsNullOrWhiteSpace($task)) { Write-Host '[light] bye.'; exit 0 }
$null = Invoke-LightPrompt -Text $task
}
Loading