Skip to content

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

Open
bahadirarda wants to merge 78 commits into
mainfrom
desktop/custom-installer
Open

feat(desktop): custom installer + React UI refactor (monorepo, design system, frameless)#93
bahadirarda wants to merge 78 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.
Per-turn, the agent now runs against the active project's cwd: a Bash tool
call (ls) emits tool-start / tool-end events so the renderer's tool chips
light up, and the response is grounded in real filesystem state ("saw N
entries in <cwd>"). projectCwd resolves the active project from the on-disk
ledger; execBash runs with a 5s ceiling in the project root.

The placeholder LLM stays in place — the surrounding plumbing (cwd
anchoring, tool routing, streaming, Wails events) is what the real namzu /
Anthropic / OpenRouter swap will plug into. Phase E adds the peerID
parameter so this same turn can run on a paired device's daemon instead.
AgentSend grows an optional peerID — when set, the turn is dispatched to
that paired device's daemon over the existing mTLS peer-relay (the same
transport pairing already authenticated). The local UI shows a "Run on..."
dropdown next to Send, listing the device + every paired peer; selecting
one routes that turn there. Local turns are unchanged.

This is the cross-device primitive the rest of the ADE can build on. The
receiving side currently parks the wrapped clawtool.agent.dispatch payload
in its daemon inbox (and the operator picks it up); receive-side
auto-routing into the peer's local AgentSend lands next so the peer's
agent runs the turn automatically end to end. Either way, the dispatch
goes over the paired-cert transport — no new auth surface.

The dev-stub bus simulates the dispatch path so the dropdown behaviour is
observable under pnpm dev too.
…side-by-side

The earlier Projects view kept the list and the conversation panel
side-by-side, which isn't how an agent dev environment is supposed to feel
— a project should be a destination you enter, not a row sitting next to
its own content. Grounded the layout in the vendored open-source reference
(under vendor/, omitted here): a slim navigable list that hands the
selected project up to the shell, and the shell swaps the main area to a
project workspace until the user backs out.

- Projects: drops embedded Conversation + side-by-side detail. Pure list
  with cwd, "recent" badge, remove, and a chevron drill-in cue. Clicking a
  row (or adding one) calls onOpen(project) — the shell takes it from
  there. ProjectsTouch on open keeps newest-used first.
- ProjectWorkspace (new): the full main area for one project — a thin
  header with a back arrow + label + cwd, then the Conversation as the
  work surface taking the rest of the column.
- App shell: tracks openProject. view==="projects" + no openProject shows
  the list; view==="projects" + openProject shows the workspace; the back
  affordance clears openProject and returns to the list. Switching to
  other tabs (Home/Agents/…) preserves the open project so coming back
  drops you straight into the workspace.
…ored ADE reference

Bumped hover from 4% → 6% and introduced a 12% pressed token to mirror
the vendored reference's button hover/click overlay tiers (10% / 20%).
The accent-soft tier dropped 14% → 10% to match the reference's
accent_overlay_1, and a stronger 25% companion (accent-soft-strong) lands
for selected/active states (its accent_overlay_2). Nothing visual breaks;
existing components keep working through the same names.
zeroconf's Browse keeps an internal instance-name cache that suppresses
re-emission of an entry whose name hasn't changed. A peer restarting
under the same hostname (same display name, new pid → new SRV port)
landed in that cache silently — handleEntry never saw the update, the
registry's address stayed pinned to the old port, and Mac → Windows
dialled a closed socket from there on out ("connection refused on
:53442" while Windows happily talked on :63768 to whoever asked it).

Re-issue Browse on a 20s sub-context (slightly faster than the 25s
reachability probe so a moved peer's address refreshes before the dial
trips on the stale one). The library's cache resets per Browse call, so
the fresh query brings the updated SRV/TXT in and Register's collapse
merges it onto the existing row. Built-in backoff on persistent errors
so a missing mDNS interface doesn't hot-loop.
The Add flow asked for a path in a text field and silently kept whatever
got pasted — tilde stayed literal, no validation, and a typo gave the
agent a cwd it couldn't cd into. Made adding a project less painful:

- New ProjectsPickFolder binding opens the native folder picker via
  Wails' OpenDirectoryDialog; returns the picked absolute path or empty
  on cancel.
- "Pick folder…" button next to the text input so the operator doesn't
  have to type/paste at all.
- ProjectsAdd expands ~ → home dir, resolves relatives to absolutes, and
  rejects paths that don't exist or aren't directories (so the error
  surfaces in the Add toast immediately instead of inside the agent's
  first tool call).
…tadata

The earlier model gated the conversation on a project existing — pick a
folder, fill a form, then talk. The vendored ADE reference shows that's
the inverse of what shipping ADEs do: AIConversation::new() takes zero
fields (conversation.rs:340), initial_working_directory is
Option<String> (history_model.rs:70), and selected_environment_id is
nullable too (environment_selector.rs:38). Sessions are the unit; cwd
and env are optional metadata the composer's chips attach later.

Pivoting clawtool to match:

- New Session ledger at ~/.config/clawtool/sessions.json. Bindings:
  SessionsList (newest-modified first, Recents order), SessionsCreate
  (zero fields required), SessionsTouch, SessionsSetTitle (lazy-derived
  from the first message), SessionsSetCwd, SessionsSetEnv, SessionsRemove.
- Sessions view replaces Projects as the desktop's landing. "+ New
  session" is the primary action: mints a session, opens an empty
  conversation immediately, no form. Recents list below.
- SessionView replaces ProjectWorkspace: thin header + back arrow +
  Conversation occupies the rest of the main area.
- Conversation refactored around a Session (not a Project). Composer
  carries chips ABOVE the editor: environment chip (clickable dropdown
  Local | paired peers) and workspace chip (clickable folder picker /
  attached folder with × to detach). Text-path input is gone. Title is
  lazy-derived from the first user message and saved back.
- AgentSend's lookup tries the session ledger first, falls back to the
  project ledger so older callers keep working during the migration.
- Old Projects.tsx + ProjectWorkspace.tsx removed (no longer wired);
  projects.go stays as a foundation for a later "saved workspaces"
  catalog the workspace chip can pick from.
…eam CLI

The local turn was a placeholder echo. It now shells out to clawtool's
supervisor (`clawtool send --agent codex`) which dispatches the prompt to
a registered upstream (codex / gemini / opencode / claude-code) and
streams the upstream's NDJSON wire format back. Each frame is translated
into the renderer's agent:event protocol so the conversation pane shows
real model output, not canned text.

- codex is the default upstream because the supervisor refuses to
  dispatch to the calling Claude Code session ("would loop") — a
  resolvable family keeps the path real until per-session model picking
  lands.
- extractAgentText covers the common wire shapes: codex item_completed
  frames, Anthropic content_block_delta, plain text fallback for
  upstreams that don't speak JSON.
- cwd from the session lands as the subprocess Dir so tools execute in
  the operator's actual working directory.
- stderr trails through to the error event so a refused dispatch
  ("would loop") surfaces in the UI instead of dying silently.
…ents

Drilling into a session now reshapes the left sidebar to be conversation-
focused: a prominent "+ New session" button at the very top, a Recents
list of past conversations (last 8, clickable to switch), then the
secondary nav (Home / Agents / Network / Updates / Settings) below.
Sessions landing keeps the previous structure so the list view is still
reachable from the entry tab. The rail refreshes when a session's title
is set so the first user message bubbles up immediately.
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).
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