diff --git a/apps/desktop/src/components/sidebar/ConversationsRow.tsx b/apps/desktop/src/components/sidebar/ConversationsRow.tsx
new file mode 100644
index 0000000..9c16dc3
--- /dev/null
+++ b/apps/desktop/src/components/sidebar/ConversationsRow.tsx
@@ -0,0 +1,33 @@
+import type { ReactElement } from "react";
+
+interface ConversationsRowProps {
+ readonly selected: boolean;
+ readonly onSelect: () => void;
+}
+
+function TalkIcon(): ReactElement {
+ return (
+
+ );
+}
+
+/** Opens what the project's bots have said to each other. */
+export default function ConversationsRow({
+ selected,
+ onSelect,
+}: ConversationsRowProps): ReactElement {
+ return (
+
+ );
+}
diff --git a/apps/desktop/src/components/sidebar/ProjectSection.tsx b/apps/desktop/src/components/sidebar/ProjectSection.tsx
index 72957db..e122feb 100644
--- a/apps/desktop/src/components/sidebar/ProjectSection.tsx
+++ b/apps/desktop/src/components/sidebar/ProjectSection.tsx
@@ -2,6 +2,7 @@ import { useState } from "react";
import type { ReactElement } from "react";
import type { Bot, Project } from "../../protocol/entities";
import BotRow from "./BotRow";
+import ConversationsRow from "./ConversationsRow";
import { MoreIcon } from "./icons";
import PinnedBots from "./PinnedBots";
import type { SidebarTreeProps } from "./tree";
@@ -73,6 +74,14 @@ export default function ProjectSection(props: ProjectSectionProps): ReactElement
{collapsed ? null : (
<>
+ {props.showConversations === true ? (
+
{
+ onSelect({ kind: "conversations", projectId: project.id });
+ }}
+ />
+ ) : null}
Promise;
readonly onDeleteProject: (projectId: string) => Promise;
readonly onTogglePin: (botId: string) => void;
+ /** Shows each project's Conversations row; needs a daemon that serves them. */
+ readonly showConversations?: boolean;
}
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/hooks/useOnBotTurns.ts b/apps/desktop/src/hooks/useOnBotTurns.ts
new file mode 100644
index 0000000..23d900e
--- /dev/null
+++ b/apps/desktop/src/hooks/useOnBotTurns.ts
@@ -0,0 +1,28 @@
+import { useEffect } from "react";
+import type { DaemonApi } from "../protocol/api";
+
+/**
+ * Calls `refresh` once a burst of `chat_turns` pushes for this bot settles:
+ * the bot's turns changed, so whatever is read from its transcript did too.
+ */
+export function useOnBotTurns(
+ client: DaemonApi,
+ botId: string,
+ refresh: () => Promise,
+ delayMs: number,
+): void {
+ useEffect(() => {
+ let timer: number | undefined;
+ const off = client.on("chat_turns", (push) => {
+ if (push.bot_id !== botId) {
+ return;
+ }
+ window.clearTimeout(timer);
+ timer = window.setTimeout(() => void refresh(), delayMs);
+ });
+ return () => {
+ window.clearTimeout(timer);
+ off();
+ };
+ }, [client, botId, refresh, delayMs]);
+}
diff --git a/apps/desktop/src/protocol/agents.ts b/apps/desktop/src/protocol/agents.ts
new file mode 100644
index 0000000..bd55f7c
--- /dev/null
+++ b/apps/desktop/src/protocol/agents.ts
@@ -0,0 +1,103 @@
+// A bot's own browser (`watch_browser`, `list_browser_activity`) and the
+// conversations between a project's bots (`list_agent_conversations`).
+
+import type { StepStatus, Trigger } from "./chat";
+
+/** A page open in a bot's browser. */
+interface BrowserTab {
+ readonly id: string;
+ readonly title: string;
+ readonly url: string;
+}
+
+/** Pushed when a watched bot's tabs change. */
+export interface BrowserTabsPush {
+ readonly type: "browser_tabs";
+ readonly bot_id: string;
+ /** False while the bot has no browser running. */
+ readonly open: boolean;
+ readonly tabs: readonly BrowserTab[];
+ /** The tab on show. */
+ readonly active: string | null;
+ /** True while the view follows the tab the bot used last. */
+ readonly following?: boolean;
+ /** Why there is nothing to show, when it is not just a closed browser. */
+ readonly reason?: string;
+}
+
+/** One screen of the tab on show, as a base64 JPEG. */
+export interface BrowserFramePush {
+ readonly type: "browser_frame";
+ readonly bot_id: string;
+ readonly tab_id: string;
+ readonly data: string;
+ readonly width: number;
+ readonly height: number;
+}
+
+/** Whose browser a step drove. */
+type BrowserKind = "own" | "owners_chrome";
+
+/** One browser action, in the turn that made it. */
+export interface BrowserAction {
+ readonly turn_id: string;
+ readonly step_id: string;
+ readonly at: string;
+ readonly browser: BrowserKind;
+ readonly title: string;
+ readonly subtitle?: string | null;
+ readonly status: StepStatus;
+ /** What started the turn: who asked, and what. */
+ readonly trigger: Trigger;
+}
+
+/** A bot as a conversation names it; archived bots stay named. */
+export interface AgentBot {
+ readonly id: string;
+ readonly name: string;
+ readonly avatar: string;
+ /** The peer it runs on, for a linked bot. */
+ readonly machine?: string | null;
+ readonly deleted: boolean;
+}
+
+/** A message from one bot to another. */
+export interface AgentMessage {
+ readonly id: string;
+ readonly num: number;
+ readonly from_bot_id: string;
+ readonly to_bot_id: string;
+ readonly kind: "task" | "reply" | "done" | "note" | "chat";
+ readonly body: string;
+ readonly ref_message_id?: string | null;
+ /** Set on a task: the task it opened, and where it stands. */
+ readonly task?: { readonly id: string; readonly state: string } | null;
+ readonly created_at: string;
+}
+
+/** Two bots that have talked. */
+export interface AgentConversation {
+ readonly bot_ids: readonly [string, string];
+ readonly message_count: number;
+ readonly last_at: string;
+ /** The newest message, its body cut to a preview. */
+ readonly last: AgentMessage | null;
+}
+
+/** A command a bot ran, or is running (`list_bot_commands`). */
+export interface BotCommand {
+ /** The tool call's id. */
+ readonly id: string;
+ readonly command: string;
+ readonly description?: string;
+ /** Run in the background: it outlives the turn that started it. */
+ readonly background: boolean;
+ /** `stopped`: by the bot, or by its session ending under it. */
+ readonly status: "running" | "done" | "failed" | "stopped";
+ readonly started_at: string;
+ readonly ended_at?: string;
+ readonly exit_code?: number;
+ readonly task_id?: string;
+ /** The end of its output: a running background command's, live. */
+ readonly output?: string;
+}
diff --git a/apps/desktop/src/protocol/chat.ts b/apps/desktop/src/protocol/chat.ts
new file mode 100644
index 0000000..dd2c1e8
--- /dev/null
+++ b/apps/desktop/src/protocol/chat.ts
@@ -0,0 +1,168 @@
+// 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 };
+
+export 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;
+ /** Who made the file, when the daemon can tell. */
+ readonly created_by?: FileCreator;
+}
+
+/** The bot (or the owner) a file came from, and how. */
+export interface FileCreator {
+ /** Absent for the owner. */
+ readonly bot_id?: string;
+ readonly name: string;
+ readonly avatar: string;
+ /** The machine a linked bot runs on. */
+ readonly machine?: string;
+ readonly via: "wrote" | "edited" | "command" | "upload" | "sent";
+}
+
+/** 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 41baf70..3a52f0b 100644
--- a/apps/desktop/src/protocol/entities.ts
+++ b/apps/desktop/src/protocol/entities.ts
@@ -14,9 +14,17 @@ export interface Project {
* goes stale the moment a teammate asks directly.
*/
readonly lead_bot_id?: string | null;
+ /** The shared git repository workers check out and push to; absent on older daemons. */
+ readonly repo?: ProjectRepo | null;
readonly created_at: string;
}
+/** A project's shared git repository. */
+export interface ProjectRepo {
+ readonly url: string;
+ readonly branch: string;
+}
+
export type BotState =
| "starting"
| "ready"
@@ -52,9 +60,22 @@ 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;
+ /** May drive the owner's own Chrome; absent on older daemons. */
+ readonly user_chrome?: boolean;
+ /** A temporary worker, archived once its one task closes. */
+ readonly temporary?: boolean;
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..0cdeb13 100644
--- a/apps/desktop/src/protocol/messages.ts
+++ b/apps/desktop/src/protocol/messages.ts
@@ -1,6 +1,25 @@
// 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 {
+ AgentBot,
+ AgentConversation,
+ AgentMessage,
+ BotCommand,
+ BrowserAction,
+ BrowserFramePush,
+ BrowserTabsPush,
+} from "./agents";
+import type { BotTask } from "./tasks";
+import type { WorkerView } from "./workers";
import type {
Bot,
BotActivity,
@@ -139,7 +158,80 @@ 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: "workers";
+ readonly project_id: string;
+ /** Queued and running oldest first, then recently finished. */
+ readonly workers: readonly WorkerView[];
+ readonly running_here: number;
+ readonly max_workers_here: number;
+ })
+ | (ReplyBase & { readonly type: "worker"; readonly worker: WorkerView })
+ | (ReplyBase & {
+ readonly type: "tasks";
+ readonly bot_id: string;
+ /** Newest first. */
+ readonly tasks: readonly BotTask[];
+ })
+ | (ReplyBase & {
+ readonly type: "browser_activity";
+ readonly bot_id: string;
+ /** Newest first. */
+ readonly activity: readonly BrowserAction[];
+ })
+ | (ReplyBase & {
+ readonly type: "bot_commands";
+ readonly bot_id: string;
+ /** Running first, then newest first. */
+ readonly commands: readonly BotCommand[];
+ })
+ | (ReplyBase & {
+ readonly type: "agent_conversations";
+ readonly project_id: string;
+ /** Most recently active first. */
+ readonly conversations: readonly AgentConversation[];
+ readonly bots: readonly AgentBot[];
+ })
+ | (ReplyBase & {
+ readonly type: "agent_conversation";
+ readonly project_id: string;
+ readonly bot_ids: readonly [string, string];
+ /** Oldest first. */
+ readonly messages: readonly AgentMessage[];
+ readonly has_more: boolean;
+ readonly bots: readonly AgentBot[];
+ })
+ | (ReplyBase & {
+ readonly type: "artifacts";
+ readonly project_id: string;
+ readonly artifacts: readonly Artifact[];
+ });
export type ServerReplyType = ServerReply["type"];
@@ -160,6 +252,7 @@ export type ServerPush =
| { readonly type: "message_new"; readonly message: BusMessage }
| { readonly type: "bot_updated"; readonly bot: Bot }
| { readonly type: "project_updated"; readonly project: Project }
+ | { readonly type: "workers_updated"; readonly project_id: string }
| { readonly type: "activity_update"; readonly activity: BotActivity }
| { readonly type: "delivery_update"; readonly delivery: Delivery }
| { readonly type: "routine_run_update"; readonly routine_run: RoutineRun }
@@ -178,7 +271,22 @@ 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;
+ }
+ | BrowserTabsPush
+ | BrowserFramePush
+ | {
+ 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..9e86180 100644
--- a/apps/desktop/src/protocol/push.ts
+++ b/apps/desktop/src/protocol/push.ts
@@ -12,6 +12,7 @@ export function emptyHandlers(): PushHandlerSets {
message_new: new Set(),
bot_updated: new Set(),
project_updated: new Set(),
+ workers_updated: new Set(),
activity_update: new Set(),
delivery_update: new Set(),
routine_run_update: new Set(),
@@ -20,6 +21,11 @@ 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(),
+ browser_tabs: new Set(),
+ browser_frame: new Set(),
};
}
@@ -69,6 +75,9 @@ export function dispatchPush(handlers: PushHandlerSets, push: ServerPush): void
case "project_updated":
emitPush(handlers, "project_updated", push);
break;
+ case "workers_updated":
+ emitPush(handlers, "workers_updated", push);
+ break;
case "notify":
emitPush(handlers, "notify", push);
break;
@@ -81,6 +90,21 @@ 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;
+ case "browser_tabs":
+ emitPush(handlers, "browser_tabs", push);
+ break;
+ case "browser_frame":
+ emitPush(handlers, "browser_frame", push);
+ break;
default:
push satisfies never;
}
diff --git a/apps/desktop/src/protocol/requests.ts b/apps/desktop/src/protocol/requests.ts
index 288e7e3..6d779c7 100644
--- a/apps/desktop/src/protocol/requests.ts
+++ b/apps/desktop/src/protocol/requests.ts
@@ -1,5 +1,6 @@
// Client → server frames (protocol v2).
+import type { PermissionAnswer } from "./chat";
import type { DecisionRequestBody } from "./decisionRequests";
import type {
BotRuntime,
@@ -16,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 }
@@ -24,7 +27,17 @@ export type ClientRequestBody =
readonly project_id: string;
readonly name: string;
}
+ | {
+ readonly type: "set_project_repo";
+ readonly project_id: string;
+ /** `null` clears the repository. */
+ readonly url: string | null;
+ /** Defaults to `main`. */
+ readonly branch?: string;
+ }
| { readonly type: "delete_project"; readonly project_id: string }
+ | { readonly type: "list_workers"; readonly project_id: string }
+ | { readonly type: "cancel_worker"; readonly worker_id: string; readonly reason?: string }
| { readonly type: "list_bots"; readonly project_id?: string }
| {
readonly type: "create_bot";
@@ -83,7 +96,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 }
@@ -141,7 +155,63 @@ 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: "set_bot_user_chrome"; readonly bot_id: string; readonly enabled: boolean }
+ /** Restarts the session; it picks its conversation back up and is told what it left unfinished. */
+ | { readonly type: "restart_bot"; readonly bot_id: string }
+ /** Restarts with a fresh conversation; work, memory and tasks are kept. */
+ | { readonly type: "clear_bot_session"; readonly bot_id: string }
+ /** Streams `browser_tabs` and `browser_frame`; `tab_id` pins a tab, else it follows the bot. */
+ | { readonly type: "watch_browser"; readonly bot_id: string; readonly tab_id?: string }
+ | { readonly type: "unwatch_browser" }
+ | { readonly type: "list_browser_activity"; readonly bot_id: string; readonly limit?: number }
+ | { readonly type: "list_bot_commands"; readonly bot_id: string; readonly limit?: number }
+ | { readonly type: "list_agent_conversations"; readonly project_id: string }
+ | {
+ readonly type: "list_agent_conversation";
+ readonly project_id: string;
+ readonly bot_ids: readonly [string, string];
+ /** A message `num`: the page ends just before it. */
+ readonly before?: number;
+ readonly limit?: number;
+ }
+ | {
+ 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..9ae6fe4 100644
--- a/apps/desktop/src/protocol/wire.ts
+++ b/apps/desktop/src/protocol/wire.ts
@@ -21,6 +21,7 @@ const PUSH_TYPES: Readonly> = {
message_new: true,
bot_updated: true,
project_updated: true,
+ workers_updated: true,
activity_update: true,
delivery_update: true,
routine_run_update: true,
@@ -29,6 +30,11 @@ const PUSH_TYPES: Readonly> = {
decision_update: true,
decision_deleted: true,
decision_comment_new: true,
+ chat_turns: true,
+ permission_request: true,
+ permission_resolved: true,
+ browser_tabs: true,
+ browser_frame: true,
};
const REPLY_TYPES: ReadonlySet = new Set(
@@ -64,6 +70,21 @@ 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,
+ workers: true,
+ worker: true,
+ upload: true,
+ task: true,
+ browser_activity: true,
+ bot_commands: true,
+ agent_conversations: true,
+ agent_conversation: true,
} satisfies Record),
);
diff --git a/apps/desktop/src/protocol/workers.ts b/apps/desktop/src/protocol/workers.ts
new file mode 100644
index 0000000..ccbda51
--- /dev/null
+++ b/apps/desktop/src/protocol/workers.ts
@@ -0,0 +1,25 @@
+// A project's temporary workers as the Workers panel lists them (`list_workers`).
+
+type WorkerState = "queued" | "running" | "done" | "cancelled" | "expired" | "failed";
+
+export interface WorkerView {
+ readonly id: string;
+ readonly project_id: string;
+ readonly name: string;
+ readonly state: WorkerState;
+ /** 1-based place in the project's queue, while queued. */
+ readonly queue_position?: number;
+ /** `here`, or the linked machine it runs on or is pinned to. */
+ readonly machine?: string | null;
+ readonly parent_bot_id: string;
+ readonly parent_name?: string | null;
+ /** The opening of the task it was given. */
+ readonly brief: string;
+ readonly task_id?: string;
+ /** Why it waits, or why it failed or was cancelled. */
+ readonly note?: string;
+ readonly bot_id?: string | null;
+ readonly created_at: string;
+ readonly started_at?: string | null;
+ readonly finished_at?: string | null;
+}
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css
index d1ac6fb..16c609b 100644
--- a/apps/desktop/src/styles.css
+++ b/apps/desktop/src/styles.css
@@ -14,6 +14,16 @@
@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/agent-browser.css";
+@import "./styles/conversations.css";
+@import "./styles/commands.css";
@import "./styles/overlays.css";
@import "./styles/settings.css";
@import "./styles/devices.css";
diff --git a/apps/desktop/src/styles/agent-browser.css b/apps/desktop/src/styles/agent-browser.css
new file mode 100644
index 0000000..cf0ee0b
--- /dev/null
+++ b/apps/desktop/src/styles/agent-browser.css
@@ -0,0 +1,255 @@
+/* A bot's own browser (live screen, tabs, activity) and its Memory tab. */
+
+.browser-pane {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ height: 100%;
+}
+
+.browser-main {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-width: 0;
+ padding: 10px 14px;
+ gap: 8px;
+}
+
+.browser-note {
+ font-size: 12px;
+}
+
+.browser-tabs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+}
+
+.browser-tab {
+ max-width: 220px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ padding: 4px 10px;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ background: var(--bg-raised);
+ color: var(--text-dim);
+ font-size: 12px;
+ cursor: pointer;
+}
+
+.browser-tab:hover {
+ background: var(--bg-hover);
+}
+
+.browser-tab-active {
+ color: var(--text-strong);
+ border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
+ background: color-mix(in srgb, var(--accent) 14%, var(--bg-raised));
+}
+
+.browser-follow {
+ margin-left: auto;
+ padding: 4px 10px;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ background: none;
+ color: var(--text-dim);
+ font-size: 12px;
+ cursor: pointer;
+}
+
+.browser-follow-on {
+ color: var(--accent);
+ border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
+}
+
+.browser-screen {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ overflow: hidden;
+ background: var(--bg-raised);
+}
+
+.browser-url {
+ padding: 6px 10px;
+ border-bottom: 1px solid var(--border);
+ font-family: var(--mono);
+ font-size: 11px;
+ color: var(--text-dim);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* The page keeps its own shape: scaled down to fit, never cropped. */
+.browser-frame {
+ flex: 1;
+ min-height: 0;
+ width: 100%;
+ height: auto;
+ object-fit: contain;
+ object-position: top center;
+}
+
+.browser-empty {
+ display: flex;
+ flex: 1;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ text-align: center;
+}
+
+.browser-activity {
+ width: 300px;
+ flex-shrink: 0;
+ border-left: 1px solid var(--border);
+ overflow-y: auto;
+ padding: 8px 0;
+}
+
+.browser-activity-empty {
+ padding: 6px 14px;
+}
+
+.browser-activity-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.browser-turn {
+ padding: 8px 14px;
+ border-bottom: 1px solid var(--border);
+}
+
+.browser-turn-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ font-size: 12px;
+}
+
+.browser-turn-why {
+ color: var(--text-strong);
+ font-weight: 600;
+}
+
+.browser-turn-ask {
+ margin-top: 2px;
+ font-size: 12px;
+ color: var(--text-dim);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.browser-actions {
+ list-style: none;
+ margin: 6px 0 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.browser-action {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 2px 6px;
+ font-size: 12px;
+ padding-left: 12px;
+ position: relative;
+}
+
+.browser-action::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 6px;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--green);
+}
+
+.browser-action.step-running::before {
+ background: var(--blue);
+}
+
+.browser-action.step-error::before {
+ background: var(--red);
+}
+
+.browser-action-detail {
+ flex-basis: 100%;
+ color: var(--text-faint);
+ font-family: var(--mono);
+ font-size: 11px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.browser-badge {
+ padding: 0 6px;
+ border-radius: 8px;
+ font-size: 10px;
+ color: var(--amber);
+ border: 1px solid color-mix(in srgb, var(--amber) 45%, transparent);
+}
+
+.memory-panel {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.memory-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 14px;
+ border-bottom: 1px solid var(--border);
+}
+
+.memory-file {
+ font-family: var(--mono);
+ font-size: 12px;
+ color: var(--text-dim);
+}
+
+.memory-body {
+ padding: 10px 14px;
+ line-height: 1.55;
+ overflow-wrap: anywhere;
+}
+
+.memory-note {
+ padding: 10px 14px;
+}
+
+.bot-chrome {
+ margin-top: 12px;
+}
+
+/* The Browser tab's live dot: the bot's browser is open and streaming. */
+.tab-live {
+ display: inline-block;
+ width: 6px;
+ height: 6px;
+ margin-left: 6px;
+ border-radius: 50%;
+ background: var(--red);
+ vertical-align: middle;
+}
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/commands.css b/apps/desktop/src/styles/commands.css
new file mode 100644
index 0000000..ee1b53e
--- /dev/null
+++ b/apps/desktop/src/styles/commands.css
@@ -0,0 +1,150 @@
+/* The Commands panel: what a bot is running and has run. */
+
+.command-row {
+ padding: 6px 14px 8px;
+ border-bottom: 1px solid var(--border);
+}
+
+.command-head {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ width: 100%;
+ padding: 0;
+ border: none;
+ background: none;
+ color: var(--text-strong);
+ font-size: 13px;
+ text-align: left;
+ cursor: pointer;
+}
+
+.command-dot {
+ width: 7px;
+ height: 7px;
+ flex-shrink: 0;
+ border-radius: 50%;
+ background: var(--gray);
+}
+
+.command-running .command-dot {
+ background: var(--blue);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--blue) 25%, transparent);
+}
+
+.command-done .command-dot {
+ background: var(--green);
+}
+
+.command-failed .command-dot {
+ background: var(--red);
+}
+
+.command-title {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.command-badge {
+ flex-shrink: 0;
+ padding: 0 6px;
+ border-radius: 8px;
+ font-size: 10px;
+ color: var(--accent);
+ border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
+}
+
+.command-line {
+ display: block;
+ margin: 4px 0 2px 13px;
+ font-family: var(--mono);
+ font-size: 11px;
+ color: var(--text-dim);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.command-row .task-when {
+ margin-left: 13px;
+ font-size: 11px;
+}
+
+.command-output {
+ margin: 6px 0 0 13px;
+ padding: 8px 10px;
+ max-height: 320px;
+ overflow: auto;
+ border-radius: 6px;
+ background: var(--bg-raised);
+ border: 1px solid var(--border);
+ font-family: var(--mono);
+ font-size: 11px;
+ line-height: 1.45;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+.task-badge-failed {
+ color: var(--red);
+ border-color: color-mix(in srgb, var(--red) 40%, var(--border));
+}
+
+.task-badge-stopped {
+ color: var(--amber);
+ border-color: color-mix(in srgb, var(--amber) 40%, var(--border));
+}
+
+.command-line-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin: 4px 0 2px 13px;
+}
+
+.command-line-row .command-line {
+ flex: 1;
+ min-width: 0;
+ margin: 0;
+}
+
+.command-block-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin: 8px 0 0 13px;
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text-dim);
+}
+
+.command-block-head + .command-output {
+ margin-top: 4px;
+}
+
+.command-full {
+ max-height: 160px;
+}
+
+.copy-button {
+ flex-shrink: 0;
+ padding: 1px 8px;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ background: var(--bg-raised);
+ color: var(--text-dim);
+ font-size: 11px;
+ text-transform: none;
+ letter-spacing: normal;
+ cursor: pointer;
+}
+
+.copy-button:hover {
+ color: var(--text-strong);
+ background: var(--bg-hover);
+}
diff --git a/apps/desktop/src/styles/conversations.css b/apps/desktop/src/styles/conversations.css
new file mode 100644
index 0000000..16d9f48
--- /dev/null
+++ b/apps/desktop/src/styles/conversations.css
@@ -0,0 +1,238 @@
+/* Conversations between a project's bots: the pairs, and each pair's chat. */
+
+.conversations-row {
+ color: var(--text-dim);
+ font-size: 13px;
+}
+
+.conversations-row .icon {
+ width: 14px;
+ height: 14px;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 1.3;
+ stroke-linejoin: round;
+}
+
+.conversations-view {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ height: 100%;
+}
+
+.conv-list {
+ width: 320px;
+ flex-shrink: 0;
+ border-right: 1px solid var(--border);
+ overflow-y: auto;
+}
+
+.conv-list-title {
+ padding: 14px 14px 8px;
+ font-size: 15px;
+}
+
+.conv-pairs {
+ list-style: none;
+ margin: 0;
+ padding: 0 6px 12px;
+}
+
+.conv-pair {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ padding: 8px;
+ border: none;
+ border-radius: 8px;
+ background: none;
+ color: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+
+.conv-pair:hover {
+ background: var(--bg-hover);
+}
+
+.conv-pair-selected,
+.conv-pair-selected:hover {
+ background: var(--bg-active);
+}
+
+/* Two faces side by side, just touching. */
+.conv-faces {
+ display: flex;
+ flex-shrink: 0;
+ gap: 2px;
+}
+
+.conv-pair-text {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-width: 0;
+ gap: 2px;
+}
+
+.conv-pair-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.conv-pair-names {
+ color: var(--text-strong);
+ font-size: 13px;
+ font-weight: 600;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.conv-preview {
+ font-size: 12px;
+ color: var(--text-dim);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.conv-count {
+ flex-shrink: 0;
+ min-width: 20px;
+ padding: 0 6px;
+ border-radius: 9px;
+ background: var(--bg-active);
+ color: var(--text-dim);
+ font-size: 11px;
+ text-align: center;
+}
+
+.conv-time {
+ flex-shrink: 0;
+ font-size: 11px;
+ color: var(--text-faint);
+}
+
+.conv-empty {
+ padding: 14px;
+}
+
+.conv-thread {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-width: 0;
+ min-height: 0;
+}
+
+.conv-thread.conv-empty {
+ align-items: center;
+ justify-content: center;
+}
+
+.conv-thread-head {
+ padding: 12px 18px;
+ border-bottom: 1px solid var(--border);
+}
+
+.conv-scroll {
+ flex: 1;
+ overflow-y: auto;
+ padding: 14px 18px 24px;
+}
+
+.conv-older {
+ display: block;
+ margin: 0 auto 12px;
+}
+
+.conv-messages {
+ list-style: none;
+ margin: 0 auto;
+ padding: 0;
+ max-width: 900px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+/* Each bot on its own side, its face beside its own bubble. */
+.conv-message {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+}
+
+.conv-right {
+ flex-direction: row-reverse;
+}
+
+.conv-bubble {
+ max-width: 78%;
+ padding: 8px 12px;
+ border-radius: 12px;
+ line-height: 1.5;
+ overflow-wrap: anywhere;
+}
+
+.conv-left .conv-bubble {
+ background: var(--bg-raised);
+ border: 1px solid var(--border);
+ border-top-left-radius: 4px;
+}
+
+.conv-right .conv-bubble {
+ background: color-mix(in srgb, var(--accent) 16%, var(--bg-raised));
+ border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border));
+ border-top-right-radius: 4px;
+}
+
+.conv-meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 4px;
+ font-size: 12px;
+}
+
+.conv-right .conv-meta {
+ justify-content: flex-end;
+}
+
+.conv-sender {
+ color: var(--text-strong);
+ font-weight: 600;
+}
+
+.conv-kind {
+ padding: 0 6px;
+ border-radius: 8px;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text-dim);
+ border: 1px solid var(--border);
+}
+
+.conv-kind-task {
+ color: var(--blue);
+ border-color: color-mix(in srgb, var(--blue) 45%, transparent);
+}
+
+.conv-kind-done {
+ color: var(--green);
+ border-color: color-mix(in srgb, var(--green) 45%, transparent);
+}
+
+.conv-body > :first-child {
+ margin-top: 0;
+}
+
+.conv-body > :last-child {
+ margin-bottom: 0;
+}
diff --git a/apps/desktop/src/styles/files.css b/apps/desktop/src/styles/files.css
new file mode 100644
index 0000000..d1fbe8e
--- /dev/null
+++ b/apps/desktop/src/styles/files.css
@@ -0,0 +1,96 @@
+/* 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);
+}
+
+/* Who made a file, under its name. */
+.files-creator {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ margin-top: 2px;
+ font-size: 11px;
+ color: var(--text-dim);
+}
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/sidebar-rows.css b/apps/desktop/src/styles/sidebar-rows.css
index 33821d2..0897948 100644
--- a/apps/desktop/src/styles/sidebar-rows.css
+++ b/apps/desktop/src/styles/sidebar-rows.css
@@ -173,6 +173,13 @@
font-weight: 600;
}
+.bot-row-tag {
+ flex: none;
+ font-size: 11px;
+ color: var(--text-dim);
+ white-space: nowrap;
+}
+
.bot-row-time {
flex: none;
font-size: 11px;
diff --git a/apps/desktop/src/styles/tasks.css b/apps/desktop/src/styles/tasks.css
new file mode 100644
index 0000000..748a49a
--- /dev/null
+++ b/apps/desktop/src/styles/tasks.css
@@ -0,0 +1,157 @@
+/* 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;
+}
+
+/* The Workers panel in project settings reuses the task rows above. */
+
+.workers-panel .tasks-section-title {
+ padding-left: 0;
+}
+
+.workers-panel .task-row {
+ padding-left: 0;
+ padding-right: 0;
+}
+
+.worker-parent {
+ font-size: 11px;
+ color: var(--text-dim);
+}
+
+.worker-brief {
+ margin-top: 2px;
+ overflow: hidden;
+ font-size: 12px;
+ color: var(--text-dim);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.worker-note {
+ margin-top: 2px;
+ font-size: 11px;
+ color: var(--text-faint);
+}
diff --git a/apps/desktop/src/test/agentFixtures.ts b/apps/desktop/src/test/agentFixtures.ts
new file mode 100644
index 0000000..e6b4010
--- /dev/null
+++ b/apps/desktop/src/test/agentFixtures.ts
@@ -0,0 +1,238 @@
+import type {
+ AgentBot,
+ AgentConversation,
+ AgentMessage,
+ BotCommand,
+ BrowserAction,
+ BrowserTabsPush,
+} from "../protocol/agents";
+import { FakeDaemon } from "./fakeDaemon";
+import * as fx from "./fixtures";
+
+const AT = "2025-01-15T10:00:00Z";
+
+export const lead: AgentBot = { id: "b1", name: "lead", avatar: "icon:comet", deleted: false };
+export const windev: AgentBot = {
+ id: "b2",
+ name: "windev",
+ avatar: "icon:frost",
+ machine: "win-pc",
+ deleted: false,
+};
+export const qa: AgentBot = { id: "b3", name: "qa", avatar: "color:#c084fc", deleted: false };
+
+export function agentMessage(over: Partial = {}): AgentMessage {
+ return {
+ id: "m1",
+ num: 1,
+ from_bot_id: lead.id,
+ to_bot_id: windev.id,
+ kind: "task",
+ body: "Port the updater to Windows.",
+ task: { id: "t1", state: "open" },
+ created_at: AT,
+ ...over,
+ };
+}
+
+export const thread: readonly AgentMessage[] = [
+ agentMessage(),
+ agentMessage({
+ id: "m2",
+ num: 2,
+ from_bot_id: windev.id,
+ to_bot_id: lead.id,
+ kind: "reply",
+ body: "Which signing certificate should the **MSI** use?",
+ task: null,
+ created_at: "2025-01-15T10:05:00Z",
+ }),
+ agentMessage({
+ id: "m3",
+ num: 3,
+ kind: "reply",
+ body: "The release one, in `certs/release.pfx`.",
+ task: null,
+ created_at: "2025-01-15T10:06:00Z",
+ }),
+ agentMessage({
+ id: "m4",
+ num: 4,
+ from_bot_id: windev.id,
+ to_bot_id: lead.id,
+ kind: "done",
+ body: "Ported. The updater builds and its tests pass on Windows.",
+ task: null,
+ created_at: "2025-01-15T11:20:00Z",
+ }),
+];
+
+export const conversations: readonly AgentConversation[] = [
+ {
+ bot_ids: [lead.id, windev.id],
+ message_count: 4,
+ last_at: "2025-01-15T11:20:00Z",
+ last: thread[3] ?? null,
+ },
+ {
+ bot_ids: [qa.id, lead.id],
+ message_count: 1,
+ last_at: "2025-01-15T09:00:00Z",
+ last: agentMessage({
+ id: "m9",
+ num: 0,
+ from_bot_id: lead.id,
+ to_bot_id: qa.id,
+ kind: "note",
+ body: "Heads up: the installer changes land today.",
+ task: null,
+ created_at: "2025-01-15T09:00:00Z",
+ }),
+ },
+];
+
+export const projectBots = [
+ fx.bot({ id: lead.id, name: lead.name, avatar: lead.avatar }),
+ fx.bot({ id: windev.id, name: windev.name, avatar: windev.avatar }),
+ fx.bot({ id: qa.id, name: qa.name, avatar: qa.avatar }),
+];
+
+export const browserTabs: BrowserTabsPush = {
+ type: "browser_tabs",
+ bot_id: "b1",
+ open: true,
+ active: "tab-1",
+ following: true,
+ tabs: [
+ { id: "tab-1", title: "Pricing — Example", url: "https://example.com/pricing" },
+ { id: "tab-2", title: "Docs", url: "https://docs.example.com/" },
+ ],
+};
+
+export const browserActivity: readonly BrowserAction[] = [
+ {
+ turn_id: "turn-2",
+ step_id: "s3",
+ at: "2025-01-15T10:10:00Z",
+ browser: "owners_chrome",
+ title: "Opened mail.example.com",
+ subtitle: "https://mail.example.com/",
+ status: "ok",
+ trigger: { kind: "owner", text: "Check whether the invoice arrived", via: "chat" },
+ },
+ {
+ turn_id: "turn-1",
+ step_id: "s2",
+ at: "2025-01-15T10:00:00Z",
+ browser: "own",
+ title: "Clicked Plans tab",
+ status: "ok",
+ trigger: {
+ kind: "bus",
+ from: "lead",
+ msg_kind: "task",
+ num: 7,
+ text: "Compare the pricing tiers",
+ },
+ },
+ {
+ turn_id: "turn-1",
+ step_id: "s1",
+ at: "2025-01-15T10:00:00Z",
+ browser: "own",
+ title: "Opened example.com",
+ subtitle: "https://example.com/pricing",
+ status: "ok",
+ trigger: {
+ kind: "bus",
+ from: "lead",
+ msg_kind: "task",
+ num: 7,
+ text: "Compare the pricing tiers",
+ },
+ },
+];
+
+const facts =
+ "# Facts\n\n- The release certificate lives in `certs/release.pfx`.\n- CI runs on the `win-2022` image.";
+
+const botCommands: readonly BotCommand[] = [
+ {
+ id: "b1",
+ command: "./build.sh --player macos",
+ description: "Build the macOS player",
+ background: true,
+ status: "running",
+ started_at: "2025-01-15T10:00:00Z",
+ task_id: "bx1",
+ output: "compiling…\nlinking player",
+ },
+ {
+ id: "t2",
+ command: "cargo test -p updater",
+ description: "Run the updater tests",
+ background: false,
+ status: "failed",
+ started_at: "2025-01-15T09:58:00Z",
+ ended_at: "2025-01-15T09:59:12Z",
+ exit_code: 101,
+ output: "test result: FAILED. 11 passed; 1 failed",
+ },
+ {
+ id: "t1",
+ command: "git status --short",
+ background: false,
+ status: "done",
+ started_at: "2025-01-15T09:57:00Z",
+ ended_at: "2025-01-15T09:57:01Z",
+ output: " M src/update.rs",
+ },
+];
+
+/** A daemon serving bot browsers, memory and conversations from the fixtures above. */
+export function agentsDaemon(): FakeDaemon {
+ const client = new FakeDaemon()
+ .onRequest("watch_browser", () => ({ type: "ok", req_id: "1" }))
+ .onRequest("unwatch_browser", () => ({ type: "ok", req_id: "1" }))
+ .onRequest("list_browser_activity", () => ({
+ type: "browser_activity",
+ req_id: "1",
+ bot_id: "b1",
+ activity: browserActivity,
+ }))
+ .onRequest("read_file", () => ({
+ type: "file",
+ req_id: "1",
+ file: { name: "FACTS.md", mime: "text/markdown", text: facts, truncated: false },
+ }))
+ .onRequest("list_bot_commands", () => ({
+ type: "bot_commands",
+ req_id: "1",
+ bot_id: "b1",
+ commands: botCommands,
+ }))
+ .onRequest("list_agent_conversations", () => ({
+ type: "agent_conversations",
+ req_id: "1",
+ project_id: "p1",
+ conversations,
+ bots: [lead, windev, qa],
+ }))
+ .onRequest("list_agent_conversation", (body) => ({
+ type: "agent_conversation",
+ req_id: "1",
+ project_id: "p1",
+ bot_ids: [lead.id, windev.id],
+ messages: body.type === "list_agent_conversation" && body.before !== undefined ? [] : thread,
+ has_more: false,
+ bots: [lead, windev],
+ }));
+ client.capabilities = [
+ ...client.capabilities,
+ "chat",
+ "bot_browser",
+ "agent_conversations",
+ "bot_commands",
+ ];
+ return client;
+}
diff --git a/apps/desktop/src/test/browserFrame.jpg b/apps/desktop/src/test/browserFrame.jpg
new file mode 100644
index 0000000..ccc26cb
Binary files /dev/null and b/apps/desktop/src/test/browserFrame.jpg differ
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__/browser-pane--closed.png b/apps/desktop/tests/visual/__screenshots__/browser-pane--closed.png
new file mode 100644
index 0000000..30ebc9c
Binary files /dev/null and b/apps/desktop/tests/visual/__screenshots__/browser-pane--closed.png differ
diff --git a/apps/desktop/tests/visual/__screenshots__/browser-pane--live.png b/apps/desktop/tests/visual/__screenshots__/browser-pane--live.png
new file mode 100644
index 0000000..d449386
Binary files /dev/null and b/apps/desktop/tests/visual/__screenshots__/browser-pane--live.png differ
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__/commands-panel--overview.png b/apps/desktop/tests/visual/__screenshots__/commands-panel--overview.png
new file mode 100644
index 0000000..ca98da3
Binary files /dev/null and b/apps/desktop/tests/visual/__screenshots__/commands-panel--overview.png differ
diff --git a/apps/desktop/tests/visual/__screenshots__/conversation-thread--two-bots.png b/apps/desktop/tests/visual/__screenshots__/conversation-thread--two-bots.png
new file mode 100644
index 0000000..84793e0
Binary files /dev/null and b/apps/desktop/tests/visual/__screenshots__/conversation-thread--two-bots.png differ
diff --git a/apps/desktop/tests/visual/__screenshots__/conversation-thread--with-pairs.png b/apps/desktop/tests/visual/__screenshots__/conversation-thread--with-pairs.png
new file mode 100644
index 0000000..3edf027
Binary files /dev/null and b/apps/desktop/tests/visual/__screenshots__/conversation-thread--with-pairs.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__/memory-panel--facts.png b/apps/desktop/tests/visual/__screenshots__/memory-panel--facts.png
new file mode 100644
index 0000000..b0efd5c
Binary files /dev/null and b/apps/desktop/tests/visual/__screenshots__/memory-panel--facts.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/crates/bus/src/schema.rs b/crates/bus/src/schema.rs
index 89faee7..7d08e97 100644
--- a/crates/bus/src/schema.rs
+++ b/crates/bus/src/schema.rs
@@ -8,6 +8,7 @@ mod base;
mod decisions;
mod history;
mod peers;
+mod workers;
use base::MIGRATION_1;
use decisions::MIGRATION_12;
@@ -15,7 +16,8 @@ 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;
+use peers::{MIGRATION_14, MIGRATION_15, MIGRATION_16};
+use workers::{MIGRATION_18, MIGRATION_19};
pub const MIGRATIONS: &[&str] = &[
MIGRATION_1,
@@ -32,4 +34,10 @@ pub const MIGRATIONS: &[&str] = &[
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,
+ // Bots drive a browser of their own; the owner's Chrome is opt-in per bot.
+ "ALTER TABLE bot ADD COLUMN user_chrome INTEGER NOT NULL DEFAULT 0;",
+ MIGRATION_18,
+ MIGRATION_19,
];
diff --git a/crates/bus/src/schema/peers.rs b/crates/bus/src/schema/peers.rs
index 511f916..2a7d045 100644
--- a/crates/bus/src/schema/peers.rs
+++ b/crates/bus/src/schema/peers.rs
@@ -44,3 +44,31 @@ CREATE TABLE peer_task (
);
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/schema/workers.rs b/crates/bus/src/schema/workers.rs
new file mode 100644
index 0000000..eb21e10
--- /dev/null
+++ b/crates/bus/src/schema/workers.rs
@@ -0,0 +1,49 @@
+//! Migration 18: temporary workers and the queue that holds them.
+//!
+//! A worker is an ordinary `bot` row with `temporary` set: it exists for one
+//! task and is archived once that task closes. Workers are capped separately
+//! from permanent bots, so a project already at its bot cap can still fan
+//! work out. The `worker` table is the asking daemon's queue: one row per
+//! spawn, from the moment it is asked for until its task closes. It names
+//! the bot once one is placed — here, or as a stand-in for one created on a
+//! linked machine.
+
+pub(super) const MIGRATION_18: &str = r#"
+ALTER TABLE bot ADD COLUMN temporary INTEGER NOT NULL DEFAULT 0;
+
+CREATE TABLE worker (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL REFERENCES project(id),
+ parent_bot_id TEXT NOT NULL REFERENCES bot(id),
+ name TEXT NOT NULL,
+ brief TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ instructions TEXT NOT NULL DEFAULT '',
+ runtime TEXT,
+ machine TEXT,
+ deadline_hours INTEGER NOT NULL,
+ state TEXT NOT NULL
+ CHECK(state IN ('queued', 'running', 'done', 'cancelled', 'expired', 'failed')),
+ bot_id TEXT REFERENCES bot(id),
+ task_id TEXT,
+ error TEXT,
+ created_at TEXT NOT NULL,
+ started_at TEXT,
+ finished_at TEXT
+);
+CREATE INDEX idx_worker_project ON worker(project_id, state, created_at);
+CREATE INDEX idx_worker_parent ON worker(parent_bot_id, state);
+"#;
+
+/// Migration 19: a project's shared git repository.
+///
+/// Workers start from a fresh checkout of its branch and push their work
+/// back to it, which is how workers on different machines share files.
+pub(super) const MIGRATION_19: &str = r#"
+CREATE TABLE project_repo (
+ project_id TEXT PRIMARY KEY REFERENCES project(id),
+ url TEXT NOT NULL,
+ branch TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+"#;
diff --git a/crates/bus/src/types.rs b/crates/bus/src/types.rs
index e558bfc..304db56 100644
--- a/crates/bus/src/types.rs
+++ b/crates/bus/src/types.rs
@@ -9,6 +9,7 @@ mod entities;
mod enums;
mod peers;
mod revisions;
+mod workers;
pub use decision_enums::*;
pub use decisions::*;
@@ -16,6 +17,7 @@ pub use entities::*;
pub use enums::*;
pub use peers::*;
pub use revisions::*;
+pub use workers::*;
pub type Id = String;
diff --git a/crates/bus/src/types/entities.rs b/crates/bus/src/types/entities.rs
index 5fc2d14..d43fd69 100644
--- a/crates/bus/src/types/entities.rs
+++ b/crates/bus/src/types/entities.rs
@@ -58,6 +58,14 @@ pub struct Bot {
/// The linked bot's id on its peer.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote_bot_id: Option,
+ /// Whether the bot may drive the owner's own Chrome through the Claude in
+ /// Chrome extension. Off by default: every bot has a browser of its own.
+ #[serde(default)]
+ pub user_chrome: bool,
+ /// A worker: created for one task and archived once that task closes.
+ /// Counted against the project's worker cap, not its bot cap.
+ #[serde(default)]
+ pub temporary: bool,
pub created_at: DateTime,
}
diff --git a/crates/bus/src/types/peers.rs b/crates/bus/src/types/peers.rs
index dc79b28..b2e085e 100644
--- a/crates/bus/src/types/peers.rs
+++ b/crates/bus/src/types/peers.rs
@@ -41,4 +41,18 @@ pub struct RemoteBot {
/// The project it works in on its own daemon. Informational only.
#[serde(default)]
pub project: String,
+ /// A temporary worker, archived on its machine once its task closes.
+ #[serde(default)]
+ pub temporary: bool,
+}
+
+/// 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/workers.rs b/crates/bus/src/types/workers.rs
new file mode 100644
index 0000000..05d5a93
--- /dev/null
+++ b/crates/bus/src/types/workers.rs
@@ -0,0 +1,151 @@
+//! Temporary workers: bots spawned for one task, held in a queue until a
+//! worker slot is free.
+
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+
+use super::{BotRuntime, Id};
+
+/// Workers a project may run at once on one machine, unless configured.
+pub const DEFAULT_MAX_WORKERS_PER_PROJECT: usize = 4;
+
+/// Where a spawn is in its life. `queued` waits for a slot; `running` has a
+/// bot and an open task; the rest are final and say how the task closed.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum WorkerState {
+ Queued,
+ Running,
+ Done,
+ Cancelled,
+ Expired,
+ /// Never started: placing it failed for a reason waiting will not fix.
+ Failed,
+}
+
+impl WorkerState {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Queued => "queued",
+ Self::Running => "running",
+ Self::Done => "done",
+ Self::Cancelled => "cancelled",
+ Self::Expired => "expired",
+ Self::Failed => "failed",
+ }
+ }
+
+ pub fn parse(raw: &str) -> Option {
+ Some(match raw {
+ "queued" => Self::Queued,
+ "running" => Self::Running,
+ "done" => Self::Done,
+ "cancelled" => Self::Cancelled,
+ "expired" => Self::Expired,
+ "failed" => Self::Failed,
+ _ => return None,
+ })
+ }
+
+ pub fn is_final(self) -> bool {
+ !matches!(self, Self::Queued | Self::Running)
+ }
+}
+
+/// One spawn, as the asking daemon tracks it.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Worker {
+ pub id: Id,
+ pub project_id: Id,
+ /// The bot that asked for it, and that its task reports to.
+ pub parent_bot_id: Id,
+ /// Reserved while queued, so the parent can address it by this name.
+ pub name: String,
+ /// The task it is given when it starts.
+ pub brief: String,
+ pub description: String,
+ pub instructions: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub runtime: Option,
+ /// The peer it must run on, by name, or `None` for any machine with a
+ /// free slot.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub machine: Option,
+ pub deadline_hours: i64,
+ pub state: WorkerState,
+ /// The bot running it: local, or the stand-in for one on a peer.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub bot_id: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub task_id: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub error: Option,
+ pub created_at: DateTime,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub started_at: Option>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub finished_at: Option>,
+}
+
+/// A project's shared git repository: where workers' checkouts come from and
+/// where their work is pushed.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ProjectRepo {
+ pub url: String,
+ pub branch: String,
+}
+
+impl ProjectRepo {
+ /// Check a repository as given, defaulting the branch to `main`.
+ ///
+ /// Both end up as arguments to `git`, so anything that could read as an
+ /// option, or that git would not take as a branch, is refused here.
+ pub fn parse(url: &str, branch: Option<&str>) -> Result {
+ let url = url.trim();
+ if url.is_empty() || url.len() > 2048 || url.starts_with('-') {
+ return Err("give the repository's clone URL".to_string());
+ }
+ if url.chars().any(char::is_whitespace) || url.contains("::") {
+ return Err(format!("'{url}' is not a clone URL git accepts here"));
+ }
+ let branch = branch
+ .map(str::trim)
+ .filter(|b| !b.is_empty())
+ .unwrap_or("main");
+ let valid = !branch.starts_with(['-', '/', '.'])
+ && !branch.ends_with(['/', '.'])
+ && !branch.contains("..")
+ && !branch.contains("//")
+ && !branch.ends_with(".lock")
+ && branch.len() <= 200
+ && branch
+ .chars()
+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/'));
+ if !valid {
+ return Err(format!("'{branch}' is not a branch name git accepts"));
+ }
+ Ok(Self {
+ url: url.to_string(),
+ branch: branch.to_string(),
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::ProjectRepo;
+
+ #[test]
+ fn a_repo_needs_a_plain_url_and_branch() {
+ let repo = ProjectRepo::parse(" git@github.com:me/book.git ", None).unwrap();
+ assert_eq!(repo.branch, "main");
+ assert_eq!(repo.url, "git@github.com:me/book.git");
+ assert!(ProjectRepo::parse("https://x/y.git", Some("drafts/v2")).is_ok());
+ for bad in ["", "--upload-pack=x", "ext::sh -c x", "a b"] {
+ assert!(ProjectRepo::parse(bad, None).is_err(), "{bad}");
+ }
+ for bad in ["-x", "a..b", "a/", "x.lock", "a b", "a~1"] {
+ assert!(ProjectRepo::parse("u", Some(bad)).is_err(), "{bad}");
+ }
+ }
+}
diff --git a/crates/gravityd/src/activity.rs b/crates/gravityd/src/activity.rs
index bb70464..640252e 100644
--- a/crates/gravityd/src/activity.rs
+++ b/crates/gravityd/src/activity.rs
@@ -68,18 +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 == '.' || cfg!(windows) && (c == '\\' || c == ':') {
- '-'
- } else {
- c
- }
- })
+ .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
home.join(".claude").join("projects").join(mangled)
}
@@ -259,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 5713890..f16c8f6 100644
--- a/crates/gravityd/src/app.rs
+++ b/crates/gravityd/src/app.rs
@@ -28,6 +28,17 @@ pub const CAPABILITIES: &[&str] = &[
"config",
"decisions",
"bot_runtime",
+ "chat",
+ "permissions",
+ "peer_chat",
+ "linked_projects",
+ "bot_browser",
+ "agent_conversations",
+ "peer_terminal",
+ "peer_browser",
+ "bot_commands",
+ "restart_bot",
+ "workers",
];
pub struct AppState {
@@ -42,6 +53,14 @@ pub struct AppState {
/// 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,
+ /// Bot browsers being watched, one shared stream each.
+ pub browsers: crate::browser::streams::BrowserStreams,
+ /// The temporary-worker queue's wake-up and placement lock.
+ pub workers: crate::workers::Workers,
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.
@@ -71,7 +90,7 @@ impl AppState {
secrets.clone(),
auto_compact.clone(),
);
- Ok(Arc::new(Self {
+ let app = Arc::new(Self {
cfg,
db,
events,
@@ -79,9 +98,21 @@ impl AppState {
supervisor,
auto_compact,
peers: crate::peer::PeerHub::default(),
+ chat: crate::chat::ChatStore::default(),
+ approvals: crate::approval::Approvals::default(),
+ browsers: crate::browser::streams::BrowserStreams::default(),
+ workers: crate::workers::Workers::default(),
started_at: Instant::now(),
started_wall: SystemTime::now(),
- }))
+ });
+ // Every session start picks up what the last one left unfinished.
+ let weak = Arc::downgrade(&app);
+ app.supervisor.on_start(Box::new(move |bot_id, continues| {
+ if let Some(app) = weak.upgrade() {
+ crate::resume::pick_up(&app, bot_id, continues);
+ }
+ }));
+ Ok(app)
}
/// True when the daemon binary on disk is newer than the running process.
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/botmgmt.rs b/crates/gravityd/src/botmgmt.rs
index 69a7659..4b9a316 100644
--- a/crates/gravityd/src/botmgmt.rs
+++ b/crates/gravityd/src/botmgmt.rs
@@ -23,10 +23,11 @@ mod revert;
mod runtime;
pub use archive::{archive_bot, prune_archived_workspaces};
-pub use create::{create_bot, create_bot_with_runtime, Created};
+pub use create::{create_bot, create_bot_with_runtime, create_worker_bot, Created, WorkersFull};
pub use revert::revert_revision;
pub use runtime::{
- check_runtime_available, requested_runtime, set_bot_runtime, RuntimeUnavailable,
+ check_runtime_available, requested_runtime, set_bot_runtime, set_bot_user_chrome,
+ RuntimeUnavailable,
};
/// Fields that may change on a bot. `None` leaves the stored value alone,
@@ -58,7 +59,8 @@ impl IdentityEdit<'_> {
#[error("a bot named '{0}' already exists in this project")]
pub struct NameTaken(pub String);
-/// Validate a name for use in a project, rejecting duplicates.
+/// Validate a name for use in a project, rejecting duplicates — live bots,
+/// and the names queued workers have reserved.
///
/// `existing` is the bot being renamed, so it does not collide with itself.
pub fn validate_name(
@@ -66,6 +68,20 @@ pub fn validate_name(
project_id: &str,
raw: &str,
existing: Option<&str>,
+) -> anyhow::Result {
+ let name = validate_bot_name(app, project_id, raw, existing)?;
+ if app.db.worker_name_reserved(project_id, &name)? {
+ return Err(NameTaken(name).into());
+ }
+ Ok(name)
+}
+
+/// Validate a name against live bots only.
+pub fn validate_bot_name(
+ app: &Arc,
+ project_id: &str,
+ raw: &str,
+ existing: Option<&str>,
) -> anyhow::Result {
let name = bus::names::validate(raw).map_err(|e| anyhow::anyhow!("{e}"))?;
if let Some(clash) = app.db.get_bot_by_name(project_id, &name)? {
@@ -91,11 +107,28 @@ pub(super) fn provision_spec<'a>(
daemon_port: app.cfg.port,
bot_token_env: BOT_TOKEN_ENV,
max_bots_per_project: app.cfg.max_bots_per_project,
+ max_workers_per_project: app.cfg.max_workers_per_project,
+ temporary: bot.temporary,
+ repo: app.db.project_repo(&project.id).ok().flatten(),
artifacts_dir: paths::artifacts_dir(&app.cfg, &project.dir_name)
.display()
.to_string(),
+ linked_machines: linked_machines(app, &project.id),
+ own_browser: app.cfg.browser.enabled,
+ user_chrome: bot.user_chrome,
}
}
+
+/// 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())
diff --git a/crates/gravityd/src/botmgmt/archive.rs b/crates/gravityd/src/botmgmt/archive.rs
index 5c419ea..07e1501 100644
--- a/crates/gravityd/src/botmgmt/archive.rs
+++ b/crates/gravityd/src/botmgmt/archive.rs
@@ -35,6 +35,14 @@ pub fn archive_bot(
actor: &Actor<'_>,
reason: Option<&str>,
) -> anyhow::Result<()> {
+ // Twice is once: a second archive would tombstone the name again.
+ if app
+ .db
+ .get_bot(&bot.id)?
+ .is_none_or(|b| b.deleted_at.is_some())
+ {
+ return Ok(());
+ }
// Stop first: the runtime must not outlive its credential, or it would keep
// making authenticated calls that then fail confusingly.
app.supervisor.stop_bot(&bot.id)?;
@@ -53,14 +61,18 @@ pub fn archive_bot(
reason.unwrap_or("deleted"),
)?;
- app.events.push(Push::notice(
- "info",
- "Bot deleted",
- match reason {
- Some(r) => format!("{} was deleted: {r}", bot.name),
- None => format!("{} was deleted.", bot.name),
- },
- ));
+ // A worker retiring is routine, and a job can retire dozens.
+ if !bot.temporary {
+ app.events.push(Push::notice(
+ "info",
+ "Bot deleted",
+ match reason {
+ Some(r) => format!("{} was deleted: {r}", bot.name),
+ None => format!("{} was deleted.", bot.name),
+ },
+ ));
+ }
+ app.workers.nudge();
if let Some(archived) = app.db.get_bot(&bot.id)? {
app.events.push(Push::BotUpdated { bot: archived });
}
diff --git a/crates/gravityd/src/botmgmt/create.rs b/crates/gravityd/src/botmgmt/create.rs
index 8584a50..0ed56e6 100644
--- a/crates/gravityd/src/botmgmt/create.rs
+++ b/crates/gravityd/src/botmgmt/create.rs
@@ -13,7 +13,9 @@ use crate::events::Push;
use crate::messaging::{self, daemon_sender, Dm};
use crate::paths;
-use super::{charter, parse_avatar, provision_spec, validate_name, IdentityEdit};
+use super::{
+ charter, parse_avatar, provision_spec, validate_bot_name, validate_name, IdentityEdit,
+};
/// Outcome of creating a bot, including whether it actually started.
pub struct Created {
@@ -70,9 +72,47 @@ pub fn create_bot_with_runtime(
creator: Option<&Bot>,
actor: &Actor<'_>,
runtime: BotRuntime,
+) -> anyhow::Result {
+ create(app, project_id, edit, creator, actor, runtime, false)
+}
+
+/// Create a temporary worker: a bot for one task, counted against the
+/// worker cap rather than the bot cap. It gets no greeting — its task is its
+/// first turn — and no toast, since a job can spawn dozens.
+pub fn create_worker_bot(
+ app: &Arc,
+ project_id: &str,
+ edit: &IdentityEdit<'_>,
+ creator: Option<&Bot>,
+ actor: &Actor<'_>,
+ runtime: BotRuntime,
+) -> anyhow::Result {
+ create(app, project_id, edit, creator, actor, runtime, true)
+}
+
+/// The worker cap is full on this machine. Typed so a dispatcher can leave
+/// the spawn queued, and a peer can tell its asker to try elsewhere.
+#[derive(Debug, thiserror::Error)]
+#[error("this project already runs its limit of {0} workers here")]
+pub struct WorkersFull(pub usize);
+
+fn create(
+ app: &Arc,
+ project_id: &str,
+ edit: &IdentityEdit<'_>,
+ creator: Option<&Bot>,
+ actor: &Actor<'_>,
+ runtime: BotRuntime,
+ temporary: bool,
) -> anyhow::Result {
let raw_name = edit.name.unwrap_or_default();
- let name = validate_name(app, project_id, raw_name, None)?;
+ // A worker's name was reserved by its own queue entry, so only live bots
+ // can clash with it.
+ let name = if temporary {
+ validate_bot_name(app, project_id, raw_name, None)?
+ } else {
+ validate_name(app, project_id, raw_name, None)?
+ };
// A caller that says nothing about the avatar gets one anyway: "create a
// bot called Steve" should produce a bot with a face, not a bare initial.
let avatar = match edit.avatar.map(str::trim).filter(|a| !a.is_empty()) {
@@ -80,15 +120,22 @@ pub fn create_bot_with_runtime(
None => random_icon(),
};
- // The population cap is the only limit on creation, so it is checked here
- // for every caller rather than at the MCP boundary.
- let live = app.db.count_live_bots(project_id)?;
- let cap = app.cfg.max_bots_per_project as i64;
- if live >= cap {
- bail!(
- "project is at its limit of {cap} bots ({live} live). \
- Delete a bot you no longer need to free a slot."
- );
+ // The population caps are the only limit on creation, so they are
+ // checked here for every caller rather than at the MCP boundary.
+ if temporary {
+ let cap = app.cfg.max_workers_per_project;
+ if app.db.count_live_workers(project_id)? >= cap as i64 {
+ return Err(WorkersFull(cap).into());
+ }
+ } else {
+ let live = app.db.count_live_bots(project_id)?;
+ let cap = app.cfg.max_bots_per_project as i64;
+ if live >= cap {
+ bail!(
+ "project is at its limit of {cap} bots ({live} live). \
+ Delete a bot you no longer need to free a slot."
+ );
+ }
}
// Asked before the insert below, and counting deleted bots too: someone
@@ -120,6 +167,15 @@ pub fn create_bot_with_runtime(
creator.map(|c| c.id.as_str()),
runtime,
)?;
+ let bot = if temporary {
+ app.db.set_bot_temporary(&bot.id, true)?;
+ Bot {
+ temporary: true,
+ ..bot
+ }
+ } else {
+ bot
+ };
// Issuing the token before provisioning means the runtime can authenticate
// as soon as the files land.
@@ -131,16 +187,17 @@ pub fn create_bot_with_runtime(
}
}
- let origin = match creator {
- Some(c) => format!("created by {}", c.name),
- None => "created".to_string(),
+ let origin = match (creator, temporary) {
+ (Some(c), true) => format!("spawned as a worker by {}", c.name),
+ (Some(c), false) => format!("created by {}", c.name),
+ (None, _) => "created".to_string(),
};
app.db
.record_revision(&bot.id, actor, RevisionField::Created, "", &origin)?;
// Nothing gates creation, so this notice is how the user finds out. It is
// a toast, not a prompt: ignorable, and the audit trail keeps the record.
- if let Some(c) = creator {
+ if let Some(c) = creator.filter(|_| !temporary) {
app.events.push(Push::notice(
"info",
"Bot created",
@@ -155,6 +212,11 @@ pub fn create_bot_with_runtime(
tracing::warn!(bot_id = %bot.id, error = %e, "created bot did not start; will retry");
}
+ if temporary {
+ app.events.push(Push::BotUpdated { bot: bot.clone() });
+ return Ok(Created { bot });
+ }
+
// Opening turn, so the new bot's window is not blank until someone types
// at it. Queued rather than delivered here: the session may not be up yet,
// and the delivery worker already defers until it is. A failure only costs
diff --git a/crates/gravityd/src/botmgmt/runtime.rs b/crates/gravityd/src/botmgmt/runtime.rs
index 5ac3e91..2cf404b 100644
--- a/crates/gravityd/src/botmgmt/runtime.rs
+++ b/crates/gravityd/src/botmgmt/runtime.rs
@@ -27,6 +27,34 @@ pub fn check_runtime_available(app: &Arc, runtime: BotRuntime) -> anyh
}
/// Persist a provider change and restart, preserving the workspace and histories.
+/// Allows or forbids the bot the owner's own Chrome. The flag is read at
+/// spawn, so a running session restarts to pick it up.
+pub fn set_bot_user_chrome(app: &Arc, bot: &Bot, enabled: bool) -> anyhow::Result {
+ anyhow::ensure!(
+ !bot.is_linked(),
+ "{} runs on another machine; change it there",
+ bot.name
+ );
+ if bot.user_chrome == enabled {
+ return Ok(bot.clone());
+ }
+ app.db.set_bot_user_chrome(&bot.id, enabled)?;
+ let mut updated = bot.clone();
+ updated.user_chrome = enabled;
+ // The system prompt says which browsers the bot has.
+ if let Err(e) = super::reprovision(app, &updated) {
+ tracing::warn!(bot_id = %bot.id, error = %e, "system prompt not refreshed");
+ }
+ if let Err(error) = app.supervisor.restart_bot(&bot.id) {
+ app.db.set_bot_user_chrome(&bot.id, bot.user_chrome)?;
+ return Err(error);
+ }
+ app.events.push(Push::BotUpdated {
+ bot: updated.clone(),
+ });
+ Ok(updated)
+}
+
pub fn set_bot_runtime(app: &Arc, bot: &Bot, runtime: BotRuntime) -> anyhow::Result