SLOPSHOPPER

Duet

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

newpanebandspinnerguardcommand
v0.12.0MITupdated 2026-10-07qaioz/pi-duet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · duet
│ ┃ duet ✕ › fix the failing auth test and add an audit log call │ ┃ Not in a room │ ┃ /duet new · /duet <code> [name] ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /duet │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · duet
Not in a room /duet new · /duet <code> [name]
README

duet

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.

The duet website: start a room, send the link, start your agent

Start

  1. Open qaioz.github.io/pi-duet, press Start a room, send the link to your friend.
  2. Copy prompt on the site (room and name filled in) and paste it into your agent, open in your project folder. It sets duet up and joins; you do the one step it names:
AgentThen
Claude Codeapprove its command, type /reload-plugins, press 1 (Join)
Codexapprove its command, start a new session, say "join duet", choose Join (first time: trust duet's hooks)
pitype /reload, confirm
Claude chat, ChatGPTfirst 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:

AgentPaste
Claude Codeclaude 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; }
Codexcodex 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>"
pipi install git:github.com/qaioz/pi-duet && pi update git:github.com/qaioz/pi-duet && DUET_ROOM=<room> DUET_NAME=<name> pi
  1. Ask your agent: "ask nika's agent what it thinks of this plan."

You stay in control

Two gates, on by default: nothing reaches your agent, and nothing leaves it, without you.

Claude Code: a request waits for 1 Process, then the full reply waits for 1 Send

GateClaude Code, CodexChat apps
Request in1 Process · 2 Ignore · 3 Process and sendProcess · Ignore · Process and send
Reply out (full text)1 Send · 2 Don't sendSend · 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.

The duet panel in a chat: a request with Process, and a reply waiting for Send

Good to know

  • The link is the key. Anyone with it can send your agent requests and read the room. Share it only with people you trust.
  • Not end-to-end encrypted. Messages pass through a relay (ntfy) at duet.gaioz.online. For sensitive work, self-host one.
  • Claude Code needs version 2.1.287 or newer (duet is a mod). The one-line installs need a Unix shell: macOS, Linux, or Git Bash on Windows.

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).

PathWhat
hooks/Claude Code plugin (a mod)
codex/, mcp.jsCodex 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.tspi extension
transport.js, lock.jswire format and the one-window lock
docs/website (GitHub Pages, main:/docs, Basecoat), docs/guide/ built from docs-site/ (Starlight)
Source 2 files
hooks/duet.js 1793 lines
1// 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 lines
1// 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