stoops 0.2.5 → 0.3.2

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.
package/dist/cli/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- InMemoryStorage,
3
+ FileBackedStorage,
4
4
  Room,
5
5
  randomName,
6
6
  randomRoomName
7
- } from "../chunk-TN56PBF3.js";
7
+ } from "../chunk-XEKY3KEU.js";
8
8
  import {
9
9
  EventProcessor,
10
10
  RemoteRoomDataSource,
@@ -25,6 +25,8 @@ import { createServer } from "http";
25
25
  import { spawn, execFileSync } from "child_process";
26
26
  import { randomUUID } from "crypto";
27
27
  import { createRequire } from "module";
28
+ import { tmpdir } from "os";
29
+ import { join as pathJoin } from "path";
28
30
 
29
31
  // src/cli/auth.ts
30
32
  import { randomBytes } from "crypto";
@@ -123,7 +125,24 @@ async function serve(options) {
123
125
  } : logServer;
124
126
  let publicUrl = serverUrl;
125
127
  let tunnelProcess = null;
126
- const storage = new InMemoryStorage();
128
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
129
+ const savePath = options.save ?? options.load ?? pathJoin(tmpdir(), `stoops-${roomName}-${timestamp}.json`);
130
+ let storage;
131
+ if (options.load) {
132
+ try {
133
+ storage = await FileBackedStorage.load(options.load);
134
+ log(`loaded room state from ${options.load}`);
135
+ } catch (err) {
136
+ if (err.code === "ENOENT") {
137
+ storage = new FileBackedStorage(options.load);
138
+ log(`no existing file at ${options.load}, starting fresh`);
139
+ } else {
140
+ throw err;
141
+ }
142
+ }
143
+ } else {
144
+ storage = new FileBackedStorage(savePath);
145
+ }
127
146
  const room = new Room(roomName, storage);
128
147
  const tokens = new TokenManager();
129
148
  const participants = /* @__PURE__ */ new Map();
@@ -513,7 +532,7 @@ Port ${port} is already in use. Another stoops instance may be running.`);
513
532
  const adminToken = tokens.generateShareToken("admin", "admin");
514
533
  const memberToken = tokens.generateShareToken("admin", "member");
515
534
  if (options.headless) {
516
- process.stdout.write(JSON.stringify({ serverUrl, publicUrl, roomName, adminToken, memberToken }) + "\n");
535
+ process.stdout.write(JSON.stringify({ serverUrl, publicUrl, roomName, adminToken, memberToken, savePath }) + "\n");
517
536
  } else if (!options.quiet) {
518
537
  let version = process.env.npm_package_version ?? "";
519
538
  if (!version) {
@@ -533,10 +552,12 @@ Port ${port} is already in use. Another stoops instance may be running.`);
533
552
  Room: ${roomName}
534
553
  Server: ${serverUrl}${publicUrl !== serverUrl ? `
535
554
  Tunnel: ${publicUrl}` : ""}
555
+ Saving: ${savePath}
536
556
 
537
557
  Join: stoops join ${joinUrl}
538
558
  Admin: stoops join ${adminUrl}
539
- Claude: stoops run claude \u2192 then tell agent to join: ${joinUrl}
559
+ Claude: stoops run claude --name MyClaude \u2192 then tell agent to join: ${joinUrl}
560
+ Codex: stoops run codex --name MyCodex \u2192 then tell agent to join: ${joinUrl}
540
561
  `);
541
562
  }
542
563
  const shutdown = async () => {
@@ -1370,7 +1391,8 @@ ${lines.join("\n")}`);
1370
1391
  if (options.shareUrl) {
1371
1392
  console.log();
1372
1393
  console.log(` Invite a friend: npx stoops join "${options.shareUrl}"`);
1373
- console.log(` Connect Claude Code: npx stoops run claude \u2192 then tell agent to join: ${options.shareUrl}`);
1394
+ console.log(` Connect Claude Code: npx stoops run claude --name MyClaude \u2192 then tell agent to join: ${options.shareUrl}`);
1395
+ console.log(` Connect Codex: npx stoops run codex --name MyCodex \u2192 then tell agent to join: ${options.shareUrl}`);
1374
1396
  console.log();
1375
1397
  }
1376
1398
  const tui = startTUI({
@@ -1572,7 +1594,7 @@ function toDisplayEvent(event, selfId, participantTypes) {
1572
1594
  // src/cli/claude/run.ts
1573
1595
  import { writeFileSync, mkdtempSync, rmSync, chmodSync } from "fs";
1574
1596
  import { join as join2 } from "path";
1575
- import { tmpdir } from "os";
1597
+ import { tmpdir as tmpdir2 } from "os";
1576
1598
 
1577
1599
  // src/cli/tmux.ts
1578
1600
  import { execFileSync as execFileSync2, spawn as spawn2 } from "child_process";
@@ -2176,7 +2198,7 @@ async function runClaude(options) {
2176
2198
  process.exit(1);
2177
2199
  }
2178
2200
  const setup = await setupAgentRuntime({ ...options, joinUrls: void 0 });
2179
- const tmpDir = mkdtempSync(join2(tmpdir(), "stoops_agent_"));
2201
+ const tmpDir = mkdtempSync(join2(tmpdir2(), "stoops_agent_"));
2180
2202
  const bridgePath = join2(tmpDir, "mcp-bridge.cjs");
2181
2203
  writeFileSync(bridgePath, MCP_STDIO_BRIDGE);
2182
2204
  chmodSync(bridgePath, 493);
@@ -2369,6 +2391,275 @@ async function pollForReady(url, timeoutMs) {
2369
2391
  return false;
2370
2392
  }
2371
2393
 
2394
+ // src/cli/codex/run.ts
2395
+ import { execFileSync as execFileSync3 } from "child_process";
2396
+ import { writeFileSync as writeFileSync2, mkdtempSync as mkdtempSync2, mkdirSync, rmSync as rmSync2 } from "fs";
2397
+ import { join as join3 } from "path";
2398
+ import { tmpdir as tmpdir3 } from "os";
2399
+
2400
+ // src/cli/codex/tmux-bridge.ts
2401
+ var SPINNER_CHARS2 = "\u280B\u2819\u2839\u2838\u283C\u2834\u2826\u2827\u2807\u280F";
2402
+ var APPROVAL_PATTERNS = [
2403
+ "Would you like to",
2404
+ "needs your approval",
2405
+ "Press Enter to confirm or Esc to cancel",
2406
+ "Do you want to approve"
2407
+ ];
2408
+ var STREAMING_PATTERNS = [
2409
+ /Working\s*\(\d+[smh]/,
2410
+ // "Working (12s" or "Working (1m 30s"
2411
+ /Working\s*$/,
2412
+ // "Working" at end of line (just started)
2413
+ /esc to interrupt/
2414
+ // hint text during streaming
2415
+ ];
2416
+ var CodexTmuxBridge = class {
2417
+ session;
2418
+ queue = [];
2419
+ pollTimer = null;
2420
+ pollIntervalMs;
2421
+ pasteDelayMs;
2422
+ keystrokeDelayMs;
2423
+ stopped = false;
2424
+ constructor(session, opts) {
2425
+ this.session = session;
2426
+ this.pollIntervalMs = opts?.pollIntervalMs ?? 200;
2427
+ this.pasteDelayMs = opts?.pasteDelayMs ?? 150;
2428
+ this.keystrokeDelayMs = opts?.keystrokeDelayMs ?? 50;
2429
+ }
2430
+ /**
2431
+ * Delivery callback — drop-in replacement for EventProcessor's deliver.
2432
+ * Pass `bridge.deliver.bind(bridge)` to EventProcessor.run().
2433
+ */
2434
+ async deliver(parts) {
2435
+ const text = contentPartsToString(parts);
2436
+ if (!text.trim()) return;
2437
+ this.inject(text);
2438
+ }
2439
+ /**
2440
+ * Detect the current TUI state by reading the screen.
2441
+ */
2442
+ detectState() {
2443
+ const lines = this.captureScreen();
2444
+ return detectCodexStateFromLines(lines);
2445
+ }
2446
+ /**
2447
+ * Try to inject text, choosing strategy based on TUI state.
2448
+ * Text is flattened to a single line to avoid multi-line paste issues.
2449
+ */
2450
+ inject(text) {
2451
+ const flat = text.replace(/\n/g, " ");
2452
+ const state = this.detectState();
2453
+ switch (state) {
2454
+ case "idle":
2455
+ this.injectIdle(flat);
2456
+ break;
2457
+ case "typing":
2458
+ this.injectWhileTyping(flat);
2459
+ break;
2460
+ default:
2461
+ this.enqueue(flat);
2462
+ break;
2463
+ }
2464
+ }
2465
+ /** Capture the screen via tmux capture-pane. */
2466
+ captureScreen() {
2467
+ return tmuxCapturePane(this.session);
2468
+ }
2469
+ /**
2470
+ * Inject into an idle prompt using bracketed paste.
2471
+ *
2472
+ * Bracketed paste wraps text in ESC[200~...ESC[201~ so crossterm
2473
+ * delivers it as a single Paste event, bypassing the burst detector.
2474
+ * After the paste, we wait for the Enter suppression window (120ms)
2475
+ * to expire, then send Enter to submit.
2476
+ */
2477
+ injectIdle(text) {
2478
+ tmuxInjectText(this.session, "\x1B[200~");
2479
+ tmuxInjectText(this.session, text);
2480
+ tmuxInjectText(this.session, "\x1B[201~");
2481
+ this.sleep(this.pasteDelayMs);
2482
+ tmuxSendEnter(this.session);
2483
+ }
2484
+ /**
2485
+ * Inject while the user is typing:
2486
+ * 1. Ctrl+U — cut to beginning of line (into kill buffer)
2487
+ * 2. Bracketed paste our text + Enter
2488
+ * 3. Ctrl+Y — yank user's text back from kill buffer
2489
+ */
2490
+ injectWhileTyping(text) {
2491
+ tmuxSendKey(this.session, "C-u");
2492
+ this.sleep(this.keystrokeDelayMs);
2493
+ tmuxInjectText(this.session, "\x1B[200~");
2494
+ tmuxInjectText(this.session, text);
2495
+ tmuxInjectText(this.session, "\x1B[201~");
2496
+ this.sleep(this.pasteDelayMs);
2497
+ tmuxSendEnter(this.session);
2498
+ this.sleep(this.keystrokeDelayMs);
2499
+ tmuxSendKey(this.session, "C-y");
2500
+ }
2501
+ /** Add to queue and start polling if not already. */
2502
+ enqueue(text) {
2503
+ this.queue.push(text);
2504
+ this.startPolling();
2505
+ }
2506
+ /** Start the polling timer to drain queued events. */
2507
+ startPolling() {
2508
+ if (this.pollTimer || this.stopped) return;
2509
+ this.pollTimer = setInterval(() => this.drainQueue(), this.pollIntervalMs);
2510
+ }
2511
+ /** Stop the polling timer. */
2512
+ stopPolling() {
2513
+ if (this.pollTimer) {
2514
+ clearInterval(this.pollTimer);
2515
+ this.pollTimer = null;
2516
+ }
2517
+ }
2518
+ /**
2519
+ * Try to drain one queued event if the state is safe.
2520
+ * Drains one at a time — each injection may trigger streaming,
2521
+ * so the next poll re-checks state before injecting more.
2522
+ */
2523
+ drainQueue() {
2524
+ if (this.queue.length === 0) {
2525
+ this.stopPolling();
2526
+ return;
2527
+ }
2528
+ const state = this.detectState();
2529
+ if (state === "idle" || state === "typing") {
2530
+ const text = this.queue.shift();
2531
+ if (state === "idle") {
2532
+ this.injectIdle(text);
2533
+ } else {
2534
+ this.injectWhileTyping(text);
2535
+ }
2536
+ if (this.queue.length === 0) {
2537
+ this.stopPolling();
2538
+ }
2539
+ }
2540
+ }
2541
+ /** Cleanup. */
2542
+ stop() {
2543
+ this.stopped = true;
2544
+ this.stopPolling();
2545
+ this.queue.length = 0;
2546
+ }
2547
+ /** Synchronous sleep — only used for tiny keystroke delays. */
2548
+ sleep(ms) {
2549
+ if (ms <= 0) return;
2550
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
2551
+ }
2552
+ };
2553
+ function detectCodexStateFromLines(lines) {
2554
+ if (lines.length === 0) return "unknown";
2555
+ const tail = lines.slice(-20);
2556
+ const tailText = tail.join("\n");
2557
+ for (const pattern of APPROVAL_PATTERNS) {
2558
+ if (tailText.includes(pattern)) return "approval";
2559
+ }
2560
+ for (const pattern of STREAMING_PATTERNS) {
2561
+ if (pattern.test(tailText)) return "streaming";
2562
+ }
2563
+ const lastFew = tail.slice(-5).join("");
2564
+ for (const ch of SPINNER_CHARS2) {
2565
+ if (lastFew.includes(ch)) return "streaming";
2566
+ }
2567
+ const nonEmpty = tail.filter((l) => l.trim().length > 0);
2568
+ if (nonEmpty.length > 0) {
2569
+ return "idle";
2570
+ }
2571
+ return "unknown";
2572
+ }
2573
+
2574
+ // src/cli/codex/run.ts
2575
+ function codexAvailable() {
2576
+ try {
2577
+ execFileSync3("codex", ["--version"], { stdio: "ignore" });
2578
+ return true;
2579
+ } catch {
2580
+ return false;
2581
+ }
2582
+ }
2583
+ async function runCodex(options) {
2584
+ if (options.headless) {
2585
+ const setup2 = await setupAgentRuntime(options);
2586
+ const deliver = async (parts) => {
2587
+ const text = contentPartsToString(parts);
2588
+ if (text.trim()) process.stdout.write(text + "\n");
2589
+ };
2590
+ const eventLoopPromise2 = setup2.processor.run(deliver, setup2.wrappedSource, setup2.initialParts).catch(() => {
2591
+ });
2592
+ process.stderr.write(`MCP server: ${setup2.mcpServer.url}
2593
+ `);
2594
+ await new Promise((resolve) => {
2595
+ process.on("SIGINT", resolve);
2596
+ process.on("SIGTERM", resolve);
2597
+ });
2598
+ await setup2.cleanup();
2599
+ await eventLoopPromise2;
2600
+ return;
2601
+ }
2602
+ if (!tmuxAvailable()) {
2603
+ console.error("Error: tmux is required but not found. Install it with: brew install tmux");
2604
+ process.exit(1);
2605
+ }
2606
+ if (!codexAvailable()) {
2607
+ console.error("Error: codex is required but not found. Install it with: npm install -g @openai/codex");
2608
+ process.exit(1);
2609
+ }
2610
+ const setup = await setupAgentRuntime({ ...options, joinUrls: void 0 });
2611
+ const tmpDir = mkdtempSync2(join3(tmpdir3(), "stoops_codex_"));
2612
+ const mcpPort = new URL(setup.mcpServer.url).port;
2613
+ const mcpUrl = `http://127.0.0.1:${mcpPort}/mcp`;
2614
+ const codexConfigDir = join3(tmpDir, ".codex");
2615
+ mkdirSync(codexConfigDir, { recursive: true });
2616
+ const configToml = [
2617
+ "[mcp_servers.stoops]",
2618
+ `url = "${mcpUrl}"`,
2619
+ `startup_timeout_sec = 15`,
2620
+ `tool_timeout_sec = 60`
2621
+ ].join("\n");
2622
+ writeFileSync2(join3(codexConfigDir, "config.toml"), configToml);
2623
+ const tmuxSession = `stoops_${setup.agentName}`;
2624
+ if (tmuxSessionExists(tmuxSession)) {
2625
+ tmuxKillSession(tmuxSession);
2626
+ }
2627
+ console.log("Launching Codex...");
2628
+ tmuxCreateSession(tmuxSession);
2629
+ const extraArgs = options.extraArgs ?? [];
2630
+ const codexCmd = [`CODEX_HOME=${codexConfigDir} codex`, ...extraArgs].join(" ");
2631
+ tmuxSendCommand(tmuxSession, codexCmd);
2632
+ const bridge = new CodexTmuxBridge(tmuxSession);
2633
+ const eventLoopPromise = setup.processor.run(bridge.deliver.bind(bridge), setup.wrappedSource).catch(() => {
2634
+ });
2635
+ for (let i = 0; i < 10; i++) {
2636
+ await new Promise((r) => setTimeout(r, 500));
2637
+ if (!tmuxSessionExists(tmuxSession)) {
2638
+ console.error("Error: Codex exited during startup. Try running again.");
2639
+ bridge.stop();
2640
+ await setup.cleanup();
2641
+ try {
2642
+ rmSync2(tmpDir, { recursive: true });
2643
+ } catch {
2644
+ }
2645
+ return;
2646
+ }
2647
+ }
2648
+ console.log("Attaching to Codex session...\n");
2649
+ try {
2650
+ await tmuxAttach(tmuxSession);
2651
+ } catch {
2652
+ }
2653
+ bridge.stop();
2654
+ await setup.cleanup();
2655
+ tmuxKillSession(tmuxSession);
2656
+ try {
2657
+ rmSync2(tmpDir, { recursive: true });
2658
+ } catch {
2659
+ }
2660
+ console.log("Disconnected.");
2661
+ }
2662
+
2372
2663
  // src/cli/index.ts
2373
2664
  var args = process.argv.slice(2);
2374
2665
  function getFlag(name, arr = args) {
@@ -2395,13 +2686,14 @@ function printUsage(stream = console.log) {
2395
2686
  stream(" stoops join <url> [--name <name>] [--guest] Join a room");
2396
2687
  stream(" stoops run claude [--name <name>] [--admin] [-- <args>] Connect Claude Code");
2397
2688
  stream(" stoops run opencode [--name <name>] [--admin] [-- <args>] Connect OpenCode");
2689
+ stream(" stoops run codex [--name <name>] [--admin] [-- <args>] Connect Codex");
2398
2690
  }
2399
2691
  async function main() {
2400
2692
  if (args.includes("--help") || args.includes("-h")) {
2401
2693
  printUsage();
2402
2694
  return;
2403
2695
  }
2404
- if (args[0] === "run" && (args[1] === "claude" || args[1] === "opencode")) {
2696
+ if (args[0] === "run" && (args[1] === "claude" || args[1] === "opencode" || args[1] === "codex")) {
2405
2697
  const runtime = args[1];
2406
2698
  const restArgs = args.slice(2);
2407
2699
  const ddIndex = restArgs.indexOf("--");
@@ -2417,8 +2709,10 @@ async function main() {
2417
2709
  };
2418
2710
  if (runtime === "claude") {
2419
2711
  await runClaude(runtimeOptions);
2420
- } else {
2712
+ } else if (runtime === "opencode") {
2421
2713
  await runOpencode(runtimeOptions);
2714
+ } else {
2715
+ await runCodex(runtimeOptions);
2422
2716
  }
2423
2717
  return;
2424
2718
  }
@@ -2447,7 +2741,9 @@ async function main() {
2447
2741
  room: getFlag("room"),
2448
2742
  port,
2449
2743
  share: args.includes("--share"),
2450
- headless: args.includes("--headless")
2744
+ headless: args.includes("--headless"),
2745
+ save: getFlag("save"),
2746
+ load: getFlag("load")
2451
2747
  });
2452
2748
  return;
2453
2749
  }
@@ -2462,7 +2758,9 @@ async function main() {
2462
2758
  room: getFlag("room"),
2463
2759
  port,
2464
2760
  share: args.includes("--share"),
2465
- quiet: true
2761
+ quiet: true,
2762
+ save: getFlag("save"),
2763
+ load: getFlag("load")
2466
2764
  });
2467
2765
  const adminJoinUrl = buildShareUrl(result.serverUrl, result.adminToken);
2468
2766
  const participantShareUrl = buildShareUrl(