{"id":"473d03b7-528e-497d-90c8-fb89984ea525","slug":"conductor-remote","kind":"mcp","name":"conductor-remote","summary":"Phone control panel for your local Conductor coding agents — monitor workspaces, read live transcripts, view diffs, and send prompts over Tailscale","owner":"hyldmo","repoUrl":"https://github.com/hyldmo/conductor-remote","license":"MIT","licenseTier":"permissive","trust":"community","stars":8,"match":null,"fewStars":false,"format":"mcp","createdAt":"2026-07-19T17:45:43.000Z","addedAt":"2026-10-21T17:45:07.841Z","logoUrl":"https://avatars.githubusercontent.com/u/3465788?u=694d50ef1ccb82e7aecb147367dc99bdac33a30f&v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["utilities"],"tags":[],"runtime":"node","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-21T18:22:24.083Z","sample":false,"url":"https://conduits.dev/tools/conductor-remote","language":"TypeScript","transports":["stdio"],"install":{"npm":"conductor-remote"},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"# conductor-remote\n\nA phone control panel for your **local** Conductor agents. Monitor every\nworkspace, read live transcripts, view diffs, and send prompts — from your\nphone, over your Tailnet, no cloud, no App Store.\n\nInstallable as a PWA (Add to Home Screen). Reads ride Conductor's on-disk SQLite\n\n<img width=\"600\" height=\"800\" alt=\"demo-workspaces\" src=\"https://github.com/user-attachments/assets/e098f964-29b2-4614-b200-9dc977799eb8\" />\n\n## Install\n\nNeeds [Conductor](https://conductor.build) running on the same Mac, Node ≥ 24, and\n[Tailscale](https://tailscale.com) on the Mac + phone. Then:\n\n```bash\nnpm i -g conductor-remote\nconductor-remote service install    # run on login; prints your phone URL\n```\n\n**Private tailnet only?** Pass `--expose tailnet` to skip public Funnel, so the URL\nis reachable only from devices logged into your tailnet:\n\n```bash\nconductor-remote service install --expose tailnet\n```\n\nThe choice is remembered across re-deploys; see **Reachability** below for the trade-off.\n\nOr the one-liner (checks Node, offers `brew install node` if missing, installs,\nregisters the service):\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/hyldmo/conductor-remote/main/install.sh | bash\n```\n\nThe published package needs no local build toolchain or native addons, so installation\nis still a plain `npm i -g`. `service install`\nprints a phone URL with an embedded token; open it and **Add to Home Screen**.\nManage the service with `conductor-remote service status|restart|uninstall`.\n\n`conductor-remote config` prints what the daemon is **actually** configured with and\nwhere each value came from, then cross-checks the configured reachability against what\nTailscale is doing. Change one value with `conductor-remote config set <setting> <value>`.\nThe setter preserves the other values in the LaunchAgent plist and restarts the relay.\n\n**Install flags.** `service install` takes flags for the install-time knobs (each\nalso settable via the env var in brackets — the flag wins when both are given).\nRun `conductor-remote --help` for the full list. The common ones:\n\n| Flag | Env | Purpose |\n| --- | --- | --- |\n| `--expose public\\|tailnet` | `EXPOSE` | Reachability (public Funnel default, or tailnet-only) |\n| `--port <n>` | `RELAY_PORT` | Listen port (default `8787`) |\n| `--token <secret>` | `RELAY_TOKEN` | Pin the shared secret (default: generated + persisted) |\n| `--write-strategy <s>` | `WRITE_STRATEGY` | `applescript` (default) or `sidecar` |\n| `--prevent-screen-lock on\\|off` | `PREVENT_SCREEN_LOCK` | Block the automatic lock during nosleep windows (default `on`) |\n\n**Reachability (`--expose`).** By default the URL is exposed publicly via\n[Tailscale Funnel](https://tailscale.com/kb/1223/funnel) — reachable from any\nbrowser, gated by the embedded 128-bit token (so the phone needs **no** Tailscale\napp). Funnel must be enabled once for your tailnet (Admin console). To keep it\ntailnet-only instead (devices logged into your tailnet, via `tailscale serve`),\ninstall with `--expose tailnet` (or `EXPOSE=tailnet`). The choice is remembered\nacross re-deploys.\n\nThe relay sits on `:443` by default. When another service already holds that\nport (Funnel is per port, and some webhooks only ever dial 443), the deploy leaves\nit alone and mounts the relay on the next free port instead, so the phone URL can\nread `https://<node>:8787/`. `service status` and `config` report whichever\nport is live; a mapping already in place is kept wherever it is.\n\n**Voice (optional).** The phone button immediately left of **+** opens one\nfleet-wide control-room call over WebRTC. Its orchestrator can give a fresh,\npaged and filterable summary of current workspace progress, bring forward one\ndecision at a time, create a workspace, and dispatch an exact prompt. Both\nwrites require a spoken preview and confirmation. The call survives\nworkspace navigation, includes live captions and a typed fallback, and needs\nonly a locally stored OpenAI key; it does not need SIP, Twilio, a webhook, or\npublic ingress. A separate dial-in transport remains available for\nlocked-screen/PSTN use. See\n[Voice setup](./docs/voice-setup.md).\n\n## Architecture\n\n```\n Phone PWA  ──HTTP/poll──►  Relay (Node, on the Mac, bound to Tailscale)\n (React, installable)                 │\n                                      ├─ reads:  node:sqlite ⟶ conductor.db   (workspaces, sessions, transcripts)\n                                      ├─ reads:  git ⟶ each worktree           (diffs vs target branch)\n                                      ├─ usage: provider CLIs ⟶ rolling plan limits (no model prompt)\n                                      ├─ writes: Actuator ⟶ Conductor\n                                      │          ├─ applescript (default): osascript ⟶ focused session\n                                      │          └─ sidecar (opt-in):      unix socket ⟶ exact session\n                                      ├─ workflow: orchestration.db ⟷ coordinator ⟶ tracked UI effects\n                                      ├─ dev:    Run/Stop ⟶ CONDUCTOR_PORT ⟶ tailnet-only HTTPS\n                                      └─ push:   turn ended ⟶ Web Push ⟶ your lock screen\n```\n\n- **Two halves.** A **Node relay** on the Mac (reads Conductor's state, drives\n  the write path) and a **React PWA** the phone installs. Vite builds the PWA to\n  `dist/`, which the relay serves.\n- **Reads are durable.** No Conductor API, no injection — the SQLite schema and\n  git worktrees outlive UI changes.\n- **Plan usage is prompt-free.** The usage gauge asks Conductor's bundled Claude\n  and Codex CLIs for structured rolling limits only while the sheet is open, then\n  caches the result for a minute. Cursor/OpenCode say why no plan quota is available.\n- **Writes are the one fragile nerve** and are isolated behind a swappable\n  `Actuator` (`src/writes/types.ts`, implemented in `src/writes/actuator.ts`).\n- **Workflow orchestration is relay-owned.** A separate WAL SQLite database\n  records frozen roles, jobs, capabilities, UI effects, receipts, and recovery;\n  a cross-process lease prevents two compatible relays from driving the shared\n  Conductor window at once.\n- **Notifications ride the durable side.** The relay watches the same SQLite for\n  turns ending and sends a Web Push straight to the phone — no Conductor process\n  involved, and it works with the app closed.\n\n## Stack\n\nReact 19 · React Router 7 · Vite 7 · Tailwind v4 · `vite-plugin-pwa` · TanStack\nQuery · Zustand · Biome · lucide. The relay is Node 24 (`node:sqlite` + native TS\ntype-stripping), with Drizzle and Zod defining and validating its owned database.\n\n## Requirements\n\n- Node ≥ 24. ([Yarn](https://yarnpkg.com) — Berry, the repo pins `yarn@4` — is\n  only needed to run from source; the published package installs with plain npm.)\n- Conductor running on the same Mac.\n- [Tailscale](https://tailscale.com) on the Mac and the phone (or any shared\n  private network) to reach the relay.\n- For the **write** path only: macOS **Accessibility** permission for whatever\n  runs the relay (Terminal/node) — System Settings ▸ Privacy & Security ▸\n  Accessibility. Reads work without it.\n\n## Run from source\n\nFor contributing or running an unpublished checkout (the `npm i -g` path above\nneeds neither a clone nor a build):\n\n```bash\ngit clone https://github.com/hyldmo/conductor-remote.git\ncd conductor-remote\nyarn install\nyarn build          # builds the PWA into dist/\nyarn start          # serves dist/ + the API (node bin/cli.js)\n```\n\nOr in one step: `yarn preview` (build + start). For live development with HMR,\n`yarn dev` runs Vite (`:5173`, proxying `/api`) alongside the relay in a\n[numux](https://github.com/hyldmo/numux) TUI — one tab each, `numux.config.ts`\npicks the ports (needs [Bun](https://bun.sh) on PATH). Open the URL the relay tab\nprints: it points at Vite's origin and carries the token, which Vite can't print.\n\nThe relay prints a phone URL with an embedded token:\n\n```\nOpen on your phone (same Tailnet):\n  http://100.x.y.z:8787/#token=<generated>\n```\n\nOpen that on the phone and **Add to Home Screen**. The token is stored in\n`localStorage`; every `/api/*` call carries it, so other devices on the LAN\ncan't read your sessions.\n\nRead marks and unsent prompt drafts stay local-first for offline use and are also\nmirrored to `~/Library/Application Support/conductor-remote/prefs.json`. A draft\nincludes its ready attachments and staged agent choices; file bytes stay in the\nworkspace or the relay's pre-workspace staging directory. That durable copy survives\na PWA hostname/origin change and restores the same state on another authenticated\ndevice. Access tokens, uploads still in flight, and in-flight send state remain\ndevice-local.\n\n### Deploy (run on login as a background service)\n\nThe relay must run on the Mac that runs Conductor — it reads the local state DB\nand git worktrees and drives the local sidecar/AX write path, so there is\nnothing to host in the cloud. \"Deployment\" here means installing it as a macOS\n**LaunchAgent** that starts on login and restarts if it dies:\n\n```bash\nyarn deploy            # build dist/ + install & start the LaunchAgent, prints the phone URL\nyarn service status    # state + pid + phone URL\nyarn service restart   # e.g. after Tailscale comes up late, or a code change\nyarn service uninstall # tear it down\n```\n\nFrom a global install the same commands are `conductor-remote service\n<status|restart|uninstall>` (and `conductor-remote service install` in place of\n`yarn deploy`, since `dist/` ships prebuilt in the package).\n\nThe token is now **persisted** (`~/Library/Application Support/conductor-remote/token`),\nso the home-screen URL stays valid across restarts and reboots. Logs land in\n`~/Library/Logs/conductor-remote/`. Two caveats: the agent bakes the absolute\n`node` path at install time — re-run `yarn deploy` after an nvm node upgrade —\nand the AppleScript write path needs Accessibility permission granted to that\n`node` binary (System Settings ▸ Privacy & Security ▸ Accessibility).\n\n### Config (env / flags)\n\nEach knob is an env var (honored by `yarn start` and `service install`) and, for\n`service install`, the CLI flag in the second column. A flag wins over the ambient\nenv. `EXPOSE`/`--expose` is documented under Install above.\n\n`service install` bakes these into the LaunchAgent plist, so that plist is the daemon's\nenvironment. Use `config set` for later changes. `conductor-remote config` reads them back:\n\n```\n$ conductor-remote config\nplist:  ~/Library/LaunchAgents/no.adluna.conductor-remote.plist\nstate:  ~/Library/Application Support/conductor-remote\ndaemon: running  (pid 85882)\n\n  expose                    tailnet      plist\n  port                      8787         default\n  write-strategy            applescript  default\n  …\n  token                     99f6…6b38    token file\n\n  ✓ tailscale agrees: serve only (tailnet)\n```\n\n```bash\nconductor-remote config set port 9000\nconductor-remote config set write-strategy sidecar\n```\n\n| Var | Flag | Default | Purpose |\n| --- | --- | --- | --- |\n| `RELAY_PORT` | `--port` | `8787` | Listen port |\n| `RELAY_HOST` | `--host` | auto (Tailscale `100.x`, else `127.0.0.1`) | Bind address |\n| `RELAY_TOKEN` | `--token` | persisted (auto-generated, reused across restarts) | Override the shared secret |\n| `WRITE_STRATEGY` | `--write-strategy` | `applescript` | `applescript` (focused session) or `sidecar` (precise per-session — see below) |\n| `PREVENT_SCREEN_LOCK` | `--prevent-screen-lock` | `on` | Block the automatic lock during nosleep windows |\n| `AUTO_UPDATE` | `--auto-update` | `auto` | Self-update mode: `auto` / `check` / `off` |\n| `CONDUCTOR_DB` | `--db` | `~/Library/Application Support/com.conductor.app/conductor.db` | State DB |\n| `CONDUCTOR_WORKSPACES` | `--workspaces` | `~/conductor/workspaces` | Worktree root |\n| `PUSH_NOTIFY` | — | `on` | `off` disables push notifications entirely |\n| `PUSH_SUBJECT` | — | the project's repo URL | VAPID `sub` (a `mailto:` or `https:` URL identifying the sender) |\n\nThe token is auto-generated once and persisted, so the home-screen icon keeps\nworking across restarts without any env var. To pin a specific secret (e.g. to\nshare a known URL), set `RELAY_TOKEN`:\n\n```bash\nRELAY_TOKEN=$(openssl rand -hex 16) yarn start\n```\n\n## What works today\n\n- ✅ Live list of active workspaces with agent status (working / idle / done),\n  repo, branch, model, per-chat context %, and an unread mark on any workspace whose chats\n  finished something you haven't looked at — reading it *on the phone* clears it,\n  which Conductor itself only does when you open the workspace on the Mac. Tap a tab's\n  context percentage or the segmented composer donut beside Attachments for estimated\n  initial/chat/thinking/tool-call composition and the approximate payload size of each\n  full-history fork format.\n- ✅ **Closed tabs** beside New chat — search closed conversations in this workspace\n  and restore the original tab with its history intact.\n- ✅ **Models** panel — change Claude Code and Codex defaults for new chats, written\n  to `~/.conductor/settings.toml`, and see rolling plan usage from their structured\n  CLI protocols with no model request. Cursor Agent and OpenCode keep their own\n  provider cards without pretending local accounting is a subscription limit.\n- ✅ Live transcript per session (assistant text, tool calls, queued messages),\n  incremental polling. Provider-native subagents reuse the workflow agent-subtab\n  strip: the parent keeps one compact spawn row, while each native child opens as\n  its own read-only transcript slice instead of duplicating its work inline.\n- ✅ **Fork a chat** with the selected response only, a concise transcript,\n  reasoning, or the full tool-call transcript. Keep it as a new tab over the same files,\n  or switch on **To new workspace** to carry the current committed, staged,\n  unstaged, and untracked-not-ignored code into a separate worktree.\n- ✅ Diff vs the workspace's target branch (file list + colorized patch,\n  including untracked files), with merge controls and **Continue** after a PR\n  lands — the same workspace and chats move onto a fresh branch. Archived chats\n  remain read-only and never show the action.\n- ✅ **Launch and forward dev servers** — the workspace header's Play button\n  starts Conductor's Run task directly when there is one choice. With multiple\n  named `[scripts.run.<id>]` configs it opens a menu and asks which one to start\n  every time—there is no remembered default. The relay then expands the\n  repository's named `[[preview_urls]]` entries (including `$CONDUCTOR_PORT`) and\n  exposes every local HTTP preview at its own tailnet-only HTTPS URL. The primary\n  preview opens directly; additional previews appear in its dropdown. Preview\n  paths, queries and fragments are preserved while only the loopback origin is\n  replaced. Repositories without `preview_urls` use Conductor's exact Open-control\n  destination when it is exposed to Accessibility, with detected-port fallback\n  behavior for current Conductor builds.\n  Open and Stop controls appear once it is running; Stop uses Conductor's own\n  button and removes only this relay's Serve mappings. Forwarding a server that\n  is already up presses nothing in Conductor, so it takes about a second and\n  opens the tab from that same tap. This requires a Run task configured\n  in Conductor and Tailscale on the viewing device, even when the relay itself\n  uses public Funnel.\n- ✅ **Send prompt** — two strategies:\n  - **`applescript`** (default): drives Conductor's real UI send, landing in the\n    *focused* session. Uses the session's own model/permission mode (zero risk of\n    altering the agent). Bring the target workspace to front first.\n  - **`sidecar`** (opt-in, `WRITE_STRATEGY=sidecar`): **broken against current\n    Conductor builds** — Conductor changed the private `-v2-` IPC the send payload\n    was reverse-engineered from, so sends are rejected upstream. AppleScript above\n    is the working path. Fixes welcome (see issue #97 for the probe handover).\n- ✅ **Attach images and files** — tap the paperclip or paste an image into the\n  composer. Text pastes over 2,000 characters become a removable `pasted-text.txt`\n  attachment in chats and New workspace; shorter or offline pastes stay in the\n  editor. Files up to 25 MB are stored in Conductor's own attachment layout before\n  the prompt is sent.\n- ✅ **Deterministic cross-provider Workflow** — configure exact picker-backed\n  planning, exploration, and implementation roles, then turn on **Workflow** in\n  New workspace or an untouched **+ New chat**. The PWA calls the UI-only\n  `POST /api/workflows`; a `202` means the immutable objective, all three frozen\n  role snapshots, the prepared target effect, and a dormant bootstrap explorer\n  are durable before Conductor is touched. One tracked explorer is guaranteed,\n  the planner may request independent extras, every current exploration Baton must\n  be delivered before implementation opens, and review stays visible until another\n  tracked pass or an explicit completion. The phone shows phase, frozen roles, child\n  tabs, exact sanitized failures, and Retry/Adopt/confirmed Replay/Cancel/Complete actions.\n  Model, effort, and Fast are frozen for active roots and children. Generic Plan\n  remains its ordinary independent control; Workflow adds no Plan-specific policy\n  or copy. The planner's no-edit boundary is an instruction plus orchestration gates,\n  not an OS sandbox around its filesystem tools.\n- ✅ **Push notifications** when an agent finishes its turn or hits an error —\n  see below.\n\n## MCP: let your agents drive Conductor\n\n`conductor-remote mcp` is an MCP server on stdio. It gives a coding agent\nrelay-backed reads and ordinary Conductor controls. Workflow Start, recovery, and\nrole editing deliberately remain authenticated phone UI operations.\n\nTwo transports, same tools.\n\n| | |\n|---|---|\n| `search_chats` · `read_chat` | full-text search every chat on this Mac, archived included, then read one |\n| `list_voice_calls` · `search_voice_calls` · `read_voice_call` | list, search, and read saved voice-call transcripts, with surrounding context and recording gaps |\n| `list_workspaces` · `list_chats` · `workspace_diff` · `list_repos` | what is running, and what it changed |\n| `plan_usage` | prompt-free Claude/Codex subscription allowances and reset times |\n| `create_workspace` | start ordinary work in a repo, with an optional first prompt and model/effort/plan/fast choices. Creation uses a deep link; selected settings apply before the prompt |\n| `dev_server` | inspect, start, forward or stop a workspace's configured Conductor Run task; use this instead of launching a long-lived server from an agent shell |\n| `send_prompt` · `stop_turn` · `close_chat` | talk to a running agent, cancel its turn, or hide a chat tab |\n| `split_chat` | move a tangent into a fresh tab, or set `new_workspace` to carry the transcript and current code into a separate worktree |\n| `list_roles` | inspect configured roles and validation issues for lightweight delegation or future Workflows; role editing remains a phone UI action |\n| `delegate_task` · `list_delegations` | spawn a lightweight tracked child from an ordinary chat, or a capability-scoped child from a Workflow root; observe active/failed jobs |\n| `list_models` · `set_agent_options` · `set_default_model` | cached model labels (including the starred default), model, effort, plan, fast; starring a default also selects it for the target chat, matching Conductor |\n| `set_workspace_status` | move a workspace between the sidebar's status groups |\n| `archive_workspace` | put a finished workspace away (Conductor's ⌘⇧A) — deletes the worktree, keeps the chat |\n| `dismiss_prompt` | throw away a prompt the relay is still holding |\n| `keep_awake` | hold this Mac awake with the lid shut, so a long run stays reachable |\n| `relay_logs` | the relay's own log — why a send failed |\n\n**Name it `conductor-remote`, not `conductor`.** Conductor injects an MCP server of its\nown into every agent it runs, and that one is already called `conductor`. Register this\nunder the same name and inside a Conductor workspace the two collide: Conductor's tools\nwin, these tools vanish, and the only trace left is this server's instructions text —\nso it reads as if the tools should be there.\n\n**stdio** — for an agent running on this Mac. The client spawns it as a child process;\nthere is no URL and nothing is exposed.\n\n```bash\nclaude mcp add --scope user conductor-remote -- conductor-remote mcp\n```\n\n`--scope user` because a Conductor workspace is a fresh worktree each time, and a\nproject-scoped server registered against your main checkout does not reach it.\n\n**HTTP** — for an agent that can only reach a URL, at `POST /mcp` on the relay, gated by\nthe same token as `/api/*`.\n\n```bash\nclaude mcp add --transport http conductor-remote https://<your-magicdns>/mcp \\\n  --header \"Authorization: Bearer $(cat ~/Library/Application\\ Support/conductor-remote/token)\"\n```\n\nEither way the relay must be running (`conductor-remote service status`). The stdio\nserver reads the persisted token itself, so it needs no configuration at all.\n\n**Who can reach the HTTP endpoint depends on `EXPOSE`**, and this is the decision worth\nmaking deliberately:\n\n| `EXPOSE` | fronted by | who reaches `/mcp` |\n|---|---|---|\n| `tailnet` | `tailscale serve` | any device signed into your tailnet, from any network |\n| `public` (default) | `tailscale funnel` | anyone on the internet holding the token |\n\n`tailnet` is not LAN-only — a laptop on hotel wi-fi reaches it fine, over WireGuard. But\na *hosted* client is not on your tailnet: an agent running on someone else's servers\nneeds `public`, where the token is the only thing between the internet and\n`create_workspace` / `send_prompt`. Prefer stdio when the agent is local, which is most\nof the time.\n\n| tool | what it does |\n|---|---|\n| `search_chats` | full-text search every chat on the Mac, archived included |\n| `read_chat` | a transcript by `session_id` — works for archived workspaces |\n| `list_voice_calls` | recent saved voice calls with previews and call IDs, including older pages |\n| `search_voice_calls` | search caller and assistant text across saved calls, with IDs for reading context |\n| `read_voice_call` | a saved call's latest entries or a window around a search hit, with partial text and recording gaps labeled |\n| `list_workspaces` | what is running right now, with status and model |\n| `list_chats` | the chat tabs in a workspace |\n| `list_roles` | picker-backed cross-provider role definitions and validation issues |\n| `delegate_task` | enqueue a tracked sibling subtab from an ordinary chat with `session_id`, `role`, and `prompt`; active Workflows require their root's id/current phase capability |\n| `list_delegations` | active and failed delegated jobs |\n| `workspace_diff` | a workspace's diff against its target branch |\n| `list_repos` | repos a workspace can be created in |\n| `plan_usage` | Claude/Codex rolling subscription limits, without sending a prompt |\n| `create_workspace` | start an ordinary new workspace, optionally with a first prompt and agent settings |\n| `dev_server` | inspect/start/stop the configured Run task and its tailnet preview (`status` is read-only; start/stop drive the real UI) |\n| `send_prompt` | send into an existing chat (drives the real UI) |\n| `stop_turn` | cancel a running answer (drives the real UI) |\n| `close_chat` | hide a chat tab without deleting its transcript (drives the real UI) |\n| `set_workspace_status` | set the sidebar status (drives the real UI) |\n| `archive_workspace` | archive a workspace (drives the real UI, deletes the worktree) |\n\nSearch, transcript, workspace/chat, diff, repo, role, delegation-status,\ndev-server status, keep-awake-status, and log reads touch no Conductor UI. `create_workspace` opens a\nConductor deep link, so creation itself needs no Accessibility and steals no focus;\nrequested model, effort, plan, and fast settings are applied later before the first\nprompt. Neither `create_workspace` nor `send_prompt` can start or join a Workflow.\nWorkflow Start is deliberately PWA-only: the relay first accepts `POST /api/workflows`,\nthen its durable coordinator performs the workspace/root effects. For an active Workflow,\n`delegate_task` requires the Workflow id, root session, role, and opaque capability delivered\nfor the current phase; it persists and returns immediately while the coordinator later\nopens/configures/sends the child. MCP cannot start, retry, adopt, replay, cancel,\ncomplete, edit roles, or dismiss a managed failure. Generic Plan remains independent\non the ordinary agent-option routes and is not part of Workflow's frozen tuple.\nAgents are explicitly told to use `dev_server` instead of starting a long-lived server\nfrom their shell. Its start and stop actions go through Conductor's Run controls, which\npreserves the workspace's allocated ports, run-mode policy and process-group cleanup;\nthe MCP tool cannot forbid shell commands, but removes the reason to use one here.\n\n**Lightweight codebase agents need no Workflow.** In an ordinary chat, call `list_roles`,\nthen `delegate_task` with `session_id`, a configured `role`, and a focused `prompt`.\nChoose a role whose instructions fit the task, usually `exploration` for investigation.\nIts model, effort, Fast mode, and preamble come from the role configuration. Repeat the call\nfor independent questions: children run concurrently after their serialized UI setup,\neach appears under its parent in **Delegated agents**, and each returns its result.\nThe parent keeps its own model and role. A completed child stays under the correct\nparent even after its successful job is removed. No planning or implementation phase\nis scheduled, and a Baton's suggested next role is only advice.\n\nOrdinary calls can set `return_mode` (`queue` behind the current turn by default;\nuse `steer` to receive the result during the current turn), `through`\n(a `read_chat` cursor), and `include_thinking` (false by default). These options are\nrefused on Workflow calls, where the coordinator owns the handoff and return path.\nBoth active Workflow roots and their children are refused by ordinary-chat intake;\nonly the root can delegate through its current phase capability. Each accepted ad hoc\njob freezes the configured role, which must use a different provider than its parent.\n\nSkills such as gstack can use the same routing through a user instruction override.\nSee [Conductor agent routing](docs/conductor-agent-routing.md) for a prompt block that\npreserves skill steps and routes helper work through tracked chats without changing\ngstack source.\n\nThe HTTP transport is deliberately minimal: the server never initiates a message, so\nthere is no SSE stream and `GET /mcp` answers 405, which the spec allows. It keeps no\nsession either. A request carrying an `Origin` header is refused outright — a real MCP\nclient sends none and a browser cannot omit one, which closes the DNS-rebinding hole\nwithout the relay having to know its own hostname behind Tailscale's TLS.\n\n**Two agents—or two compatible relay processes—cannot intentionally overlap UI\nwrites.** Conductor has one window, so a priority lock serializes work inside each\nprocess and a lease in `orchestration.db` serializes cooperating processes. Workflow\neffects persist their dispatch boundary and external PID/start identity before they\nmay execute, so a successor reconciles positive evidence instead of blindly repeating\nan abandoned action. Agents remain background priority behind the phone, and past four\nlocally queued operations a caller is refused with \"busy, retry shortly\" rather than\njoining a line it would only time out waiting in.\n\nWorth knowing before you wire it up: `send_prompt` into a chat that is already\nworking **steers that agent** rather than starting a new turn, and `stop_turn`\ndestroys work in flight. `close_chat` keeps the transcript and is reversible with\nthe phone's **Closed tabs** view or Conductor's ⌘⇧T; a working chat is refused until\n`close_running` explicitly confirms the desktop's own warning. The tool descriptions\nsurface each of those choices.\n\nThe workflow icon in either workspace header opens **Roles**. Models there are\nchoices previously read from Conductor's real picker; a missing or ambiguous label\nstays red and Start is refused before the relay opens a tab. The shipped\nrole placeholders stay invalid until an exact model is selected for each role.\nStart freezes all three roles for that run; editing a\ndefinition later affects only the next run. The root receives those frozen model labels\nand responsibilities in its first prompt; it does not need to call `list_roles`, whose\noutput describes mutable defaults for later runs. In the sidebar, a Workflow workspace\nreplaces the active model label with an ordered icon stack: Workflow, planning,\nexploration, then implementation; the frozen stack remains after completion. In\n**New workspace** and the composer of\nan untouched **+ New chat**, the **Workflow** pill is the kickoff switch. Its accepted\nstate appears immediately from the top-level Workflow list, including before a new\nworkspace is bound. The planning tuple is read-only, while the generic Plan control\nkeeps its normal availability and meaning. A delivered root prompt activates the\nalready-recorded bootstrap explorer. The planner can request extras only through its\nWorkflow-scoped `delegate_task` phase\ncapability. Once a chat has a first message or belongs to an active Workflow, the\nkickoff pill is gone. Blocked runs remain visible for phone-owned recovery, and\nreviewing runs remain until **Mark complete**; Cancel stops orchestration without\ndeleting worktrees, chats, or turns.\n\n## Notifications\n\nTurn them on from the **Connect sheet** (the QR button in the workspace list\nheader) ▸ *Notifications*. The relay then pings that phone whenever an agent\n**ends a turn** — which covers finishing, asking you a question, and stopping at\na permission prompt — or **errors**. The notification carries the workspace name,\nthe repo, and the agent's last line; tapping it opens that workspace.\n\nNotifications arrive with the app closed. There's a *Send a test notification*\nlink right under the switch to prove the path end to end.\n\n**On iPhone/iPad you must add the app to your Home Screen first** — iOS only\nexposes notifications to installed web apps, never to a Safari tab. The switch\nsays so if you haven't. The connection also has to be `https://` (the Tailscale\nURL `yarn deploy` sets up, or `127.0.0.1` locally); a plain LAN IP is not a\nsecure context and browsers refuse push there.\n\nUnder the hood: standard Web Push (VAPID + `aes128gcm`), implemented directly on\n`node:crypto` so the package keeps its zero runtime dependencies. Subscriptions\nand the VAPID keypair live in\n`~/Library/Application Support/conductor-remote/push.json` (mode `0600`). The\nrelay only ever POSTs to Apple's/Google's/Mozilla's push endpoint for the phones\nyou subscribed; nothing else leaves the Mac. `PUSH_NOTIFY=off` disables the whole\nthing.\n\n## Layout\n\n```\nsrc/       the Node relay: stable server.ts/mcp.ts entries; http/ startup services\n           and route groups; reads/, writes/, orchestration/, delivery/, agents/,\n           dev-server/, files/, git/, host/, usage/, search/, transcript/,\n           notifications/ and voice/ domains\nweb/       the React PWA (Vite root): app shell, feature component folders,\n           grouped hooks and client helpers\npublic/    icon.svg + PWA PNGs, at the repo root so Conductor's icon lookup\n           finds them, plus the push and self-heal service-worker halves\nscripts/   stable service.ts CLI plus service/ helpers, icon/keep-awake setup,\n           AppleScript manifest copying and repository validators\ntests/     Vitest unit, contract, concurrency and integration suites by domain\ndist/      built PWA (gitignored) — what the relay serves\n```\n\nA per-file map, with what each one owns, is in\n[ARCHITECTURE.md](./ARCHITECTURE.md).\n\n## Security notes\n\n- The relay binds to your Tailnet IP (or loopback), not `0.0.0.0`.\n- All data endpoints require the shared token; static shell assets are public\n  but carry no secrets. The service worker never caches `/api/*`.\n- The write path can drive your real agents — keep the token private and the\n  bind address off untrusted networks.\n- The relay reads Conductor's SQLite DB **read-only** and never writes to it.\n  Your Conductor data stays on your machine. Opening Models makes the already\n  authenticated Claude/Codex CLI request its account-limit snapshot; it sends no\n  prompt, transcript, or workspace data.\n\n## Troubleshooting\n\n### The phone can't reach a tailnet-only relay (Tailscale says Connected)\n\nSuspect **iCloud Private Relay** first. It routes WebKit traffic — Safari, and any\nhome-screen web app — through Apple, around the VPN and its DNS, so MagicDNS never\nanswers. Turn it off in Settings ▸ Apple ID ▸ iCloud ▸ Private Relay.\n\nIt fails permanently rather than slowly, because `<node>.ts.net` keeps resolving\n*publicly* to the Funnel ingress addresses long after Funnel is switched off (measured\nunchanged 10+ minutes later, across three public resolvers), and those addresses answer\nnothing once `serve` replaced `funnel`. A tailnet device gets the node's `100.x` address\nfrom MagicDNS instead. So anything that bypasses MagicDNS lands on a dead address and\nstays there. The same phone works fine on `--expose public`, where those ingress\naddresses are live — which is what makes this look like the relay's fault.\n\nTo tell them apart from the Mac:\n\n```bash\ntailscale ping <phone-name>                        # is the tunnel actually up?\ndig +short <node>.ts.net @1.1.1.1                  # public answer\ndscacheutil -q host -a name <node>.ts.net          # MagicDNS answer\n```\n\nDifferent answers plus a phone that is on the tailnet means something on the phone is\nresolving outside the VPN.\n\n### Something failed and the Mac is somewhere else\n\nThe relay's own log is readable from the phone: **Connect sheet ▸ Relay logs**\n(the QR button in the workspace header). It has three sources — `Live` is the\nrunning relay's console, ordered and timestamped; `relay.log` / `relay.err.log`\ntail the LaunchAgent's files on disk, which is where the output of a *previous*\nprocess survives a crash-and-restart. **Problems only** filters to warnings and\nerrors, and **Copy** puts the visible lines on the clipboard for pasting into an\nissue.\n\nThe same thing over HTTP (the access token is redacted from every response, so\nwhat comes back is safe to share):\n\n```bash\ncurl -s -H \"authorization: Bearer $TOKEN\" \"$RELAY/api/logs?limit=100\"                  # live\ncurl -s -H \"authorization: Bearer $TOKEN\" \"$RELAY/api/logs?file=relay.err.log\"         # on disk\n```\n\nOn the Mac itself, `conductor-remote service logs` still follows both files.\n\n### The relay stops answering when the Mac is on battery\n\n**Symptom.** On battery, the phone loses the relay after a few minutes — even\nthough the LaunchAgent is still running and a keep-awake app (Amphetamine,\nKeepingYouAwake, `caffeinate`) is active. Plug into power and it recovers. This\nbites anyone running the relay on an unplugged laptop; it is **not** a bug in\nconductor-remote.\n\n**Cause — it's *Maintenance Sleep*, not idle sleep.** Confirm the reason:\n\n```bash\npmset -g log | grep \"Entering Sleep state due to\" | tail\n#  'Maintenance Sleep' / 'Sleep Service Back to Sleep'  ← the culprit\n#  'Idle Sleep'                                          ← NOT what's happening\n```\n\nmacOS has two independent sleep paths, and the popular keep-awake tools only\nreach one of them:\n\n| Layer | Sleep type | Controlled by | Keep-awake apps reach it? |\n| --- | --- | --- | --- |\n| Idle timer | `Idle Sleep` | `PreventUserIdleSystemSleep` power assertion | ✅ yes |\n| Standby / Power Nap | **`Maintenance Sleep`** | `pmset` `standby` / `powernap` | ❌ **no** |\n\nAmphetamine, KeepingYouAwake and `caffeinate -i` all hold only the *idle*\nassertion. On battery the Mac isn't idle-sleeping — it's cycling through\n**standby maintenance sleep** (a periodic dark-wake → back-to-sleep loop) that\nlives at the root `pmset` layer no assertion can touch. Running those tools with\n`sudo` changes nothing: the assertion is identical at any privilege, and there is\nno assertion for the standby layer. Closing the lid is the same story one level\ndown: it sleeps an Apple Silicon Mac immediately and no assertion stops that\neither, so a keep-awake app plus a shut lid is still a sleeping Mac. The root\n`disablesleep` flag *does* hold it awake lid-closed, which is one lever `nosleep`\npulls below (in use on an M5 Pro). The other is a bounded `caffeinate -d` display\nassertion. ScreenSaverDaemon checks it before starting the idle screen saver, so\nthe automatic screen lock stays off while the window runs. A manual lock or lid\nclose can still lock the session; sends then park until you unlock (#87).\n\n**Fix — `conductor-remote nosleep`.** It blocks sleep at the root `pmset` layer (the\none no assertion reaches) for a bounded window and **auto-reverts** on exit, Ctrl-C, or\nthe timeout — nothing permanent, nothing left on your machine:\n\n```bash\nconductor-remote nosleep 2h     # also 90m, 30s, bare seconds; no arg = until Ctrl-C\n```\n\nThe confirmation carries the wall-clock **expiry**, measured from when your password lands\nrather than when you typed the command. It also warns that the automatic lock is off:\n\n```text\n✓ Sleep disabled until 21:45 (2h, incl. lid closed). Ctrl-C to restore.\n⚠ Automatic screen lock is disabled for this window. Anyone with physical access can use this Mac.\n```\n\nThis is on by default because the AppleScript write path needs an unlocked session. Configure\nit from the Mac CLI. The value applies to terminal and phone-armed windows:\n\n```bash\nconductor-remote config set prevent-screen-lock off\nconductor-remote config  # reports prevent-screen-lock off from the plist\n```\n\nUse `on` to restore the default. `PREVENT_SCREEN_LOCK=off` remains available as an\nenvironment override for direct relay runs.\n\nOut of the box it prompts for `sudo` and runs in the foreground. Since sending from the\nphone already means manually turning on your hotspot, flipping this on for the session is\nthe same kind of deliberate, revertable switch — no boot daemon, no permanent change.\nSimplest of all: keep the Mac on **AC power**, where it never maintenance-sleeps.\n\n**Stop it asking — `conductor-remote nosleep setup`.** One-time, one password:\n\n```bash\nconductor-remote nosleep setup              # install\nconductor-remote nosleep status             # what's installed, does it work, is sleep blocked now\nconductor-remote nosleep setup --uninstall  # remove both files\n```\n\nIt installs a root-owned helper at `/usr/local/libexec/conductor-remote-nosleep` and a\nsudoers drop-in naming exactly that one command `NOPASSWD`. After it, `nosleep` needs no\npassword — which is also what would let the **relay** arm it, since the LaunchAgent has no\nterminal to prompt on and so can never self-arm without this.\n\nIt is deliberately narrow, because a rule like this is the classic way a machine gets a\nroot hole. The helper lives in a directory only root can write (`/usr/local/bin` is\ngroup-writable by `admin` on a stock Mac, so anything there would be trivially replaceable);\nit is **copied out** of the package rather than referenced inside it, so the self-updater\ncan't smuggle new root-executed code in behind you (a stale copy is reported, never\nsilently refreshed); and the rule carries no argument wildcard — the helper validates its\nown input. The drop-in is checked with `visudo -cf` as a draft and the assembled set is\nre-checked after install, with the drop-in pulled back out if anything is wrong, since a\nbad `/etc/sudoers.d` entry can cost you `sudo` altogether.\n\n**From the phone.** Once the rule is installed, the Connect sheet grows a **Mac** section:\ntap 1h / 4h / 10h to choose a duration, or use the clock to enter the same duration syntax\nas the CLI (`80h`, `90m`, `30s`, or bare seconds). \"Let it sleep\" ends the window early\n(`GET`/`POST`/`DELETE /api/nosleep`, capped at 168h / 7 days). On screens narrower than\n400px, the 4h button is hidden to keep the controls on one row. It reports whether the active\nwindow blocks the screen lock. The armed window is spawned\n*detached*, because the relay restarts itself on every self-update and a plain child would\ndie with it and quietly restore sleep at the worst moment; the relay finds it again after a\nrestart by reading `/var/run/conductor-remote-nosleep.pid`. Only one window may be armed —\narming takes over from any other, including one you left running in a terminal. That is not\npoliteness: two windows would each capture the *other's* already-flipped values and\n\"restore\" those on the way out, leaving a Mac that can never sleep again.\n\nEnding the window — \"Let it sleep\", or the timer running out — actually *sleeps* a\nlid-closed Mac rather than just re-allowing sleep. Clamshell sleep is an event macOS fires\nwhen the lid closes, and the lid closed while sleep was disabled, so restoring\n`disablesleep 0` on its own leaves the Mac sitting awake under a shut lid indefinitely.\nThe relay checks the lid (`AppleClamshellState`) and runs `pmset sleepnow` — no root\nneeded — a beat after answering, so the phone gets its confirmation before the Mac drops\noff the network. An open lid just gets sleep re-enabled, exactly as before.\n\nThe same section carries a **fallback network**: pick a Wi-Fi network the Mac already knows\n(your phone's hotspot), and if the Mac loses its link entirely the funnel watchdog joins it\nand re-registers Funnel, whose ingress a change of public endpoint invalidates anyway. It\nstores **SSIDs only** — macOS holds the credentials, and passing a password to\n`networksetup` would *write* one into the keychain. Off by default, behind a 5-minute\ncooldown, and gated on `route -n get default` finding nothing: the associated SSID is not a\nusable signal, since macOS hides it behind Location Services (this Mac reports \"not\nassociated\" while a default route is live on the same interface).\n\n**Check / undo:**\n\n```bash\npmset -g | grep -i sleepdisabled                    # 1 = sleep blocked\nsudo pmset -b standby 1 powernap 1 disablesleep 0   # restore defaults\n```\n\nWhile `nosleep` is running you can quit any keep-awake app — those only ever asserted\non the idle layer, which was never the problem. It restores on its own; the manual undo\nabove is only for the rare case where it was killed hard.\n\n## Disclaimer\n\nUnofficial and not affiliated with, endorsed by, or supported by Conductor. It\nreads your own local Conductor data and automates your own machine. It depends\non Conductor's on-disk layout (a SQLite DB + git worktrees) and, for the sidecar\nwrite strategy, a private IPC — neither is a public API and both may change\nbetween Conductor releases. See [FINDINGS.md](./FINDINGS.md) for how it's\nstructured to keep breakage rare and cheap to repair. macOS only.\n\n## License\n\n[MIT](./LICENSE)\n"}