Skip to content

feat(desktop): custom installer + React UI refactor (monorepo, design system, frameless)#93

Merged
bahadirarda merged 86 commits into
mainfrom
desktop/custom-installer
Jun 1, 2026
Merged

feat(desktop): custom installer + React UI refactor (monorepo, design system, frameless)#93
bahadirarda merged 86 commits into
mainfrom
desktop/custom-installer

Conversation

@bahadirarda
Copy link
Copy Markdown
Contributor

@bahadirarda bahadirarda commented May 26, 2026

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 Wails window.go/window.runtime globals (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 on App.Mode():

  • setup → the stepped installer (Welcome → Installing live log → Done)
  • installer (first run) → branded onboarding / one-time init
  • app → 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.exe self-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 in bin\ to avoid the Clawtool.exe/clawtool.exe case 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

  • CI green on Windows + macOS; ClawtoolSetup.exe (~75 MB) embeds the React SPA + correct payload layout (verified by artifact inspection).
  • All packages typecheck; all surfaces build with Vite and render correctly (headless).

Needs a Windows smoke-test

  • Running install end-to-end + frameless window behavior (drag / controls) on real Windows — these can't be verified from the build host.

Follow-up (separate effort)

  • The physical 3-process split (separate updater/app .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

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.
@bahadirarda bahadirarda changed the title Custom app-style installer (no wizard): self-installing ClawtoolSetup.exe feat(setup): custom app-style installer with self-installing ClawtoolSetup.exe May 26, 2026
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.
@bahadirarda bahadirarda changed the title feat(setup): custom app-style installer with self-installing ClawtoolSetup.exe feat(desktop): custom installer + React UI refactor (monorepo, design system, frameless) May 27, 2026
… 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.
@bahadirarda bahadirarda merged commit 6bcbbdd into main Jun 1, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant