stoops 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of stoops might be problematic. Click here for more details.

@@ -0,0 +1,2468 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ InMemoryStorage,
4
+ Room,
5
+ randomName,
6
+ randomRoomName
7
+ } from "../chunk-LC5WPWR2.js";
8
+ import {
9
+ EventProcessor,
10
+ RemoteRoomDataSource,
11
+ SseMultiplexer
12
+ } from "../chunk-SS5NGUJM.js";
13
+ import "../chunk-5ADJGMXQ.js";
14
+ import {
15
+ buildCatchUpLines,
16
+ contentPartsToString,
17
+ createRuntimeMcpServer,
18
+ formatTimestamp
19
+ } from "../chunk-BLGV3QN4.js";
20
+ import {
21
+ createEvent
22
+ } from "../chunk-HQS7HBZR.js";
23
+
24
+ // src/cli/serve.ts
25
+ import { createServer } from "http";
26
+ import { spawn, execFileSync } from "child_process";
27
+ import { randomUUID } from "crypto";
28
+ import { createRequire } from "module";
29
+
30
+ // src/cli/auth.ts
31
+ import { randomBytes } from "crypto";
32
+ var TokenManager = class {
33
+ /** share token hash → authority level */
34
+ _shareTokens = /* @__PURE__ */ new Map();
35
+ /** session token → participant data */
36
+ _sessionTokens = /* @__PURE__ */ new Map();
37
+ /**
38
+ * Generate a share token at the given authority tier.
39
+ * Callers can only generate tokens at their own tier or below.
40
+ */
41
+ generateShareToken(callerAuthority, targetAuthority) {
42
+ if (!canGrant(callerAuthority, targetAuthority)) return null;
43
+ const token = randomBytes(16).toString("hex");
44
+ this._shareTokens.set(token, targetAuthority);
45
+ return token;
46
+ }
47
+ /** Validate a share token and return its authority level. */
48
+ validateShareToken(token) {
49
+ return this._shareTokens.get(token) ?? null;
50
+ }
51
+ /** Create a session token for a participant. */
52
+ createSessionToken(participantId, authority) {
53
+ const token = randomBytes(16).toString("hex");
54
+ this._sessionTokens.set(token, { participantId, authority });
55
+ return token;
56
+ }
57
+ /** Validate a session token and return participant data. */
58
+ validateSessionToken(token) {
59
+ return this._sessionTokens.get(token) ?? null;
60
+ }
61
+ /** Revoke a session token (on disconnect). */
62
+ revokeSessionToken(token) {
63
+ this._sessionTokens.delete(token);
64
+ }
65
+ /** Update the authority level for an existing session. */
66
+ updateSessionAuthority(token, newAuthority) {
67
+ const data = this._sessionTokens.get(token);
68
+ if (!data) return false;
69
+ data.authority = newAuthority;
70
+ return true;
71
+ }
72
+ /** Find a session token by participant ID (for cleanup). */
73
+ findSessionByParticipant(participantId) {
74
+ for (const [token, data] of this._sessionTokens) {
75
+ if (data.participantId === participantId) return token;
76
+ }
77
+ return null;
78
+ }
79
+ };
80
+ var TIER_ORDER = {
81
+ admin: 2,
82
+ participant: 1,
83
+ observer: 0
84
+ };
85
+ function canGrant(callerLevel, targetLevel) {
86
+ return TIER_ORDER[callerLevel] >= TIER_ORDER[targetLevel];
87
+ }
88
+ function buildShareUrl(baseUrl, token) {
89
+ const url = new URL(baseUrl);
90
+ url.searchParams.set("token", token);
91
+ return url.toString();
92
+ }
93
+ function extractToken(url) {
94
+ try {
95
+ const parsed = new URL(url);
96
+ return parsed.searchParams.get("token");
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ // src/cli/serve.ts
103
+ async function enrichAndSend(res, event, room) {
104
+ if (event.type === "MessageSent" && event.message.reply_to_id) {
105
+ const replyMsg = await room.getMessage(event.message.reply_to_id);
106
+ const enriched = {
107
+ ...event,
108
+ _replyToName: replyMsg?.sender_name ?? null
109
+ };
110
+ res.write(`data: ${JSON.stringify(enriched)}
111
+
112
+ `);
113
+ return;
114
+ }
115
+ res.write(`data: ${JSON.stringify(event)}
116
+
117
+ `);
118
+ }
119
+ async function serve(options) {
120
+ const roomName = options.room ?? randomRoomName();
121
+ const port = options.port ?? 7890;
122
+ const serverUrl = `http://127.0.0.1:${port}`;
123
+ const log = options.headless ? () => {
124
+ } : logServer;
125
+ let publicUrl = serverUrl;
126
+ let tunnelProcess = null;
127
+ const storage = new InMemoryStorage();
128
+ const room = new Room(roomName, storage);
129
+ const tokens = new TokenManager();
130
+ const participants = /* @__PURE__ */ new Map();
131
+ const observers = /* @__PURE__ */ new Map();
132
+ const idToSession = /* @__PURE__ */ new Map();
133
+ const sseConnections = /* @__PURE__ */ new Map();
134
+ async function parseBody(req) {
135
+ const chunks = [];
136
+ for await (const chunk of req) chunks.push(chunk);
137
+ try {
138
+ return JSON.parse(Buffer.concat(chunks).toString());
139
+ } catch {
140
+ return {};
141
+ }
142
+ }
143
+ function getSession(token) {
144
+ if (!token) return null;
145
+ const p = participants.get(token);
146
+ if (p) return { ...p, kind: "participant" };
147
+ const o = observers.get(token);
148
+ if (o) return { ...o, kind: "observer" };
149
+ return null;
150
+ }
151
+ function jsonError(res, status, error) {
152
+ res.writeHead(status, { "Content-Type": "application/json" });
153
+ res.end(JSON.stringify({ error }));
154
+ }
155
+ function jsonOk(res, data = {}) {
156
+ res.writeHead(200, { "Content-Type": "application/json" });
157
+ res.end(JSON.stringify({ ok: true, ...data }));
158
+ }
159
+ const httpServer = createServer(async (req, res) => {
160
+ const url = new URL(req.url ?? "/", `http://localhost:${port}`);
161
+ if (url.pathname === "/events" && (req.method === "GET" || req.method === "POST")) {
162
+ const authHeader = req.headers.authorization;
163
+ const sessionToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
164
+ const session = getSession(sessionToken);
165
+ if (!session) {
166
+ jsonError(res, 401, "Invalid session token");
167
+ return;
168
+ }
169
+ res.writeHead(200, {
170
+ "Content-Type": "text/event-stream",
171
+ "Cache-Control": "no-cache",
172
+ "Connection": "keep-alive",
173
+ "Access-Control-Allow-Origin": "*"
174
+ });
175
+ res.flushHeaders();
176
+ sseConnections.set(session.id, res);
177
+ const history = await room.listEvents(void 0, 50);
178
+ for (const event of [...history.items].reverse()) {
179
+ await enrichAndSend(res, event, room);
180
+ }
181
+ const streamEvents = async () => {
182
+ try {
183
+ for await (const event of session.channel) {
184
+ await enrichAndSend(res, event, room);
185
+ }
186
+ } catch {
187
+ }
188
+ };
189
+ streamEvents();
190
+ req.on("close", () => {
191
+ sseConnections.delete(session.id);
192
+ });
193
+ return;
194
+ }
195
+ if (req.method === "GET") {
196
+ const sessionToken = url.searchParams.get("token");
197
+ const session = getSession(sessionToken);
198
+ if (url.pathname === "/participants") {
199
+ if (!session) return jsonError(res, 401, "Invalid session token");
200
+ const list = room.listParticipants().map((p) => ({
201
+ id: p.id,
202
+ name: p.name,
203
+ type: p.type,
204
+ authority: p.authority ?? "participant"
205
+ }));
206
+ res.writeHead(200, { "Content-Type": "application/json" });
207
+ res.end(JSON.stringify({ participants: list }));
208
+ return;
209
+ }
210
+ if (url.pathname.startsWith("/message/")) {
211
+ if (!session) return jsonError(res, 401, "Invalid session token");
212
+ const messageId = url.pathname.slice("/message/".length);
213
+ const msg = await room.getMessage(messageId);
214
+ if (!msg) return jsonError(res, 404, "Message not found");
215
+ res.writeHead(200, { "Content-Type": "application/json" });
216
+ res.end(JSON.stringify({ message: msg }));
217
+ return;
218
+ }
219
+ if (url.pathname === "/messages") {
220
+ if (!session) return jsonError(res, 401, "Invalid session token");
221
+ const count = parseInt(url.searchParams.get("count") ?? "30", 10);
222
+ const cursor = url.searchParams.get("cursor") ?? null;
223
+ const result = await room.listMessages(count, cursor);
224
+ res.writeHead(200, { "Content-Type": "application/json" });
225
+ res.end(JSON.stringify(result));
226
+ return;
227
+ }
228
+ if (url.pathname === "/events/history") {
229
+ if (!session) return jsonError(res, 401, "Invalid session token");
230
+ const category = url.searchParams.get("category") ?? null;
231
+ const count = parseInt(url.searchParams.get("count") ?? "50", 10);
232
+ const cursor = url.searchParams.get("cursor") ?? null;
233
+ const result = await room.listEvents(category, count, cursor);
234
+ res.writeHead(200, { "Content-Type": "application/json" });
235
+ res.end(JSON.stringify(result));
236
+ return;
237
+ }
238
+ if (url.pathname === "/search") {
239
+ if (!session) return jsonError(res, 401, "Invalid session token");
240
+ const query = url.searchParams.get("query") ?? "";
241
+ if (!query) return jsonError(res, 400, "Missing query parameter");
242
+ const count = parseInt(url.searchParams.get("count") ?? "10", 10);
243
+ const cursor = url.searchParams.get("cursor") ?? null;
244
+ const result = await room.searchMessages(query, count, cursor);
245
+ res.writeHead(200, { "Content-Type": "application/json" });
246
+ res.end(JSON.stringify(result));
247
+ return;
248
+ }
249
+ }
250
+ if (req.method === "POST") {
251
+ const body = await parseBody(req);
252
+ if (url.pathname === "/join") {
253
+ const shareToken = String(body.token ?? "");
254
+ const legacyType = String(body.type ?? "");
255
+ let authority;
256
+ if (shareToken) {
257
+ const tokenAuthority = tokens.validateShareToken(shareToken);
258
+ if (!tokenAuthority) return jsonError(res, 403, "Invalid share token");
259
+ authority = tokenAuthority;
260
+ } else if (legacyType === "guest") {
261
+ authority = "observer";
262
+ } else if (legacyType === "human") {
263
+ authority = "participant";
264
+ } else {
265
+ authority = "participant";
266
+ }
267
+ const participantType = String(body.type ?? "human");
268
+ const name = String(body.name ?? randomName());
269
+ if (authority === "observer") {
270
+ const id2 = `obs_${randomUUID().slice(0, 8)}`;
271
+ const channel2 = room.observe();
272
+ const sessionToken3 = tokens.createSessionToken(id2, "observer");
273
+ observers.set(sessionToken3, { id: id2, authority: "observer", channel: channel2, sessionToken: sessionToken3 });
274
+ idToSession.set(id2, sessionToken3);
275
+ const participantList2 = room.listParticipants().map((p) => ({
276
+ id: p.id,
277
+ name: p.name,
278
+ type: p.type,
279
+ authority: p.authority ?? "participant"
280
+ }));
281
+ res.writeHead(200, { "Content-Type": "application/json" });
282
+ res.end(JSON.stringify({
283
+ sessionToken: sessionToken3,
284
+ participantId: id2,
285
+ roomName,
286
+ roomId: room.roomId,
287
+ participants: participantList2,
288
+ authority: "observer"
289
+ }));
290
+ return;
291
+ }
292
+ const id = `${participantType}_${randomUUID().slice(0, 8)}`;
293
+ const channel = await room.connect(id, name, { type: participantType, authority });
294
+ const sessionToken2 = tokens.createSessionToken(id, authority);
295
+ participants.set(sessionToken2, { id, name, authority, channel, sessionToken: sessionToken2 });
296
+ idToSession.set(id, sessionToken2);
297
+ const participantList = room.listParticipants().map((p) => ({
298
+ id: p.id,
299
+ name: p.name,
300
+ type: p.type,
301
+ authority: p.authority ?? "participant"
302
+ }));
303
+ log(`${name} joined (${authority})`);
304
+ res.writeHead(200, { "Content-Type": "application/json" });
305
+ res.end(JSON.stringify({
306
+ sessionToken: sessionToken2,
307
+ participantId: id,
308
+ roomName,
309
+ roomId: room.roomId,
310
+ participants: participantList,
311
+ authority
312
+ }));
313
+ return;
314
+ }
315
+ const sessionToken = String(body.token ?? "");
316
+ const session = getSession(sessionToken);
317
+ if (url.pathname === "/message") {
318
+ if (!session) return jsonError(res, 401, "Invalid session token");
319
+ if (session.authority === "observer") return jsonError(res, 403, "Observers cannot send messages");
320
+ const content = String(body.content ?? "");
321
+ const replyTo = body.replyTo ? String(body.replyTo) : void 0;
322
+ if (!content) return jsonError(res, 400, "Empty message");
323
+ const p = participants.get(sessionToken);
324
+ if (!p) return jsonError(res, 403, "Not a participant");
325
+ const msg = await p.channel.sendMessage(content, replyTo);
326
+ jsonOk(res, { messageId: msg.id });
327
+ return;
328
+ }
329
+ if (url.pathname === "/event") {
330
+ if (!session) return jsonError(res, 401, "Invalid session token");
331
+ if (session.authority === "observer") return jsonError(res, 403, "Observers cannot emit events");
332
+ const event = body.event;
333
+ if (!event) return jsonError(res, 400, "Missing event");
334
+ const p = participants.get(sessionToken);
335
+ if (!p) return jsonError(res, 403, "Not a participant");
336
+ await p.channel.emit(event);
337
+ jsonOk(res);
338
+ return;
339
+ }
340
+ if (url.pathname === "/set-mode") {
341
+ if (!session) return jsonError(res, 401, "Invalid session token");
342
+ const targetId = body.participantId ? String(body.participantId) : session.id;
343
+ const mode = String(body.mode ?? "");
344
+ if (!mode) return jsonError(res, 400, "Missing mode");
345
+ if (targetId !== session.id && session.authority !== "admin") {
346
+ return jsonError(res, 403, "Only admins can change other participants' modes");
347
+ }
348
+ const p = participants.get(sessionToken);
349
+ if (!p) return jsonError(res, 403, "Not a participant");
350
+ await p.channel.emit(createEvent({
351
+ type: "Activity",
352
+ category: "ACTIVITY",
353
+ room_id: room.roomId,
354
+ participant_id: targetId,
355
+ action: "mode_changed",
356
+ detail: { mode }
357
+ }));
358
+ jsonOk(res);
359
+ return;
360
+ }
361
+ if (url.pathname === "/set-authority") {
362
+ if (!session) return jsonError(res, 401, "Invalid session token");
363
+ if (session.authority !== "admin") return jsonError(res, 403, "Only admins can change authority");
364
+ const targetId = String(body.participantId ?? "");
365
+ const newAuthority = String(body.authority ?? "");
366
+ if (!targetId) return jsonError(res, 400, "Missing participantId");
367
+ if (!["admin", "participant", "observer"].includes(newAuthority)) {
368
+ return jsonError(res, 400, "Invalid authority. Must be admin, participant, or observer.");
369
+ }
370
+ if (targetId === session.id) return jsonError(res, 400, "Cannot change own authority");
371
+ const targetSession = idToSession.get(targetId);
372
+ if (!targetSession) return jsonError(res, 404, "Participant not found");
373
+ const target = participants.get(targetSession);
374
+ if (!target) return jsonError(res, 404, "Participant not found");
375
+ target.authority = newAuthority;
376
+ tokens.updateSessionAuthority(targetSession, newAuthority);
377
+ room.setParticipantAuthority(targetId, newAuthority);
378
+ const p = participants.get(sessionToken);
379
+ if (p) {
380
+ await p.channel.emit(createEvent({
381
+ type: "Activity",
382
+ category: "ACTIVITY",
383
+ room_id: room.roomId,
384
+ participant_id: targetId,
385
+ action: "authority_changed",
386
+ detail: { authority: newAuthority }
387
+ }));
388
+ }
389
+ log(`${target.name} authority \u2192 ${newAuthority}`);
390
+ jsonOk(res);
391
+ return;
392
+ }
393
+ if (url.pathname === "/kick") {
394
+ if (!session) return jsonError(res, 401, "Invalid session token");
395
+ if (session.authority !== "admin") return jsonError(res, 403, "Only admins can kick");
396
+ const targetId = String(body.participantId ?? "");
397
+ if (!targetId) return jsonError(res, 400, "Missing participantId");
398
+ const targetSession = idToSession.get(targetId);
399
+ if (targetSession) {
400
+ const target = participants.get(targetSession) ?? observers.get(targetSession);
401
+ if (target) {
402
+ await target.channel.disconnect();
403
+ participants.delete(targetSession);
404
+ observers.delete(targetSession);
405
+ idToSession.delete(targetId);
406
+ tokens.revokeSessionToken(targetSession);
407
+ const sse = sseConnections.get(targetId);
408
+ if (sse) {
409
+ sse.end();
410
+ sseConnections.delete(targetId);
411
+ }
412
+ log(`kicked ${targetId}`);
413
+ }
414
+ }
415
+ jsonOk(res);
416
+ return;
417
+ }
418
+ if (url.pathname === "/share") {
419
+ if (!session) return jsonError(res, 401, "Invalid session token");
420
+ if (session.authority === "observer") return jsonError(res, 403, "Observers cannot create share links");
421
+ const targetAuthority = body.authority ?? void 0;
422
+ const links = {};
423
+ if (targetAuthority) {
424
+ const token = tokens.generateShareToken(session.authority, targetAuthority);
425
+ if (!token) return jsonError(res, 403, `Cannot generate ${targetAuthority} link`);
426
+ links[targetAuthority] = buildShareUrl(publicUrl, token);
427
+ } else {
428
+ const tiers = ["admin", "participant", "observer"];
429
+ for (const tier of tiers) {
430
+ const token = tokens.generateShareToken(session.authority, tier);
431
+ if (token) links[tier] = buildShareUrl(publicUrl, token);
432
+ }
433
+ }
434
+ res.writeHead(200, { "Content-Type": "application/json" });
435
+ res.end(JSON.stringify({ links }));
436
+ return;
437
+ }
438
+ if (url.pathname === "/disconnect") {
439
+ const token = String(body.token ?? "");
440
+ const legacyId = String(body.participantId ?? body.agentId ?? "");
441
+ let targetToken = token;
442
+ if (!targetToken && legacyId) {
443
+ targetToken = idToSession.get(legacyId) ?? "";
444
+ }
445
+ if (targetToken) {
446
+ const p = participants.get(targetToken);
447
+ if (p) {
448
+ await p.channel.disconnect();
449
+ participants.delete(targetToken);
450
+ idToSession.delete(p.id);
451
+ tokens.revokeSessionToken(targetToken);
452
+ const sse = sseConnections.get(p.id);
453
+ if (sse) {
454
+ sse.end();
455
+ sseConnections.delete(p.id);
456
+ }
457
+ log(`${p.name} disconnected`);
458
+ }
459
+ const o = observers.get(targetToken);
460
+ if (o) {
461
+ await o.channel.disconnect();
462
+ observers.delete(targetToken);
463
+ idToSession.delete(o.id);
464
+ tokens.revokeSessionToken(targetToken);
465
+ const sse = sseConnections.get(o.id);
466
+ if (sse) {
467
+ sse.end();
468
+ sseConnections.delete(o.id);
469
+ }
470
+ }
471
+ }
472
+ jsonOk(res);
473
+ return;
474
+ }
475
+ }
476
+ res.writeHead(404).end("Not found");
477
+ });
478
+ httpServer.on("error", (err) => {
479
+ if (err.code === "EADDRINUSE") {
480
+ console.error(`
481
+ Port ${port} is already in use. Another stoops instance may be running.`);
482
+ console.error(` Kill it: lsof -ti :${port} | xargs kill`);
483
+ console.error(` Or use: stoops --port ${port + 1}
484
+ `);
485
+ process.exit(1);
486
+ }
487
+ throw err;
488
+ });
489
+ await new Promise((resolve) => {
490
+ httpServer.listen(port, "0.0.0.0", () => resolve());
491
+ });
492
+ if (options.share) {
493
+ tunnelProcess = await startTunnel(port);
494
+ if (tunnelProcess) {
495
+ const tunnelUrl = await waitForTunnelUrl(tunnelProcess);
496
+ if (tunnelUrl) publicUrl = tunnelUrl;
497
+ }
498
+ }
499
+ const adminToken = tokens.generateShareToken("admin", "admin");
500
+ const participantToken = tokens.generateShareToken("admin", "participant");
501
+ if (options.headless) {
502
+ process.stdout.write(JSON.stringify({ serverUrl, publicUrl, roomName, adminToken, participantToken }) + "\n");
503
+ } else if (!options.quiet) {
504
+ let version = process.env.npm_package_version ?? "";
505
+ if (!version) {
506
+ try {
507
+ const require2 = createRequire(import.meta.url);
508
+ const pkg = require2("../../package.json");
509
+ version = pkg.version ?? "unknown";
510
+ } catch {
511
+ version = "unknown";
512
+ }
513
+ }
514
+ const adminUrl = buildShareUrl(publicUrl, adminToken);
515
+ const joinUrl = buildShareUrl(publicUrl, participantToken);
516
+ console.log(`
517
+ stoops v${version}
518
+
519
+ Room: ${roomName}
520
+ Server: ${serverUrl}${publicUrl !== serverUrl ? `
521
+ Tunnel: ${publicUrl}` : ""}
522
+
523
+ Join: stoops join ${joinUrl}
524
+ Admin: stoops join ${adminUrl}
525
+ Claude: stoops run claude \u2192 then tell agent to join: ${joinUrl}
526
+ `);
527
+ }
528
+ const shutdown = async () => {
529
+ log("shutting down...");
530
+ if (tunnelProcess) {
531
+ tunnelProcess.kill();
532
+ tunnelProcess = null;
533
+ }
534
+ for (const [id, sse] of sseConnections) {
535
+ sse.end();
536
+ sseConnections.delete(id);
537
+ }
538
+ for (const p of participants.values()) {
539
+ await p.channel.disconnect().catch(() => {
540
+ });
541
+ }
542
+ for (const o of observers.values()) {
543
+ await o.channel.disconnect().catch(() => {
544
+ });
545
+ }
546
+ await new Promise((resolve, reject) => {
547
+ httpServer.close((err) => err ? reject(err) : resolve());
548
+ });
549
+ process.exit(0);
550
+ };
551
+ process.on("SIGINT", shutdown);
552
+ process.on("SIGTERM", shutdown);
553
+ return { serverUrl, publicUrl, roomName, adminToken, participantToken };
554
+ }
555
+ function logServer(message) {
556
+ console.log(` [${formatTimestamp(/* @__PURE__ */ new Date())}] ${message}`);
557
+ }
558
+ function cloudflaredAvailable() {
559
+ try {
560
+ execFileSync("which", ["cloudflared"], { stdio: "ignore" });
561
+ return true;
562
+ } catch {
563
+ return false;
564
+ }
565
+ }
566
+ async function startTunnel(port) {
567
+ if (!cloudflaredAvailable()) {
568
+ console.error(" --share requires cloudflared. Install: brew install cloudflared");
569
+ return null;
570
+ }
571
+ const child = spawn("cloudflared", ["tunnel", "--url", `http://localhost:${port}`], {
572
+ stdio: ["ignore", "ignore", "pipe"]
573
+ });
574
+ child.on("error", () => {
575
+ });
576
+ return child;
577
+ }
578
+ function waitForTunnelUrl(child, timeoutMs = 15e3) {
579
+ return new Promise((resolve) => {
580
+ let resolved = false;
581
+ let buffer = "";
582
+ const timer = setTimeout(() => {
583
+ if (!resolved) {
584
+ resolved = true;
585
+ resolve(null);
586
+ }
587
+ }, timeoutMs);
588
+ child.stderr?.on("data", (chunk) => {
589
+ buffer += chunk.toString();
590
+ const match = buffer.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
591
+ if (match && !resolved) {
592
+ resolved = true;
593
+ clearTimeout(timer);
594
+ resolve(match[0]);
595
+ }
596
+ });
597
+ child.on("exit", () => {
598
+ if (!resolved) {
599
+ resolved = true;
600
+ clearTimeout(timer);
601
+ resolve(null);
602
+ }
603
+ });
604
+ });
605
+ }
606
+
607
+ // src/cli/join.ts
608
+ import { randomUUID as randomUUID2 } from "crypto";
609
+ import { createInterface } from "readline";
610
+
611
+ // src/cli/tui.tsx
612
+ import React, { useState, useEffect, useCallback, useMemo } from "react";
613
+ import { render, Box, Text, Static, useStdout, useInput } from "ink";
614
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
615
+ var C = {
616
+ cyan: "#00d4ff",
617
+ purple: "#8b5cf6",
618
+ orange: "#ff8c42",
619
+ pink: "#f472b6",
620
+ green: "#34d399",
621
+ yellow: "#fbbf24",
622
+ danger: "#f87171",
623
+ text: "#eceff4",
624
+ secondary: "#b0b7c4",
625
+ dim: "#7e8798",
626
+ muted: "#5b6679",
627
+ border: "#475264"
628
+ };
629
+ var AGENT_COLORS = [C.cyan, C.purple, C.orange, C.pink, C.green, C.yellow];
630
+ var SIGILS = ["\u25C6", "\u25B2", "\u25CF", "\u25A0", "\u2605", "\u25C9", "\u25C8", "\u25B8"];
631
+ var BANNER_LINES = [
632
+ " __ ",
633
+ " _____/ /_____ ____ ____ _____",
634
+ " / ___/ __/ __ \\/ __ \\/ __ \\/ ___/",
635
+ " (__ ) /_/ /_/ / /_/ / /_/ (__ ) ",
636
+ "/____/\\__/\\____/\\____/ .___/____/ ",
637
+ " /_/ "
638
+ ];
639
+ var GRADIENT = ["#9b6dff", "#7c8bff", "#5da8ff", "#3dc4ff", "#1ddcff", "#00e8ff"];
640
+ var ENGAGEMENT_MODES = [
641
+ "everyone",
642
+ "people",
643
+ "agents",
644
+ "me",
645
+ "standby-everyone",
646
+ "standby-people",
647
+ "standby-agents",
648
+ "standby-me"
649
+ ];
650
+ var SLASH_COMMANDS = [
651
+ { name: "/who", description: "List participants" },
652
+ { name: "/leave", description: "Disconnect and exit" },
653
+ { name: "/share", description: "Generate share links" },
654
+ { name: "/kick", description: "Remove a participant", adminOnly: true, params: [
655
+ { label: "name", completions: "participants" }
656
+ ] },
657
+ { name: "/mute", description: "Make read-only (observer)", adminOnly: true, params: [
658
+ { label: "name", completions: "participants" }
659
+ ] },
660
+ { name: "/unmute", description: "Restore to participant", adminOnly: true, params: [
661
+ { label: "name", completions: "participants" }
662
+ ] },
663
+ { name: "/setmode", description: "Set engagement mode", adminOnly: true, params: [
664
+ { label: "name", completions: "participants" },
665
+ { label: "mode", completions: ENGAGEMENT_MODES }
666
+ ] }
667
+ ];
668
+ var CMD_DISPLAY_COL = 26;
669
+ function seedHash(s) {
670
+ let h = 0;
671
+ for (let i = 0; i < s.length; i++) h = (h << 5) - h + s.charCodeAt(i) | 0;
672
+ return Math.abs(h);
673
+ }
674
+ function makeIdentityAssigner() {
675
+ const map = /* @__PURE__ */ new Map();
676
+ let colorIdx = 0;
677
+ return (name) => {
678
+ if (!map.has(name)) {
679
+ const h = seedHash(name);
680
+ map.set(name, {
681
+ color: AGENT_COLORS[colorIdx++ % AGENT_COLORS.length],
682
+ sigil: SIGILS[h % SIGILS.length]
683
+ });
684
+ }
685
+ return map.get(name);
686
+ };
687
+ }
688
+ var NAME_COL = 12;
689
+ function EventLine({
690
+ event,
691
+ identify
692
+ }) {
693
+ const ts = /* @__PURE__ */ jsxs(Text, { color: C.muted, children: [
694
+ event.ts,
695
+ " "
696
+ ] });
697
+ if (event.kind === "message") {
698
+ const { color, sigil } = identify(event.senderName);
699
+ const isSelf = event.isSelf;
700
+ const nameColor = isSelf ? C.text : event.senderType === "agent" ? color : C.secondary;
701
+ const sigilColor = isSelf ? C.dim : event.senderType === "agent" ? color : C.dim;
702
+ const sigilChar = isSelf ? "\u203A" : event.senderType === "agent" ? sigil : "\xB7";
703
+ const contentColor = isSelf ? C.text : C.secondary;
704
+ return /* @__PURE__ */ jsxs(Box, { paddingX: 1, children: [
705
+ /* @__PURE__ */ jsxs(Box, { flexShrink: 0, children: [
706
+ ts,
707
+ /* @__PURE__ */ jsxs(Text, { color: sigilColor, children: [
708
+ sigilChar,
709
+ " "
710
+ ] }),
711
+ /* @__PURE__ */ jsx(Text, { color: nameColor, bold: isSelf, children: event.senderName.slice(0, NAME_COL).padEnd(NAME_COL) }),
712
+ /* @__PURE__ */ jsx(Text, { children: " " })
713
+ ] }),
714
+ /* @__PURE__ */ jsx(Box, { flexGrow: 1, flexShrink: 1, children: /* @__PURE__ */ jsxs(Text, { wrap: "wrap", children: [
715
+ event.replyToName && /* @__PURE__ */ jsxs(Text, { color: C.dim, children: [
716
+ "\u2192 ",
717
+ event.replyToName,
718
+ " "
719
+ ] }),
720
+ /* @__PURE__ */ jsx(Text, { color: contentColor, children: event.content })
721
+ ] }) })
722
+ ] });
723
+ }
724
+ if (event.kind === "join") {
725
+ const isAgent = event.participantType === "agent";
726
+ const { color, sigil } = isAgent ? identify(event.name) : { color: C.dim, sigil: "\xB7" };
727
+ return /* @__PURE__ */ jsxs(Box, { paddingX: 1, children: [
728
+ ts,
729
+ /* @__PURE__ */ jsxs(Text, { color: isAgent ? color : C.dim, children: [
730
+ sigil,
731
+ " "
732
+ ] }),
733
+ /* @__PURE__ */ jsx(Text, { color: isAgent ? color : C.dim, children: event.name }),
734
+ /* @__PURE__ */ jsx(Text, { color: C.green, children: " joined" })
735
+ ] });
736
+ }
737
+ if (event.kind === "leave") {
738
+ const isAgent = event.participantType === "agent";
739
+ const { color: nameColor } = isAgent ? identify(event.name) : { color: C.muted };
740
+ return /* @__PURE__ */ jsxs(Box, { paddingX: 1, children: [
741
+ ts,
742
+ /* @__PURE__ */ jsx(Text, { color: C.muted, children: "\xB7 " }),
743
+ /* @__PURE__ */ jsx(Text, { color: nameColor, children: event.name }),
744
+ /* @__PURE__ */ jsx(Text, { color: C.danger, children: " left" })
745
+ ] });
746
+ }
747
+ if (event.kind === "mode") {
748
+ return /* @__PURE__ */ jsxs(Box, { paddingX: 1, children: [
749
+ ts,
750
+ /* @__PURE__ */ jsx(Text, { color: C.dim, children: "mode \u2192 " }),
751
+ /* @__PURE__ */ jsx(Text, { color: C.yellow, bold: true, children: event.mode })
752
+ ] });
753
+ }
754
+ if (event.kind === "system") {
755
+ return /* @__PURE__ */ jsxs(Box, { paddingX: 1, children: [
756
+ ts,
757
+ /* @__PURE__ */ jsx(Text, { color: C.dim, children: " " }),
758
+ /* @__PURE__ */ jsx(Text, { color: C.secondary, children: event.content })
759
+ ] });
760
+ }
761
+ return null;
762
+ }
763
+ function App({
764
+ roomName,
765
+ onSend,
766
+ onCtrlC,
767
+ onReady,
768
+ readOnly,
769
+ isAdmin
770
+ }) {
771
+ const [events, setEvents] = useState([]);
772
+ const [agentNames, setAgentNames] = useState([]);
773
+ const [participants, setParticipants] = useState([]);
774
+ const [input, setInput] = useState("");
775
+ const [selectedIndex, setSelectedIndex] = useState(0);
776
+ const { stdout } = useStdout();
777
+ const identify = useMemo(makeIdentityAssigner, []);
778
+ const push = useCallback((event) => {
779
+ setEvents((prev) => [...prev, event]);
780
+ }, []);
781
+ useEffect(() => {
782
+ onReady({ push, setAgentNames, setParticipants });
783
+ }, []);
784
+ const suggestionState = useMemo(() => {
785
+ const mentionMatch = input.match(/@([a-zA-Z0-9_-]*)$/);
786
+ if (mentionMatch && participants.length > 0) {
787
+ const prefix = mentionMatch[1].toLowerCase();
788
+ const filtered2 = participants.filter((p) => p.toLowerCase().startsWith(prefix));
789
+ if (filtered2.length > 0) {
790
+ const before = input.slice(0, mentionMatch.index);
791
+ const items2 = filtered2.map((p) => ({
792
+ kind: "mention",
793
+ value: p,
794
+ insert: before + "@" + p + " "
795
+ }));
796
+ const ghostHint2 = prefix.length === 0 ? "" : filtered2[0].slice(prefix.length);
797
+ return { items: items2, ghostHint: ghostHint2 };
798
+ }
799
+ }
800
+ if (!input.startsWith("/")) return { items: [], ghostHint: "" };
801
+ const spaceIdx = input.indexOf(" ");
802
+ if (spaceIdx === -1) {
803
+ const prefix = input.toLowerCase();
804
+ const items2 = SLASH_COMMANDS.filter((cmd2) => {
805
+ if (cmd2.adminOnly && !isAdmin) return false;
806
+ return cmd2.name.startsWith(prefix);
807
+ }).map((cmd2) => ({
808
+ kind: "command",
809
+ cmd: cmd2,
810
+ insert: cmd2.name + " "
811
+ }));
812
+ return { items: items2, ghostHint: "" };
813
+ }
814
+ const cmdName = input.slice(0, spaceIdx).toLowerCase();
815
+ const cmd = SLASH_COMMANDS.find((c) => c.name === cmdName && (!c.adminOnly || isAdmin));
816
+ if (!cmd?.params) return { items: [], ghostHint: "" };
817
+ const rest = input.slice(spaceIdx + 1);
818
+ const words = rest.split(/\s+/);
819
+ const hasTrailingSpace = rest.endsWith(" ") || rest === "";
820
+ const completedCount = hasTrailingSpace ? words.filter(Boolean).length : Math.max(0, words.length - 1);
821
+ const currentPrefix = hasTrailingSpace ? "" : (words[words.length - 1] ?? "").toLowerCase();
822
+ const paramIdx = completedCount;
823
+ if (paramIdx >= cmd.params.length) return { items: [], ghostHint: "" };
824
+ const param = cmd.params[paramIdx];
825
+ const ghostStart = currentPrefix ? paramIdx + 1 : paramIdx;
826
+ const ghostHint = cmd.params.slice(ghostStart).map((p) => `<${p.label}>`).join(" ");
827
+ if (!param.completions) return { items: [], ghostHint };
828
+ const values = param.completions === "participants" ? participants : param.completions;
829
+ const filtered = currentPrefix ? values.filter((v) => v.toLowerCase().startsWith(currentPrefix)) : values;
830
+ const completedWords = words.slice(0, completedCount).filter(Boolean);
831
+ const base = cmdName + (completedWords.length ? " " + completedWords.join(" ") : "") + " ";
832
+ const items = filtered.map((v) => ({
833
+ kind: "param",
834
+ value: v,
835
+ insert: base + v + " "
836
+ }));
837
+ return { items, ghostHint };
838
+ }, [input, isAdmin, participants]);
839
+ const suggestions = suggestionState.items;
840
+ useEffect(() => {
841
+ setSelectedIndex(0);
842
+ }, [input]);
843
+ useInput((char, key) => {
844
+ if (key.ctrl && char === "c") {
845
+ onCtrlC?.();
846
+ return;
847
+ }
848
+ if (readOnly || !onSend) return;
849
+ if (suggestions.length > 0) {
850
+ if (key.downArrow) {
851
+ setSelectedIndex((i) => Math.min(i + 1, suggestions.length - 1));
852
+ return;
853
+ }
854
+ if (key.upArrow) {
855
+ setSelectedIndex((i) => Math.max(i - 1, 0));
856
+ return;
857
+ }
858
+ if (key.return || key.tab) {
859
+ const picked = suggestions[selectedIndex];
860
+ if (!picked) return;
861
+ if (key.return && picked.kind === "command" && !picked.cmd.params) {
862
+ onSend(picked.cmd.name);
863
+ setInput("");
864
+ return;
865
+ }
866
+ setInput(picked.insert);
867
+ return;
868
+ }
869
+ if (key.escape) {
870
+ setInput("");
871
+ return;
872
+ }
873
+ }
874
+ if (key.return && key.meta) {
875
+ setInput((prev) => prev + "\n");
876
+ return;
877
+ }
878
+ if (key.return) {
879
+ const content = input.trim();
880
+ if (content) onSend(content);
881
+ setInput("");
882
+ return;
883
+ }
884
+ if (key.backspace || key.delete) {
885
+ setInput((prev) => prev.slice(0, -1));
886
+ return;
887
+ }
888
+ if (key.ctrl || key.meta || key.escape || key.tab || key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) {
889
+ return;
890
+ }
891
+ if (char) {
892
+ setInput((prev) => prev + char);
893
+ }
894
+ });
895
+ const cols = stdout.columns ?? 80;
896
+ const entries = useMemo(
897
+ () => [{ id: "__banner__" }, ...events.map((e) => ({ id: e.id, event: e }))],
898
+ [events]
899
+ );
900
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
901
+ /* @__PURE__ */ jsx(Static, { items: entries, children: (entry) => {
902
+ if (!entry.event) {
903
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingX: 2, paddingTop: 1, paddingBottom: 1, children: [
904
+ BANNER_LINES.map((line, i) => /* @__PURE__ */ jsx(Text, { color: GRADIENT[i], children: line }, i)),
905
+ /* @__PURE__ */ jsx(Text, { children: " " }),
906
+ /* @__PURE__ */ jsxs(Text, { children: [
907
+ /* @__PURE__ */ jsx(Text, { color: C.dim, children: " room " }),
908
+ /* @__PURE__ */ jsx(Text, { color: C.cyan, bold: true, children: roomName })
909
+ ] })
910
+ ] }, entry.id);
911
+ }
912
+ return /* @__PURE__ */ jsx(EventLine, { event: entry.event, identify }, entry.id);
913
+ } }),
914
+ /* @__PURE__ */ jsxs(Box, { paddingX: 1, children: [
915
+ /* @__PURE__ */ jsx(Text, { color: C.purple, children: "\u2500" }),
916
+ /* @__PURE__ */ jsx(Text, { color: C.border, children: "\u2500".repeat(Math.max(0, cols - 4)) }),
917
+ /* @__PURE__ */ jsx(Text, { color: C.cyan, children: "\u2500" })
918
+ ] }),
919
+ agentNames.length > 0 && /* @__PURE__ */ jsx(Box, { paddingX: 1, children: agentNames.map((name, i) => {
920
+ const { color, sigil } = identify(name);
921
+ return /* @__PURE__ */ jsxs(React.Fragment, { children: [
922
+ i > 0 && /* @__PURE__ */ jsx(Text, { color: C.border, children: " \xB7 " }),
923
+ /* @__PURE__ */ jsxs(Text, { color, children: [
924
+ sigil,
925
+ " ",
926
+ name
927
+ ] })
928
+ ] }, name);
929
+ }) }),
930
+ readOnly || !onSend ? /* @__PURE__ */ jsx(Box, { paddingX: 1, children: /* @__PURE__ */ jsx(Text, { color: C.muted, children: " watching as guest" }) }) : /* @__PURE__ */ jsx(Box, { paddingX: 1, flexDirection: "column", children: (input || "").split("\n").map((line, i, arr) => /* @__PURE__ */ jsxs(Box, { children: [
931
+ /* @__PURE__ */ jsx(Text, { color: C.cyan, bold: true, children: i === 0 ? "\u203A " : " " }),
932
+ /* @__PURE__ */ jsxs(Text, { children: [
933
+ line,
934
+ i === arr.length - 1 && /* @__PURE__ */ jsx(Text, { inverse: true, children: " " }),
935
+ i === arr.length - 1 && suggestionState.ghostHint !== "" && /* @__PURE__ */ jsx(Text, { color: C.muted, children: suggestionState.ghostHint })
936
+ ] })
937
+ ] }, i)) }),
938
+ suggestions.length > 0 && /* @__PURE__ */ jsx(Box, { flexDirection: "column", paddingX: 1, children: suggestions.map((s, i) => {
939
+ const selected = i === selectedIndex;
940
+ if (s.kind === "command") {
941
+ const paramHint = s.cmd.params ? " " + s.cmd.params.map((p) => `<${p.label}>`).join(" ") : "";
942
+ const display = s.cmd.name + paramHint;
943
+ return /* @__PURE__ */ jsxs(Box, { children: [
944
+ /* @__PURE__ */ jsx(Text, { color: selected ? C.cyan : C.muted, children: selected ? "\u203A " : " " }),
945
+ /* @__PURE__ */ jsxs(Text, { children: [
946
+ /* @__PURE__ */ jsx(Text, { color: selected ? C.cyan : C.secondary, bold: selected, children: s.cmd.name }),
947
+ /* @__PURE__ */ jsx(Text, { color: C.muted, children: paramHint.padEnd(CMD_DISPLAY_COL - display.length + paramHint.length) })
948
+ ] }),
949
+ /* @__PURE__ */ jsx(Text, { color: C.dim, children: s.cmd.description })
950
+ ] }, s.cmd.name);
951
+ }
952
+ return /* @__PURE__ */ jsxs(Box, { children: [
953
+ /* @__PURE__ */ jsx(Text, { color: selected ? C.cyan : C.muted, children: selected ? "\u203A " : " " }),
954
+ s.kind === "mention" && /* @__PURE__ */ jsx(Text, { color: C.dim, children: "@" }),
955
+ /* @__PURE__ */ jsx(Text, { color: selected ? C.cyan : C.secondary, bold: selected, children: s.value })
956
+ ] }, s.value);
957
+ }) })
958
+ ] });
959
+ }
960
+ function startTUI(opts) {
961
+ let handle = null;
962
+ const queue = [];
963
+ const onReady = (h) => {
964
+ handle = h;
965
+ for (const event of queue.splice(0)) h.push(event);
966
+ };
967
+ const { unmount } = render(
968
+ /* @__PURE__ */ jsx(
969
+ App,
970
+ {
971
+ roomName: opts.roomName,
972
+ onSend: opts.onSend,
973
+ onCtrlC: opts.onCtrlC,
974
+ onReady,
975
+ readOnly: opts.readOnly,
976
+ isAdmin: opts.isAdmin
977
+ }
978
+ ),
979
+ { exitOnCtrlC: false }
980
+ );
981
+ return {
982
+ push(event) {
983
+ if (handle) handle.push(event);
984
+ else queue.push(event);
985
+ },
986
+ setAgentNames(names) {
987
+ handle?.setAgentNames(names);
988
+ },
989
+ setParticipants(names) {
990
+ handle?.setParticipants(names);
991
+ },
992
+ stop() {
993
+ unmount();
994
+ }
995
+ };
996
+ }
997
+
998
+ // src/cli/join.ts
999
+ async function join(options) {
1000
+ const token = extractToken(options.server);
1001
+ let serverUrl;
1002
+ try {
1003
+ const parsed = new URL(options.server);
1004
+ parsed.search = "";
1005
+ serverUrl = parsed.toString().replace(/\/$/, "");
1006
+ } catch {
1007
+ serverUrl = options.server.replace(/\/$/, "");
1008
+ }
1009
+ const name = options.name ?? randomName();
1010
+ const isGuest = options.guest ?? false;
1011
+ let sessionToken;
1012
+ let participantId;
1013
+ let roomName;
1014
+ let authority;
1015
+ let participants;
1016
+ try {
1017
+ const joinBody = {};
1018
+ if (token) {
1019
+ joinBody.token = token;
1020
+ joinBody.type = "human";
1021
+ joinBody.name = name;
1022
+ } else if (isGuest) {
1023
+ joinBody.type = "guest";
1024
+ } else {
1025
+ joinBody.type = "human";
1026
+ joinBody.name = name;
1027
+ }
1028
+ const res = await fetch(`${serverUrl}/join`, {
1029
+ method: "POST",
1030
+ headers: { "Content-Type": "application/json" },
1031
+ body: JSON.stringify(joinBody)
1032
+ });
1033
+ if (!res.ok) {
1034
+ const err = await res.text();
1035
+ console.error(`Failed to join: ${err}`);
1036
+ process.exit(1);
1037
+ }
1038
+ const data = await res.json();
1039
+ sessionToken = String(data.sessionToken ?? "");
1040
+ participantId = String(data.participantId);
1041
+ roomName = String(data.roomName);
1042
+ authority = data.authority ?? "participant";
1043
+ participants = data.participants ?? [];
1044
+ } catch {
1045
+ console.error(`Cannot reach stoops server at ${serverUrl}. Is it running?`);
1046
+ process.exit(1);
1047
+ }
1048
+ let disconnected = false;
1049
+ let cleanupStream = null;
1050
+ const disconnect = async () => {
1051
+ if (disconnected) return;
1052
+ disconnected = true;
1053
+ cleanupStream?.();
1054
+ try {
1055
+ await fetch(`${serverUrl}/disconnect`, {
1056
+ method: "POST",
1057
+ headers: { "Content-Type": "application/json" },
1058
+ body: JSON.stringify({ token: sessionToken })
1059
+ });
1060
+ } catch {
1061
+ }
1062
+ };
1063
+ if (options.headless) {
1064
+ const sseController = new AbortController();
1065
+ const cleanup = async () => {
1066
+ sseController.abort();
1067
+ await disconnect();
1068
+ };
1069
+ process.on("SIGINT", async () => {
1070
+ await cleanup();
1071
+ process.exit(0);
1072
+ });
1073
+ process.on("SIGTERM", async () => {
1074
+ await cleanup();
1075
+ process.exit(0);
1076
+ });
1077
+ const rl = createInterface({ input: process.stdin, terminal: false });
1078
+ rl.on("line", async (line) => {
1079
+ const content = line.trim();
1080
+ if (!content || authority === "observer") return;
1081
+ try {
1082
+ await fetch(`${serverUrl}/message`, {
1083
+ method: "POST",
1084
+ headers: { "Content-Type": "application/json" },
1085
+ body: JSON.stringify({ token: sessionToken, content })
1086
+ });
1087
+ } catch {
1088
+ }
1089
+ });
1090
+ try {
1091
+ const res = await fetch(`${serverUrl}/events`, {
1092
+ method: "POST",
1093
+ headers: { Accept: "text/event-stream", Authorization: `Bearer ${sessionToken}` },
1094
+ signal: sseController.signal
1095
+ });
1096
+ if (!res.ok || !res.body) {
1097
+ process.stderr.write("Failed to connect event stream\n");
1098
+ await cleanup();
1099
+ process.exit(1);
1100
+ }
1101
+ const reader = res.body.getReader();
1102
+ const decoder = new TextDecoder();
1103
+ let buf = "";
1104
+ while (true) {
1105
+ const { done, value } = await reader.read();
1106
+ if (done) break;
1107
+ buf += decoder.decode(value, { stream: true });
1108
+ const parts = buf.split("\n\n");
1109
+ buf = parts.pop();
1110
+ for (const part of parts) {
1111
+ const dataLine = part.split("\n").find((l) => l.startsWith("data: "));
1112
+ if (!dataLine) continue;
1113
+ try {
1114
+ const event = JSON.parse(dataLine.slice(6));
1115
+ process.stdout.write(JSON.stringify(event) + "\n");
1116
+ } catch {
1117
+ }
1118
+ }
1119
+ }
1120
+ } catch {
1121
+ }
1122
+ if (!disconnected) {
1123
+ await cleanup();
1124
+ }
1125
+ process.exit(0);
1126
+ }
1127
+ const isReadOnly = authority === "observer" || isGuest;
1128
+ function systemEvent(content) {
1129
+ tui.push({
1130
+ id: randomUUID2(),
1131
+ ts: formatTimestamp(/* @__PURE__ */ new Date()),
1132
+ kind: "system",
1133
+ content
1134
+ });
1135
+ }
1136
+ async function handleSlashCommand(input) {
1137
+ const parts = input.slice(1).split(/\s+/);
1138
+ const cmd = parts[0]?.toLowerCase();
1139
+ const args2 = parts.slice(1);
1140
+ switch (cmd) {
1141
+ // ── /who ──────────────────────────────────────────────────────
1142
+ case "who": {
1143
+ try {
1144
+ const res = await fetch(`${serverUrl}/participants?token=${sessionToken}`);
1145
+ if (!res.ok) {
1146
+ systemEvent("Failed to get participant list.");
1147
+ return;
1148
+ }
1149
+ const data = await res.json();
1150
+ const lines = data.participants.map((p) => {
1151
+ const auth = p.authority ?? "participant";
1152
+ return ` ${p.type === "agent" ? "agent" : "human"} ${p.name} (${auth})`;
1153
+ });
1154
+ systemEvent(`Participants:
1155
+ ${lines.join("\n")}`);
1156
+ } catch {
1157
+ systemEvent("Failed to reach server.");
1158
+ }
1159
+ return;
1160
+ }
1161
+ // ── /leave ────────────────────────────────────────────────────
1162
+ case "leave": {
1163
+ await disconnect();
1164
+ tui.stop();
1165
+ process.exit(0);
1166
+ return;
1167
+ }
1168
+ // ── /kick <name> (admin only) ─────────────────────────────────
1169
+ case "kick": {
1170
+ if (authority !== "admin") {
1171
+ systemEvent("Only admins can kick.");
1172
+ return;
1173
+ }
1174
+ const targetName = args2[0];
1175
+ if (!targetName) {
1176
+ systemEvent("Usage: /kick <name>");
1177
+ return;
1178
+ }
1179
+ try {
1180
+ const res = await fetch(`${serverUrl}/participants?token=${sessionToken}`);
1181
+ if (!res.ok) {
1182
+ systemEvent("Failed to get participant list.");
1183
+ return;
1184
+ }
1185
+ const data = await res.json();
1186
+ const target = data.participants.find((p) => p.name.toLowerCase() === targetName.toLowerCase());
1187
+ if (!target) {
1188
+ systemEvent(`Participant "${targetName}" not found.`);
1189
+ return;
1190
+ }
1191
+ const kickRes = await fetch(`${serverUrl}/kick`, {
1192
+ method: "POST",
1193
+ headers: { "Content-Type": "application/json" },
1194
+ body: JSON.stringify({ token: sessionToken, participantId: target.id })
1195
+ });
1196
+ if (!kickRes.ok) {
1197
+ systemEvent(`Failed to kick: ${await kickRes.text()}`);
1198
+ return;
1199
+ }
1200
+ systemEvent(`Kicked ${targetName}.`);
1201
+ } catch {
1202
+ systemEvent("Failed to reach server.");
1203
+ }
1204
+ return;
1205
+ }
1206
+ // ── /mute <name> (admin only) — demote to observer ────────────
1207
+ case "mute": {
1208
+ if (authority !== "admin") {
1209
+ systemEvent("Only admins can mute.");
1210
+ return;
1211
+ }
1212
+ const targetName = args2[0];
1213
+ if (!targetName) {
1214
+ systemEvent("Usage: /mute <name>");
1215
+ return;
1216
+ }
1217
+ try {
1218
+ const res = await fetch(`${serverUrl}/participants?token=${sessionToken}`);
1219
+ if (!res.ok) {
1220
+ systemEvent("Failed to get participant list.");
1221
+ return;
1222
+ }
1223
+ const data = await res.json();
1224
+ const target = data.participants.find((p) => p.name.toLowerCase() === targetName.toLowerCase());
1225
+ if (!target) {
1226
+ systemEvent(`Participant "${targetName}" not found.`);
1227
+ return;
1228
+ }
1229
+ const authRes = await fetch(`${serverUrl}/set-authority`, {
1230
+ method: "POST",
1231
+ headers: { "Content-Type": "application/json" },
1232
+ body: JSON.stringify({ token: sessionToken, participantId: target.id, authority: "observer" })
1233
+ });
1234
+ if (!authRes.ok) {
1235
+ systemEvent(`Failed to mute: ${await authRes.text()}`);
1236
+ return;
1237
+ }
1238
+ systemEvent(`Muted ${targetName} (observer).`);
1239
+ } catch {
1240
+ systemEvent("Failed to reach server.");
1241
+ }
1242
+ return;
1243
+ }
1244
+ // ── /unmute <name> (admin only) — restore to participant ──────
1245
+ case "unmute": {
1246
+ if (authority !== "admin") {
1247
+ systemEvent("Only admins can unmute.");
1248
+ return;
1249
+ }
1250
+ const targetName = args2[0];
1251
+ if (!targetName) {
1252
+ systemEvent("Usage: /unmute <name>");
1253
+ return;
1254
+ }
1255
+ try {
1256
+ const res = await fetch(`${serverUrl}/participants?token=${sessionToken}`);
1257
+ if (!res.ok) {
1258
+ systemEvent("Failed to get participant list.");
1259
+ return;
1260
+ }
1261
+ const data = await res.json();
1262
+ const target = data.participants.find((p) => p.name.toLowerCase() === targetName.toLowerCase());
1263
+ if (!target) {
1264
+ systemEvent(`Participant "${targetName}" not found.`);
1265
+ return;
1266
+ }
1267
+ const authRes = await fetch(`${serverUrl}/set-authority`, {
1268
+ method: "POST",
1269
+ headers: { "Content-Type": "application/json" },
1270
+ body: JSON.stringify({ token: sessionToken, participantId: target.id, authority: "participant" })
1271
+ });
1272
+ if (!authRes.ok) {
1273
+ systemEvent(`Failed to unmute: ${await authRes.text()}`);
1274
+ return;
1275
+ }
1276
+ systemEvent(`Unmuted ${targetName} (participant).`);
1277
+ } catch {
1278
+ systemEvent("Failed to reach server.");
1279
+ }
1280
+ return;
1281
+ }
1282
+ // ── /setmode <name> <mode> (admin only) ───────────────────────
1283
+ case "setmode": {
1284
+ if (authority !== "admin") {
1285
+ systemEvent("Only admins can set modes.");
1286
+ return;
1287
+ }
1288
+ const targetName = args2[0];
1289
+ const mode = args2[1];
1290
+ if (!targetName || !mode) {
1291
+ systemEvent("Usage: /setmode <name> <mode>");
1292
+ return;
1293
+ }
1294
+ try {
1295
+ const res = await fetch(`${serverUrl}/participants?token=${sessionToken}`);
1296
+ if (!res.ok) {
1297
+ systemEvent("Failed to get participant list.");
1298
+ return;
1299
+ }
1300
+ const data = await res.json();
1301
+ const target = data.participants.find((p) => p.name.toLowerCase() === targetName.toLowerCase());
1302
+ if (!target) {
1303
+ systemEvent(`Participant "${targetName}" not found.`);
1304
+ return;
1305
+ }
1306
+ const modeRes = await fetch(`${serverUrl}/set-mode`, {
1307
+ method: "POST",
1308
+ headers: { "Content-Type": "application/json" },
1309
+ body: JSON.stringify({ token: sessionToken, participantId: target.id, mode })
1310
+ });
1311
+ if (!modeRes.ok) {
1312
+ systemEvent(`Failed to set mode: ${await modeRes.text()}`);
1313
+ return;
1314
+ }
1315
+ systemEvent(`Set ${targetName} to ${mode}.`);
1316
+ } catch {
1317
+ systemEvent("Failed to reach server.");
1318
+ }
1319
+ return;
1320
+ }
1321
+ // ── /share [--as <tier>] ──────────────────────────────────────
1322
+ case "share": {
1323
+ if (authority === "observer") {
1324
+ systemEvent("Observers cannot create share links.");
1325
+ return;
1326
+ }
1327
+ let targetAuthority;
1328
+ if (args2[0] === "--as" && args2[1]) {
1329
+ targetAuthority = args2[1];
1330
+ }
1331
+ try {
1332
+ const body = { token: sessionToken };
1333
+ if (targetAuthority) body.authority = targetAuthority;
1334
+ const res = await fetch(`${serverUrl}/share`, {
1335
+ method: "POST",
1336
+ headers: { "Content-Type": "application/json" },
1337
+ body: JSON.stringify(body)
1338
+ });
1339
+ if (!res.ok) {
1340
+ systemEvent(`Failed: ${await res.text()}`);
1341
+ return;
1342
+ }
1343
+ const data = await res.json();
1344
+ const lines = Object.entries(data.links).map(
1345
+ ([tier, url]) => ` ${tier}: stoops join ${url}`
1346
+ );
1347
+ systemEvent(`Share links:
1348
+ ${lines.join("\n")}`);
1349
+ } catch {
1350
+ systemEvent("Failed to reach server.");
1351
+ }
1352
+ return;
1353
+ }
1354
+ default:
1355
+ systemEvent(`Unknown command: /${cmd}`);
1356
+ }
1357
+ }
1358
+ if (options.shareUrl) {
1359
+ console.log();
1360
+ console.log(` Invite a friend: npx stoops join "${options.shareUrl}"`);
1361
+ console.log(` Connect Claude Code: npx stoops run claude \u2192 then tell agent to join: ${options.shareUrl}`);
1362
+ console.log();
1363
+ }
1364
+ const tui = startTUI({
1365
+ roomName,
1366
+ readOnly: isReadOnly,
1367
+ isAdmin: authority === "admin",
1368
+ onSend: isReadOnly ? void 0 : async (content) => {
1369
+ if (content.startsWith("/")) {
1370
+ await handleSlashCommand(content);
1371
+ return;
1372
+ }
1373
+ try {
1374
+ await fetch(`${serverUrl}/message`, {
1375
+ method: "POST",
1376
+ headers: { "Content-Type": "application/json" },
1377
+ body: JSON.stringify({ token: sessionToken, content })
1378
+ });
1379
+ } catch {
1380
+ }
1381
+ },
1382
+ onCtrlC: async () => {
1383
+ await disconnect();
1384
+ tui.stop();
1385
+ process.exit(0);
1386
+ }
1387
+ });
1388
+ const agentNames = participants.filter((p) => p.type === "agent").map((p) => p.name);
1389
+ if (agentNames.length > 0) {
1390
+ tui.setAgentNames(agentNames);
1391
+ }
1392
+ const participantNames = new Set(
1393
+ participants.filter((p) => p.id !== participantId).map((p) => p.name)
1394
+ );
1395
+ tui.setParticipants([...participantNames]);
1396
+ {
1397
+ const participantTypes = /* @__PURE__ */ new Map();
1398
+ for (const p of participants) {
1399
+ participantTypes.set(p.id, p.type);
1400
+ }
1401
+ const currentAgents = new Set(agentNames);
1402
+ let sseController = null;
1403
+ cleanupStream = () => {
1404
+ if (sseController) {
1405
+ sseController.abort();
1406
+ sseController = null;
1407
+ }
1408
+ };
1409
+ const connectSSE = async () => {
1410
+ sseController = new AbortController();
1411
+ try {
1412
+ const res = await fetch(`${serverUrl}/events`, {
1413
+ method: "POST",
1414
+ headers: {
1415
+ Accept: "text/event-stream",
1416
+ Authorization: `Bearer ${sessionToken}`
1417
+ },
1418
+ signal: sseController.signal
1419
+ });
1420
+ if (!res.ok || !res.body) {
1421
+ console.error("Failed to connect event stream");
1422
+ await disconnect();
1423
+ process.exit(1);
1424
+ }
1425
+ const reader = res.body.getReader();
1426
+ const decoder = new TextDecoder();
1427
+ let buffer = "";
1428
+ while (true) {
1429
+ const { done, value } = await reader.read();
1430
+ if (done) break;
1431
+ buffer += decoder.decode(value, { stream: true });
1432
+ const parts = buffer.split("\n\n");
1433
+ buffer = parts.pop();
1434
+ for (const part of parts) {
1435
+ const dataLine = part.split("\n").find((l) => l.startsWith("data: "));
1436
+ if (!dataLine) continue;
1437
+ try {
1438
+ const event = JSON.parse(dataLine.slice(6));
1439
+ if (event.type === "ParticipantJoined") {
1440
+ participantTypes.set(event.participant.id, event.participant.type);
1441
+ }
1442
+ const displayEvent = toDisplayEvent(event, participantId, participantTypes);
1443
+ if (displayEvent) {
1444
+ tui.push(displayEvent);
1445
+ }
1446
+ if (event.type === "ParticipantJoined") {
1447
+ if (event.participant.type === "agent") {
1448
+ currentAgents.add(event.participant.name);
1449
+ tui.setAgentNames([...currentAgents]);
1450
+ }
1451
+ if (event.participant.id !== participantId) {
1452
+ participantNames.add(event.participant.name);
1453
+ tui.setParticipants([...participantNames]);
1454
+ }
1455
+ }
1456
+ if (event.type === "ParticipantLeft") {
1457
+ if (event.participant.type === "agent") {
1458
+ currentAgents.delete(event.participant.name);
1459
+ tui.setAgentNames([...currentAgents]);
1460
+ }
1461
+ participantTypes.delete(event.participant.id);
1462
+ participantNames.delete(event.participant.name);
1463
+ tui.setParticipants([...participantNames]);
1464
+ }
1465
+ } catch {
1466
+ }
1467
+ }
1468
+ }
1469
+ if (!disconnected) {
1470
+ tui.stop();
1471
+ console.log("\nServer disconnected.");
1472
+ process.exit(0);
1473
+ }
1474
+ } catch {
1475
+ if (!disconnected) {
1476
+ tui.stop();
1477
+ console.log("\nServer disconnected.");
1478
+ process.exit(0);
1479
+ }
1480
+ }
1481
+ };
1482
+ connectSSE();
1483
+ }
1484
+ process.on("SIGINT", async () => {
1485
+ await disconnect();
1486
+ tui.stop();
1487
+ process.exit(0);
1488
+ });
1489
+ process.on("SIGTERM", async () => {
1490
+ await disconnect();
1491
+ tui.stop();
1492
+ process.exit(0);
1493
+ });
1494
+ }
1495
+ function toDisplayEvent(event, selfId, participantTypes) {
1496
+ const ts = formatTimestamp(new Date(event.timestamp));
1497
+ switch (event.type) {
1498
+ case "MessageSent": {
1499
+ const msg = event.message;
1500
+ const senderType = participantTypes.get(msg.sender_id) ?? "human";
1501
+ return {
1502
+ id: msg.id,
1503
+ ts,
1504
+ kind: "message",
1505
+ senderName: msg.sender_name,
1506
+ senderType,
1507
+ isSelf: msg.sender_id === selfId,
1508
+ content: msg.content,
1509
+ replyToName: event._replyToName ?? void 0
1510
+ };
1511
+ }
1512
+ case "ParticipantJoined":
1513
+ return {
1514
+ id: randomUUID2(),
1515
+ ts,
1516
+ kind: "join",
1517
+ name: event.participant.name,
1518
+ participantType: event.participant.type
1519
+ };
1520
+ case "ParticipantLeft":
1521
+ return {
1522
+ id: randomUUID2(),
1523
+ ts,
1524
+ kind: "leave",
1525
+ name: event.participant.name,
1526
+ participantType: event.participant.type
1527
+ };
1528
+ case "Activity":
1529
+ if (event.action === "mode_changed") {
1530
+ return {
1531
+ id: randomUUID2(),
1532
+ ts,
1533
+ kind: "mode",
1534
+ mode: String(event.detail?.mode ?? "")
1535
+ };
1536
+ }
1537
+ if (event.action === "authority_changed") {
1538
+ const newAuth = String(event.detail?.authority ?? "");
1539
+ return {
1540
+ id: randomUUID2(),
1541
+ ts,
1542
+ kind: "system",
1543
+ content: `authority \u2192 ${newAuth}`
1544
+ };
1545
+ }
1546
+ return null;
1547
+ default:
1548
+ return null;
1549
+ }
1550
+ }
1551
+
1552
+ // src/cli/claude/run.ts
1553
+ import { writeFileSync, mkdtempSync, rmSync, chmodSync } from "fs";
1554
+ import { join as join2 } from "path";
1555
+ import { tmpdir } from "os";
1556
+
1557
+ // src/cli/tmux.ts
1558
+ import { execFileSync as execFileSync2, spawn as spawn2 } from "child_process";
1559
+ function sanitizeSessionName(name) {
1560
+ return name.replace(/[.:$%]/g, "_");
1561
+ }
1562
+ function tmuxAvailable() {
1563
+ try {
1564
+ execFileSync2("tmux", ["-V"], { stdio: "ignore" });
1565
+ return true;
1566
+ } catch {
1567
+ return false;
1568
+ }
1569
+ }
1570
+ function tmuxSessionExists(session) {
1571
+ try {
1572
+ const name = sanitizeSessionName(session);
1573
+ execFileSync2("tmux", ["has-session", "-t", name], { stdio: "ignore" });
1574
+ return true;
1575
+ } catch {
1576
+ return false;
1577
+ }
1578
+ }
1579
+ function tmuxCreateSession(session) {
1580
+ const name = sanitizeSessionName(session);
1581
+ execFileSync2("tmux", ["new-session", "-d", "-s", name]);
1582
+ execFileSync2("tmux", ["set", "-t", name, "status", "off"]);
1583
+ }
1584
+ function tmuxSendCommand(session, command) {
1585
+ const name = sanitizeSessionName(session);
1586
+ execFileSync2("tmux", ["send-keys", "-t", name, "-l", command]);
1587
+ execFileSync2("tmux", ["send-keys", "-t", name, "Enter"]);
1588
+ }
1589
+ function tmuxInjectText(session, text) {
1590
+ const name = sanitizeSessionName(session);
1591
+ execFileSync2("tmux", ["send-keys", "-t", name, "-l", text]);
1592
+ }
1593
+ function tmuxSendEnter(session) {
1594
+ const name = sanitizeSessionName(session);
1595
+ execFileSync2("tmux", ["send-keys", "-t", name, "Enter"]);
1596
+ }
1597
+ function tmuxAttach(session) {
1598
+ const name = sanitizeSessionName(session);
1599
+ if (process.env.TMUX) {
1600
+ try {
1601
+ execFileSync2("tmux", ["switch-client", "-t", name], { stdio: "ignore" });
1602
+ } catch {
1603
+ }
1604
+ return new Promise((resolve) => {
1605
+ const poll = setInterval(() => {
1606
+ try {
1607
+ execFileSync2("tmux", ["has-session", "-t", name], { stdio: "ignore" });
1608
+ } catch {
1609
+ clearInterval(poll);
1610
+ resolve();
1611
+ }
1612
+ }, 500);
1613
+ });
1614
+ }
1615
+ return new Promise((resolve) => {
1616
+ const child = spawn2("tmux", ["attach", "-t", name], { stdio: "inherit" });
1617
+ child.on("exit", () => resolve());
1618
+ child.on("error", () => resolve());
1619
+ });
1620
+ }
1621
+ function tmuxCapturePane(session) {
1622
+ try {
1623
+ const name = sanitizeSessionName(session);
1624
+ const output = execFileSync2("tmux", ["capture-pane", "-t", name, "-p"], {
1625
+ encoding: "utf-8"
1626
+ });
1627
+ return output.split("\n");
1628
+ } catch {
1629
+ return [];
1630
+ }
1631
+ }
1632
+ function tmuxSendKey(session, key) {
1633
+ const name = sanitizeSessionName(session);
1634
+ execFileSync2("tmux", ["send-keys", "-t", name, key]);
1635
+ }
1636
+ function tmuxKillSession(session) {
1637
+ try {
1638
+ const name = sanitizeSessionName(session);
1639
+ execFileSync2("tmux", ["kill-session", "-t", name], { stdio: "ignore" });
1640
+ } catch {
1641
+ }
1642
+ }
1643
+
1644
+ // src/cli/claude/tmux-bridge.ts
1645
+ var SPINNER_CHARS = "\u280B\u2819\u2839\u2838\u283C\u2834\u2826\u2827\u2807\u280F";
1646
+ var DIALOG_PATTERNS = [
1647
+ "Enter to select",
1648
+ "to navigate",
1649
+ "Esc to cancel",
1650
+ "Ready to code?",
1651
+ "Review your answers",
1652
+ "ctrl+g to edit in"
1653
+ ];
1654
+ var PERMISSION_PATTERNS = [
1655
+ "(Y)",
1656
+ "Allow ",
1657
+ "Deny ",
1658
+ "approve",
1659
+ "Yes / No"
1660
+ ];
1661
+ var TmuxBridge = class {
1662
+ session;
1663
+ queue = [];
1664
+ pollTimer = null;
1665
+ pollIntervalMs;
1666
+ keystrokeDelayMs;
1667
+ stopped = false;
1668
+ constructor(session, opts) {
1669
+ this.session = session;
1670
+ this.pollIntervalMs = opts?.pollIntervalMs ?? 200;
1671
+ this.keystrokeDelayMs = opts?.keystrokeDelayMs ?? 50;
1672
+ }
1673
+ /**
1674
+ * Delivery callback — drop-in replacement for the raw tmuxDeliver lambda.
1675
+ * Pass `bridge.deliver.bind(bridge)` to EventProcessor.run().
1676
+ */
1677
+ async deliver(parts) {
1678
+ const text = contentPartsToString(parts);
1679
+ if (!text.trim()) return;
1680
+ this.inject(text);
1681
+ }
1682
+ /**
1683
+ * Detect the current TUI state by reading the screen.
1684
+ * Exported for testing — the heuristic logic is in detectStateFromLines().
1685
+ */
1686
+ detectState() {
1687
+ const lines = this.captureScreen();
1688
+ return detectStateFromLines(lines);
1689
+ }
1690
+ /**
1691
+ * Try to inject text, choosing strategy based on TUI state.
1692
+ * If the state is unsafe, queues the text and starts polling.
1693
+ *
1694
+ * Text is flattened to a single line before injection to avoid triggering
1695
+ * Claude Code's paste detection. When multi-line text arrives via
1696
+ * `send-keys -l`, Claude Code detects it as a paste and collapses it into
1697
+ * "[Pasted text #1 +N lines]" which may not reliably submit with Enter.
1698
+ */
1699
+ inject(text) {
1700
+ const flat = text.replace(/\n/g, " ");
1701
+ const state = this.detectState();
1702
+ switch (state) {
1703
+ case "idle":
1704
+ this.injectIdle(flat);
1705
+ break;
1706
+ case "typing":
1707
+ this.injectWhileTyping(flat);
1708
+ break;
1709
+ default:
1710
+ this.enqueue(flat);
1711
+ break;
1712
+ }
1713
+ }
1714
+ /** Capture the screen via tmux capture-pane. */
1715
+ captureScreen() {
1716
+ return tmuxCapturePane(this.session);
1717
+ }
1718
+ /**
1719
+ * Inject into an idle prompt: type text + Enter.
1720
+ * Sends a second Enter after a short delay as a safety net — if Claude Code's
1721
+ * paste detection swallowed the first Enter, the second one submits. If the
1722
+ * first Enter worked, Claude is streaming and the second Enter is a no-op.
1723
+ */
1724
+ injectIdle(text) {
1725
+ tmuxInjectText(this.session, text);
1726
+ tmuxSendEnter(this.session);
1727
+ this.sleep(80);
1728
+ tmuxSendEnter(this.session);
1729
+ }
1730
+ /**
1731
+ * Inject while the user is typing:
1732
+ * 1. Ctrl+U — cut line to kill ring
1733
+ * 2. Inject our text + Enter
1734
+ * 3. Ctrl+Y — paste the user's text back
1735
+ */
1736
+ injectWhileTyping(text) {
1737
+ tmuxSendKey(this.session, "C-u");
1738
+ this.sleep(this.keystrokeDelayMs);
1739
+ tmuxInjectText(this.session, text);
1740
+ tmuxSendEnter(this.session);
1741
+ this.sleep(80);
1742
+ tmuxSendEnter(this.session);
1743
+ this.sleep(this.keystrokeDelayMs);
1744
+ tmuxSendKey(this.session, "C-y");
1745
+ }
1746
+ /** Add to queue and start polling if not already. */
1747
+ enqueue(text) {
1748
+ this.queue.push(text);
1749
+ this.startPolling();
1750
+ }
1751
+ /** Start the polling timer to drain queued events. */
1752
+ startPolling() {
1753
+ if (this.pollTimer || this.stopped) return;
1754
+ this.pollTimer = setInterval(() => this.drainQueue(), this.pollIntervalMs);
1755
+ }
1756
+ /** Stop the polling timer. */
1757
+ stopPolling() {
1758
+ if (this.pollTimer) {
1759
+ clearInterval(this.pollTimer);
1760
+ this.pollTimer = null;
1761
+ }
1762
+ }
1763
+ /**
1764
+ * Try to drain one queued event if the state is safe.
1765
+ *
1766
+ * Drains one event at a time rather than batching all into one multi-line
1767
+ * string — multi-line text triggers Claude Code's paste detection which
1768
+ * collapses it into "[Pasted text #1 +N lines]".
1769
+ *
1770
+ * After injecting one event, the poll continues. The next cycle re-checks
1771
+ * state: if Claude is busy (streaming), remaining events wait. If idle,
1772
+ * the next event is injected. Events are already flattened in inject().
1773
+ */
1774
+ drainQueue() {
1775
+ if (this.queue.length === 0) {
1776
+ this.stopPolling();
1777
+ return;
1778
+ }
1779
+ const state = this.detectState();
1780
+ if (state === "idle" || state === "typing") {
1781
+ const text = this.queue.shift();
1782
+ if (state === "idle") {
1783
+ this.injectIdle(text);
1784
+ } else {
1785
+ this.injectWhileTyping(text);
1786
+ }
1787
+ if (this.queue.length === 0) {
1788
+ this.stopPolling();
1789
+ }
1790
+ }
1791
+ }
1792
+ /** Cleanup. */
1793
+ stop() {
1794
+ this.stopped = true;
1795
+ this.stopPolling();
1796
+ this.queue.length = 0;
1797
+ }
1798
+ /** Synchronous sleep — only used for tiny keystroke delays. */
1799
+ sleep(ms) {
1800
+ if (ms <= 0) return;
1801
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1802
+ }
1803
+ };
1804
+ function detectStateFromLines(lines) {
1805
+ if (lines.length === 0) return "unknown";
1806
+ const tail = lines.slice(-15);
1807
+ const tailText = tail.join("\n");
1808
+ for (const pattern of DIALOG_PATTERNS) {
1809
+ if (tailText.includes(pattern)) return "dialog";
1810
+ }
1811
+ for (const pattern of PERMISSION_PATTERNS) {
1812
+ if (tailText.includes(pattern)) return "permission";
1813
+ }
1814
+ const lastFew = tail.slice(-5).join("");
1815
+ for (const ch of SPINNER_CHARS) {
1816
+ if (lastFew.includes(ch)) return "streaming";
1817
+ }
1818
+ const promptChar = /^[❯›](\s|$)/;
1819
+ const footerChar = /^[❯›]{2}\s/;
1820
+ const separatorLine = /^[─━─\-]{10,}/;
1821
+ for (let i = tail.length - 1; i >= 0; i--) {
1822
+ const line = tail[i].trimStart();
1823
+ if (footerChar.test(line)) {
1824
+ for (let j = i - 1; j >= 0; j--) {
1825
+ const above = tail[j].trimStart();
1826
+ if (promptChar.test(above)) {
1827
+ const content = above.replace(/^[❯›]\s*/, "").trim();
1828
+ return content.length === 0 ? "idle" : "typing";
1829
+ }
1830
+ }
1831
+ break;
1832
+ }
1833
+ }
1834
+ for (let i = tail.length - 1; i >= 0; i--) {
1835
+ const line = tail[i].trimStart();
1836
+ if (promptChar.test(line)) {
1837
+ const above = i > 0 ? tail[i - 1].trimStart() : "";
1838
+ const below = i < tail.length - 1 ? tail[i + 1].trimStart() : "";
1839
+ if (separatorLine.test(above) || separatorLine.test(below)) {
1840
+ const content = line.replace(/^[❯›]\s*/, "").trim();
1841
+ return content.length === 0 ? "idle" : "typing";
1842
+ }
1843
+ }
1844
+ }
1845
+ return "unknown";
1846
+ }
1847
+
1848
+ // src/cli/runtime-setup.ts
1849
+ async function setupAgentRuntime(options) {
1850
+ const agentName = options.name ?? randomName();
1851
+ const pendingUrls = options.joinUrls ?? [];
1852
+ const joinResults = [];
1853
+ const sseMux = new SseMultiplexer();
1854
+ const processor = new EventProcessor("", agentName, {
1855
+ defaultMode: "everyone"
1856
+ });
1857
+ const mcpServer = await createRuntimeMcpServer({
1858
+ resolver: processor,
1859
+ toolOptions: {
1860
+ isEventSeen: (id) => processor.isEventSeen(id),
1861
+ markEventsSeen: (ids) => processor.markEventsSeen(ids),
1862
+ assignRef: (id) => processor.assignRef(id),
1863
+ resolveRef: (ref) => processor.resolveRef(ref)
1864
+ },
1865
+ admin: options.admin,
1866
+ onSetMode: async (room, mode) => {
1867
+ const conn = processor.resolve(room);
1868
+ if (!conn) return { success: false, error: `Unknown room "${room}".` };
1869
+ processor.setModeForRoom(conn.dataSource.roomId, mode, false);
1870
+ try {
1871
+ const ds = conn.dataSource;
1872
+ const res = await fetch(`${ds.serverUrl}/set-mode`, {
1873
+ method: "POST",
1874
+ headers: { "Content-Type": "application/json" },
1875
+ body: JSON.stringify({ token: ds.sessionToken, mode })
1876
+ });
1877
+ if (!res.ok) return { success: false, error: `Server rejected: ${await res.text()}` };
1878
+ } catch {
1879
+ }
1880
+ return { success: true };
1881
+ },
1882
+ onJoinRoom: async (url, alias) => {
1883
+ const token = extractToken(url);
1884
+ let serverUrl;
1885
+ try {
1886
+ const parsed = new URL(url);
1887
+ parsed.search = "";
1888
+ serverUrl = parsed.toString().replace(/\/$/, "");
1889
+ } catch {
1890
+ serverUrl = url.replace(/\/$/, "");
1891
+ }
1892
+ try {
1893
+ const joinBody = { type: "agent", name: agentName };
1894
+ if (token) joinBody.token = token;
1895
+ const res = await fetch(`${serverUrl}/join`, {
1896
+ method: "POST",
1897
+ headers: { "Content-Type": "application/json" },
1898
+ body: JSON.stringify(joinBody),
1899
+ signal: AbortSignal.timeout(15e3)
1900
+ });
1901
+ if (!res.ok) return { success: false, error: `Failed to join: ${await res.text()}` };
1902
+ const data = await res.json();
1903
+ const sessionToken = String(data.sessionToken ?? "");
1904
+ const roomName = alias ?? String(data.roomName ?? "");
1905
+ const roomId = String(data.roomId ?? "");
1906
+ const authority = String(data.authority ?? "participant");
1907
+ const participants = data.participants ?? [];
1908
+ const newParticipantId = String(data.participantId ?? "");
1909
+ const dataSource = new RemoteRoomDataSource(serverUrl, sessionToken, roomId);
1910
+ dataSource.setParticipants(participants);
1911
+ dataSource.setSelf(newParticipantId, agentName);
1912
+ if (joinResults.length === 0) {
1913
+ processor.participantId = newParticipantId;
1914
+ }
1915
+ processor.setRoomParticipantId(roomId, newParticipantId);
1916
+ const mode = processor.getModeForRoom(roomId) ?? "everyone";
1917
+ processor.connectRemoteRoom(dataSource, roomName);
1918
+ sseMux.addConnection(serverUrl, sessionToken, roomName, roomId);
1919
+ const jr = {
1920
+ serverUrl,
1921
+ sessionToken,
1922
+ participantId: newParticipantId,
1923
+ roomName,
1924
+ roomId,
1925
+ authority,
1926
+ participants,
1927
+ dataSource
1928
+ };
1929
+ joinResults.push(jr);
1930
+ const conn = processor.resolve(roomName);
1931
+ let recentLines = [];
1932
+ if (conn) {
1933
+ recentLines = await buildCatchUpLines(conn, {
1934
+ isEventSeen: (id) => processor.isEventSeen(id),
1935
+ markEventsSeen: (ids) => processor.markEventsSeen(ids),
1936
+ assignRef: (id) => processor.assignRef(id)
1937
+ });
1938
+ }
1939
+ await options.onRoomJoined?.();
1940
+ return {
1941
+ success: true,
1942
+ roomName,
1943
+ agentName,
1944
+ authority,
1945
+ mode,
1946
+ participants: participants.filter((p) => p.id !== newParticipantId).map((p) => ({ name: p.name, authority: p.authority ?? "participant" })),
1947
+ recentLines
1948
+ };
1949
+ } catch (err) {
1950
+ const msg = err instanceof Error ? err.message : String(err);
1951
+ return { success: false, error: `Unable to connect. Is the server running? (${serverUrl}) \u2014 ${msg}` };
1952
+ }
1953
+ },
1954
+ onLeaveRoom: async (room) => {
1955
+ const conn = processor.resolve(room);
1956
+ if (!conn) return { success: false, error: `Unknown room "${room}".` };
1957
+ const roomId = conn.dataSource.roomId;
1958
+ const idx = joinResults.findIndex((jr) => jr.roomId === roomId);
1959
+ if (idx >= 0) {
1960
+ const jr = joinResults[idx];
1961
+ sseMux.removeConnection(roomId);
1962
+ processor.disconnectRemoteRoom(roomId);
1963
+ try {
1964
+ await fetch(`${jr.serverUrl}/disconnect`, {
1965
+ method: "POST",
1966
+ headers: { "Content-Type": "application/json" },
1967
+ body: JSON.stringify({ token: jr.sessionToken })
1968
+ });
1969
+ } catch {
1970
+ }
1971
+ joinResults.splice(idx, 1);
1972
+ }
1973
+ return { success: true };
1974
+ },
1975
+ onAdminSetModeFor: options.admin ? async (room, participant, mode) => {
1976
+ const conn = processor.resolve(room);
1977
+ if (!conn) return { success: false, error: `Unknown room "${room}".` };
1978
+ const ds = conn.dataSource;
1979
+ const p = conn.dataSource.listParticipants().find((pp) => pp.name === participant);
1980
+ if (!p) return { success: false, error: `Unknown participant "${participant}".` };
1981
+ try {
1982
+ const res = await fetch(`${ds.serverUrl}/set-mode`, {
1983
+ method: "POST",
1984
+ headers: { "Content-Type": "application/json" },
1985
+ body: JSON.stringify({ token: ds.sessionToken, participantId: p.id, mode })
1986
+ });
1987
+ if (!res.ok) return { success: false, error: await res.text() };
1988
+ return { success: true };
1989
+ } catch {
1990
+ return { success: false, error: "Server unreachable." };
1991
+ }
1992
+ } : void 0,
1993
+ onAdminMute: options.admin ? async (room, participant) => {
1994
+ const conn = processor.resolve(room);
1995
+ if (!conn) return { success: false, error: `Unknown room "${room}".` };
1996
+ const ds = conn.dataSource;
1997
+ const p = conn.dataSource.listParticipants().find((pp) => pp.name === participant);
1998
+ if (!p) return { success: false, error: `Unknown participant "${participant}".` };
1999
+ try {
2000
+ const res = await fetch(`${ds.serverUrl}/set-authority`, {
2001
+ method: "POST",
2002
+ headers: { "Content-Type": "application/json" },
2003
+ body: JSON.stringify({ token: ds.sessionToken, participantId: p.id, authority: "observer" })
2004
+ });
2005
+ if (!res.ok) return { success: false, error: await res.text() };
2006
+ return { success: true };
2007
+ } catch {
2008
+ return { success: false, error: "Server unreachable." };
2009
+ }
2010
+ } : void 0,
2011
+ onAdminUnmute: options.admin ? async (room, participant) => {
2012
+ const conn = processor.resolve(room);
2013
+ if (!conn) return { success: false, error: `Unknown room "${room}".` };
2014
+ const ds = conn.dataSource;
2015
+ const p = conn.dataSource.listParticipants().find((pp) => pp.name === participant);
2016
+ if (!p) return { success: false, error: `Unknown participant "${participant}".` };
2017
+ try {
2018
+ const res = await fetch(`${ds.serverUrl}/set-authority`, {
2019
+ method: "POST",
2020
+ headers: { "Content-Type": "application/json" },
2021
+ body: JSON.stringify({ token: ds.sessionToken, participantId: p.id, authority: "participant" })
2022
+ });
2023
+ if (!res.ok) return { success: false, error: await res.text() };
2024
+ return { success: true };
2025
+ } catch {
2026
+ return { success: false, error: "Server unreachable." };
2027
+ }
2028
+ } : void 0,
2029
+ onAdminKick: options.admin ? async (room, participant) => {
2030
+ const conn = processor.resolve(room);
2031
+ if (!conn) return { success: false, error: `Unknown room "${room}".` };
2032
+ const ds = conn.dataSource;
2033
+ const p = conn.dataSource.listParticipants().find((pp) => pp.name === participant);
2034
+ if (!p) return { success: false, error: `Unknown participant "${participant}".` };
2035
+ try {
2036
+ const res = await fetch(`${ds.serverUrl}/kick`, {
2037
+ method: "POST",
2038
+ headers: { "Content-Type": "application/json" },
2039
+ body: JSON.stringify({ token: ds.sessionToken, participantId: p.id })
2040
+ });
2041
+ if (!res.ok) return { success: false, error: await res.text() };
2042
+ return { success: true };
2043
+ } catch {
2044
+ return { success: false, error: "Server unreachable." };
2045
+ }
2046
+ } : void 0
2047
+ });
2048
+ const wrappedSource = {
2049
+ [Symbol.asyncIterator]() {
2050
+ const inner = sseMux[Symbol.asyncIterator]();
2051
+ return {
2052
+ async next() {
2053
+ const result = await inner.next();
2054
+ if (!result.done) {
2055
+ const { roomId, event } = result.value;
2056
+ const jr = joinResults.find((j) => j.roomId === roomId);
2057
+ if (jr) {
2058
+ if (event.type === "ParticipantJoined") {
2059
+ jr.dataSource.addParticipant(event.participant);
2060
+ } else if (event.type === "ParticipantLeft") {
2061
+ jr.dataSource.removeParticipant(event.participant_id);
2062
+ }
2063
+ }
2064
+ }
2065
+ return result;
2066
+ }
2067
+ };
2068
+ }
2069
+ };
2070
+ async function cleanup() {
2071
+ await processor.stop();
2072
+ sseMux.close();
2073
+ await mcpServer.stop();
2074
+ for (const jr of joinResults) {
2075
+ try {
2076
+ await fetch(`${jr.serverUrl}/disconnect`, {
2077
+ method: "POST",
2078
+ headers: { "Content-Type": "application/json" },
2079
+ body: JSON.stringify({ token: jr.sessionToken })
2080
+ });
2081
+ } catch {
2082
+ }
2083
+ }
2084
+ }
2085
+ let initialParts;
2086
+ if (pendingUrls.length > 0) {
2087
+ if (pendingUrls.length === 1) {
2088
+ initialParts = [{ type: "text", text: `Use join_room("${pendingUrls[0]}") to connect.` }];
2089
+ } else {
2090
+ const lines = pendingUrls.map((u) => ` join_room("${u}")`);
2091
+ initialParts = [{ type: "text", text: `Rooms to join:
2092
+ ${lines.join("\n")}` }];
2093
+ }
2094
+ }
2095
+ return {
2096
+ agentName,
2097
+ joinResults,
2098
+ initialParts,
2099
+ processor,
2100
+ sseMux,
2101
+ mcpServer,
2102
+ wrappedSource,
2103
+ cleanup
2104
+ };
2105
+ }
2106
+
2107
+ // src/cli/claude/run.ts
2108
+ var MCP_STDIO_BRIDGE = [
2109
+ "#!/usr/bin/env node",
2110
+ '"use strict";',
2111
+ 'const { createInterface } = require("readline");',
2112
+ "const url = `http://127.0.0.1:${process.argv[2]}/mcp`;",
2113
+ "const rl = createInterface({ input: process.stdin });",
2114
+ "(async () => {",
2115
+ " for await (const line of rl) {",
2116
+ " if (!line.trim()) continue;",
2117
+ " try {",
2118
+ " const res = await fetch(url, {",
2119
+ ' method: "POST",',
2120
+ ' headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" },',
2121
+ " body: line,",
2122
+ " });",
2123
+ " if (res.status === 202) continue;",
2124
+ " const body = await res.text();",
2125
+ ' for (const bl of body.split("\\n")) {',
2126
+ " const m = bl.match(/^data: (.+)/);",
2127
+ ' if (m) process.stdout.write(m[1] + "\\n");',
2128
+ " }",
2129
+ " } catch {",
2130
+ " process.exit(1);",
2131
+ " }",
2132
+ " }",
2133
+ "})();"
2134
+ ].join("\n");
2135
+ async function runClaude(options) {
2136
+ if (options.headless) {
2137
+ const setup2 = await setupAgentRuntime(options);
2138
+ const deliver = async (parts) => {
2139
+ const text = contentPartsToString(parts);
2140
+ if (text.trim()) process.stdout.write(text + "\n");
2141
+ };
2142
+ const eventLoopPromise2 = setup2.processor.run(deliver, setup2.wrappedSource, setup2.initialParts).catch(() => {
2143
+ });
2144
+ process.stderr.write(`MCP server: ${setup2.mcpServer.url}
2145
+ `);
2146
+ await new Promise((resolve) => {
2147
+ process.on("SIGINT", resolve);
2148
+ process.on("SIGTERM", resolve);
2149
+ });
2150
+ await setup2.cleanup();
2151
+ await eventLoopPromise2;
2152
+ return;
2153
+ }
2154
+ if (!tmuxAvailable()) {
2155
+ console.error("Error: tmux is required but not found. Install it with: brew install tmux");
2156
+ process.exit(1);
2157
+ }
2158
+ const setup = await setupAgentRuntime({ ...options, joinUrls: void 0 });
2159
+ const tmpDir = mkdtempSync(join2(tmpdir(), "stoops_agent_"));
2160
+ const bridgePath = join2(tmpDir, "mcp-bridge.cjs");
2161
+ writeFileSync(bridgePath, MCP_STDIO_BRIDGE);
2162
+ chmodSync(bridgePath, 493);
2163
+ const mcpPort = new URL(setup.mcpServer.url).port;
2164
+ const mcpConfigPath = join2(tmpDir, "mcp.json");
2165
+ const mcpConfig = {
2166
+ mcpServers: {
2167
+ stoops: {
2168
+ type: "stdio",
2169
+ command: process.execPath,
2170
+ args: [bridgePath, mcpPort]
2171
+ }
2172
+ }
2173
+ };
2174
+ writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2));
2175
+ const tmuxSession = `stoops_${setup.agentName}`;
2176
+ if (tmuxSessionExists(tmuxSession)) {
2177
+ tmuxKillSession(tmuxSession);
2178
+ }
2179
+ console.log("Launching Claude Code...");
2180
+ tmuxCreateSession(tmuxSession);
2181
+ const extraArgs = options.extraArgs ?? [];
2182
+ const claudeCmd = [`claude --mcp-config ${mcpConfigPath}`, ...extraArgs].join(" ");
2183
+ tmuxSendCommand(tmuxSession, claudeCmd);
2184
+ const bridge = new TmuxBridge(tmuxSession);
2185
+ const eventLoopPromise = setup.processor.run(bridge.deliver.bind(bridge), setup.wrappedSource).catch(() => {
2186
+ });
2187
+ for (let i = 0; i < 10; i++) {
2188
+ await new Promise((r) => setTimeout(r, 500));
2189
+ if (!tmuxSessionExists(tmuxSession)) {
2190
+ console.error("Error: Claude Code exited during startup. Try running again.");
2191
+ bridge.stop();
2192
+ await setup.cleanup();
2193
+ try {
2194
+ rmSync(tmpDir, { recursive: true });
2195
+ } catch {
2196
+ }
2197
+ return;
2198
+ }
2199
+ }
2200
+ console.log("Attaching to Claude Code session...\n");
2201
+ try {
2202
+ await tmuxAttach(tmuxSession);
2203
+ } catch {
2204
+ }
2205
+ bridge.stop();
2206
+ await setup.cleanup();
2207
+ tmuxKillSession(tmuxSession);
2208
+ try {
2209
+ rmSync(tmpDir, { recursive: true });
2210
+ } catch {
2211
+ }
2212
+ console.log("Disconnected.");
2213
+ }
2214
+
2215
+ // src/cli/opencode/run.ts
2216
+ import { spawn as spawn3 } from "child_process";
2217
+ async function runOpencode(options) {
2218
+ const opencodePort = 14096 + Math.floor(Math.random() * 1e3);
2219
+ const opencodeUrl = `http://127.0.0.1:${opencodePort}`;
2220
+ const roomSessions = /* @__PURE__ */ new Map();
2221
+ async function findStoopsSession() {
2222
+ try {
2223
+ const res = await fetch(`${opencodeUrl}/session`);
2224
+ if (!res.ok) return null;
2225
+ const sessions = await res.json();
2226
+ for (const sess of sessions.slice(0, 3)) {
2227
+ const msgRes = await fetch(`${opencodeUrl}/session/${sess.id}/message`);
2228
+ if (!msgRes.ok) continue;
2229
+ const messages = await msgRes.json();
2230
+ for (const msg of messages) {
2231
+ for (const part of msg.parts ?? []) {
2232
+ if (part.type === "tool" && part.tool?.includes("stoops__")) {
2233
+ return sess.id;
2234
+ }
2235
+ }
2236
+ }
2237
+ }
2238
+ return sessions.length > 0 ? sessions[0].id : null;
2239
+ } catch {
2240
+ return null;
2241
+ }
2242
+ }
2243
+ const setup = await setupAgentRuntime({
2244
+ ...options,
2245
+ joinUrls: void 0
2246
+ });
2247
+ const opencodeConfig = {
2248
+ mcp: {
2249
+ stoops: {
2250
+ type: "remote",
2251
+ url: setup.mcpServer.url,
2252
+ oauth: false
2253
+ }
2254
+ }
2255
+ };
2256
+ const extraArgs = options.extraArgs ?? [];
2257
+ const opencodeArgs = ["serve", "--port", String(opencodePort), ...extraArgs];
2258
+ console.log("Launching OpenCode...");
2259
+ let child;
2260
+ try {
2261
+ child = spawn3("opencode", opencodeArgs, {
2262
+ env: {
2263
+ ...process.env,
2264
+ OPENCODE_CONFIG_CONTENT: JSON.stringify(opencodeConfig)
2265
+ },
2266
+ stdio: ["ignore", "pipe", "pipe"]
2267
+ });
2268
+ } catch {
2269
+ console.error("Error: opencode is required but not found. Install it from https://opencode.ai");
2270
+ await setup.cleanup();
2271
+ process.exit(1);
2272
+ }
2273
+ child.stderr?.on("data", (chunk) => {
2274
+ process.stderr.write(chunk);
2275
+ });
2276
+ let childExited = false;
2277
+ child.on("exit", () => {
2278
+ childExited = true;
2279
+ });
2280
+ child.on("error", async () => {
2281
+ console.error("Error: failed to start opencode. Is it installed?");
2282
+ await setup.cleanup();
2283
+ process.exit(1);
2284
+ });
2285
+ const ready = await pollForReady(opencodeUrl, 3e4);
2286
+ if (!ready) {
2287
+ console.error("OpenCode did not become ready within 30 seconds.");
2288
+ child.kill();
2289
+ await setup.cleanup();
2290
+ process.exit(1);
2291
+ }
2292
+ console.log(` OpenCode running on ${opencodeUrl}`);
2293
+ async function deliver(parts) {
2294
+ const roomId = setup.processor.currentContextRoomId;
2295
+ if (!roomId) return;
2296
+ if (!roomSessions.has(roomId)) {
2297
+ const sid = await findStoopsSession();
2298
+ if (!sid) return;
2299
+ roomSessions.set(roomId, sid);
2300
+ console.log(` Linked room ${roomId} \u2192 session ${sid}`);
2301
+ }
2302
+ const targetSession = roomSessions.get(roomId);
2303
+ const text = contentPartsToString(parts);
2304
+ if (!text.trim()) return;
2305
+ try {
2306
+ const res = await fetch(`${opencodeUrl}/session/${targetSession}/message`, {
2307
+ method: "POST",
2308
+ headers: { "Content-Type": "application/json" },
2309
+ body: JSON.stringify({
2310
+ parts: [{ type: "text", text }]
2311
+ })
2312
+ });
2313
+ await res.text();
2314
+ } catch {
2315
+ }
2316
+ }
2317
+ const eventLoopPromise = setup.processor.run(deliver, setup.wrappedSource);
2318
+ console.log(`
2319
+ OpenCode agent running.`);
2320
+ console.log(` To watch: opencode attach ${opencodeUrl}
2321
+ `);
2322
+ const exitPromise = new Promise((resolve) => {
2323
+ child.on("exit", resolve);
2324
+ });
2325
+ const signalPromise = new Promise((resolve) => {
2326
+ const handler = () => {
2327
+ resolve();
2328
+ };
2329
+ process.on("SIGINT", handler);
2330
+ process.on("SIGTERM", handler);
2331
+ });
2332
+ await Promise.race([exitPromise, signalPromise]);
2333
+ if (!childExited) {
2334
+ child.kill();
2335
+ }
2336
+ await setup.cleanup();
2337
+ console.log("Disconnected.");
2338
+ }
2339
+ async function pollForReady(url, timeoutMs) {
2340
+ const start = Date.now();
2341
+ while (Date.now() - start < timeoutMs) {
2342
+ try {
2343
+ const res = await fetch(`${url}/session/status`);
2344
+ if (res.ok) return true;
2345
+ } catch {
2346
+ }
2347
+ await new Promise((r) => setTimeout(r, 500));
2348
+ }
2349
+ return false;
2350
+ }
2351
+
2352
+ // src/cli/index.ts
2353
+ var args = process.argv.slice(2);
2354
+ function getFlag(name, arr = args) {
2355
+ const idx = arr.indexOf(`--${name}`);
2356
+ if (idx === -1) return void 0;
2357
+ const value = arr[idx + 1];
2358
+ if (value === void 0 || value.startsWith("--")) return void 0;
2359
+ return value;
2360
+ }
2361
+ function getAllFlags(name, arr = args) {
2362
+ const results = [];
2363
+ const flag = `--${name}`;
2364
+ for (let i = 0; i < arr.length; i++) {
2365
+ if (arr[i] === flag && arr[i + 1] && !arr[i + 1].startsWith("--")) {
2366
+ results.push(arr[i + 1]);
2367
+ }
2368
+ }
2369
+ return results;
2370
+ }
2371
+ function printUsage(stream = console.log) {
2372
+ stream("Usage:");
2373
+ stream(" stoops [--room <name>] [--port <port>] [--share] Host + join");
2374
+ stream(" stoops serve [--room <name>] [--port <port>] [--share] Headless server");
2375
+ stream(" stoops join <url> [--name <name>] [--guest] Join a room");
2376
+ stream(" stoops run claude [--name <name>] [--admin] [-- <args>] Connect Claude Code");
2377
+ stream(" stoops run opencode [--name <name>] [--admin] [-- <args>] Connect OpenCode");
2378
+ }
2379
+ async function main() {
2380
+ if (args.includes("--help") || args.includes("-h")) {
2381
+ printUsage();
2382
+ return;
2383
+ }
2384
+ if (args[0] === "run" && (args[1] === "claude" || args[1] === "opencode")) {
2385
+ const runtime = args[1];
2386
+ const restArgs = args.slice(2);
2387
+ const ddIndex = restArgs.indexOf("--");
2388
+ const stoopsArgs = ddIndex >= 0 ? restArgs.slice(0, ddIndex) : restArgs;
2389
+ const extraArgs = ddIndex >= 0 ? restArgs.slice(ddIndex + 1) : [];
2390
+ const joinUrls = getAllFlags("join", stoopsArgs);
2391
+ const runtimeOptions = {
2392
+ joinUrls: joinUrls.length > 0 ? joinUrls : void 0,
2393
+ name: getFlag("name", stoopsArgs),
2394
+ admin: stoopsArgs.includes("--admin"),
2395
+ headless: stoopsArgs.includes("--headless"),
2396
+ extraArgs
2397
+ };
2398
+ if (runtime === "claude") {
2399
+ await runClaude(runtimeOptions);
2400
+ } else {
2401
+ await runOpencode(runtimeOptions);
2402
+ }
2403
+ return;
2404
+ }
2405
+ if (args[0] === "join") {
2406
+ const server = args[1];
2407
+ if (!server || server.startsWith("--")) {
2408
+ console.error("Usage: stoops join <url> [--name <name>] [--guest] [--headless]");
2409
+ process.exit(1);
2410
+ }
2411
+ await join({
2412
+ server,
2413
+ name: getFlag("name"),
2414
+ guest: args.includes("--guest"),
2415
+ headless: args.includes("--headless")
2416
+ });
2417
+ return;
2418
+ }
2419
+ if (args[0] === "serve") {
2420
+ const portStr = getFlag("port");
2421
+ const port = portStr ? parseInt(portStr, 10) : void 0;
2422
+ if (port !== void 0 && (isNaN(port) || port < 0 || port > 65535)) {
2423
+ console.error(`Invalid port: ${portStr}`);
2424
+ process.exit(1);
2425
+ }
2426
+ await serve({
2427
+ room: getFlag("room"),
2428
+ port,
2429
+ share: args.includes("--share"),
2430
+ headless: args.includes("--headless")
2431
+ });
2432
+ return;
2433
+ }
2434
+ if (args.length === 0 || args[0]?.startsWith("--")) {
2435
+ const portStr = getFlag("port");
2436
+ const port = portStr ? parseInt(portStr, 10) : void 0;
2437
+ if (port !== void 0 && (isNaN(port) || port < 0 || port > 65535)) {
2438
+ console.error(`Invalid port: ${portStr}`);
2439
+ process.exit(1);
2440
+ }
2441
+ const result = await serve({
2442
+ room: getFlag("room"),
2443
+ port,
2444
+ share: args.includes("--share"),
2445
+ quiet: true
2446
+ });
2447
+ const adminJoinUrl = buildShareUrl(result.serverUrl, result.adminToken);
2448
+ const participantShareUrl = buildShareUrl(
2449
+ result.publicUrl !== result.serverUrl ? result.publicUrl : result.serverUrl,
2450
+ result.participantToken
2451
+ );
2452
+ await join({
2453
+ server: adminJoinUrl,
2454
+ name: getFlag("name"),
2455
+ shareUrl: participantShareUrl
2456
+ });
2457
+ return;
2458
+ }
2459
+ console.error(`Unknown command: ${args[0]}
2460
+ `);
2461
+ printUsage(console.error);
2462
+ process.exit(1);
2463
+ }
2464
+ main().catch((err) => {
2465
+ console.error(err);
2466
+ process.exit(1);
2467
+ });
2468
+ //# sourceMappingURL=index.js.map