feat(desktop): custom installer + React UI refactor (monorepo, design system, frameless)#93
Merged
Merged
Conversation
First piece of the custom app-style installer (replacing the classic NSIS wizard). internal/setup mirrors exactly what NSIS did — lay the bundled binaries into %LOCALAPPDATA%\Programs\Clawtool, create Start-menu + Desktop shortcuts, add the dir to the user PATH, and register an Add/Remove entry with a self-contained uninstall.ps1 — but as a library the clawtool-setup app drives with a modern UI. Windows OS integration via PowerShell (no fragile COM/syscall). Cross-platform-compilable; Windows branches no-op elsewhere.
The mechanics landed in the main module's internal/setup (which is actually the recipe/onboard-runner package) by mistake. Move them to a self-contained cmd/clawtool-installer/install package: pure os/exec + PowerShell, no main-module deps, so the Wails installer build stays decoupled (same reason clawtool-installer is its own module). internal/setup restored untouched.
The setup build embeds the payload (Clawtool.exe, clawtool.exe, ClawtoolUpdate.exe) via //go:embed; when present, the binary boots in "setup" mode and self-installs to %LOCALAPPDATA%\Programs\Clawtool — no classic wizard — then launches the installed app. Progress streams to a dedicated setup phase in the UI as "setup:step" events, with monotonic progress that tolerates async event delivery.
Replace the NSIS wizard build with a 2-build flow: compile the app (Clawtool.exe), stage it plus the headless clawtool.exe and ClawtoolUpdate.exe into payload/, then recompile so the payload is embedded — producing a self-installing ClawtoolSetup.exe with no classic wizard. Drops the NSIS toolchain, WebView2-bootstrapper and EnVar plugin fetches, and the committed project.nsi/wails_tools.nsh.
In setup mode the binary launches the installed Clawtool.exe before it quits, so both run briefly. Sharing one single-instance lock made the freshly-launched app treat the still-running setup as the first instance and exit instead of starting. Setup now uses a distinct lock id.
Rework the custom installer into a guided, app-style stepped flow — Welcome (Install button) → Installing (live, timestamped install log with per-binary sizes and paths) → Done (Open / Finish) — instead of a zero-click auto-install. RunSetup no longer launches the app; the Done step's Open button does, via OpenInstalled, keeping the user in control. Add a brand package as the single source of product identity (name, CLI, publisher, tagline, derived exe/shortcut/dir names); install.go and app.go route through it, and App.Brand feeds the frontend so the UI hardcodes nothing. Renaming the product is now a one-file edit (plus the build-time artifact names noted in brand.go). Reground the visual design in a well-regarded terminal's real shipped dark theme: #181818 base, opacity-layered surfaces, one cool accent used sparingly, hairline borders, small radii, no gradients — replacing the earlier brighter, gradient-heavy look.
Apply the same grounded design to the app shell and Home/Network/Updates views: drop the square logo mark for a plain wordmark, replace the gradient brand/buttons, the card gradients, the drop shadows and the Home glow with flat opacity-layered surfaces, hairline borders and the single cool accent used sparingly. The wordmark and Home headline now read the product name from App.Brand so nothing is hardcoded.
The Conventional Commits check reads the PR title from the event payload; the default pull_request types omit `edited`, so fixing a bad title never re-ran the check (and a rerun only replays the stale payload). Subscribe to `edited` so a corrected title re-validates.
…e app The GUI (Clawtool.exe) and headless CLI (clawtool.exe) differ only in case, so staging both into one directory on the case-insensitive CI runner made the CLI build clobber the app — the embedded payload ended up with only clawtool.exe, no Clawtool.exe. payloadPresent() (case-sensitive embed lookup for "Clawtool.exe") then returned false, so ClawtoolSetup.exe booted as the app instead of the installer and skipped the whole flow. Move the CLI into a bin/ subdir (payload/bin, install root/bin) so it never shares a directory with the GUI; put bin/ on PATH so `clawtool` still resolves; point locateClawtool at bin/; apply the same split to the macOS .app embed. Add a regression test asserting no case collision in the layout.
Rework Home/Network/Updates away from bordered cards toward structure from whitespace + hairline dividers + type hierarchy, grounded in modern dev-tool UIs (Linear/Raycast/Warp). Home becomes a status hero + inline metric strip + hairline agent rows; Network shows agents as rows and the cross-device + LAN controls as a plain grouped section; Updates is a hairline key/value list. Slim sidebar with an accent-bar active indicator and a live status dot. Lays out within the default 980x660 (and the 820x560 minimum) without overflow.
Add a short settle delay after taskkill in stopRunning so an upgrade-over-running install doesn't hit a file lock when overwriting the binaries the old app/tray/daemon just held open (mirrors the old NSIS Sleep). The old app, tray and daemon are stopped before install; the Done step relaunches the freshly-installed app.
Begin the proper UI architecture refactor (replacing the single hand-written index.html): a pnpm workspace under desktop/ with a shared @clawtool/design-system package — Warp-grounded design tokens (CSS custom properties, 3-tier), global base, and the first React components (Wordmark, StatusDot, Badge, Button, Metric, Section, AgentRow, and the frameless cross-platform TitleBar with platform-conditional window controls). A _gallery app verifies it builds with Vite and renders. This is the shared base the installer/updater/app surfaces and the split Go binaries will consume.
@clawtool/bridge wraps the Wails-injected globals behind a typed, never-throw layer: App.* method wrappers (mode/brand/networkSnapshot/circle*/lan*/update/ setup/...) returning parsed types from the contract, runtime event on/emit, and Win window controls (minimise/toggleMaximise/...) + environmentPlatform. Surfaces consume this instead of touching window.go/window.runtime directly.
The main app surface built on the design system + bridge: a frameless shell (custom TitleBar with platform-conditional window controls + slim Sidebar with accent-bar nav and a live status footer) and the three views — Home (status hero + inline metric strip + agent rows), Network (local agents, cross-device circle key + LAN switch, peers), Updates (key/value + actions). Adds Sidebar, Switch and KeyValue to the design system. Data flows through @clawtool/bridge; verified building with Vite and rendering at 980x660.
installer-ui: stepped Welcome -> Installing (live LogFeed + ProgressBar from setup:step) -> Done, frameless. updater-ui: minimal launch splash driven by an update:status event. Adds ProgressBar + LogFeed to the design system. All three surfaces (app/installer/updater) now build with Vite and typecheck clean.
Replace the hand-written vanilla index.html with the Vite-built React SPA (app-ui) embedded in the proven Wails binary. The single bundle routes on App.Mode(): setup -> installer surface, installer (first run) -> onboarding init, app -> the main app. Compose the installer surface into app-ui via a workspace dep so one bundle serves every mode. Make the window frameless (custom titlebar drives drag + window controls; macOS keeps inset traffic lights via TitleBarHiddenInset). CI builds the pnpm monorepo and places the bundle into the Go embed dir before the existing 2-build payload flow. The physical 3-process split (separate updater/app exes) is staged on the same surfaces but deferred until a Windows smoke-test, to avoid shipping an untested install pipeline.
… affordances - Fix circle key: auto-copy on generate + a Copy button + toast (was uncopyable). - Implement "Join with a key" (was a dead button): reveal input -> circleSet. - Per-family AgentIcon (claude/codex/gemini/opencode/hermes, brand-tinted monograms). - New Agents tab: per-agent rows with icon + family/bridge/tags/sandbox, Connect (claim) / Disconnect (release) actions, and this device's A2A card (name/version/ url/skills). Go bindings AgentClaim/AgentRelease/LocalCard added. - Install/connect affordances: bridge-missing agents show "Install bridge"/"Connect" instead of a dead row; empty states route to the Agents tab. - Network view now focuses on cross-device (circle/LAN) + devices.
…idge install - Agent logos: real brand marks via Simple Icons where redistribution is permitted (Claude, Gemini); clean brand-tinted fallback for families the licensed library omits (e.g. OpenAI/Codex, which the brand restricts). - Agents tab: per-agent status chips (ready / bridge missing / not installed / disabled) with a 5s live refresh; click a row for a detail SidePane, and a "This device's card" pane shows the A2A card (name/version/url/skills). - Install affordance now actually installs: BridgeAdd Go binding runs `clawtool bridge add <family>` (the canonical install) instead of a claim that errored on a missing bridge/binary; errors surface verbatim. - Pairing: frame cross-device as generate/enter a pairing key on the same network (copy + Toast already fixed); a true short pairing-code protocol is a daemon follow-up. - New design-system components: SidePane, AgentIcon (Simple Icons-backed).
Map every agent family Simple Icons covers to its real brand mark: Claude, Gemini, Ollama, Perplexity, Mistral, GitHub Copilot, Cursor, Windsurf, Hugging Face (+ Nous Hermes via HF). Near-black marks render in the foreground tint so they stay visible on the dark UI. Families the licensed library omits — OpenAI/Codex (brand-restricted) and opencode — keep a clean brand-tinted fallback chip; we don't hand-copy a restricted logo.
AgentIcon now resolves icons in order: a custom asset at design-system/src/assets/logos/<family>.svg|png (picked up automatically via import.meta.glob) -> the bundled Simple Icons brand mark -> a tinted fallback chip. Lets the owner supply a logo the default licensed set omits (e.g. Codex) by sourcing an SVG from a licensed set (lobehub.com/icons, MIT) and saving it in that folder — no heavyweight icon dependency, no bundled restricted mark.
Owner-sourced Codex mark (from lobehub.com/icons, MIT) dropped into assets/logos/; AgentIcon's custom-logo loader renders it automatically.
Surface the daemon's existing pairing ledger (a2a.PairingStore) so the receiving device shows an approve/deny prompt when another machine wants to pair: - core: new `clawtool peer pair list|approve|deny` CLI over GlobalPairingStore (pending requests carry a short code; approve/deny by code or fingerprint). - app: PairList/PairApprove/PairDeny bindings + a PairPrompt that polls the ledger and overlays "<device> wants to pair · code <code> · Deny/Accept". The receiving prompt + approve/deny is complete and unit-tested (CLI). The pending request is created when the other device contacts this one over the existing relay path; an explicit sender-side "Pair" button (proactive request to a discovered device) is the remaining piece and needs a two-device test.
…to pair
Sender side of cross-device pairing:
- core: POST /v1/peers/{id}/pair-request — resolves the peer's address from
the registry and relays this device's install fingerprint + display name to
the peer's /v1/relay (circle-key authed, mirrors proxyPeerAgents), so the
peer records a pending request and shows its approve prompt. New
`clawtool peer pair request <peer_id>` CLI calls it via the local daemon.
- app: PairRequest binding + Network "Devices on your network" now lists
mDNS-discovered clawtool devices (name + address + status) each with a Pair
button that sends the request.
Pairs with the existing approve popup: Pair here -> approve prompt there.
Cross-device handoff still needs a two-device (Mac+Windows) smoke test.
…ner Home - Settings view: About (name/version/cli/install dir), Diagnostics that runs `clawtool doctor` (new RunDoctor binding) and shows the output, and a GitHub link (BrowserOpenURL via a new openURL bridge helper). - Icons: replace the hand-rolled SVGs with lucide-react (nav + frameless window controls); delete the bespoke icons module. - Titlebar: move the clawtool wordmark into the titlebar (leading edge); the sidebar starts straight at the nav. TitleBar gains an optional `brand` slot. - Home: drop the agent list (Agents tab owns that now) — Home is the status hero + a clickable metric strip (Agents / Devices / Cross-device) + footer.
The desktop Updates view shows a dynamic "What's new" section, so the machine-readable check needs the release body + page URL alongside the existing version fields. Both are omitempty, keeping the contract backward compatible.
…ynamic updates - Shared view header (title left, action button right) across Home/Agents/ Network/Updates so every screen aligns the same way; content uses the full width instead of a capped column. - Home: "Engine is running" with the metric strip on the right, pulse removed. - Network: device rows expand via a chevron to list that device's agents (logo, family/bridge/tags, status) loaded from peerAgents; "Settings" side pane holds discoverability + pairing key; the pairing key stays locked (blurred, lock icon) until the device is discoverable; device list no longer clips. - Updates: a single state-driven button (Check now → Install vX → installing shimmer → Restart to finish) plus a dynamic "What's new" changelog rendered from the release notes the CLI now returns. - Settings: GitHub link as a corner icon. - Add a Vite dev-server stub layer so the UI runs in a plain browser with realistic data for iteration.
When a session is open the sidebar nav (Home/Agents/Network/Updates/
Settings) is fully hidden, not just deprioritized — the rail becomes
conversation-only. Added a subtle slide+fade animation on the rail so
the swap feels intentional, and on the workspace itself so a session
fades in instead of popping.
Composer follows the reference more closely:
- Slim breadcrumb header in SessionView: folder icon · folder name /
session title — no h1, no back arrow (the rail handles switching).
- Editor wraps the send glyph (ArrowUp) in its own bottom-right corner;
there's no separate Send button anymore. The editor padding-right
reserves space so text never collides with the glyph.
- Footer row below the editor carries an agent + env badge on the right
("Codex · Local" / "Codex · <peer-name>") so the operator always sees
which instance the turn is dispatching to, and a working indicator on
the left.
- Placeholder copy changed to "Type / for commands" to telegraph the
slash-command direction.
The composer's "Codex · Local" badge is now a real picker. Clicking it opens a dropdown of every CALLABLE agent on this device (filtered from /v1/agents — bridge-missing families never show), and selecting one persists Session.agent so subsequent turns dispatch to that family. runAgentTurn reads the session's pinned family before shelling out to `clawtool send --agent <family>`; empty falls back to codex for the same "Claude Code loops" reason. SessionsSetAgent + sessionAgent join the existing setCwd / setEnv pair. TitleBar swaps sides on macOS: the brand wordmark trails (right edge) so the native traffic lights (drawn by Wails' TitleBarHiddenInset) own the leading side without competition. Windows/Linux keep brand-left, controls-right.
…ys-on composer Sessions landing now matches the reference Welcome screen: a hero heading, a compact Recents row (title + folder + age + remove + drill-in chevron), and a composer pinned to the bottom of the main area that's always available. Typing + send mints a session, applies the landing's draft env/cwd to it, opens its workspace, and seeds the first message — Conversation auto-sends the seed on mount so "type → hit Enter → see reply" is one gesture, not two. The Sessions/SessionView/Conversation chain plumbs the seed as an initialMessage prop; the shell tracks it in App-level state and clears it via onSeedConsumed after the first send so re-renders don't re-fire. The "Add project" preamble and project-required workflow are gone — sessions stand on their own.
…t fallback
Three live bugs found while testing the real flow on Mac:
1. The shared daemon goes stale (sleep, manual stop, crash) and the
send subprocess gets no upstream — agents list returns empty, the
send fails silently. AgentSend now runs `clawtool daemon start` (an
Ensure-style no-op when healthy) before the dispatch, so a stale
daemon revives in the same turn.
2. Codex was the default but the operator's codex CLI hits its usage
limit during a real session, returning an error mid-stream. Default
now resolves to opencode — verified live and reliable on this Mac
(claude loops because Claude Code IS the only "claude" instance and
the supervisor refuses to dispatch to its caller).
3. The composer's agent picker showed only the default ("codex") when
the daemon was briefly stale because /v1/agents returned []. The
Conversation now falls back to the canonical family set
(opencode/codex/gemini/claude) when the snapshot is empty, so the
picker stays switchable while AgentSend's new Ensure call brings
the daemon back.
The previous fallback surfaced a canonical family set when the snapshot came back empty, which lied to the operator: the picker would show agents that might not even be installed on this device. Drop the hardcoded fallback completely. The picker always reflects what /v1/agents actually returns. To handle the "daemon briefly stale" case that motivated the fallback, the same polling loop now calls EnsureGateway before each snapshot so a dead daemon revives on the next tick and the real list comes back on its own. The daemon's state, not a static guess, is the only source of truth.
…dot, no avatars The transcript looked like a chat sample app: hard-edged YOU / AGT avatar circles, chips above the editor, raw error text inline. Reshaped to mirror the vendored reference: - User messages render as a soft accent chip (~80% width cap), inline at the start of the row — no avatar, no "YOU" label. - Assistant messages lead with a small amber dot (pulses while streaming); the body sits next to it in plain prose, no badge. Errors flip the dot red and dim the body. - The "would loop" supervisor error becomes a one-line friendly message telling the operator to pick a different family in the agent badge, not a raw shell-quoted trace. - Composer collapses chips and the agent badge into the SAME footer row beneath the editor, leaving a single full-width editor card with the send glyph in its corner. No "Working…" text label — the pulsing dot on the in-flight message carries that signal now.
…+ rail back Three operator-reported gaps: 1. Traffic lights were missing on macOS because Frameless:true strips the native window chrome including the lights. Make Frameless conditional — false on darwin (TitleBarHiddenInset then gives the inset look WITH the OS traffic lights), true on Windows/Linux where the React layer draws its own controls. 2. The agent picker keyed on family and hardcoded an "opencode" default, so it couldn't represent multiple instances of one family and lied when the default wasn't installed. Now everything is INSTANCE-based: the badge + menu label by instance id, dispatch targets the instance, and the default is the first callable instance the daemon actually reports (preferring non-claude, since the local claude-code instance loops on self-dispatch). No hardcoded names anywhere — Go's firstCallableAgent reads /v1/agents and the UI adopts the first callable on load. A device with no callable agent gets a clear error instead of a doomed dispatch. 3. Inside a session there was no way back to the list. SessionRail gains an "All sessions" back affordance above "New session".
Follow-up to the prior commit whose three edits partially missed due to a mid-flight file-hash race: the agent badge now reads agentLabel (the instance id, "no agent" until one loads), the dead capitalize() helper is gone, and SessionRail's onBack prop + ChevronLeft import are in place so "All sessions" compiles. Typecheck + Vite build + installer go build all green.
…heartbeat Two operator-reported defects: 1. Traffic lights never appeared on macOS. The earlier "make Frameless conditional" edit silently missed — it targeted a struct shape that didn't match the real main.go, so Frameless stayed hardcoded true and the native chrome (lights included) was stripped. Apply it for real: frameless := runtime.GOOS != "darwin", so darwin keeps native chrome + TitleBarHiddenInset shows the inset traffic lights. Also make the React TitleBar transparent + borderless on macOS (.barMac) so it never paints over the light zone and the window reads as one surface. 2. `clawtool peer heartbeat` (installed as a Claude Code Stop/UserPromptSubmit hook) errored "no such file" every turn for any session that never ran `peer register` — the common case. Treat a missing session-state file as a clean no-op (exit 0, no stderr) so Claude Code stops surfacing it as a hook failure.
…ght place The prior commit's main.go edit had silently missed (hash race), leaving "runtime" imported-but-unused — the installer wouldn't compile, and Frameless stayed hardcoded true so macOS still had no traffic lights. Apply it correctly now: frameless := runtime.GOOS != "darwin", Frameless: frameless. And the heartbeat no-op landed in readPeerIDFile (redundant) instead of runPeerHeartbeat; move it to the caller so a missing session-state file exits 0 silently. Verified: installer + cli compile, `clawtool peer heartbeat` now exits 0 with no stderr when unregistered.
…eam back
Two operator-reported defects, both about seeing a real reply instead of an
error or a "dispatching" stub.
1. Picking claude on the local device errored "would loop." The claude
transport's guard refuses to dispatch when CLAUDE_CODE_SESSION_ID is set
— but the desktop app is NOT a Claude Code session; it only inherited
that var (and CLAUDECODE) from the shell that launched it. A GUI-
dispatched turn is a fresh subprocess, never a re-entry, so runAgentTurn
strips both markers from the child env (envWithout, now variadic).
Verified live: `env -u CLAUDE_CODE_SESSION_ID -u CLAUDECODE clawtool send
--agent claude` returns a real reply (LOCAL_CLAUDE_OK) instead of the
loop error. extractAgentText also learned the claude-code stream-json
shape ({"type":"assistant","message":{"content":[{text}]}}) so the reply
renders.
2. Sending to a paired device only showed "Dispatching / Delivered" — the
message hit the peer's inbox with no agent run, no reply. Added
/v1/peer-run (receive side: run the supervisor, stream NDJSON back,
peerOrBearer-gated) + proxyPeerRun (/v1/peers/{id}/run: forward over mTLS
and stream through). dispatchAgentToPeer drives that endpoint and
translates streamed frames into delta events, so the remote agent's
answer streams into the conversation token-by-token; pairing_required /
error statuses surface cleanly. Turn timeout raised 90s → 10m.
… session bleed
A multi-agent audit of the ADE surface confirmed 12 real findings; this lands
the high-value ones (verified against source + a local smoke test).
Cross-device correctness (was: remote turn ran the wrong agent in the wrong dir):
- dispatchAgentToPeer now carries the session's pinned agent + cwd in the
peer-run body, not just {message}. Without the agent the receiver self-picked
its first callable (often its own claude → loop); without cwd it ran in the
daemon's dir.
- peer_run_handler: peerRunRequest gains Cwd; handlePeerRun passes opts["cwd"]
to the supervisor (transports honor it) so the remote run lands in the
operator's folder.
- proxyPeerRun now surfaces a peer's >=400 response body as {error} instead of
an opaque stream that emits nothing, so the UI shows the real cause.
Local correctness (was: attached folder silently ignored):
- runAgentTurn pins $PWD to the session cwd alongside cmd.Dir — Go's cmd.Dir
doesn't update the inherited PWD, and the upstream transports resolve their
workdir from $PWD, so tools were running in the parent's dir.
Perf / churn:
- runAgentTurn replaces the unconditional `daemon start` on every turn with
ensureDaemonBase (probe-then-start) — no more restart-on-healthy latency or
peer-registry churn.
- Conversation polls ensureGateway ONCE on mount, not every 5s tick.
- The agent picker's auto-default now persists via SessionsSetAgent, so the
backend dispatches the same instance and skips its firstCallableAgent
round-trip, and a seed-send uses the right agent.
UI race:
- Switching sessions mid-stream resets turnRef + sending, so deltas from the
previous session's still-running turn no longer paint into the new one.
envWithout allocates explicitly (make) instead of aliasing os.Environ's
backing array.
… daemon churn Landing env chip was dead: onClick did setEnv(env ? "" : "") which always resolves to "", so a paired peer could never be selected before minting a session. Replace it with a real dropdown (Local + paired devices), mirroring the in-session composer, with peers loaded via one ensureGateway + snapshot. Conversation pane: - re-bind the agent:event listener on session.id so its cleanup runs on every switch and a stale subscription can't paint another session's deltas - persist the auto-picked default agent (sessionsSetAgent) so the backend resolves it from disk on the first send — no extra /v1/agents round-trip and the dispatched instance always matches the badge - consume the seed message exactly once, keyed on the message itself, so a stale seed left in parent state can't re-fire into a different session - ensureGateway ONCE on mount, then poll snapshots only — calling it every 5s re-spawned `daemon start` on a healthy daemon across every open pane Agent dispatch (installer backend): - gate peer status-frame detection on the error-shaped keys so a normal completion frame that is valid JSON isn't speculatively unmarshaled and swallowed before extractAgentText sees it - surface scanner.Err() in both the local and peer stream loops instead of emitting a clean "done" on a truncated stream - bound the daemon-ensure subprocess with an 8s context timeout ensureDaemonBase: cache the resolved URL for 8s (success-only) so the per-pane poll stops spawning `daemon url`/`daemon start` subprocesses every tick.
…rl cache The env-picker commit shipped with a broken Peer shape: it read p.name and p.device_id, but Peer only has peer_id + display_name (device_id lives under metadata). tsc failed, so CI would have too. Use display_name/peer_id. Also add the dropdown's own CSS (chipWrap/menu/menuItem/menuEmpty) to Sessions.module.css — they were referenced but never defined, so the menu rendered unstyled and shoved the composer around. ensureDaemonBase: cache the resolved URL for 8s (success-only) so the per-pane network poll stops spawning `daemon url`/`daemon start` subprocesses every tick — the daemon-churn fix the batch-2 message claimed but didn't actually contain.
Cross-device turns had no abort path: dispatchAgentToPeer ran on its own context.Background()+10m timeout with no link to the UI's lifetime, so navigating away from a session left the goroutine + HTTP stream alive until the ceiling. Completes the Phase-E peer-routing lifecycle. - App gains a sync.Map of turn id → CancelFunc; turnContext() derives a cancellable, 10-min-bounded context and registers it, release() cancels + deregisters (defer it so a self-finishing turn cleans up its own entry) - AgentCancel(turnID) reaches that cancel func — aborts the local send subprocess or the peer HTTP stream - both runAgentTurn and dispatchAgentToPeer now derive their context from turnContext instead of a bare WithTimeout - bridge: agentCancel(turnID) binding - Conversation: on session switch, cancel the still-in-flight turn in the effect cleanup (turnRef is already reset for the incoming session)
A Finder/dock launch on macOS hands the app a stub PATH (/usr/bin:/bin) with none of the dirs agent CLIs live in (/opt/homebrew/bin, ~/.local/bin, npm/cargo/go bins). The daemon the app spawns inherits that stub, and since the supervisor resolves families with a LIVE exec.LookPath on every /v1/agents call, every family comes back binary-missing / bridge-missing — the operator sees "claude not installed, all bridges missing" even though the CLIs are right there in a terminal. Reproduced: a daemon under PATH=/usr/bin:/bin reports callable:[]; the same daemon under the login PATH reports claude callable. startup() now calls ensureUserPath() before spawning anything: it keeps the inherited PATH, folds in the login shell's PATH (Homebrew, nvm/asdf/mise, custom profiles), and adds the standard macOS dirs as a backstop — so every child (daemon start, clawtool send) inherits the real PATH. No-op on Windows. Verified end-to-end: from PATH=/usr/bin:/bin, after ensureUserPath claude/codex/opencode all resolve via exec.LookPath.
…patch The local chat path spawned `clawtool send --agent <X>`, which routes to an external agent CLI (codex/gemini/opencode) and depends on each one's health, env, and quota — so a codex usage-limit (or any per-CLI failure) showed up as 'nothing happened' with the error swallowed on a clean exit. The user's direction is that namzu IS the runtime; the desktop is its UI. AgentSend's local branch now calls runNamzuTurn, which spawns `node <namzu>/packages/cli/dist/bin.js run-stream` and translates namzu's NDJSON AgentEvents (delta/tool-start/error/done) straight into the agent:event protocol. namzu is credential-first (Claude Code OAuth from the Keychain, auto-refresh) and provider-generic, so a turn answers without any external agent CLI being installed or healthy. - locateNamzu resolves node (PATH via ensureUserPath, then /opt/homebrew etc.) + the CLI entry (CLAWTOOL_NAMZU_BIN override, the shipped .app Contents/Resources/namzu, then the dev submodule path) - errors surface even on clean exit: a stream error, an explicit namzu error frame, or a no-output turn all emit a Kind:error (with stderr) instead of a silent done - sessions with no folder get a stable scratch cwd so namzu's <cwd>/.namzu history has a home - cross-device turns (dispatchAgentToPeer) are unchanged; runAgentTurn is kept for reference but off the hot path Verified: under a Finder-style stub PATH (/usr/bin:/bin) with absolute node, `run-stream` streams a real reply via the Keychain credential — no homebrew, no agent CLI needed.
Bundle the namzu CLI the desktop spawns for local turns into the macOS .app so a turn runs with zero external dependencies - no Homebrew, no system node, no agent CLI. Chosen over Bun-compile / Node SEA, which are fragile with a complex SDK + dynamic imports; namzu has zero native modules so an esbuild single-file bundle is safe.
- installer.yml: esbuild collapses the whole pnpm workspace + node_modules + the literal dynamic provider imports into one ~19 MB namzu.cjs, then fetches the official Node (universal arm64+x64 via lipo) and places both at Contents/Resources/namzu/{node,namzu.cjs}.
- namzu_runtime.go locateNamzu: prefer the bundled namzu.cjs + the node next to it (shipped, self-contained); fall back to the dev submodule entry + system node. CLAWTOOL_NAMZU_BIN still overrides.
- bump the namzu submodule pointer to 8d3024d (run-stream).
Proven locally: node namzu.cjs run-stream streams a real reply via the Keychain credential with no node_modules present, even under a stub PATH (delta:12).
…nds @types/node The Bundle-namzu-runtime step ran pnpm -r build in the namzu submodule without installing its deps first. namzu's @types/node + esbuild live in its OWN node_modules (its root package.json isn't a desktop workspace member), so the cli tsc build failed on CI with TS2688 'Cannot find type definition file for node'. Add pnpm install --frozen-lockfile (namzu has its own committed lockfile) before the build.
Two real failures fixed: 1) esbuild was a TRANSITIVE dep so 'pnpm exec esbuild' failed with ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL. Use pnpm dlx esbuild@0.27.7 (pinned). 2) The CLI uses import.meta, which esbuild only bundles in --format=esm (CJS gave '5 errors: set output format to esm'). Switch to ESM output (namzu.mjs) + a createRequire banner so CJS-interop deps still load. Also guard the bundle step to runner.os==macOS — only the macOS embed step consumes the bundle today; Windows namzu shipping is a follow-up, so its installer no longer fails here. namzu_runtime.go locateNamzuBin now resolves Resources/namzu/namzu.mjs. Proven locally: pnpm dlx esbuild ESM bundle (20MB) runs under a stub PATH with absolute node and streams a real reply (delta+done), no dynamic-require errors.
The ESM bundle failed two ways before this: esbuild couldn't resolve react-devtools-core (Ink imports it), and marking it --external just moved the failure to runtime (ERR_MODULE_NOT_FOUND in an isolated dir). Ink only imports it under process.env.DEV==='true' (never in our headless run-stream), so alias it to an empty stub — the bundle stays self-contained. Add an isolated --help smoke-test to the step so a broken bundle fails CI here, not on the user's machine. Proven locally: aliased bundle runs run-stream under a stub PATH with no node_modules and streams a real reply (delta+done, ISO3_OK).
Reopening a session was always blank because the desktop never persisted or reloaded messages. Now:
- runNamzuTurn passes --session <session.id> so namzu binds the turn to a persisted conversation in the cwd's .namzu store (loads prior turns as context, appends the new pair).
- AgentHistory(projectID) shells 'namzu history --session' and returns the transcript as {role,content}[] JSON.
- bridge: agentHistory binding.
- Conversation hydrates on mount from agentHistory (alive + seed guarded so a new-session auto-send isn't clobbered), mapping role->Msg kind.
- bumps the namzu submodule to ddff8ce (run-stream --session + history).
Verified end-to-end via the namzu CLI: persist -> history -> fresh-process recall of a secret from loaded context.
…l + cross-device dispatch
namzu was reachable only via the desktop's special-case runNamzuTurn, so a peer (or the Sessions agent picker) targeting namzu had no route — cross-device fell back to the bridge supervisor which has no namzu. Make namzu a first-class supervisor transport so sup.Send(agent) routes to it like any family.
- internal/agents/namzu_locate.go (new): shared LocateNamzu/LocateNamzuBin/locateNode + a sync.Once CachedLocateNamzu (memoized so /v1/agents polls never re-stat the bundle). Resolves CLAWTOOL_NAMZU_BIN -> shipped Resources/namzu/namzu.mjs (both MacOS/Clawtool and MacOS/bin/clawtool daemon depths) -> dev submodule.
- internal/agents/namzu_transport.go (new): NamzuTransport spawns node namzu.mjs run-stream [--session][extra] prompt via startStreamingExecFull; ErrBinaryMissing when the bundle is absent.
- supervisor.go: register "namzu" in the transports map; add it (plus the latent-missing hermes/aider) to validFamily; add a composeAgent namzu arm (Bridge="", callable gated on CachedLocateNamzu — bundled runtime, not a PATH binary).
- agent.go extractAgentText: fall back to raw["kind"] when "type" is absent, so namzu's {kind:delta,text} frames render on BOTH the local supervisor path and the cross-device SENDER (dispatchAgentToPeer routes peer replies through here).
- cmd/clawtool-installer keeps a SIBLING copy of the resolver (separate Go module can't import internal/) — documented; added the daemon-depth Resources path.
Verified via CLI against a fresh daemon built from this tree:
- /v1/agents lists namzu callable=true alongside claude/codex/gemini/opencode.
- clawtool send --agent namzu -> streams {kind:delta} "SPINE_NAMZU_OK".
- POST /v1/peer-run {agent:namzu} (the cross-device RECEIVER path) -> streams "PEER_NAMZU_OK": the peer answers with the targeted agent, generically.
…vability)
Backs the desktop's Sessions surface — a device-wide view of every agentic run, local OR peer-triggered, with status/instance/family/origin. Today three subsystems track run state but none is unified or HTTP-queryable; this adds one in-memory registry + one read endpoint.
- runs_registry.go: RunRegistry (Upsert/SetStatus/Remove/List/CountByInstance) over a Run{run_id, session_id, status, agent_family, agent_instance, origin, peer_name, title, started_at, last_activity}. Terminal runs go idle (still visible) then a janitor trims them after 10m. GetGlobalRunRegistry singleton mirrors GetGlobalPeerRouter.
- handleSendMessage (http.go): local runs register origin=local; defer idle; failed on error.
- handlePeerRun (peer_run_handler.go): peer-triggered runs register origin=peer + peer_name from the X-Clawtool-Device-Name header — the RECEIVER logs them, so the local Sessions dashboard shows 'running, triggered by peer <name>'. CountByInstance is the per-agent session badge for the Agents surface.
- GET /v1/runs (runs_handler.go): peer-aware like /v1/agents (a dashboard on one device can read another's runs) — returns runs[] + summary{running,idle,total}. Helpers newRunID/familyOfInstance/titleFromPrompt.
Verified: unit test (running default, origin/peer labels, CountByInstance drops on idle, idle stays visible, StartedAt preserved) + live against a fresh daemon — a peer-run (X-Clawtool-Device-Name: studio-mini) and a local send BOTH appeared in /v1/runs with origin local and peer:studio-mini respectively.
Reframes Sessions from a single-agent chat landing into the observability surface the corrected model calls for: a live table of every agentic run on this device — local turns AND peer-triggered runs — with status dot (running/idle/failed), owning instance + family, and origin badge ('local' / 'peer: <device>' / 'via biam'). Polls every 2s. A composer stays pinned below so it's still an entry point; clicking a run opens/attaches to its conversation.
- app.go AgentRuns: fetch /v1/runs, degrade to an empty summary when the gateway is down.
- bridge: Run + RunsSnapshot types; agentRuns() binding.
- Sessions.tsx: rebuilt as the monitor (origin/status rendering) + retained composer entry.
- Sessions.module.css: status-dot classes.
Backend (/v1/runs) was CLI-proven last commit: a peer-run with X-Clawtool-Device-Name and a local send both showed up with origin peer:<name> and local.
…Sessions) Two small surface wins over the run-registry: - Agents.tsx polls App.agentRuns() and shows a '<n> running' accent badge per instance, derived client-side from non-terminal /v1/runs rows (CountByInstance equivalent, no server change). - Sessions openRun now carries the run's agent_instance + origin into the opened Session (agent + env), so composing into a run dispatches back to the SAME agent (and peer, for a peer-triggered run) — the conversation pane already honors session.agent/env.
…ls/delegation tree)
Adds the do-work surface the three-surface model calls for: namzu rendered as its own GUI tab, distinct from Sessions (monitor) and the per-session Conversation.
- views/Namzu.tsx: streams over the same agent:event channel as Conversation but dispatches via agentSendOpts so the per-turn model + skills ride as run-stream flags. A switcher row picks the model and toggles skill chips (sourced from namzuSkills → namzu skills-json); a delegation rail renders the live sub-agent tree from tool-end/tool-start detail (namzu orchestrating other agents). Hydrates its transcript from the stable namzu-workspace session.
- Go: AgentSendOpts(projectID,message,peerID,optsJSON) threads {model,instance,skills} into runNamzuTurn's run-stream argv; agentEvent + namzuEvent gain detail (delegation steps); tool-end emitted with detail; NamzuSkills shells skills-json.
- bridge: agentSendOpts, namzuSkills; AgentEvent.detail; NamzuSkill/NamzuTurnOpts types.
- App.tsx: Namzu as the first nav tab (4-spot pattern, keep-alive mount).
- bump namzu submodule to 02f37e1 (run-stream flags + skills-json).
Typecheck + vite build green.
… Sessions
Two Namzu-tab bugs:
A) The model switcher hardcoded a Claude-only list, so non-Claude
providers were unreachable from the tab. Replace it with dynamic
provider + per-provider model pickers sourced from `namzu
providers-json` (App.NamzuProviders): a provider dropdown with
credential-detected dots (detected-first), and a model dropdown of
that provider's real catalog — free-text + the registry default when
a provider exposes no listing. Provider + model ride to run-stream as
--provider/--model via AgentSendOpts.
B) A turn started from the Namzu tab never appeared in the Sessions
/v1/runs monitor, because it executes IN-PROCESS in the desktop, not
in the daemon whose registry backs /v1/runs. Add daemon endpoints
POST /v1/runs/register and POST /v1/runs/{id}/status (authed:
bearer/loopback, never peer — writes are device-local) and have
runNamzuTurn register the run at start (origin=local, instance via
opts) and flip it to idle on any exit path.
Submodule namzu bumped to dd5c886 (providers-json + run-stream
--provider + the listModels registration fix).
The bundled namzu runtime crashed at startup (`Cannot find module '../package.json'`) because the SDK/telemetry version modules read package.json via createRequire at import time, which esbuild leaves as a runtime require that can't resolve inside the single-file bundle. This failed the installer's bundle smoke test (namzu --help / providers-json died before output). namzu b776acf guards the read; the bundle now boots and providers-json returns each detected provider's real model list.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A ground-up refactor of the clawtool desktop experience, plus the custom installer.
Architecture (new
desktop/pnpm monorepo):packages/design-system— single source of truth: Warp-grounded design tokens (CSS custom properties, 3-tier) + 13 React components (Wordmark, StatusDot, Badge, Button, Metric, Section, AgentRow, Sidebar, Switch, KeyValue, ProgressBar, LogFeed, and a frameless cross-platform TitleBar).packages/bridge— typed, never-throw wrapper over the Wailswindow.go/window.runtimeglobals (App.* methods + events + window controls).apps/{app-ui, installer-ui, updater-ui}— the three UI surfaces, all composed from the design system.Shipping binary: the proven Wails binary (
cmd/clawtool-installer) now embeds the Vite-built React SPA (app-ui). One bundle routes onApp.Mode():setup→ the stepped installer (Welcome → Installing live log → Done)installer(first run) → branded onboarding / one-time initapp→ the main app (frameless shell + slim sidebar + Home / Network / Updates)Window chrome: frameless on both platforms with our own titlebar (drag region + min/maximize/close on Windows; macOS keeps inset native traffic lights). No OS-native title bar.
Custom installer (unchanged, proven):
ClawtoolSetup.exeself-installs (no NSIS wizard) — lays the app + headless CLI (bin\clawtool.exe) + updater into%LOCALAPPDATA%\Programs\Clawtool, wires shortcuts/PATH/uninstaller, then launches the app. CI 2-build payload flow; the CLI lives inbin\to avoid theClawtool.exe/clawtool.execase collision.Design grounding: tokens + UX taken from the real default dark theme of a well-regarded terminal + modern dev-tool patterns (calm dark, opacity-layered surfaces, hairline dividers, one cool accent, no gradients, no bordered-card clutter).
Verified
ClawtoolSetup.exe(~75 MB) embeds the React SPA + correct payload layout (verified by artifact inspection).Needs a Windows smoke-test
Follow-up (separate effort)
.exes; updater runs at launch → atomic-swap → hands off to app, per the Velopack model) is designed for and supported by these surfaces, but deferred until a Windows smoke-test to avoid shipping an untested install/launch path.🤖 Generated with Claude Code