Pair your Claude Code with another developer's coding agent (pi, Claude Code or Codex) through a shared room.

Pair your coding agent with a friend's.
"ask nika's agent to run the tests and send me the failures"
Their agent does the work on their machine. The answer lands in your session. Claude Code, Codex, pi, Claude chat, ChatGPT, in any mix. No accounts.

| Agent | Then |
|---|---|
| Claude Code | approve its command, type /reload-plugins, press 1 (Join) |
| Codex | approve its command, start a new session, say "join duet", choose Join (first time: trust duet's hooks) |
| pi | type /reload, confirm |
| Claude chat, ChatGPT | first add the connector: name it duet when you add it (to rename: Remove, then add it again), URL https://mcp-duet.gaioz.online/mcp; then click Join in the panel (its form is the private way) |
Any prompt leaves the room code in the session's history, so the model's provider sees it. Claude Code and pi: the terminal lines below keep it out. Codex joins through its model either way.
Or run it yourself, in a terminal:
| Agent | Paste |
|---|---|
| Claude Code | claude plugin marketplace add qaioz/pi-duet && claude plugin install duet@pi-duet && claude plugin update duet@pi-duet --scope user && { claude plugin enable duet@pi-duet --scope user 2>/dev/null; DUET_ROOM=<room> DUET_NAME=<name> claude; } |
| Codex | codex plugin marketplace add qaioz/pi-duet && codex plugin marketplace upgrade pi-duet && codex plugin add duet@pi-duet && codex "join duet room <room> as <name>" |
| pi | pi install git:github.com/qaioz/pi-duet && pi update git:github.com/qaioz/pi-duet && DUET_ROOM=<room> DUET_NAME=<name> pi |
Two gates, on by default: nothing reaches your agent, and nothing leaves it, without you.

| Gate | Claude Code, Codex | Chat apps |
|---|---|---|
| Request in | 1 Process · 2 Ignore · 3 Process and send | Process · Ignore · Process and send |
| Reply out (full text) | 1 Send · 2 Don't send | Send · Don't send |
Process and send OKs that one reply ahead: it goes out without the Send step. The next request asks again. Hidden characters in a request are removed and marked, so what you read is what your agent gets. Want it hands-free? /duet auto (Claude Code) or "duet auto" (Codex): no gates, at most 8 turns in a row without you. pi always runs this way.

duet.gaioz.online. For sensitive work, self-host one.Guide → setup per agent, updating, team repos, Claude Desktop, how it works, limits.
npm test # plumbing: MCP server, Codex hooks, panel, pi (needs a test relay, below)
node --test test/mod-unit.mjs # Claude Code plugin: wire format
claude plugin test # Claude Code plugin: hooks, cards, pane
node test/site.mjs # website and guide in headless Chromium
node mcpb/build.mjs # rebuild docs/duet.mcpb after changing the server
Test relay: docker run -d --name duet-ntfy-test -p 127.0.0.1:18080:80 -e NTFY_BASE_URL=http://127.0.0.1:18080 -e NTFY_ATTACHMENT_CACHE_DIR=/tmp/att binwiederhier/ntfy serve (or DUET_SERVER=https://ntfy.sh).
| Path | What |
|---|---|
hooks/ | Claude Code plugin (a mod) |
codex/, mcp.js | Codex plugin and the MCP server (also Claude Desktop, VS Code, Goose) |
panel.js, hosted.js, hosted/ | the chat panel (MCP App) and the hosted server |
index.ts | pi extension |
transport.js, lock.js | wire format and the one-window lock |
docs/ | website (GitHub Pages, main:/docs, Basecoat), docs/guide/ built from docs-site/ (Starlight) |
hooks/duet.js 1793 lines1// duet for Claude Code, as a mod: pair this session with another developer's coding agent (pi,
2// Claude Code or Codex) through a shared room on an ntfy relay.
3//
4// /duet new make a room and join it /duet <room> [name] [relay] join a room
5// /duet open the duet pane /duet ask | auto gates on / off
6// /duet off leave the room (and forget it in this folder)
7//
8// Receiving: `$.process.spawn` runs curl against the relay's JSON stream (mods have no streaming
9// network API; Node isn't guaranteed). Sending: `$.http.fetch` POST — which also means a session
10// whose policy refuses mod network requests never joins (curl is never used to go around it).
11//
12// Two gates in ask mode. Gate 1: each request waits as a card above the prompt (1 Process,
13// 2 Ignore, 3 Process and send). Gate 2: every reply Claude sends with the duet tool waits as a card
14// showing the whole reply (1 Send, 2 Don't send); the tool call holds until the press. "Process and
15// send" is the user's OK for one reply ahead: the first send of that request's turn to its sender
16// goes out without the gate 2 card. It lives in peerTurn only (never in what Claude reads or sets)
17// and ends with the turn; a second send, a send to anyone else, or any other turn still waits. Auto mode: no gates, up to MAX_AUTO
18// requests in a row without the user. While Claude works on a peer's request it runs under the
19// user's own permission mode, like any other turn: duet adds no checks of its own. So gate 2 holds
20// the duet tool only; it is not a fence around what leaves the computer: where commands run unasked,
21// a peer can ask Claude to post to the room's relay topic (or anywhere) with curl.
22//
23// Which turn is the peer's (for the spinner, the notes the peer gets, and which request a reply
24// answers): Claude Code hands turn.start the prompt's text wrapped in its own lines ("The duet plugin
25// sent a message: …"), so a turn is the peer's when its text contains a frame duet submitted, until
26// that turn's own turn.complete. duet submits only while Claude is idle, so its turn is the next one.
27//
28// Every function that touches `$` is declared at the top level of this file: Claude Code's
29// validator refuses `$` passed to an imported function. wire.js is pure.
30import {
31 DEFAULT_SERVER, LEAVE_WORDS, MAX_AUTO, MAX_BYTES, MAX_TEXT, attachmentUrl, byteLength, envelope, firstLine, fitName, frameForClaude, isEnvelope, isForMe, placeFor,
32 isName, isPlaceholderName, isRelayUrl, isRoomCode, joinQuestion, newRoomCode, randomId, readJoinFile, sanitize, stripHidden, HIDDEN_MARK, sha256hex, timeOf, topicFor,
33} from "./wire.js";
34
35const PANE = "duet";
36const SEND_TOOL = "mcp__duet__send";
37const REPAIR_POLL_MS = 10_000; // ntfy.sh writes its cache in batches; re-poll a resumed range once
38const MAX_BACKOFF_MS = 30_000;
39const LOCK_STALE_MS = 60_000;
40const JOIN_POLL_MS = 2500; // the join file, looked for while not in a room
41const HISTORY_MAX = 200; // per room, kept in $.store across restarts
42const HISTORY_TEXT_MAX = 1500; // characters of one history entry saved to $.store (memory keeps it whole)
43const HISTORY_BYTES_MAX = 512 * 1024; // one room's saved history, as JSON in UTF-8: 5 rooms stay well under $.store's 4 MiB
44const HISTORY_ROOMS = 5; // rooms whose history is kept; older ones are dropped
45const QUEUE_MAX = 50;
46const BATCH_MAX = 5; // messages handed to Claude in one turn
47const VIA = { pi: "pi", "claude-code": "Claude Code", codex: "Codex", chat: "chat panel" };
48const TOAST_GAP_MS = 15_000; // one "new request" toast per sender per burst
49// Permission modes in which a tool call still asks the user unless a rule allows it. Only used for
50// the one confirm when the user turns auto on in a session that runs commands unasked.
51const ASKING_MODES = ["default", "acceptEdits", "plan", "dontAsk"];
52
53// ---------- state (module variables; the room and the turn in progress are also kept in $.store) ----------
54
55const moduleId = randomId(); // this module instance; a reload loads a new one (kept in DUET_MODULE)
56let token = randomId(); // this window; adopted from $.store after a module reload
57let installId = "";
58let sessionId = "";
59let cwd = "";
60let home = "";
61let duetDir = ""; // ~/.duet (DUET_HOME): the lock every duet client on this computer shares
62let server = DEFAULT_SERVER;
63let defaultName = "";
64let permissionMode = ""; // "" until Claude Code reports it (classic.* events)
65
66let room = null; // { code, name, key, fromId, topic, lockKey, fileLock, mode, cursor, server, riskOk }
67let generation = 0; // bumped on every join and leave; loops of an older room stop
68let joinEpoch = 0; // bumped by /duet off: a join still in flight then gives up
69let joinedAt = 0; // when this window's own join went out: a join just after it is the others' answer
70let wakeSupervisor = null;
71let child = null; // the running curl stream
72let connected = false;
73let connError = "";
74let heartbeat = null;
75let joinPoll = null; // the join file's timer, while not in a room (interactive sessions only)
76let watchJoin = false; // this session looks for the join file (it can draw): again after a leave
77let joinOffer = null; // { room, name, relay, question }: a join file taken, waiting for 1 Join / 2 Ignore
78let heldCursor = null; // the newest resume point, saved once nothing received is still open
79const seen = new Set();
80const peers = new Map(); // name -> { via, at, left }
81
82let queue = []; // messages waiting for the user (ask) or for Claude to be free (auto)
83const lastToast = new Map(); // sender -> time of the last "new request" toast
84let pendingPeer = null; // { envs, text, roomKey, submitted, preSend }: taken, waiting for Claude to be idle
85let expected = []; // [{ text, froms, roomKey, envs, at, preSend }]: submitted frames whose turn hasn't started yet
86let peerTurn = null; // { froms, roomKey, turnId, waitNoted, answers, preSend }: preSend = the sender whose reply is OK'd ahead ("" once used)
87let runningTurn = ""; // the main loop's turn in progress, "" while Claude is idle
88let userPromptSince = false; // the user's own prompt entered since duet's last submission
89let outbox = []; // [{ id, text, to, decision }]: replies waiting at gate 2
90let autoTurns = 0;
91let paused = false;
92
93let history = []; // { at, who, text, note }: the room as the pane shows it, read-only
94let historySave = null; // a pending $.store write of the history
95let tab = "history"; // the pane's open tab: "history" | "settings"
96let paneNote = ""; // one terse line under Settings ("code copied")
97const sent = new Map(); // our messages' ids -> first line, to show what a reply answers
98let host = ""; // this computer's name, for the folder hash a join carries (see wire.js placeFor)
99let lineChain = Promise.resolve(); // received lines, one at a time in arrival order (stream and repair poll)
100const warnedAbout = new Set(); // warnings already given: "crowd", "place:<name>"
101
102const viaLabel = (via) => (Object.hasOwn(VIA, String(via)) ? VIA[via] : "");
103const oneLine = (s) => sanitize(s, 200).replace(/\n/g, " "); // a name or list of names on a card, safe to draw
104const livePeers = () => [...peers.entries()].filter(([, p]) => !p.left);
105const peerList = () => livePeers().map(([n]) => n).join(", ");
106const peerNames = () => (peerTurn ? peerTurn.froms.join(", ") : peerList() || "the room");
107const autoActive = () => !!room && room.mode === "auto" && !paused;
108const busyWithPeer = () => !!(pendingPeer || peerTurn || expected.length);
109// exact: the turn's text holds the frame duet submitted. Only then does a "Process and send" carry over.
110const peerTurnFrom = (x, turnId, exact) => ({
111 froms: x.froms,
112 roomKey: x.roomKey,
113 turnId,
114 waitNoted: false,
115 answers: (x.envs ?? []).map((m) => ({ from: m.from, id: m.id })),
116 preSend: exact && x.preSend && x.froms.length === 1 ? x.froms[0] : "",
117 agents: [], // subagents started during this turn: their sends count as the turn's
118});
119
120// ---------- small helpers that use $ ----------
121
122function redraw($) {
123 $.ui.invalidate("ui.render");
124}
125
126function wait($, ms) {
127 return new Promise((resolve) => {
128 $.clock.after(ms, resolve);
129 });
130}
131
132// Simple history: name · time · text. Notes (joins, leaves) are dim lines. Saved per room.
133// The pane keeps the whole text in memory (it is where the user reads a request in full); only the
134// copy saved to $.store is cut, to HISTORY_TEXT_MAX characters an entry.
135function remember($, entry) {
136 const text = String(entry.text ?? "");
137 history.push({ at: new Date().toISOString(), who: entry.who ?? "", text, ...(entry.note ? { note: true } : {}) });
138 if (history.length > HISTORY_MAX) history = history.slice(-HISTORY_MAX);
139 if (!room || historySave) return;
140 const key = "history:" + room.key;
141 // One write per burst.
142 historySave = $.clock.after(200, () => {
143 historySave = null;
144 void $.store.set(key, fitHistory(history)).catch(() => {});
145 });
146}
147
148// The newest entries whose JSON fits HISTORY_BYTES_MAX (non-ASCII text, escapes: a character can be
149// several bytes), so a full $.store never blocks the writes of the cursor, the room and the lock.
150function fitHistory(full) {
151 const list = full.map((h) => (typeof h.text === "string" && h.text.length > HISTORY_TEXT_MAX ? { ...h, text: h.text.slice(0, HISTORY_TEXT_MAX) + "…" } : h));
152 let total = 2;
153 let i = list.length;
154 while (i > 0) {
155 const size = byteLength(JSON.stringify(list[i - 1])) + 1;
156 if (total + size > HISTORY_BYTES_MAX) break;
157 total += size;
158 i--;
159 }
160 return i ? list.slice(i) : list;
161}
162
163async function canDraw($) {
164 try {
165 const surfaces = await $.session.surfaces();
166 return surfaces.some((s) => s === "terminal" || s === "desktop");
167 } catch {
168 return false;
169 }
170}
171
172async function publish($, relay, topic, env) {
173 const body = JSON.stringify(env);
174 const bytes = byteLength(body);
175 if (typeof env.text === "string" && env.text.length > MAX_TEXT) throw new Error(`${env.text.length} characters, limit ${MAX_TEXT}: send the key part, or split it`);
176 if (bytes > MAX_BYTES) throw new Error(`${Math.round(bytes / 1000)} KB, limit ${MAX_BYTES / 1000} KB: send the key part, or split it`);
177 const res = await Promise.race([
178 $.http.fetch(`${relay}/${topic}`, { method: "POST", body }),
179 new Promise((_, reject) => {
180 $.clock.after(15_000, () => reject(new Error("relay timeout (15 s)")));
181 }),
182 ]);
183 if (!res.ok) {
184 const hint =
185 res.status === 429
186 ? " (rate limit: wait a minute)"
187 : bytes > 4000 && (res.status === 400 || res.status === 413)
188 ? " (relay takes no long messages: split under 3.8 KB)"
189 : "";
190 throw new Error(`relay HTTP ${res.status}${hint}`);
191 }
192}
193
194function sendNote($, note, to) {
195 if (!room) return;
196 const env = envelope({ fromId: room.fromId, from: room.name, kind: "note", note, ...(to ? { to } : {}) });
197 void publish($, room.server, room.topic, env).catch(() => {});
198}
199
200// The resume point to keep: just before the oldest message not yet handled, or the newest seen.
201function resumePoint() {
202 const open = pendingPeer?.envs?.[0] ?? expected[0]?.envs?.[0] ?? queue[0];
203 if (open) return open._prev ?? null;
204 return heldCursor;
205}
206
207async function saveCursor($) {
208 if (!room) return;
209 const cursor = resumePoint();
210 if (!cursor) return;
211 if (cursor === heldCursor) heldCursor = null;
212 await $.store.set("cursor:" + room.key, cursor);
213}
214
215// The turn in progress, kept across a module reload (which resets module variables).
216async function saveTurn($) {
217 await $.store.set("turn:" + sessionId, { pendingPeer, expected, peerTurn, runningTurn, at: Date.now() });
218}
219
220// ---------- receiving ----------
221
222// Runs for the whole session: idles until a room is joined, then keeps one curl stream open.
223async function supervise($) {
224 let backoff = 1000;
225 for (;;) {
226 if (!room) {
227 await new Promise((resolve) => {
228 wakeSupervisor = resolve;
229 });
230 wakeSupervisor = null;
231 continue;
232 }
233 const gen = generation;
234 const started = Date.now();
235 await streamOnce($, room, gen);
236 if (gen !== generation) {
237 backoff = 1000;
238 continue;
239 }
240 if (Date.now() - started > 60_000) backoff = 1000;
241 await wait($, backoff);
242 backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
243 }
244}
245
246async function streamOnce($, r, gen) {
247 const floor = r.cursor;
248 const q = floor ? `?since=${encodeURIComponent(floor.id || String(floor.time))}` : "";
249 let buf = "";
250 let repair = null;
251 try {
252 // The URL holds the topic, which is the room's secret: pass it on stdin, not in argv (ps).
253 // Every part is ours: the relay passed isRelayUrl, the topic is hex, the id is URI-encoded.
254 const stream = $.process.spawn({
255 argv: ["curl", "-sSfN", "--speed-limit", "1", "--speed-time", "90", "-K", "-"],
256 input: `url = "${r.server}/${r.topic}/json${q}"\n`,
257 });
258 child = stream;
259 if (floor) repair = $.clock.after(REPAIR_POLL_MS, () => void repairPoll($, r, gen, floor).catch(() => {}));
260 for await (const piece of stream) {
261 if (gen !== generation) break;
262 if (piece.stream === "stderr") {
263 connError = sanitize(piece.text, 200).trim();
264 redraw($);
265 continue;
266 }
267 buf += piece.text;
268 let nl;
269 while ((nl = buf.indexOf("\n")) >= 0) {
270 await queueLine($, r, gen, buf.slice(0, nl), true, floor);
271 buf = buf.slice(nl + 1);
272 }
273 }
274 } catch (err) {
275 connError = /ENOENT|not found|cannot start/i.test(String(err?.message)) ? "curl missing (needed to receive)" : String(err?.message ?? err);
276 } finally {
277 child = null;
278 repair?.cancel?.();
279 if (connected) {
280 connected = false;
281 redraw($);
282 }
283 }
284}
285
286async function repairPoll($, r, gen, floor) {
287 if (gen !== generation) return;
288 try {
289 const res = await $.http.fetch(`${r.server}/${r.topic}/json?poll=1&since=${encodeURIComponent(floor.id || String(floor.time))}`);
290 for (const line of res.text.split("\n")) await queueLine($, r, gen, line, false, floor);
291 } catch {}
292}
293
294// One line at a time, in arrival order: a long message's download mustn't let a later line (or a
295// repair-poll line) overtake it.
296function queueLine($, r, gen, line, live, floor) {
297 lineChain = lineChain.then(() => handleLine($, r, gen, line, live, floor)).catch(() => {});
298 return lineChain;
299}
300
301async function handleLine($, r, gen, line, live, floor) {
302 if (gen !== generation || !line.trim()) return;
303 let evt;
304 try {
305 evt = JSON.parse(line);
306 } catch {
307 return;
308 }
309 if (evt.event === "open") {
310 connected = true;
311 connError = "";
312 redraw($);
313 return;
314 }
315 if (evt.event !== "message" || typeof evt.id !== "string" || seen.has(evt.id)) return;
316 // ntfy answers a since= id it doesn't have with its whole cache: skip what's before the resume point.
317 if (floor && ((floor.id && evt.id === floor.id) || evt.time < floor.time)) return;
318 seen.add(evt.id);
319 if (seen.size > 1000) seen.delete(seen.values().next().value);
320 const fresh = !(evt.time < Date.now() / 1000 - 12 * 3600); // never act on anything older than 12 h
321 let body = fresh ? evt.message : null;
322 // A long message's body is an attachment on the relay: fetch it only from this relay's own /file/
323 // path, since anyone can post an attachment that points anywhere.
324 const a = evt.attachment;
325 if (fresh && a && typeof a.url === "string") {
326 body = null;
327 const url = attachmentUrl(a, r.server, MAX_BYTES + 4096);
328 if (url) {
329 let why = "";
330 try {
331 // Never wait on a download for long: a stuck one would stop everything after it.
332 const res = await Promise.race([
333 $.http.fetch(url),
334 new Promise((_, reject) => {
335 $.clock.after(20_000, () => reject(new Error("timeout")));
336 }),
337 ]);
338 if (res.ok) body = res.text;
339 else why = "expired on the relay";
340 } catch {
341 why = "couldn't be downloaded";
342 }
343 if (why) {
344 remember($, { text: `long message ${why} · lost`, note: true });
345 $.ui.toast(`duet: long message ${why} · lost`);
346 }
347 }
348 }
349 let env = null;
350 try {
351 env = body == null ? null : JSON.parse(body);
352 } catch {}
353 if (gen !== generation) return;
354 if (isEnvelope(env)) {
355 // Where to resume so this message comes again if it is still open at a restart or a move.
356 // A first message has no previous id: resume from just before its second (ntfy takes a time).
357 env._prev = r.cursor ?? { id: "", time: evt.time - 1 };
358 onEnvelope($, r, env);
359 }
360 if (live) {
361 r.cursor = { id: evt.id, time: evt.time };
362 heldCursor = r.cursor;
363 await saveCursor($);
364 }
365}
366
367// Our own name from another client (another computer: the local lock can't see it). Warn, once per
368// client; only for what it sent since this window joined (not a replay from before), and not from
369// this very folder (a window taking over).
370function sameName($, r, env) {
371 if (env.fromId === r.fromId || (env.kind !== "join" && env.kind !== "msg") || warnedAbout.has("same:" + env.fromId)) return;
372 if (String(env.from).normalize("NFC").toLowerCase() !== r.name.normalize("NFC").toLowerCase()) return;
373 if ((env.place && env.place === r.place) || !(Date.parse(env.ts) >= joinedAt - 5000)) return;
374 warnedAbout.add("same:" + env.fromId);
375 const via = viaLabel(env.via);
376 const text = `another ${oneLine(r.name)} is in this room${via ? ` (${via})` : ""} · use another name`;
377 remember($, { text, note: true });
378 $.ui.toast("duet: " + text, { timeoutMs: 10_000 }); // needs the user: long enough to be seen
379}
380
381function onEnvelope($, r, env) {
382 sameName($, r, env);
383 if (!isForMe(env, r.fromId, r.name)) return;
384 const before = peers.get(env.from);
385 const isNew = !before || before.left;
386 peers.set(env.from, { via: env.via ?? before?.via ?? "", at: Date.now(), left: false });
387 // Quiet: warnings, joins, leaves and notes go to the history only.
388 if (livePeers().length > 1 && !warnedAbout.has("crowd")) {
389 warnedAbout.add("crowd");
390 remember($, { text: `more than two in the room (${peerList()}): a reply without "to" reaches everyone`, note: true });
391 }
392 if (env.kind === "join") {
393 if (env.place && env.place === r.place && !warnedAbout.has("place:" + env.from)) {
394 warnedAbout.add("place:" + env.from);
395 remember($, { text: `${env.from} is in this same folder (another window)`, note: true });
396 }
397 if (isNew) {
398 const via = viaLabel(env.via);
399 remember($, { text: `${env.from} joined${via ? " · " + via : ""}`, note: true });
400 // Answer once, so a newcomer learns who is here; not a join that answers ours (sent twice otherwise).
401 if (Date.now() - joinedAt > 5000) void publish($, r.server, r.topic, envelope({ fromId: r.fromId, from: r.name, kind: "join", via: "claude-code", place: r.place })).catch(() => {});
402 }
403 redraw($);
404 return;
405 }
406 if (env.kind === "note") {
407 const text = {
408 declined: `${env.from} ignored your message`,
409 stopped: `${env.from} stopped your request`,
410 failed: `${env.from}'s agent failed on your request`,
411 "approval-wait": `${env.from} is approving a step`,
412 left: `${env.from} left`,
413 moved: `${env.from} moved to another window`,
414 }[env.note];
415 if (env.note === "left") peers.set(env.from, { ...peers.get(env.from), left: true });
416 remember($, { text, note: true });
417 redraw($);
418 return;
419 }
420 // A reply to one of ours: say which one (the card and what Claude reads).
421 env.reLine = env.re && sent.has(env.re) ? sent.get(env.re) : undefined; // ours only: a peer can't set it
422 remember($, { who: env.from, text: env.text });
423 if (queue.length >= QUEUE_MAX) {
424 queue.shift();
425 $.ui.toast(`duet: over ${QUEUE_MAX} waiting · oldest dropped`);
426 }
427 queue.push(env);
428 // The user must act only in ask mode (or auto paused): then one toast per sender per burst.
429 const now = Date.now();
430 if (!autoActive() && now - (lastToast.get(env.from) ?? 0) > TOAST_GAP_MS) $.ui.toast(`${env.from}: new request`);
431 lastToast.set(env.from, now);
432 void deliver($).catch(() => {});
433 redraw($);
434}
435
436// ---------- delivery ----------
437
438async function deliver($) {
439 if (!autoActive() || !queue.length || busyWithPeer()) return;
440 if (autoTurns >= MAX_AUTO) {
441 paused = true;
442 pausedForTool = false;
443 $.ui.toast(`duet: ${MAX_AUTO} in a row · rest wait for you`);
444 redraw($);
445 return;
446 }
447 if (!(await sendToolReady($))) {
448 if (!toolOff) return void deliver($).catch(() => {}); // found again while this looked: look again
449 // No way to answer: wait (the line above the prompt says why); resumes when the tool is back.
450 paused = true;
451 pausedForTool = true;
452 redraw($);
453 return;
454 }
455 if (!autoActive() || !queue.length || busyWithPeer()) return;
456 autoTurns++;
457 await startPeerTurn($, queue.splice(0, BATCH_MAX));
458}
459
460// Would a shell command nobody named run without the user being asked, right now? Claude Code's own
461// decision for a made-up command answers it ("allow": bypassPermissions or a rule like Bash(*)); its
462// "auto" mode counts as unasked (a classifier approves, not the user). Only asked when the user turns
463// auto on, for the one confirm.
464async function runsUnasked($) {
465 if (permissionMode === "auto") return true;
466 try {
467 const r = await $.tool.check({ tool: "Bash", input: { command: "duet-permission-check" } });
468 return r?.decision === "allow";
469 } catch {
470 return !ASKING_MODES.includes(permissionMode);
471 }
472}
473
474async function startPeerTurn($, envs, preSend = false) {
475 pendingPeer = { envs, text: frameForClaude(envs, cwd, SEND_TOOL), roomKey: room?.key ?? "", submitted: false, preSend };
476 await saveTurn($);
477 redraw($);
478 await submitWhenIdle($);
479}
480
481// Hand the taken request to Claude once no turn is running, so the turn it starts is the next one.
482async function submitWhenIdle($) {
483 const p = pendingPeer;
484 if (!p || p.submitted || runningTurn) return;
485 p.submitted = true;
486 const froms = [...new Set(p.envs.map((x) => x.from))];
487 expected = [...expected, { text: p.text, froms, roomKey: p.roomKey, envs: p.envs, at: Date.now(), preSend: !!p.preSend }];
488 userPromptSince = false;
489 pendingPeer = null;
490 await saveTurn($);
491 let result;
492 try {
493 result = await $.prompt.submit({ text: p.text }); // never `asUser`
494 } catch (err) {
495 result = { drop: String(err?.message ?? err) };
496 }
497 if (result?.drop) {
498 // Refused (another mod, or a UserPromptSubmit hook): back to waiting, and no auto retry loop.
499 expected = expected.filter((x) => x.text !== p.text);
500 if (room?.key === p.roomKey) queue.unshift(...p.envs);
501 paused = room?.mode === "auto" ? true : paused;
502 pausedForTool = false;
503 await saveTurn($);
504 $.ui.toast("duet: Claude Code refused the request: " + sanitize(result.drop, 120));
505 redraw($);
506 }
507}
508
509async function cancelWaiting($) {
510 const p = pendingPeer;
511 if (!p || p.submitted) return;
512 pendingPeer = null;
513 if (room?.key === p.roomKey) queue.unshift(...p.envs);
514 await saveTurn($);
515 redraw($);
516}
517
518// ---------- joining and leaving ----------
519
520async function claim($, lockKey) {
521 const cur = await $.store.get(lockKey);
522 if (cur && cur.token !== token && !cur.released && Date.now() - cur.at < LOCK_STALE_MS) return cur;
523 await $.store.set(lockKey, { token, cwd, at: Date.now(), released: false });
524 // $.store has no compare-and-swap: write, wait, read back, and keep it only if it is still ours.
525 await wait($, 300);
526 const back = await $.store.get(lockKey);
527 return back?.token === token ? null : back;
528}
529
530// ---------- the lock every duet client shares ----------
531// One window per room and name on this computer, whatever the client: pi, Codex (the MCP server) or
532// this plugin. Claude Code windows also agree among themselves through $.store (claim, above), which
533// can ask "Move it here?"; the file below keeps the other clients out. Same format as lock.js:
534// ~/.duet/<hash>.lock holding { v: 2, client, token, pid, cwd, at }, rewritten every 20 s, held while
535// `at` is under a minute old.
536
537async function readFileLock($, path) {
538 let text;
539 try {
540 text = String(await $.fs.read(path)).trim();
541 } catch {
542 return null;
543 }
544 try {
545 const l = JSON.parse(text);
546 return l && typeof l === "object" ? l : null;
547 } catch {
548 return null;
549 }
550}
551
552// true / false, or undefined where it can't be told (no `kill`, as on Windows).
553async function pidAlive($, pid) {
554 try {
555 return (await $.process.run(["kill", "-0", String(pid)], { timeoutMs: 3000 })).exitCode === 0;
556 } catch {
557 return undefined;
558 }
559}
560
561async function fileLockHeld($, l) {
562 if (!l || l.released) return false;
563 if (l.pid && (await pidAlive($, l.pid)) === false) return false;
564 return Date.now() - (Number(l.at) || 0) < LOCK_STALE_MS;
565}
566
567async function writeFileLock($, path, released) {
568 try {
569 await $.fs.write(path, JSON.stringify({ v: 2, client: "claude-code", token, cwd, at: released ? 0 : Date.now(), ...(released ? { released: true } : {}) }) + "\n");
570 } catch (err) {
571 $.ui.log(`couldn't write the shared lock ${path}: ${String(err?.message ?? err)}`, { to: "debug" });
572 }
573}
574
575const describeClient = (l) => ({ pi: "pi", codex: "Codex", mcp: "a duet MCP server", "claude-code": "another Claude Code" })[l?.client] ?? "another duet window";
576
577// Runs detached from the command or button that asked for it, so its waits count against no hook.
578// mode: "ask" or "auto" (a module reload keeps the mode it had); quiet: a rejoin nobody typed.
579// copy: /duet new puts the fresh code on the clipboard.
580// Joins on their way (a rejoin, the env room, /duet, the join file): the join file's poll waits for them.
581let joinsInFlight = 0;
582async function join($, code, nameArg, mode, quiet, relayArg, copy) {
583 joinsInFlight++;
584 try {
585 return await joinNow($, code, nameArg, mode, quiet, relayArg, copy);
586 } finally {
587 joinsInFlight--;
588 }
589}
590
591async function joinNow($, code, nameArg, mode, quiet, relayArg, copy) {
592 if (!isRoomCode(code)) {
593 $.ui.log("room code: 3–64 letters, digits, . _ - · or /duet new");
594 return;
595 }
596 if (isPlaceholderName(nameArg)) {
597 $.ui.log(`"${nameArg}" is a placeholder · /duet ${code} <your name>`);
598 return;
599 }
600 if (relayArg && !isRelayUrl(relayArg)) {
601 $.ui.log(`bad relay ${sanitize(relayArg, 100)} · http(s) URL only, e.g. https://duet.gaioz.online`);
602 return;
603 }
604 const relay = (relayArg || server).replace(/\/+$/, "");
605 const name = fitName(nameArg || defaultName || "anon");
606 const epoch = joinEpoch;
607 if (room) {
608 if (room.code === code && room.name === name && room.server === relay) return openPane($);
609 await leave($, "left", false);
610 }
611 const key = `${relay} ${code} ${name}`;
612 const fromId = (await sha256hex(`${installId} ${key}`)).slice(0, 32);
613 const topic = await topicFor(code);
614 const hash16 = (await sha256hex(key)).slice(0, 16);
615 const lockKey = "owner:" + hash16;
616 const fileLock = `${duetDir}/${hash16}.lock`;
617
618 const held = await claim($, lockKey);
619 if (held && quiet) return; // another window has it: a quiet rejoin leaves it there
620 let moved = false;
621 if (held) {
622 let answer = "Cancel";
623 try {
624 answer = await $.ui.ask(`duet: ${code} · ${name} · open in another window (${held.cwd}) · move here?`, ["Move here", "Cancel"]);
625 } catch {}
626 if (answer !== "Move here") return;
627 await $.store.set(lockKey, { ...held, release: token });
628 let released = false;
629 for (let i = 0; i < 20 && !released; i++) {
630 await wait($, 500);
631 const cur = await $.store.get(lockKey);
632 released = !cur || !!cur.released;
633 }
634 // No answer in 10 s: that window is gone or stuck; take the room anyway.
635 if (!released) $.ui.log("other window silent · took the room");
636 await $.store.set(lockKey, { token, cwd, at: Date.now(), released: false });
637 moved = true;
638 }
639 // Another client (pi, Codex), or a Claude Code with its own config, in this room under this name.
640 const other = await readFileLock($, fileLock);
641 if (other && other.token !== token && !(moved && other.client === "claude-code") && (await fileLockHeld($, other))) {
642 const cur = await $.store.get(lockKey);
643 if (cur?.token === token) await $.store.set(lockKey, { ...cur, released: true });
644 if (!quiet) {
645 const where = other.cwd ? ` (${other.cwd})` : other.pid ? ` (pid ${other.pid})` : "";
646 $.ui.log(`${name} already in ${code} here · ${describeClient(other)}${where} · not joined`);
647 $.ui.toast(`duet: ${name} already in this room · ${describeClient(other)}`);
648 }
649 return;
650 }
651 await writeFileLock($, fileLock);
652
653 // No saved place in this room: listen from just before joining, so the others' answers to our
654 // join (sent within a second or two) aren't missed while the stream is still opening.
655 const saved = await $.store.get("cursor:" + key);
656 const cursor = saved ?? { id: "", time: Math.floor(Date.now() / 1000) - 2 };
657 const r = { code, name, key, fromId, topic, lockKey, fileLock, server: relay, mode: mode === "auto" ? "auto" : "ask", cursor, riskOk: false };
658 try {
659 // The first network request: if the relay can't be reached, or this session's policy refuses
660 // network requests from mods, duet doesn't join.
661 r.place = await placeFor(cwd, topic, host);
662 await publish($, relay, topic, envelope({ fromId, from: name, kind: "join", via: "claude-code", place: r.place }));
663 joinedAt = Date.now();
664 } catch (err) {
665 const cur = await $.store.get(lockKey);
666 if (cur?.token === token) await $.store.set(lockKey, { ...cur, released: true });
667 await writeFileLock($, fileLock, true);
668 $.ui.log(`relay ${relay} unreachable: ${String(err?.message ?? err)} · not joined`);
669 $.ui.toast("duet: relay unreachable · not joined");
670 return;
671 }
672 if (epoch !== joinEpoch) {
673 // /duet off came while this join was on its way: don't join after all.
674 const cur = await $.store.get(lockKey);
675 if (cur?.token === token) await $.store.set(lockKey, { ...cur, released: true });
676 await writeFileLock($, fileLock, true);
677 return;
678 }
679 room = r;
680 generation++;
681 queue = [];
682 outbox = [];
683 peers.clear();
684 const stored = await $.store.get("history:" + key);
685 // Keep the histories of the last few rooms only: $.store is 4 MiB for everything.
686 try {
687 const recent = [key, ...(((await $.store.get("history-rooms")) ?? []).filter((k) => k !== key))];
688 for (const old of recent.slice(HISTORY_ROOMS)) await $.store.delete("history:" + old);
689 await $.store.set("history-rooms", recent.slice(0, HISTORY_ROOMS));
690 } catch {}
691 history = Array.isArray(stored) ? stored.slice(-HISTORY_MAX) : [];
692 autoTurns = 0;
693 paused = false;
694 pausedForTool = false;
695 heldCursor = null;
696 await $.store.set("room:" + cwd, { code, name, relay, at: Date.now() });
697 await $.store.set("name", name);
698 await $.store.set("active:" + sessionId, { lockKey, token, code, name, mode: r.mode, relay });
699 defaultName = name;
700 heartbeat?.cancel?.();
701 heartbeat = $.clock.every(2000, () => void beat($).catch(() => {}));
702 joinPoll?.cancel?.();
703 joinPoll = null;
704 wakeSupervisor?.();
705 remember($, { text: quiet ? `you rejoined as ${name}` : `you joined as ${name}`, note: true });
706 let copied = false;
707 if (copy) {
708 try {
709 copied = !!(await $.ui.copy({ text: code }))?.isCopied;
710 } catch {}
711 }
712 if (!quiet) $.ui.log(`joined ${code} as ${name}${copied ? " · code copied" : ""}`);
713 logToolOff($);
714 redraw($);
715 void deliver($).catch(() => {});
716}
717
718// Every 2 s: notice another window taking the room, hand it over when asked, refresh the lock.
719let lastBeat = 0;
720async function beat($) {
721 const fresh = expected.filter((x) => Date.now() - x.at < 30 * 60_000);
722 if (fresh.length !== expected.length) {
723 expected = fresh;
724 await saveTurn($);
725 redraw($);
726 }
727 if (!room) return;
728 const r = room;
729 const cur = await $.store.get(r.lockKey);
730 if (room !== r) return;
731 if (cur && cur.token !== token && !cur.released) {
732 await leave($, null, false, true);
733 $.ui.log(`${r.code} moved to another window (${cur.cwd})`);
734 return;
735 }
736 if (cur?.release && cur.release !== token) {
737 // Save where to resume (before any card still open here) before letting go of the room.
738 await leave($, "moved", false);
739 $.ui.log(`${r.code} handed to another window`);
740 return;
741 }
742 if (Date.now() - lastBeat > 20_000) {
743 lastBeat = Date.now();
744 // Someone else holds the shared lock: another client found this window stale and took over.
745 const shared = await readFileLock($, r.fileLock);
746 if (room !== r) return;
747 if (shared && shared.token !== token && shared.client !== "claude-code" && (await fileLockHeld($, shared))) {
748 // Not a failure and nothing to do: history and the transcript only (quiet).
749 remember($, { text: `room moved to ${describeClient(shared)}`, note: true });
750 await leave($, null, false, true);
751 $.ui.log(`${r.code} is now open as ${r.name} in ${describeClient(shared)}${shared.cwd ? ` (${shared.cwd})` : ""} · left here`);
752 return;
753 }
754 await writeFileLock($, r.fileLock);
755 await $.store.set(r.lockKey, { token, cwd, at: lastBeat, released: false });
756 await $.store.set("room:" + cwd, { code: r.code, name: r.name, relay: r.server, at: lastBeat });
757 }
758}
759
760// A request already handed to Claude keeps running after a leave; its reply can't be sent once the
761// room is gone. forget: /duet off — the folder forgets its room.
762async function leave($, note, forget, lost) {
763 if (!room) return;
764 const r = room;
765 if (note) sendNote($, note);
766 await saveCursor($);
767 if (historySave) {
768 historySave.cancel?.();
769 historySave = null;
770 }
771 await $.store.set("history:" + r.key, fitHistory(history)).catch(() => {});
772 // A request taken but not yet handed to Claude stays with the room (its resume point is saved).
773 if (pendingPeer && !pendingPeer.submitted) pendingPeer = null;
774 await saveTurn($);
775 room = null;
776 generation++;
777 heartbeat?.cancel?.();
778 heartbeat = null;
779 try {
780 child?.return?.();
781 } catch {}
782 child = null;
783 connected = false;
784 queue = [];
785 heldCursor = null;
786 if (!lost) {
787 const cur = await $.store.get(r.lockKey);
788 if (cur?.token === token) await $.store.set(r.lockKey, { ...cur, released: true });
789 if ((await readFileLock($, r.fileLock))?.token === token) await writeFileLock($, r.fileLock, true);
790 }
791 await $.store.delete("active:" + sessionId);
792 if (forget) await $.store.delete("room:" + cwd);
793 if (watchJoin) pollJoinFile($);
794 redraw($);
795}
796
797async function setMode($, mode) {
798 if (!room) {
799 $.ui.log("not in a room · /duet new or /duet <code>");
800 return;
801 }
802 if (mode === "auto" && !room.riskOk && (await runsUnasked($))) {
803 // The one confirm: commands run unasked here, so auto lets the other side's agent run them.
804 let answer = "Keep ask";
805 try {
806 answer = await $.ui.ask("duet auto? · commands run unasked here · the other agent could run them", ["Turn auto on", "Keep ask"]);
807 } catch {}
808 if (!room) return;
809 if (answer !== "Turn auto on") {
810 redraw($);
811 return;
812 }
813 room.riskOk = true;
814 }
815 if (!room) return;
816 room.mode = mode;
817 autoTurns = 0;
818 paused = false;
819 pausedForTool = false;
820 const active = await $.store.get("active:" + sessionId);
821 if (active) await $.store.set("active:" + sessionId, { ...active, mode });
822 $.ui.log(mode === "auto" ? `auto · no gates · max ${MAX_AUTO} in a row` : "ask · both gates on");
823 redraw($);
824 void deliver($).catch(() => {});
825}
826
827// ---------- what the user does with a card ----------
828
829// The card shows, and acts on, the waiting messages from one sender together (up to a batch).
830function firstGroup() {
831 if (!queue.length) return [];
832 const from = queue[0].from;
833 return queue.filter((e) => e.from === from).slice(0, BATCH_MAX);
834}
835
836// Gate 1: a press acts at once. "take" (Process), "take-send" (Process and send), "ignore".
837let choosing = false; // a gate-1 press is checking the send tool: a second press waits its turn
838async function choose($, action) {
839 if (choosing) return;
840 const envs = firstGroup();
841 if (!envs.length || !room) return;
842 const r = room;
843 const take = action === "take" || action === "take-send";
844 if (take && busyWithPeer()) {
845 $.ui.toast("duet: busy with the last request");
846 return;
847 }
848 // No send tool here: Claude would have no way to answer but another tool. Keep the request waiting.
849 if (take) {
850 choosing = true;
851 let ready = false;
852 try {
853 ready = await sendToolReady($);
854 } finally {
855 choosing = false;
856 }
857 if (!ready) {
858 // The line above the prompt stays; the toast answers this press.
859 $.ui.toast("duet: " + toolOffText());
860 return;
861 }
862 // The check waited: a leave, another press or a turn may have come in meanwhile.
863 if (room !== r || !envs.every((e) => queue.includes(e))) return;
864 if (busyWithPeer()) {
865 $.ui.toast("duet: busy with the last request");
866 return;
867 }
868 }
869 queue = queue.filter((e) => !envs.includes(e));
870 if (take) {
871 await startPeerTurn($, envs, action === "take-send");
872 } else {
873 sendNote($, "declined", envs[0].from);
874 redraw($);
875 await saveCursor($);
876 }
877}
878
879// Gate 2: the press that settles a waiting reply.
880function decide($, item, decision) {
881 if (!item || item.decision) return;
882 item.decision = decision;
883 redraw($);
884}
885
886// Hold the send tool's call until the user presses Send or Don't send. The hook's time limit stops
887// while a mods API call is in flight and runs on through a promise of the mod's own (and through
888// $.clock.sleep), so the wait sits inside short blocking processes: `sleep` (macOS, Linux, Git Bash),
889// else `ping` to this computer (Windows' own, System32: about a second a round), else PowerShell's
890// Start-Sleep. A waiter that can't start is dropped for the rest of this module's life; one that
891// fails (a non-zero exit, its time limit) is dropped only after WAITER_FAILS in a row, so one hiccup
892// doesn't move the wait to a slower one. Each has a short time limit of its own: `ping -n 2` on
893// Linux/macOS never ends by itself. Only with none of them left does the wait fall back to
894// $.clock.sleep, which counts against the hook's 10 s; the hook's .catch then refuses the send
895// (fails closed).
896const WAITERS = [
897 { argv: ["sleep", "0.25"], timeoutMs: 2000 },
898 { argv: ["ping", "-n", "2", "127.0.0.1"], timeoutMs: 3000 },
899 { argv: ["powershell", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Milliseconds 250"], timeoutMs: 5000 },
900];
901const WAITER_FAILS = 3;
902let waiter = 0; // the first of WAITERS not yet given up on here
903let waiterFails = 0; // failures in a row of WAITERS[waiter]
904async function awaitDecision($, item, signal) {
905 const decision = await waitForPress($, item, signal);
906 // The hook's budget may have run out (the tool already answered "Not sent: duet error") while a
907 // press came in: never send after that.
908 return signal?.aborted ? "stopped" : decision;
909}
910async function waitForPress($, item, signal) {
911 while (!item.decision) {
912 if (signal?.aborted) return "stopped";
913 if (!room) return "left";
914 // A module reload (a plugin update) while this reply waits: the new module never draws this
915 // card, so settle the call, unsent, instead of holding it until Esc.
916 let current = "";
917 try {
918 current = (await $.env.get("DUET_MODULE")) || "";
919 } catch {}
920 if (current && current !== moduleId) return "reloaded";
921 if (item.decision) break;
922 if (waiter < WAITERS.length) {
923 let missing = false;
924 try {
925 const r = await $.process.run(WAITERS[waiter].argv, { timeoutMs: WAITERS[waiter].timeoutMs });
926 if (r.exitCode === 0) {
927 waiterFails = 0;
928 continue;
929 }
930 } catch (err) {
931 missing = /ENOENT|not found|cannot find|no such file/i.test(String(err?.message ?? err));
932 }
933 if (missing || ++waiterFails >= WAITER_FAILS) {
934 waiter++;
935 waiterFails = 0;
936 }
937 continue;
938 }
939 try {
940 await $.clock.sleep(250, signal ? { signal } : undefined);
941 } catch {
942 return signal?.aborted ? "stopped" : "reloaded";
943 }
944 }
945 return item.decision;
946}
947
948async function openPane($) {
949 await $.ui.open({ id: PANE, title: "duet", focus: true, closeOnEscape: true });
950 redraw($);
951}
952
953// Back in the room after a restart, without asking: the folder remembers its room until /duet off.
954async function autoRejoin($) {
955 if (room || joinsInFlight || joinOffer || !(await canDraw($))) return;
956 const rec = await $.store.get("room:" + cwd);
957 if (!rec?.code || room || joinsInFlight) return; // a join started while this looked
958 await join($, rec.code, rec.name, "ask", true, rec.relay);
959}
960
961// The website's prompt writes ~/.duet/join.json and the user types /reload-plugins (or, with duet
962// already loaded, nothing: the poll finds it). Taken once: emptied at once, so no other window takes
963// it too. A file for another agent or folder is left alone; a stale one is emptied. Taking it never
964// joins: it shows the card "Join <room> as <name>? · <folder>" (1 Join, 2 Ignore), so a room is joined
965// by the user's own hand even when something else wrote the file. The poll looks only while not in a
966// room; a reload in a room looks too (inRoomToo) and shows the same card, which says what it leaves.
967// A /duet typed after the prompt empties the file (dropJoinFile).
968async function takeJoinFile($, inRoomToo = false, nested = false) {
969 if ((room && !inRoomToo) || joinsInFlight) return false;
970 const path = `${duetDir}/join.json`;
971 let text;
972 try {
973 // Asked every 2.5 s: a missing file is the usual answer, and not an error.
974 if (!(await $.fs.exists(path))) return false;
975 text = String(await $.fs.read(path));
976 } catch {
977 return false;
978 }
979 const got = readJoinFile(text, "claude-code", cwd, Date.now(), nested);
980 if (!got || (room && !inRoomToo) || joinsInFlight) return false;
981 try {
982 await $.fs.write(path, "{}");
983 } catch {
984 return false;
985 }
986 if (!got.take) return false;
987 joinOffer = { room: got.take.room, name: got.take.name, relay: got.take.relay, question: joinQuestion(got.take, home) };
988 logToolOff($);
989 redraw($);
990 return true;
991}
992
993// The join card's press: 1 joins (ask mode), 2 drops it (the file is already gone either way).
994function answerJoinOffer($, yes) {
995 const o = joinOffer;
996 joinOffer = null;
997 redraw($);
998 if (!o) return;
999 if (yes) void join($, o.room, o.name, "ask", false, o.relay).catch(() => {});
1000 else void autoRejoin($).catch(() => {}); // a start that showed the card first: back to the folder's room
1001}
1002
1003// A /duet <room> or /duet new typed by the user: a join file for this window is older than it, so it goes.
1004async function dropJoinFile($) {
1005 if (joinOffer) {
1006 joinOffer = null;
1007 redraw($);
1008 }
1009 const path = `${duetDir}/join.json`;
1010 try {
1011 if (!(await $.fs.exists(path))) return;
1012 if (readJoinFile(String(await $.fs.read(path)), "claude-code", cwd, Date.now(), true)) await $.fs.write(path, "{}");
1013 } catch {}
1014}
1015
1016function pollJoinFile($) {
1017 joinPoll?.cancel?.();
1018 joinPoll = $.clock.every(JOIN_POLL_MS, () => void takeJoinFile($).catch(() => {}));
1019}
1020
1021// The send tool: registered first thing in session.start (before anything that could throw or wait),
1022// and checked again before a request is handed to Claude. Seen on a Mac (2026-10-07, issue #33):
1023// a session where Claude had no mcp__duet__send, and on Process it reached for another duet tool (a
1024// claude.ai connector) instead. Never silently: a refusal is said.
1025const SEND_SPEC = {
1026 name: "send",
1027 description:
1028 "Send a message to the other agent(s) in your duet room (another developer's coding agent on their computer). " +
1029 "Use it to answer a duet request, or when your user asks you to tell the other side something. " +
1030 "Your text replies are seen only by your own user; this tool is the only way to reach the other side. " +
1031 "Send one complete reply when you're done, not progress updates or several small messages; split only if it is over ~3.5 KB. " +
1032 "In ask mode your user sees the whole reply and presses Send or Don't send; if they don't send it, don't send it again.",
1033 inputSchema: {
1034 type: "object",
1035 properties: {
1036 text: { type: "string", description: "The message. Split longer content into several calls." },
1037 to: { type: "string", description: "Recipient name, if the room has more than one other person." },
1038 },
1039 required: ["text"],
1040 },
1041};
1042let sendToolError = "";
1043// The send tool missing from Claude's tools, seen on a Mac (issue #33): ~/.claude.json had
1044// projects["<folder>"].disabledMcpServers = ["duet"] (switched off in /mcp there), so Claude Code
1045// never connects the server $.tool.register runs. Said on a line above the prompt that stays (while
1046// in a room or offered one) and once in the transcript; requests wait. Looked at again on every
1047// Process press and every TOOL_POLL_MS while missing; the line goes once the tool is there.
1048const TOOL_POLL_MS = 10_000;
1049const MCP_SERVER = "duet"; // the server name $.tool.register gives this plugin's tools
1050let toolOff = null; // null while the tool is there (or Claude Code can't say); { switchedOff } while missing
1051let toolPoll = null;
1052let toolOffLogged = false;
1053let toolSeen = 0; // bumped each time the tool is found: an older "missing" answer then doesn't count
1054let pausedForTool = false; // auto paused only because the tool was missing: resumes when it is back (any other pause or unpause clears it)
1055const toolOffText = () =>
1056 toolOff?.switchedOff
1057 ? "duet's send tool is switched off in /mcp for this folder · /mcp → duet → Enable · requests wait until then"
1058 : "duet's send tool is off in this folder · /mcp → duet → Enable, or /reload-plugins · requests wait until then";
1059// Said only where it matters: in a room, or with a Join card up.
1060const toolOffShown = () => !!toolOff && !!(room || joinOffer);
1061
1062// Is "duet" in this folder's disabledMcpServers in Claude Code's global config (read only, never
1063// written)? Claude Code keys it by the git checkout's root, else by the folder it started in (with /
1064// on Windows too). false whenever it can't be told.
1065async function mcpSwitchedOff($) {
1066 try {
1067 const dir = ((await $.env.get("CLAUDE_CONFIG_DIR")) || home).replace(/[\\/]+$/, "");
1068 if (!dir) return false;
1069 const projects = JSON.parse(String(await $.fs.read(`${dir}/.claude.json`)))?.projects;
1070 if (!projects || typeof projects !== "object") return false;
1071 let key = cwd;
1072 try {
1073 const top = await $.process.run(["git", "-C", cwd, "rev-parse", "--show-toplevel"], { timeoutMs: 3000 });
1074 if (top.exitCode === 0 && top.stdout.trim()) key = top.stdout.trim();
1075 } catch {}
1076 const slash = key.replace(/\\/g, "/");
1077 const entry = Object.hasOwn(projects, key) ? projects[key] : Object.hasOwn(projects, slash) ? projects[slash] : undefined;
1078 const list = entry?.disabledMcpServers;
1079 return Array.isArray(list) && list.includes(MCP_SERVER);
1080 } catch {
1081 return false;
1082 }
1083}
1084
1085function logToolOff($) {
1086 if (!toolOffShown() || toolOffLogged) return;
1087 toolOffLogged = true;
1088 $.ui.log(toolOffText());
1089}
1090
1091async function noteToolOff($) {
1092 const seen = toolSeen;
1093 const switchedOff = await mcpSwitchedOff($);
1094 if (seen !== toolSeen) return; // found meanwhile (a Process press, the timer)
1095 const changed = !toolOff || toolOff.switchedOff !== switchedOff;
1096 toolOff = { switchedOff };
1097 if (changed) toolOffLogged = false;
1098 if (!toolPoll) toolPoll = $.clock.every(TOOL_POLL_MS, () => void checkSendTool($).catch(() => {}));
1099 logToolOff($);
1100 redraw($);
1101}
1102
1103function noteToolOn($) {
1104 toolSeen++;
1105 toolPoll?.cancel?.();
1106 toolPoll = null;
1107 if (!toolOff) return;
1108 const said = toolOffLogged;
1109 toolOff = null;
1110 toolOffLogged = false;
1111 if (said) $.ui.log("duet's send tool is on · requests can be processed");
1112 if (pausedForTool) {
1113 pausedForTool = false;
1114 paused = false;
1115 void deliver($).catch(() => {});
1116 }
1117 redraw($);
1118}
1119
1120// true / false, or null when Claude Code can't say (no tool list).
1121async function hasSendTool($) {
1122 try {
1123 return (await $.tool.list()).some((t) => t.name === SEND_TOOL);
1124 } catch {
1125 return null;
1126 }
1127}
1128
1129// The start's check and the slow timer's: only looks (registering again waits up to 8 s).
1130async function checkSendTool($) {
1131 const has = await hasSendTool($);
1132 if (has === true) noteToolOn($);
1133 else if (has === false && !toolOff) await noteToolOff($); // still missing: nothing new to read
1134}
1135
1136async function registerSendTool($) {
1137 try {
1138 await $.tool.register(SEND_SPEC);
1139 sendToolError = "";
1140 return true;
1141 } catch (err) {
1142 sendToolError = String(err?.message ?? err);
1143 return false;
1144 }
1145}
1146// Is the send tool among the tools Claude can call now? Registered again if not. true when Claude
1147// Code can't say (no tool list): the hand-over isn't held up on a guess.
1148// Switched off in /mcp: registering again changes nothing (and waits up to 8 s), so it is skipped.
1149async function sendToolReady($) {
1150 const first = await hasSendTool($);
1151 if (first === true) noteToolOn($);
1152 if (first !== false) return true;
1153 if (!(await mcpSwitchedOff($))) {
1154 await registerSendTool($);
1155 if ((await hasSendTool($)) !== false) {
1156 noteToolOn($);
1157 return true;
1158 }
1159 }
1160 await noteToolOff($);
1161 return false;
1162}
1163
1164// ---------- the send tool ----------
1165
1166async function sendTool($, e, signal) {
1167 if (!room) return { result: "Not sent: not in a duet room" };
1168 const raw = String(e.text ?? "");
1169 if (raw.length > MAX_TEXT) return { result: `Not sent: ${raw.length} characters, limit ${MAX_TEXT} · send the key part, or split it` };
1170 // What goes out is exactly what the gate 2 card shows: no control or invisible characters.
1171 const text = sanitize(raw, MAX_TEXT).trim();
1172 if (!stripHidden(raw).text.trim()) return { result: "Not sent: empty" };
1173 if (text.length > MAX_TEXT) return { result: `Not sent: ${text.length} characters, limit ${MAX_TEXT} · send the key part, or split it` };
1174 const to = typeof e.to === "string" && e.to.trim() ? e.to.trim() : undefined;
1175 // `to` comes from the model: only the name of someone in the room (it shows on the card, the
1176 // spinner and the toast, and a name nobody has would reach no one).
1177 if (to !== undefined && !isName(to)) return { result: "Not sent: bad name in to" };
1178 if (to !== undefined && !livePeers().some(([n]) => n === to)) return { result: `Not sent: no ${to} in the room · in it: ${peerList() || "no one yet"}` };
1179 const fromPeer = !!peerTurn;
1180 if (fromPeer && peerTurn.roomKey !== room.key) return { result: "Not sent: request from a room you left · tell your user" };
1181 const r = room;
1182 // "Process and send": the user OK'd this request's reply ahead. Only the first send of its turn
1183 // (subagents included) that reaches exactly its sender; used up before any await, so two sends in
1184 // parallel can't both use it.
1185 const live = livePeers().map(([n]) => n);
1186 const reaches = to ?? (live.length === 1 ? live[0] : "");
1187 // Only from the turn that runs now, or a subagent it started.
1188 const ownTurn = fromPeer && peerTurn.turnId === runningTurn && (!e.agentId || (peerTurn.agents ?? []).includes(e.agentId));
1189 const preTo = ownTurn && !autoActive() && peerTurn.preSend && reaches === peerTurn.preSend ? peerTurn.preSend : "";
1190 const preSent = !!preTo;
1191 if (preSent) {
1192 peerTurn.preSend = "";
1193 await saveTurn($); // a module reload must not bring it back
1194 }
1195 // What was OK'd is a reply to the sender: it goes to the sender only, even without `to`.
1196 const sendTo = to ?? (preTo || undefined);
1197 // Gate 2, in ask mode: the whole reply waits above the prompt for Send / Don't send. Without
1198 // `to` a reply reaches everyone in the room, so the card names everyone.
1199 if (!autoActive() && !preSent) {
1200 const item = { id: randomId(), text, to: to ?? (peerList() || "the room"), decision: "", agentId: e.agentId };hooks/wire.js 272 lines1// The duet wire format for the Claude Code mod: pure functions, no `node:` imports, no mods API.
2// It must stay compatible with transport.js (pi, the MCP server): same topic hash, same envelope.
3// pi and the MCP server drop `kind: "note"` (their isEnvelope rejects it) and ignore the fields they
4// don't know (`by`), so everything here is safe to send to them.
5
6export const DEFAULT_SERVER = "https://duet.gaioz.online"; // the duet relay (ntfy), run by the author
7// Longer messages still go out as one: the relay stores the body as an attachment (ntfy does that
8// above 4096 bytes; ntfy.sh keeps them 3 h, up to 2 MB) and receivers fetch it. Same as transport.js.
9export const MAX_BYTES = 256_000;
10export const MAX_TEXT = 200_000;
11// Unattended peer-started turns allowed in auto mode before duet falls back to asking.
12export const MAX_AUTO = 8;
13// A Text string child may hold at most 10,000 characters; keep well under.
14export const MAX_SHOWN = 6000;
15
16export const NOTES = ["declined", "stopped", "failed", "approval-wait", "left", "moved"];
17
18const NAME = /^[\p{L}\p{N}][\p{L}\p{M}\p{N}._-]{0,39}$/u;
19// No hidden characters either (\p{M} holds the variation selectors and the grapheme joiner): a name
20// goes into every prompt and form unchanged.
21export const isName = (name) => typeof name === "string" && NAME.test(name) && !stripHidden(name).hidden;
22export const isPlaceholderName = (name) => typeof name === "string" && /^your[-_ ]?name$/i.test(name);
23export const fitName = (name) =>
24 isName(name)
25 ? name
26 : Array.from(stripHidden(name).text.normalize("NFC").replace(/[^\p{L}\p{M}\p{N}._-]+/gu, "-").replace(/^[^\p{L}\p{N}]+/u, ""))
27 .slice(0, 40)
28 .join("") || "anon";
29
30export function isRelayUrl(url) {
31 return /^https?:\/\/[A-Za-z0-9.-]+(:\d{1,5})?(\/[A-Za-z0-9._~\/-]*)?$/.test(url);
32}
33
34// Words that mean "leave" in /duet (pi uses /duet off). None of them can be a room name.
35export const LEAVE_WORDS = ["off", "leave", "stop", "disable", "quit", "exit"];
36
37// A room code is the shared secret: 3-64 letters, digits, . _ -, not a leave word. The same rule as
38// transport.js isRoomCode (Codex, pi, the MCP servers, the join file): test/mod-unit.mjs compares them.
39export const isRoomCode = (room) =>
40 typeof room === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{2,63}$/.test(room) && !LEAVE_WORDS.includes(room.toLowerCase());
41
42// The join file (~/.duet/join.json) the website's prompt writes: { agent, room, name, relay, cwd,
43// pcwd, at } with `at` in Unix seconds. What a client does with its text: "take" ({ room, name,
44// relay }: shown to the user to confirm, never joined by itself), "clear" (stale or from the future:
45// nobody will take it) or null (leave it: another client or folder may). The same rules as lock.js
46// acceptJoin (Codex, pi): a relay is required, `at` is a number and not ahead of this clock.
47export const JOIN_FRESH_S = 30 * 60;
48// A folder as both sides write it: Git Bash's /c/x and Windows' C:\x are one folder; no trailing slash.
49const folderKey = (p) => (typeof p === "string" && p ? p.replace(/\\/g, "/").replace(/^\/([A-Za-z])(?=\/|$)/, "$1:").replace(/\/+$/, "") || "/" : "");
50// Windows paths (C:\x, \\server\x) compare without case; Linux and macOS ones as written.
51const isWindowsPath = (p) => typeof p === "string" && (/^[A-Za-z]:/.test(p) || p.includes("\\"));
52// The join file's folder is this one or, on this window's own start or reload (nested), one inside it:
53// the agent's shell may have cd'd into a subfolder. A poll takes only its own folder, so a window open
54// in ~ doesn't take every prompt pasted below it.
55export const sameFolder = (paths, folder, nested = false) => {
56 const here0 = folderKey(folder);
57 return !!here0 && paths.some((p) => {
58 const fold = isWindowsPath(folder) || isWindowsPath(p);
59 const here = fold ? here0.toLowerCase() : here0;
60 const k = fold ? folderKey(p).toLowerCase() : folderKey(p);
61 const root = here === "/" || /^[A-Za-z]:$/.test(here); // / or C:\ is no one's project
62 return !!k && (k === here || (nested && !root && k.startsWith(here + "/")));
63 });
64};
65export function readJoinFile(text, agent, folder, nowMs, nested = false) {
66 let j;
67 try {
68 j = JSON.parse(text);
69 } catch {
70 return null;
71 }
72 if (!j || typeof j !== "object" || typeof j.at !== "number") return null;
73 const age = nowMs / 1000 - j.at;
74 // `date +%s` on this computer wrote it: a time ahead of now was not written by the prompt.
75 if (!(age >= 0 && age <= JOIN_FRESH_S)) return { clear: true };
76 if (j.agent !== agent) return null;
77 if (!sameFolder([j.cwd, j.pcwd], folder, nested)) return null;
78 const relay = typeof j.relay === "string" ? j.relay.replace(/\/+$/, "") : "";
79 if (!isRoomCode(j.room) || !isName(j.name) || isPlaceholderName(j.name) || !isRelayUrl(relay)) return null;
80 // The folder shown is this window's own: the file's cwd is free text, and only one of cwd/pcwd matched.
81 return { take: { room: j.room, name: j.name, relay, folder } };
82}
83
84// What the user is asked before a join file joins (every client): "Join <room> as <name>? · <folder>",
85// with "· relay <host>" before the folder when it isn't duet's own relay. The relay is never cut; the
86// folder (the window's own, home as ~) is one line, its end kept, at most FOLDER_MAX characters.
87const FOLDER_MAX = 60;
88export const DUET_RELAY = "https://duet.gaioz.online";
89export function joinQuestion({ room, name, relay, folder }, home = "") {
90 let where = String(folder ?? "").replace(/[\\/]+$/, "") || "/";
91 const h = String(home ?? "").replace(/[\\/]+$/, "");
92 if (h && (where === h || where.startsWith(h + "/") || where.startsWith(h + "\\"))) where = "~" + where.slice(h.length);
93 where = where.replace(/\s+/g, " ");
94 if (where.length > FOLDER_MAX) where = "…" + where.slice(-(FOLDER_MAX - 1));
95 let relayHost = "";
96 if (relay && relay !== DUET_RELAY) {
97 try {
98 relayHost = new URL(relay).host;
99 } catch {
100 relayHost = String(relay);
101 }
102 if (relay.startsWith("http://")) relayHost = "http://" + relayHost;
103 }
104 return cleanText(`Join ${room} as ${name}?${relayHost ? ` · relay ${relayHost}` : ""} · ${where}`).replace(/\s+/g, " ");
105}
106
107const hex = (bytes) => Array.from(new Uint8Array(bytes), (b) => b.toString(16).padStart(2, "0")).join("");
108
109export async function sha256hex(text) {
110 return hex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
111}
112
113// The room name is the shared secret; only its hash ever reaches the server. Same as transport.js.
114export async function topicFor(room) {
115 return "duet_" + (await sha256hex("pi-duet:" + room)).slice(0, 40);
116}
117
118export function randomId() {
119 if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
120 const b = crypto.getRandomValues(new Uint8Array(16));
121 b[6] = (b[6] & 0x0f) | 0x40;
122 b[8] = (b[8] & 0x3f) | 0x80;
123 const h = hex(b);
124 return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
125}
126
127const WORDS = [
128 "amber", "birch", "cedar", "delta", "ember", "fjord", "grove", "heron", "indigo", "juniper", "kelp", "lumen",
129 "maple", "nectar", "onyx", "pebble", "quartz", "raven", "sage", "tidal", "umber", "violet", "willow", "zephyr",
130 "otter", "lynx", "falcon", "badger", "marten", "osprey", "puffin", "walrus", "yak", "gecko", "bison", "crane",
131];
132
133// A fresh room code like "amber-otter-4821-x7q2": 2 words + 4 digits + 4 base-36 chars (~50 bits).
134export function newRoomCode() {
135 const r = crypto.getRandomValues(new Uint32Array(4));
136 const tail = (r[3] >>> 0).toString(36).padStart(4, "0").slice(-4);
137 return `${WORDS[r[0] % WORDS.length]}-${WORDS[r[1] % WORDS.length]}-${String(r[2] % 10000).padStart(4, "0")}-${tail}`;
138}
139
140// Which computer and folder an agent works in, as a hash (same recipe as transport.js placeFor):
141// two windows in the same folder share it, so a join can warn that they may edit the same files.
142// Salted with the room's topic, so the same folder can't be recognised across rooms.
143export async function placeFor(cwd, topic, host) {
144 return (await sha256hex(`duet-place:${topic}:${host}:${cwd}`)).slice(0, 16);
145}
146
147// A long message's attachment, accepted only when it is a real upload on this very relay (same rules
148// as transport.js attachmentUrl): same origin, path exactly <relay path>/file/<id>[.ext], no query,
149// and a size. Anyone can post an attachment that points anywhere.
150export function attachmentUrl(a, server, max) {
151 if (!a || typeof a.url !== "string" || typeof a.size !== "number" || a.size > max) return null;
152 let u, base;
153 try {
154 u = new URL(a.url);
155 base = new URL(server);
156 } catch {
157 return null;
158 }
159 const path = base.pathname.replace(/\/+$/, "");
160 if (u.origin !== base.origin || u.search || u.hash || u.username || u.password) return null;
161 return new RegExp(`^${path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/file/[A-Za-z0-9]+(\\.[A-Za-z0-9]+)?$`).test(u.pathname) && !/\/\.\.?\//.test(a.url) ? u.href : null;
162}
163
164// The first line of a text, short: how a reply names the message it answers.
165export const firstLine = (text) => {
166 const line = String(text).split("\n").find((l) => l.trim()) ?? "";
167 return line.length > 80 ? line.slice(0, 79) + "…" : line;
168};
169
170export function envelope(fields) {
171 return { v: 1, id: randomId(), ...fields, ts: new Date().toISOString() };
172}
173
174export function isEnvelope(e) {
175 if (
176 e?.v !== 1 ||
177 typeof e.fromId !== "string" ||
178 typeof e.from !== "string" ||
179 !isName(e.from) ||
180 typeof e.ts !== "string" ||
181 (e.to !== undefined && typeof e.to !== "string") ||
182 (e.re !== undefined && typeof e.re !== "string") ||
183 (e.place !== undefined && typeof e.place !== "string")
184 )
185 return false;
186 if (e.kind === "join") return true;
187 if (e.kind === "msg") return typeof e.text === "string" && e.text.length <= MAX_TEXT && !e.text.includes("\0");
188 if (e.kind === "note") return NOTES.includes(e.note);
189 return false;
190}
191
192// Drop our own echoes (by install id) and messages addressed to someone else.
193export function isForMe(env, myFromId, myName) {
194 if (env.fromId === myFromId) return false;
195 return !env.to || env.to.normalize("NFC").toLowerCase() === myName.normalize("NFC").toLowerCase();
196}
197
198export const byteLength = (s) => new TextEncoder().encode(s).length;
199
200// The one cleaner for the other side's text: a copy of transport.js's (the mod can't import from
201// outside hooks/); test/mod-unit.mjs checks that both agree. Removed: ANSI escapes, control characters
202// but tab and newline (C0, C1, U+2028/2029), format characters (\p{Cf}: bidi controls, zero-width,
203// tags), every default-ignorable code point (variation selectors, CGJ, Hangul fillers, Mongolian FVS,
204// the whole tag block) and the blank Braille cell: each draws as nothing while a model still reads it.
205// Text built from them can carry a whole hidden instruction. Anything removed leaves a visible mark.
206export const HIDDEN = /\x1b\[[0-9;?]*[ -\/]*[@-~]|[\x00-\x08\x0b-\x1f\x7f-\x9f\u2028\u2029\u2800\u{E0000}-\u{E0FFF}]|\p{Cf}|\p{Default_Ignorable_Code_Point}/gu;
207export const HIDDEN_MARK = " [hidden characters removed]";
208export function stripHidden(text) {
209 let hidden = false;
210 const clean = String(text ?? "")
211 .replace(/\r\n?/g, "\n")
212 .replace(HIDDEN, () => ((hidden = true), ""));
213 return { text: clean, hidden };
214}
215export function cleanText(text) {
216 const r = stripHidden(text);
217 return r.hidden ? r.text + HIDDEN_MARK : r.text;
218}
219
220// Peer text is untrusted: Text refuses control characters other than tab and newline (and an invalid
221// tree silently falls back to the engine's drawing), so they go (stripHidden), and the length is capped.
222// A card shows every word Claude gets (gate 1) and every word a reply sends (gate 2): the same
223// function makes both.
224export function sanitize(text, max = MAX_SHOWN) {
225 const { text: clean, hidden } = stripHidden(text);
226 const cut = clean.length > max ? clean.slice(0, max) + `… [${clean.length - max} more characters]` : clean;
227 return hidden ? cut + HIDDEN_MARK : cut;
228}
229
230// The first `lines` lines of a text, each cut to `width`, for the card above the prompt.
231export function preview(text, lines = 4, width = 160, hint = " — /duet to read all") {
232 const all = sanitize(text).split("\n");
233 const total = String(text ?? "").replace(/\r\n?/g, "\n").split("\n").length; // before sanitize shortens it
234 let cut = String(text ?? "").length > MAX_SHOWN; // sanitize shortened it
235 const shown = all.slice(0, lines).map((l) => {
236 if (l.length <= width) return l;
237 cut = true;
238 return l.slice(0, width - 1) + "…";
239 });
240 // Say so whenever anything is left out, not only whole lines.
241 if (total > lines) shown.push(`… (${total - lines} more lines${hint})`);
242 else if (cut) shown.push(`… (cut${hint})`);
243 return shown.join("\n");
244}
245
246const clock = (ts) => {
247 const t = Date.parse(ts);
248 if (Number.isNaN(t)) return "";
249 const d = new Date(t);
250 return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
251};
252export const timeOf = clock;
253
254// What Claude reads when duet starts a turn. The engine puts "The duet plugin sent a message:" above
255// it, so Claude knows this isn't its own user. Observed: asked to work "in your folder", the model
256// used the home directory — so the folder is named.
257export function frameForClaude(envs, cwd, tool) {
258 const froms = [...new Set(envs.map((e) => e.from))].join(", ");
259 const parts = envs.map((e) => {
260 const who = e.by === "person" ? "the other person, typing to you directly" : "the other person's agent, on their computer";
261 const at = clock(e.ts);
262 const answers = e.reLine ? ` — a reply to your message “${sanitize(e.reLine, 100)}”` : "";
263 return `[duet] from ${e.from} (${who})${at ? ", " + at : ""}${answers}:\n\n${sanitize(e.text, MAX_TEXT)}`;
264 });
265 return (
266 `${parts.join("\n\n---\n\n")}\n\n` +
267 `Only your own user sees your text replies: to answer ${froms}, call the ${tool} tool. ` +
268 `Answer only with ${tool}, never another duet tool or connector. If ${tool} is missing, tell your user so and don't send. ` +
269 `Send one complete reply when you're done.`
270 );
271}
272