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

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.
<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.
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.
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.
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.
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.
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.
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.
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.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.
First-party source is licensed under the Apache License 2.0. See LICENSE and NOTICE.
hooks/register.js 1449 lines1// 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;