Gravity
{phase.kind === "probing" ? (
-
Looking for a daemon on this Mac…
+
Looking for a daemon on this computer…
) : null}
{phase.kind === "connecting" ? (
Connecting to the daemon…
) : null}
{phase.kind === "installing" ? (
<>
-
Installing the daemon on this Mac…
+
Installing the daemon on this computer…
- It is installed as a launchd agent in ~/Library/LaunchAgents and keeps running in the
- background after you quit Gravity.
+ The daemon starts when you sign in and keeps running in the background after you close
+ Gravity.
{phase.waited > 0 ? (
@@ -238,7 +238,7 @@ function ManualConnectForm(props: ManualConnectFormProps): ReactElement {
}}
>
- Point Gravity at a daemon running elsewhere — a Mac mini or server on your tailnet. A device
+ Point Gravity at a daemon running on another computer or server on your tailnet. A device
token is only needed when the daemon is on another machine.
{
void revealProject(project.dir_name);
},
diff --git a/apps/desktop/src/components/tasks/TaskRow.tsx b/apps/desktop/src/components/tasks/TaskRow.tsx
new file mode 100644
index 0000000..bcf0274
--- /dev/null
+++ b/apps/desktop/src/components/tasks/TaskRow.tsx
@@ -0,0 +1,165 @@
+import { useLayoutEffect, useRef, useState } from "react";
+import type { ReactElement } from "react";
+import type { DaemonApi } from "../../protocol/api";
+import type { Routine } from "../../protocol/entities";
+import type { BotTask } from "../../protocol/tasks";
+import { errText, fmtTimestamp } from "../../util";
+import ChatMarkdown from "../chat/ChatMarkdown";
+
+const STATE_LABEL: Readonly
> = {
+ open: "open",
+ done: "done",
+ cancelled: "cancelled",
+ expired: "expired",
+};
+
+function counterpart(task: BotTask): string {
+ const machine = task.other.machine == null ? "" : ` @ ${task.other.machine}`;
+ return task.role === "assigned"
+ ? `From ${task.other.name}${machine}`
+ : `To ${task.other.name}${machine}`;
+}
+
+function when(task: BotTask): string {
+ if (task.state !== "open") {
+ return task.closed_at == null ? "" : fmtTimestamp(task.closed_at);
+ }
+ return task.deadline_at == null ? "" : `due ${fmtTimestamp(task.deadline_at)}`;
+}
+
+/**
+ * Text clamped to two lines, with "Show more" when the clamp hides some of it
+ * or `hasMore` says there is more elsewhere. Without `onMore` it expands in
+ * place.
+ */
+function Clamped({
+ text,
+ hasMore = false,
+ onMore,
+}: {
+ readonly text: string;
+ readonly hasMore?: boolean;
+ readonly onMore?: () => void;
+}): ReactElement {
+ const [open, setOpen] = useState(false);
+ const [cut, setCut] = useState(false);
+ const body = useRef(null);
+
+ // Whether two lines hide part of the text; measured, since it depends on width.
+ useLayoutEffect(() => {
+ const element = body.current;
+ if (element !== null && !open) {
+ setCut(element.scrollHeight > element.clientHeight + 1);
+ }
+ // A new text is a new length to measure; the effect reads the DOM.
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies
+ }, [text, open]);
+
+ return (
+ <>
+
+ {text}
+
+ {cut || hasMore || open ? (
+
+ ) : null}
+ >
+ );
+}
+
+interface TaskRowProps {
+ readonly task: BotTask;
+ readonly client: DaemonApi;
+ readonly botId: string;
+}
+
+/**
+ * One task: who it is with, what was asked, and how it ended. The list holds
+ * previews; opening a row loads the whole request and result when the preview
+ * was cut, and shows them as the markdown bots write.
+ */
+export function TaskRow({ task, client, botId }: TaskRowProps): ReactElement {
+ const [open, setOpen] = useState(false);
+ const [full, setFull] = useState(null);
+ const [error, setError] = useState(null);
+ const shown = full ?? task;
+ const cutByDaemon = task.request_truncated === true || task.result_truncated === true;
+
+ const toggle = async (): Promise => {
+ setOpen((current) => !current);
+ if (open || !cutByDaemon || full !== null) {
+ return;
+ }
+ try {
+ const reply = await client.request(
+ { type: "get_task", bot_id: botId, task_id: task.id },
+ "task",
+ );
+ setFull(reply.task);
+ } catch (failure) {
+ setError(errText(failure));
+ }
+ };
+
+ return (
+
+
+ {counterpart(task)}
+ {task.state === "open" ? null : (
+ {STATE_LABEL[task.state]}
+ )}
+ {when(task)}
+
+ {open ? (
+ <>
+
+ {shown.request}
+
+ {shown.result == null ? null : (
+
+ {shown.result}
+
+ )}
+ {error === null ? null : {error}
}
+
+ >
+ ) : (
+ void toggle()}
+ />
+ )}
+
+ );
+}
+
+/** A routine's next scheduled run. */
+export function UpcomingRow({ routine }: { readonly routine: Routine }): ReactElement {
+ return (
+
+
+ Routine {routine.name}
+
+ {routine.next_run_at == null ? "" : fmtTimestamp(routine.next_run_at)}
+
+
+
+
+ );
+}
diff --git a/apps/desktop/src/components/tasks/TasksPanel.stories.tsx b/apps/desktop/src/components/tasks/TasksPanel.stories.tsx
new file mode 100644
index 0000000..b84c884
--- /dev/null
+++ b/apps/desktop/src/components/tasks/TasksPanel.stories.tsx
@@ -0,0 +1,12 @@
+import type { Story } from "@ladle/react";
+import * as fx from "../../test/fixtures";
+import { tasksDaemon } from "../../test/taskFixtures";
+import TasksPanel from "./TasksPanel";
+
+const client = tasksDaemon();
+
+export const Overview: Story = () => (
+
+
+
+);
diff --git a/apps/desktop/src/components/tasks/TasksPanel.test.tsx b/apps/desktop/src/components/tasks/TasksPanel.test.tsx
new file mode 100644
index 0000000..7732836
--- /dev/null
+++ b/apps/desktop/src/components/tasks/TasksPanel.test.tsx
@@ -0,0 +1,106 @@
+import { act, render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+import * as fx from "../../test/fixtures";
+import { sampleTasks, task, tasksDaemon } from "../../test/taskFixtures";
+import TasksPanel, { sections } from "./TasksPanel";
+
+describe("TasksPanel", () => {
+ it("splits tasks into now, waiting, upcoming and done", async () => {
+ render();
+ const now = await screen.findByRole("region", { name: "Now" });
+ expect(within(now).getByText("From lead")).toBeInTheDocument();
+ const waiting = screen.getByRole("region", { name: "Waiting on others" });
+ expect(within(waiting).getByText("To windev @ win-pc")).toBeInTheDocument();
+ const upcoming = screen.getByRole("region", { name: "Upcoming" });
+ expect(within(upcoming).getByText("Routine nightly-report")).toBeInTheDocument();
+ expect(within(upcoming).queryByText("Routine paused")).not.toBeInTheDocument();
+ const done = screen.getByRole("region", { name: "Done" });
+ expect(within(done).getByText("done")).toBeInTheDocument();
+ expect(within(done).getByText("expired")).toBeInTheDocument();
+ });
+
+ it("shows a finished task's result when opened", async () => {
+ render();
+ const row = await screen.findByText("Review the installer changes.");
+ expect(screen.queryByText("Reviewed — two nits, both fixed.")).not.toBeInTheDocument();
+ const more = within(row.closest("li") ?? document.body).getByRole("button", {
+ name: "Show more",
+ });
+ await userEvent.click(more);
+ expect(screen.getByText("Reviewed — two nits, both fixed.")).toBeInTheDocument();
+ await userEvent.click(screen.getByRole("button", { name: "Show less" }));
+ expect(screen.queryByText("Reviewed — two nits, both fixed.")).not.toBeInTheDocument();
+ });
+
+ it("loads the whole task when its preview was cut, and shows it as markdown", async () => {
+ const preview = task({
+ id: "t9",
+ state: "done",
+ request: "Batch 15 review returned three findings…",
+ request_truncated: true,
+ result: "I ruled on all six items…",
+ result_truncated: true,
+ });
+ const client = tasksDaemon([preview]).onRequest("get_task", () => ({
+ type: "task",
+ req_id: "9",
+ task: {
+ ...preview,
+ request: "Batch 15 review returned three findings, all fixed.",
+ request_truncated: false,
+ result: "I ruled on **all six** items, guard order included.",
+ result_truncated: false,
+ },
+ }));
+ render();
+ await userEvent.click(await screen.findByRole("button", { name: "Show more" }));
+ expect(await screen.findByText("all six")).toBeInTheDocument();
+ expect(screen.getByText(/guard order included/)).toBeInTheDocument();
+ expect(
+ screen.getByText("Batch 15 review returned three findings, all fixed."),
+ ).toBeInTheDocument();
+ expect(client.requests.at(-1)?.body).toEqual({ type: "get_task", bot_id: "b1", task_id: "t9" });
+ });
+
+ it("refreshes after bus traffic and says when there is nothing", async () => {
+ const client = tasksDaemon([]).onRequest("list_routines", () => ({
+ type: "routines",
+ req_id: "2",
+ routines: [],
+ }));
+ render();
+ expect(await screen.findByText(/No tasks yet/)).toBeInTheDocument();
+
+ client.onRequest("list_tasks", () => ({
+ type: "tasks",
+ req_id: "3",
+ bot_id: "b1",
+ tasks: [task()],
+ }));
+ act(() => {
+ client.emit("message_new", { type: "message_new", message: fx.message() });
+ });
+ await waitFor(
+ () => {
+ expect(screen.getByRole("region", { name: "Now" })).toBeInTheDocument();
+ },
+ { timeout: 2000 },
+ );
+ });
+
+ it("reports a daemon that cannot list tasks", async () => {
+ const client = tasksDaemon().onRequest("list_tasks", () => {
+ throw new Error("unknown type: list_tasks");
+ });
+ render();
+ expect(await screen.findByText("unknown type: list_tasks")).toBeInTheDocument();
+ });
+
+ it("groups by state and role", () => {
+ const grouped = sections(sampleTasks);
+ expect(grouped.now.map((t) => t.id)).toEqual(["t1"]);
+ expect(grouped.waiting.map((t) => t.id)).toEqual(["t2"]);
+ expect(grouped.done.map((t) => t.id)).toEqual(["t3", "t4"]);
+ });
+});
diff --git a/apps/desktop/src/components/tasks/TasksPanel.tsx b/apps/desktop/src/components/tasks/TasksPanel.tsx
new file mode 100644
index 0000000..653ef3b
--- /dev/null
+++ b/apps/desktop/src/components/tasks/TasksPanel.tsx
@@ -0,0 +1,142 @@
+import { useCallback, useEffect, useState } from "react";
+import type { ReactElement } from "react";
+import { useLoadOnConnect } from "../../hooks/useLoadOnConnect";
+import type { DaemonApi } from "../../protocol/api";
+import type { Bot, Routine } from "../../protocol/entities";
+import type { BotTask } from "../../protocol/tasks";
+import { errText } from "../../util";
+import { TaskRow, UpcomingRow } from "./TaskRow";
+
+interface TasksPanelProps {
+ readonly client: DaemonApi;
+ readonly bot: Bot;
+ readonly connected: boolean;
+}
+
+/** Waits this long after a burst of bus traffic before refetching. */
+const REFRESH_DELAY_MS = 600;
+
+interface Sections {
+ readonly now: readonly BotTask[];
+ readonly waiting: readonly BotTask[];
+ readonly done: readonly BotTask[];
+}
+
+export function sections(tasks: readonly BotTask[]): Sections {
+ return {
+ now: tasks.filter((t) => t.state === "open" && t.role === "assigned"),
+ waiting: tasks.filter((t) => t.state === "open" && t.role === "delegated"),
+ done: tasks.filter((t) => t.state !== "open"),
+ };
+}
+
+/** Enabled routines with a next run, soonest first. */
+function upcoming(routines: readonly Routine[]): readonly Routine[] {
+ const next = routines.filter((r) => r.enabled && r.next_run_at != null);
+ // `sort` on a fresh copy, not `toSorted`: the build targets ES2021.
+ // oxlint-disable-next-line unicorn/no-array-sort
+ next.sort((a, b) => ((a.next_run_at ?? "") < (b.next_run_at ?? "") ? -1 : 1));
+ return next;
+}
+
+/** What the bot is doing, waiting on, has scheduled, and has finished. */
+export default function TasksPanel({ client, bot, connected }: TasksPanelProps): ReactElement {
+ const [tasks, setTasks] = useState([]);
+ const [routines, setRoutines] = useState([]);
+ const [error, setError] = useState(null);
+
+ const load = useCallback(async (): Promise => {
+ try {
+ const [listed, scheduled] = await Promise.all([
+ client.request({ type: "list_tasks", bot_id: bot.id }, "tasks"),
+ client.request({ type: "list_routines", bot_id: bot.id }, "routines"),
+ ]);
+ setTasks(listed.tasks);
+ setRoutines(scheduled.routines);
+ setError(null);
+ } catch (failure) {
+ setError(errText(failure));
+ }
+ }, [client, bot.id]);
+
+ useLoadOnConnect(connected, load);
+
+ // Tasks open and close through bus messages and routine runs; refetch after
+ // a burst rather than on every frame of it.
+ useEffect(() => {
+ let timer: number | undefined;
+ const soon = (): void => {
+ window.clearTimeout(timer);
+ timer = window.setTimeout(() => void load(), REFRESH_DELAY_MS);
+ };
+ const off = [client.on("message_new", soon), client.on("routine_run_update", soon)];
+ return () => {
+ window.clearTimeout(timer);
+ for (const unsubscribe of off) {
+ unsubscribe();
+ }
+ };
+ }, [client, load]);
+
+ const { now, waiting, done } = sections(tasks);
+ const next = upcoming(routines);
+ const empty = tasks.length === 0 && next.length === 0;
+ return (
+
+
+ Tasks
+
+
+ {error === null ? null :
{error}
}
+ {empty && error === null ? (
+
+ No tasks yet. Work {bot.name} is given or hands out shows up here.
+
+ ) : null}
+
+ {now.map((task) => (
+
+ ))}
+
+
+ {waiting.map((task) => (
+
+ ))}
+
+
+ {next.map((routine) => (
+
+ ))}
+
+
+ {done.map((task) => (
+
+ ))}
+
+
+ );
+}
+
+function Section({
+ title,
+ count,
+ children,
+}: {
+ readonly title: string;
+ readonly count: number;
+ readonly children: ReactElement[];
+}): ReactElement | null {
+ if (count === 0) {
+ return null;
+ }
+ return (
+
+
+ {title} {count}
+
+
+
+ );
+}
diff --git a/apps/desktop/src/dictation.test.ts b/apps/desktop/src/dictation.test.ts
new file mode 100644
index 0000000..483d5e1
--- /dev/null
+++ b/apps/desktop/src/dictation.test.ts
@@ -0,0 +1,51 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+const invoke = vi.fn<(command: string) => Promise>();
+let deliver: ((event: { payload: unknown }) => void) | null = null;
+const unlisten = vi.fn<() => void>();
+
+vi.mock("@tauri-apps/api/core", () => ({ invoke: (command: string) => invoke(command) }));
+vi.mock("@tauri-apps/api/event", () => ({
+ listen: async (_name: string, handler: (event: { payload: unknown }) => void) => {
+ deliver = handler;
+ return unlisten;
+ },
+}));
+
+const { nativeDictation } = await import("./dictation");
+
+describe("nativeDictation", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ invoke.mockReset();
+ deliver = null;
+ });
+
+ it("is unavailable outside the app shell", async () => {
+ expect(await nativeDictation.available()).toBe(false);
+ expect(invoke).not.toHaveBeenCalled();
+ });
+
+ it("asks the shell, and passes only well-formed events on", async () => {
+ vi.stubGlobal("__TAURI_INTERNALS__", {});
+ invoke.mockResolvedValue(true);
+ expect(await nativeDictation.available()).toBe(true);
+
+ const seen: unknown[] = [];
+ const stop = await nativeDictation.start((event) => seen.push(event));
+ expect(invoke).toHaveBeenCalledWith("start_dictation");
+ deliver?.({ payload: { kind: "partial", text: "hi" } });
+ deliver?.({ payload: { kind: "bogus" } });
+ deliver?.({ payload: "nonsense" });
+ expect(seen).toEqual([{ kind: "partial", text: "hi" }]);
+ stop();
+ expect(invoke).toHaveBeenCalledWith("stop_dictation");
+ });
+
+ it("stops listening when the shell refuses to start", async () => {
+ vi.stubGlobal("__TAURI_INTERNALS__", {});
+ invoke.mockRejectedValue("Speech recognition is off");
+ await expect(nativeDictation.start(() => {})).rejects.toThrow("Speech recognition is off");
+ expect(unlisten).toHaveBeenCalled();
+ });
+});
diff --git a/apps/desktop/src/dictation.ts b/apps/desktop/src/dictation.ts
new file mode 100644
index 0000000..9a4b9e8
--- /dev/null
+++ b/apps/desktop/src/dictation.ts
@@ -0,0 +1,54 @@
+import { invoke } from "@tauri-apps/api/core";
+import { listen } from "@tauri-apps/api/event";
+import { isTauri } from "./tauri";
+
+/** What the native recognizer reports while a dictation runs. */
+export type DictationEvent =
+ | { readonly kind: "partial"; readonly text: string }
+ | { readonly kind: "final"; readonly text: string }
+ | { readonly kind: "ended"; readonly error?: string };
+
+function isDictationEvent(value: unknown): value is DictationEvent {
+ if (typeof value !== "object" || value === null || !("kind" in value)) {
+ return false;
+ }
+ return value.kind === "partial" || value.kind === "final" || value.kind === "ended";
+}
+
+/** The app's on-device dictation: the shell's speech recognizer, or none in a browser. */
+export interface Dictation {
+ readonly available: () => Promise;
+ /** Starts listening; `onEvent` sees the transcript. Resolves to a stop function. */
+ readonly start: (onEvent: (event: DictationEvent) => void) => Promise<() => void>;
+}
+
+export const nativeDictation: Dictation = {
+ available: async () => {
+ if (!isTauri()) {
+ return false;
+ }
+ try {
+ return await invoke("dictation_available");
+ } catch {
+ return false;
+ }
+ },
+ start: async (onEvent) => {
+ const unlisten = await listen("dictation", (event) => {
+ if (isDictationEvent(event.payload)) {
+ onEvent(event.payload);
+ }
+ });
+ try {
+ await invoke("start_dictation");
+ } catch (failure) {
+ unlisten();
+ throw failure instanceof Error ? failure : new Error(String(failure));
+ }
+ return () => {
+ void invoke("stop_dictation");
+ // Late events (the final transcript) still arrive until the composer is done.
+ window.setTimeout(unlisten, 3000);
+ };
+ },
+};
diff --git a/apps/desktop/src/protocol/chat.ts b/apps/desktop/src/protocol/chat.ts
new file mode 100644
index 0000000..a3c81cb
--- /dev/null
+++ b/apps/desktop/src/protocol/chat.ts
@@ -0,0 +1,155 @@
+// The chat wire model: a bot's conversation as turns, read by the daemon from
+// the runtime's transcript. See docs/superpowers/specs/2026-09-16-chat-pane-design.md.
+
+type OwnerVia = "chat" | "terminal";
+
+export type Trigger =
+ | { readonly kind: "owner"; readonly text: string; readonly via: OwnerVia }
+ | {
+ readonly kind: "bus";
+ readonly from: string;
+ readonly msg_kind: string;
+ readonly num: number;
+ readonly text: string;
+ readonly task_id?: string;
+ }
+ | {
+ readonly kind: "routine";
+ readonly name: string;
+ readonly text: string;
+ readonly run_id?: string;
+ }
+ | { readonly kind: "ruling"; readonly decision_id: string; readonly text: string }
+ | { readonly kind: "resumed" }
+ | { readonly kind: "background"; readonly text: string };
+
+type StepStatus = "running" | "ok" | "error";
+
+export interface ImageRef {
+ readonly id: string;
+ readonly mime: string;
+}
+
+interface FileRef {
+ readonly path: string;
+ readonly name: string;
+}
+
+export interface Step {
+ readonly type: "step";
+ readonly id: string;
+ readonly tool: string;
+ readonly title: string;
+ readonly subtitle?: string;
+ readonly status: StepStatus;
+ readonly minor: boolean;
+ readonly added?: number;
+ readonly removed?: number;
+ readonly images?: readonly ImageRef[];
+}
+
+type AsideKind = "compacted" | "interrupted" | "incoming";
+
+export type ChatItem =
+ | { readonly type: "text"; readonly id: string; readonly markdown: string }
+ | Step
+ | {
+ readonly type: "sent";
+ readonly id: string;
+ readonly to: string;
+ readonly msg_kind: string;
+ readonly body: string;
+ }
+ | {
+ readonly type: "completed";
+ readonly id: string;
+ readonly task_id: string;
+ readonly result: string;
+ readonly artifacts: readonly FileRef[];
+ }
+ | {
+ readonly type: "decision";
+ readonly id: string;
+ readonly decision_id?: string;
+ readonly title: string;
+ }
+ | {
+ readonly type: "aside";
+ readonly id: string;
+ readonly kind: AsideKind;
+ readonly text: string;
+ };
+
+export interface TurnStats {
+ readonly commands: number;
+ readonly reads: number;
+ readonly edits: number;
+ readonly added: number;
+ readonly removed: number;
+ readonly sent: number;
+ readonly images: number;
+ readonly errors: number;
+}
+
+export interface ChatTurn {
+ readonly id: string;
+ readonly bot_id: string;
+ readonly started_at: string;
+ readonly ended_at?: string;
+ readonly duration_ms?: number;
+ readonly open: boolean;
+ readonly trigger: Trigger;
+ readonly items: readonly ChatItem[];
+ readonly stats: TurnStats;
+}
+
+/** The heavy half of a step, fetched when it is opened. */
+export interface StepDetail {
+ readonly input?: string;
+ readonly command?: string;
+ readonly output?: string;
+ readonly diff?: readonly string[];
+ readonly content?: string;
+}
+
+export interface Artifact {
+ readonly path: string;
+ /** Relative to the project's artifacts directory, `/`-separated. */
+ readonly rel: string;
+ readonly name: string;
+ readonly size: number;
+ readonly modified?: string | null;
+ readonly mime: string;
+ readonly title?: string;
+}
+
+/** A file's contents: `text` when it is text, otherwise `base64`. */
+export interface FileBody {
+ readonly path?: string;
+ readonly name: string;
+ readonly mime: string;
+ readonly size?: number;
+ readonly text?: string;
+ readonly base64?: string;
+ readonly truncated: boolean;
+}
+
+/** A bot's tool waiting on the owner's answer. */
+export interface PermissionRequest {
+ readonly id: string;
+ readonly bot_id: string;
+ readonly tool: string;
+ readonly summary: string;
+ readonly input: string;
+ readonly created_at: string;
+ readonly expires_at: string;
+}
+
+export type PermissionAnswer = "allow_once" | "allow_session" | "deny";
+
+export type PermissionOutcome =
+ | "allowed_once"
+ | "allowed_session"
+ | "denied"
+ | "expired"
+ | "abandoned";
diff --git a/apps/desktop/src/protocol/client.ts b/apps/desktop/src/protocol/client.ts
index 05772f5..0849de5 100644
--- a/apps/desktop/src/protocol/client.ts
+++ b/apps/desktop/src/protocol/client.ts
@@ -251,6 +251,9 @@ export class DaemonClient implements DaemonApi {
protocol_version: PROTOCOL_VERSION,
token,
client: CLIENT_ID,
+ // This app shows permission cards, so the daemon may hold a bot's
+ // prompt for it instead of leaving it to the terminal.
+ features: ["permission_cards"],
});
if (replyIs(reply, "error")) {
throw new DaemonError(reply.code, reply.message);
diff --git a/apps/desktop/src/protocol/entities.ts b/apps/desktop/src/protocol/entities.ts
index fce815e..134cec7 100644
--- a/apps/desktop/src/protocol/entities.ts
+++ b/apps/desktop/src/protocol/entities.ts
@@ -29,7 +29,11 @@ export type BotState =
| "stopping"
| "stopped";
+export type BotRuntime = "claude_code" | "codex_cli";
+
export interface Bot {
+ /** Absent on older daemons, which use Claude Code. */
+ readonly runtime?: BotRuntime;
readonly id: string;
readonly project_id: string;
readonly name: string;
@@ -48,9 +52,18 @@ export interface Bot {
readonly created_by_bot_id?: string | null;
/** Set when archived: the row and its history survive, the bot does not. */
readonly deleted_at?: string | null;
+ /** Set on a linked bot: one that runs on a paired peer daemon. */
+ readonly peer?: BotPeer | null;
readonly created_at: string;
}
+/** The peer daemon a linked bot runs on. */
+interface BotPeer {
+ readonly id: string;
+ readonly name: string;
+ readonly online: boolean;
+}
+
/**
* One line of "what happened last" for a bot, as the sidebar shows it.
*
diff --git a/apps/desktop/src/protocol/messages.ts b/apps/desktop/src/protocol/messages.ts
index ffc78b7..71b738a 100644
--- a/apps/desktop/src/protocol/messages.ts
+++ b/apps/desktop/src/protocol/messages.ts
@@ -1,6 +1,15 @@
// Server → client frames (protocol v2): replies carry `req_id`, pushes do not.
+import type {
+ Artifact,
+ ChatTurn,
+ FileBody,
+ PermissionOutcome,
+ PermissionRequest,
+ StepDetail,
+} from "./chat";
import type { Decision, DecisionComment, PendingCounts, PublishResult, Tag } from "./decisions";
+import type { BotTask } from "./tasks";
import type {
Bot,
BotActivity,
@@ -139,7 +148,43 @@ export type ServerReply =
readonly results: readonly PublishResult[];
})
| (ReplyBase & { readonly type: "tag"; readonly tag: Tag })
- | (ReplyBase & { readonly type: "tags"; readonly tags: readonly Tag[] });
+ | (ReplyBase & { readonly type: "tags"; readonly tags: readonly Tag[] })
+ | (ReplyBase & {
+ readonly type: "chat";
+ readonly bot_id: string;
+ /** Oldest first. */
+ readonly turns: readonly ChatTurn[];
+ readonly has_more: boolean;
+ })
+ | (ReplyBase & {
+ readonly type: "chat_step";
+ readonly bot_id: string;
+ readonly item_id: string;
+ readonly detail: StepDetail;
+ })
+ | (ReplyBase & { readonly type: "file"; readonly file: FileBody })
+ | (ReplyBase & {
+ readonly type: "permissions";
+ readonly permissions: readonly PermissionRequest[];
+ })
+ | (ReplyBase & { readonly type: "permission"; readonly permission: PermissionRequest })
+ | (ReplyBase & { readonly type: "task"; readonly task: BotTask })
+ | (ReplyBase & {
+ readonly type: "upload";
+ /** `path` is set once the last chunk is in. */
+ readonly upload: { readonly upload_id: string; readonly path?: string };
+ })
+ | (ReplyBase & {
+ readonly type: "tasks";
+ readonly bot_id: string;
+ /** Newest first. */
+ readonly tasks: readonly BotTask[];
+ })
+ | (ReplyBase & {
+ readonly type: "artifacts";
+ readonly project_id: string;
+ readonly artifacts: readonly Artifact[];
+ });
export type ServerReplyType = ServerReply["type"];
@@ -178,7 +223,20 @@ export type ServerPush =
}
| { readonly type: "decision_update"; readonly decision: Decision }
| { readonly type: "decision_deleted"; readonly decision_id: string }
- | { readonly type: "decision_comment_new"; readonly comment: DecisionComment };
+ | { readonly type: "decision_comment_new"; readonly comment: DecisionComment }
+ | { readonly type: "permission_request"; readonly request: PermissionRequest }
+ | {
+ readonly type: "permission_resolved";
+ readonly request_id: string;
+ readonly bot_id: string;
+ readonly outcome: PermissionOutcome;
+ }
+ | {
+ readonly type: "chat_turns";
+ readonly bot_id: string;
+ /** New or changed turns of a loaded chat, oldest first. */
+ readonly turns: readonly ChatTurn[];
+ };
export type ServerPushType = ServerPush["type"];
diff --git a/apps/desktop/src/protocol/push.ts b/apps/desktop/src/protocol/push.ts
index b892708..7789c20 100644
--- a/apps/desktop/src/protocol/push.ts
+++ b/apps/desktop/src/protocol/push.ts
@@ -20,6 +20,9 @@ export function emptyHandlers(): PushHandlerSets {
decision_update: new Set(),
decision_deleted: new Set(),
decision_comment_new: new Set(),
+ chat_turns: new Set(),
+ permission_request: new Set(),
+ permission_resolved: new Set(),
};
}
@@ -81,6 +84,15 @@ export function dispatchPush(handlers: PushHandlerSets, push: ServerPush): void
case "decision_comment_new":
emitPush(handlers, "decision_comment_new", push);
break;
+ case "chat_turns":
+ emitPush(handlers, "chat_turns", push);
+ break;
+ case "permission_request":
+ emitPush(handlers, "permission_request", push);
+ break;
+ case "permission_resolved":
+ emitPush(handlers, "permission_resolved", push);
+ break;
default:
push satisfies never;
}
diff --git a/apps/desktop/src/protocol/requests.ts b/apps/desktop/src/protocol/requests.ts
index 40db2a3..a0ab790 100644
--- a/apps/desktop/src/protocol/requests.ts
+++ b/apps/desktop/src/protocol/requests.ts
@@ -1,7 +1,14 @@
// Client → server frames (protocol v2).
+import type { PermissionAnswer } from "./chat";
import type { DecisionRequestBody } from "./decisionRequests";
-import type { DeliveryState, DeviceCapability, OverlapPolicy, RoutineTrigger } from "./entities";
+import type {
+ BotRuntime,
+ DeliveryState,
+ DeviceCapability,
+ OverlapPolicy,
+ RoutineTrigger,
+} from "./entities";
export type ClientRequestBody =
| DecisionRequestBody
@@ -10,6 +17,8 @@ export type ClientRequestBody =
readonly protocol_version: number;
readonly token: string;
readonly client: string;
+ /** Optional behaviours this client supports, e.g. `permission_cards`. */
+ readonly features?: readonly string[];
}
| { readonly type: "list_projects" }
| { readonly type: "create_project"; readonly name: string }
@@ -22,6 +31,7 @@ export type ClientRequestBody =
| { readonly type: "list_bots"; readonly project_id?: string }
| {
readonly type: "create_bot";
+ readonly runtime?: BotRuntime;
readonly project_id: string;
/** Omit for an automatically numbered "New Bot" placeholder. */
readonly name?: string;
@@ -37,6 +47,11 @@ export type ClientRequestBody =
readonly instructions?: string;
readonly avatar?: string;
}
+ | {
+ readonly type: "set_bot_runtime";
+ readonly bot_id: string;
+ readonly runtime: BotRuntime;
+ }
| {
readonly type: "delete_bot";
readonly bot_id: string;
@@ -71,7 +86,8 @@ export type ClientRequestBody =
| {
readonly type: "list_messages";
readonly conversation_id: string;
- readonly before_id?: string;
+ /** Messages numbered below this one. */
+ readonly before_num?: number;
readonly limit?: number;
}
| { readonly type: "list_bot_activity"; readonly project_id?: string }
@@ -129,7 +145,44 @@ export type ClientRequestBody =
readonly name: string;
readonly capabilities: readonly DeviceCapability[];
}
- | { readonly type: "revoke_device"; readonly device_id: string };
+ | { readonly type: "revoke_device"; readonly device_id: string }
+ | {
+ readonly type: "list_chat";
+ readonly bot_id: string;
+ /** A turn id: the page ends just before it. */
+ readonly before?: string;
+ readonly limit?: number;
+ }
+ | { readonly type: "get_chat_step"; readonly bot_id: string; readonly item_id: string }
+ | { readonly type: "get_chat_image"; readonly bot_id: string; readonly image_id: string }
+ | { readonly type: "list_artifacts"; readonly project_id: string }
+ | {
+ readonly type: "read_file";
+ readonly path: string;
+ /** The bot's directory and its project's artifacts. */
+ readonly bot_id?: string;
+ /** The project's artifacts only. */
+ readonly project_id?: string;
+ }
+ | { readonly type: "list_permissions"; readonly bot_id?: string }
+ | { readonly type: "list_tasks"; readonly bot_id: string; readonly limit?: number }
+ | { readonly type: "get_task"; readonly bot_id: string; readonly task_id: string }
+ | {
+ readonly type: "write_artifact";
+ readonly project_id: string;
+ readonly name: string;
+ /** One chunk of the file. */
+ readonly base64: string;
+ /** From the first chunk's reply; absent on the first chunk. */
+ readonly upload_id?: string;
+ readonly last: boolean;
+ }
+ | {
+ readonly type: "answer_permission";
+ readonly request_id: string;
+ readonly decision: PermissionAnswer;
+ readonly reason?: string;
+ };
/**
* Requests eligible for the request/response helper: everything but the
diff --git a/apps/desktop/src/protocol/tasks.ts b/apps/desktop/src/protocol/tasks.ts
new file mode 100644
index 0000000..3734f90
--- /dev/null
+++ b/apps/desktop/src/protocol/tasks.ts
@@ -0,0 +1,25 @@
+// A bot's tasks as the Tasks panel lists them (`list_tasks`).
+
+type TaskState = "open" | "done" | "cancelled" | "expired";
+
+export interface BotTask {
+ readonly id: string;
+ readonly state: TaskState;
+ /** `assigned`: this bot was asked; `delegated`: it asked someone else. */
+ readonly role: "assigned" | "delegated";
+ /** The other end: who asked, or who was asked. */
+ readonly other: {
+ readonly id?: string | null;
+ readonly name: string;
+ /** Set when the other bot runs on a peer machine. */
+ readonly machine?: string | null;
+ };
+ readonly request: string;
+ /** Set when `request` is a preview; `get_task` has all of it. */
+ readonly request_truncated?: boolean;
+ readonly result?: string | null;
+ readonly result_truncated?: boolean;
+ readonly created_at: string;
+ readonly deadline_at?: string | null;
+ readonly closed_at?: string | null;
+}
diff --git a/apps/desktop/src/protocol/wire.ts b/apps/desktop/src/protocol/wire.ts
index b025975..d4c9bfb 100644
--- a/apps/desktop/src/protocol/wire.ts
+++ b/apps/desktop/src/protocol/wire.ts
@@ -29,6 +29,9 @@ const PUSH_TYPES: Readonly> = {
decision_update: true,
decision_deleted: true,
decision_comment_new: true,
+ chat_turns: true,
+ permission_request: true,
+ permission_resolved: true,
};
const REPLY_TYPES: ReadonlySet = new Set(
@@ -64,6 +67,15 @@ const REPLY_TYPES: ReadonlySet = new Set(
publish_result: true,
tag: true,
tags: true,
+ chat: true,
+ chat_step: true,
+ file: true,
+ artifacts: true,
+ permissions: true,
+ permission: true,
+ tasks: true,
+ upload: true,
+ task: true,
} satisfies Record),
);
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css
index d1ac6fb..38cf9e1 100644
--- a/apps/desktop/src/styles.css
+++ b/apps/desktop/src/styles.css
@@ -14,6 +14,13 @@
@import "./styles/control-tags.css";
@import "./styles/bot-identity.css";
@import "./styles/message.css";
+@import "./styles/code.css";
+@import "./styles/chat.css";
+@import "./styles/chat-steps.css";
+@import "./styles/lightbox.css";
+@import "./styles/files.css";
+@import "./styles/permissions.css";
+@import "./styles/tasks.css";
@import "./styles/overlays.css";
@import "./styles/settings.css";
@import "./styles/devices.css";
diff --git a/apps/desktop/src/styles/chat-steps.css b/apps/desktop/src/styles/chat-steps.css
new file mode 100644
index 0000000..12dae84
--- /dev/null
+++ b/apps/desktop/src/styles/chat-steps.css
@@ -0,0 +1,144 @@
+/* Chat pane: folded tool steps, their detail, and image strips. */
+
+.chat-steps {
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--bg-raised);
+}
+
+.chat-steps-summary,
+.chat-step-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ font: inherit;
+ font-size: 12px;
+ color: var(--text-dim);
+ background: none;
+ border: 0;
+ padding: 6px 10px;
+ text-align: left;
+ cursor: pointer;
+}
+
+.chat-steps-summary:hover,
+.chat-step-row:hover {
+ color: var(--text);
+}
+
+.chat-chevron {
+ flex: none;
+ color: var(--text-faint);
+}
+
+.chat-step-list {
+ list-style: none;
+ margin: 0;
+ padding: 0 0 4px;
+ border-top: 1px solid var(--border);
+}
+
+.chat-step-minor .chat-step-row {
+ opacity: 0.6;
+}
+
+.chat-step-dot {
+ flex: none;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--green);
+}
+
+.chat-step-error .chat-step-dot {
+ background: var(--red);
+}
+
+.chat-step-running .chat-step-dot {
+ background: var(--blue);
+}
+
+.chat-step-title {
+ color: var(--text);
+ white-space: nowrap;
+}
+
+.chat-step-subtitle {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: var(--mono);
+ font-size: 11px;
+ color: var(--text-faint);
+}
+
+.chat-step-lines {
+ margin-left: auto;
+ font-family: var(--mono);
+ font-size: 11px;
+}
+
+.chat-step-running {
+ margin-left: auto;
+ color: var(--blue);
+}
+
+.chat-step-detail {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 4px 10px 10px 24px;
+ font-size: 12px;
+}
+
+.chat-step-output {
+ margin: 0;
+ max-height: 320px;
+ overflow: auto;
+ padding: 8px;
+ border-radius: 6px;
+ background: #101116;
+ font-family: var(--mono);
+ font-size: 11px;
+ white-space: pre-wrap;
+ color: var(--text-dim);
+}
+
+.chat-image-strip {
+ display: flex;
+ gap: 6px;
+ overflow-x: auto;
+ padding: 0 10px 10px;
+}
+
+/* A thumbnail keeps its image's shape: fixed height, width from the aspect. */
+.chat-thumb {
+ flex: none;
+ display: block;
+ padding: 0;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg);
+ cursor: zoom-in;
+ overflow: hidden;
+}
+
+.chat-thumb img {
+ display: block;
+ height: 120px;
+ width: auto;
+ max-width: 360px;
+ object-fit: contain;
+}
+
+.chat-thumb-empty {
+ height: 120px;
+ display: inline-flex;
+ align-items: center;
+ padding: 0 10px;
+ color: var(--text-faint);
+ font-size: 11px;
+ cursor: default;
+}
diff --git a/apps/desktop/src/styles/chat.css b/apps/desktop/src/styles/chat.css
new file mode 100644
index 0000000..1f5123a
--- /dev/null
+++ b/apps/desktop/src/styles/chat.css
@@ -0,0 +1,334 @@
+/* Chat pane: a bot's turns, folded steps, cards and the composer. */
+
+.chat-pane {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.chat-scroll {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding: 16px 20px 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+}
+
+.chat-older {
+ align-self: center;
+}
+
+.chat-empty {
+ margin: auto;
+ max-width: 360px;
+ text-align: center;
+ color: var(--text-dim);
+ line-height: 1.5;
+}
+
+.chat-turn,
+.chat-message {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ max-width: 860px;
+ width: 100%;
+ margin: 0 auto;
+}
+
+.chat-turn-head {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ font-size: 12px;
+}
+
+.chat-turn-head-own,
+.chat-message-own .chat-turn-head {
+ justify-content: flex-end;
+}
+
+.chat-turn-label {
+ color: var(--text-strong);
+ font-weight: 600;
+}
+
+.chat-turn-meta,
+.chat-turn-stats,
+.chat-pending-state {
+ color: var(--text-faint);
+ font-size: 11px;
+}
+
+.chat-turn-open .chat-turn-label::after {
+ content: "";
+ display: inline-block;
+ width: 6px;
+ height: 6px;
+ margin-left: 6px;
+ border-radius: 50%;
+ background: var(--blue);
+ vertical-align: middle;
+}
+
+.chat-bubble {
+ border-radius: 12px;
+ padding: 8px 12px;
+ line-height: 1.5;
+ max-width: 78%;
+ overflow-wrap: anywhere;
+}
+
+.chat-bubble-own {
+ align-self: flex-end;
+ background: color-mix(in srgb, var(--accent) 22%, var(--bg-raised));
+ color: var(--text-strong);
+ white-space: pre-wrap;
+}
+
+.chat-bubble-in {
+ align-self: flex-start;
+ background: var(--bg-raised);
+ border: 1px solid var(--border);
+}
+
+.chat-pending {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 4px;
+ max-width: 860px;
+ width: 100%;
+ margin: 0 auto;
+}
+
+.chat-text {
+ line-height: 1.55;
+ color: var(--text);
+}
+
+.chat-markdown p {
+ margin: 0 0 8px;
+}
+
+.chat-markdown p:last-child {
+ margin-bottom: 0;
+}
+
+.md-inline-code {
+ font-family: var(--mono);
+ font-size: 12px;
+ padding: 1px 4px;
+ border-radius: 4px;
+ background: var(--bg-active);
+}
+
+.chat-working {
+ color: var(--blue);
+ font-size: 12px;
+}
+
+.chat-note {
+ color: var(--text-dim);
+ font-size: 12px;
+ font-style: italic;
+}
+
+.chat-note-incoming {
+ font-style: normal;
+ border-left: 2px solid var(--amber);
+ padding-left: 8px;
+}
+
+.chat-error {
+ color: var(--red);
+ font-style: normal;
+}
+
+/* Bus traffic and results: labelled, never styled as the owner. */
+.chat-aside {
+ border-left: 2px solid var(--border);
+ padding-left: 10px;
+}
+
+.chat-aside-label,
+.chat-card-label {
+ display: block;
+ color: var(--text-dim);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ margin-bottom: 2px;
+}
+
+.chat-aside-body {
+ white-space: pre-wrap;
+ color: var(--text);
+}
+
+.chat-card {
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 10px 12px;
+ background: var(--bg-raised);
+}
+
+.chat-completed {
+ border-color: color-mix(in srgb, var(--green) 40%, var(--border));
+}
+
+.chat-decision {
+ border-color: color-mix(in srgb, var(--amber) 40%, var(--border));
+}
+
+.chat-files {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 8px;
+}
+
+.chat-file-chip {
+ font: inherit;
+ font-size: 12px;
+ color: var(--accent);
+ background: var(--bg-active);
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ padding: 2px 10px;
+ cursor: pointer;
+}
+
+.chat-file-chip:hover {
+ border-color: var(--accent);
+}
+
+/* The composer. */
+.chat-composer {
+ display: flex;
+ align-items: flex-end;
+ gap: 8px;
+ padding: 10px 20px 14px;
+ border-top: 1px solid var(--border);
+}
+
+.chat-composer-field {
+ flex: 1;
+ min-height: 38px;
+ max-height: 200px;
+ resize: vertical;
+ font: inherit;
+ color: var(--text);
+ background: var(--bg-raised);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 9px 12px;
+ line-height: 1.4;
+}
+
+.chat-composer-field:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+.chat-composer-field:disabled {
+ opacity: 0.6;
+}
+
+/* Search, attachments and decisions in the chat. */
+.chat-search {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 20px;
+ border-bottom: 1px solid var(--border);
+}
+
+.chat-search-field,
+.chat-decision-other {
+ flex: 1;
+ font: inherit;
+ color: var(--text);
+ background: var(--bg-raised);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 5px 8px;
+}
+
+.chat-search-count,
+.chat-composer-hint {
+ font-size: 11px;
+ color: var(--text-faint);
+}
+
+.chat-composer-hint {
+ padding: 0 20px;
+}
+
+.chat-attachments {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ padding: 8px 20px 0;
+}
+
+.chat-file-chip-error {
+ color: var(--red);
+}
+
+.chat-file-chip-remove {
+ margin-left: 4px;
+ font: inherit;
+ color: var(--text-faint);
+ background: none;
+ border: 0;
+ cursor: pointer;
+}
+
+.chat-attach {
+ align-self: center;
+}
+
+.chat-decision-title {
+ color: var(--text-strong);
+}
+
+.chat-decision-ruling {
+ margin-top: 6px;
+ color: var(--green);
+}
+
+.chat-decision-options {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 8px;
+}
+
+/* Find in the chat: every match, and the one on screen. */
+::highlight(chat-search) {
+ background-color: color-mix(in srgb, var(--amber) 35%, transparent);
+}
+
+::highlight(chat-search-current) {
+ background-color: var(--amber);
+ color: #000;
+}
+
+.chat-mic {
+ align-self: center;
+}
+
+.chat-mic-on {
+ color: var(--red);
+ animation: chat-mic-pulse 1.4s ease-in-out infinite;
+}
+
+@keyframes chat-mic-pulse {
+ 50% {
+ opacity: 0.45;
+ }
+}
diff --git a/apps/desktop/src/styles/code.css b/apps/desktop/src/styles/code.css
new file mode 100644
index 0000000..3dbbf71
--- /dev/null
+++ b/apps/desktop/src/styles/code.css
@@ -0,0 +1,90 @@
+/* Code blocks, highlighting and diffs, shared by the chat and file previews. */
+
+.code-block {
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ overflow: hidden;
+ background: #101116;
+}
+
+.code-block-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 3px 8px 3px 10px;
+ border-bottom: 1px solid var(--border);
+ font-size: 11px;
+ color: var(--text-faint);
+}
+
+.code-block-copy {
+ font: inherit;
+ color: var(--text-dim);
+ background: none;
+ border: 0;
+ cursor: pointer;
+}
+
+.code-block-copy:hover {
+ color: var(--text);
+}
+
+.code-block-body {
+ margin: 0;
+ padding: 10px 12px;
+ overflow: auto;
+ max-height: 480px;
+ font-family: var(--mono);
+ font-size: 12px;
+ line-height: 1.5;
+ color: var(--text);
+}
+
+.tok-keyword {
+ color: #c792ea;
+}
+
+.tok-string {
+ color: #a5d6a7;
+}
+
+.tok-comment {
+ color: var(--text-faint);
+ font-style: italic;
+}
+
+.tok-number {
+ color: #f8bd7f;
+}
+
+.diff-view {
+ margin: 0;
+ max-height: 480px;
+ overflow: auto;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: #101116;
+ font-family: var(--mono);
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.diff-line {
+ padding: 0 10px;
+ white-space: pre;
+}
+
+.diff-add {
+ background: color-mix(in srgb, var(--green) 14%, transparent);
+ color: #b9f6ca;
+}
+
+.diff-del {
+ background: color-mix(in srgb, var(--red) 14%, transparent);
+ color: #ffcdd2;
+}
+
+.diff-hunk {
+ color: var(--blue);
+ background: var(--bg-raised);
+}
diff --git a/apps/desktop/src/styles/files.css b/apps/desktop/src/styles/files.css
new file mode 100644
index 0000000..b7258c5
--- /dev/null
+++ b/apps/desktop/src/styles/files.css
@@ -0,0 +1,86 @@
+/* The bot view's side panel tabs and the Files panel. */
+
+.side-tabs {
+ flex: none;
+}
+
+.files-panel {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ height: 100%;
+}
+
+.files-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--border);
+}
+
+.files-preview-name {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--text-strong);
+}
+
+.files-empty {
+ padding: 16px 14px;
+ color: var(--text-dim);
+}
+
+.files-list {
+ list-style: none;
+ margin: 0;
+ padding: 4px 0;
+ overflow-y: auto;
+}
+
+.files-row {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ width: 100%;
+ padding: 7px 14px;
+ font: inherit;
+ text-align: left;
+ color: var(--text);
+ background: none;
+ border: 0;
+ cursor: pointer;
+}
+
+.files-row:hover {
+ background: var(--bg-hover);
+}
+
+.files-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.files-meta {
+ font-size: 11px;
+ color: var(--text-faint);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.files-preview {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ padding: 12px 14px;
+}
+
+.files-image {
+ max-width: 100%;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+}
diff --git a/apps/desktop/src/styles/lightbox.css b/apps/desktop/src/styles/lightbox.css
new file mode 100644
index 0000000..9fdbad5
--- /dev/null
+++ b/apps/desktop/src/styles/lightbox.css
@@ -0,0 +1,112 @@
+/* Full-size screenshots: fitted to the window at their own shape, or at
+ actual size to scroll around. */
+
+.lightbox {
+ position: fixed;
+ inset: 0;
+ z-index: 70;
+ display: flex;
+ flex-direction: column;
+ background: rgba(5, 6, 9, 0.92);
+}
+
+.lightbox-dialog {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.lightbox-bar {
+ flex: none;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 16px;
+ padding-top: max(10px, var(--titlebar-h));
+ color: var(--text-dim);
+ font-size: 12px;
+}
+
+.lightbox-count {
+ color: var(--text-strong);
+ min-width: 48px;
+}
+
+.lightbox-hint {
+ flex: 1;
+ color: var(--text-faint);
+}
+
+.lightbox-stage {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 64px 24px;
+ overflow: hidden;
+}
+
+.lightbox-image {
+ padding: 0;
+ border: 0;
+ background: none;
+ cursor: zoom-in;
+ max-width: 100%;
+ max-height: 100%;
+ display: flex;
+}
+
+/* Fitted: never larger than the stage, never cropped, aspect kept. */
+.lightbox-image img {
+ display: block;
+ max-width: 100%;
+ max-height: calc(100vh - var(--titlebar-h) - 72px);
+ width: auto;
+ height: auto;
+ object-fit: contain;
+ border-radius: 6px;
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
+}
+
+/* Actual size: the image keeps its pixels and the stage scrolls. */
+.lightbox-actual {
+ overflow: auto;
+ align-items: flex-start;
+ justify-content: flex-start;
+}
+
+.lightbox-actual .lightbox-image {
+ max-width: none;
+ max-height: none;
+ margin: auto;
+ cursor: zoom-out;
+}
+
+.lightbox-actual .lightbox-image img {
+ max-width: none;
+ max-height: none;
+}
+
+.lightbox-note {
+ color: var(--text-dim);
+}
+
+.lightbox-nav {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--text-strong);
+ background: rgba(20, 22, 28, 0.7);
+ border-radius: 50%;
+ padding: 6px;
+}
+
+.lightbox-prev {
+ left: 12px;
+}
+
+.lightbox-next {
+ right: 12px;
+}
diff --git a/apps/desktop/src/styles/permissions.css b/apps/desktop/src/styles/permissions.css
new file mode 100644
index 0000000..26f776c
--- /dev/null
+++ b/apps/desktop/src/styles/permissions.css
@@ -0,0 +1,83 @@
+/* Permission cards: a bot's tool waiting on the owner's answer. */
+
+.permission-cards {
+ flex: none;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 10px 20px;
+ border-bottom: 1px solid var(--border);
+ background: color-mix(in srgb, var(--amber) 6%, var(--bg));
+ max-height: 45%;
+ overflow-y: auto;
+}
+
+.permission-card {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 10px 12px;
+ border: 1px solid color-mix(in srgb, var(--amber) 45%, var(--border));
+ border-radius: 10px;
+ background: var(--bg-raised);
+}
+
+.permission-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ font-size: 11px;
+}
+
+.permission-label {
+ color: var(--amber);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.permission-expiry,
+.permission-readonly {
+ color: var(--text-faint);
+ font-size: 11px;
+}
+
+.permission-summary {
+ font-family: var(--mono);
+ font-size: 12px;
+ color: var(--text-strong);
+ overflow-wrap: anywhere;
+}
+
+.permission-toggle {
+ align-self: flex-start;
+ font: inherit;
+ font-size: 11px;
+ color: var(--text-dim);
+ background: none;
+ border: 0;
+ padding: 0;
+ cursor: pointer;
+}
+
+.permission-reason {
+ font: inherit;
+ color: var(--text);
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 6px 8px;
+}
+
+.permission-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+}
+
+.permission-actions kbd {
+ margin-left: 4px;
+ font-family: var(--mono);
+ font-size: 10px;
+ opacity: 0.6;
+}
diff --git a/apps/desktop/src/styles/tasks.css b/apps/desktop/src/styles/tasks.css
new file mode 100644
index 0000000..1a118f8
--- /dev/null
+++ b/apps/desktop/src/styles/tasks.css
@@ -0,0 +1,126 @@
+/* The Tasks panel: a bot's current, delegated, scheduled and finished work. */
+
+.tasks-panel {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.tasks-section {
+ padding: 8px 0 4px;
+}
+
+.tasks-section-title {
+ margin: 0;
+ padding: 4px 14px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text-dim);
+}
+
+.tasks-count {
+ color: var(--text-faint);
+ font-weight: 400;
+}
+
+.tasks-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.task-row {
+ padding: 6px 14px 8px;
+ border-bottom: 1px solid var(--border);
+}
+
+.task-row-head {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+ font-size: 12px;
+ color: var(--text);
+}
+
+.task-more {
+ margin-top: 2px;
+ padding: 0;
+ font: inherit;
+ font-size: 11px;
+ color: var(--accent);
+ background: none;
+ border: 0;
+ cursor: pointer;
+}
+
+.task-more:hover {
+ text-decoration: underline;
+}
+
+.task-who {
+ font-weight: 600;
+ color: var(--text-strong);
+}
+
+.task-when {
+ margin-left: auto;
+ font-size: 11px;
+ color: var(--text-faint);
+ white-space: nowrap;
+}
+
+.task-badge {
+ font-size: 10px;
+ padding: 0 6px;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ color: var(--text-dim);
+}
+
+.task-badge-done {
+ color: var(--green);
+ border-color: color-mix(in srgb, var(--green) 40%, var(--border));
+}
+
+.task-badge-expired,
+.task-badge-cancelled {
+ color: var(--amber);
+ border-color: color-mix(in srgb, var(--amber) 40%, var(--border));
+}
+
+.task-request,
+.task-result {
+ margin-top: 4px;
+ font-size: 12px;
+ line-height: 1.45;
+ color: var(--text-dim);
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+.task-clamp {
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.task-result {
+ padding: 6px 8px;
+ border-left: 2px solid var(--green);
+ background: var(--bg-raised);
+ color: var(--text);
+}
+
+/* An opened task reads as the markdown the bot wrote. */
+.task-full,
+.task-result {
+ white-space: normal;
+}
+
+.task-full .markdown p,
+.task-result .markdown p {
+ margin: 0 0 6px;
+}
diff --git a/apps/desktop/src/test/chatFixtures.ts b/apps/desktop/src/test/chatFixtures.ts
new file mode 100644
index 0000000..4b3b43e
--- /dev/null
+++ b/apps/desktop/src/test/chatFixtures.ts
@@ -0,0 +1,50 @@
+import type { ChatItem, ChatTurn, Step, TurnStats } from "../protocol/chat";
+
+const AT = "2026-10-01T10:00:00Z";
+
+const NO_STATS: TurnStats = {
+ commands: 0,
+ reads: 0,
+ edits: 0,
+ added: 0,
+ removed: 0,
+ sent: 0,
+ images: 0,
+ errors: 0,
+};
+
+export function stats(over: Partial = {}): TurnStats {
+ return { ...NO_STATS, ...over };
+}
+
+export function step(over: Partial = {}): Step {
+ return {
+ type: "step",
+ id: "s1",
+ tool: "Bash",
+ title: "Run tests",
+ subtitle: "cargo test",
+ status: "ok",
+ minor: false,
+ ...over,
+ };
+}
+
+export function text(markdown: string, id = "t1"): ChatItem {
+ return { type: "text", id, markdown };
+}
+
+export function turn(over: Partial = {}): ChatTurn {
+ return {
+ id: "turn-1",
+ bot_id: "b1",
+ started_at: AT,
+ ended_at: "2026-10-01T10:03:04Z",
+ duration_ms: 184_000,
+ open: false,
+ trigger: { kind: "owner", text: "port the updater", via: "chat" },
+ items: [text("Done — it builds.")],
+ stats: stats(),
+ ...over,
+ };
+}
diff --git a/apps/desktop/src/test/taskFixtures.ts b/apps/desktop/src/test/taskFixtures.ts
new file mode 100644
index 0000000..e960a3d
--- /dev/null
+++ b/apps/desktop/src/test/taskFixtures.ts
@@ -0,0 +1,53 @@
+import type { BotTask } from "../protocol/tasks";
+import { FakeDaemon } from "./fakeDaemon";
+import * as fx from "./fixtures";
+
+export function task(over: Partial = {}): BotTask {
+ return {
+ id: "t1",
+ state: "open",
+ role: "assigned",
+ other: { id: "b2", name: "lead" },
+ request: "Port the updater to Windows.",
+ created_at: "2025-01-15T10:00:00Z",
+ deadline_at: "2025-01-16T10:00:00Z",
+ ...over,
+ };
+}
+
+export const sampleTasks: readonly BotTask[] = [
+ task(),
+ task({
+ id: "t2",
+ role: "delegated",
+ other: { id: "b3", name: "windev", machine: "win-pc" },
+ request: "Sign the MSI with the release certificate.",
+ }),
+ task({
+ id: "t3",
+ state: "done",
+ request: "Review the installer changes.",
+ result: "Reviewed — two nits, both fixed.",
+ closed_at: "2025-01-15T09:00:00Z",
+ }),
+ task({ id: "t4", state: "expired", role: "delegated", request: "Benchmark the cold start." }),
+];
+
+/** A daemon answering `list_tasks` and `list_routines` for the Tasks panel. */
+export function tasksDaemon(tasks: readonly BotTask[] = sampleTasks): FakeDaemon {
+ return new FakeDaemon()
+ .onRequest("list_tasks", () => ({ type: "tasks", req_id: "1", bot_id: "b1", tasks }))
+ .onRequest("list_routines", () => ({
+ type: "routines",
+ req_id: "2",
+ routines: [
+ fx.routine({ id: "r1", name: "nightly-report", next_run_at: "2025-01-16T02:00:00Z" }),
+ fx.routine({
+ id: "r2",
+ name: "paused",
+ enabled: false,
+ next_run_at: "2025-01-16T03:00:00Z",
+ }),
+ ],
+ }));
+}
diff --git a/apps/desktop/tests/visual/__screenshots__/chat-composer--read-only.png b/apps/desktop/tests/visual/__screenshots__/chat-composer--read-only.png
new file mode 100644
index 0000000..d9ebc17
Binary files /dev/null and b/apps/desktop/tests/visual/__screenshots__/chat-composer--read-only.png differ
diff --git a/apps/desktop/tests/visual/__screenshots__/chat-composer--ready.png b/apps/desktop/tests/visual/__screenshots__/chat-composer--ready.png
new file mode 100644
index 0000000..40d600b
Binary files /dev/null and b/apps/desktop/tests/visual/__screenshots__/chat-composer--ready.png differ
diff --git a/apps/desktop/tests/visual/__screenshots__/code-block--diff.png b/apps/desktop/tests/visual/__screenshots__/code-block--diff.png
new file mode 100644
index 0000000..704b2c4
Binary files /dev/null and b/apps/desktop/tests/visual/__screenshots__/code-block--diff.png differ
diff --git a/apps/desktop/tests/visual/__screenshots__/code-block--type-script.png b/apps/desktop/tests/visual/__screenshots__/code-block--type-script.png
new file mode 100644
index 0000000..5eae1b3
Binary files /dev/null and b/apps/desktop/tests/visual/__screenshots__/code-block--type-script.png differ
diff --git a/apps/desktop/tests/visual/__screenshots__/lightbox--wide-screenshot.png b/apps/desktop/tests/visual/__screenshots__/lightbox--wide-screenshot.png
new file mode 100644
index 0000000..6f8b010
Binary files /dev/null and b/apps/desktop/tests/visual/__screenshots__/lightbox--wide-screenshot.png differ
diff --git a/apps/desktop/tests/visual/__screenshots__/permission-cards--read-only.png b/apps/desktop/tests/visual/__screenshots__/permission-cards--read-only.png
new file mode 100644
index 0000000..0044bce
Binary files /dev/null and b/apps/desktop/tests/visual/__screenshots__/permission-cards--read-only.png differ
diff --git a/apps/desktop/tests/visual/__screenshots__/permission-cards--waiting.png b/apps/desktop/tests/visual/__screenshots__/permission-cards--waiting.png
new file mode 100644
index 0000000..a8bfb42
Binary files /dev/null and b/apps/desktop/tests/visual/__screenshots__/permission-cards--waiting.png differ
diff --git a/apps/desktop/tests/visual/__screenshots__/tasks-panel--overview.png b/apps/desktop/tests/visual/__screenshots__/tasks-panel--overview.png
new file mode 100644
index 0000000..11acf2f
Binary files /dev/null and b/apps/desktop/tests/visual/__screenshots__/tasks-panel--overview.png differ
diff --git a/apps/desktop/tests/visual/__screenshots__/turn-view--owner-chat.png b/apps/desktop/tests/visual/__screenshots__/turn-view--owner-chat.png
new file mode 100644
index 0000000..ed4776e
Binary files /dev/null and b/apps/desktop/tests/visual/__screenshots__/turn-view--owner-chat.png differ
diff --git a/apps/desktop/tests/visual/__screenshots__/turn-view--task-with-steps.png b/apps/desktop/tests/visual/__screenshots__/turn-view--task-with-steps.png
new file mode 100644
index 0000000..ebee8c9
Binary files /dev/null and b/apps/desktop/tests/visual/__screenshots__/turn-view--task-with-steps.png differ
diff --git a/apps/desktop/tests/visual/__screenshots__/turn-view--working.png b/apps/desktop/tests/visual/__screenshots__/turn-view--working.png
new file mode 100644
index 0000000..78f6738
Binary files /dev/null and b/apps/desktop/tests/visual/__screenshots__/turn-view--working.png differ
diff --git a/apps/marketing/public/THIRD_PARTY_NOTICES.txt b/apps/marketing/public/THIRD_PARTY_NOTICES.txt
index 87c08aa..f2a4c33 100644
--- a/apps/marketing/public/THIRD_PARTY_NOTICES.txt
+++ b/apps/marketing/public/THIRD_PARTY_NOTICES.txt
@@ -1,7 +1,7 @@
GRAVITY MARKETING THIRD-PARTY NOTICES
Gravity's MIT license is separate from these upstream terms.
-Native inventory target: aarch64-apple-darwin.
+Native inventory targets: aarch64-apple-darwin, x86_64-pc-windows-msvc.
Runtime dependency closures are conservative; tree-shaken code may be absent.
Build/test notices are included conservatively for generated code; their
presence does not mean the tools themselves are distributed.
diff --git a/crates/bus/src/schema.rs b/crates/bus/src/schema.rs
index edc2891..a4a9460 100644
--- a/crates/bus/src/schema.rs
+++ b/crates/bus/src/schema.rs
@@ -7,6 +7,7 @@
mod base;
mod decisions;
mod history;
+mod peers;
use base::MIGRATION_1;
use decisions::MIGRATION_12;
@@ -14,6 +15,7 @@ use history::{
MIGRATION_10, MIGRATION_11, MIGRATION_2, MIGRATION_3, MIGRATION_4, MIGRATION_5, MIGRATION_6,
MIGRATION_7, MIGRATION_8, MIGRATION_9,
};
+use peers::{MIGRATION_14, MIGRATION_15, MIGRATION_16};
pub const MIGRATIONS: &[&str] = &[
MIGRATION_1,
@@ -28,4 +30,8 @@ pub const MIGRATIONS: &[&str] = &[
MIGRATION_10,
MIGRATION_11,
MIGRATION_12,
+ "ALTER TABLE bot ADD COLUMN runtime TEXT NOT NULL DEFAULT 'claude_code' CHECK(runtime IN ('claude_code', 'codex_cli'));",
+ MIGRATION_14,
+ MIGRATION_15,
+ MIGRATION_16,
];
diff --git a/crates/bus/src/schema/peers.rs b/crates/bus/src/schema/peers.rs
new file mode 100644
index 0000000..2a7d045
--- /dev/null
+++ b/crates/bus/src/schema/peers.rs
@@ -0,0 +1,74 @@
+//! Migration 14: peer daemons and linked bots.
+//!
+//! A linked bot is an ordinary `bot` row with `peer_id` set, so every foreign
+//! key that assumes a local bot (message senders, task ends, deliveries) keeps
+//! holding. The mapping tables carry the other side's ids: a message or task
+//! that crosses the link has an id on each daemon, and threading, dedupe and
+//! closing a mirrored task all need to translate between them.
+
+pub(super) const MIGRATION_14: &str = r#"
+CREATE TABLE peer (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE,
+ daemon_id TEXT UNIQUE,
+ url TEXT,
+ created_at TEXT NOT NULL,
+ last_seen_at TEXT,
+ revoked_at TEXT
+);
+
+ALTER TABLE bot ADD COLUMN peer_id TEXT REFERENCES peer(id);
+ALTER TABLE bot ADD COLUMN remote_bot_id TEXT;
+CREATE UNIQUE INDEX idx_bot_remote ON bot(peer_id, remote_bot_id, project_id)
+ WHERE peer_id IS NOT NULL AND deleted_at IS NULL;
+
+CREATE TABLE peer_link (
+ peer_id TEXT NOT NULL REFERENCES peer(id),
+ bot_id TEXT NOT NULL REFERENCES bot(id),
+ PRIMARY KEY (peer_id, bot_id)
+);
+
+CREATE TABLE peer_message (
+ peer_id TEXT NOT NULL REFERENCES peer(id),
+ remote_message_id TEXT NOT NULL,
+ message_id TEXT NOT NULL,
+ PRIMARY KEY (peer_id, remote_message_id)
+);
+CREATE INDEX idx_peer_message_local ON peer_message(peer_id, message_id);
+
+CREATE TABLE peer_task (
+ peer_id TEXT NOT NULL REFERENCES peer(id),
+ remote_task_id TEXT NOT NULL,
+ task_id TEXT NOT NULL,
+ PRIMARY KEY (peer_id, remote_task_id)
+);
+CREATE INDEX idx_peer_task_local ON peer_task(peer_id, task_id);
+"#;
+
+/// Migration 15: a revoked peer frees its name and its daemon.
+///
+/// The name is tombstoned the way an archived bot's is, and the daemon id
+/// released, so pairing the same machine again can reuse both. New
+/// revocations do the same in `Db::revoke_peer`.
+pub(super) const MIGRATION_15: &str = r#"
+UPDATE peer SET name = name || '#' || substr(id, 1, 8)
+ WHERE revoked_at IS NOT NULL AND instr(name, '#') = 0;
+UPDATE peer SET daemon_id = NULL WHERE revoked_at IS NOT NULL;
+"#;
+
+/// Migration 16: projects linked across peers.
+///
+/// A link is recorded on both daemons, each holding the other's project id.
+/// A project links with at most one project per peer, and the same remote
+/// project with at most one project here.
+pub(super) const MIGRATION_16: &str = r#"
+CREATE TABLE project_link (
+ project_id TEXT NOT NULL REFERENCES project(id),
+ peer_id TEXT NOT NULL REFERENCES peer(id),
+ remote_project_id TEXT NOT NULL,
+ remote_project_name TEXT NOT NULL,
+ linked_at TEXT NOT NULL,
+ PRIMARY KEY (project_id, peer_id)
+);
+CREATE UNIQUE INDEX idx_project_link_remote ON project_link(peer_id, remote_project_id);
+"#;
diff --git a/crates/bus/src/types.rs b/crates/bus/src/types.rs
index 3fc3b5b..e558bfc 100644
--- a/crates/bus/src/types.rs
+++ b/crates/bus/src/types.rs
@@ -7,11 +7,15 @@ mod decision_enums;
mod decisions;
mod entities;
mod enums;
+mod peers;
+mod revisions;
pub use decision_enums::*;
pub use decisions::*;
pub use entities::*;
pub use enums::*;
+pub use peers::*;
+pub use revisions::*;
pub type Id = String;
diff --git a/crates/bus/src/types/entities.rs b/crates/bus/src/types/entities.rs
index 36e66a9..5fc2d14 100644
--- a/crates/bus/src/types/entities.rs
+++ b/crates/bus/src/types/entities.rs
@@ -33,6 +33,8 @@ pub struct Bot {
pub description: String,
pub avatar: String,
pub instructions: String,
+ #[serde(default)]
+ pub runtime: BotRuntime,
pub state: BotState,
pub state_reason: String,
pub unread_count: i64,
@@ -49,65 +51,38 @@ pub struct Bot {
/// leave `list_bots`, addressing, and the population cap.
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option>,
+ /// Set on a linked bot: one that runs on this peer daemon and stands in
+ /// here as a message target. It has no workspace and no runtime.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub peer_id: Option,
+ /// The linked bot's id on its peer.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub remote_bot_id: Option,
pub created_at: DateTime,
}
-/// One field-level change to a bot's identity: the audit trail that makes
-/// unsupervised self-management reversible.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct BotRevision {
- pub id: Id,
- pub bot_id: Id,
- /// `user` or `bot:`.
- pub changed_by: String,
- pub field: RevisionField,
- pub old_value: String,
- pub new_value: String,
- pub created_at: DateTime,
+impl Bot {
+ /// True for a bot that runs on a peer daemon rather than here.
+ pub fn is_linked(&self) -> bool {
+ self.peer_id.is_some()
+ }
}
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
-pub enum RevisionField {
- Name,
- Avatar,
- Description,
- Instructions,
- /// Lifecycle markers rather than field edits; `new_value` carries the
- /// reason, so a bot's whole life is one ordered list.
- Created,
- Deleted,
+pub enum BotRuntime {
+ #[default]
+ ClaudeCode,
+ CodexCli,
}
-impl RevisionField {
+impl BotRuntime {
pub fn as_str(self) -> &'static str {
match self {
- RevisionField::Name => "name",
- RevisionField::Avatar => "avatar",
- RevisionField::Description => "description",
- RevisionField::Instructions => "instructions",
- RevisionField::Created => "created",
- RevisionField::Deleted => "deleted",
- }
- }
-
- pub fn parse(s: &str) -> Option {
- match s {
- "name" => Some(RevisionField::Name),
- "avatar" => Some(RevisionField::Avatar),
- "description" => Some(RevisionField::Description),
- "instructions" => Some(RevisionField::Instructions),
- "created" => Some(RevisionField::Created),
- "deleted" => Some(RevisionField::Deleted),
- _ => None,
+ Self::ClaudeCode => "claude_code",
+ Self::CodexCli => "codex_cli",
}
}
-
- /// Whether reverting this entry means writing `old_value` back. Lifecycle
- /// markers are history, not state, so they are not revertible.
- pub fn is_revertible(self) -> bool {
- !matches!(self, RevisionField::Created | RevisionField::Deleted)
- }
}
/// A bot's DM thread. Every bot has exactly one, created with it.
diff --git a/crates/bus/src/types/peers.rs b/crates/bus/src/types/peers.rs
new file mode 100644
index 0000000..313d991
--- /dev/null
+++ b/crates/bus/src/types/peers.rs
@@ -0,0 +1,55 @@
+//! Peer daemons: another Gravity daemon of the same owner whose bots this
+//! one can exchange messages with. See `docs/peer-bots.md`.
+
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+
+use super::Id;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Peer {
+ pub id: Id,
+ /// The owner's name for the other machine, e.g. `win-pc`.
+ pub name: String,
+ /// The other daemon's stable id, learned at the first handshake. A token
+ /// is bound to the first daemon that presents it.
+ pub daemon_id: Option,
+ /// Where to dial, on the side that dials. `None` on the side that listens.
+ pub url: Option,
+ pub created_at: DateTime,
+ pub last_seen_at: Option>,
+ pub revoked_at: Option>,
+}
+
+/// Largest file sent with a forwarded result.
+pub const MAX_PEER_ARTIFACT_BYTES: u64 = 16 * 1024 * 1024;
+
+/// Largest total of files sent with one forwarded result.
+pub const MAX_PEER_ARTIFACTS_TOTAL_BYTES: u64 = 48 * 1024 * 1024;
+
+/// A bot as a peer describes it: enough to stand it in as a linked bot.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RemoteBot {
+ pub id: Id,
+ pub name: String,
+ #[serde(default)]
+ pub description: String,
+ #[serde(default)]
+ pub avatar: String,
+ #[serde(default)]
+ pub runtime: super::BotRuntime,
+ /// The project it works in on its own daemon. Informational only.
+ #[serde(default)]
+ pub project: String,
+}
+
+/// A project here linked with a project on a peer: one team across two
+/// machines. Recorded on both daemons, each with the other's project id.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ProjectLink {
+ pub project_id: Id,
+ pub peer_id: Id,
+ pub remote_project_id: Id,
+ pub remote_project_name: String,
+ pub linked_at: DateTime,
+}
diff --git a/crates/bus/src/types/revisions.rs b/crates/bus/src/types/revisions.rs
new file mode 100644
index 0000000..55040be
--- /dev/null
+++ b/crates/bus/src/types/revisions.rs
@@ -0,0 +1,64 @@
+//! The bot identity audit trail.
+
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+
+use super::Id;
+
+/// One field-level change to a bot's identity: the audit trail that makes
+/// unsupervised self-management reversible.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct BotRevision {
+ pub id: Id,
+ pub bot_id: Id,
+ /// `user` or `bot:`.
+ pub changed_by: String,
+ pub field: RevisionField,
+ pub old_value: String,
+ pub new_value: String,
+ pub created_at: DateTime,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum RevisionField {
+ Name,
+ Avatar,
+ Description,
+ Instructions,
+ /// Lifecycle markers rather than field edits; `new_value` carries the
+ /// reason, so a bot's whole life is one ordered list.
+ Created,
+ Deleted,
+}
+
+impl RevisionField {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ RevisionField::Name => "name",
+ RevisionField::Avatar => "avatar",
+ RevisionField::Description => "description",
+ RevisionField::Instructions => "instructions",
+ RevisionField::Created => "created",
+ RevisionField::Deleted => "deleted",
+ }
+ }
+
+ pub fn parse(s: &str) -> Option {
+ match s {
+ "name" => Some(RevisionField::Name),
+ "avatar" => Some(RevisionField::Avatar),
+ "description" => Some(RevisionField::Description),
+ "instructions" => Some(RevisionField::Instructions),
+ "created" => Some(RevisionField::Created),
+ "deleted" => Some(RevisionField::Deleted),
+ _ => None,
+ }
+ }
+
+ /// Whether reverting this entry means writing `old_value` back. Lifecycle
+ /// markers are history, not state, so they are not revertible.
+ pub fn is_revertible(self) -> bool {
+ !matches!(self, RevisionField::Created | RevisionField::Deleted)
+ }
+}
diff --git a/crates/gravityd/Cargo.toml b/crates/gravityd/Cargo.toml
index d43cc4b..4f9e980 100644
--- a/crates/gravityd/Cargo.toml
+++ b/crates/gravityd/Cargo.toml
@@ -28,8 +28,9 @@ sha2 = { workspace = true }
hex = { workspace = true }
futures = { workspace = true }
bytes = { workspace = true }
+tokio-tungstenite = "0.24"
+base64 = "0.22"
[dev-dependencies]
tempfile = "3"
-tokio-tungstenite = "0.24"
reqwest = { version = "0.12", features = ["json"] }
diff --git a/crates/gravityd/src/activity.rs b/crates/gravityd/src/activity.rs
index 9955c95..640252e 100644
--- a/crates/gravityd/src/activity.rs
+++ b/crates/gravityd/src/activity.rs
@@ -68,12 +68,14 @@ const TAIL_BYTES: u64 = 1 << 20;
const MAX_CHARS: usize = 200;
/// Claude Code's transcript directory for a workspace: the absolute path with
-/// every `/` and `.` replaced by `-`, under `~/.claude/projects`.
+/// every character that is not an ASCII letter or digit replaced by `-`, under
+/// `~/.claude/projects`. Underscores count: a workspace under `my_app` is
+/// filed as `my-app`.
pub(crate) fn transcript_dir(home: &Path, workspace: &Path) -> PathBuf {
let mangled: String = workspace
.to_string_lossy()
.chars()
- .map(|c| if c == '/' || c == '.' { '-' } else { c })
+ .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
home.join(".claude").join("projects").join(mangled)
}
@@ -155,7 +157,11 @@ fn assistant_text(entry: &Value) -> Option {
/// has no transcript yet (never started, or never spoke).
pub fn from_transcript(home: &Path, workspace: &Path) -> Option {
let path = newest_transcript(&transcript_dir(home, workspace))?;
- for line in tail_lines(&path, SCAN_LINES) {
+ from_transcript_file(&path)
+}
+
+fn from_transcript_file(path: &Path) -> Option {
+ for line in tail_lines(path, SCAN_LINES) {
let Ok(entry) = serde_json::from_str::(&line) else {
continue;
};
@@ -194,7 +200,13 @@ fn from_bus(db: &Db, bot_id: &str) -> Option {
/// One preview line for a bot: the newer of its last Claude Code turn and its
/// last bus message. None when the bot has said nothing yet.
pub fn for_bot(db: &Db, home: &Path, bot_id: &str, workspace: &Path) -> Option {
- newer(from_transcript(home, workspace), from_bus(db, bot_id))
+ let transcript = match db.get_bot(bot_id).ok().flatten().map(|bot| bot.runtime) {
+ Some(bus::BotRuntime::CodexCli) => {
+ from_transcript_file(&crate::runtime::codex::transcript_path(workspace))
+ }
+ _ => from_transcript(home, workspace),
+ };
+ newer(transcript, from_bus(db, bot_id))
}
/// Collapses whitespace and caps the length, so one preview stays one line.
@@ -243,6 +255,7 @@ pub async fn watch(app: Arc) {
Ok(Internal::BotDone { bot_id, .. }) => {
tokio::spawn(settle_and_push(app.clone(), bot_id, Utc::now()));
}
+ Ok(_) => {}
// A burst of finished turns must not retire the watcher for the
// rest of the daemon's life: the turns it skipped surface on the
// next snapshot, the ones after it keep flowing.
diff --git a/crates/gravityd/src/activity/tests.rs b/crates/gravityd/src/activity/tests.rs
index 76f8dc0..5410381 100644
--- a/crates/gravityd/src/activity/tests.rs
+++ b/crates/gravityd/src/activity/tests.rs
@@ -36,6 +36,15 @@ fn mangles_the_workspace_path_the_way_claude_code_does() {
);
}
+#[test]
+fn mangles_underscores_like_claude_code() {
+ let dir = transcript_dir(
+ Path::new("/Users/me"),
+ Path::new("/Users/me/.gravity/projects/my_app/bots/q_a/workspace"),
+ );
+ assert!(dir.ends_with("-Users-me--gravity-projects-my-app-bots-q-a-workspace"));
+}
+
#[test]
fn reads_the_newest_assistant_turn() {
let tmp = tempfile::tempdir().expect("tmp");
diff --git a/crates/gravityd/src/app.rs b/crates/gravityd/src/app.rs
index 3340061..82e93a0 100644
--- a/crates/gravityd/src/app.rs
+++ b/crates/gravityd/src/app.rs
@@ -8,7 +8,7 @@ use crate::db::Db;
use crate::events::Events;
use crate::overrides::{AutoCompactOverride, AUTO_COMPACT_META_KEY};
use crate::runtime::double::DoubleAdapter;
-use crate::runtime::pty::PtyAdapter;
+use crate::runtime::mixed::MixedAdapter;
use crate::runtime::RuntimeAdapter;
use crate::secrets::Secrets;
use crate::supervisor::Supervisor;
@@ -27,6 +27,11 @@ pub const CAPABILITIES: &[&str] = &[
"bot_self_management",
"config",
"decisions",
+ "bot_runtime",
+ "chat",
+ "permissions",
+ "peer_chat",
+ "linked_projects",
];
pub struct AppState {
@@ -38,6 +43,13 @@ pub struct AppState {
/// Runtime override for `cfg.auto_compact_window`, shared with the
/// supervisor and persisted in the `meta` table.
pub auto_compact: AutoCompactOverride,
+ /// Live links to peer daemons, shared by the delivery worker (forwarding
+ /// to linked bots) and the control plane (pairing and linking).
+ pub peers: crate::peer::PeerHub,
+ /// Bots' conversations read from their transcripts, for the chat pane.
+ pub chat: crate::chat::ChatStore,
+ /// Permission prompts waiting on the owner's answer.
+ pub approvals: crate::approval::Approvals,
pub started_at: Instant,
/// Wall-clock start, kept alongside the monotonic `started_at` purely so
/// [`AppState::stale_build`] can compare it against a file mtime.
@@ -52,7 +64,7 @@ impl AppState {
let secrets = Arc::new(Secrets::open(&cfg.secrets_dir())?);
let events = Events::new();
let adapter: Arc = match cfg.runtime {
- RuntimeKind::Pty => Arc::new(PtyAdapter),
+ RuntimeKind::Pty => Arc::new(MixedAdapter::new(&cfg)),
RuntimeKind::Double => Arc::new(DoubleAdapter),
};
let auto_compact = AutoCompactOverride::default();
@@ -74,6 +86,9 @@ impl AppState {
secrets,
supervisor,
auto_compact,
+ peers: crate::peer::PeerHub::default(),
+ chat: crate::chat::ChatStore::default(),
+ approvals: crate::approval::Approvals::default(),
started_at: Instant::now(),
started_wall: SystemTime::now(),
}))
diff --git a/crates/gravityd/src/approval.rs b/crates/gravityd/src/approval.rs
new file mode 100644
index 0000000..af5bce6
--- /dev/null
+++ b/crates/gravityd/src/approval.rs
@@ -0,0 +1,276 @@
+//! Permission prompts answered from the app. A runtime asks — Claude Code
+//! through its `PermissionRequest` hook (`claude`), Codex through its App
+//! Server approval requests (`runtime`) — and the owner answers a card: allow
+//! once, allow for this session, or deny. Nothing defaults to yes: an
+//! unanswered prompt is denied when its window closes, and with no app able to
+//! answer, the runtime is left to ask in its own terminal.
+
+use std::collections::HashMap;
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::{Arc, Mutex};
+use std::time::Duration;
+
+use bus::BotState;
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use tokio::sync::oneshot;
+
+use crate::app::AppState;
+use crate::events::Push;
+
+mod claude;
+mod runtime;
+
+pub use claude::permission_hook;
+pub use runtime::watch;
+
+/// How long Claude Code waits on the hook. Kept above any answer window the
+/// config allows, so the daemon always answers before the hook is killed.
+pub const HOOK_TIMEOUT_SECS: u64 = 900;
+/// The longest answer window the config may ask for.
+const MAX_WINDOW_SECS: u64 = HOOK_TIMEOUT_SECS - 60;
+const MAX_INPUT_CHARS: usize = 4_000;
+
+#[derive(Debug, Clone, Serialize)]
+pub struct PermissionRequest {
+ pub id: String,
+ pub bot_id: String,
+ pub tool: String,
+ /// One line saying what the tool would do.
+ pub summary: String,
+ /// The tool input, pretty-printed and truncated.
+ pub input: String,
+ pub created_at: DateTime,
+ pub expires_at: DateTime,
+}
+
+#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum Answer {
+ AllowOnce,
+ AllowSession,
+ Deny,
+}
+
+#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum Outcome {
+ AllowedOnce,
+ AllowedSession,
+ Denied,
+ Expired,
+ /// The asker went away before an answer: the runtime moved on without it.
+ Abandoned,
+}
+
+/// How a prompt ended for the runtime that asked.
+enum Decision {
+ Answered(Answer, Option),
+ Expired,
+}
+
+struct Pending {
+ request: PermissionRequest,
+ /// Set for a runtime request, so the runtime can withdraw it.
+ runtime_key: Option,
+ reply: oneshot::Sender<(Answer, Option)>,
+}
+
+#[derive(Default)]
+pub struct Approvals {
+ pending: Mutex>,
+ /// Connected clients that show permission cards and may answer them.
+ answerers: AtomicUsize,
+}
+
+/// Held by a connection that can answer permission cards; dropping it, when
+/// the connection closes, takes that client out of the count.
+pub struct Answerer(Arc);
+
+impl Drop for Answerer {
+ fn drop(&mut self) {
+ self.0.approvals.answerers.fetch_sub(1, Ordering::SeqCst);
+ }
+}
+
+/// Counts a connection in as able to answer permission cards.
+pub fn answerer(app: &Arc) -> Answerer {
+ app.approvals.answerers.fetch_add(1, Ordering::SeqCst);
+ Answerer(app.clone())
+}
+
+impl Approvals {
+ fn lock(&self) -> std::sync::MutexGuard<'_, HashMap> {
+ self.pending.lock().unwrap_or_else(|e| e.into_inner())
+ }
+
+ pub fn list(&self, bot_id: Option<&str>) -> Vec {
+ let mut out: Vec = self
+ .lock()
+ .values()
+ .map(|p| p.request.clone())
+ .filter(|r| bot_id.is_none_or(|id| r.bot_id == id))
+ .collect();
+ out.sort_by_key(|r| r.created_at);
+ out
+ }
+
+ /// Answers a pending prompt. Errors when it is no longer pending.
+ pub fn answer(
+ &self,
+ request_id: &str,
+ answer: Answer,
+ reason: Option,
+ ) -> anyhow::Result {
+ let pending = self
+ .lock()
+ .remove(request_id)
+ .ok_or_else(|| anyhow::anyhow!("that permission prompt is no longer waiting"))?;
+ let request = pending.request.clone();
+ // A closed receiver means the asker just gave up; the prompt is gone
+ // either way, so the answer has nothing left to decide.
+ let _ = pending.reply.send((answer, reason));
+ Ok(request)
+ }
+
+ /// Withdraws a runtime's request that was settled somewhere else.
+ fn withdraw(&self, bot_id: &str, key: u64) {
+ self.lock()
+ .retain(|_, p| !(p.request.bot_id == bot_id && p.runtime_key == Some(key)));
+ }
+}
+
+/// Removes a prompt that is no longer waiting, whichever way it ended, and
+/// tells clients.
+struct Settle<'a> {
+ app: &'a AppState,
+ request: PermissionRequest,
+ outcome: Option,
+}
+
+impl Drop for Settle<'_> {
+ fn drop(&mut self) {
+ self.app.approvals.lock().remove(&self.request.id);
+ let outcome = self.outcome.unwrap_or(Outcome::Abandoned);
+ self.app.events.push(Push::PermissionResolved {
+ request_id: self.request.id.clone(),
+ bot_id: self.request.bot_id.clone(),
+ outcome,
+ });
+ if self.app.supervisor.state(&self.request.bot_id).0 == BotState::WaitingForApproval
+ && self
+ .app
+ .approvals
+ .list(Some(&self.request.bot_id))
+ .is_empty()
+ {
+ self.app.supervisor.set_state(
+ &self.request.bot_id,
+ BotState::Working,
+ "permission answered",
+ );
+ }
+ }
+}
+
+/// Shows the owner a card and waits for the answer, or for the window to
+/// close. `None` when no app can answer, or the prompt went away first: the
+/// runtime then asks in its own terminal.
+async fn decide(
+ app: &AppState,
+ bot_id: &str,
+ tool: &str,
+ input: &Value,
+ runtime_key: Option,
+) -> Option {
+ // No app that could answer is open: the prompt belongs in the terminal,
+ // exactly as it was before cards existed.
+ if app.approvals.answerers.load(Ordering::SeqCst) == 0 {
+ return None;
+ }
+ let window = Duration::from_secs(app.cfg.permission_timeout_seconds.clamp(1, MAX_WINDOW_SECS));
+ let created_at = Utc::now();
+ let request = PermissionRequest {
+ id: bus::new_id(),
+ bot_id: bot_id.to_string(),
+ tool: tool.to_string(),
+ summary: summary(tool, input),
+ input: crate::chat::truncate(
+ &serde_json::to_string_pretty(input).unwrap_or_default(),
+ MAX_INPUT_CHARS,
+ ),
+ created_at,
+ expires_at: created_at + chrono::Duration::from_std(window).ok()?,
+ };
+ let (tx, rx) = oneshot::channel();
+ app.approvals.lock().insert(
+ request.id.clone(),
+ Pending {
+ request: request.clone(),
+ runtime_key,
+ reply: tx,
+ },
+ );
+ let mut settle = Settle {
+ app,
+ request: request.clone(),
+ outcome: None,
+ };
+ app.supervisor
+ .set_state(bot_id, BotState::WaitingForApproval, &request.summary);
+ app.events.push(Push::PermissionRequest { request });
+
+ match tokio::time::timeout(window, rx).await {
+ Ok(Ok((answer, reason))) => {
+ settle.outcome = Some(match answer {
+ Answer::AllowOnce => Outcome::AllowedOnce,
+ Answer::AllowSession => Outcome::AllowedSession,
+ Answer::Deny => Outcome::Denied,
+ });
+ Some(Decision::Answered(answer, reason))
+ }
+ Ok(Err(_)) => None,
+ Err(_) => {
+ settle.outcome = Some(Outcome::Expired);
+ Some(Decision::Expired)
+ }
+ }
+}
+
+/// One line for the card: the command, the file, or the tool's own name.
+fn summary(tool: &str, input: &Value) -> String {
+ let field = |key: &str| input[key].as_str().filter(|v| !v.is_empty());
+ let detail = field("command")
+ .or_else(|| field("file_path"))
+ .or_else(|| field("url"))
+ .or_else(|| field("path"))
+ .or_else(|| field("description"))
+ .or_else(|| field("reason"));
+ match detail {
+ Some(detail) => format!(
+ "{tool}: {}",
+ crate::chat::truncate(detail.lines().next().unwrap_or(detail), 200)
+ ),
+ None => tool.to_string(),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+
+ #[test]
+ fn summarises_the_prompt() {
+ assert_eq!(
+ summary("Bash", &json!({"command": "rm -rf build\necho done"})),
+ "Bash: rm -rf build"
+ );
+ assert_eq!(
+ summary("Write", &json!({"file_path": "/w/a.txt"})),
+ "Write: /w/a.txt"
+ );
+ assert_eq!(summary("mcp__x__y", &json!({})), "mcp__x__y");
+ }
+}
diff --git a/crates/gravityd/src/approval/claude.rs b/crates/gravityd/src/approval/claude.rs
new file mode 100644
index 0000000..28b05b5
--- /dev/null
+++ b/crates/gravityd/src/approval/claude.rs
@@ -0,0 +1,110 @@
+//! Claude Code's side of permission prompts: the `PermissionRequest` hook
+//! posts the prompt and waits for the decision it prints back.
+
+use std::sync::Arc;
+
+use axum::extract::State;
+use axum::http::{HeaderMap, StatusCode};
+use axum::response::IntoResponse;
+use axum::Json;
+use serde_json::{json, Value};
+
+use crate::app::AppState;
+
+use super::{decide, Answer, Decision};
+
+/// POST /hook/permission — answers with the hook output that decides the
+/// prompt, or an empty body to leave the prompt to the terminal.
+pub async fn permission_hook(
+ State(app): State>,
+ headers: HeaderMap,
+ Json(body): Json,
+) -> impl IntoResponse {
+ let bot = headers
+ .get("authorization")
+ .and_then(|v| v.to_str().ok())
+ .and_then(|s| s.strip_prefix("Bearer "))
+ .and_then(|token| app.secrets.bot_for_token(token));
+ let Some(bot_id) = bot else {
+ return (StatusCode::UNAUTHORIZED, Json(Value::Null)).into_response();
+ };
+ let tool = body["tool_name"].as_str().unwrap_or("a tool").to_string();
+ let output = match decide(&app, &bot_id, &tool, &body["tool_input"], None).await {
+ Some(Decision::Answered(Answer::AllowOnce, _)) => allow(None),
+ Some(Decision::Answered(Answer::AllowSession, _)) => {
+ allow(Some(session_rules(&tool, &body)))
+ }
+ Some(Decision::Answered(Answer::Deny, reason)) => {
+ deny(reason.as_deref().unwrap_or("The owner denied this."))
+ }
+ Some(Decision::Expired) => deny("The owner did not answer this permission prompt in time."),
+ None => return StatusCode::OK.into_response(),
+ };
+ (StatusCode::OK, Json(output)).into_response()
+}
+
+fn allow(updated_permissions: Option>) -> Value {
+ let mut decision = json!({ "behavior": "allow" });
+ if let Some(rules) = updated_permissions.filter(|r| !r.is_empty()) {
+ decision["updatedPermissions"] = json!(rules);
+ }
+ json!({ "hookSpecificOutput": { "hookEventName": "PermissionRequest", "decision": decision } })
+}
+
+fn deny(message: &str) -> Value {
+ json!({
+ "hookSpecificOutput": {
+ "hookEventName": "PermissionRequest",
+ "decision": { "behavior": "deny", "message": message }
+ }
+ })
+}
+
+/// The rules "allow for this session" adds: Claude Code's own suggestions,
+/// confined to the session, or a rule for the tool when it suggested none.
+/// Mode changes and anything persistent are dropped: nothing this answer
+/// grants outlives the session.
+fn session_rules(tool: &str, body: &Value) -> Vec {
+ let suggested: Vec = body["permission_suggestions"]
+ .as_array()
+ .into_iter()
+ .flatten()
+ .filter(|s| matches!(s["type"].as_str(), Some("addRules" | "addDirectories")))
+ .map(|s| {
+ let mut s = s.clone();
+ s["destination"] = json!("session");
+ s
+ })
+ .collect();
+ if !suggested.is_empty() {
+ return suggested;
+ }
+ vec![json!({
+ "type": "addRules",
+ "rules": [{ "toolName": tool }],
+ "behavior": "allow",
+ "destination": "session"
+ })]
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn session_rules_never_persist() {
+ let body = json!({ "permission_suggestions": [
+ {"type": "addRules", "rules": [{"toolName": "Bash", "ruleContent": "npm test"}],
+ "behavior": "allow", "destination": "localSettings"},
+ {"type": "setMode", "mode": "acceptEdits", "destination": "session"}
+ ]});
+ let rules = session_rules("Bash", &body);
+ assert_eq!(rules.len(), 1);
+ assert_eq!(rules[0]["destination"], "session");
+ assert_eq!(rules[0]["rules"][0]["ruleContent"], "npm test");
+
+ let fallback = session_rules("WebFetch", &json!({}));
+ assert_eq!(fallback[0]["rules"][0]["toolName"], "WebFetch");
+ assert_eq!(fallback[0]["destination"], "session");
+ }
+}
diff --git a/crates/gravityd/src/approval/runtime.rs b/crates/gravityd/src/approval/runtime.rs
new file mode 100644
index 0000000..d696bf2
--- /dev/null
+++ b/crates/gravityd/src/approval/runtime.rs
@@ -0,0 +1,58 @@
+//! Runtimes that raise permission requests themselves (Codex App Server
+//! approvals): the supervisor relays them as internal events, and the answer
+//! goes back through the bot's session.
+
+use std::sync::Arc;
+
+use tokio::sync::broadcast::error::RecvError;
+
+use crate::app::AppState;
+use crate::events::Internal;
+use crate::runtime::PermissionAnswer;
+
+use super::{decide, Answer, Decision};
+
+/// Answers runtime permission requests for as long as the daemon runs.
+pub async fn watch(app: Arc) {
+ let mut internal = app.events.subscribe_internal();
+ loop {
+ match internal.recv().await {
+ Ok(Internal::RuntimePermission {
+ bot_id,
+ key,
+ tool,
+ input,
+ }) => {
+ let app = app.clone();
+ tokio::spawn(async move {
+ let answer = match decide(&app, &bot_id, &tool, &input, Some(key)).await {
+ Some(Decision::Answered(Answer::AllowOnce, _)) => PermissionAnswer::Once,
+ Some(Decision::Answered(Answer::AllowSession, _)) => {
+ PermissionAnswer::Session
+ }
+ Some(Decision::Answered(Answer::Deny, _) | Decision::Expired) => {
+ PermissionAnswer::Deny
+ }
+ // No app could answer, or the runtime settled it: its
+ // own terminal has the prompt.
+ None => return,
+ };
+ if let Err(e) = app
+ .supervisor
+ .answer_runtime_permission(&bot_id, key, answer)
+ {
+ tracing::warn!(bot_id, error = %e, "could not hand the permission answer back");
+ }
+ });
+ }
+ Ok(Internal::RuntimePermissionGone { bot_id, key }) => {
+ app.approvals.withdraw(&bot_id, key);
+ }
+ Ok(_) => {}
+ Err(RecvError::Lagged(skipped)) => {
+ tracing::warn!(skipped, "permission watcher lagged");
+ }
+ Err(RecvError::Closed) => break,
+ }
+ }
+}
diff --git a/crates/gravityd/src/backup.rs b/crates/gravityd/src/backup.rs
index 6ac7a73..35db544 100644
--- a/crates/gravityd/src/backup.rs
+++ b/crates/gravityd/src/backup.rs
@@ -195,6 +195,8 @@ mod tests {
// Existing database blocks restore without --overwrite.
assert!(restore(&cfg2, &out, false).is_err());
+ // Windows refuses to rename a database while SQLite holds it open.
+ drop(db2);
restore(&cfg2, &out, true).unwrap();
assert!(std::fs::read_dir(home2.path())
.unwrap()
diff --git a/crates/gravityd/src/botmgmt.rs b/crates/gravityd/src/botmgmt.rs
index 01b23ca..97c8e25 100644
--- a/crates/gravityd/src/botmgmt.rs
+++ b/crates/gravityd/src/botmgmt.rs
@@ -20,10 +20,14 @@ mod archive;
mod charter;
mod create;
mod revert;
+mod runtime;
pub use archive::{archive_bot, prune_archived_workspaces};
-pub use create::{create_bot, Created};
+pub use create::{create_bot, create_bot_with_runtime, Created};
pub use revert::revert_revision;
+pub use runtime::{
+ check_runtime_available, requested_runtime, set_bot_runtime, RuntimeUnavailable,
+};
/// Fields that may change on a bot. `None` leaves the stored value alone,
/// which is what lets `update_self(avatar: "…")` avoid touching instructions.
@@ -90,8 +94,20 @@ pub(super) fn provision_spec<'a>(
artifacts_dir: paths::artifacts_dir(&app.cfg, &project.dir_name)
.display()
.to_string(),
+ linked_machines: linked_machines(app, &project.id),
}
}
+
+/// The peers a project is linked through, by name, for the system prompt.
+fn linked_machines(app: &AppState, project_id: &str) -> Vec {
+ app.db
+ .project_links(project_id)
+ .unwrap_or_default()
+ .iter()
+ .filter_map(|link| app.db.get_peer(&link.peer_id).ok().flatten())
+ .map(|peer| peer.name)
+ .collect()
+}
pub(super) fn parse_avatar(raw: &str) -> anyhow::Result {
bus::avatar::parse(raw)
.map(|a| a.as_stored())
@@ -181,6 +197,10 @@ pub fn apply_identity_edit(
/// Rewrite the daemon-owned files for a bot after its identity changed.
pub fn reprovision(app: &Arc, bot: &Bot) -> anyhow::Result<()> {
+ // A linked bot's files live on its peer; there is nothing to write here.
+ if bot.is_linked() {
+ return Ok(());
+ }
let project = app
.db
.get_project(&bot.project_id)?
@@ -195,7 +215,12 @@ pub fn reprovision(app: &Arc, bot: &Bot) -> anyhow::Result<()> {
/// `CLAUDE.md` pick up the new layout. The write is content-hash guarded, so
/// this is a no-op once each bot is current.
pub fn regenerate_all_system_md(app: &Arc) -> anyhow::Result<()> {
- for bot in app.db.list_bots(None)? {
+ for bot in app
+ .db
+ .list_bots(None)?
+ .into_iter()
+ .filter(|b| !b.is_linked())
+ {
if let Err(e) = reprovision(app, &bot) {
tracing::warn!(bot_id = %bot.id, error = %e, "system.md regeneration failed");
}
diff --git a/crates/gravityd/src/botmgmt/create.rs b/crates/gravityd/src/botmgmt/create.rs
index 25a1c1f..8584a50 100644
--- a/crates/gravityd/src/botmgmt/create.rs
+++ b/crates/gravityd/src/botmgmt/create.rs
@@ -3,7 +3,7 @@
use std::sync::Arc;
use anyhow::bail;
-use bus::{Bot, MessageKind, RevisionField};
+use bus::{Bot, BotRuntime, MessageKind, RevisionField};
use rand::seq::SliceRandom;
use crate::app::AppState;
@@ -50,6 +50,26 @@ pub fn create_bot(
edit: &IdentityEdit<'_>,
creator: Option<&Bot>,
actor: &Actor<'_>,
+) -> anyhow::Result {
+ create_bot_with_runtime(
+ app,
+ project_id,
+ edit,
+ creator,
+ actor,
+ creator
+ .map(|b| b.runtime)
+ .unwrap_or(app.cfg.default_bot_runtime),
+ )
+}
+
+pub fn create_bot_with_runtime(
+ app: &Arc,
+ project_id: &str,
+ edit: &IdentityEdit<'_>,
+ creator: Option<&Bot>,
+ actor: &Actor<'_>,
+ runtime: BotRuntime,
) -> anyhow::Result {
let raw_name = edit.name.unwrap_or_default();
let name = validate_name(app, project_id, raw_name, None)?;
@@ -89,7 +109,7 @@ pub fn create_bot(
let description = charter::description(edit.description);
let instructions = charter::instructions(edit.instructions, creator.map(|c| c.name.as_str()));
- let bot = app.db.create_bot(
+ let bot = app.db.create_bot_with_runtime(
project_id,
&name,
&description,
@@ -98,13 +118,14 @@ pub fn create_bot(
&workspace.display().to_string(),
&dir_name,
creator.map(|c| c.id.as_str()),
+ runtime,
)?;
// Issuing the token before provisioning means the runtime can authenticate
// as soon as the files land.
app.secrets.bot_token(&bot.id)?;
let dirs = paths::provision_bot(&app.cfg, &provision_spec(app, &project, &bot))?;
- if app.cfg.runtime == RuntimeKind::Pty {
+ if app.cfg.runtime == RuntimeKind::Pty && bot.runtime == BotRuntime::ClaudeCode {
if let Err(error) = paths::trust_workspace(&app.cfg.user_home, &dirs.workspace) {
tracing::warn!(bot_id = %bot.id, %error, "could not trust new bot workspace");
}
diff --git a/crates/gravityd/src/botmgmt/runtime.rs b/crates/gravityd/src/botmgmt/runtime.rs
new file mode 100644
index 0000000..5ac3e91
--- /dev/null
+++ b/crates/gravityd/src/botmgmt/runtime.rs
@@ -0,0 +1,46 @@
+//! Runtime selection shared by the control client and bot-authenticated MCP.
+
+use std::sync::Arc;
+
+use bus::{Bot, BotRuntime};
+use serde_json::Value;
+
+use crate::app::AppState;
+use crate::events::Push;
+
+#[derive(Debug, thiserror::Error)]
+#[error("{0}")]
+pub struct RuntimeUnavailable(String);
+
+pub fn requested_runtime(args: &Value) -> anyhow::Result