SLOPSHOPPER

cosyncing-claude

Drive this Claude session from cosyncing: prompts in, turn state out, permission decisions answered from either side.

newbandguardnetworktimer
★ 47v0.5.13MITupdated 2026-10-10cosyncing/cosyncing/mods/cosyncing-claude
A shopper browsing a rack in a slop shop
README

srcset="apps/client/assets/brand/source/cosyncing-lockup-stacked-reverse.svg"> <img src="apps/client/assets/brand/source/cosyncing-lockup-stacked.svg" alt="cosyncing" width="280">

<a href="https://cosyncing.com/#sync"> srcset="https://cosyncing.com/assets/sync/sync-demo-dark.gif"> <img src="https://cosyncing.com/assets/sync/sync-demo-light.gif" alt="cosyncing app and agent CLI staying in sync through takeover and a permission request" width="830"> </a>

srcset="apps/client/assets/brand/marketing/social-banner-1280x640.png"> <img src="apps/client/assets/brand/marketing/social-banner-white-1280x640.png" alt="Code anywhere. Sync everywhere. Your agents keep working. You keep moving." width="830">

<a href="https://cosyncing.com/">Website</a> · <a href="#install">Install</a> · <a href="#client">Client</a> · <a href="docs/README.md">Docs</a> · <a href="docs/CONTRIBUTING.md">Contributing</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a>


Synchronize and control your agents — from CLI to GUI, from desktop to phone. Pick up right where you left off, anywhere. cosyncing keeps your coding agents in sync across your own network.

The broker runs on the machine where your agents work. It watches their sessions and serves a client that shows each one — grouped by project, with its transcript, diffs, commands, and any prompt waiting on you. Read a session, answer a prompt, or take over. No account to create, no hosted service between the client and the broker.

Supported agents

<a href="https://www.claude.com/product/claude-code" title="Claude Code"><img src="docs/assets/agents/pills/claude.png" alt="Claude Code" height="34"></a> <a href="https://openai.com/codex/" title="Codex"><img src="docs/assets/agents/pills/codex.png" alt="Codex" height="34"></a> <a href="https://opencode.ai/" title="OpenCode"><img src="docs/assets/agents/pills/opencode.png" alt="OpenCode" height="34"></a> <a href="https://pi.dev/" title="Pi"><img src="docs/assets/agents/pills/pi.png" alt="Pi" height="34"></a> <a href="https://www.kimi.com/code" title="Kimi CLI"><img src="docs/assets/agents/pills/kimi.png" alt="Kimi CLI" height="34"></a> <a href="https://github.com/deepseek-ai/deepseek-harness" title="DeepSeek Harness"><img src="docs/assets/agents/pills/dsh.png" alt="DeepSeek Harness" height="34"></a> <a href="https://antigravity.google/" title="Antigravity"><img src="docs/assets/agents/pills/antigravity.png" alt="Antigravity" height="34"></a> <a href="https://github.com/can1357/oh-my-pi" title="omp (oh-my-pi)"><img src="docs/assets/agents/pills/omp.svg" alt="omp (oh-my-pi)" height="34"></a> <a href="https://reasonix.io/" title="Reasonix"><img src="docs/assets/agents/pills/reasonix.svg" alt="Reasonix" height="34"></a> <a href="https://grok.com/" title="Grok Build"><img src="docs/assets/agents/pills/grok.svg" alt="Grok Build" height="34"></a> <a href="https://cline.bot/" title="Cline"><img src="docs/assets/agents/pills/cline.svg" alt="Cline" height="34"></a> <a href="https://kilocode.ai/" title="Kilo Code"><img src="docs/assets/agents/pills/kilocode.svg" alt="Kilo Code" height="34"></a>

One protocol covers all twelve. Per-agent control differs: a Claude Code session in your own terminal syncs with the app in both directions, including approvals, once setup installs its mod; without the mod it opens read-only until you take over. See supported-agent setup for versions and installation, and adapter support for the capability matrix.

Foreground clients can join the same broker-owned Codex, Pi, omp, or Reasonix Drive session without starting a second native Resume. A Claude Code session without the mod keeps its Observe/Take-over flow on another client, while OpenCode keeps its shared-live behavior. Background Observe connections stay read-only.

Experimental: Eight provisional adapters are available to source contributors. Kimi Code observes every session on a kimi web server read-only, drives the ones cosyncing created — prompts, approvals, model selection — and takes over the ones it did not, explicitly. DeepSeek Harness connects to a dsh web host and gives active foreground clients a shared transcript and control surface, with model and reasoning-effort selection, permission presets, the host's own slash commands, and image attachments. General file attachments are not supported — the host accepts image content only — and background resident subscriptions and some message presentation remain follow-up work. Antigravity reads the Antigravity CLI's own conversation store — no server involved — replays every conversation read-only, and drives one through a broker-owned agy child; two clients can share a Drive, and a write from a terminal hands the session back. omp uses its own packaged bridge and native RPC dialect for discovery, live sync, prompts, approvals, commands, models, file input, and session creation. Reasonix observes its bounded local store and resumes through a lazy broker-owned ACP child; joined clients share one writer, while terminal true sync and file input remain unsupported. Grok Build observes its bounded local store and, on 1.0.13 or newer, adds authenticated broker-owned ACP Create/Resume, prompts, approvals, commands, and model/effort/mode controls. Cline keeps bounded default-profile parent/subagent snapshots read-only, while app-created sessions use an isolated broker-owned Hub for Create/Resume, prompts, Stop, approvals, create-time model/mode, and shared Drive. Exact-id terminal handoff stays separate from that writer. Kilo Code observes bounded local SQLite snapshots and, on 7.4.23 or newer, adds authenticated Create/Drive, approvals, model selection, and rename through a broker-owned host on dedicated loopback port 4097. Terminal true sync remains unsupported for all three.

None needs a rollout flag. Managed-host adapters need no terminal left open: an installed cosyncing service starts a host when none is running, restarts one that crashes, and stops only the process it started. A host you started yourself is never stopped, replaced, or reconfigured, and setup names each managed runtime before you agree to it. Install DeepSeek Harness globally with npm install -g @deepseek-ai/dsh — cosyncing looks for dsh on your PATH, so an npx-only install can be talked to but never started or version-checked. See supported-agent setup for each runtime.

Prerequisites

The server runs with Bun 1.3.8 or newer. The one-command installers acquire it when needed; only the npm installation path requires Node.js/npm. The broker is local-only by default. Cross-device use requires a proxy, tunnel, VPN, mesh network, or another operator-owned connectivity method. For a simple private route, see Tailscale Serve; for a self-managed overlay, see WireGuard or EasyTier. After cosyncing setup, you can also copy https://github.com/cosyncing/cosyncing/tree/main/docs/connectivity to a coding agent and ask it to configure your chosen method while keeping the broker bound to loopback. Tokdash is optional but strongly recommended for quota tracking and warnings.

See installation prerequisites for Linux and macOS commands, WSL notes, and Tokdash setup. For the signed one-liner installers, see installing with cosyncing's own installer.

Install

The package contains one JavaScript application bundle and the web client. Supported broker hosts are Linux x64, Linux arm64, Apple Silicon macOS, and Windows x64. Windows ARM64 is not qualified yet, and the broker refuses it — including an x64 process running under ARM64 emulation.

Before setup, install only the agents you use; see agent setup and PATH preflight.

Install the current release with one command:

Linux / Apple Silicon macOS:

curl --proto '=https' --tlsv1.2 -fsSL https://cosyncing.com/install.sh | sh

Windows x64 (PowerShell):

powershell -NoProfile -c "irm https://cosyncing.com/install.ps1 | iex"

Windows 11 x64 is supported. Windows 10 is not supported: Microsoft Defender may classify the installer as a Trojan, and you should not disable Defender to force installation.

The installer verifies the release, installs the broker and a supported desktop client, then runs interactive setup and pairs the client. Headless Linux and Linux arm64 receive the server without a desktop client. Bun is acquired when needed; Node.js/npm are not required. See the installer guide for broker-only commands, unattended installation and signed-channel updates.

npm

If you prefer npm, preinstall Bun and Node.js/npm, then install:

npm install --global cosyncing

Open a new login shell, then configure the service:

cosyncing setup

# After setup, use cosy as the shorthand for cosyncing
cosy restart
cosy doctor
cosy status
cosy pair

setup inspects the machine, shows exactly what it will change, and applies the whole plan or none of it. It copies the broker to ~/.cosyncing/bin/cosyncing, installs a user service that runs that copy with your Bun, and prints your broker URL. The broker refuses to start until setup has committed.

To update, let npm replace the global package, then re-run setup so cosyncing copies the new application into its managed service and reconciles the installation:

npm update --global cosyncing
cosy setup

cosy update reports this package-manager-owned update path; it does not run npm or modify the global package.

After setup

cosy pair --broker-url https://cosy.example.com includes that client-reachable origin in a five-minute, one-use QR code. The URL is not persisted or probed. Omit the flag for an authentication-only offer when the client already knows its broker URL. See the connectivity chooser. Scan the QR from a client to grant access; cosy devices list lists paired devices, and cosy devices revoke <id> revokes one.

After setup, cosy doctor diagnoses the machine without changing it, and cosy status summarizes install, service, agents, and sessions.

Client

The packaged Flutter web app is served by your own broker at /cosy/; it does not fetch application code from a third-party host at runtime. Setup prints the URL; open it in any browser that can reach the broker. Android and desktop clients are available from GitHub Releases. The iOS client will follow later through TestFlight.

<a href="https://cosyncing.com/demo/"> srcset="https://cosyncing.com/assets/shots/demo/real/dark/workspace.png"> <img src="https://cosyncing.com/assets/shots/demo/real/light/workspace.png" alt="cosyncing landscape workspace with a session roster beside a live conversation" width="620"> srcset="https://cosyncing.com/assets/shots/demo/real/dark/sessions.png"> <img src="https://cosyncing.com/assets/shots/demo/real/light/sessions.png" alt="cosyncing portrait client with sessions grouped by project and live status" width="180"> </a>

Server — the broker runs on:

<img alt="macOS on Apple Silicon" src="https://img.shields.io/badge/macOS-Apple%20Silicon-0F766E?logo=apple&logoColor=white"> <img alt="Windows x64" src="https://img.shields.io/badge/Windows-x64-0F766E?logo=windows&logoColor=white"> <img alt="Linux x64 and arm64" src="https://img.shields.io/badge/Linux-x64%20%C2%B7%20arm64-0F766E?logo=linux&logoColor=white">

Clients — the source tree and CI cover six platforms:

<img alt="Android" src="https://img.shields.io/badge/Android-3DDC84?logo=android&logoColor=white"> <img alt="iOS" src="https://img.shields.io/badge/iOS-0D96F6?logo=apple&logoColor=white"> <img alt="Linux" src="https://img.shields.io/badge/Linux-FCC624?logo=linux&logoColor=black"> <img alt="macOS" src="https://img.shields.io/badge/macOS-6E6E73?logo=apple&logoColor=white"> <img alt="Windows" src="https://img.shields.io/badge/Windows-0078D4"> <img alt="Web" src="https://img.shields.io/badge/Web-E34F26?logo=html5&logoColor=white">

Windows x64 hosts the broker natively in this release. Windows ARM64 does not: it is not qualified yet, and the broker refuses it rather than running unverified — including an x64 process under ARM64 emulation, which reports itself as x64 and is detected by asking Windows what the machine is. Intel macOS server hosting remains unsupported. WSL also remains supported, as a Linux host; connectivity software that forwards broker loopback must run where it can reach that broker, so see the method-specific guides if you stay on WSL.

Privacy and security

The broker runs on your machine, under your account. Broker state is stored there; session content is sent only to authenticated clients over the network you choose. cosyncing operates no hosted service in that connection path and includes no analytics or advertising telemetry. Optional features contact only the services they name, such as local Tokdash quota data. cosyncing does not configure or contact connectivity providers; any proxy, tunnel, VPN, or mesh is operator-owned. The npm-installed broker does not silently replace itself: npm owns package updates, and cosy setup reconciles the installed service after an update.

Report vulnerabilities through GitHub private vulnerability reporting, per SECURITY.md.

Repository layout

  • packages/typescript/ — broker, wire-contract owner, agent adapters, transport, and crypto.
  • packages/dart/ — client contract, transport, Flutter adapter, and crypto.
  • apps/client/ — the Flutter application, including every platform runner, test suite, integration driver, and developer tool.
  • contracts/generated/ — broker-owned, flattened client contract snapshot.
  • apps/poc-ui/ — non-production proof-of-concept UI retained for deterministic broker tests.

Development

The repository pins Flutter 3.44.3 in .fvmrc and Bun 1.3.8 in package.json. Run commands from the repository root.

bun install --frozen-lockfile
bun run client:pub-get
bun run typecheck
bun run client:analyze
bun run client:test

Regenerate broker-owned client contracts with bun run contract:generate. CI runs bun run contract:check and fails on a stale snapshot.

Start with docs/README.md and build and test. Read CONTRIBUTING.md and CODE_OF_CONDUCT.md before opening a change; contributions use fork-and-pull-request and require a signed-off commit. Usage questions go to GitHub Discussions and reproducible defects to GitHub Issues — see SUPPORT.md. Installs from a predecessor client start fresh; see local data and upgrades.

License

First-party source is licensed under the Apache License 2.0. See LICENSE and NOTICE.

Source 1 files
hooks/register.js 1449 lines
1// cosyncing true sync for a terminal Claude Code session.
2//
3// One file, no imports. It reaches only the local cosyncing broker, over a private
4// Unix socket: no network host, no process execution, no credential handle, no timer.
5// Every path here is fail-open. No broker, no socket, no refusal: plain Claude.
6//
7// The API authority is the build's own declarations, laid into .claude-plugin/types/
8// when this mod loads. The calls this file makes, and nothing beside them:
9//   $.env.get  $.http.fetch  $.session.id  $.session.version  $.session.cwd
10//   $.session.surfaces  $.session.append  $.prompt.submit  $.turn.abort  $.ui.resolve
11//   $.ui.invalidate  $.ui.log  $.clock.after
12//
13// COSYNCING_CLAUDE_DISABLE=1 is this terminal's own off switch, read at session start (and on a
14// loop revived by a hot reload). It turns the whole mod off: no registration, no poll, no event,
15// no hold and no band, so the session is plain Claude and the app sees it as a mirrored row. The
16// other switch lives in cosyncing's settings and reaches the broker's gate; it stops holds for
17// every terminal at once while the rest of the sync keeps running.
18
19const HOST = 'http://cosyncing.local';
20const ROUTES = {
21  register: '/claude/mod/register',
22  poll: '/claude/mod/poll',
23  hold: '/claude/mod/hold',
24  event: '/claude/mod/event',
25};
26
27const PROTOCOL_VERSION = 1;
28const POLL_WAIT_MS = 20000;
29// A hold waits for as long as the person does, the way Claude's own dialog does: the terminal can
30// answer the whole time, so nothing about the hold itself runs out. Each request in it is bounded
31// instead. The broker parks a held call's long-poll for 20 s at most and renews the hold while it
32// is parked, so a request that has not come back after this long is a broker that stopped
33// answering, and the call goes back to Claude's own dialog.
34const HOLD_POLL_BREAK_MS = 25000;
35// The broker parks a held call's long-poll; it does not answer one at once with nothing in it. A
36// broker that did would have this hook spinning for as long as the hold lasted, which is now as
37// long as the person takes, so a few such answers in a row hand the call back.
38const QUICK_EMPTY_MS = 1000;
39const QUICK_EMPTY_LIMIT = 3;
40// The broker answers an ask at once, held or released, before it waits on anybody. An ask still
41// unanswered after this long is a broker that is not answering, and the call goes back to Claude's
42// own dialog rather than leaving the person's Claude waiting on it.
43const ACK_BREAK_MS = 5000;
44// Nothing waits on an event: the hook that sends one has already moved on. This only stops a
45// broker that never answers from holding up the events queued behind it.
46const EVENT_BREAK_MS = 5000;
47// How long a hold told `settled-elsewhere` waits for the outcome the poll leg is carrying. The broker
48// says so only after it handed that outcome over, so this is a margin for one reply in flight.
49const SETTLED_WAIT_MS = 5000;
50const TRANSPORT_BREAK = 2;
51const QUESTION_TOOL = 'AskUserQuestion';
52// The whole call, for the card's "Show details". Bounded so a hold for a large Write still fits the
53// broker's 64 KB body however its text escapes: 8,000 characters at six bytes each is 48 KB.
54const DETAIL_MAX_CHARS = 8000;
55
56// How long a parked loop waits before it dials again, and never longer than this. A broker that
57// is restarting, a registration that was refused, a dial that cannot land: each one used to END
58// the loop, and only `session.start` ever started one, so `cosy restart` killed sync in every
59// open terminal. Parking on a capped backoff is what lets an idle session re-register on its own
60// once the broker is back, with no hot loop and no spin.
61//
62// The cap is 10 s and not 30 s, and the reason is the promise itself. A backoff that has already
63// climbed to its ceiling is a wait that has to finish BEFORE the next dial, so a 30 s ceiling
64// means a session that parked while the broker was down can sit for 30 s after the broker
65// returns -- the worst case, not the average. Ten seconds is still six small requests a minute
66// per idle terminal, which is nowhere near a spin, and it puts recovery inside a window a test
67// can assert rather than one it has to hope in.
68const BACKOFF_MS = [1000, 2000, 4000, 8000, 10000];
69
70// Refusals no retry can change, because each is a fact about this process: the broker launched it,
71// its Claude is below the floor, it speaks another protocol, or it runs as another user. The loop
72// stops on the first one, the way it stops when there is no socket to dial, and this terminal is
73// plain Claude for the rest of its life. Everything else is retried on the backoff: no broker yet,
74// a refused connection, a row that went away (`no_registration`). `session_claimed` is retried too:
75// the first terminal to re-register after a broker restart gets the claim, and the one refused
76// takes the session over once that process dies or stops polling, which only a retry can see.
77const PERMANENT_REFUSALS = ['broker_child', 'claude_version_too_old', 'protocol_version_mismatch', 'uid_mismatch'];
78
79/**
80 * Every wait this file takes, in one place. Production never changes these: the values are the
81 * constants above. The seam suites scale them down through `tuneForTest`, because a 25 s request
82 * bound or a 10 s park is a case a test has to be able to run in well under a second.
83 */
84const TIMING = {
85  holdPollMs: HOLD_POLL_BREAK_MS,
86  quickEmptyMs: QUICK_EMPTY_MS,
87  ackMs: ACK_BREAK_MS,
88  eventMs: EVENT_BREAK_MS,
89  settledWaitMs: SETTLED_WAIT_MS,
90  backoffMs: BACKOFF_MS,
91  pollWaitMs: POLL_WAIT_MS,
92};
93
94/**
95 * Test seam: replace some of the waits above for this module instance. Unknown keys and values
96 * that are not positive numbers (or, for `backoffMs`, a non-empty list of them) are ignored, so a
97 * typo cannot quietly turn a bound off. Nothing in the product calls this.
98 */
99export function tuneForTest(overrides) {
100  if (!overrides || typeof overrides !== 'object') return;
101  for (const key of Object.keys(TIMING)) {
102    const value = overrides[key];
103    if (key === 'backoffMs') {
104      if (Array.isArray(value) && value.length > 0 && value.every((ms) => typeof ms === 'number' && ms > 0)) TIMING.backoffMs = value.slice();
105    } else if (typeof value === 'number' && value > 0) {
106      TIMING[key] = value;
107    }
108  }
109}
110
111/** The broker's socket file name, spelled once. */
112const SOCKET_FILENAME = 'claude-mod.sock';
113
114/**
115 * The absolute socket path, stamped in by `cosyncing setup` when it writes the installed
116 * marketplace copy, next to the version stamp it already writes. The tracked file keeps the
117 * empty literal, so a `--plugin-dir` run off this checkout falls through to the env rules.
118 *
119 * The stamp exists because a terminal does not inherit the broker's environment. An operator who
120 * moved COSYNCING_HOME would otherwise have a mod dialling `~/.cosyncing` forever, and the mod
121 * can read no files: its whole window on the machine is the literal list in `readEnvironment`
122 * and this constant, which is why setup's copy carries the answer it already knows.
123 */
124const STAMPED_SOCKET_PATH = '';
125
126/**
127 * Which evaluation of this module is running: when it was evaluated, in base 36, and a random tail.
128 *
129 * A hot reload evaluates the module again inside the same Claude process, and the old evaluation's
130 * request chain keeps running after it. The kernel's pid cannot tell the two apart, so every
131 * request carries this tag; the broker keeps the row with the newer one and answers the older one
132 * `superseded`, and the older one stops for good instead of taking the row back.
133 */
134const INSTANCE = Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 12).replace(/[^a-z0-9]/g, '');
135
136// One session per process. The kill switch is the only value cached across polls: the
137// local gates have to answer between polls, and a stale kill switch fails toward
138// "hold", which is the direction the spec's fail-open matrix asks for.
139const state = {
140  socketPath: '',
141  disabled: false,
142  spawned: false,
143  steer: false,
144  debug: false,
145  sessionId: '',
146  /**
147   * The id the broker ACCEPTED a registration for. Not the same fact as `sessionId`, which is the
148   * id this terminal is working with: a terminal refused as `session_claimed` used to adopt the id
149   * anyway, and since the loop only registered when the id changed, it never asked again -- it
150   * polled a row that belonged to another process, was refused on every round, and stayed out
151   * even after that process died.
152   */
153  registeredId: '',
154  cwd: '',
155  surface: '',
156  interactive: false,
157  version: '',
158  turnId: '',
159  /**
160   * When this module last saw the main turn end (`turn.complete` or `session.end`), in ms since the
161   * epoch, or 0 for never. Sent with every registration: a broker that restarted has forgotten it,
162   * and a turn stopped from the app writes no interruption row, so without this the restarted broker
163   * drew that turn as still running in every history read until the next turn ended.
164   */
165  turnEndedAt: 0,
166  /**
167   * True from the main turn's end until the next one starts. A call that arrives then with no
168   * `agentId` is not this conversation's: see `conversationCall`. False until a turn is seen, so a
169   * module loaded mid-turn holds as it always did.
170   */
171  betweenTurns: false,
172  killSwitch: false,
173  /** Open holds, oldest first, each with its own band. See `headBand`. */
174  bands: [],
175  loopToken: 0,
176  /** Whether a loop is running under some token. See `ensureLoop`. */
177  loopStarted: false,
178  /** No socket path in this process's environment: a fact that cannot change, so it is asked once. */
179  loopDeclined: false,
180  /** The permanent refusal that stopped the loop for good, if one did. See PERMANENT_REFUSALS. */
181  refusedForGood: '',
182  seq: 0,
183  /** Consecutive stops in a row, which is the backoff step. Cleared by any good round trip. */
184  backoff: 0,
185  /** Consecutive `again` rounds. The first is retried at once; the rest park on the backoff. */
186  againStreak: 0,
187  transportFailures: 0,
188  /** Set when the broker says a newer evaluation of this module holds the session. Final. */
189  retired: false,
190  /**
191   * The id `session.end` closed. A process that is exiting can still read it from
192   * `$.session.id()`, and registering it again re-created a row for a session that had ended.
193   */
194  endedSessionId: '',
195  /** Request ids of the commands already run, newest last, so a redelivery is not a rerun. */
196  recentCommands: [],
197  /** The events not yet sent, in order. See `report`. */
198  events: Promise.resolve(),
199};
200
201// The debug log and nothing else. `$.ui.log` with no sink appends a dim row to the person's own
202// transcript, so a diagnostic switch turned into lines in the conversation they were having. The
203// build's `{ to: "debug" }` sink is the debug log alone (`claude --debug` or `--debug-file`), and
204// nothing on screen. Callers pass ids, kinds and codes: never a prompt, a steer, or any text a
205// person or another plugin wrote, because the debug log is a file people attach to bug reports.
206function log($, message) {
207  if (state.debug) $.ui.log('cosyncing: ' + message, { to: 'debug' });
208}
209
210// `HttpResponse.text` is a string property, not a method. The build said so in its own
211// declarations and the spike confirmed it on the wire, so this reads it as one.
212function bodyOf(response) {
213  const body = response ? response.text : '';
214  return typeof body === 'string' ? body : '';
215}
216
217async function call($, route, payload, query) {
218  return callFor($, state.sessionId, route, payload, query);
219}
220
221/**
222 * One request to the broker. With `bound`, the wait for its answer ends after `bound.ms` (thrown
223 * as `timeout`) or as soon as `bound.band` is decided here (thrown as `woken`). Without one it is
224 * the engine's own limit, which is only right for a wait no hook is parked on.
225 */
226async function callFor($, sessionId, route, payload, query, bound) {
227  // Never dial without a path we vouch for: an empty `socketPath` makes the URL real.
228  if (!isAbsolutePath(state.socketPath)) throw new Error('socket_unresolved');
229  let target = HOST + ROUTES[route] + '?sid=' + encodeURIComponent(sessionId) + '&inst=' + INSTANCE;
230  if (query) target += '&' + query;
231  const sent = $.http.fetch(target, {
232    method: 'POST',
233    headers: { 'content-type': 'application/json' },
234    body: JSON.stringify(payload),
235    socketPath: state.socketPath,
236  });
237  const response = bound ? await within($, bound.ms, sent, bound.band) : await sent;
238  if (response === TIMED_OUT) throw new Error('timeout');
239  if (response === WOKEN) throw new Error('woken');
240  const status = Number(response ? response.status : 0);
241  let parsed = null;
242  try {
243    parsed = JSON.parse(bodyOf(response) || 'null');
244  } catch (error) {
245    parsed = null;
246  }
247  if (status !== 200 || !parsed || parsed.ok !== true) {
248    const code = parsed && parsed.code ? String(parsed.code) : 'status_' + status;
249    // A newer evaluation of this module has the session. Nothing this one does from now on can
250    // help, and everything it does would take the row from the module the engine is calling.
251    if (code === 'superseded') retire($);
252    const error = new Error(code);
253    // The broker's own refusal, as against a dial that never landed (the fetch rejects) or a reply
254    // that is not the broker's: the broker is there, and it said no to this request.
255    if (parsed && parsed.code) error.refused = true;
256    throw error;
257  }
258  state.transportFailures = 0;
259  return parsed;
260}
261
262/** What `within` resolves with when its time ran out, and when the band it watched was decided. */
263const TIMED_OUT = { bound: 'timeout' };
264const WOKEN = { bound: 'woken' };
265
266/**
267 * `work`, unless `ms` pass first, or `band` is decided here first.
268 *
269 * `$.http.fetch` takes no signal, and the engine gives a request 30 s before it gives up on it. A
270 * hook awaiting a broker that accepts the connection and never answers was stuck for those 30 s,
271 * twice over for a hold, and the person's Claude was stuck behind it. This races the request
272 * against the host's own timer instead. The request is not cancelled, because nothing can cancel
273 * it: it is only no longer waited on, and an answer that comes after is dropped.
274 *
275 * A timer the host refuses leaves the engine's own limit as the only bound, which is what there
276 * was before. The race costs a hook nothing either way: its clock stops while a `$` call is out.
277 */
278function within($, ms, work, band) {
279  return new Promise((resolve, reject) => {
280    let waiting = true;
281    let timer = null;
282    const finish = (settleWith, value) => {
283      if (!waiting) return;
284      waiting = false;
285      if (band && band.wake === wake) band.wake = null;
286      if (timer) {
287        try {
288          timer.cancel();
289        } catch (error) {
290          // Already fired, or gone with the environment that set it.
291        }
292      }
293      settleWith(value);
294    };
295    const wake = () => finish(resolve, WOKEN);
296    if (band) band.wake = wake;
297    try {
298      timer = $.clock.after(Math.max(1, ms), () => finish(resolve, TIMED_OUT));
299    } catch (error) {
300      timer = null;
301    }
302    if (work) work.then((value) => finish(resolve, value), (error) => finish(reject, error));
303    // Nothing to wait on but the band, and no timer to stop waiting: waiting would be for ever.
304    else if (!timer) finish(resolve, TIMED_OUT);
305  });
306}
307
308/** Stop this module's loop for good: a newer evaluation of it owns the session now. */
309function retire($) {
310  if (state.retired) return;
311  state.retired = true;
312  state.loopToken += 1;
313  state.loopStarted = false;
314  log($, 'a newer copy of this mod holds the session: this one stops');
315  breakBand($, 'superseded');
316}
317
318// A fault is the mod's cue to stop talking, not to dial again: a broker that is not
319// there must not turn into a loop of attempts. Two faults clear the band and the loop.
320function fault($) {
321  state.transportFailures += 1;
322  if (state.transportFailures >= TRANSPORT_BREAK) breakBand($, 'transport');
323  return state.transportFailures < TRANSPORT_BREAK;
324}
325
326/**
327 * Where the broker's mod socket is, resolved the way the broker binds it.
328 *
329 * The order is the broker's: an explicit override, then the path setup stamped into this copy,
330 * then `$COSYNCING_HOME/claude-mod.sock`, then `$HOME/.cosyncing/claude-mod.sock`. Anything that
331 * is not absolute is rejected rather than guessed at, because `$.http.fetch` with an empty
332 * `socketPath` does not fail -- it goes out over TCP to whatever answers the host in the URL.
333 * No path we can vouch for means no sync, which is the fail-open shape; the wrong path means
334 * handing the user's prompts to a stranger's process.
335 *
336 * An override that is set decides, even when it is unusable. A relative COSYNCING_CLAUDE_SOCK is
337 * refused here exactly as the broker refuses to bind one, and it does not fall through to the
338 * stamped path: whoever set it meant one particular broker, and the stamped path is usually the
339 * production one.
340 */
341export function resolveSocketPath(sources) {
342  const override = typeof sources.override === 'string' ? sources.override.trim() : '';
343  if (override) return isAbsolutePath(override) ? override : '';
344  const stamped = typeof sources.stamped === 'string' ? sources.stamped.trim() : '';
345  if (isAbsolutePath(stamped)) return stamped;
346  return socketUnder(sources.cosyncingHome) || socketUnder(sources.home, '.cosyncing');
347}
348
349function isAbsolutePath(value) {
350  return typeof value === 'string' && value.startsWith('/');
351}
352
353function socketUnder(dir, ...segments) {
354  if (typeof dir !== 'string') return '';
355  let base = dir.trim();
356  if (!isAbsolutePath(base)) return '';
357  while (base.length > 1 && base.endsWith('/')) base = base.slice(0, -1);
358  const parts = [base === '/' ? '' : base].concat(segments.filter((part) => typeof part === 'string' && part.length > 0));
359  return parts.join('/') + '/' + SOCKET_FILENAME;
360}
361
362async function readEnvironment($) {
363  // Literal reads only, so the shipped inventory says exactly what the mod can see.
364  const socket = await $.env.get('COSYNCING_CLAUDE_SOCK');
365  const disable = await $.env.get('COSYNCING_CLAUDE_DISABLE');
366  const steer = await $.env.get('COSYNCING_CLAUDE_STEER');
367  const spawned = await $.env.get('COSYNCING_SPAWNED');
368  const debug = await $.env.get('COSYNCING_CLAUDE_DEBUG');
369  const cosyncingHome = await $.env.get('COSYNCING_HOME');
370  const home = await $.env.get('HOME');
371  state.socketPath = resolveSocketPath({
372    override: socket,
373    stamped: STAMPED_SOCKET_PATH,
374    cosyncingHome,
375    home,
376  });
377  state.disabled = disable === '1';
378  // Default on, and an explicit 0 is the only off: the operator's terminal is not going to
379  // carry our variable, and a mid-turn message that silently queues instead of steering is a
380  // worse surprise than one that steers. Same convention the broker uses for its own switch.
381  state.steer = steer !== '0';
382  state.spawned = spawned === '1';
383  state.debug = debug === '1';
384}
385
386async function sessionVersion($) {
387  try {
388    const v = await $.session.version();
389    return String((v && (v.base || v.version)) || '');
390  } catch (error) {
391    return '';
392  }
393}
394
395async function currentSessionId($) {
396  try {
397    return String(await $.session.id());
398  } catch (error) {
399    return '';
400  }
401}
402
403// The local gates. The mode and the viewer count are the broker's to read when the
404// hold arrives; these are only the facts the mod can know cheaply, and each one that
405// fails sends the call to Claude's own dialog.
406function canHold() {
407  return state.sessionId.length > 0
408    // Only a terminal the broker accepted: a refused one is Observe, and holds nothing.
409    && state.registeredId === state.sessionId
410    && !state.disabled
411    && !state.retired
412    && !state.spawned
413    && !state.killSwitch
414    && state.interactive === true
415    && state.surface === 'terminal'
416    && state.socketPath.length > 0;
417}
418
419async function registerSession($) {
420  const id = await currentSessionId($);
421  if (!id) return false;
422  state.sessionId = id;
423  // Behind every event already queued. Events go out without a hook waiting on them, and a
424  // `session.end` still on its way when the same id registered again landed after it -- and closed
425  // the row that had just been opened.
426  await state.events;
427  try {
428    const reply = await call($, 'register', {
429      protocolVersion: PROTOCOL_VERSION,
430      sessionId: id,
431      cwd: state.cwd,
432      claudeVersion: state.version,
433      isInteractive: state.interactive,
434      surface: state.surface,
435      instance: INSTANCE,
436      // The turn running right now, if any. A row is born knowing no turn, so without this a
437      // broker restart mid-turn -- or a reload whose first hook was this turn's own turn.start,
438      // reported before any registration existed -- left Stop refused as no_active_turn.
439      ...(state.turnId ? { turnId: state.turnId } : {}),
440      // When the last turn ended, which a restarted broker no longer knows. See `state.turnEndedAt`.
441      ...(state.turnEndedAt > 0 ? { turnEndedAt: state.turnEndedAt } : {}),
442    });
443    // The register reply is the odd one out: there `state` is the row's state name and the
444    // switch sits beside it, while every other reply carries `state.killSwitch`. Reading the
445    // poll shape here read `undefined` off a string and left the switch stuck off.
446    state.killSwitch = reply.killSwitch === true;
447    state.registeredId = id;
448    log($, 'registered ' + id + ' as ' + reply.state + ' peer ' + reply.peerPid);
449    return true;
450  } catch (error) {
451    // Refused -- most often `session_claimed`, another live terminal on the same session. The
452    // loop parks and asks again on its own cadence, which is how this terminal takes the session
453    // over once that process dies or stops polling.
454    state.registeredId = '';
455    const code = String(error.message || error);
456    if (PERMANENT_REFUSALS.includes(code)) {
457      state.refusedForGood = code;
458      log($, 'register refused for good: ' + code + '; staying off');
459      return false;
460    }
461    log($, 'register refused: ' + code);
462    return false;
463  }
464}
465
466// The band is a picture of one open hold. `outcome`, `broken` and `chosen` are written
467// from three places (the poll loop, a turn event, a keypress) and read by the hold
468// loop, which is the only place that decides.
469// The bands are one per open hold, not one shared slot. Two tools can be awaiting a
470// permission at the same moment -- a subagent's call and the main thread's, or two parallel
471// tool uses -- and with a single slot the second ask overwrote the first. The first hold then
472// had no face left to answer from, and its answer went to whichever call held the slot, which
473// is how an approval ends up on a call the user never saw.
474function openBands() {
475  return state.bands.filter((band) => !band.outcome && !band.broken);
476}
477
478/**
479 * The band the keyboard is showing: the oldest ask still waiting that the broker said it holds.
480 *
481 * Not simply the oldest ask. A render can come at any moment -- a keypress, a resize, another
482 * plugin's invalidate -- and one that landed while an ask was still on its way to the broker drew
483 * an undecided band: buttons for a call the broker might be about to release, or never hear of.
484 */
485function headBand() {
486  const open = openBands().filter((band) => band.shown);
487  return open.length > 0 ? open[0] : null;
488}
489
490/**
491 * Put this hold's band on screen.
492 *
493 * Not when the ask arrives -- when the broker has said it is HOLDING the ask. Drawing on the
494 * ask itself flashed a band on every prompt in auto mode, on every prompt with nobody
495 * watching, and on every prompt while the broker was down, because in all three the broker
496 * answers that same request with a release before the first round trip ends. The band is a
497 * promise that somebody is waiting on you; it may only appear once that is true.
498 */
499function showBand($, band) {
500  if (band.shown || band.outcome || band.broken) return;
501  band.shown = true;
502  $.ui.invalidate('ui.render');
503}
504
505function dropBand($, band) {
506  const at = state.bands.indexOf(band);
507  if (at < 0) return;
508  state.bands.splice(at, 1);
509  // A dropped band's picture can outlive it on screen until the engine draws again, and its
510  // buttons are closures over it. Marked, a press on that picture does nothing.
511  band.dropped = true;
512  // Anything that was ever drawn is drawn away. Asking only about `shown` left a band that a render
513  // had painted before its ack -- and that then ended on the deadline or a refusal -- standing with
514  // live buttons.
515  if (band.shown || band.drawn) $.ui.invalidate('ui.render');
516}
517
518function settle($, outcome) {
519  const band = state.bands.find((candidate) => candidate.requestId === outcome.requestId);
520  if (!band || band.outcome || band.broken) return false;
521  band.outcome = outcome;
522  // The hold may be parked on a request the other leg has just made moot.
523  wakeBand(band);
524  // The next ask in the queue takes the band's place as soon as this one leaves it.
525  $.ui.invalidate('ui.render');
526  return true;
527}
528
529/** Let the hold waiting on this band stop waiting on the broker: it has been decided here. */
530function wakeBand(band) {
531  const wake = band.wake;
532  band.wake = null;
533  if (wake) wake();
534}
535
536function breakBand($, why) {
537  const open = openBands();
538  if (open.length === 0) return;
539  log($, 'band cleared: ' + why + ' (' + open.length + ' open)');
540  for (const band of open) {
541    band.broken = why;
542    wakeBand(band);
543  }
544  $.ui.invalidate('ui.render');
545}
546
547function bandTree($, e, band) {
548  const { Box, Text, Button } = $.ui.resolve(e);
549  // Only a human press reaches this closure, which is the point of the band: nothing
550  // else in this file can answer `allow`. The POST fires here rather than from the
551  // hold loop because that loop is parked inside a long-poll when the key goes down.
552  const tap = (behavior) => () => {
553    // `dropped`: the hold behind this picture has already given its call back. The broker may still
554    // hold the request for a few seconds more, and a press here would answer it -- deciding a call
555    // the engine has already put to the person in its own dialog.
556    if (band.dropped || band.outcome || band.broken || band.chosen) return;
557    band.chosen = behavior;
558    log($, 'band tap ' + behavior);
559    void answerBand($, band, behavior);
560  };
561  const asking = band.questions && band.questions.length;
562  const title = asking
563    ? 'cosyncing: Claude is asking a question'
564    : 'cosyncing: Claude wants to run ' + band.tool;
565  if (asking) {
566    // One button, because it is the only answer this band can give. A survey is not
567    // settled by `allow`: its answer is a set of choices, and the choices are on the
568    // app's card or in Claude's own dialog. Offered Allow and Deny anyway, both
569    // buttons did the same thing as the third -- hand the call back, which drops the
570    // app's card and reopens the picker -- so a user tapping what looked like an
571    // approval got a picker instead, twice.
572    // The face names the chord, as the permission band's does: a bare 1 reaches this button only
573    // while the band holds the keyboard, and typed into the composer it is a 1 in the next prompt.
574    return Box({
575      flexDirection: 'column',
576      children: [
577        Text({ children: title }),
578        Box({
579          flexDirection: 'row',
580          children: [
581            Button({ key: 'cosyncing-dialog', label: "Answer in Claude's dialog", hotkey: '1', plain: true, autoFocus: true, onPress: tap('dialog') }),
582          ],
583        }),
584        Text({ dimColor: true, children: 'cosyncing: answer it in the cosyncing app, or ctrl+x then tab, then 1, to have Claude ask here' }),
585      ],
586    });
587  }
588  // Measured on 2.1.289: a Button hotkey arms only while this band holds the keyboard, and a
589  // bare digit typed in the composer answers a survey's rows rather than a plugin's band. So the
590  // band says the chord on its own face and starts its ring on Allow, which is the answer a
591  // person most often means; Enter there is one key from the moment the band takes the keys.
592  return Box({
593    flexDirection: 'column',
594    children: [
595      Text({ children: title }),
596      Box({
597        flexDirection: 'row',
598        children: [
599          Button({ key: 'cosyncing-allow', label: 'Allow', hotkey: '1', plain: true, autoFocus: true, onPress: tap('allow') }),
600          Button({ key: 'cosyncing-deny', label: 'Deny', hotkey: '2', plain: true, onPress: tap('deny') }),
601          Button({ key: 'cosyncing-dialog', label: "Show Claude's dialog", hotkey: '3', plain: true, onPress: tap('dialog') }),
602        ],
603      }),
604      Text({ dimColor: true, children: 'cosyncing: ctrl+x then tab to take these keys' }),
605    ],
606  });
607}
608
609/**
610 * Whether a call is one this terminal's conversation is making, and so one to hold.
611 *
612 * Measured on 2.1.295: when a turn ends, Claude runs a forked agent to guess the next prompt
613 * (`prompt_suggestion` in its debug log), and that fork can call tools. Its calls reach `tool.call`
614 * and `tool.check` in the main loop's envelope, with no `agentId`, but after the main turn's
615 * `turn.complete` and before any `turn.start`. Held, a fork's AskUserQuestion drew a band and an app
616 * card for a question that is in no transcript, and it took the band's place in front of the real
617 * question asked next. A subagent's call carries its `agentId` and is held as before.
618 */
619function conversationCall(e) {
620  if (typeof e.agentId === 'string' && e.agentId.length > 0) return true;
621  return !state.betweenTurns;
622}
623
624async function answerBand($, band, behavior) {
625  if (behavior === 'dialog') {
626    // The human chose Claude's own dialog. Say so over the event leg, so the app's card
627    // closes for the same reason the band did instead of holding a call nobody will run.
628    band.broken = 'dialog';
629    wakeBand(band);
630    // `via` says which of the terminal's own cancels this was, so the app's card can say the
631    // answer is in the terminal rather than in some other client.
632    report($, 'user-cancel', band.requestId, { via: 'dialog' });
633    return;
634  }
635  let reply = null;
636  try {
637    reply = await callFor($, state.sessionId, 'event', {
638      sessionId: state.sessionId,
639      kind: 'hold.answer',
640      requestId: band.requestId,
641      detail: { behavior, source: 'band' },
642    }, undefined, { ms: TIMING.eventMs });
643  } catch (error) {
644    const code = String(error.message || error);
645    log($, 'band answer refused: ' + code);
646    // The registration this band was drawn for is gone, and every hold it had open with it.
647    if (code === 'no_registration' || code === 'peer_mismatch' || code === 'superseded') {
648      holdGone($, band);
649      return;
650    }
651    // The answer never landed, and the call is still held. A band that stayed chosen here left every
652    // button inert, "Show Claude's dialog" included, on a hold that has no deadline.
653    band.chosen = '';
654    $.ui.invalidate('ui.render');
655    return;
656  }
657  // The broker holds no such call any more: answered from the app, ended with its turn, or lapsed.
658  if (reply && reply.answered === false) holdGone($, band);
659}
660
661/**
662 * The broker has no open hold behind this band. Its hold leg is told to stop waiting and ask once
663 * more, which ends it with whatever the broker decided -- the app's answer, a release -- and takes
664 * the band down. The band stays chosen until then, so nothing on it can be pressed in the meantime.
665 */
666function holdGone($, band) {
667  log($, 'band answer found no hold: the call ends with what the broker has');
668  wakeBand(band);
669}
670
671/**
672 * Ask the broker to hold this call, and wait for the answer.
673 *
674 * Returns the outcome, or null when nothing was answered: released by a gate, broken by a turn
675 * event or a keypress, its hook aborted, or a broker that stopped answering. Every null is the
676 * engine's own `ask` put back on the table, which is the fail-open rule. There is no deadline: a
677 * call nobody has answered is still a call somebody can answer, from either side.
678 */
679async function hold($, request) {
680  const band = {
681    // Unique per process, and the broker scopes it by registration generation, so a resumed
682    // process cannot inherit the dead one's card.
683    requestId: 'cm-' + (++state.seq),
684    tool: request.tool,
685    questions: request.questions || null,
686    outcome: null,
687    broken: null,
688    chosen: '',
689    // Whether this hold's band is on screen. `showBand` waits for the broker's first reply,
690    // so a call it is about to release never paints a band at all.
691    shown: false,
692    /** Whether a render ever painted it, which is what decides that dropping it repaints. */
693    drawn: false,
694    /** Set once the hold is over, so a press on a picture of it that is still on screen is inert. */
695    dropped: false,
696    /** Set while the hold waits on the broker, so a decision made here ends the wait. */
697    wake: null,
698  };
699  state.bands.push(band);
700  // The engine aborts a hook it has given up on -- an interrupted turn -- and drops its answer a few
701  // seconds later. A hold with no deadline must end there too, or it would go on asking the broker
702  // about a call nobody is running and keep the app's card open for it.
703  const signal = request.signal;
704  const onAbort = () => {
705    if (band.broken || band.outcome) return;
706    band.broken = 'aborted';
707    wakeBand(band);
708    // Said, so the app's card closes now rather than when the broker notices the silence. An
709    // interrupt is Escape at the keyboard or the app's own Stop; the broker knows which.
710    report($, 'user-cancel', band.requestId, { via: 'interrupt' });
711  };
712  if (signal && typeof signal.addEventListener === 'function') signal.addEventListener('abort', onAbort);
713  if (signal && signal.aborted === true) onAbort();
714  let quickEmpty = 0;
715  try {
716    for (;;) {
717      if (band.broken) return null;
718      if (band.outcome) return band.outcome;
719      // Until the broker has said it holds the call, the wait is for an answer it gives at once.
720      const ms = band.shown ? TIMING.holdPollMs : TIMING.ackMs;
721      const sentAt = Date.now();
722      let reply = null;
723      try {
724        reply = await callFor($, state.sessionId, 'hold', {
725          sessionId: state.sessionId,
726          requestId: band.requestId,
727          tool: request.tool,
728          decision: 'ask',
729          input: request.input,
730          ...(request.detail ? { detail: request.detail } : {}),
731          ...(request.toolUseId ? { toolUseId: request.toolUseId } : {}),
732          ...(request.questions ? { questions: request.questions } : {}),
733        }, undefined, { ms, band });
734      } catch (error) {
735        const code = String(error.message || error);
736        // Decided here while the request was out: by the poll leg, the band, or a turn event.
737        if (code === 'woken') continue;
738        log($, 'hold refused: ' + code);
739        if (code === 'timeout') {
740          log($, 'hold: no answer from the broker within ' + ms + ' ms: handing the call back');
741          return null;
742        }
743        // Not the poll leg's rule. Re-registering from here would bump the registration
744        // generation, and a new generation closes every hold the previous one opened -- including
745        // this one, and any other call this session is parked on. So one leg only: the loop owns
746        // re-registration, and a hold whose row is gone says so and hands the call back.
747        // A refusal here may be about a registration this terminal has already replaced -- a hold
748        // opened before a resume answers after it -- so it changes nothing but this call.
749        if (code === 'no_registration' || code === 'peer_mismatch') {
750          log($, 'hold has no registration any more: handing the call back');
751          return null;
752        }
753        // Any other refusal is about this call -- its body, its shape -- and offering it again would
754        // be refused the same way. It is not a transport fault: counted as one, a single oversize
755        // question cleared every other band this session had up.
756        if (error.refused) {
757          log($, 'hold: the broker will not hold this call: handing it back');
758          return null;
759        }
760        if (!fault($)) return null;
761        continue;
762      }
763      if (reply.state) state.killSwitch = !!reply.state.killSwitch;
764      if (reply.settledElsewhere) {
765        // This call is over, and its outcome went out on the poll leg, which this terminal will read
766        // in a moment. Offering the call again would be refused the same way: before the broker said
767        // so, the re-offer opened a second hold for a call that had already been decided.
768        if (!band.outcome && !band.broken) await within($, TIMING.settledWaitMs, null, band);
769        if (band.broken || !band.outcome) {
770          log($, 'hold settled elsewhere, and the outcome never reached this terminal: handing the call back');
771          return null;
772        }
773        return band.outcome;
774      }
775      if (reply.held === true) {
776        // The broker has the call and is waiting on somebody. That is the cue to put the band
777        // up: not the arrival of the ask, which painted one over every prompt the broker
778        // released in the same round trip -- in auto mode, with nobody watching, and whenever
779        // the row could not answer at all.
780        showBand($, band);
781        continue;
782      }
783      if (reply.verdict) {
784        settle($, { requestId: band.requestId, kind: 'verdict', behavior: reply.verdict.behavior, source: reply.verdict.source });
785      } else if (reply.answer) {
786        settle($, { requestId: band.requestId, kind: 'answer', answers: reply.answer.answers });
787      } else if (reply.release) {
788        settle($, { requestId: band.requestId, kind: 'release', why: reply.release.why });
789      } else if (Date.now() - sentAt < TIMING.quickEmptyMs) {
790        // Still held, said without waiting: see QUICK_EMPTY_MS.
791        quickEmpty += 1;
792        if (quickEmpty >= QUICK_EMPTY_LIMIT) {
793          log($, 'hold: the broker answers without waiting: handing the call back');
794          return null;
795        }
796      } else {
797        quickEmpty = 0;
798      }
799    }
800  } finally {
801    if (signal && typeof signal.removeEventListener === 'function') signal.removeEventListener('abort', onAbort);
802    dropBand($, band);
803  }
804}
805
806/**
807 * Tell the broker something happened, and do not wait for it to listen.
808 *
809 * Hooks awaited this, so a broker that accepted the connection and never answered held up every
810 * turn start and turn end in the person's Claude for the engine's whole 30 s fetch limit. The event
811 * is queued instead, and the queue sends one at a time so the broker still reads a turn's end after
812 * its start; each send has its own bound, so one lost request delays the next by that much at most.
813 * Returns the queue, which is never rejected.
814 */
815function report($, kind, requestId, detail, forSession) {
816  const sessionId = forSession || state.sessionId;
817  if (state.disabled || state.retired || state.spawned || state.refusedForGood || !sessionId) return state.events;
818  const payload = {
819    sessionId,
820    kind,
821    ...(requestId ? { requestId } : {}),
822    ...(detail ? { detail } : {}),
823  };
824  state.events = state.events.then(() => sendEvent($, sessionId, kind, payload));
825  return state.events;
826}
827
828async function sendEvent($, sessionId, kind, payload) {
829  // Queued before the broker refused this terminal for good, and nothing it says would be heard now.
830  if (state.refusedForGood) return;
831  try {
832    await callFor($, sessionId, 'event', payload, undefined, { ms: TIMING.eventMs });
833  } catch (error) {
834    log($, kind + ' event refused: ' + String(error.message || error));
835  }
836}
837
838async function runCommand($, command) {
839  // Defence in depth: the broker's queue delivers once, but a requeue after a write it thought
840  // had failed, or a queue that accepted the same request twice, would otherwise run a prompt twice.
841  const id = typeof command.requestId === 'string' ? command.requestId : '';
842  if (id) {
843    if (state.recentCommands.includes(id)) {
844      log($, 'command ' + command.op + ' ' + id + ' already ran: not running it again');
845      return;
846    }
847    state.recentCommands.push(id);
848    if (state.recentCommands.length > 64) state.recentCommands.shift();
849  }
850  log($, 'command ' + command.op);
851  try {
852    if (command.op === 'prompt' && typeof command.text === 'string') {
853      await $.prompt.submit({ text: command.text, asUser: true });
854    } else if (command.op === 'steer' && typeof command.text === 'string') {
855      // Steering is an in-turn append, so it needs a turn this terminal knows is running. The broker
856      // chose "steer" from the last turn it heard about, and that turn can end before the command
857      // lands here: an append into a finished turn is stored and starts nothing, which left the
858      // person's words unanswered in the transcript with the app showing them as sent. With no
859      // turn running, the words are a prompt.
860      if (steerRoute(state.steer) === 'append' && state.turnId) {
861        const appended = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: command.text }] } });
862        // Two ways this does not land, and both used to be silent. A plugin above refuses the
863        // append and the call resolves to `{ deny: reason }` with nothing stored; and a turn
864        // that ended between the broker's decision to steer and this call leaves an in-turn
865        // append writing into a turn that is no longer there. Either way the person's words
866        // were gone, with the app showing them as sent. The fallback costs one input boundary
867        // and says nothing about it being unusual, which is what a queued prompt is.
868        if (!appended || appended.deny || !appended.uuid) {
869          // Not the refusal's reason: a plugin that refuses an append may quote the words back.
870          log($, 'append refused (' + (appended && appended.deny ? 'a plugin refused it' : 'no row id') + '): submitting as a prompt instead');
871          await $.prompt.submit({ text: command.text, asUser: true });
872        }
873      } else {
874        // Steering off in this terminal, or no turn to steer: the words still go to Claude, one
875        // boundary later. A command that is read and then dropped is a lost message, and the user
876        // has no way to know the app sent it.
877        await $.prompt.submit({ text: command.text, asUser: true });
878      }
879    } else if (command.op === 'abort') {
880      // Only the turn the engine actually started, and only while it is still running. The
881      // broker stamps its current turn onto an abort that arrived without one, so an abort
882      // reaching here with no id means there was no turn to stop -- and stopping "whatever is
883      // current" would interrupt a turn the user never meant to touch.
884      const turnId = String(command.turnId || '');
885      if (!turnId || turnId !== state.turnId) {
886        log($, 'abort refused: no running turn for ' + (turnId || '(none)'));
887        report($, 'command.refused', command.requestId, { op: 'abort', reason: 'no_active_turn' });
888      } else {
889        await $.turn.abort({ turnId });
890      }
891    }
892    // There is no `answer` op. A question's answer resolves the hold it belongs to, because a
893    // mod parked on a hold is not parked on its poll leg at the same time, and an answer handed
894    // over as a queued command sat unread until the hold ended by itself.
895  } catch (error) {
896    // A host error about a prompt or a steer may carry the text it failed on, so for those two
897    // the log says which command failed and no more.
898    const carriesText = command.op === 'prompt' || command.op === 'steer';
899    log($, 'command ' + command.op + ' failed' + (carriesText ? '' : ': ' + String(error.message || error)));
900    // Said to the broker, which tells the app: a message the app showed as sent and the terminal
901    // never delivered was lost without a word. A fixed code, never the error, for the same reason
902    // as the log line above.
903    report($, 'command.refused', command.requestId, { op: String(command.op), reason: 'host_error' });
904  }
905}
906
907/**
908 * One round of the loop: register if the id moved, then long-poll.
909 *
910 * 'ok' is a good round trip, 'again' is worth another immediate try (the row went away, so the
911 * next round registers), and anything else is a reason to park. 'again' deliberately does not
912 * clear the backoff, so a broker that keeps dropping the row between the register and the poll
913 * backs off instead of oscillating.
914 */
915async function pollOnce($, token) {
916  if (state.retired) return 'retired';
917  const id = await currentSessionId($);
918  // Every await below can come back to a loop that a newer one replaced. A retired loop's answer
919  // is about the row it was serving, and writing it into the shared state cleared the NEW loop's
920  // session id between its register and its first poll.
921  if (token !== state.loopToken) return 'stale';
922  // `/clear` and a resume both re-issue the id. Re-register on any change from the
923  // id we hold rather than on a remembered one: the remembered id is the stale fact.
924  if (!id) return 'no-session';
925  // The id `session.end` closed is not registered again. A process on its way out still reads
926  // it, and registering it re-created a row for a session that had ended -- twice, in real runs.
927  if (id === state.endedSessionId) return 'ended';
928  // A changed id registers, and so does an id the broker has not accepted yet: a refused
929  // registration is asked again on the park cadence rather than adopted and polled.
930  if (id !== state.sessionId || id !== state.registeredId) {
931    state.sessionId = id;
932    const accepted = await registerSession($);
933    if (token !== state.loopToken) return 'stale';
934    if (!accepted) return state.refusedForGood ? 'refused-for-good' : 'register-refused';
935  }
936  let reply = null;
937  try {
938    reply = await call($, 'poll', { sessionId: state.sessionId, wait: TIMING.pollWaitMs });
939  } catch (error) {
940    if (token !== state.loopToken) return 'stale';
941    const code = String(error.message || error);
942    if (PERMANENT_REFUSALS.includes(code)) {
943      state.refusedForGood = code;
944      log($, 'poll refused for good: ' + code + '; staying off');
945      return 'refused-for-good';
946    }
947    log($, 'poll refused: ' + code);
948    // A broker that restarted, or that evicted this row as stale, or a row that now belongs to
949    // another process, is not a transport fault: the answer is to register again. Only a dial
950    // that cannot land counts.
951    if (code === 'no_registration' || code === 'peer_mismatch' || code === 'session_mismatch') {
952      state.registeredId = '';
953      return 'again';
954    }
955    if (!fault($)) return 'transport';
956    return 'again';
957  }
958  if (token !== state.loopToken) return 'stale';
959  if (reply.state) state.killSwitch = !!reply.state.killSwitch;
960  if (reply.command) await runCommand($, reply.command);
961  else if (reply.verdict) settle($, { requestId: reply.verdict.requestId, kind: 'verdict', behavior: reply.verdict.behavior, source: reply.verdict.source });
962  else if (reply.answer) settle($, { requestId: reply.answer.requestId, kind: 'answer', answers: reply.answer.answers });
963  else if (reply.release) settle($, { requestId: reply.release.requestId, kind: 'release', why: reply.release.why });
964  return 'ok';
965}
966
967// Chained fetches: each poll is a request the broker holds open, so the loop's own pace is the
968// broker's. A new token retires the previous loop.
969async function runLoop($, token) {
970  while (token === state.loopToken && !state.retired) {
971    const outcome = await pollOnce($, token);
972    if (outcome === 'stale') return;
973    if (outcome === 'ok') {
974      state.backoff = 0;
975      state.againStreak = 0;
976      continue;
977    }
978    if (outcome === 'retired') return;
979    if (outcome === 'refused-for-good') {
980      // Not parked: nothing a later attempt sends could be answered differently.
981      state.loopStarted = false;
982      state.loopDeclined = true;
983      return;
984    }
985    // One `again` is retried at once: the row went away and the next round registers. Two in a
986    // row is a broker that keeps losing the row, and an unbacked-off retry against that was a
987    // spin -- hundreds of registrations a second, each one closing every hold the row had open.
988    if (outcome === 'again') {
989      state.againStreak += 1;
990      if (state.againStreak <= 1) continue;
991    }
992    park($, token, outcome);
993    return;
994  }
995}
996
997function park($, token, reason) {
998  if (state.retired || token !== state.loopToken) return;
999  // An ended session is waiting for `/clear` to hand it a new id: a local read, not a dial, so it
1000  // is retried at the first step and never climbs to the ceiling.
1001  const step = reason === 'ended' ? 0 : state.backoff;
1002  const wait = TIMING.backoffMs[Math.min(step, TIMING.backoffMs.length - 1)];
1003  if (reason !== 'ended') state.backoff += 1;
1004  log($, 'sync parked (' + reason + '), next attempt in ' + wait + ' ms');
1005  // `$.clock.after` and not `$.clock.sleep`: this loop is detached from the dispatch that
1006  // started it, and a sleep there would spend a budget that dispatch no longer has. `after` is
1007  // the host's own timer, and a hot reload cancels its pending waits with the old environment.
1008  try {
1009    $.clock.after(wait, () => {
1010      // A newer `session.start` owns the loop by then; this timer is a leftover.
1011      if (token !== state.loopToken) return;
1012      void runLoop($, token);
1013    });
1014  } catch (error) {
1015    // The host refused the timer. Nothing is driving the loop now, so it must not read as running:
1016    // marked stopped, the next hook's `ensureLoop` starts it again.
1017    state.loopStarted = false;
1018    log($, 'the host refused the park timer: the next hook restarts the loop');
1019  }
1020}
1021
1022function startLoop($) {
1023  const token = ++state.loopToken;
1024  if (state.disabled || state.spawned || state.refusedForGood || !isAbsolutePath(state.socketPath)) {
1025    // Switched off, launched by cosyncing itself (Drive and Take over, which the broker refuses as
1026    // its own child and which hold nothing here anyway), refused for good, or nothing to dial. Each
1027    // is fixed for this process, so the revived loop does not go asking again on every turn.
1028    log($, state.disabled
1029      ? 'COSYNCING_CLAUDE_DISABLE=1: staying off'
1030      : state.spawned
1031        ? 'COSYNCING_SPAWNED=1: a Claude cosyncing started; staying off'
1032        : state.refusedForGood
1033          ? 'refused for good (' + state.refusedForGood + '); staying off'
1034          : 'no broker socket in view; staying off');
1035    state.loopStarted = false;
1036    state.loopDeclined = true;
1037    return;
1038  }
1039  state.loopStarted = true;
1040  // A new loop starts its own count: the one it replaced may have been mid-backoff.
1041  state.backoff = 0;
1042  state.againStreak = 0;
1043  void runLoop($, token);
1044}
1045
1046/**
1047 * Start the loop if nothing is driving it.
1048 *
1049 * A hot reload (plugin files change under a running Claude, which the marketplace copy does
1050 * on `cosy update`) re-evaluates this module: `state` comes back empty and the host cancels
1051 * the pending `$.clock.after` waits along with the old environment. Only `session.start`
1052 * started a loop, and a reload mid-session never fires another one, so the reloaded mod sat
1053 * in every open terminal doing nothing at all -- registered nowhere, holding nothing, with
1054 * the app still showing the row as synced.
1055 *
1056 * The facts `session.start` carried are gone with the old module, so they are read back from
1057 * the build rather than guessed at. `$.session.surfaces()` is the same reading `session.start`
1058 * documents for its own `surface` field ("terminal under the REPL; null for a -p run or the
1059 * SDK, which draw nowhere yet"), and the build's `isInteractive` is described by exactly the
1060 * same split, so a terminal surface is a person at a prompt and anything else is not. If the
1061 * call fails there is nothing to assert, and the mod stays off: registering an invented
1062 * interactive terminal is how a headless run ends up with a hold nobody can answer.
1063 */
1064async function ensureLoop($) {
1065  if (state.retired || state.loopStarted || state.loopDeclined) return;
1066  await readEnvironment($);
1067  if (state.disabled || state.spawned || !isAbsolutePath(state.socketPath)) {
1068    state.loopDeclined = true;
1069    return;
1070  }
1071  if (!state.surface) {
1072    state.surface = await recoveredSurface($);
1073    state.interactive = state.surface === 'terminal';
1074  }
1075  if (!state.version) state.version = await sessionVersion($);
1076  if (!state.cwd) {
1077    try {
1078      const cwd = await $.session.cwd();
1079      if (typeof cwd === 'string') state.cwd = cwd;
1080    } catch (error) {
1081      log($, 'cwd unavailable: ' + String(error.message || error));
1082    }
1083  }
1084  log($, 'loop restarted without session.start (surface=' + (state.surface || 'none') + ')');
1085  startLoop($);
1086}
1087
1088/** The session's own first surface, or '' when the build will not say. */
1089async function recoveredSurface($) {
1090  try {
1091    const surfaces = await $.session.surfaces();
1092    const first = Array.isArray(surfaces) && surfaces.length > 0 ? surfaces[0] : '';
1093    return typeof first === 'string' ? first : '';
1094  } catch (error) {
1095    return '';
1096  }
1097}
1098
1099/**
1100 * An AskUserQuestion answer is returned only when it is exactly the shape the tool
1101 * validates. A wrong shape does not fall back to the human: the question dies and the
1102 * model reads a validator error, which is the worst thing this mod can do to a user.
1103 */
1104/**
1105 * Which call a `steer` command becomes: an in-turn append, or an ordinary queued prompt.
1106 *
1107 * The broker decides whether to ask for steering at all; this is the mod's own half, so
1108 * `COSYNCING_CLAUDE_STEER=0` in one terminal really means that terminal keeps its turns
1109 * unsteered. Neither route loses the message.
1110 */
1111export function steerRoute(steeringEnabled) {
1112  return steeringEnabled === true ? 'append' : 'prompt';
1113}
1114
1115// What 2.1.292's AskUserQuestion takes from a hook: answers of up to 8,192 characters each (its
1116// 32,768 for the whole set is four of them, and a call asks four questions at most). A longer one
1117// would be refused there, and the person would get the picker after the app had said Sent.
1118const ANSWER_MAX_CHARS = 8192;
1119// A number answer, written the way Claude's own number control writes one.
1120const NUMBER_ANSWER = /^-?\d+(\.\d+)?$/;
1121
1122/**
1123 * A multi-select answer read back into its labels, or null for text Claude's picker could not have
1124 * written. The picker joins the chosen labels with ", " and writes a label holding ", " or a double
1125 * quote as a JSON string; this is the parser 2.1.292 reads that back with, so a label with a comma
1126 * in it is one label here, as it is there.
1127 */
1128export function splitLabels(answer) {
1129  const parts = [];
1130  let rest = answer;
1131  for (;;) {
1132    if (rest.startsWith('"')) {
1133      let end = -1;
1134      let escaped = false;
1135      for (let index = 1; index < rest.length; index += 1) {
1136        const char = rest[index];
1137        if (escaped) {
1138          escaped = false;
1139          continue;
1140        }
1141        if (char === '\\') {
1142          escaped = true;
1143          continue;
1144        }
1145        if (char === '"') {
1146          end = index;
1147          break;
1148        }
1149      }
1150      if (end === -1) return null;
1151      try {
1152        const label = JSON.parse(rest.slice(0, end + 1));
1153        if (typeof label !== 'string') return null;
1154        parts.push(label);
1155      } catch (error) {
1156        return null;
1157      }
1158      rest = rest.slice(end + 1);
1159    } else {
1160      const comma = rest.indexOf(', ');
1161      const part = comma === -1 ? rest : rest.slice(0, comma);
1162      if (part.includes('"')) return null;
1163      parts.push(part);
1164      rest = comma === -1 ? '' : rest.slice(comma);
1165    }
1166    if (rest === '') break;
1167    if (!rest.startsWith(', ')) return null;
1168    rest = rest.slice(2);
1169    if (rest === '') return null;
1170  }
1171  return parts;
1172}
1173
1174/**
1175 * The questions as the broker needs them. An option's `preview` is a mock-up or a snippet Claude
1176 * draws beside the option; the app does not draw it, and four of them can carry a hold past the
1177 * broker's 64 KB body, which handed the question back to the picker with no card at all. The tool
1178 * still gets the questions it asked, untouched.
1179 */
1180export function withoutPreviews(questions) {
1181  return questions.map((question) => {
1182    if (!question || typeof question !== 'object' || !Array.isArray(question.options)) return question;
1183    return {
1184      ...question,
1185      options: question.options.map((option) => {
1186        if (!option || typeof option !== 'object' || !('preview' in option)) return option;
1187        const { preview, ...rest } = option;
1188        return rest;
1189      }),
1190    };
1191  });
1192}
1193
1194export function validateAnswers(questions, answers) {
1195  if (!Array.isArray(questions) || questions.length < 1 || questions.length > 4) return false;
1196  if (!answers || typeof answers !== 'object' || Array.isArray(answers)) return false;
1197  if (Object.keys(answers).length !== questions.length) return false;
1198  for (const question of questions) {
1199    if (!question || typeof question.question !== 'string') return false;
1200    if (!(question.question in answers)) return false;