diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ef7d760..0e300250 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,11 @@ on: push: branches: [main] pull_request: + # Include `edited` so the Conventional Commits PR-title check re-validates + # when a title is fixed (default types omit it, leaving the check stuck red + # — a rerun just replays the stale event payload, so only a new event + # helps). + types: [opened, synchronize, reopened, edited] branches: [main] permissions: diff --git a/.github/workflows/installer.yml b/.github/workflows/installer.yml index 7a553ca1..42a940f9 100644 --- a/.github/workflows/installer.yml +++ b/.github/workflows/installer.yml @@ -65,6 +65,11 @@ jobs: working-directory: cmd/clawtool-installer steps: - uses: actions/checkout@v4 + with: + # Pull namzu (and any future submodules) so desktop/submodules/namzu + # is populated — the desktop pnpm workspace links @namzu/sdk from + # there as the embedded agent runtime. + submodules: recursive - uses: actions/setup-go@v5 with: @@ -86,75 +91,118 @@ jobs: - name: wails doctor run: wails doctor || true - # ── Windows: build GUI → bundle headless binary → compile our - # customized NSIS installer (build/windows/installer/project.nsi) - # which installs BOTH the GUI + clawtool.exe into Program Files, - # creates shortcuts + an uninstaller, and offers to run setup. ── - - name: Install NSIS (Windows) - if: runner.os == 'Windows' - run: choco install nsis -y --no-progress - shell: pwsh - - - name: Build the GUI (Windows) - if: runner.os == 'Windows' - run: wails build -platform windows/amd64 - shell: pwsh + # Build the React frontend monorepo (Vite) and place the app SPA's + # bundle where the Go binary embeds it (//go:embed all:frontend/dist). + # The same SPA serves every mode (app / installer / setup) by routing on + # App.Mode(), so one bundle is embedded by both the app and setup builds. + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" - - name: Restore committed NSIS template (Windows) - if: runner.os == 'Windows' - working-directory: ${{ github.workspace }} - # `wails build` may regenerate wails_tools.nsh; restore our - # checked-in, customized project.nsi + wails_tools.nsh before - # compiling so the clawtool.exe bundling + tweaks survive. - run: git checkout -- cmd/clawtool-installer/build/windows/installer/ - shell: pwsh + - name: Build desktop frontend (Vite) + working-directory: ${{ github.workspace }}/desktop + run: | + corepack enable + pnpm install --frozen-lockfile + # The desktop app imports types from @namzu/sdk, whose dist/ is the + # main entry — build it before app-ui so the import resolves. + pnpm --filter @namzu/sdk build + pnpm --filter @clawtool/app-ui build + shell: bash - - name: Bundle headless clawtool.exe (Windows) - if: runner.os == 'Windows' - working-directory: ${{ github.workspace }} - run: go build -ldflags "-s -w" -o cmd/clawtool-installer/build/bin/clawtool.exe ./cmd/clawtool + # Bundle the namzu runtime the desktop spawns for local turns into a + # single self-contained CJS file. esbuild collapses the whole pnpm + # workspace + node_modules + the literal dynamic provider imports into + # one ~19 MB namzu.cjs — so the shipped .app needs only a node binary + + # this file, no node_modules tree. Generic: any node CLI bundles this way. + # macOS-only: only the macOS embed step below consumes namzu.cjs today; + # the Windows installer doesn't ship the namzu runtime yet (follow-up). + - name: Bundle namzu runtime (esbuild) + if: runner.os == 'macOS' + working-directory: ${{ github.workspace }}/desktop/submodules/namzu + run: | + # namzu's @types/node lives in ITS OWN node_modules — the desktop + # workspace doesn't include namzu's root package.json, so the earlier + # `pnpm install` in desktop/ doesn't provide it. Install namzu's own + # deps here (it has its own committed lockfile) or the cli build fails + # with TS2688 "Cannot find type definition file for 'node'". + pnpm install --frozen-lockfile + pnpm -r build + # esbuild is only a TRANSITIVE dep here, so `pnpm exec esbuild` can't + # find it — fetch + run it with pnpm dlx (pinned for reproducibility). + # ESM output (not CJS): the CLI uses import.meta, which esbuild only + # supports in --format=esm. The createRequire banner restores a + # working `require` so the bundle's CJS-interop deps still load. + # react-devtools-core: Ink imports it ONLY under DEV=true (never in + # our headless run-stream), but esbuild still tries to resolve it — + # alias it to an empty stub so the bundle stays self-contained. + printf 'export function connectToDevTools(){}\nexport default {};\n' > /tmp/devtools-stub.mjs + pnpm dlx esbuild@0.27.7 packages/cli/dist/bin.js \ + --bundle --platform=node --format=esm --target=node20 \ + --outfile=namzu.mjs \ + --alias:react-devtools-core=/tmp/devtools-stub.mjs \ + "--banner:js=import { createRequire as __cr } from 'module'; const require = __cr(import.meta.url);" + ls -la namzu.mjs + # Smoke-test the bundle in an isolated dir (no node_modules) so a + # broken bundle fails the build here, not on the user's machine. + mkdir -p /tmp/namzu-smoke && cp namzu.mjs /tmp/namzu-smoke/ + node /tmp/namzu-smoke/namzu.mjs --help >/dev/null + echo "namzu bundle smoke OK" + shell: bash - - name: Bundle updater ClawtoolUpdate.exe (Windows) - if: runner.os == 'Windows' + - name: Place frontend bundle into the Go embed dir working-directory: ${{ github.workspace }} - run: go build -ldflags "-s -w" -o cmd/clawtool-installer/build/bin/ClawtoolUpdate.exe ./cmd/clawtool-updater - shell: pwsh + run: | + rm -rf cmd/clawtool-installer/frontend/dist + mkdir -p cmd/clawtool-installer/frontend/dist + cp -R desktop/apps/app-ui/dist/. cmd/clawtool-installer/frontend/dist/ + shell: bash - - name: Fetch WebView2 bootstrapper (Windows) + # ── Windows: custom app-style installer via a 2-build flow ────── + # No classic NSIS wizard. The same Wails binary is both the app and + # the setup — payload presence (//go:embed all:payload) flips it + # into "setup" mode (see payload.go / main.go). + # + # Build 1 → the app, Clawtool.exe, with an empty payload/ → plain + # "app" mode. + # Stage → copy that Clawtool.exe + the headless clawtool.exe + + # ClawtoolUpdate.exe into payload/. + # Build 2 → recompile; the payload is now embedded, so the binary + # self-installs on launch. Rename it ClawtoolSetup.exe. + # + # Running ClawtoolSetup.exe lays the embedded payload into + # %LOCALAPPDATA%\Programs\Clawtool, wires shortcuts/PATH/uninstaller + # (install/install.go), then launches the installed Clawtool.exe — + # which, having no payload, runs as the app. + - name: Build 1 — the app GUI (Windows) if: runner.os == 'Windows' - working-directory: cmd/clawtool-installer/build/windows/installer - # The wails.webview2runtime NSIS macro bundles this so the - # installer can provision WebView2 on machines that lack it. - # `wails build -nsis` downloads it automatically; our manual - # makensis call must fetch it into tmp/ first. - run: | - New-Item -ItemType Directory -Force -Path tmp | Out-Null - Invoke-WebRequest "https://go.microsoft.com/fwlink/p/?LinkId=2124703" -OutFile "tmp\MicrosoftEdgeWebview2Setup.exe" + run: wails build -platform windows/amd64 shell: pwsh - - name: Fetch EnVar NSIS plugin (Windows) + - name: Stage payload — app + headless CLI + updater (Windows) if: runner.os == 'Windows' - working-directory: cmd/clawtool-installer/build/windows/installer - # project.nsi adds the install dir to the system PATH via the - # EnVar plugin (the only truncation-safe way — see the comment in - # project.nsi). makensis needs the DLL on its plugin search path; - # `!addplugindir ".\plugins"` points at the dir we drop it into. - # Unicode true ⇒ use the x86-unicode build of the plugin. + working-directory: ${{ github.workspace }} + # CLI goes under payload/bin/ — Clawtool.exe (app) and clawtool.exe + # (CLI) differ only in case, so they MUST NOT share a directory: on + # the case-insensitive runner the second write would clobber the + # first (this once dropped the app from the payload entirely). run: | - New-Item -ItemType Directory -Force -Path plugins | Out-Null - New-Item -ItemType Directory -Force -Path tmp | Out-Null - Invoke-WebRequest "https://nsis.sourceforge.io/mediawiki/images/7/7f/EnVar_plugin.zip" -OutFile "tmp\EnVar_plugin.zip" - Expand-Archive -Path "tmp\EnVar_plugin.zip" -DestinationPath "tmp\envar" -Force - Copy-Item "tmp\envar\Plugins\x86-unicode\EnVar.dll" -Destination "plugins\EnVar.dll" -Force + $payload = "cmd/clawtool-installer/payload" + New-Item -ItemType Directory -Force -Path "$payload/bin" | Out-Null + Copy-Item "cmd/clawtool-installer/build/bin/Clawtool.exe" "$payload/Clawtool.exe" -Force + go build -ldflags "-s -w" -o "$payload/bin/clawtool.exe" ./cmd/clawtool + go build -ldflags "-s -w" -o "$payload/ClawtoolUpdate.exe" ./cmd/clawtool-updater shell: pwsh - - name: Compile NSIS installer (Windows) + - name: Build 2 — self-installing ClawtoolSetup.exe (Windows) if: runner.os == 'Windows' - working-directory: cmd/clawtool-installer/build/windows/installer - # choco installs NSIS but doesn't refresh this step's PATH, so - # call makensis by its standard install path. + # Stamp the setup's display version from the release tag when called + # from release.yml; "dev" for branch / PR builds. run: | - & "C:\Program Files (x86)\NSIS\makensis.exe" "-DARG_WAILS_AMD64_BINARY=..\..\bin\Clawtool.exe" project.nsi + $ver = if ("${{ inputs.tag }}") { "${{ inputs.tag }}" -replace '^v','' } else { "dev" } + wails build -platform windows/amd64 -ldflags "-X main.setupVersion=$ver" + Move-Item "build/bin/Clawtool.exe" "build/bin/ClawtoolSetup.exe" -Force shell: pwsh # ── macOS: build .app, embed a universal headless clawtool, .dmg ── @@ -170,8 +218,11 @@ jobs: if [ -z "$app" ]; then echo "no .app produced"; exit 1; fi GOARCH=amd64 go build -ldflags "-s -w" -o /tmp/clawtool-amd64 ./cmd/clawtool GOARCH=arm64 go build -ldflags "-s -w" -o /tmp/clawtool-arm64 ./cmd/clawtool - lipo -create -output "$app/Contents/MacOS/clawtool" /tmp/clawtool-amd64 /tmp/clawtool-arm64 - echo "embedded universal clawtool at $app/Contents/MacOS/clawtool" + # CLI under MacOS/bin/ — the GUI binary and the headless CLI would + # otherwise case-collide in Contents/MacOS on case-insensitive macOS. + mkdir -p "$app/Contents/MacOS/bin" + lipo -create -output "$app/Contents/MacOS/bin/clawtool" /tmp/clawtool-amd64 /tmp/clawtool-arm64 + echo "embedded universal clawtool at $app/Contents/MacOS/bin/clawtool" # Standalone updater alongside it (the app hands off to this to # swap binaries it can't overwrite while running, then relaunch). GOARCH=amd64 go build -ldflags "-s -w" -o /tmp/upd-amd64 ./cmd/clawtool-updater @@ -179,13 +230,53 @@ jobs: lipo -create -output "$app/Contents/MacOS/ClawtoolUpdate" /tmp/upd-amd64 /tmp/upd-arm64 echo "embedded universal updater at $app/Contents/MacOS/ClawtoolUpdate" + # Ship a self-contained namzu runtime + Node inside the .app so a local + # turn runs with zero external dependencies (no Homebrew, no system + # node, no agent CLI). locateNamzu (namzu_runtime.go) resolves + # Contents/Resources/namzu/{node,namzu.cjs}. + - name: Embed namzu runtime + Node into .app (macOS) + if: runner.os == 'macOS' + working-directory: ${{ github.workspace }} + run: | + app=$(ls -d cmd/clawtool-installer/build/bin/*.app | head -1) + [ -n "$app" ] || { echo "no .app produced"; exit 1; } + res="$app/Contents/Resources/namzu" + mkdir -p "$res" + cp desktop/submodules/namzu/namzu.mjs "$res/namzu.mjs" + NODE_VER=v22.14.0 + base="https://nodejs.org/dist/${NODE_VER}" + curl -fsSL "${base}/node-${NODE_VER}-darwin-arm64.tar.gz" -o /tmp/node-arm64.tgz + curl -fsSL "${base}/node-${NODE_VER}-darwin-x64.tar.gz" -o /tmp/node-x64.tgz + mkdir -p /tmp/node-arm64 /tmp/node-x64 + tar -xzf /tmp/node-arm64.tgz -C /tmp/node-arm64 --strip-components=1 + tar -xzf /tmp/node-x64.tgz -C /tmp/node-x64 --strip-components=1 + lipo -create -output "$res/node" /tmp/node-arm64/bin/node /tmp/node-x64/bin/node + chmod +x "$res/node" + echo "embedded namzu runtime + node at $res"; ls -la "$res" + "$res/node" "$res/namzu.mjs" --help >/dev/null 2>&1 && echo "bundled namzu OK" || echo "bundled namzu --help nonzero (non-fatal)" + - name: Package macOS .dmg if: runner.os == 'macOS' run: | app=$(ls -d build/bin/*.app | head -1) if [ -z "$app" ]; then echo "no .app produced"; exit 1; fi + # macOS convention: the bundle is just the product name — no + # "installer" suffix (that's a Windows thing). Rename here so the + # icon a user sees in Finder is "Clawtool.app". + if [ "$(basename "$app")" != "Clawtool.app" ]; then + mv "$app" "$(dirname "$app")/Clawtool.app" + app="$(dirname "$app")/Clawtool.app" + fi + # Drag-to-install .dmg layout: stage the app next to an + # Applications symlink, so opening the .dmg shows "Clawtool.app | + # Applications →" and the user drags one onto the other — the + # standard macOS install gesture. + stage="$(mktemp -d)/dmg" + mkdir -p "$stage" + cp -R "$app" "$stage/Clawtool.app" + ln -s /Applications "$stage/Applications" hdiutil create -volname "Clawtool" \ - -srcfolder "$app" -ov -format UDZO \ + -srcfolder "$stage" -ov -format UDZO \ "build/bin/Clawtool.dmg" - name: Upload installer artifact diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..83247458 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "desktop/submodules/namzu"] + path = desktop/submodules/namzu + url = https://github.com/cogitave/namzu.git diff --git a/cmd/clawtool-installer/agent.go b/cmd/clawtool-installer/agent.go new file mode 100644 index 00000000..43f73c48 --- /dev/null +++ b/cmd/clawtool-installer/agent.go @@ -0,0 +1,471 @@ +// Agent dispatch — the seam Conversation calls when the operator sends a +// message in a project. Phase C wires the protocol end to end with a +// placeholder responder so the UI/event plumbing is real; Phase D swaps the +// responder for namzu's actual runtime (per-project session, clawtool MCP +// tools); Phase E adds an optional peer routing path so the dispatch can run +// on a paired device's daemon instead of locally. +package main + +import ( + "bufio" + "context" + "encoding/json" + "io" + "net/http" + "os" + "os/exec" + "strings" + "time" + + "github.com/google/uuid" + wruntime "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// agentEvent is the single shape streamed back over Wails events. +// Kind: "start" | "delta" | "tool-start" | "tool-end" | "done" | "error". +type agentEvent struct { + TurnID string `json:"turn_id"` + ProjectID string `json:"project_id"` + Kind string `json:"kind"` + Text string `json:"text,omitempty"` + ToolName string `json:"tool_name,omitempty"` + Error string `json:"error,omitempty"` + Detail []string `json:"detail,omitempty"` // sub-agent delegation steps (namzu tool-end) +} + +// AgentSend kicks off a turn for the given project. Returns the turn id +// immediately; the actual events stream over the "agent:event" channel — +// the renderer subscribes via runtime.EventsOn and updates the transcript +// as deltas arrive. Empty project id is allowed (for one-off chats not +// anchored to a project). Phase E: a non-empty peerID dispatches the turn +// to that paired device via the local daemon's peer-relay mechanism +// instead of running it locally; events still stream over the same channel +// (the local side reports the dispatch state; the receiver runs the +// actual turn on its own daemon). +func (a *App) AgentSend(projectID, message, peerID string) string { + return a.AgentSendOpts(projectID, message, peerID, "") +} + +// AgentSendOpts is AgentSend with an extra JSON opts string so the Namzu tab +// can drive the namzu runtime: {"model":"…","instance":"…","skills":["a","b"]}. +// optsJSON is empty for a plain Sessions/Conversation turn. peerID, when set, +// still routes cross-device (opts apply to the local namzu path only). +func (a *App) AgentSendOpts(projectID, message, peerID, optsJSON string) string { + turnID := uuid.NewString() + if strings.TrimSpace(peerID) != "" { + go a.dispatchAgentToPeer(turnID, projectID, message, peerID) + } else { + // Local turns run through namzu (the credential-first runtime), not + // clawtool's bridge dispatch. runAgentTurn is kept for reference but no + // longer on the hot path. + go a.runNamzuTurn(turnID, projectID, message, parseNamzuOpts(optsJSON)) + } + b, _ := json.Marshal(map[string]any{"ok": true, "turn_id": turnID}) + return string(b) +} + +// namzuTurnOpts carries the Namzu tab's per-turn selections. +type namzuTurnOpts struct { + Model string `json:"model,omitempty"` + Provider string `json:"provider,omitempty"` + Instance string `json:"instance,omitempty"` + Skills []string `json:"skills,omitempty"` +} + +func parseNamzuOpts(optsJSON string) namzuTurnOpts { + var o namzuTurnOpts + if s := strings.TrimSpace(optsJSON); s != "" { + _ = json.Unmarshal([]byte(s), &o) + } + return o +} + +// AgentCancel aborts an in-flight turn. The renderer calls this when the +// operator navigates away from a session with a turn still running so a +// cross-device dispatch doesn't keep a goroutine + HTTP stream alive until +// the 10-minute ceiling. No-op if the turn already finished. +func (a *App) AgentCancel(turnID string) string { + if cancel, ok := a.agentTurns.LoadAndDelete(turnID); ok { + cancel.(context.CancelFunc)() + } + return `{"ok":true}` +} + +// turnContext derives a cancellable, 10-minute-bounded context for one +// turn and registers its cancel under the turn id so AgentCancel can reach +// it. The returned release() cancels and deregisters — defer it so a turn +// that ends on its own cleans up its own entry. +func (a *App) turnContext(turnID string) (context.Context, func()) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + a.agentTurns.Store(turnID, context.CancelFunc(cancel)) + return ctx, func() { + cancel() + a.agentTurns.Delete(turnID) + } +} + +// dispatchAgentToPeer ships the turn to a paired device. The local clawtool +// CLI's `peer send` already speaks the mTLS-authenticated peer-relay over +// the daemon, so we don't reinvent transport — we just wrap the payload as +// a clawtool agent dispatch (the receiver's daemon can later auto-route it +// to its own AgentSend; until that lands, the message lands in the peer's +// inbox where the operator picks it up). +func (a *App) dispatchAgentToPeer(turnID, projectID, message, peerID string) { + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "start"}) + + base, err := a.ensureDaemonBase() + if err != nil { + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "error", Error: err.Error()}) + return + } + // Drive the local daemon's peer-run proxy: it forwards the prompt to the + // paired device's /v1/peer-run over mTLS, the peer runs its supervisor, + // and the NDJSON reply streams back here token-by-token — so the operator + // sees the remote agent's answer, not just "delivered". Carry the + // session's pinned agent + cwd so the REMOTE supervisor runs the agent + // the operator chose in the right directory — without them the receiver + // self-picks its first callable (which can be its own claude → loop) and + // runs in the daemon's dir. + agent := a.sessionAgent(projectID) + cwd := a.sessionCwd(projectID) + if cwd == "" { + cwd = a.projectCwd(projectID) + } + reqBody, _ := json.Marshal(map[string]any{"message": message, "agent": agent, "cwd": cwd}) + // Cancellable + 10-min-bounded, registered under the turn id so + // AgentCancel can tear the stream down when the operator navigates + // away — otherwise this goroutine + HTTP connection live until the + // ceiling even though nobody's watching. + ctx, release := a.turnContext(turnID) + defer release() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/v1/peers/"+peerID+"/run", strings.NewReader(string(reqBody))) + if err != nil { + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "error", Error: err.Error()}) + return + } + req.Header.Set("Content-Type", "application/json") + // The local daemon is loopback + no-auth, so no bearer is needed here. + resp, err := http.DefaultClient.Do(req) + if err != nil { + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "error", Error: "could not reach peer: " + err.Error()}) + return + } + defer resp.Body.Close() + + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + sawText := false + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + // The proxy may answer with a one-shot JSON status (e.g. + // pairing_required) instead of an NDJSON stream — surface that. + // Gate on the error-shaped keys so a normal completion frame that + // happens to be valid JSON never gets speculatively unmarshaled + // and swallowed here; status is only ever the first frame. + if !sawText && (strings.Contains(line, "\"pairing_required\"") || strings.Contains(line, "\"error\"")) { + var status map[string]any + if json.Unmarshal([]byte(line), &status) == nil { + if pr, _ := status["pairing_required"].(bool); pr { + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "error", Error: "This device isn't approved on the peer yet — approve it there, then retry."}) + return + } + if errStr, _ := status["error"].(string); errStr != "" { + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "error", Error: errStr}) + return + } + } + } + if text, ok := extractAgentText(line); ok && text != "" { + sawText = true + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "delta", Text: text}) + } + } + // A read error (network drop, oversized line) exits the loop silently — + // report it instead of a clean done so the operator doesn't mistake a + // truncated reply for a complete one. + if err := scanner.Err(); err != nil { + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "error", Error: "stream interrupted: " + err.Error()}) + return + } + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "done"}) +} + +// runAgentTurn drives one turn LOCALLY by handing the prompt off to +// clawtool's supervisor, which dispatches it to a registered agent +// (codex / gemini / opencode / claude-code, etc.) and streams the +// upstream's wire output back. We translate that stream into the +// renderer's agent:event protocol so the conversation pane shows real +// model output, not a placeholder. +// +// Agent selection: codex by default — the supervisor refuses to +// dispatch to the calling Claude Code session ("would loop"), so a +// resolvable family stays the safe default until per-session model +// picking lands. cwd, when the session has one, becomes the agent's +// working directory so tools land there. +func (a *App) runAgentTurn(turnID, projectID, message string) { + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "start"}) + + cwd := a.sessionCwd(projectID) + if cwd == "" { + cwd = a.projectCwd(projectID) + } + + bin, err := locateClawtool() + if err != nil { + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "error", Error: err.Error()}) + return + } + + // Make sure the shared daemon is live before dispatching — a stale + // daemon (system sleep, crash) leaves /v1/agents empty and the send + // subprocess gets no upstream. `daemon start` is Ensure-style and + // no-ops on a healthy daemon. + ensureCtx, ensureCancel := context.WithTimeout(context.Background(), 8*time.Second) + ensure := exec.CommandContext(ensureCtx, bin, "daemon", "start") + hideConsole(ensure) + _ = ensure.Run() + ensureCancel() + + // Resolve the instance the session is pinned to (composer's agent + // picker writes this via SessionsSetAgent). When unset, pick the + // first callable instance the daemon reports — never a hardcoded + // name — preferring a non-claude family since the local claude-code + // instance loops on self-dispatch. + agent := a.sessionAgent(projectID) + if agent == "" { + agent = a.firstCallableAgent() + } + if agent == "" { + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "error", Error: "no callable agent on this device — install or start one (codex, opencode, gemini…)"}) + return + } + // Cancellable + 10-min-bounded, registered under the turn id so + // AgentCancel kills the send subprocess when the operator navigates + // away mid-turn. + ctx, release := a.turnContext(turnID) + defer release() + cmd := exec.CommandContext(ctx, bin, "send", "--agent", agent, message) + if cwd != "" { + cmd.Dir = cwd + } + // Strip CLAUDECODE from the child env. The supervisor's loop guard + // refuses to dispatch to the claude family when CLAUDECODE=1 — but the + // GUI app is NOT a Claude Code session; it only inherited that var when + // launched from a Claude Code shell (or a parent that had it). A turn + // dispatched from the desktop is a fresh subprocess, never a re-entry, + // so clearing it lets the operator pick claude on their own machine. + cmd.Env = envWithout(os.Environ(), "CLAUDECODE") + hideConsole(cmd) + stdout, err := cmd.StdoutPipe() + if err != nil { + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "error", Error: "open stdout: " + err.Error()}) + return + } + stderr, _ := cmd.StderrPipe() + if err := cmd.Start(); err != nil { + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "error", Error: "dispatch: " + err.Error()}) + return + } + + // Stream upstream NDJSON line-by-line and translate to delta events. + // Each upstream speaks a slightly different wire format; the + // extractAgentText helper covers the common cases (codex item + // frames, Anthropic content-block deltas, plain text fallbacks). + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + if text, ok := extractAgentText(line); ok && text != "" { + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "delta", Text: text}) + } + } + streamErr := scanner.Err() + + // Surface stderr trailing the run so the operator sees the real + // failure mode (e.g. "refusing to dispatch to the calling Claude + // Code session") rather than a silent done. + var stderrBuf strings.Builder + if stderr != nil { + _, _ = io.Copy(&stderrBuf, stderr) + } + if werr := cmd.Wait(); werr != nil { + msg := strings.TrimSpace(stderrBuf.String()) + if msg == "" { + msg = werr.Error() + } + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "error", Error: msg}) + return + } + // The process exited 0 but the scanner may have stopped on a read + // error mid-stream — surface that rather than a misleading done. + if streamErr != nil { + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "error", Error: "stream error: " + streamErr.Error()}) + return + } + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "done"}) +} + +// extractAgentText pulls human-visible text out of one upstream NDJSON +// frame. Returns ("", false) when the frame is metadata-only (thread +// starts / usage updates) so the caller skips silently. +// firstCallableAgent asks the daemon for its agent roster and returns the +// instance id of the first callable one, preferring a non-claude family +// (the local claude-code instance loops on self-dispatch). Empty when the +// daemon is unreachable or nothing is callable — the caller surfaces a +// clear "no callable agent" error rather than guessing a name. +func (a *App) firstCallableAgent() string { + base, err := a.ensureDaemonBase() + if err != nil { + return "" + } + raw, err := httpGetJSON(base + "/v1/agents") + if err != nil { + return "" + } + var payload struct { + Agents []struct { + Instance string `json:"instance"` + Family string `json:"family"` + Callable bool `json:"callable"` + } `json:"agents"` + } + if json.Unmarshal(raw, &payload) != nil { + return "" + } + // Two passes: a non-claude callable first, then any callable. + for _, ag := range payload.Agents { + if ag.Callable && ag.Family != "claude" { + return ag.Instance + } + } + for _, ag := range payload.Agents { + if ag.Callable { + return ag.Instance + } + } + return "" +} + +// envWithout returns env with any KEY=… entries for the given key dropped. +// Used to strip CLAUDECODE so a desktop-dispatched turn isn't mistaken for +// a Claude Code self-dispatch by the supervisor's loop guard. +func envWithout(env []string, keys ...string) []string { + out := env[:0:0] + for _, e := range env { + drop := false + for _, k := range keys { + if strings.HasPrefix(e, k+"=") { + drop = true + break + } + } + if !drop { + out = append(out, e) + } + } + return out +} + +func extractAgentText(line string) (string, bool) { + var raw map[string]any + if err := json.Unmarshal([]byte(line), &raw); err != nil { + // Non-JSON line — pass through (some upstreams print plain text). + return line, true + } + t, _ := raw["type"].(string) + if t == "" { + // namzu's run-stream emits AgentEvents keyed on "kind" (delta / + // tool-start / done / error), not "type". Fall back to it so namzu + // text renders on BOTH the local supervisor path and the cross-device + // sender (dispatchAgentToPeer routes peer replies through here too). + t, _ = raw["kind"].(string) + } + switch t { + case "item.completed": + if item, ok := raw["item"].(map[string]any); ok { + if it, _ := item["type"].(string); it == "agent_message" { + if text, _ := item["text"].(string); text != "" { + return text, true + } + } + } + case "assistant": + // claude-code stream-json: {"type":"assistant","message":{"content": + // [{"type":"text","text":"…"}]}}. Concatenate the text blocks; the + // trailing {"type":"result"} frame repeats the same text, so it's + // skipped (falls through to the default no-op below). + if msg, ok := raw["message"].(map[string]any); ok { + if blocks, ok := msg["content"].([]any); ok { + var sb strings.Builder + for _, b := range blocks { + if bm, ok := b.(map[string]any); ok { + if bt, _ := bm["type"].(string); bt == "text" { + if txt, _ := bm["text"].(string); txt != "" { + sb.WriteString(txt) + } + } + } + } + if sb.Len() > 0 { + return sb.String(), true + } + } + } + case "content_block_delta": + if delta, ok := raw["delta"].(map[string]any); ok { + if text, _ := delta["text"].(string); text != "" { + return text, true + } + } + case "text", "delta": + if text, _ := raw["text"].(string); text != "" { + return text, true + } + } + return "", false +} + +// projectCwd resolves a project id to its working directory by re-reading +// the on-disk ledger. Empty when the id is unknown — the responder falls +// back to a project-less reply. +func (a *App) projectCwd(projectID string) string { + if strings.TrimSpace(projectID) == "" { + return "" + } + ps, err := loadProjects() + if err != nil { + return "" + } + for _, p := range ps { + if p.ID == projectID { + return p.Cwd + } + } + return "" +} + +// execBash runs a one-liner in the project cwd with a 5s ceiling. Output +// is trimmed so the caller can summarize without re-walking the bytes. +// Phase D's single tool primitive; richer tools land alongside the real +// LLM hookup. +func execBash(cwd, command string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "bash", "-lc", command) + cmd.Dir = cwd + out, err := cmd.CombinedOutput() + return strings.TrimSpace(string(out)), err +} + +func (a *App) emitAgent(ev agentEvent) { + if a.ctx == nil { + return + } + wruntime.EventsEmit(a.ctx, "agent:event", ev) +} diff --git a/cmd/clawtool-installer/app.go b/cmd/clawtool-installer/app.go index 64357833..32487a3c 100644 --- a/cmd/clawtool-installer/app.go +++ b/cmd/clawtool-installer/app.go @@ -12,12 +12,13 @@ import ( "os" "os/exec" "path/filepath" - "regexp" "runtime" "strings" "sync" "time" + "github.com/cogitave/clawtool/cmd/clawtool-installer/brand" + "github.com/cogitave/clawtool/cmd/clawtool-installer/install" "github.com/energye/systray" "github.com/wailsapp/wails/v2/pkg/options" wruntime "github.com/wailsapp/wails/v2/pkg/runtime" @@ -33,6 +34,12 @@ var trayIcon []byte type App struct { ctx context.Context + // In-flight agent turns: turn id → context.CancelFunc. Lets the UI + // abort a turn (local subprocess or cross-device stream) when the + // operator navigates away, so a paired-device run doesn't keep a + // goroutine + HTTP connection alive until the 10-minute ceiling. + agentTurns sync.Map + // Update poller state. mUpdate is the tray menu item whose label // flips between "Check for updates" and "Install update "; // updMu guards the latest poll result the click handler reads. @@ -51,10 +58,59 @@ func NewApp() *App { return &App{} } // initializes — on launch it only checks for updates, then shows the UI. var desktopMode = "app" -// Mode reports the running role ("app" | "installer") so the frontend's -// boot() can pick the install flow vs the app flow. +// Mode reports the running role ("app" | "installer" | "setup") so the +// frontend's boot() can pick the right flow. func (a *App) Mode() string { return desktopMode } +// setupVersion is stamped by CI (ldflags) into the ClawtoolSetup build so the +// Add/Remove-Programs entry shows the right version; "dev" for local builds. +var setupVersion = "dev" + +// Brand feeds the frontend the product identity (name, command, tagline, +// install dir) from the single source of truth so the UI never hardcodes it. +func (a *App) Brand() string { + b, _ := json.Marshal(struct { + Name string `json:"name"` + CLI string `json:"cli"` + Tagline string `json:"tagline"` + InstallDir string `json:"installDir"` + Version string `json:"version"` + }{brand.Name, brand.CLI, brand.Tagline, install.InstallDir(), setupVersion}) + return string(b) +} + +// RunSetup is called by the frontend's Welcome step (Install button) in setup +// mode: it lays the embedded payload down (install dir + shortcuts + PATH + +// uninstaller). Progress streams as "setup:step" events. It does NOT launch +// the app — the Done step does that via OpenInstalled, so the user stays in +// control of the stepped flow. +func (a *App) RunSetup() string { + root, err := payloadRoot() + if err != nil { + return jsonErr("install payload unavailable: " + err.Error()) + } + dir, err := install.Install(root, setupVersion, func(label, detail string) { + wruntime.EventsEmit(a.ctx, "setup:step", map[string]string{"label": label, "detail": detail}) + }) + if err != nil { + return jsonErr(err.Error()) + } + b, _ := json.Marshal(struct { + OK bool `json:"ok"` + Dir string `json:"dir"` + }{OK: true, Dir: dir}) + return string(b) +} + +// OpenInstalled launches the freshly-installed app (Done step's "Open" +// button). The install already succeeded, so a launch failure is non-fatal. +func (a *App) OpenInstalled() string { + if err := install.LaunchInstalled(); err != nil { + return jsonErr(err.Error()) + } + return `{"ok":true}` +} + // startup captures the Wails runtime context and starts the system-tray // presence so the operator can see clawtool is running (and reopen / // quit it). @@ -70,6 +126,25 @@ func (a *App) Mode() string { return desktopMode } // LockOSThread guarantees. func (a *App) startup(ctx context.Context) { a.ctx = ctx + // Repair PATH for the daemon + agent subprocesses we spawn. 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 we start inherits that stub, so its + // LIVE exec.LookPath("claude"/"codex"/…) finds nothing and /v1/agents + // reports every family binary-missing / bridge-missing — the operator + // sees "claude not installed, all bridges missing" even though the CLIs + // are right there in a terminal. Augment our own process PATH once here + // so every child (daemon start, `clawtool send`) inherits the real one. + ensureUserPath() + // macOS: skip the menu-bar tray. Cocoa's NSApp owns the main thread, + // and energye/systray's Cocoa loop fights Wails' main loop → SIGTRAP + // on launch (the .app never opens). The app works fine as a regular + // window without a tray icon; a native NSStatusItem path can land + // later. Windows + Linux keep the tray (Win32/AppIndicator don't have + // this constraint). + if runtime.GOOS == "darwin" { + return + } go func() { runtime.LockOSThread() systray.Run(a.onTrayReady, nil) @@ -407,6 +482,22 @@ func (a *App) NetworkSnapshot() string { return string(b) } +// AgentRuns fetches the daemon's /v1/runs — the device-wide list of active + +// recent agentic runs (local and peer-triggered) that backs the Sessions +// observability surface. Returns the raw body verbatim; "[]"-equivalent empty +// summary on an unreachable gateway so the UI degrades to "no runs". +func (a *App) AgentRuns() string { + base, err := a.ensureDaemonBase() + if err != nil { + return `{"runs":[],"summary":{"running":0,"idle":0,"total":0}}` + } + body, rerr := httpGetJSON(base + "/v1/runs") + if rerr != nil || len(body) == 0 { + return `{"runs":[],"summary":{"running":0,"idle":0,"total":0}}` + } + return string(body) +} + // PeerAgents proxies the daemon's /v1/peers/{id}/agents (circle-key // gated cross-device agent enumeration) for the Network view's per-peer // expansion. @@ -478,10 +569,6 @@ func (a *App) EnsureGateway() string { return `{"ok":true}` } -// circleKeyRE matches a clawtool circle key (hex secret) so we can tell -// the key line apart from the CLI's "no circle key set …" notice. -var circleKeyRE = regexp.MustCompile(`^[0-9a-fA-F]{32,}$`) - // firstLine returns the first trimmed line of command output. func firstLine(b []byte) string { s := strings.TrimSpace(string(b)) @@ -491,137 +578,310 @@ func firstLine(b []byte) string { return s } -// CircleStatus reports whether this device has joined a cross-device -// "circle" — a shared key that lets paired devices list each other's -// agents over the LAN. Returns the key so the UI can show + copy it. -// Runs `clawtool a2a circle-key show` (the installer is a separate -// module and drives clawtool through its CLI, never importing internals). -func (a *App) CircleStatus() string { +// LanStatus reports whether the daemon is LAN-exposed (reachable by +// paired peers) or loopback-only. Reads `clawtool daemon lan status`. +func (a *App) LanStatus() string { bin, err := locateClawtool() if err != nil { return jsonErr(err.Error()) } - cmd := exec.Command(bin, "a2a", "circle-key", "show") + cmd := exec.Command(bin, "daemon", "lan", "status") hideConsole(cmd) - out, _ := cmd.CombinedOutput() // "no key" exits non-zero; that's fine - key := firstLine(out) - if !circleKeyRE.MatchString(key) { - key = "" - } + out, _ := cmd.Output() b, _ := json.Marshal(struct { - OK bool `json:"ok"` - HasKey bool `json:"has_key"` - Key string `json:"key,omitempty"` - }{OK: true, HasKey: key != "", Key: key}) + OK bool `json:"ok"` + Enabled bool `json:"enabled"` + }{OK: true, Enabled: firstLine(out) == "on"}) return string(b) } -// CircleGenerate creates a fresh circle key (saved on this device) and -// returns it so the UI can show it for copying to the other devices. -func (a *App) CircleGenerate() string { +// LanEnable exposes this device to LAN circle peers; LanDisable returns it +// to loopback-only. Both restart the daemon (the call blocks until it's +// back up); the first enable also triggers the platform firewall prompt. +func (a *App) LanEnable() string { return a.lanSet("on") } +func (a *App) LanDisable() string { return a.lanSet("off") } + +func (a *App) lanSet(mode string) string { bin, err := locateClawtool() if err != nil { return jsonErr(err.Error()) } - cmd := exec.Command(bin, "a2a", "circle-key", "generate") + cmd := exec.Command(bin, "daemon", "lan", mode) hideConsole(cmd) - out, err := cmd.Output() - if err != nil { - return jsonErr("could not generate a circle key") - } - key := firstLine(out) - if !circleKeyRE.MatchString(key) { - return jsonErr("unexpected circle-key output") + if out, err := cmd.CombinedOutput(); err != nil { + msg := firstLine(out) + if msg == "" { + msg = "could not change LAN exposure" + } + return jsonErr(msg) } - b, _ := json.Marshal(struct { - OK bool `json:"ok"` - Key string `json:"key"` - }{OK: true, Key: key}) - return string(b) + return `{"ok":true}` } -// CircleSet joins an existing circle by saving the key generated on -// another device. The daemon reads the key per request, so it takes -// effect immediately — no restart. -func (a *App) CircleSet(key string) string { - key = strings.TrimSpace(key) - if key == "" { - return jsonErr("enter the circle key from your other device") +// AgentClaim registers clawtool with an agent host (e.g. claude / codex) so +// the agent can call clawtool's tools — `clawtool agents claim `. This +// is the "Connect" action that resolves a bridge-missing agent. +func (a *App) AgentClaim(name string) string { return a.agentAction("claim", name) } + +// AgentRelease unregisters clawtool from an agent host — +// `clawtool agents release ` (the "Disconnect" action). +func (a *App) AgentRelease(name string) string { return a.agentAction("release", name) } + +func (a *App) agentAction(action, name string) string { + name = strings.TrimSpace(name) + if name == "" { + return jsonErr("missing agent name") } bin, err := locateClawtool() if err != nil { return jsonErr(err.Error()) } - cmd := exec.Command(bin, "a2a", "circle-key", "set", key) + cmd := exec.Command(bin, "agents", action, name) hideConsole(cmd) if out, err := cmd.CombinedOutput(); err != nil { msg := firstLine(out) if msg == "" { - msg = "could not set the circle key" + msg = "could not " + action + " " + name } return jsonErr(msg) } return `{"ok":true}` } -// CircleClear leaves the circle (clears the key), disabling cross-device -// agent enumeration on this device. -func (a *App) CircleClear() string { +// BridgeAdd installs the canonical bridge for an agent family — +// `clawtool bridge add ` — so a bridge-missing agent becomes +// callable. This is the "Install bridge" action. The error (e.g. the agent +// binary not being on PATH) is surfaced to the UI verbatim. +func (a *App) BridgeAdd(family string) string { + family = strings.TrimSpace(family) + if family == "" { + return jsonErr("missing agent family") + } bin, err := locateClawtool() if err != nil { return jsonErr(err.Error()) } - cmd := exec.Command(bin, "a2a", "circle-key", "clear") + cmd := exec.Command(bin, "bridge", "add", family, "--json") hideConsole(cmd) - if err := cmd.Run(); err != nil { - return jsonErr("could not leave the circle") + if out, err := cmd.CombinedOutput(); err != nil { + msg := firstLine(out) + if msg == "" { + msg = "could not install the " + family + " bridge" + } + return jsonErr(msg) } return `{"ok":true}` } -// LanStatus reports whether the daemon is LAN-exposed (reachable by -// circle peers) or loopback-only. Reads `clawtool daemon lan status`. -func (a *App) LanStatus() string { +// PairList returns the daemon's pairing ledger (pending + decided requests) +// as the raw JSON array from `clawtool peer pair list --json`. The app polls +// this to surface an incoming "X wants to pair" approval prompt. +func (a *App) PairList() string { bin, err := locateClawtool() if err != nil { - return jsonErr(err.Error()) + return "[]" } - cmd := exec.Command(bin, "daemon", "lan", "status") + cmd := exec.Command(bin, "peer", "pair", "list", "--json") hideConsole(cmd) - out, _ := cmd.Output() - b, _ := json.Marshal(struct { - OK bool `json:"ok"` - Enabled bool `json:"enabled"` - }{OK: true, Enabled: firstLine(out) == "on"}) - return string(b) + out, err := cmd.Output() + if err != nil || len(out) == 0 { + return "[]" + } + return string(out) } -// LanEnable exposes this device to LAN circle peers; LanDisable returns it -// to loopback-only. Both restart the daemon (the call blocks until it's -// back up); the first enable also triggers the platform firewall prompt. -func (a *App) LanEnable() string { return a.lanSet("on") } -func (a *App) LanDisable() string { return a.lanSet("off") } +// PairApprove / PairDeny resolve a pending pairing request by its short code +// or fingerprint — the Accept / Deny actions on the approval prompt. +func (a *App) PairApprove(selector string) string { return a.pairDecide("approve", selector) } +func (a *App) PairDeny(selector string) string { return a.pairDecide("deny", selector) } +func (a *App) PairForget(selector string) string { return a.pairDecide("forget", selector) } -func (a *App) lanSet(mode string) string { +func (a *App) pairDecide(action, selector string) string { + selector = strings.TrimSpace(selector) + if selector == "" { + return jsonErr("missing pairing selector") + } bin, err := locateClawtool() if err != nil { return jsonErr(err.Error()) } - cmd := exec.Command(bin, "daemon", "lan", mode) + cmd := exec.Command(bin, "peer", "pair", action, selector) hideConsole(cmd) if out, err := cmd.CombinedOutput(); err != nil { msg := firstLine(out) if msg == "" { - msg = "could not change LAN exposure" + msg = "could not " + action + " the pairing request" } return jsonErr(msg) } return `{"ok":true}` } +// RunDoctor runs `clawtool doctor` and returns its full output for the +// Settings → Diagnostics panel. doctor exits non-zero when it finds issues, +// but we still want to show what it printed, so the exit code is ignored. +func (a *App) RunDoctor() string { + bin, err := locateClawtool() + if err != nil { + return jsonErr(err.Error()) + } + cmd := exec.Command(bin, "doctor") + hideConsole(cmd) + out, _ := cmd.CombinedOutput() + b, _ := json.Marshal(struct { + OK bool `json:"ok"` + Output string `json:"output"` + }{true, string(out)}) + return string(b) +} + +// PairRequest asks a discovered peer to pair — `clawtool peer pair request +// ` — which makes that device show an approve/deny prompt. Returns +// the daemon's JSON ({ok,sent} | {not_in_circle} | {needs_circle_key} | error). +func (a *App) PairRequest(peerID string) string { + peerID = strings.TrimSpace(peerID) + if peerID == "" { + return jsonErr("missing peer id") + } + bin, err := locateClawtool() + if err != nil { + return jsonErr(err.Error()) + } + cmd := exec.Command(bin, "peer", "pair", "request", peerID) + hideConsole(cmd) + out, err := cmd.Output() + if err != nil { + return jsonErr("could not send the pairing request") + } + if s := strings.TrimSpace(string(out)); s != "" { + return s + } + return `{"ok":true}` +} + +// LocalCard returns this device's public A2A Agent Card +// (/.well-known/agent-card.json) — capability/skill metadata, no secrets — +// for the Agents tab to display. Raw daemon body on success. +func (a *App) LocalCard() string { + base, err := a.ensureDaemonBase() + if err != nil { + return jsonErr(err.Error()) + } + body, err := httpGetJSON(base + "/.well-known/agent-card.json") + if err != nil { + return jsonErr(err.Error()) + } + return string(body) +} + +// ensureUserPath augments this process's PATH with the user's real login +// PATH so the children we spawn (the clawtool daemon, `clawtool send`) can +// resolve agent CLIs that a GUI/Finder launch's stub PATH hides. No-op on +// Windows (GUI launches there already inherit the user PATH). Strategy: +// keep everything we already have, fold in the login shell's PATH (picks up +// Homebrew, nvm/asdf/mise, custom profiles), then add the standard macOS +// dirs as a backstop — de-duped, existing entries kept first. +func ensureUserPath() { + if runtime.GOOS == "windows" { + return + } + seen := map[string]bool{} + var dirs []string + add := func(d string) { + d = strings.TrimSpace(d) + if d == "" || seen[d] { + return + } + seen[d] = true + dirs = append(dirs, d) + } + for _, d := range filepath.SplitList(os.Getenv("PATH")) { + add(d) + } + for _, d := range filepath.SplitList(loginShellPath()) { + add(d) + } + if home, err := os.UserHomeDir(); err == nil { + for _, d := range []string{ + "/opt/homebrew/bin", "/opt/homebrew/sbin", + "/usr/local/bin", "/usr/local/sbin", + filepath.Join(home, ".local", "bin"), + filepath.Join(home, ".cargo", "bin"), + filepath.Join(home, "go", "bin"), + filepath.Join(home, ".bun", "bin"), + "/usr/bin", "/bin", "/usr/sbin", "/sbin", + } { + add(d) + } + } + _ = os.Setenv("PATH", strings.Join(dirs, string(os.PathListSeparator))) +} + +// loginShellPath asks the user's login shell for its PATH so we inherit the +// same dirs a terminal would. "" on any failure — the caller has a +// hardcoded fallback. A sentinel brackets the value so shell-rc noise +// (banners, prompts) can't corrupt the parse. +func loginShellPath() string { + shell := os.Getenv("SHELL") + if shell == "" { + shell = "/bin/zsh" + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, shell, "-l", "-c", "printf '__CLAWPATH__%s__END__' \"$PATH\"") + out, err := cmd.Output() + if err != nil { + return "" + } + return parsePathSentinel(string(out)) +} + +// parsePathSentinel extracts the value between the __CLAWPATH__ / __END__ +// markers loginShellPath prints. Pure so it's unit-testable without a shell. +func parsePathSentinel(s string) string { + const open, close = "__CLAWPATH__", "__END__" + i := strings.Index(s, open) + if i < 0 { + return "" + } + rest := s[i+len(open):] + j := strings.Index(rest, close) + if j < 0 { + return "" + } + return rest[:j] +} + +// daemonBase caches the resolved daemon URL briefly so the per-pane 5s +// network poll doesn't spawn a `daemon url` (and sometimes `daemon +// start`) subprocess on every tick. Only successful resolves are cached; +// a miss falls through to a real probe + start, so a crashed daemon is +// still revived within the TTL. +var ( + daemonBaseMu sync.Mutex + daemonBaseVal string + daemonBaseAt time.Time +) + +const daemonBaseTTL = 8 * time.Second + +func cacheDaemonBase(base string) { + daemonBaseMu.Lock() + daemonBaseVal, daemonBaseAt = base, time.Now() + daemonBaseMu.Unlock() +} + // ensureDaemonBase resolves the daemon's loopback base URL, starting the // daemon if it isn't recorded yet. func (a *App) ensureDaemonBase() (string, error) { + daemonBaseMu.Lock() + if daemonBaseVal != "" && time.Since(daemonBaseAt) < daemonBaseTTL { + cached := daemonBaseVal + daemonBaseMu.Unlock() + return cached, nil + } + daemonBaseMu.Unlock() + bin, err := locateClawtool() if err != nil { return "", err @@ -636,6 +896,7 @@ func (a *App) ensureDaemonBase() (string, error) { return "", errors.New("gateway not running") } } + cacheDaemonBase(base) return base, nil } @@ -708,17 +969,19 @@ func parseStepLine(line string) (stepEvent, bool) { return stepEvent{Level: level, Label: label, Message: message, Raw: trimmed}, true } -// locateClawtool finds the clawtool binary to drive: first next to the -// installer executable (the .exe / .app ships them side-by-side), then -// on PATH. +// locateClawtool finds the clawtool binary to drive: first in the bin/ subdir +// beside the GUI (the .exe / .app ship the CLI there so it doesn't case-collide +// with the GUI exe — Clawtool.exe vs clawtool.exe), then on PATH. func locateClawtool() (string, error) { name := "clawtool" if runtime.GOOS == "windows" { name = "clawtool.exe" } if self, err := os.Executable(); err == nil { - cand := filepath.Join(filepath.Dir(self), name) - if _, statErr := os.Stat(cand); statErr == nil { + // bin/ only — NOT Dir(self)/clawtool.exe, which on a case-insensitive + // FS resolves to the GUI (Clawtool.exe) sitting in the root. + cand := filepath.Join(filepath.Dir(self), install.CLISubdir, name) + if fi, statErr := os.Stat(cand); statErr == nil && !fi.IsDir() { return cand, nil } } diff --git a/cmd/clawtool-installer/brand/brand.go b/cmd/clawtool-installer/brand/brand.go new file mode 100644 index 00000000..87cc9ae6 --- /dev/null +++ b/cmd/clawtool-installer/brand/brand.go @@ -0,0 +1,37 @@ +// Package brand is the single source of truth for the product's identity +// across the installer module: its name, the terminal command, the publisher, +// the tagline, and the derived binary + shortcut + install-dir names. Rename +// the product by editing the constants here — the runtime UI (App.Brand feeds +// the frontend) and the install mechanics both follow. +// +// The only rename points OUTSIDE this file are build-time artifact names that +// Go can't reach: wails.json's "outputfilename" and the binary names in the +// installer CI (.github/workflows/installer.yml). Keep those in sync with +// Display below. +package brand + +const ( + // Name is the lowercase, spoken/display name shown in UI copy. + Name = "clawtool" + // Display is the capitalized form used for the install dir, the exe base + // names, shortcuts, and the Add/Remove-Programs entry. + Display = "Clawtool" + // CLI is the terminal command users type. + CLI = "clawtool" + // Publisher is the Add/Remove-Programs publisher. + Publisher = "Cogitave" + // Tagline is the one-line product description on the installer welcome. + Tagline = "An agent gateway for your machine — one tool layer, everywhere." +) + +// AppExe is the GUI app/setup binary name (e.g. Clawtool.exe). +func AppExe() string { return Display + ".exe" } + +// CLIExe is the headless CLI/daemon binary name (e.g. clawtool.exe). +func CLIExe() string { return CLI + ".exe" } + +// UpdaterExe is the standalone updater binary name (e.g. ClawtoolUpdate.exe). +func UpdaterExe() string { return Display + "Update.exe" } + +// ShortcutName is the Start-menu/Desktop .lnk file name. +func ShortcutName() string { return Display + ".lnk" } diff --git a/cmd/clawtool-installer/build/windows/installer/project.nsi b/cmd/clawtool-installer/build/windows/installer/project.nsi deleted file mode 100644 index 5a2e277a..00000000 --- a/cmd/clawtool-installer/build/windows/installer/project.nsi +++ /dev/null @@ -1,185 +0,0 @@ -Unicode true - -## clawtool desktop installer — based on Wails' canonical project.nsi -## (v2.10.1), customized to also install the headless `clawtool.exe` -## alongside the GUI. INFO_* + the wails.* macros come from -## wails_tools.nsh. Built via a manual makensis call (see -## .github/workflows/installer.yml) with: -## makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\Clawtool.exe project.nsi - -# Per-user install (industry-standard for an auto-updating desktop app: -# VS Code, Chrome user-install, etc.). Defined BEFORE wails_tools.nsh so -# its !ifndef default of "admin" doesn't win — this flips -# RequestExecutionLevel to "user" and makes wails.setShellContext use -# the current-user shell folders. The payoff: the tray can run -# `clawtool upgrade` to swap the binary in $LOCALAPPDATA with NO UAC -# prompt, so updates are silent. (Per-machine Program Files would force -# a UAC prompt on every update.) -!define REQUEST_EXECUTION_LEVEL "user" - -# Install the GUI as Clawtool.exe (the app), not the wails project name -# (clawtool-installer.exe). Defined BEFORE wails_tools.nsh so its -# !ifndef default ("${INFO_PROJECTNAME}.exe") doesn't win. The shortcuts, -# finish-page run, and uninstaller all reference ${PRODUCT_EXECUTABLE}. -!define PRODUCT_EXECUTABLE "Clawtool.exe" - -!include "wails_tools.nsh" - -VIProductVersion "${INFO_PRODUCTVERSION}.0" -VIFileVersion "${INFO_PRODUCTVERSION}.0" - -VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}" -VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer" -VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}" -VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}" -VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}" -VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}" - -# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware -ManifestDPIAware true - -!include "MUI.nsh" -!include "WinMessages.nsh" # WM_SETTINGCHANGE / HWND_BROADCAST for the PATH broadcast. - -# EnVar plugin — the only safe way to edit the system PATH from NSIS. -# A naive "read Path into a var, append, write back" corrupts PATH on -# the stock makensis build (NSIS_MAX_STRLEN=1024 silently truncates a -# long PATH). EnVar edits the registry value directly, with dedup, and -# preserves the REG_EXPAND_SZ type. The plugin DLL is fetched into -# ./plugins by the installer CI step before makensis runs. -!addplugindir ".\plugins" - -# (Icon defines intentionally omitted — no build/windows/icon.ico is -# shipped; MUI uses its defaults so the installer builds without it.) -!define MUI_FINISHPAGE_NOAUTOCLOSE -!define MUI_ABORTWARNING - -# Offer to run the one-time initialize flow right after files are installed. -# The GUI launched with --installer runs the bootstrap (daemon, bridges, -# agent claim, init); without the flag (the Start-menu/desktop shortcuts) it -# is just the app. This is the installer-vs-app role split — the installer -# initializes once, the app never does. -!define MUI_FINISHPAGE_RUN "$INSTDIR\${PRODUCT_EXECUTABLE}" -!define MUI_FINISHPAGE_RUN_PARAMETERS "--installer" -!define MUI_FINISHPAGE_RUN_TEXT "Set up clawtool now" - -!insertmacro MUI_PAGE_WELCOME -!insertmacro MUI_PAGE_DIRECTORY -!insertmacro MUI_PAGE_INSTFILES -!insertmacro MUI_PAGE_FINISH - -!insertmacro MUI_UNPAGE_INSTFILES - -!insertmacro MUI_LANGUAGE "English" - -Name "${INFO_PRODUCTNAME}" -# Industry-standard setup name (cf. DiscordSetup.exe / SlackSetup.exe), -# not the arch-tagged dev artifact name. -OutFile "..\..\bin\ClawtoolSetup.exe" -# Per-user location (no admin needed; writable by the tray for silent -# self-update). $LOCALAPPDATA\Programs\ is where per-user apps land -# by convention (same place VS Code's user setup installs). -InstallDir "$LOCALAPPDATA\Programs\${INFO_PRODUCTNAME}" -ShowInstDetails show - -Function .onInit - !insertmacro wails.checkArchitecture -FunctionEnd - -Section - !insertmacro wails.setShellContext - - # Re-install / upgrade over an existing copy: a running clawtool - # holds its .exe files locked, so the file copy below would fail - # (the operator-reported "already installed, couldn't reinstall"). - # Stop the daemon gracefully if a previous clawtool is on PATH, then - # force-kill the tray GUI + any daemon so every binary unlocks. - # taskkill on a not-running image just returns nonzero — harmless. - nsExec::Exec 'clawtool daemon stop' - nsExec::Exec 'taskkill /F /IM ${PRODUCT_EXECUTABLE}' - nsExec::Exec 'taskkill /F /IM clawtool.exe' - Sleep 600 - - !insertmacro wails.webview2runtime - - SetOutPath $INSTDIR - - # The Wails GUI installer binary (installed as ${PRODUCT_EXECUTABLE}). - !insertmacro wails.files - - # The headless clawtool binary the GUI drives — bundled so the - # install is self-contained. locateClawtool() finds it next to the - # GUI in $INSTDIR. - File "..\..\bin\clawtool.exe" - - # The standalone updater: the app hands off to it to swap binaries it - # can't overwrite while running, then relaunch. Lives next to the app. - File "..\..\bin\ClawtoolUpdate.exe" - - CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" - CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" - - # Put $INSTDIR on the PATH so `clawtool` is a global terminal command - # (the operator-reported "clawtool: command not found"). SetHKCU - # targets the per-user PATH — matches the per-user install and needs - # no admin. AddValue dedups, so re-running won't stack duplicates. - EnVar::SetHKCU - EnVar::AddValue "Path" "$INSTDIR" - Pop $0 - DetailPrint "Add clawtool to user PATH: $0 (0 = ok)" - # Broadcast the change so newly-launched shells pick it up without a - # reboot. Already-open terminals still need to be reopened — the GUI - # success screen tells the operator this. - SendMessage ${HWND_BROADCAST} ${WM_SETTINGCHANGE} 0 "STR:Environment" /TIMEOUT=5000 - - !insertmacro wails.associateFiles - !insertmacro wails.associateCustomProtocols - - # Add/Remove Programs registration. The stock wails.writeUninstaller - # macro writes to HKLM, which a non-admin per-user install can't - # touch — so register under HKCU instead (where per-user apps belong, - # and where Windows' Settings > Apps reads them for this user). - WriteUninstaller "$INSTDIR\uninstall.exe" - WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}" - WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}" - WriteRegStr HKCU "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}" - WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}" - WriteRegStr HKCU "${UNINST_KEY}" "InstallLocation" "$INSTDIR" - WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" - WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S" - WriteRegDWORD HKCU "${UNINST_KEY}" "NoModify" 1 - WriteRegDWORD HKCU "${UNINST_KEY}" "NoRepair" 1 -SectionEnd - -Section "uninstall" - !insertmacro wails.setShellContext - - # Stop the running gateway so its binaries unlock before removal. - nsExec::Exec 'taskkill /F /IM ${PRODUCT_EXECUTABLE}' - nsExec::Exec 'taskkill /F /IM clawtool.exe' - Sleep 600 - - # Remove $INSTDIR from the user PATH (mirror of the install-time - # AddValue). DeleteValue is a no-op if it isn't present, so a partial - # / repeated uninstall stays clean. - EnVar::SetHKCU - EnVar::DeleteValue "Path" "$INSTDIR" - Pop $0 - DetailPrint "Remove clawtool from user PATH: $0 (0 = ok)" - SendMessage ${HWND_BROADCAST} ${WM_SETTINGCHANGE} 0 "STR:Environment" /TIMEOUT=5000 - - RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath - - RMDir /r $INSTDIR - - Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" - Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk" - - !insertmacro wails.unassociateFiles - !insertmacro wails.unassociateCustomProtocols - - # Mirror of the HKCU ARP registration written on install (the stock - # wails.deleteUninstaller targets HKLM, which we never wrote to). - Delete "$INSTDIR\uninstall.exe" - DeleteRegKey HKCU "${UNINST_KEY}" -SectionEnd diff --git a/cmd/clawtool-installer/build/windows/installer/wails_tools.nsh b/cmd/clawtool-installer/build/windows/installer/wails_tools.nsh deleted file mode 100644 index faee354d..00000000 --- a/cmd/clawtool-installer/build/windows/installer/wails_tools.nsh +++ /dev/null @@ -1,196 +0,0 @@ -# Rendered from Wails' canonical wails_tools.nsh (v2.10.1) with the -# project's values substituted and the file-association / custom- -# protocol template ranges rendered empty (clawtool-installer declares -# neither). Kept checked in so a manual `makensis` build works without -# relying on `wails build` to regenerate it. - -!include "x64.nsh" -!include "WinVer.nsh" -!include "FileFunc.nsh" - -!ifndef INFO_PROJECTNAME - !define INFO_PROJECTNAME "clawtool-installer" -!endif -!ifndef INFO_COMPANYNAME - !define INFO_COMPANYNAME "Cogitave" -!endif -!ifndef INFO_PRODUCTNAME - !define INFO_PRODUCTNAME "clawtool" -!endif -!ifndef INFO_PRODUCTVERSION - !define INFO_PRODUCTVERSION "1.0.0" -!endif -!ifndef INFO_COPYRIGHT - !define INFO_COPYRIGHT "Copyright © Cogitave" -!endif -!ifndef PRODUCT_EXECUTABLE - !define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe" -!endif -!ifndef UNINST_KEY_NAME - !define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" -!endif -!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}" - -!ifndef REQUEST_EXECUTION_LEVEL - !define REQUEST_EXECUTION_LEVEL "admin" -!endif - -RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" - -!ifdef ARG_WAILS_AMD64_BINARY - !define SUPPORTS_AMD64 -!endif - -!ifdef ARG_WAILS_ARM64_BINARY - !define SUPPORTS_ARM64 -!endif - -!ifdef SUPPORTS_AMD64 - !ifdef SUPPORTS_ARM64 - !define ARCH "amd64_arm64" - !else - !define ARCH "amd64" - !endif -!else - !ifdef SUPPORTS_ARM64 - !define ARCH "arm64" - !else - !error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY" - !endif -!endif - -!macro wails.checkArchitecture - !ifndef WAILS_WIN10_REQUIRED - !define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later." - !endif - - !ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED - !define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}" - !endif - - ${If} ${AtLeastWin10} - !ifdef SUPPORTS_AMD64 - ${if} ${IsNativeAMD64} - Goto ok - ${EndIf} - !endif - - !ifdef SUPPORTS_ARM64 - ${if} ${IsNativeARM64} - Goto ok - ${EndIf} - !endif - - IfSilent silentArch notSilentArch - silentArch: - SetErrorLevel 65 - Abort - notSilentArch: - MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}" - Quit - ${else} - IfSilent silentWin notSilentWin - silentWin: - SetErrorLevel 64 - Abort - notSilentWin: - MessageBox MB_OK "${WAILS_WIN10_REQUIRED}" - Quit - ${EndIf} - - ok: -!macroend - -!macro wails.files - !ifdef SUPPORTS_AMD64 - ${if} ${IsNativeAMD64} - File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}" - ${EndIf} - !endif - - !ifdef SUPPORTS_ARM64 - ${if} ${IsNativeARM64} - File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}" - ${EndIf} - !endif -!macroend - -!macro wails.writeUninstaller - WriteUninstaller "$INSTDIR\uninstall.exe" - - SetRegView 64 - WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}" - WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}" - WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}" - WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}" - WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" - WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S" - - ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 - IntFmt $0 "0x%08X" $0 - WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0" -!macroend - -!macro wails.deleteUninstaller - Delete "$INSTDIR\uninstall.exe" - - SetRegView 64 - DeleteRegKey HKLM "${UNINST_KEY}" -!macroend - -!macro wails.setShellContext - ${If} ${REQUEST_EXECUTION_LEVEL} == "admin" - SetShellVarContext all - ${else} - SetShellVarContext current - ${EndIf} -!macroend - -# Install webview2 by launching the bootstrapper -!macro wails.webview2runtime - !ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT - !define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime" - !endif - - SetRegView 64 - # If the admin key exists and is not empty then webview2 is already installed - ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" - ${If} $0 != "" - Goto ok - ${EndIf} - - ${If} ${REQUEST_EXECUTION_LEVEL} == "user" - ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" - ${If} $0 != "" - Goto ok - ${EndIf} - ${EndIf} - - SetDetailsPrint both - DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}" - SetDetailsPrint listonly - - InitPluginsDir - CreateDirectory "$pluginsdir\webview2bootstrapper" - SetOutPath "$pluginsdir\webview2bootstrapper" - File "tmp\MicrosoftEdgeWebview2Setup.exe" - ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install' - - SetDetailsPrint both - ok: -!macroend - -# clawtool-installer declares no file associations or custom protocols, -# so these are intentionally empty (the canonical template fills them -# from a Go-template range). -!macro wails.associateFiles -!macroend - -!macro wails.unassociateFiles -!macroend - -!macro wails.associateCustomProtocols -!macroend - -!macro wails.unassociateCustomProtocols -!macroend diff --git a/cmd/clawtool-installer/frontend/dist/.gitignore b/cmd/clawtool-installer/frontend/dist/.gitignore new file mode 100644 index 00000000..c3d07e57 --- /dev/null +++ b/cmd/clawtool-installer/frontend/dist/.gitignore @@ -0,0 +1,3 @@ +* +!.gitkeep +!.gitignore diff --git a/cmd/clawtool-installer/frontend/dist/.gitkeep b/cmd/clawtool-installer/frontend/dist/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cmd/clawtool-installer/frontend/dist/index.html b/cmd/clawtool-installer/frontend/dist/index.html deleted file mode 100644 index e259eec6..00000000 --- a/cmd/clawtool-installer/frontend/dist/index.html +++ /dev/null @@ -1,588 +0,0 @@ - - - - - - clawtool - - - - -
-

clawtool

-
-
Checking for updates…
-
- - -
-

Initializing clawtool

-
Setting this device up as a gateway…
-
0%
-
    - -
    - - -
    - -
    -
    -
    - -
    -

    clawtool is running

    -
    This device is a gateway on your network
    -
    -
    -
    -
    Local agents
    -
    LAN peers
    -
    Cross-device
    -
    -

    Quick actions

    -
    - - -
    -
    Closing the window keeps clawtool running in the menu bar.
    -
    -
    - -

    This device

    -
    Agents running locally on this machine.
    -
    -

    Cross-device

    -
    -

    Network

    -
    -
    -
    -

    Updates

    -
    clawtool checks for a new version each time it launches.
    -
    -
    Installed
    -
    Latest
    -
    Statuschecking…
    -
    - - -
    -
    -
    -
    -
    - - - - diff --git a/cmd/clawtool-installer/install/install.go b/cmd/clawtool-installer/install/install.go new file mode 100644 index 00000000..2d35c89f --- /dev/null +++ b/cmd/clawtool-installer/install/install.go @@ -0,0 +1,244 @@ +// Package install performs a custom, NSIS-free install on Windows. +// +// It mirrors exactly what the old NSIS wizard did — place the bundled +// binaries under %LOCALAPPDATA%\Programs\, create Start-menu + +// Desktop shortcuts, add the CLI to the user PATH, and register an +// Add/Remove-Programs entry with a working uninstaller — but driven by the +// setup app's modern, stepped UI instead of a classic Next/Next wizard. +// +// Layout note: the GUI (Clawtool.exe) and the headless CLI (clawtool.exe) +// differ only in case, so they CANNOT share a directory on a case-insensitive +// filesystem (Windows NTFS, default macOS) — one would clobber the other. The +// CLI therefore lives in a bin/ subdir; the GUI + updater sit in the root, and +// bin/ goes on PATH so `clawtool` still resolves as a terminal command. +// +// All product names come from the brand package (single source of truth), so +// a rename never touches this file. Windows OS integration (shortcuts, PATH, +// registry) is done via PowerShell rather than COM/syscall: it's the +// dependency-free, well-trodden path. Everything compiles on every OS; the +// Windows branches no-op elsewhere (macOS ships a .dmg, not this installer). +package install + +import ( + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/cogitave/clawtool/cmd/clawtool-installer/brand" +) + +// CLISubdir is where the headless CLI lives, relative to the install root — +// kept out of the root so it doesn't case-collide with the GUI exe. +const CLISubdir = "bin" + +// PayloadBinaries are the payload entries (forward-slash, embed-relative) laid +// into the install dir, preserving structure: the GUI + updater in the root, +// the CLI under bin/. +func PayloadBinaries() []string { + return []string{brand.AppExe(), brand.UpdaterExe(), CLISubdir + "/" + brand.CLIExe()} +} + +// imageNames are the running process image names to stop before overwriting +// (base names; taskkill matches on image name regardless of path). +func imageNames() []string { + return []string{brand.AppExe(), brand.CLIExe(), brand.UpdaterExe()} +} + +// Progress reports one install step to the UI. detail is optional context +// (a path, a size, a short note) shown dimmed next to the label. +type Progress func(label, detail string) + +func noop(string, string) {} + +// InstallDir returns %LOCALAPPDATA%\Programs\ — the per-user location +// the NSIS installer used, so `clawtool upgrade` keeps swapping in place with +// no UAC prompt. On non-Windows it returns a sane per-user dir (unused in +// practice; macOS ships a .dmg). +func InstallDir() string { + if runtime.GOOS == "windows" { + base := os.Getenv("LOCALAPPDATA") + if base == "" { + base = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local") + } + return filepath.Join(base, "Programs", brand.Display) + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".local", "share", strings.ToLower(brand.Display)) +} + +// CLIDir is the directory the headless CLI is installed into (root\bin). +func CLIDir(installDir string) string { return filepath.Join(installDir, CLISubdir) } + +// Install lays the product down from the embedded payload. Idempotent: a +// second run overwrites the binaries and refreshes shortcuts / PATH / +// registry. Returns the install dir. It does NOT launch the app — the caller +// (the Done step) decides when to open it. +func Install(payload fs.FS, version string, progress Progress) (string, error) { + if progress == nil { + progress = noop + } + dir := InstallDir() + + progress("Closing any running "+brand.CLI, "daemon stop · taskkill") + stopRunning(dir) + + progress("Preparing install directory", dir) + if err := os.MkdirAll(CLIDir(dir), 0o755); err != nil { + return dir, fmt.Errorf("create install dir: %w", err) + } + + for _, rel := range PayloadBinaries() { + dst := filepath.Join(dir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return dir, fmt.Errorf("mkdir for %s: %w", rel, err) + } + if err := copyFromFS(payload, rel, dst); err != nil { + return dir, fmt.Errorf("write %s: %w", rel, err) + } + progress("Writing "+filepath.Base(dst), humanSize(dst)) + } + + if runtime.GOOS == "windows" { + progress("Creating shortcuts", "Start menu · Desktop") + if err := windowsShortcuts(dir); err != nil { + return dir, fmt.Errorf("shortcuts: %w", err) + } + progress("Adding "+brand.CLI+" to PATH", "user environment") + if err := windowsAddToPath(CLIDir(dir)); err != nil { + return dir, fmt.Errorf("PATH: %w", err) + } + progress("Registering uninstaller", "Apps & features") + if err := windowsRegisterUninstall(dir, version); err != nil { + return dir, fmt.Errorf("register uninstaller: %w", err) + } + } + return dir, nil +} + +// LaunchInstalled starts the freshly-installed app in installer mode (which +// runs the one-time initialize flow), detached, so setup can exit. +func LaunchInstalled() error { + cmd := exec.Command(filepath.Join(InstallDir(), brand.AppExe()), "--installer") + return cmd.Start() +} + +func copyFromFS(src fs.FS, name, dst string) error { + data, err := fs.ReadFile(src, name) + if err != nil { + return err + } + return os.WriteFile(dst, data, 0o755) +} + +// humanSize returns the file's size like "31.2 MB" (best-effort; "" on error). +func humanSize(path string) string { + fi, err := os.Stat(path) + if err != nil { + return "" + } + const u = 1024.0 + b := float64(fi.Size()) + switch { + case b >= u*u: + return fmt.Sprintf("%.1f MB", b/(u*u)) + case b >= u: + return fmt.Sprintf("%.0f KB", b/u) + default: + return fmt.Sprintf("%d B", fi.Size()) + } +} + +func stopRunning(dir string) { + if runtime.GOOS != "windows" { + return + } + // Best-effort: stop the daemon and kill running images (app, tray, CLI, + // updater) so the .exe files aren't locked while we overwrite them. + _ = exec.Command(filepath.Join(CLIDir(dir), brand.CLIExe()), "daemon", "stop").Run() + for _, img := range imageNames() { + _ = exec.Command("taskkill", "/F", "/IM", img).Run() + } + // taskkill returns before the OS has fully released the file handles; + // give them a moment so the subsequent overwrite doesn't hit a lock + // ("file in use") on an upgrade-over-running install. + time.Sleep(700 * time.Millisecond) +} + +// windowsShortcuts writes Start-menu + Desktop .lnk files via WScript.Shell. +func windowsShortcuts(dir string) error { + target := filepath.Join(dir, brand.AppExe()) + ps := fmt.Sprintf(` +$ws = New-Object -ComObject WScript.Shell +foreach ($d in @($ws.SpecialFolders('Programs'), $ws.SpecialFolders('Desktop'))) { + $lnk = $ws.CreateShortcut((Join-Path $d %q)) + $lnk.TargetPath = %q + $lnk.WorkingDirectory = %q + $lnk.IconLocation = %q + $lnk.Save() +}`, brand.ShortcutName(), target, dir, target) + return powershell(ps) +} + +// windowsAddToPath appends the given dir (the CLI's bin/) to the user PATH +// (idempotent). +func windowsAddToPath(binDir string) error { + ps := fmt.Sprintf(` +$p = [Environment]::GetEnvironmentVariable('Path','User') +if (-not ($p -split ';' | Where-Object { $_ -eq %q })) { + if ($p -and -not $p.EndsWith(';')) { $p += ';' } + [Environment]::SetEnvironmentVariable('Path', $p + %q, 'User') +}`, binDir, binDir) + return powershell(ps) +} + +// windowsRegisterUninstall writes the Add/Remove-Programs entry and drops a +// self-contained uninstall.ps1 (removes the dir, PATH entry, shortcuts, and +// the registry key) that UninstallString points at — so uninstall works +// without needing the app to handle a flag. +func windowsRegisterUninstall(dir, version string) error { + key := `HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\` + brand.Display + icon := filepath.Join(dir, brand.AppExe()) + uninstallScript := filepath.Join(dir, "uninstall.ps1") + binDir := CLIDir(dir) + + imgs := "'" + strings.Join(imageNames(), "','") + "'" + script := fmt.Sprintf(` +foreach ($img in %s) { taskkill /F /IM $img 2>$null } +$p = ([Environment]::GetEnvironmentVariable('Path','User') -split ';' | Where-Object { $_ -and $_ -ne %q }) -join ';' +[Environment]::SetEnvironmentVariable('Path', $p, 'User') +$ws = New-Object -ComObject WScript.Shell +foreach ($d in @($ws.SpecialFolders('Programs'), $ws.SpecialFolders('Desktop'))) { Remove-Item (Join-Path $d %q) -ErrorAction SilentlyContinue } +Remove-Item -Path %q -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item -Path %q -Recurse -Force -ErrorAction SilentlyContinue`, + imgs, binDir, brand.ShortcutName(), key, dir) + if err := os.WriteFile(uninstallScript, []byte(script), 0o644); err != nil { + return err + } + + uninstallCmd := fmt.Sprintf(`powershell -NoProfile -ExecutionPolicy Bypass -File "%s"`, uninstallScript) + reg := fmt.Sprintf(` +New-Item -Path %q -Force | Out-Null +Set-ItemProperty -Path %q -Name 'DisplayName' -Value %q +Set-ItemProperty -Path %q -Name 'DisplayVersion' -Value %q +Set-ItemProperty -Path %q -Name 'Publisher' -Value %q +Set-ItemProperty -Path %q -Name 'DisplayIcon' -Value %q +Set-ItemProperty -Path %q -Name 'InstallLocation' -Value %q +Set-ItemProperty -Path %q -Name 'UninstallString' -Value %q +Set-ItemProperty -Path %q -Name 'NoModify' -Value 1 -Type DWord +Set-ItemProperty -Path %q -Name 'NoRepair' -Value 1 -Type DWord`, + key, key, brand.Display, key, version, key, brand.Publisher, key, icon, key, dir, key, uninstallCmd, key, key) + return powershell(reg) +} + +func powershell(script string) error { + cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out))) + } + return nil +} diff --git a/cmd/clawtool-installer/install/install_test.go b/cmd/clawtool-installer/install/install_test.go new file mode 100644 index 00000000..7c240bcc --- /dev/null +++ b/cmd/clawtool-installer/install/install_test.go @@ -0,0 +1,56 @@ +package install + +import ( + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" +) + +func TestInstallDir_NonEmpty(t *testing.T) { + if InstallDir() == "" { + t.Fatal("InstallDir must not be empty") + } +} + +func TestCopyFromFS(t *testing.T) { + payload := fstest.MapFS{"clawtool.exe": {Data: []byte("BINARY")}} + dst := filepath.Join(t.TempDir(), "clawtool.exe") + if err := copyFromFS(payload, "clawtool.exe", dst); err != nil { + t.Fatalf("copy: %v", err) + } + got, err := os.ReadFile(dst) + if err != nil || string(got) != "BINARY" { + t.Fatalf("copied file wrong: %q err=%v", got, err) + } +} + +func TestPayloadBinariesIncludesAll(t *testing.T) { + // CLI lives under bin/ so it doesn't case-collide with the GUI exe. + want := map[string]bool{"Clawtool.exe": false, "bin/clawtool.exe": false, "ClawtoolUpdate.exe": false} + for _, b := range PayloadBinaries() { + want[b] = true + } + for name, found := range want { + if !found { + t.Errorf("PayloadBinaries missing %s", name) + } + } +} + +func TestNoCaseCollisionInInstallLayout(t *testing.T) { + // The GUI (Clawtool.exe) and CLI (clawtool.exe) must never land in the + // same directory — they collide on a case-insensitive filesystem. Verify + // each install destination dir holds at most one spelling of "clawtool*". + dir := InstallDir() + seen := map[string]string{} // dir + lowercased-base -> original rel + for _, rel := range PayloadBinaries() { + dst := filepath.Join(dir, filepath.FromSlash(rel)) + k := filepath.Dir(dst) + "\x00" + strings.ToLower(filepath.Base(dst)) + if prev, ok := seen[k]; ok { + t.Fatalf("case collision: %q and %q resolve to the same path on a case-insensitive FS", prev, rel) + } + seen[k] = rel + } +} diff --git a/cmd/clawtool-installer/main.go b/cmd/clawtool-installer/main.go index a91ffdae..1f2ace67 100644 --- a/cmd/clawtool-installer/main.go +++ b/cmd/clawtool-installer/main.go @@ -10,22 +10,25 @@ // Wails toolchain + platform webview libs to build — it is NOT // compiled by the main repo CI. Build + verify with `wails build` // (or `wails dev`) on a Windows / macOS host. The Go here is -// gofmt-clean and follows the Wails v2 vanilla template; treat any +// gofmt-clean and follows the Wails v2 conventions; treat any // build error as a version-pin / API-drift fix, not a redesign. package main import ( "embed" "os" + "runtime" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" + "github.com/wailsapp/wails/v2/pkg/options/mac" ) -// assets embeds the splash frontend. `wails build` populates/uses -// frontend/dist; the vanilla (no-bundler) setup keeps the static -// files directly under frontend/dist. +// assets embeds the built React SPA (the desktop/ pnpm monorepo's app-ui, +// Vite-built). CI runs `pnpm build` and copies the bundle into frontend/dist +// before the Go build; locally, do the same. The single SPA routes on +// App.Mode() to the app / installer / first-launch surfaces. // //go:embed all:frontend/dist var assets embed.FS @@ -39,12 +42,37 @@ func main() { desktopMode = "installer" } } + // The ClawtoolSetup build embeds the payload — running it self-installs + // (no classic wizard) then launches the installed app. The plain app + // build has no payload, so this never fires for the installed Clawtool.exe. + if payloadPresent() { + desktopMode = "setup" + } app := NewApp() + // The setup binary launches the installed Clawtool.exe (in --installer + // mode) before it quits, so for a moment both run. They must NOT share a + // single-instance lock: if they did, the freshly-launched app would see + // the still-running setup as the first instance, surface it, and exit + // instead of starting. Give setup its own lock id. + instanceID := "com.cogitave.clawtool.installer" + if desktopMode == "setup" { + instanceID = "com.cogitave.clawtool.setup" + } + + // macOS: Frameless:true strips the native window chrome including the + // traffic lights. Keep the window NON-frameless on darwin so + // TitleBarHiddenInset shows the inset traffic lights over full-size + // content; Windows/Linux stay frameless and the frontend draws its own + // min/maximize/close controls. + frameless := runtime.GOOS != "darwin" + err := wails.Run(&options.App{ - Title: "clawtool", - Width: 520, - Height: 420, + Title: "clawtool", + Frameless: frameless, + Mac: &mac.Options{TitleBar: mac.TitleBarHiddenInset()}, + Width: 520, + Height: 420, // The window opens as a small, fixed Discord-style splash — the // Updater phase (every launch) and, on first run, the // Initializing phase render here. Once those finish, the frontend @@ -56,7 +84,7 @@ func main() { MaxWidth: 520, MinHeight: 420, MaxHeight: 420, - BackgroundColour: &options.RGBA{R: 255, G: 255, B: 255, A: 255}, + BackgroundColour: &options.RGBA{R: 24, G: 24, B: 24, A: 255}, // Closing the window hides it — clawtool stays in the system // tray as the running gateway. Quit from the tray menu. HideWindowOnClose: true, @@ -64,7 +92,7 @@ func main() { // launch must not start a conflicting process — it just // surfaces the running window. SingleInstanceLock: &options.SingleInstanceLock{ - UniqueId: "com.cogitave.clawtool.installer", + UniqueId: instanceID, OnSecondInstanceLaunch: app.onSecondInstanceLaunch, }, AssetServer: &assetserver.Options{Assets: assets}, diff --git a/cmd/clawtool-installer/namzu_runtime.go b/cmd/clawtool-installer/namzu_runtime.go new file mode 100644 index 00000000..10447d06 --- /dev/null +++ b/cmd/clawtool-installer/namzu_runtime.go @@ -0,0 +1,426 @@ +// namzu runtime — the LOCAL agent engine for the desktop. +// +// The operator's direction: namzu IS the runtime; the desktop is just its +// UI. So a local turn (env = "Local") runs through the namzu CLI's +// streaming one-shot (`node run-stream`) instead of clawtool's +// multi-CLI bridge dispatch. namzu is credential-first (reads the Claude +// Code OAuth from the Keychain, auto-refreshes) and provider-generic, so +// this works without any external agent CLI being healthy — fixing the +// "can't send to codex / nothing happens / errors vanish" failure mode of +// the bridge path. +// +// Wire format: run-stream emits one compact JSON object per line, each an +// AgentEvent ({"kind":"delta","text":…} / "tool-start" / "tool-end" / +// "error" / "done"). We translate those straight into the renderer's +// agent:event protocol. Cross-device turns still use dispatchAgentToPeer. +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// namzuEvent is one NDJSON line from `namzu run-stream`. Only the fields the +// desktop renders are decoded; the rest (usage/task) are ignored for now. +// Detail carries a tool/sub-agent's step lines — namzu's delegation tree. +type namzuEvent struct { + Kind string `json:"kind"` + Text string `json:"text,omitempty"` + ToolName string `json:"toolName,omitempty"` + Message string `json:"message,omitempty"` + Detail []string `json:"detail,omitempty"` +} + +// runNamzuTurn drives one LOCAL turn through the namzu CLI and streams its +// reply back over the agent:event channel. opts carries the Namzu tab's +// per-turn model / instance / skills selection (zero value for a plain turn). +func (a *App) runNamzuTurn(turnID, projectID, message string, opts namzuTurnOpts) { + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "start"}) + + // Make this IN-PROCESS turn visible on the device-wide Sessions surface. + // The run executes here in the desktop (namzu runtime), not in the daemon, + // so the daemon's run registry can't observe it on its own — we register it + // explicitly and flip it to idle when the turn ends (any exit path). + a.registerLocalRun(turnID, projectID, opts.Instance, message) + defer a.updateLocalRunStatus(turnID, "idle") + + node, binJS, err := locateNamzu() + if err != nil { + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "error", Error: err.Error()}) + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "done"}) + return + } + + // cwd anchors namzu's tools + its on-disk session store (/.namzu). + // A session with no folder still needs a stable home so history persists + // across turns — never the daemon's random working directory. + cwd := a.namzuTurnCwd(projectID) + + ctx, release := a.turnContext(turnID) + defer release() + + // Bind the turn to a persisted conversation keyed by the session id, so + // namzu loads this session's prior turns as context and appends this one — + // that's what makes a reopened session show its history (AgentHistory). The + // Namzu tab's selections (model/instance/skills) ride as run-stream flags. + args := []string{binJS, "run-stream", "--session", projectID} + if opts.Model != "" { + args = append(args, "--model", opts.Model) + } + if opts.Provider != "" { + args = append(args, "--provider", opts.Provider) + } + if opts.Instance != "" { + args = append(args, "--instance", opts.Instance) + } + if len(opts.Skills) > 0 { + args = append(args, "--skills", strings.Join(opts.Skills, ",")) + } + args = append(args, message) + cmd := exec.CommandContext(ctx, node, args...) + cmd.Dir = cwd + // Inherit the (PATH-repaired, see ensureUserPath) environment so namzu's + // credential discovery + `node` resolution match a terminal launch. + cmd.Env = os.Environ() + hideConsole(cmd) + + stdout, err := cmd.StdoutPipe() + if err != nil { + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "error", Error: "open stdout: " + err.Error()}) + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "done"}) + return + } + var stderrBuf strings.Builder + cmd.Stderr = &stderrBuf + // With --session, run-stream loads prior history from the cwd's .namzu + // store itself, so we don't feed history on stdin. Closing it lets the + // child proceed immediately. + cmd.Stdin = strings.NewReader("") + + if err := cmd.Start(); err != nil { + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "error", Error: "launch namzu: " + err.Error()}) + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "done"}) + return + } + + sawText := false + sawError := false + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var ev namzuEvent + if json.Unmarshal([]byte(line), &ev) != nil { + continue // non-JSON noise — skip + } + switch ev.Kind { + case "delta": + if ev.Text != "" { + sawText = true + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "delta", Text: ev.Text}) + } + case "tool-start": + if ev.ToolName != "" { + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "tool-start", ToolName: ev.ToolName, Detail: ev.Detail}) + } + case "tool-end": + // Carries the sub-agent delegation steps (├─/└─ tree) when the tool + // was an `Agent` delegation — the Namzu tab renders these live. + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "tool-end", ToolName: ev.ToolName, Detail: ev.Detail}) + case "error": + sawError = true + msg := ev.Message + if msg == "" { + msg = "namzu reported an error" + } + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "error", Error: msg}) + case "done": + // terminal — handled after the loop so we can fold in stderr/no-output + } + } + streamErr := scanner.Err() + _ = cmd.Wait() + + // Surface a failure the operator can act on instead of a silent empty + // reply: a stream read error, or a turn that produced neither text nor an + // explicit error (e.g. the child died before emitting anything — stderr + // usually says why). + if streamErr != nil { + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "error", Error: "stream error: " + streamErr.Error()}) + } else if !sawText && !sawError { + msg := strings.TrimSpace(stderrBuf.String()) + if msg == "" { + msg = "namzu produced no output" + } else if len(msg) > 600 { + msg = msg[:600] + "…" + } + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "error", Error: msg}) + } + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "done"}) +} + +// namzuTurnCwd resolves the working directory a session's turns (and its +// history store) live in: the session's cwd, else its project's, else a +// stable per-install scratch dir so history survives across turns. +func (a *App) namzuTurnCwd(projectID string) string { + cwd := a.sessionCwd(projectID) + if cwd == "" { + cwd = a.projectCwd(projectID) + } + if cwd == "" { + cwd = namzuScratchDir() + } + return cwd +} + +// namzuScratchDir is a stable per-install working directory for sessions +// that haven't attached a folder, so namzu's /.namzu history survives +// across turns. Best-effort: falls back to the OS temp dir. +func namzuScratchDir() string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return os.TempDir() + } + dir := filepath.Join(home, ".config", "clawtool", "namzu-workspace") + _ = os.MkdirAll(dir, 0o700) + return dir +} + +// AgentHistory returns a session's persisted transcript as a JSON array of +// {role, content} — the messages namzu stored for prior turns under this +// session id. The renderer calls this on mount to hydrate past messages so a +// reopened session isn't blank. Empty array on any failure (no history yet, +// runtime missing) — the chat still works, it just starts fresh. +func (a *App) AgentHistory(projectID string) string { + node, binJS, err := locateNamzu() + if err != nil { + return "[]" + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, node, binJS, "history", "--session", projectID) + cmd.Dir = a.namzuTurnCwd(projectID) + cmd.Env = os.Environ() + hideConsole(cmd) + out, err := cmd.Output() + if err != nil { + return "[]" + } + trimmed := strings.TrimSpace(string(out)) + if !strings.HasPrefix(trimmed, "[") { + return "[]" // guard against any stray non-JSON line + } + return trimmed +} + +// NamzuSkills returns the skills namzu discovers ({name, description, source}[] +// JSON) — the Namzu tab's chip rail. Shells `namzu skills-json`. Empty array on +// any failure so the rail degrades to "no skills". +func (a *App) NamzuSkills() string { + node, binJS, err := locateNamzu() + if err != nil { + return "[]" + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, node, binJS, "skills-json") + cmd.Dir = namzuScratchDir() + cmd.Env = os.Environ() + hideConsole(cmd) + out, err := cmd.Output() + if err != nil { + return "[]" + } + trimmed := strings.TrimSpace(string(out)) + if !strings.HasPrefix(trimmed, "[") { + return "[]" + } + return trimmed +} + +// NamzuProviders returns the providers namzu can route to, each with its +// detected-credential state, default flag, and concrete model list +// ({provider,label,detected,default,models[]}[] JSON) — the Namzu tab's +// provider + per-provider model pickers. Shells `namzu providers-json`. Empty +// array on any failure so the pickers degrade to a free-text model field. +func (a *App) NamzuProviders() string { + node, binJS, err := locateNamzu() + if err != nil { + return "[]" + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, node, binJS, "providers-json") + cmd.Dir = namzuScratchDir() + cmd.Env = os.Environ() + hideConsole(cmd) + out, err := cmd.Output() + if err != nil { + return "[]" + } + trimmed := strings.TrimSpace(string(out)) + if !strings.HasPrefix(trimmed, "[") { + return "[]" + } + return trimmed +} + +// registerLocalRun announces an in-process desktop turn to the daemon's run +// registry so the Sessions surface (which reads /v1/runs) sees it. instance +// defaults to "namzu" — the runtime that actually executes a local turn. title +// is the operator's prompt, truncated for the row. Best-effort: a turn must run +// even when the daemon is momentarily unreachable, so failures are swallowed. +func (a *App) registerLocalRun(runID, sessionID, instance, title string) { + if strings.TrimSpace(instance) == "" { + instance = "namzu" + } + a.postDaemonJSON("/v1/runs/register", map[string]string{ + "run_id": runID, + "session_id": sessionID, + "agent_instance": instance, + "origin": "local", + "title": runTitle(title), + }) +} + +// updateLocalRunStatus flips a registered run to a new lifecycle state — the +// turn end-path sets "idle" so a finished turn doesn't read as perpetually live +// in Sessions. Best-effort, same rationale as registerLocalRun. +func (a *App) updateLocalRunStatus(runID, status string) { + a.postDaemonJSON("/v1/runs/"+runID+"/status", map[string]string{"status": status}) +} + +// postDaemonJSON POSTs a small JSON body to the local daemon over loopback. No +// bearer is attached: the machine is its own trust boundary (the daemon trusts +// loopback, or runs token-less when not LAN-exposed). Fire-and-forget — run +// registration is observability and must never block or fail a turn. +func (a *App) postDaemonJSON(path string, payload any) { + base, err := a.ensureDaemonBase() + if err != nil { + return + } + body, err := json.Marshal(payload) + if err != nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+path, bytes.NewReader(body)) + if err != nil { + return + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return + } + _ = resp.Body.Close() +} + +// runTitle trims an operator prompt to a compact single-line run title for the +// Sessions row. Mirrors the daemon's titleFromPrompt budget so local and +// peer-triggered rows read consistently. +func runTitle(s string) string { + s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " ")) + if len(s) > 80 { + s = strings.TrimSpace(s[:80]) + "…" + } + return s +} + +// locateNamzu resolves the node binary and the namzu CLI entry. A shipped +// .app carries both in Contents/Resources/namzu ({node, namzu.mjs}) so a local +// turn runs with zero external dependencies; a dev build falls back to the +// submodule entry + system node. Returns a clear error when neither resolves. +// +// NOTE: this is intentionally a SIBLING copy of internal/agents/namzu_locate.go +// (LocateNamzu). cmd/clawtool-installer is a separate Go module (see go.mod) and +// Go's internal/ visibility rule blocks it from importing the parent's +// internal/ packages — so the desktop and the supervisor's namzu transport each +// carry the same resolver. Keep the two in sync (same env override, bundle +// path, dev walk-up). +func locateNamzu() (node string, binJS string, err error) { + binJS = locateNamzuBin() + if binJS == "" { + return "", "", errors.New("namzu runtime not found — the desktop build is missing its bundled namzu CLI") + } + node = locateNode(filepath.Dir(binJS)) + if node == "" { + return "", "", errors.New("node runtime not found — reinstall the app (it ships its own Node) or install Node.js") + } + return node, binJS, nil +} + +// locateNamzuBin finds the namzu CLI entry. Order: explicit override, the +// shipped self-contained bundle (Resources/namzu/namzu.mjs), then the dev +// submodule's built entry (packages/cli/dist/bin.js). +func locateNamzuBin() string { + if env := strings.TrimSpace(os.Getenv("CLAWTOOL_NAMZU_BIN")); env != "" && fileExists(env) { + return env + } + var cands []string + if exe, err := os.Executable(); err == nil { + exeDir := filepath.Dir(exe) + cands = append(cands, filepath.Join(exeDir, "..", "Resources", "namzu", "namzu.mjs")) + cands = append(cands, filepath.Join(exeDir, "..", "..", "Resources", "namzu", "namzu.mjs")) + cands = append(cands, filepath.Join(exeDir, "namzu", "namzu.mjs")) + } + if wd, err := os.Getwd(); err == nil { + for dir := wd; ; { + cands = append(cands, filepath.Join(dir, "desktop", "submodules", "namzu", "packages", "cli", "dist", "bin.js")) + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + } + for _, cand := range cands { + if fileExists(cand) { + return cand + } + } + return "" +} + +// locateNode resolves the node executable. A node bundled in bundleDir (the +// shipped .app's Resources/namzu) wins so the app is self-contained; then PATH; +// then the common install dirs a Finder-launched .app might still miss. +func locateNode(bundleDir string) string { + if bundleDir != "" { + bundled := filepath.Join(bundleDir, "node") + if fileExists(bundled) { + return bundled + } + } + if p, err := exec.LookPath("node"); err == nil { + return p + } + for _, cand := range []string{ + "/opt/homebrew/bin/node", + "/usr/local/bin/node", + "/usr/bin/node", + } { + if fileExists(cand) { + return cand + } + } + return "" +} + +func fileExists(p string) bool { + info, err := os.Stat(p) + return err == nil && !info.IsDir() +} diff --git a/cmd/clawtool-installer/path_test.go b/cmd/clawtool-installer/path_test.go new file mode 100644 index 00000000..d9248e64 --- /dev/null +++ b/cmd/clawtool-installer/path_test.go @@ -0,0 +1,47 @@ +package main + +import ( + "os" + "path/filepath" + "runtime" + "slices" + "testing" +) + +func TestParsePathSentinel(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"clean", "__CLAWPATH__/opt/homebrew/bin:/usr/bin__END__", "/opt/homebrew/bin:/usr/bin"}, + {"with rc noise", "welcome banner\n__CLAWPATH__/a:/b__END__\n", "/a:/b"}, + {"empty value", "__CLAWPATH____END__", ""}, + {"no markers", "/just/a/path", ""}, + {"open only", "__CLAWPATH__/a:/b", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := parsePathSentinel(c.in); got != c.want { + t.Fatalf("parsePathSentinel(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} + +// ensureUserPath must never DROP a dir already on PATH, and must add the +// standard macOS/Homebrew bins so a stub-PATH GUI launch can still find +// agent CLIs. Skipped on Windows where it's a deliberate no-op. +func TestEnsureUserPathKeepsAndAugments(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("ensureUserPath is a no-op on Windows") + } + t.Setenv("PATH", "/usr/bin:/bin") + ensureUserPath() + got := filepath.SplitList(os.Getenv("PATH")) + for _, want := range []string{"/usr/bin", "/bin", "/opt/homebrew/bin"} { + if !slices.Contains(got, want) { + t.Fatalf("PATH %v missing %q", got, want) + } + } +} diff --git a/cmd/clawtool-installer/payload.go b/cmd/clawtool-installer/payload.go new file mode 100644 index 00000000..3b08e598 --- /dev/null +++ b/cmd/clawtool-installer/payload.go @@ -0,0 +1,29 @@ +package main + +import ( + "embed" + "io/fs" + + "github.com/cogitave/clawtool/cmd/clawtool-installer/brand" +) + +// payloadFS holds the binaries the setup build lays down. CI fills ./payload +// with Clawtool.exe + clawtool.exe + ClawtoolUpdate.exe before building +// ClawtoolSetup.exe; the plain app build leaves it empty (just .gitkeep). +// +//go:embed all:payload +var payloadFS embed.FS + +// payloadRoot is the embedded payload rooted at the dir. +func payloadRoot() (fs.FS, error) { return fs.Sub(payloadFS, "payload") } + +// payloadPresent reports whether the install payload is embedded — i.e. this +// is the ClawtoolSetup build (run it → self-install), not the plain app build. +func payloadPresent() bool { + root, err := payloadRoot() + if err != nil { + return false + } + _, err = fs.Stat(root, brand.AppExe()) + return err == nil +} diff --git a/cmd/clawtool-installer/payload/.gitkeep b/cmd/clawtool-installer/payload/.gitkeep new file mode 100644 index 00000000..64c87c04 --- /dev/null +++ b/cmd/clawtool-installer/payload/.gitkeep @@ -0,0 +1,2 @@ +# CI fills this dir with Clawtool.exe + clawtool.exe + ClawtoolUpdate.exe +# before the ClawtoolSetup build; empty in the plain app build. diff --git a/cmd/clawtool-installer/projects.go b/cmd/clawtool-installer/projects.go new file mode 100644 index 00000000..e1848d7a --- /dev/null +++ b/cmd/clawtool-installer/projects.go @@ -0,0 +1,225 @@ +// Projects ledger — each project is a working directory the agent operates +// against. The desktop's left sidebar lists them; selecting one anchors the +// conversation pane (Phase C) and namzu's per-project session tree +// (~/.namzu/projects//sessions/...). +// +// Persisted at ~/.config/clawtool/projects.json (alongside daemon.json, +// device-cert.pem, …). Bindings are intentionally thin: list/add/remove + +// touch-on-use. No git/cwd magic here — the UI passes a path the user picked. +package main + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" + wruntime "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// ProjectsPickFolder opens the native folder picker so the operator can +// pick a project's cwd visually instead of typing/pasting a path into a +// text field (which silently swallowed ~ and made the Add flow painful). +// Returns the chosen absolute path, or empty when the user cancels. +func (a *App) ProjectsPickFolder() string { + if a.ctx == nil { + return jsonErr("window not ready") + } + path, err := wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{ + Title: "Pick a project folder", + CanCreateDirectories: true, + }) + if err != nil { + return jsonErr(err.Error()) + } + b, _ := json.Marshal(map[string]any{"ok": true, "path": path}) + return string(b) +} + +type Project struct { + ID string `json:"id"` + Label string `json:"label"` + Cwd string `json:"cwd"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt time.Time `json:"last_used_at"` +} + +func projectsPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "", errors.New("can't resolve home directory") + } + return filepath.Join(home, ".config", "clawtool", "projects.json"), nil +} + +func loadProjects() ([]Project, error) { + p, err := projectsPath() + if err != nil { + return nil, err + } + b, err := os.ReadFile(p) + if err != nil { + if os.IsNotExist(err) { + return []Project{}, nil + } + return nil, err + } + if len(b) == 0 { + return []Project{}, nil + } + var out []Project + if err := json.Unmarshal(b, &out); err != nil { + return nil, err + } + return out, nil +} + +func saveProjects(ps []Project) error { + p, err := projectsPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + return err + } + body, err := json.MarshalIndent(ps, "", " ") + if err != nil { + return err + } + return os.WriteFile(p, append(body, '\n'), 0o600) +} + +// ProjectsList returns the registered projects, newest-used first. +func (a *App) ProjectsList() string { + ps, err := loadProjects() + if err != nil { + return jsonErr(err.Error()) + } + // Newest LastUsedAt first; insertion-stable sort is fine for the + // small N a user maintains. + for i := 1; i < len(ps); i++ { + for j := i; j > 0 && ps[j].LastUsedAt.After(ps[j-1].LastUsedAt); j-- { + ps[j-1], ps[j] = ps[j], ps[j-1] + } + } + b, _ := json.Marshal(ps) + return string(b) +} + +// ProjectsAdd registers a working directory as a project. If one already +// exists for that cwd, its LastUsedAt is refreshed and the existing record +// is returned — the UI never produces duplicates from a re-add. +func (a *App) ProjectsAdd(label, cwd string) string { + cwd = strings.TrimSpace(cwd) + if cwd == "" { + return jsonErr("cwd is required") + } + // Expand `~` to the home dir + resolve relatives so an operator who + // pastes `~/repos/foo` gets a working project instead of a literal + // path the agent can't cd into. + if strings.HasPrefix(cwd, "~") { + if home, err := os.UserHomeDir(); err == nil && home != "" { + cwd = filepath.Join(home, strings.TrimPrefix(cwd, "~")) + } + } + if abs, err := filepath.Abs(cwd); err == nil { + cwd = abs + } + // Verify the path actually exists + is a directory; a stale path makes + // the agent's first tool call fail in confusing ways. + if fi, err := os.Stat(cwd); err != nil { + return jsonErr("folder doesn't exist: " + cwd) + } else if !fi.IsDir() { + return jsonErr("not a folder: " + cwd) + } + label = strings.TrimSpace(label) + if label == "" { + label = filepath.Base(cwd) + } + ps, err := loadProjects() + if err != nil { + return jsonErr(err.Error()) + } + now := time.Now().UTC() + for i := range ps { + if ps[i].Cwd == cwd { + ps[i].LastUsedAt = now + if label != "" { + ps[i].Label = label + } + if err := saveProjects(ps); err != nil { + return jsonErr(err.Error()) + } + b, _ := json.Marshal(map[string]any{"ok": true, "project": ps[i]}) + return string(b) + } + } + p := Project{ + ID: uuid.NewString(), + Label: label, + Cwd: cwd, + CreatedAt: now, + LastUsedAt: now, + } + ps = append(ps, p) + if err := saveProjects(ps); err != nil { + return jsonErr(err.Error()) + } + b, _ := json.Marshal(map[string]any{"ok": true, "project": p}) + return string(b) +} + +// ProjectsRemove drops a project from the ledger by id. +func (a *App) ProjectsRemove(id string) string { + id = strings.TrimSpace(id) + if id == "" { + return jsonErr("project id is required") + } + ps, err := loadProjects() + if err != nil { + return jsonErr(err.Error()) + } + out := ps[:0] + found := false + for _, p := range ps { + if p.ID == id { + found = true + continue + } + out = append(out, p) + } + if !found { + return jsonErr("no project with that id") + } + if err := saveProjects(out); err != nil { + return jsonErr(err.Error()) + } + return `{"ok":true}` +} + +// ProjectsTouch marks a project as most-recently-used (called when the UI +// selects it). The ordering bubbles it to the top of ProjectsList. +func (a *App) ProjectsTouch(id string) string { + id = strings.TrimSpace(id) + if id == "" { + return jsonErr("project id is required") + } + ps, err := loadProjects() + if err != nil { + return jsonErr(err.Error()) + } + now := time.Now().UTC() + for i := range ps { + if ps[i].ID == id { + ps[i].LastUsedAt = now + if err := saveProjects(ps); err != nil { + return jsonErr(err.Error()) + } + return `{"ok":true}` + } + } + return jsonErr("no project with that id") +} diff --git a/cmd/clawtool-installer/sessions.go b/cmd/clawtool-installer/sessions.go new file mode 100644 index 00000000..d34e8e9d --- /dev/null +++ b/cmd/clawtool-installer/sessions.go @@ -0,0 +1,273 @@ +// Sessions — the first-class unit of conversation, grounded in the +// vendored ADE reference under vendor/: an AIConversation starts with +// zero required fields, and a folder (cwd) + environment are nullable +// metadata attached when the operator picks them. Sessions live here, not +// projects — projects are a separate optional catalog of "saved +// workspaces" that a session can attach to via the composer's workspace +// chip. +// +// Persisted at ~/.config/clawtool/sessions.json. Bindings are kept thin +// (list/create/touch/set-cwd/set-env/remove); the title is derived +// lazily from the first user message on the renderer side so the +// backend doesn't have to second-guess UX. +package main + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" +) + +// Session is one conversation. cwd + env are intentionally optional — +// the vendored reference's AIConversation::new() (conversation.rs:340) +// takes neither at creation; both can land via composer chips later. +type Session struct { + ID string `json:"id"` + Title string `json:"title,omitempty"` + Cwd string `json:"cwd,omitempty"` + Env string `json:"env,omitempty"` // "" = Local; otherwise a paired peer id + Agent string `json:"agent,omitempty"` // "" = supervisor default; otherwise a registered family (codex/claude/gemini/opencode/...) + CreatedAt time.Time `json:"created_at"` + LastModified time.Time `json:"last_modified"` +} + +func sessionsPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "", errors.New("can't resolve home directory") + } + return filepath.Join(home, ".config", "clawtool", "sessions.json"), nil +} + +func loadSessions() ([]Session, error) { + p, err := sessionsPath() + if err != nil { + return nil, err + } + b, err := os.ReadFile(p) + if err != nil { + if os.IsNotExist(err) { + return []Session{}, nil + } + return nil, err + } + if len(b) == 0 { + return []Session{}, nil + } + var out []Session + if err := json.Unmarshal(b, &out); err != nil { + return nil, err + } + return out, nil +} + +func saveSessions(ss []Session) error { + p, err := sessionsPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + return err + } + body, err := json.MarshalIndent(ss, "", " ") + if err != nil { + return err + } + return os.WriteFile(p, append(body, '\n'), 0o600) +} + +// SessionsList returns sessions ordered newest-modified first — Recents +// sort order from the vendored reference's conversation_list/view.rs. +func (a *App) SessionsList() string { + ss, err := loadSessions() + if err != nil { + return jsonErr(err.Error()) + } + for i := 1; i < len(ss); i++ { + for j := i; j > 0 && ss[j].LastModified.After(ss[j-1].LastModified); j-- { + ss[j-1], ss[j] = ss[j], ss[j-1] + } + } + b, _ := json.Marshal(ss) + return string(b) +} + +// SessionsCreate mints a fresh session with no fields required. The UI +// can call this on "+ New session" click and immediately drop the +// operator into an empty conversation — no form, no folder picker, no +// env step. cwd and env can land later via composer chips. +func (a *App) SessionsCreate() string { + now := time.Now().UTC() + s := Session{ + ID: uuid.NewString(), + CreatedAt: now, + LastModified: now, + } + ss, _ := loadSessions() + ss = append(ss, s) + if err := saveSessions(ss); err != nil { + return jsonErr(err.Error()) + } + b, _ := json.Marshal(map[string]any{"ok": true, "session": s}) + return string(b) +} + +// SessionsTouch bumps last_modified so the session bubbles to the top of +// Recents — called on selection and after each turn completes. +func (a *App) SessionsTouch(id string) string { + return a.sessionsUpdate(id, func(s *Session) { + s.LastModified = time.Now().UTC() + }) +} + +// SessionsSetTitle persists a title derived from the first user message +// (lazy titling — the vendored reference does this in history_model:60). +func (a *App) SessionsSetTitle(id, title string) string { + title = strings.TrimSpace(title) + if title == "" { + return jsonErr("title is required") + } + if len(title) > 80 { + title = title[:80] + } + return a.sessionsUpdate(id, func(s *Session) { + s.Title = title + s.LastModified = time.Now().UTC() + }) +} + +// SessionsSetCwd attaches (or clears with "") a working directory to a +// session. The composer's workspace chip drives this — vendored +// reference's composer chip semantics (agent_input_footer chips.rs). +func (a *App) SessionsSetCwd(id, cwd string) string { + cwd = strings.TrimSpace(cwd) + if cwd != "" { + if strings.HasPrefix(cwd, "~") { + if home, err := os.UserHomeDir(); err == nil && home != "" { + cwd = filepath.Join(home, strings.TrimPrefix(cwd, "~")) + } + } + if abs, err := filepath.Abs(cwd); err == nil { + cwd = abs + } + if fi, err := os.Stat(cwd); err != nil { + return jsonErr("folder doesn't exist: " + cwd) + } else if !fi.IsDir() { + return jsonErr("not a folder: " + cwd) + } + } + return a.sessionsUpdate(id, func(s *Session) { + s.Cwd = cwd + s.LastModified = time.Now().UTC() + }) +} + +// SessionsSetEnv attaches a runtime environment to a session. "" = Local +// (the default); a paired peer's id routes the conversation to that +// device's daemon (see AgentSend's peerID path). +func (a *App) SessionsSetEnv(id, env string) string { + return a.sessionsUpdate(id, func(s *Session) { + s.Env = strings.TrimSpace(env) + s.LastModified = time.Now().UTC() + }) +} + +// SessionsSetAgent pins the upstream family (codex / claude / gemini / +// opencode / ...) the supervisor dispatches this session's turns to. +// "" reverts to the per-device default (currently codex; claude loops +// when the operator's Claude Code session IS the only instance). +func (a *App) SessionsSetAgent(id, agent string) string { + return a.sessionsUpdate(id, func(s *Session) { + s.Agent = strings.TrimSpace(agent) + s.LastModified = time.Now().UTC() + }) +} + +// SessionsRemove deletes a session from the ledger. +func (a *App) SessionsRemove(id string) string { + id = strings.TrimSpace(id) + if id == "" { + return jsonErr("session id is required") + } + ss, _ := loadSessions() + out := ss[:0] + found := false + for _, s := range ss { + if s.ID == id { + found = true + continue + } + out = append(out, s) + } + if !found { + return jsonErr("no session with that id") + } + if err := saveSessions(out); err != nil { + return jsonErr(err.Error()) + } + return `{"ok":true}` +} + +func (a *App) sessionsUpdate(id string, fn func(*Session)) string { + id = strings.TrimSpace(id) + if id == "" { + return jsonErr("session id is required") + } + ss, err := loadSessions() + if err != nil { + return jsonErr(err.Error()) + } + for i := range ss { + if ss[i].ID == id { + fn(&ss[i]) + if err := saveSessions(ss); err != nil { + return jsonErr(err.Error()) + } + b, _ := json.Marshal(map[string]any{"ok": true, "session": ss[i]}) + return string(b) + } + } + return jsonErr("no session with that id") +} + +// sessionAgent resolves a session id to its pinned upstream family +// ("" when none / use device default). +func (a *App) sessionAgent(sessionID string) string { + if strings.TrimSpace(sessionID) == "" { + return "" + } + ss, err := loadSessions() + if err != nil { + return "" + } + for _, s := range ss { + if s.ID == sessionID { + return s.Agent + } + } + return "" +} + +// sessionCwd resolves a session id to its attached cwd ("" when none). +// Called by the agent dispatch so tool routing uses the session's chip +// state instead of an ambient project. +func (a *App) sessionCwd(sessionID string) string { + if strings.TrimSpace(sessionID) == "" { + return "" + } + ss, err := loadSessions() + if err != nil { + return "" + } + for _, s := range ss { + if s.ID == sessionID { + return s.Cwd + } + } + return "" +} diff --git a/cmd/clawtool-installer/wails.json b/cmd/clawtool-installer/wails.json index 0e3996bc..0e4670a2 100644 --- a/cmd/clawtool-installer/wails.json +++ b/cmd/clawtool-installer/wails.json @@ -14,6 +14,6 @@ "companyName": "Cogitave", "productName": "clawtool", "copyright": "Copyright © Cogitave", - "comments": "clawtool desktop app — vanilla (no-bundler) frontend; static files live under frontend/dist and are embedded directly." + "comments": "clawtool desktop app — React SPA (desktop/ pnpm monorepo, Vite-built) embedded from frontend/dist; CI builds the bundle and places it there before the Go build." } } diff --git a/cmd/clawtool/main.go b/cmd/clawtool/main.go index b8fe015a..07baab52 100755 --- a/cmd/clawtool/main.go +++ b/cmd/clawtool/main.go @@ -151,6 +151,12 @@ func parseServeFlags(argv []string) (server.HTTPOptions, bool, error) { } opts.Listen = argv[i+1] i++ + case "--peer-listen": + if i+1 >= len(argv) { + return opts, debug, fmt.Errorf("--peer-listen requires a value (e.g. '0.0.0.0:52829')") + } + opts.PeerListen = argv[i+1] + i++ case "--token-file": if i+1 >= len(argv) { return opts, debug, fmt.Errorf("--token-file requires a path") @@ -217,6 +223,14 @@ func parseServeFlags(argv []string) (server.HTTPOptions, bool, error) { "(this exposes clawtool's MCP surface to every device on your network)", opts.Listen) } + // The peer listener is LAN-facing by design (mTLS, fingerprint-gated), + // but binding beyond loopback still demands the explicit --allow-lan + // opt-in — same foot-gun guard as --listen. + if opts.PeerListen != "" && !opts.AllowLAN && !server.IsLoopbackAddress(opts.PeerListen) { + return opts, debug, fmt.Errorf( + "--peer-listen %s binds beyond loopback; pass --allow-lan to confirm", + opts.PeerListen) + } return opts, debug, nil } diff --git a/desktop/.gitignore b/desktop/.gitignore new file mode 100644 index 00000000..62ccde41 --- /dev/null +++ b/desktop/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.tsbuildinfo +.DS_Store diff --git a/desktop/apps/_gallery/index.html b/desktop/apps/_gallery/index.html new file mode 100644 index 00000000..51ef3824 --- /dev/null +++ b/desktop/apps/_gallery/index.html @@ -0,0 +1,11 @@ + + + + + design system gallery + + +
    + + + diff --git a/desktop/apps/_gallery/package.json b/desktop/apps/_gallery/package.json new file mode 100644 index 00000000..4c916fa8 --- /dev/null +++ b/desktop/apps/_gallery/package.json @@ -0,0 +1,22 @@ +{ + "name": "@clawtool/gallery", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@clawtool/design-system": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^6.0.7" + } +} diff --git a/desktop/apps/_gallery/src/main.tsx b/desktop/apps/_gallery/src/main.tsx new file mode 100644 index 00000000..0e2f2842 --- /dev/null +++ b/desktop/apps/_gallery/src/main.tsx @@ -0,0 +1,81 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "@clawtool/design-system/global.css"; +import { + AgentRow, + Badge, + Button, + EmptyRow, + List, + Metric, + MetricStrip, + SectionHeader, + StatusDot, + TitleBar, + Wordmark, +} from "@clawtool/design-system"; + +const noop = () => {}; + +function Gallery() { + return ( +
    + } + controls={{ onMinimize: noop, onToggleMaximize: noop, onClose: noop }} + /> +
    + {/* status hero */} +
    + +
    +

    Gateway active

    +
    + This device is reachable as a clawtool gateway. +
    +
    +
    +
    mac-studio.local
    +
    +
    + +
    + + + + + +
    + + Network →} /> + + + + + + +
    + + + + accent + online + busy + neutral +
    +
    + + No peers discovered yet — start clawtool on another machine. + +
    +
    +
    + ); +} + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/desktop/apps/_gallery/src/vite-env.d.ts b/desktop/apps/_gallery/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/desktop/apps/_gallery/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/desktop/apps/_gallery/tsconfig.json b/desktop/apps/_gallery/tsconfig.json new file mode 100644 index 00000000..fe31a3a7 --- /dev/null +++ b/desktop/apps/_gallery/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": [] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/desktop/apps/_gallery/vite.config.ts b/desktop/apps/_gallery/vite.config.ts new file mode 100644 index 00000000..081c8d9f --- /dev/null +++ b/desktop/apps/_gallery/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], +}); diff --git a/desktop/apps/app-ui/index.html b/desktop/apps/app-ui/index.html new file mode 100644 index 00000000..ce34b602 --- /dev/null +++ b/desktop/apps/app-ui/index.html @@ -0,0 +1,11 @@ + + + + + clawtool + + +
    + + + diff --git a/desktop/apps/app-ui/package.json b/desktop/apps/app-ui/package.json new file mode 100644 index 00000000..4a612eb6 --- /dev/null +++ b/desktop/apps/app-ui/package.json @@ -0,0 +1,26 @@ +{ + "name": "@clawtool/app-ui", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@clawtool/bridge": "workspace:*", + "@clawtool/design-system": "workspace:*", + "@clawtool/installer-ui": "workspace:*", + "@namzu/sdk": "workspace:*", + "lucide-react": "^1.16.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^6.0.7" + } +} diff --git a/desktop/apps/app-ui/src/App.module.css b/desktop/apps/app-ui/src/App.module.css new file mode 100644 index 00000000..c2e6bbc1 --- /dev/null +++ b/desktop/apps/app-ui/src/App.module.css @@ -0,0 +1,213 @@ +.shell { display: flex; flex-direction: column; height: 100vh; } +.body { display: flex; flex: 1; min-height: 0; } +.content { flex: 1; min-width: 0; overflow-x: hidden; overflow-y: auto; padding: 30px 36px; display: flex; flex-direction: column; position: relative; } +/* One consistent content column so every view uses the same full width. */ +.page { flex: 1; min-height: 0; width: 100%; display: flex; flex-direction: column; } + +.status { display: flex; align-items: center; gap: 13px; } +.status h1 { font-size: var(--size-2xl); font-weight: 600; letter-spacing: -0.02em; } +.status .sub { color: var(--text-secondary); font-size: 13px; margin-top: 2px; } +.status .metricsRight { margin-left: auto; } +.foot { + margin-top: auto; + padding-top: 18px; + color: var(--text-tertiary); + font-size: 12px; + display: flex; + align-items: center; + justify-content: space-between; +} +.vh { font-size: var(--size-xl); font-weight: 600; letter-spacing: -0.02em; } +.lead { color: var(--text-secondary); font-size: 13px; margin-top: 2px; } +/* Shared view header: title block on the left, action button on the far right. */ +.head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } +.head > .actionSlot { flex: none; margin-top: 2px; } +.locked { position: relative; } +.lockedInner { filter: blur(2.5px); opacity: 0.45; pointer-events: none; user-select: none; } +.lockNote { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + color: var(--text-secondary); + font-size: 12.5px; +} +.banner { + margin-bottom: 16px; + padding: 11px 14px; + border-radius: var(--radius-md); + background: var(--surface); + color: var(--text-secondary); + font-size: 13px; +} +.xdesc { color: var(--text-secondary); font-size: 12.5px; margin-top: 8px; line-height: 1.5; } +.xrow { display: flex; align-items: center; gap: 12px; margin-top: 14px; } +.switchRow { + display: flex; + align-items: flex-start; + gap: 14px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--hairline); +} +.switchRow .txt { flex: 1; } +.switchRow .st { font-weight: 600; font-size: 13.5px; } +.switchRow .sd { color: var(--text-secondary); font-size: 12.5px; margin-top: 3px; line-height: 1.45; } +.actions { display: flex; gap: 18px; margin-top: 18px; } +.input { + font: inherit; + font-size: 13px; + width: 100%; + padding: 9px 12px; + border: 1px solid var(--hairline); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); +} +.input:focus { outline: none; border-color: var(--accent); } +.err { color: var(--warning); font-size: 12.5px; margin-top: 8px; } +.linklike { background: none; border: 0; padding: 0; color: var(--accent); cursor: pointer; font: inherit; font-size: var(--size-md); } +.linklike:hover { text-decoration: underline; } +.device { border-bottom: 1px solid var(--hairline); } +.deviceRow { + display: flex; + align-items: center; + gap: 10px; + padding: 11px 6px; +} +.deviceRow:hover { background: var(--hover); } +.chevron { + flex: none; + display: grid; + place-items: center; + width: 24px; + height: 24px; + border: 0; + background: none; + border-radius: var(--radius-sm); + color: var(--text-tertiary); + cursor: pointer; + transition: color 0.14s, background 0.14s; +} +.chevron:hover { color: var(--text); background: var(--hover); } +.agentSub { + padding: 4px 6px 12px 40px; + display: flex; + flex-direction: column; + gap: 2px; +} +.subMuted { color: var(--text-secondary); font-size: 12.5px; padding: 8px 0; } +.subAgent { + display: flex; + align-items: center; + gap: 11px; + padding: 8px 10px; + border-radius: var(--radius-sm); +} +.subAgent:hover { background: var(--hover); } +.subAgentName { font-weight: 550; font-size: 13px; } +.subAgentMeta { + color: var(--text-secondary); + font-size: 12px; + margin-top: 1px; + font-family: var(--font-mono); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.deviceName { font-weight: 550; font-size: var(--size-md); } +.deviceMeta { color: var(--text-secondary); font-size: var(--size-sm); margin-top: 1px; font-family: var(--font-mono); } +.deviceRight { margin-left: auto; display: flex; align-items: center; gap: 12px; flex: none; } +.field { + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid var(--hairline); +} +.fieldHead { display: flex; align-items: center; justify-content: space-between; } +.deviceList { + margin: 0 -6px; + padding: 0 6px; +} +.deviceName { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.emptyFilter { color: var(--text-secondary); font-size: 13px; padding: 14px 6px; } +.cornerIcon { + position: absolute; + right: 28px; + bottom: 24px; + width: 34px; + height: 34px; + display: grid; + place-items: center; + border: 1px solid var(--hairline); + border-radius: var(--radius-md); + background: var(--surface); + color: var(--text-secondary); + cursor: pointer; + transition: color 0.14s, border-color 0.14s; +} +.cornerIcon:hover { color: var(--text); border-color: var(--hairline-strong); } +.doctor { + margin-top: 10px; + max-height: 280px; + overflow: auto; + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: var(--radius-sm); + padding: 12px 14px; + font: 12px/1.6 var(--font-mono); + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-word; +} +.changelog { margin-top: 26px; } +.clHead { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; } +.clTitle { font-weight: 600; font-size: var(--size-md); } +.clBody { + margin-top: 10px; + border-top: 1px solid var(--hairline); + padding-top: 12px; +} +.clH { + font-weight: 600; + font-size: 11.5px; + color: var(--text); + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 14px 0 5px; +} +.clH:first-child { margin-top: 0; } +.clBullet { + display: flex; + gap: 9px; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.55; + padding: 2px 0; +} +.clBullet::before { content: "•"; color: var(--accent); flex: none; } +.clPara { color: var(--text-secondary); font-size: 13px; line-height: 1.6; padding: 3px 0; } +.clEmpty { color: var(--text-secondary); font-size: 13px; margin-top: 12px; } +.updateBtn { position: relative; overflow: hidden; min-width: 200px; text-align: center; } +.updateBtn .btnLabel { position: relative; z-index: 1; } +.updateBtn .fill { + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--accent-ink) 22%, transparent), transparent); + background-size: 220% 100%; + animation: updShimmer 1.1s linear infinite; +} +@keyframes updShimmer { + from { background-position: 220% 0; } + to { background-position: -220% 0; } +} +.keybox { + font: 500 12.5px var(--font-mono); + color: var(--text); + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: var(--radius-sm); + padding: 7px 11px; + letter-spacing: 0.02em; +} diff --git a/desktop/apps/app-ui/src/App.tsx b/desktop/apps/app-ui/src/App.tsx new file mode 100644 index 00000000..8440b8b7 --- /dev/null +++ b/desktop/apps/app-ui/src/App.tsx @@ -0,0 +1,147 @@ +import { useEffect, useState } from "react"; +import { NavItem, Sidebar, StatusDot, TitleBar, Wordmark, type Platform } from "@clawtool/design-system"; +import { App as Backend, Win, environmentPlatform, type Brand } from "@clawtool/bridge"; +import { Bot, House, MessageSquarePlus, Network as NetIcon, RefreshCw, Settings as SettingsGlyph, Sparkles } from "lucide-react"; +import { Home } from "./views/Home"; +import { Agents } from "./views/Agents"; +import { Namzu } from "./views/Namzu"; +import { Network } from "./views/Network"; +import { Updates } from "./views/Updates"; +import { Settings } from "./views/Settings"; +import { Sessions } from "./views/Sessions"; +import { SessionView } from "./views/SessionView"; +import { PairPrompt } from "./components/PairPrompt"; +import { SessionRail } from "./components/SessionRail"; +import type { Session } from "@clawtool/bridge"; +import styles from "./App.module.css"; + +type View = "namzu" | "sessions" | "home" | "agents" | "network" | "updates" | "settings"; + +export function App() { + const [platform, setPlatform] = useState("windows"); + const [brand, setBrand] = useState({ name: "clawtool", cli: "clawtool", tagline: "", installDir: "", version: "" }); + // Sessions is the ADE landing — chat-first per the vendored reference; + // projects aren't a prerequisite. Click "+ New session" or a recent to + // drill into its conversation; other nav items are secondary. + const [view, setView] = useState("sessions"); + const [openSession, setOpenSession] = useState(null); + // First-message seed handed up from the Sessions landing composer when + // the operator types + sends from the empty state; the Conversation + // auto-sends it on mount so "type → send → see reply" is one gesture. + const [seedMessage, setSeedMessage] = useState(""); + // Bumped after sessionsCreate/touch/title so SessionRail re-pulls the list. + const [recentsKey, setRecentsKey] = useState(0); + + useEffect(() => { + environmentPlatform().then((p) => setPlatform(p === "web" ? "windows" : p)); + Backend.brand().then(setBrand); + Backend.ensureGateway(); + }, []); + + const controls = { + onMinimize: () => Win.minimise(), + onToggleMaximize: () => Win.toggleMaximise(), + onClose: () => Win.quit(), + }; + + const ic = { size: 16, strokeWidth: 1.8 }; + const hidden = { display: "none" } as const; + + return ( +
    + } controls={controls} /> +
    + setOpenSession(null)} + onOpen={(s) => { + setOpenSession(s); + setView("sessions"); + }} + /> + ) : undefined + } + footer={ + <> + + {brand.version ? `v${brand.version}` : ""} + + } + > + {/* Session-shell takeover: when a session is open the secondary + nav is hidden so the sidebar stays purely conversation- + focused (SessionRail at top). The whole sidebar fades on the + swap thanks to App.module.css's .sideFade transitions. */} + {openSession ? null : ( + <> + } label="Namzu" active={view === "namzu"} onClick={() => setView("namzu")} /> + } + label="Sessions" + active={view === "sessions"} + onClick={() => setView("sessions")} + /> + } label="Home" active={view === "home"} onClick={() => setView("home")} /> + } label="Agents" active={view === "agents"} onClick={() => setView("agents")} /> + } label="Network" active={view === "network"} onClick={() => setView("network")} /> + } label="Updates" active={view === "updates"} onClick={() => setView("updates")} /> + } label="Settings" active={view === "settings"} onClick={() => setView("settings")} /> + + )} + +
    + {/* Keep every view mounted (native-app keep-alive): toggle visibility + instead of unmounting, so state + scroll persist and switching + tabs never flashes an empty re-load. Views refresh in the + background only while active. */} +
    + { + setSeedMessage(seed ?? ""); + setOpenSession(s); + }} + /> +
    +
    + {openSession ? ( + setSeedMessage("")} + onBack={() => setOpenSession(null)} + onTitleUpdate={(title) => { + setOpenSession((s) => (s ? { ...s, title } : s)); + setRecentsKey((n) => n + 1); + }} + /> + ) : null} +
    +
    + +
    +
    + setView(v as View)} /> +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    + +
    + ); +} diff --git a/desktop/apps/app-ui/src/Init.module.css b/desktop/apps/app-ui/src/Init.module.css new file mode 100644 index 00000000..36e05e39 --- /dev/null +++ b/desktop/apps/app-ui/src/Init.module.css @@ -0,0 +1,6 @@ +.shell { display: flex; flex-direction: column; height: 100vh; } +.body { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 26px 30px 22px; } +.top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; } +.title { font-size: var(--size-xl); font-weight: 600; letter-spacing: -0.02em; } +.sub { color: var(--text-secondary); font-size: 13px; margin-top: 2px; } +.bar { margin: 14px 0; } diff --git a/desktop/apps/app-ui/src/InitSurface.tsx b/desktop/apps/app-ui/src/InitSurface.tsx new file mode 100644 index 00000000..8639593f --- /dev/null +++ b/desktop/apps/app-ui/src/InitSurface.tsx @@ -0,0 +1,72 @@ +import { useEffect, useRef, useState } from "react"; +import { Badge, LogFeed, ProgressBar, TitleBar, Wordmark, type LogLine, type Platform } from "@clawtool/design-system"; +import { App as Backend, Win, environmentPlatform, on, type Brand, type InstallDone, type InstallStep } from "@clawtool/bridge"; +import styles from "./Init.module.css"; + +function clock(): string { + const d = new Date(); + return [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":"); +} + +// First-launch onboarding: runs the one-time initialize flow (daemon, bridges, +// agent claim) with a branded, live progress view, then hands off to the app. +export function InitSurface({ onDone }: { onDone: () => void }) { + const [platform, setPlatform] = useState("windows"); + const [brand, setBrand] = useState({ name: "clawtool", cli: "clawtool", tagline: "", installDir: "", version: "" }); + const [lines, setLines] = useState([]); + const [pct, setPct] = useState(4); + const [ready, setReady] = useState(false); + const pctRef = useRef(4); + const doneRef = useRef(false); + + function bump(to: number) { + pctRef.current = Math.max(pctRef.current, Math.min(to, 100)); + setPct(pctRef.current); + } + + useEffect(() => { + environmentPlatform().then((p) => setPlatform(p === "web" ? "windows" : p)); + Backend.brand().then(setBrand); + let n = 0; + const offStep = on("install:step", (s) => { + if (doneRef.current || !s?.label) return; + const tone = s.level === "warn" ? "warn" : s.level === "fail" ? "warn" : "ok"; + setLines((prev) => [...prev, { time: clock(), label: s.label!, detail: s.message, tone }]); + n += 1; + bump(8 + Math.min(86, n * 11)); + }); + const offDone = on("install:done", (d) => { + doneRef.current = true; + bump(100); + if (d?.ok) { + setReady(true); + setTimeout(onDone, 1100); + } + }); + Backend.install(); + return () => { + offStep(); + offDone(); + }; + }, [onDone]); + + const controls = { onMinimize: () => Win.minimise(), onToggleMaximize: () => Win.toggleMaximise(), onClose: () => Win.quit() }; + + return ( +
    + +
    +
    + + first launch +
    +

    {ready ? `${brand.name} is ready` : `Setting up ${brand.name}`}

    +
    {ready ? "Your gateway is live on this device." : "Bringing your gateway online…"}
    +
    + +
    + +
    +
    + ); +} diff --git a/desktop/apps/app-ui/src/Root.tsx b/desktop/apps/app-ui/src/Root.tsx new file mode 100644 index 00000000..309eb011 --- /dev/null +++ b/desktop/apps/app-ui/src/Root.tsx @@ -0,0 +1,35 @@ +import { useEffect, useState } from "react"; +import { App as Backend } from "@clawtool/bridge"; +import { App as InstallerSurface } from "@clawtool/installer-ui"; +import { App } from "./App"; +import { InitSurface } from "./InitSurface"; + +type Route = "loading" | "setup" | "init" | "app"; + +// The single shipping SPA routes on the Go-decided mode: +// setup -> the installer flow (self-install) +// installer -> first-launch onboarding (one-time init) if not yet initialized +// app -> the main app +// (The physical 3-binary split reuses these same surfaces, one per binary.) +export function Root() { + const [route, setRoute] = useState("loading"); + + function enterApp() { + Backend.enterApp(); // grow the window from the fixed splash to the full app + setRoute("app"); + } + + useEffect(() => { + (async () => { + const mode = await Backend.mode(); + if (mode === "setup") return setRoute("setup"); + if (mode === "installer" && !(await Backend.isInitialized())) return setRoute("init"); + enterApp(); + })(); + }, []); + + if (route === "loading") return null; + if (route === "setup") return ; + if (route === "init") return ; + return ; +} diff --git a/desktop/apps/app-ui/src/components/Conversation.module.css b/desktop/apps/app-ui/src/components/Conversation.module.css new file mode 100644 index 00000000..fec9ac78 --- /dev/null +++ b/desktop/apps/app-ui/src/components/Conversation.module.css @@ -0,0 +1,229 @@ +.wrap { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + gap: 12px; +} +.transcript { + flex: 1; + min-height: 180px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 22px; + padding: 6px 2px 12px; +} +/* User row: a soft accent chip aligned at the start of the line — + matches the vendored reference's conversation pattern. */ +.userRow { display: flex; } +.userChip { + display: inline-block; + max-width: 80%; + padding: 8px 14px; + border-radius: var(--radius-md); + background: var(--accent-soft); + color: var(--text); + font-size: 13.5px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} +/* Assistant row: small status dot leading the body, no avatar circle. */ +.agentRow { + display: flex; + gap: 12px; + align-items: flex-start; +} +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--c-amber); + margin-top: 7px; + flex: none; +} +.dotLive { animation: pulseDot 1.4s ease-in-out infinite; } +@keyframes pulseDot { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(0.8); } +} +.agentBody { + flex: 1; + min-width: 0; + font-size: 13.5px; + line-height: 1.55; + color: var(--text); + white-space: pre-wrap; + word-break: break-word; +} +.agentErr .dot { background: var(--danger); } +.agentErr .agentBody { color: var(--text-secondary); } +.toolChips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } +.tool { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 9px; + border-radius: var(--radius-sm); + background: var(--surface-recessed); + border: 1px solid var(--hairline); + font-size: 11.5px; + color: var(--text-secondary); + font-family: var(--font-mono); +} +.caret { + display: inline-block; + width: 7px; + height: 14px; + background: var(--accent); + vertical-align: -2px; + margin-left: 1px; + animation: caret 0.9s steps(2) infinite; +} +@keyframes caret { + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } +} +.composer { + display: flex; + flex-direction: column; + gap: 8px; + border-top: 1px solid var(--hairline); + padding-top: 12px; +} +.chips { + display: flex; + gap: 6px; + align-items: center; + flex-wrap: wrap; +} +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border: 1px solid var(--hairline); + border-radius: var(--radius-pill); + background: var(--surface-recessed); + color: var(--text-secondary); + font-size: 11.5px; + cursor: pointer; + transition: border-color 0.12s, color 0.12s, background 0.12s; +} +.chip:hover { border-color: var(--hairline-strong); color: var(--text); background: var(--hover); } +.envChipWrap { position: relative; } +.envMenu { + position: absolute; + bottom: calc(100% + 4px); + left: 0; + min-width: 220px; + background: var(--surface); + border: 1px solid var(--hairline-strong); + border-radius: var(--radius-md); + box-shadow: var(--shadow-overlay); + padding: 4px; + display: flex; + flex-direction: column; + gap: 2px; + z-index: 20; +} +.envItem { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border: 0; + background: transparent; + color: var(--text); + font-size: 12.5px; + text-align: left; + cursor: pointer; + border-radius: var(--radius-sm); +} +.envItem:hover { background: var(--hover); } +.envEmpty { padding: 6px 10px; color: var(--text-tertiary); font-size: 12px; } +.editorWrap { + position: relative; + display: flex; + align-items: flex-end; +} +.editor { + flex: 1; + font: inherit; + font-size: 13.5px; + line-height: 1.5; + padding: 12px 44px 12px 14px; + border: 1px solid var(--hairline); + border-radius: var(--radius-md); + background: var(--surface); + color: var(--text); + resize: none; + min-height: 48px; + max-height: 200px; + width: 100%; +} +.editor:focus { outline: none; border-color: var(--accent); } +/* Send glyph lives INSIDE the editor — bottom-right corner — matching + the reference composer where there is no separate Send button. */ +.sendInline { + position: absolute; + right: 8px; + bottom: 8px; + width: 28px; + height: 28px; + display: grid; + place-items: center; + border: 0; + border-radius: var(--radius-sm); + background: var(--accent); + color: var(--accent-ink); + cursor: pointer; + transition: background 0.12s, transform 0.06s; +} +.sendInline:hover:not(:disabled) { background: var(--accent-hover); } +.sendInline:active:not(:disabled) { transform: translateY(1px); } +.sendInline:disabled { background: var(--surface-recessed); color: var(--text-tertiary); cursor: default; } +.footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 0 2px; + font-size: 11.5px; + color: var(--text-tertiary); +} +.footerLeft { min-height: 14px; } +.agentPickWrap { position: relative; } +.agentBadge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 9px; + border-radius: var(--radius-pill); + background: var(--surface-recessed); + border: 1px solid var(--hairline); + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 11.5px; + cursor: pointer; + transition: border-color 0.12s, color 0.12s; +} +.agentBadge:hover:not(:disabled) { border-color: var(--accent); color: var(--text); } +.agentMenu { + position: absolute; + right: 0; + bottom: calc(100% + 4px); + min-width: 200px; + background: var(--surface); + border: 1px solid var(--hairline-strong); + border-radius: var(--radius-md); + box-shadow: var(--shadow-overlay); + padding: 4px; + display: flex; + flex-direction: column; + gap: 2px; + z-index: 20; +} +.envItemActive { background: var(--accent-soft); color: var(--text); } +.empty { color: var(--text-secondary); font-size: 13px; padding: 14px 6px; } diff --git a/desktop/apps/app-ui/src/components/Conversation.tsx b/desktop/apps/app-ui/src/components/Conversation.tsx new file mode 100644 index 00000000..61e3b4eb --- /dev/null +++ b/desktop/apps/app-ui/src/components/Conversation.tsx @@ -0,0 +1,388 @@ +import { useEffect, useRef, useState, type KeyboardEvent } from "react"; +import { App, on, type Agent, type AgentEvent, type Peer, type Session } from "@clawtool/bridge"; +import { ArrowUp, ChevronDown, Folder, FolderX, Monitor } from "lucide-react"; +import styles from "./Conversation.module.css"; + +type Msg = + | { kind: "user"; text: string } + | { kind: "assistant"; text: string; tools: string[]; streaming: boolean; error?: boolean }; + +// Per-session conversation. cwd + env live on the session itself and +// show up as clickable chips above the editor — Folder picker drops a +// cwd onto the session; the env chip swaps Local for any paired peer. +export function Conversation({ + session, + initialMessage, + onSeedConsumed, + onTitleUpdate, +}: { + session: Session; + initialMessage?: string; + onSeedConsumed?: () => void; + onTitleUpdate?: (title: string) => void; +}) { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [sending, setSending] = useState(false); + const [peers, setPeers] = useState([]); + const [agents, setAgents] = useState([]); + const [cwd, setCwd] = useState(session.cwd || ""); + const [env, setEnv] = useState(session.env || ""); + // Agent is the registered INSTANCE id (e.g. "codex", "opencode", + // "gemini-work") — instances carry their own names; a family can have + // several. Empty until the first callable instance loads, so the + // default is whatever the daemon actually reports, never hardcoded. + const [agent, setAgent] = useState(session.agent || ""); + const [envOpen, setEnvOpen] = useState(false); + const [agentOpen, setAgentOpen] = useState(false); + const turnRef = useRef(""); + const seedConsumedRef = useRef(""); + const scrollRef = useRef(null); + + // Keep local state in sync if the session swaps out from under us. + // Critically, drop any in-flight turn id + sending flag too — otherwise + // deltas from the PREVIOUS session's still-running turn keep matching + // turnRef and paint into this session's transcript. On the way out we + // also cancel that turn on the backend so a cross-device stream doesn't + // keep a goroutine + connection alive after we've stopped listening. + useEffect(() => { + let alive = true; + setMessages([]); + setCwd(session.cwd || ""); + setEnv(session.env || ""); + setAgent(session.agent || ""); + turnRef.current = ""; + setSending(false); + // Hydrate the persisted transcript so a reopened session isn't blank. + // Guarded on `alive` + the seed: if a seed is auto-sending (new session + // from the landing) or the user switched away, don't clobber the live + // messages with a stale load. + (async () => { + const past = await App.agentHistory(session.id); + if (!alive || seedConsumedRef.current || !Array.isArray(past) || past.length === 0) return; + setMessages( + past.map((m) => + m.role === "user" + ? { kind: "user", text: m.content } + : { kind: "assistant", text: m.content, tools: [], streaming: false }, + ), + ); + })(); + return () => { + alive = false; + if (turnRef.current) App.agentCancel(turnRef.current); + }; + }, [session.id]); + + // Once callable agents load, adopt the first one as the default when the + // session hasn't pinned a specific instance — so the badge shows a real, + // dispatchable instance instead of a guess. Prefer a non-claude family + // since the local claude-code instance loops on self-dispatch. + useEffect(() => { + if (agent) return; + if (agents.length === 0) return; + const pick = agents.find((a) => a.family !== "claude") ?? agents[0]; + setAgent(pick.instance); + // Persist immediately so the backend resolves this session's agent + // from disk on the very first send — no extra /v1/agents round-trip, + // and the dispatched instance always matches the badge. + App.sessionsSetAgent(session.id, pick.instance); + }, [agents, agent, session.id]); + + // Auto-send the seed message the Sessions landing handed up so the + // operator's first prompt isn't stranded in the composer when the + // workspace opens. Consumed on mount so re-renders don't re-fire. + useEffect(() => { + const seed = initialMessage?.trim(); + if (!seed) return; + // Consume each unique seed exactly once — keyed on the message itself + // so a stale seed left in parent state can't re-fire into a different + // session when it opens. + if (seedConsumedRef.current === seed) return; + seedConsumedRef.current = seed; + sendText(seed); + onSeedConsumed?.(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session.id, initialMessage]); + + useEffect(() => { + let alive = true; + async function snapshot() { + const snap = await App.networkSnapshot(); + if (!alive) return; + if (snap.ok === true) { + setPeers(snap.peers?.peers ?? []); + // Only callable agents are dispatchable; hide bridge-missing / + // not-installed families so the picker doesn't lie. NEVER + // hardcode a list — the daemon's /v1/agents is the source of + // truth; an empty result means "no callable agents here right + // now," which the picker surfaces as such. + const all = snap.agents?.agents ?? []; + setAgents(all.filter((a) => a.callable)); + } + } + // Ensure the daemon is live ONCE on mount, then just poll snapshots. + // Calling ensureGateway every tick re-spawned `daemon start` on an + // already-healthy daemon every 5s across every open pane. + (async () => { + await App.ensureGateway(); + if (alive) await snapshot(); + })(); + const t = setInterval(snapshot, 5000); + return () => { + alive = false; + clearInterval(t); + }; + }, []); + + useEffect(() => { + const off = on("agent:event", (ev) => { + if (!turnRef.current || ev.turn_id !== turnRef.current) return; + if (ev.kind === "delta") { + setMessages((m) => { + const last = m[m.length - 1]; + if (!last || last.kind !== "assistant") return m; + const next = m.slice(0, -1); + next.push({ ...last, text: last.text + (ev.text ?? "") }); + return next; + }); + } else if (ev.kind === "tool-start" && ev.tool_name) { + setMessages((m) => { + const last = m[m.length - 1]; + if (!last || last.kind !== "assistant") return m; + const next = m.slice(0, -1); + next.push({ ...last, tools: [...last.tools, ev.tool_name as string] }); + return next; + }); + } else if (ev.kind === "done" || ev.kind === "error") { + setMessages((m) => { + const last = m[m.length - 1]; + if (!last || last.kind !== "assistant") return m; + const next = m.slice(0, -1); + if (ev.kind === "error") { + // Map the supervisor's "would loop" message into something + // operator-friendly; keep raw text from anything else. + let friendly = ev.error || "Couldn't reach that agent."; + if (/would loop/i.test(friendly)) { + friendly = "Can't dispatch to this device's own Claude Code session (would loop). Pick a different family in the agent badge."; + } + next.push({ ...last, streaming: false, text: friendly, error: true }); + } else { + next.push({ ...last, streaming: false }); + } + return next; + }); + setSending(false); + turnRef.current = ""; + } + }); + // Re-bind per session so the listener's cleanup runs on every switch + // and a stale subscription can't paint another session's deltas here. + return () => off(); + }, [session.id]); + + useEffect(() => { + const el = scrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [messages]); + + async function send() { + await sendText(input); + } + + async function sendText(raw: string) { + const text = raw.trim(); + if (!text || sending) return; + setSending(true); + setInput(""); + setMessages((m) => [ + ...m, + { kind: "user", text }, + { kind: "assistant", text: "", tools: [], streaming: true }, + ]); + // Lazy-derive the title from the first user message — the vendored + // reference's history_model:60 pattern. Only when not already set. + if (!session.title && onTitleUpdate) { + const t = text.length > 60 ? text.slice(0, 60) + "…" : text; + App.sessionsSetTitle(session.id, t); + onTitleUpdate(t); + } + const r = (await App.agentSend(session.id, text, env)) as Record; + if (r.ok && typeof r.turn_id === "string") { + turnRef.current = r.turn_id; + } else { + setMessages((m) => { + const last = m[m.length - 1]; + if (!last || last.kind !== "assistant") return m; + const next = m.slice(0, -1); + next.push({ + ...last, + streaming: false, + text: typeof r.error === "string" ? `[error: ${r.error}]` : "[error sending message]", + }); + return next; + }); + setSending(false); + } + } + + async function pickFolder() { + const r = (await App.projectsPickFolder()) as Record; + if (r.ok && typeof r.path === "string" && r.path) { + await App.sessionsSetCwd(session.id, r.path); + setCwd(r.path); + } + } + + async function clearFolder() { + await App.sessionsSetCwd(session.id, ""); + setCwd(""); + } + + async function chooseEnv(next: string) { + await App.sessionsSetEnv(session.id, next); + setEnv(next); + setEnvOpen(false); + } + + async function chooseAgent(next: string) { + await App.sessionsSetAgent(session.id, next); + setAgent(next); + setAgentOpen(false); + } + + function onKey(e: KeyboardEvent) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + send(); + } + } + + const envLabel = + env === "" ? "Local" : peers.find((p) => p.peer_id === env)?.display_name || `peer ${env.slice(0, 8)}`; + const cwdLabel = cwd ? cwd.split("/").pop() || cwd : "No folder"; + const agentLabel = agent || "no agent"; + + return ( +
    +
    + {messages.length === 0 ? null : ( + messages.map((m, i) => + m.kind === "user" ? ( +
    + {m.text} +
    + ) : ( +
    + +
    + {m.tools.length > 0 ? ( +
    + {m.tools.map((t, j) => ( + ⚙ {t} + ))} +
    + ) : null} + {m.streaming && !m.text ? ( + + ) : ( + <> + {m.text} + {m.streaming ? : null} + + )} +
    +
    + ), + ) + )} +
    + +
    +
    +