Skip to content

feat(workers): temporary workers bots can spawn, and every bot's permission prompts in the Control Center - #23

Open
mrinaldi2 wants to merge 59 commits into
ahilles107:mainfrom
mrinaldi2:claude/temporary-workers
Open

mrinaldi2 wants to merge 59 commits into
ahilles107:mainfrom
mrinaldi2:claude/temporary-workers

Conversation

@mrinaldi2

Copy link
Copy Markdown
Contributor

Stacked on #22 (which is stacked on #21 and #20). This branch builds on codex/agent-browser-memory, so the diff against main also shows those PRs' commits. This PR's own changes are the 7 commits from 254f652 to 3e9ed2d. Merge #20, #21 and #22 first.

Merging the stack: with Create a merge commit, #20 → #21 → #22 → this PR each merge cleanly in turn (checked by simulating the sequence on today's main). A squash merge of one PR makes the next one conflict, because main then holds the earlier PR's changes as a different commit. After each squash, merging main into the next branch with git merge -s ours fixes it, since main's tree is then exactly the previous branch's.

What

Temporary workers

  • Spawning. A bot can spawn short-lived workers for parallel work (spawn_worker). For example, a book bot spawns one worker per chapter. Each worker gets the brief as an ordinary task, reports through complete_task, and retires.
  • A cap and a queue.
    • Cap: workers have their own cap, max_workers_per_project (default 4), separate from max_bots_per_project.
    • Queue: spawns past the cap queue and start as workers finish.
    • Spawn states: spawn_worker returns at once with running, queued (with a queue position) or failed.
    • Rules: workers cannot spawn workers.
  • Across machines. When this machine is full, a spawn is offered to a linked peer for a linked project (feat(peer): link projects across peer daemons #21). The offer uses the existing create_bot peer frame with temporary: true, and a full peer refuses with at_capacity.
  • A shared repository.
    • Setting it: a project can name a git repository and branch (set_project_repo, or Project settings).
    • Clone and push: each worker clones it when it starts, pulls, rebases and pushes before it reports.
    • Unpushed work: work left unpushed when a worker stops is saved to a branch of its own, gravity/<name>-<id>, and named in the result.
    • Git safety: git runs non-interactively, with the ext transport disabled.
  • Seeing and cancelling.
    • Requests: list_workers and cancel_worker, plus a workers_updated push.
    • Desktop: the app shows each project's queue with Cancel buttons and tags workers in the sidebar.
    • Bot field: bots now carry temporary, so clients can tell workers apart.
  • Storage. Schema migrations 18 and 19 add the worker table and project repositories.

Every bot's permission prompts in the Control Center

  • The problem. With the app open, the daemon holds a Claude Code or Codex permission prompt for the app, but the card only showed above the selected bot. A prompt from any other bot was visible nowhere and was denied when it expired.
  • The Control Center now lists every waiting prompt above the decisions. Each card names its bot, links to it, and is answered there as on the bot.
  • Badges: prompts count as urgent in the Control Center badge and the dock badge.
  • Toast: a prompt from a bot not on screen raises a toast that opens the Control Center.

Why

  • Workers: bots could only hand work to permanent bots, so splitting a large job across parallel sessions meant creating and cleaning up bots by hand, with no limit on how many ran at once.
  • Prompts: permission prompts from bots you weren't looking at were silently timing out and being denied.

No GitHub issue tracks this.

How tested

  • Full verify: pnpm run verify passes on this branch after merging feat: each bot's own browser, commands, memory, conversations, and restarts that keep the work going #22 and current main: Rust tests, desktop typecheck, lint, knip, coverage, fallow and the 89 browser tests.
  • New Rust tests (tests/workers.rs, db/workers_tests.rs): queueing past the cap, cancelling queued and running workers, a full machine handing spawns to a linked peer, refused spawns, and deleting a running worker.
  • New desktop tests: the permission inbox (prompts from every bot, badge counts, the toast).
  • Installed: the daemon and desktop app from this branch are running on macOS. They haven't been rebuilt or run on Windows yet.

Rollout

  • Migrations: 18 and 19 run on daemon start.
  • Compatibility: existing projects are unaffected until a bot spawns a worker. temporary is a new field on bots, and older clients ignore it.
  • Worker git credentials: workers push with each machine's own git credentials, so a shared repository needs push access on every machine that runs workers.

Limitations

  • A permission prompt from a bot on a linked machine is shown on that machine's app (or its terminal), not forwarded to this machine's Control Center.
  • The iOS app does not yet show workers or answer permission prompts.

🤖 Generated with Claude Code

mrinaldi2 and others added 30 commits October 1, 2026 09:34
Bot creation and the supervision tick could both enter start_bot for a
new bot before either registered its session. With Codex, the second
runtime resumed the thread the first had just created and failed with
"thread already has an active writer"; the failure path then cleared the
healthy first session, crashing the bot until the next retry.

start_bot now claims the bot under the supervisor lock before launching
anything, and releases the claim when it returns, so a concurrent caller
backs off instead of starting a second runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pair two of the owner's daemons over Tailscale and stand a bot from one
in as a linked bot in a project on the other. A linked bot is an ordinary
bot row with peer_id set, so addressing, task budgets and history work
unchanged; the delivery worker forwards deliveries for it over the /peer
link instead of writing to a local session.

The receiving daemon stores each forwarded message as if the sender were
local: a task is mirrored with the sender's deadline and hop count, a
result or cancel closes the mirrored task, and the sender gets a linked
bot of its own on first contact so replies route back. Files listed in a
result travel with it (from the bot's own directory or the project
artifacts only, size-capped) and land in the recipient project's
artifacts directory.

Pairing is a one-time invite holding a link token bound to the first
daemon that presents it. Peers can only list bots, link one, and deliver
to bots linked to them; revoking drops the link. `gravityd peer ...`
drives pairing and linking until the desktop UI lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs ahilles107/gravity#13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Phase 1 of the chat pane (ahilles107/gravity#13). The daemon now reads a
bot's runtime transcript and serves it as turns: what woke the bot (the
owner from the composer or the terminal, another bot, a routine, a ruling,
a background job), its text, its tool steps with diff counts and images,
and Gravity's own bus calls as sent messages, results and decisions.

A per-bot reader folds the transcript in incrementally and a watcher pushes
chat_turns for loaded chats as they grow. Step detail, images and files are
fetched on demand; read_file only reaches the bot's own directory and its
project's artifacts. New read-grant requests list_chat, get_chat_step,
get_chat_image, list_artifacts and read_file, behind a `chat` capability.

Also fixes two bugs the research turned up:
- transcript_dir kept `_`, while Claude Code files every non-alphanumeric
  character as `-`, so bots under such paths had no activity preview.
- Routine runs never completed from the transcript on Unix: the Stop hook
  did not forward Claude Code's payload (so no transcript_path), and the
  run-id scan did not expect Claude Code's peer-message wrapper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Phase 2 of the chat pane (ahilles107/gravity#13). Opening a bot now shows
its conversation as turns read from the daemon's chat model: who woke it,
its answer as markdown with highlighted code, tool steps folded into
groups that expand to their command, diff and output, step images with a
viewer, sent messages, results with their files, and decisions raised.
The composer sends through send_user_message and shows a message's
delivery state until the bot reads it. The chat follows chat_turns
pushes and pages back through older turns.

The Terminal and Routines tabs stay (⌘1-3, ⌘L focuses the composer),
the side panel gains a Files tab listing the project's artifacts with
previews, and a linked bot from a peer shows its bus conversation. A
daemon without the chat capability keeps the terminal-first view.

Bot markdown keeps the Control Center's policy: links and remote images
are inert; images render only from bytes the daemon served. Also fixes
list_messages paging, which sent before_id to a daemon reading before_num.

Visual baselines for the new stories still need to come from CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Phase 3 of the chat pane (ahilles107/gravity#13). Bots get a blocking
PermissionRequest hook (not PreToolUse, which fires for every call the
bot is already allowed to make). The hook posts the prompt to
/hook/permission and waits; the app shows a card above the bot's tabs
with Allow once, Allow for this session and Deny (with a reason the bot
sees), and the answer goes back as Claude Code's decision.

Nothing defaults to yes:
- An unanswered prompt is denied when its window closes
  (permission_timeout_seconds, default 600, below the hook's timeout).
- "Allow for this session" sends Claude Code's suggested rules with the
  destination forced to session; mode changes and persistent rules are
  dropped, so no settings file is ever written.
- The daemon only holds a prompt while an app that shows cards (hello
  features: permission_cards) is connected with the control grant.
  Otherwise, or if the hook fails, Claude Code's own terminal prompt
  appears as before.
- Answering is `control`, the same grant as typing into the terminal.

New list_permissions / answer_permission requests and permission_request
/ permission_resolved pushes. Also splits chat/builder.rs (505 lines,
over the limit since phase 1) and moves the Unix hook settings out of
paths.rs into paths/unix_hooks.rs.

The live Claude Code check the issue requires is still to be run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Next to Info and Files, a bot's Tasks tab shows its work in four
sections: Now (open tasks it was given), Waiting on others (open tasks
it delegated, with the peer machine for linked bots), Upcoming (its
enabled routines' next runs) and Done (finished, cancelled or expired
tasks, opening to the result that closed them). It refetches after bus
traffic and routine runs.

A new read-grant `list_tasks` request serves a bot's tasks from both
ends, newest first, with the request, the result and when it closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Phase 4 of the chat pane (ahilles107/gravity#13).

The Codex worker now records every command, file change (with its diff
as Claude Code-style structured patch hunks) and MCP call in
codex-observations.jsonl as tool_use / tool_result pairs, and marks each
turn's end with a turn_duration record, so the chat reads a Codex bot's
turns, steps, diffs and bus calls the same way as a Claude bot's.

Command, file-change and permission approvals become permission cards:
the worker raises a structured SessionEvent::Permission, the supervisor
relays it on the internal bus, the approval broker shows the card, and
the answer goes back through RuntimeSession::answer_permission as the
App Server's accept / acceptForSession / decline (or a turn- or
session-scoped permission grant). Whichever answers first, the card or
the native CLI, decides; serverRequest/resolved withdraws the card.
Questions and elicitations stay in the native CLI.

The broker is split into a shared decide step with Claude hook and
runtime front ends. The supervisor's session event loop moves to its own
file. Also fixes the scheduler's routine-completion listener, which
ended for good on the first internal event that was not BotDone, or on
a lagged burst.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Phase 5 of the chat pane (ahilles107/gravity#13). A linked bot's chat
used to show only its bus conversation. Now list_chat, get_chat_step,
get_chat_image and read_file for a linked bot are forwarded to the
daemon it runs on, which serves them for bots linked to that peer only
(read_file stays confined to the bot's own directory and its project's
artifacts there). Turns come back with the local linked bot's id.

While a peer has a chat open, the daemon running the bot streams its
chat_turns as event frames (no req_id, no answer) and the other daemon
pushes them to its clients under the linked bot's id. Daemons advertise
`peer_chat`; the app shows the full chat for linked bots on such a
daemon, with a note saying which machine the bot runs on, and keeps
the bus-conversation view otherwise. Permission prompts of a linked bot
are still answered on its own machine.

The two-daemon test setup moves to tests/common/peers.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Phase 6 of the chat pane (ahilles107/gravity#13), the parts the issue
left out of v1:

- Decisions in the thread: a decision a bot raised shows its options in
  the chat. Picking one, or writing an answer, publishes it in one step
  (publish_decisions with an inline ruling), as "Answer and publish" does
  in the Control Center; it needs the approve grant. The card links to
  the Control Center for drafts, holds and the discussion.
- Attachments: attach or paste files in the composer. They upload in
  chunks through a new control-grant write_artifact request into the
  project's artifacts/uploads/ (16 MB cap, name confined to its last
  component), and the message lists their paths for the bot.
- Slash commands: a message starting with / is typed into the bot's
  terminal, as if typed there, with a hint under the composer.
- Search: ⌘F filters the loaded turns by their text, steps and trigger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Search now works like find in page: every match in the loaded turns is
  highlighted in place (CSS custom highlights), the current one apart, and
  ▲/▼ or Enter / Shift+Enter step between them, scrolling each into view.
  Folded step groups open while searching so their text is found. Turns
  are no longer filtered out.
- Tasks: requests, results and routine prompts clamped to two lines get a
  "Show more" / "Show less" when the clamp hides something or a result is
  waiting, instead of only an ellipsis.
- Image thumbnails keep their image's aspect ratio (fixed height, width
  from the image) instead of a square crop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Tasks list trims each request to 400 characters and each result to
600, so "Show more" could only reveal a cut-off preview. The list now says
when it trimmed (request_truncated, result_truncated), a new read-grant
get_task request returns one task with its full request and result, and
opening a row loads it when the preview was cut. Opened requests and
results render as the markdown bots write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A mic button in the composer turns speech into text with the operating
system's own recognizer, so audio never goes to Gravity or a third
party. On macOS, AVAudioEngine feeds the microphone to Apple's Speech
framework (objc2-speech / objc2-avf-audio), set to recognize on the
device whenever the Mac supports it. Partial transcripts stream into the
draft after what was already typed, and the final one stays; pressing
the button again stops.

The first use asks for Speech Recognition and Microphone access: the app
now carries NSSpeechRecognitionUsageDescription and
NSMicrophoneUsageDescription (src-tauri/Info.plist), and signed releases
get the hardened-runtime audio-input entitlement (Entitlements.plist).
The mic is hidden where dictation is unavailable: a plain browser, and
Windows until its recognizer (Windows.Media.SpeechRecognition) lands.

The composer's attachments move into useAttachments and its buttons into
ComposerParts. Notices gain the seven objc2 framework crates, which ship
no license file and use the reviewed objc2 LICENSE override like their
siblings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The full-size image viewer reused the command palette's overlay panel,
which is 580px wide, at most 60vh tall and clips its content, so wide
screenshots were cropped into a box. Images now open in a full-window
lightbox: fitted to the window at their own aspect ratio, or at actual
size (click the image) with the stage scrolling. ←/→ and the side
buttons page through a step group's images; Esc or the backdrop closes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The composer's mic was macOS-only. On Windows, SAPI's in-process
recognizer with its dictation grammar now feeds the same partial and
final events, so audio stays on the PC. WinRT dictation was not used
because it requires Windows' online speech service.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After revoke_peer, pairing the same two daemons again failed with "a peer
named '<name>' already exists": the revoked row kept its name under the
UNIQUE constraint. It also kept the other daemon's id under its own UNIQUE
constraint, so even a new name would have left the link unable to bind.

Revoking now tombstones the name the way archiving a bot does
(`name#<id8>`) and releases the daemon id. Views show the owner's name, not
the tombstone. Migration 15 does the same for peers revoked before it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ation

fec6522 changed apps/desktop/src-tauri/Cargo.toml and Cargo.lock without
regenerating the notice inventory, so `scripts/notices.py` refused them. It
added no crates: the regenerated records and bundles are identical, so only
the two input hashes change. The records are kept as committed rather than
regenerated here, since a macOS run swaps the marketing build tools'
Windows platform packages for macOS ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Linking a project here with one on a peer makes the two one team: every
bot that runs on either side is exposed to the peer and stands in on the
other side, and stays mirrored while linked. This is the daemon half of the
protocol GravitiOS is built against (gravitiOS docs/linked-projects.md).

- hello_ok carries `daemon_id`; capabilities add `linked_projects`.
- A `project_link` table (migration 16) records each link on both sides;
  one link per (project, peer), and per (peer, remote project).
- Projects gain `links` (peer_id, peer_name, online, remote_project_id,
  remote_project_name, linked_at), and `project_updated` is pushed when they
  change.
- Control plane: `list_peer_projects` (read), `link_project` and
  `unlink_project` (control), and `create_bot` with `peer_id`, which creates
  the bot on the peer and replies with its stand-in here. Errors keep their
  code across the link: `conflict`, `not_linked`, `not_found`, `unavailable`.
- Peer frames: `list_projects`, `link_project`, `unlink_project`,
  `create_bot`, `update_bot`, `delete_bot`, and the `project_roster` and
  `project_links` events. A peer acts only on projects linked through it,
  except `list_projects` and `link_project`; accepted links are logged.
- Mirroring: a watcher on the daemon's own pushes sends a linked project's
  whole roster on any bot change; the receiver creates, updates and archives
  stand-ins to match. Events on a link are applied in order, and each link-up
  resends every roster and drops links the other side no longer holds.
- A link whose rosters share a bot name is refused, naming the bots.
  Unlinking from either side, revoking the peer or archiving the project
  archives the stand-ins on both sides (cancelling their open tasks) and
  stops the peer reaching the project's bots.
- Stand-ins no longer count towards `max_bots_per_project`.
- MCP `create_bot` takes `machine`, allowed only when the project is linked
  through that peer; `update_bot` and `delete_bot` on a bot created there
  are forwarded, and the peer holds them to the creator rule. The system
  prompt names the machines a project is linked with.
- The owner's `update_bot`, `set_bot_runtime` and `delete_bot` on a stand-in
  in a linked project are forwarded to its machine, since changing only the
  stand-in would be undone by the next roster.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mrinaldi2 and others added 29 commits October 1, 2026 23:30
Eleven stories added with the chat pane had no baseline, so the visual
suite failed on each: chat composer, code blocks, lightbox, permission
cards, tasks panel and turn view. Generated in the pinned Playwright
container with --update-snapshots=missing, so the existing 72 baselines
are untouched and still match byte for byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
protocol.md lists the new requests, `create_bot`'s `peer_id`, the project's
`links`, `daemon_id` in hello_ok and the `linked_projects` capability.
peer-bots.md gains a "Linked projects" section (what a link means, how
linking, mirroring and creating on the other machine work, and the peer
frames), notes that revoking frees the peer's name, and adds the schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ions

Bots drove the owner's own Chrome through the Claude in Chrome extension:
the owner's global `claudeInChromeDefaultEnabled` reached every bot
session, so tabs opened in their Chrome with nothing saying which bot
asked or why.

Each bot now gets a browser of its own:
- Its session runs the Playwright MCP server (pinned, with the vision
  tools for coordinate clicks, plus tabs) against a headless Chrome whose
  profile lives in the bot's directory, so logins persist and stay the
  bot's. Claude and Codex bots both get it. The daemon finds Node itself,
  since launchd's PATH misses version managers, and picks Chrome, else
  Edge, else Playwright's Chromium. `[browser]` in gravityd.toml can turn
  it off or pin Node, the channel, headless and the package.
- Bots start with `--no-chrome`. A per-bot `user_chrome` switch
  (`set_bot_user_chrome`, migration 17) gives one back the owner's Chrome
  for tasks that need their logged-in sessions; it restarts the session.
  The system prompt tells the bot which browsers it has.
- `watch_browser` streams a bot's browser to the app: `browser_tabs` when
  its tabs change and `browser_frame` screencast frames of the tab the bot
  used last, or one the owner picked. Chrome is found through the
  DevToolsActivePort file in the profile; no new dependencies.
- `list_browser_activity` lists the bot's browser steps, newest first, with
  their turn and what started it, and says whose browser each step used.
  Browser steps are titled by what they did ("Opened example.com",
  "Clicked Sign in").

Conversations between bots: `list_agent_conversations` returns the pairs
of bots in a project that have talked, most recent first, and
`list_agent_conversation` one pair's messages oldest first, paged, with
each message's sender, recipient, kind and task state. Both read the two
bots' DMs together; no schema change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clients show the Browser tab, the Chrome switch and the Conversations
section only on daemons that advertise `bot_browser` and
`agent_conversations`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…' conversations

- Browser tab: the bot's own browser, live. Its tabs (following the one
  the bot used last, or one you pick), the page as the daemon streams it,
  and every browser action under the request that started its turn, with
  actions in your own Chrome marked. It streams only while shown.
- "Can use your Chrome" in the bot's Info panel: off by default; allowing
  it restarts the bot with access to your Chrome.
- Memory tab: the bot's FACTS.md as markdown, reread after its turns.
- Conversations: a row at the top of each project in the sidebar opens
  the pairs of bots that have talked, most recent first, and each pair's
  messages as a chat. Every bot has its own bubble and avatar, on a side
  that stays put (the one higher in the sidebar on the left), with the
  message kind and a task's state. It refreshes as they talk and pages
  back on request.

Each appears only on daemons advertising `bot_browser` or
`agent_conversations`. Visual baselines for the new stories were made in
the pinned Playwright container.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bot-browser.md explains why bots no longer use the owner's Chrome, what
each bot's browser can do, how the app watches it, and the [browser]
settings. protocol.md lists the new requests, pushes and capabilities.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chrome keeps a DevTools HTTP connection open whatever the request asks,
so reading the tab list to the end of the stream timed out and the
Browser tab saw no tabs. Found by driving a real bot browser through a
throwaway daemon; the test's fake DevTools now keeps its connections
open the same way, and fails without this fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The app now watches a bot's browser from the moment the bot is selected,
whatever tab is open, so the Browser tab is live when opened; its label
shows a red dot while the bot's browser is open.

Since the phone will watch too, the daemon no longer gives each watching
connection its own screencast. Every viewer of a bot reads one shared
stream: one tab-list poll per browser, one screencast per tab. Streams hold
only the latest value, so a viewer that joins gets the current tabs and
screen at once and a slow one (a phone on a weak link) skips to the newest
frame. A stream stops when its last viewer leaves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bots come back from a daemon restart resuming their conversation, but a
turn that was running when the session stopped did not resume: the bot
sat idle and its open tasks waited forever.

At startup, before sessions start again, the daemon now reads each bot's
transcript for a turn that never ended, and its open tasks. Each bot with
either gets one note: what started the interrupted turn, its open tasks
with their task ids and requests, and to pick up where it left off (checking
its files first, since running commands stopped), or keep waiting if it
was waiting on someone. A note still waiting to be delivered is not sent
again, so quick successive restarts tell a bot once.
`resume_after_restart = false` in gravityd.toml turns it off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r machine

A linked bot's terminal was only reachable on its own machine. Now the
daemon a client is connected to mirrors it: the bot's machine feeds its
terminal output over the peer link (`term_attach`, then `term_frames`
events) into the stand-in's own terminal buffer, so the desktop and a
phone attach to the stand-in exactly as to a local bot, with replay and
resume, and the link carries one feed however many of them watch. Input
and resizes go back as `term_input` and `term_resize` events.

The feed runs while anyone watches, picks up after the last frame it sent
when the link comes back, and stops (`term_detach`) when the last viewer
leaves. A peer only feeds or takes input for bots linked to it; typing
still needs the `control` grant on the client's own daemon.

The desktop shows a linked bot's Terminal tab on daemons advertising
`peer_terminal`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A linked bot's Browser tab now works from the other machine, and from a
phone connected to it. The bot's machine watches its browser for the peer
through the same shared stream its own clients read (`browser_watch`) and
forwards the pushes as `browser_feed` events: tab lists at once, frames
newest only and at most a few a second, so a slow link never queues them.

The other daemon keeps one feed per stand-in and tab choice, shared by all
its viewers, and renames the bot to the stand-in. When the link drops,
viewers are told the machine is offline, and the feeds resume when it is
back. The browser log is read from the bot's machine. Daemons advertise
`peer_browser`; the desktop shows a linked bot's Browser tab on them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new side tab beside Info, Tasks and Files shows every command a bot runs:
what is running now and what finished, foreground and background, each with
its description, command line, how long it took, its exit code and its
output. A background command still running shows the live end of its
output file.

The daemon reads commands from the bot's transcript, alongside the chat:
each `Bash` call from start to result, and a background one until the
runtime's task notification or the bot's `TaskStop`. A command left
running when its session ended reads as stopped. Codex sessions record
commands the same way. Checked against a real bot transcript: 771 commands,
every one resolved.

`list_bot_commands` serves it (read grant, `bot_commands` capability); for
a linked bot it is read from its machine, so the phone sees the same list.
The panel refetches as the bot's turns change and polls while a background
command runs. The refetch-on-turns effect the Memory, Commands and browser
activity panels share is now one hook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Picking up unfinished work only happened when the daemon started. A bot
restarted on its own (a crash restart, a runtime or Chrome change) came back
resuming its conversation but was not told about the turn it was cut off
in, which just stopped there.

The check now runs before every session start: the supervisor calls a hook
while the transcript still ends where the last session stopped, and a bot
cut off mid-turn, or holding open tasks, gets one note once its session is
up. That covers the daemon starting, crashes, runtime and Chrome changes,
and the two new requests:

- `restart_bot`: the session restarts and picks its conversation back up.
- `clear_bot_session`: the bot restarts with a fresh conversation (Claude
  Code without `--continue`; Codex without its saved thread). Its workspace,
  FACTS.md and tasks stay, and its note says the conversation was cleared
  and points at what it keeps.

Both need the `control` grant, work on a linked bot by asking its machine,
and are advertised as `restart_bot`, so the phone can use them. The desktop
adds Restart and Clear chat to the bot's header, each confirmed first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each row in the Commands tab gets a Copy button for its command, and opening
a row shows the full command and its output, each with its own Copy. The
copy button is now one component, shared with code blocks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each artifact now carries `created_by`: the bot (or the owner) a file came
from, and how, shown under the file's name with the bot's avatar.

- Written or edited: the first bot whose file tools touched the path, read
  from its transcript alongside its chat and commands.
- Made by a command: otherwise, the first bot whose command named the path
  (a build, a copy, an image render).
- Uploaded: files in `uploads/` are the owner's.
- Sent: files a bot on another machine handed back with a result, traced
  through the message that carried them to that bot and its machine.

A file nothing can account for is left unattributed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d bots

On a real project a quarter of the files had no maker: bots usually copy
into the artifacts directory through a shell variable
(`cp run.csv $A/31620468-vtrig.csv`, `cp *.png $A/b8b97477-frames/`), so
their commands never held the file's full path. A command now also counts
when it names the file's path inside the artifacts directory, or its
folder, when that name is distinctive enough (12 characters or more) not to
be any file. Archived bots' transcripts are read too, since they made files
before they were archived.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A script a bot writes into the artifacts directory, once run, produces
files no command names. Bots name a task's files after the task
(`<task id>-summary.json`), so a file still unaccounted for goes to the
maker of the other files named after the same task, as made by a command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On native Windows `npx` is `npx.cmd`, a batch script that neither Claude
Code nor Codex can start directly, so every bot's own browser would have
failed to come up. The session now runs it as `cmd /c npx.cmd …`, the form
Claude Code documents for `npx` servers on native Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bot can now split a job across temporary workers with `spawn_worker`:
each is a bot created for one task, handed the brief as an ordinary
delegated task, and archived once that task closes, so its result
comes back as a `done` like any task's.

Workers have their own per-machine cap, `max_workers_per_project`
(default 4), separate from the bot cap, so a project whose permanent
bots fill theirs can still fan out. Spawns past it wait in a FIFO queue
and start as earlier workers finish; worker tasks do not count against
the three-task fan-out. In a linked project a spawn goes to whichever
machine has a free slot, here first. Workers cannot spawn workers.

`list_workers` and `cancel_worker` round out the tools, and the desktop
marks workers in the bot list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	.github/workflows/windows.yml
#	CONTRIBUTING.md
#	apps/desktop/src/components/bot/BotRuntimePicker.tsx
#	crates/gravityd/src/activity.rs
#	crates/gravityd/src/app.rs
#	crates/gravityd/src/db/peers.rs
#	crates/gravityd/src/paths.rs
#	crates/gravityd/src/paths/trust.rs
#	crates/gravityd/src/paths/windows_hooks.rs
#	crates/gravityd/src/peer.rs
#	crates/gravityd/src/peer/hub.rs
#	crates/gravityd/src/peer/inbound.rs
#	crates/gravityd/src/runtime/codex.rs
#	crates/gravityd/src/runtime/codex/approvals.rs
#	crates/gravityd/src/runtime/codex/native.rs
#	crates/gravityd/src/runtime/codex/observations.rs
#	crates/gravityd/src/runtime/codex/worker.rs
#	crates/gravityd/src/runtime/mod.rs
#	crates/gravityd/src/runtime/pty/tests.rs
#	crates/gravityd/src/server.rs
#	crates/gravityd/src/supervisor.rs
#	crates/gravityd/src/supervisor/lifecycle.rs
#	crates/gravityd/src/ws.rs
#	crates/gravityd/src/ws/dispatch.rs
#	crates/gravityd/tests/codex_runtime.rs
#	crates/gravityd/tests/fixtures/codex-server.mjs
#	crates/gravityd/tests/peer_bots.rs
#	docs/bot-runtimes.md
#	docs/peer-bots.md
#	docs/protocol.md
#	docs/windows.md
#	third-party/inventory.json
A project can now name a shared git repository (`set_project_repo`, or
the new Shared repository panel in project settings). Every worker then
starts from a fresh worktree of the branch tip, fetched into a bare
per-project cache on the machine it runs on, so it always begins from
the latest pushed work. When it calls `complete_task`, the daemon
commits what it left, rebases onto the branch and pushes; if that
conflicts it pushes the worker's own branch instead. The result its
parent reads says which commit landed or which branch to merge.

A worker placed on a linked machine clones the same repository there
with that machine's own credentials, and the linked project adopts the
repository when it has none. Git runs non-interactively with timeouts,
one operation per project cache at a time, and a retired worker's
checkout is removed only once nothing in it is left unpushed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…epository

Adds docs/workers.md and points the protocol, peer-bot and architecture
docs at it. Also says why a spawn pinned to an offline machine waits,
and flags workers in list_bots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Project settings now list the project's workers — running (and where),
queued with their position, and recently finished — and the owner can
cancel any spawn that has not finished. The daemon serves this through
`list_workers` and `cancel_worker`, and pushes `workers_updated` whenever
a spawn is queued, placed, waits for a new reason, or finishes.

A worker that stops without its work on the remote — its task cancelled
or expired, or its push failed — no longer loses it. Before it retires,
the daemon on its machine commits what it left and pushes the worker's
own branch, and the worker tells whoever spawned it which branch holds
the work. Nothing unfinished reaches the shared branch. The worker is
archived only once that note has left, so it never speaks after it is
gone, and its checkout is removed once everything in it is on the
remote. The git runner moves to its own module to make room.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Spawning six workers placed one and waited, and the worker that was
created sat without its task. Placing a worker cloned the project's
repository first, inside the lock every placement pass shares, and only
handed over the task once the clone finished — a large repository on a
slow link took longer than the five-minute limit, so every spawn waited
and the worker was retired without ever starting.

Placing a worker now does what create_bot and a task do: it creates the
bot and delegates its brief in one quick step, so every free slot fills
at once, and spawns that do not fit here are offered to linked machines
in parallel, outside the lock. Workers clone, pull and push the shared
repository themselves, as their prompt tells them — in their own
terminal, where a slow clone holds up nobody and a rebase conflict is
resolved by the bot that wrote the work. The daemon steps in only when a
worker retires, saving anything it left unpushed to its own branch in the
background and telling its parent.

Also gives a worker created without its task (the daemon stopped between
the two) its task on the next pass, and makes archiving an archived bot a
no-op instead of tombstoning its name twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bots sent to clients never carried `temporary`, so neither the desktop
nor the phone could mark a worker. A spawn that waited for a slot kept
its "waiting for a free worker slot" note after it started; starting it
now clears the note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… Center

A permission card showed only above the selected bot. With the app open,
the daemon holds a prompt for a card rather than the bot's terminal, so a
prompt from any other bot was visible nowhere: the bot sat waiting for
approval until the window closed and the prompt was denied.

The Control Center now lists every waiting prompt above the decisions,
each naming its bot with a link to open it, answered there as on the bot.
Prompts count as urgent in the Control Center badge and the dock badge,
and one from a bot not on screen raises a toast that opens the Control
Center. Claude Code and Codex prompts both arrive this way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants