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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions apps/desktop/src/components/runners/runner-card.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { Cookie, Ellipsis, HeartPulse, KeyRound, Monitor, MonitorSmartphone, Network, Pencil, RefreshCw, Sparkles, Trash2, TriangleAlert } from "lucide-react";
import { CircleArrowUp, CircleCheck, Cookie, Ellipsis, HeartPulse, KeyRound, Monitor, MonitorSmartphone, Network, Pencil, RefreshCw, Sparkles, Trash2, TriangleAlert } from "lucide-react";
import type { RemoteRunner } from "@godmode/shared";
import { Button } from "@/components/ui/button";
import {
Expand Down Expand Up @@ -31,6 +31,7 @@ import {
runnerVerdict,
runnerWorkLine,
} from "./runner-parts";
import { RunnerUpdateStrip, hasUpdateNews, shortBuild } from "./runner-update";
import type { RunnerActions } from "./use-runner-actions";

const FOOTER_BUTTON = "h-7 gap-1.5 px-2 text-[12.5px] font-normal text-muted-foreground hover:text-foreground [&_svg]:size-3.5";
Expand Down Expand Up @@ -112,7 +113,15 @@ export function RunnerCard({
<span aria-hidden className="opacity-40">
·
</span>
<span>Godmode {runner.version}</span>
<span title={runner.build ?? undefined}>
Godmode {runner.version}
{shortBuild(runner.build) && <span className="font-mono text-[11px] opacity-75"> {shortBuild(runner.build)}</span>}
</span>
{online && runner.update.state === "current" && !runner.update.tools.length && (
<span className="inline-flex items-center gap-1 text-success">
<CircleCheck className="size-3" aria-hidden /> Up to date
</span>
)}
</>
)}
</p>
Expand Down Expand Up @@ -178,6 +187,8 @@ export function RunnerCard({
</div>
)}

{hasUpdateNews(runner.update) && <RunnerUpdateStrip runner={runner} actions={actions} className="mx-4 mb-4" />}

<div className="mt-auto flex flex-wrap items-center gap-x-3 gap-y-2 border-t px-4 py-3">
<div className="min-w-0 flex-1 basis-48">
<p className="flex items-center gap-1.5 text-[13px] font-medium">
Expand Down Expand Up @@ -311,6 +322,25 @@ function RunnerMenu({ runner, actions, onRename, onAddresses, onRemove }: { runn
</span>
<Switch size="sm" checked={runner.syncBrowser} tabIndex={-1} aria-hidden className="pointer-events-none mt-0.5" />
</DropdownMenuItem>
<DropdownMenuItem
role="menuitemcheckbox"
aria-checked={runner.update.autoUpdate}
className="items-start"
onSelect={(e) => {
e.preventDefault();
actions.update.mutate({ runner, patch: { autoUpdate: !runner.update.autoUpdate } });
}}
>
<CircleArrowUp className="mt-0.5" />
<span className="min-w-0 flex-1">
<span className="block">Update automatically</span>
<span className="block text-[11px] leading-snug text-muted-foreground">Gets this computer's Godmode once its chats are done.</span>
</span>
<Switch size="sm" checked={runner.update.autoUpdate} tabIndex={-1} aria-hidden className="pointer-events-none mt-0.5" />
</DropdownMenuItem>
<DropdownMenuItem disabled={runner.state !== "online" || actions.isBusy("upgrade", runner.id)} onClick={() => actions.upgrade.mutate(runner)}>
<RefreshCw /> {runner.update.state === "available" || runner.update.tools.length ? "Update now" : "Check for updates"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={onRemove}>
<Trash2 /> Remove…
Expand Down
13 changes: 4 additions & 9 deletions apps/desktop/src/components/runners/runner-parts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ export function HealthPill({

/** One line that answers "can I give it work right now?". */
export function runnerVerdict(runner: RemoteRunner, summary: RunnerHealthSummary | null): { tone: CheckTone | "busy"; label: string } {
if (runner.update.state === "restarting") return { tone: "busy", label: "Restarting with the new Godmode…" };
switch (runner.state) {
case "connecting":
return { tone: "busy", label: "Looking for it on the network…" };
Expand Down Expand Up @@ -251,22 +252,16 @@ export interface RunnerProblem {
* runner itself is the checks' business.)
*/
export function runnerProblem(runner: RemoteRunner): RunnerProblem | null {
if (runner.state === "offline") {
if (runner.state === "offline" && runner.update.state !== "restarting") {
return {
kind: "connection",
tone: "fail",
title: `${runner.name} is offline`,
detail: `${sentence(runner.error ?? "Is it awake and on the same network?")} Godmode keeps trying — its chats continue when it's back.`,
};
}
if (runner.state === "update_required") {
return {
kind: "connection",
tone: "warn",
title: `${runner.name} runs another version of Godmode`,
detail: `${runner.error ? `${sentence(runner.error)} ` : ""}Update Godmode on both computers to the same version, then try again.`,
};
}
// The update strip says what to do (and has the command).
if (runner.state === "update_required") return null;
if (runner.state !== "online") return null;
if (runner.sync.state === "failed") {
return {
Expand Down
117 changes: 117 additions & 0 deletions apps/desktop/src/components/runners/runner-update.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { ArrowUpRight, CircleArrowUp, CircleX, Hourglass, RefreshCw, RotateCw, TriangleAlert } from "lucide-react";
import type { RemoteRunner, RunnerUpdate } from "@godmode/shared";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
import { CommandBlock, THIS_COMPUTER_INLINE } from "./runner-parts";
import type { RunnerActions } from "./use-runner-actions";

/** "6e83efe 2026-10-07" → "6e83efe"; "dev" stays. */
export function shortBuild(build: string | null | undefined): string | null {
const commit = build?.trim().split(/\s+/)[0]?.replace(/\+changes$/, "*");
return commit && commit !== "unknown" ? commit : null;
}

/** Whether the card shows the update strip: something to install, on its way, or in the way. */
export function hasUpdateNews(update: RunnerUpdate): boolean {
return update.state !== "current" || update.tools.length > 0;
}

const BUSY: ReadonlySet<RunnerUpdate["state"]> = new Set(["sending", "waiting", "installing", "restarting"]);

function versionLine(runner: RemoteRunner): string {
const { target } = runner.update;
const from = [runner.version, shortBuild(runner.build)].filter(Boolean).join(" · ");
const to = [target.version, shortBuild(target.build)].filter(Boolean).join(" · ");
return from && from !== to ? `Godmode ${from} → ${to}` : `Godmode ${to}`;
}

function toolsLine(update: RunnerUpdate): string | null {
if (!update.tools.length) return null;
return update.tools.map((t) => (t.current && t.latest ? `${t.name} ${t.current} → ${t.latest}` : t.name)).join(", ");
}

function copy(runner: RemoteRunner): { title: string; detail: string | null } {
const u = runner.update;
const tools = toolsLine(u);
switch (u.state) {
case "available":
return {
title: "Update available",
detail: [`${versionLine(runner)}, the one ${THIS_COMPUTER_INLINE} runs`, tools].filter(Boolean).join(". Also: "),
};
case "current":
return { title: u.tools.length === 1 ? "Tool update" : "Tool updates", detail: tools };
case "sending":
return {
title: u.source === "bridge" ? "Fetching the new Godmode" : "Sending the new Godmode",
detail: u.detail ?? (u.source === "controller" ? `From ${THIS_COMPUTER_INLINE}, end-to-end encrypted` : null),
};
case "waiting":
return { title: "Ready to install", detail: u.detail ?? "It installs once its chats are done." };
case "installing":
return { title: "Installing", detail: u.detail ?? "Checking the new Godmode and putting it in place" };
case "restarting":
return { title: "Restarting", detail: "With the new Godmode. It's back in a moment." };
case "failed":
return { title: "The update didn't work", detail: u.detail };
case "unsupported":
return { title: "Update it on the runner", detail: u.detail };
}
}

/**
* The runner's update, right in its card: what it would get, the progress while it gets there, and what to do when it
* can't be updated from here.
*/
export function RunnerUpdateStrip({ runner, actions, className }: { runner: RemoteRunner; actions: RunnerActions; className?: string }) {
const u = runner.update;
const busy = BUSY.has(u.state);
const starting = actions.isBusy("upgrade", runner.id);
const online = runner.state === "online";
const { title, detail } = copy(runner);
const tone = u.state === "failed" ? "fail" : u.state === "unsupported" ? "warn" : "brand";
const Icon = u.state === "failed" ? CircleX : u.state === "unsupported" ? TriangleAlert : u.state === "waiting" ? Hourglass : u.state === "restarting" ? RotateCw : CircleArrowUp;
const percent = u.progress === null ? null : Math.round(u.progress * 100);
const actionable = u.state === "available" || u.state === "failed" || (u.state === "current" && u.tools.length > 0);

return (
<section
aria-label={`Update of ${runner.name}`}
aria-live="polite"
className={cn(
"relative overflow-hidden rounded-lg border px-3 py-2.5 text-xs",
tone === "fail" ? "border-destructive/25 bg-destructive/[0.05]" : tone === "warn" ? "border-warning/30 bg-warning/[0.07]" : "border-brand/20 bg-brand-soft/60",
className,
)}
>
<div className="flex flex-wrap items-start gap-x-2.5 gap-y-2">
<span className={cn("mt-px shrink-0", tone === "fail" ? "text-destructive" : tone === "warn" ? "text-warning" : "text-brand-strong")}>
{busy && u.state !== "waiting" ? <Spinner className="size-3.5" aria-hidden /> : <Icon className="size-3.5" aria-hidden />}
</span>
<div className="min-w-0 flex-1 basis-48 space-y-0.5">
<p className={cn("flex items-baseline gap-1.5 font-medium", tone === "fail" ? "text-destructive" : "text-foreground", busy && "text-shimmer")}>
{title}
{percent !== null && u.state === "sending" && <span className="font-normal text-muted-foreground tabular-nums">{percent}%</span>}
</p>
{detail && <p className="leading-relaxed break-words text-muted-foreground">{detail}</p>}
</div>
{actionable && (
<Button size="xs" variant={u.state === "failed" ? "outline" : "default"} disabled={!online || starting} onClick={() => actions.upgrade.mutate(runner)}>
{starting ? <Spinner className="size-3" /> : u.state === "failed" ? <RefreshCw /> : <ArrowUpRight />}
{u.state === "failed" ? "Try again" : "Update"}
</Button>
)}
</div>
{u.command && <CommandBlock text={u.command} title={`Run this on ${runner.hostname || runner.name}`} className="mt-2.5" />}
{u.state === "sending" && (
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-brand/10" aria-hidden>
<div
className={cn("h-full bg-brand transition-[width] duration-300 ease-out", percent === null && "w-1/3 animate-indeterminate")}
style={percent === null ? undefined : { width: `${percent}%` }}
/>
</div>
)}
</section>
);
}
26 changes: 24 additions & 2 deletions apps/desktop/src/components/runners/use-runner-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { api } from "@/lib/api";
import { qk } from "@/lib/queryKeys";
import { upsertRunner } from "@/lib/realtime";

type RunnerAction = "connect" | "sync" | "health" | "autofix" | "update" | "remove";
type RunnerAction = "connect" | "sync" | "health" | "autofix" | "update" | "upgrade" | "remove";

/**
* Reconnect, copy the setup, check health, fix with Claude, change and remove runners — with toasts, and the answer
Expand Down Expand Up @@ -83,16 +83,38 @@ export function useRunnerActions() {
onSettled: (_res, _e, { runner }) => mark("autofix", runner.id, false),
});

/** Its Godmode to this computer's, its tools to their newest. The card follows the progress (runner.updated). */
const upgrade = useMutation({
mutationFn: (runner: RemoteRunner) => api.runners.upgrade(runner.id),
onMutate: (runner) => mark("upgrade", runner.id, true),
onSuccess: (next) => {
void upsertRunner(qc, next);
const u = next.update;
if (u.state === "failed") toast.error(`Couldn't update ${next.name}`, { description: u.detail ?? undefined });
else if (u.state === "waiting") toast.success(`${next.name} installs the update next`, { description: u.detail ?? undefined });
else if (u.state === "current" && !u.tools.length) toast.success(`${next.name} is up to date`, { description: `Godmode ${u.target.version} and all of its tools.` });
},
onError: (e, runner) => toastApiError(e, `Couldn't update ${runner.name}`, qc),
onSettled: (_res, _e, runner) => mark("upgrade", runner.id, false),
});

const update = useMutation({
mutationFn: ({ runner, patch }: { runner: RemoteRunner; patch: RunnerPatch }) => api.runners.update(runner.id, patch),
onMutate: ({ runner, patch }) => {
mark("update", runner.id, true);
// The switch in the menu answers at once.
if (patch.syncBrowser !== undefined) void upsertRunner(qc, { ...runner, syncBrowser: patch.syncBrowser });
if (patch.autoUpdate !== undefined) void upsertRunner(qc, { ...runner, update: { ...runner.update, autoUpdate: patch.autoUpdate } });
},
onSuccess: (next, { patch }) => {
void upsertRunner(qc, next);
if (patch.name !== undefined) toast.success(`Renamed to ${next.name}`);
else if (patch.autoUpdate !== undefined)
toast.success(next.update.autoUpdate ? "Updates install by themselves" : "Updates wait for you", {
description: next.update.autoUpdate
? `${next.name} gets this computer's Godmode whenever it runs another one, once its chats are done.`
: `${next.name} keeps its Godmode until you click Update.`,
});
else if (patch.syncBrowser !== undefined)
toast.success(next.syncBrowser ? "Browser sessions are copied along" : "Browser sessions stay on this computer", {
description: next.syncBrowser
Expand Down Expand Up @@ -121,7 +143,7 @@ export function useRunnerActions() {
onSettled: (_res, _e, runner) => mark("remove", runner.id, false),
});

return { connect, sync, checkHealth, autofix, update, remove, isBusy };
return { connect, sync, checkHealth, autofix, upgrade, update, remove, isBusy };
}

export type RunnerActions = ReturnType<typeof useRunnerActions>;
5 changes: 5 additions & 0 deletions apps/desktop/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@
--animate-dream-dot: dream-dot 2s ease-out infinite;
--animate-march: march 0.6s linear infinite;
--animate-ekg: ekg 2.8s cubic-bezier(0.45, 0, 0.3, 1) infinite;
--animate-indeterminate: indeterminate 1.4s ease-in-out infinite;

@keyframes shimmer {
0% { background-position: 200% 0; }
Expand Down Expand Up @@ -226,6 +227,10 @@
0% { stroke-dashoffset: 28; }
70%, 100% { stroke-dashoffset: -100; }
}
@keyframes indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(300%); }
}
}

@layer base {
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ import type {
RunnerHealth,
RunnerPairingOffer,
RunnerPatch,
RunnerUpdateInput,
SendMessageInput,
SendMessageOutcome,
SendMessageResult,
Expand Down Expand Up @@ -676,6 +677,8 @@ export const api = {
/** `refresh` runs the checks on the runner again instead of answering from its last result. */
health: (id: string, refresh = false) => get<RunnerHealth>(`/api/runners/${id}/health`, { refresh: refresh ? 1 : undefined }),
fix: (id: string, checkId: string) => post<RunnerFixResult>(`/api/runners/${id}/health/fix`, { id: checkId }),
/** Its Godmode to this computer's and its tools to their newest; the progress follows as `runner.updated`. */
upgrade: (id: string, input: RunnerUpdateInput = {}) => post<RemoteRunner>(`/api/runners/${id}/update`, input),
/** Starts a chat here whose agent diagnoses and repairs the runner. */
autofix: (id: string, input: RunnerAutofixInput = {}) => post<StartChatResult>(`/api/runners/${id}/autofix`, input),
/** Any API call answered by the runner instead of this computer: `path` is the route without `/api`, e.g. "/computer/sources". */
Expand Down
15 changes: 15 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1474,6 +1474,21 @@ A **runner** is a headless Godmode core on another computer (macOS for now) that
`godmode runner status` asks the serving runner (`/api/runner/health` on loopback) for its own view. **Fix with
Claude** starts a chat on this computer with `conversations.runner_tools_id`: its agent gets `runner_health`,
`runner_fix` and `runner_exec` (a login shell on the runner, in its data dir) and the health report and log tail.
* **Updates** (`runnerUpdates.ts` on the controller, `selfUpdate.ts` on the runner). A runner gets the Godmode this
computer runs: the same program when both are the same platform and architecture (compared by SHA-256, `RunnerInfo.digest`;
sent over the link in 4 MiB pieces, `PUT /api/link/update/chunk`), else the release of the same version from
usegodmode.com with this computer's licence key (`/api/link/update/download`, compared by version). Never an older
one. The runner stages it next to its executable (`.godmode-update`), checks size and SHA-256, starts it once with
`version`, waits until no run works, renames it over the executable and restarts: under launchd it exits with 75 so
KeepAlive starts the new program, otherwise a shell waits for the old process and starts the new one with the same
arguments. Meta `update.pending` lets the restarted runner report a build that didn't take. Runners from before the
updater (no `build` in RunnerInfo) fetch this computer's program once from a short-lived token URL through
`/api/link/exec` (the bridge, macOS service only). Each runner has `auto_update` (default on): it is updated when it
connects, every 15 minutes while connected and when the switch goes on; a failed automatic attempt isn't repeated for
the same build for 6 hours. `POST /api/runners/:id/update` (the Update button) does the same at once and then installs
the runner's tool updates (`/api/link/updates`, `/api/link/updates/install`; runners also keep their tools current
through their own background upkeep). What it can't do from here (a runner from source, another link protocol, no
licence key) comes back as `unsupported` with a command to run there.
* **Removing a runner** tells it to forget this computer, fails its working runs and turns its chats into chats of this
computer. Backups carry no runners, controllers or `link.*` meta, and restored chats lose their runner.

Expand Down
Loading
Loading