@vincentkoc/multicodex 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.
Files changed (3) hide show
  1. package/README.md +22 -27
  2. package/dist/cli.mjs +492 -56
  3. package/package.json +15 -17
package/README.md CHANGED
@@ -6,8 +6,9 @@ their own Codex installation, authentication, repository, tools, and local
6
6
  terminal.
7
7
 
8
8
  The browser shows a live structured view of each lane and gives the host
9
- visible, policy-controlled coordination actions. It does not proxy a raw
10
- terminal or answer local Codex approvals.
9
+ visible, policy-controlled coordination actions. Participants can additionally
10
+ opt into an ephemeral, read-only Ghostty terminal mirror. Browser viewers
11
+ cannot type into the terminal or answer local Codex approvals.
11
12
 
12
13
  ## Quickstart
13
14
 
@@ -24,19 +25,21 @@ npx --yes @vincentkoc/multicodex@latest doctor
24
25
  npx --yes @vincentkoc/multicodex@latest host --repo . --title "OpenAI event build"
25
26
  ```
26
27
 
27
- Open the printed control URL. Use **add a person** to copy a named invite
28
- command:
28
+ Open the printed control URL. Use **invite teammate** to create a visible,
29
+ single-use named invite command:
29
30
 
30
31
  ```bash
31
32
  npx --yes @vincentkoc/multicodex@latest join '<invite-url>' \
32
33
  --repo . \
33
34
  --name Queenie \
34
- --policy suggest
35
+ --policy suggest \
36
+ --terminal-mirror
35
37
  ```
36
38
 
37
39
  The join command launches a normal local Codex TUI. Running the same command
38
40
  again resumes the same MultiCodex lane and Codex thread. Add `--fresh` only to
39
- create a new lane intentionally.
41
+ create a new lane intentionally. Remove `--terminal-mirror` to publish only
42
+ structured activity.
40
43
 
41
44
  ## Multi-Machine Rooms
42
45
 
@@ -58,8 +61,8 @@ room server and conductor still run on the host machine.
58
61
  The control room keeps the team rail, selected lane activity, and conductor
59
62
  conversation visible together.
60
63
 
61
- - add people with named copyable invite commands;
62
- - remove a lane and revoke its capability;
64
+ - create, recopy, and revoke named single-use teammate invites;
65
+ - remove a participant lane and revoke its capability;
63
66
  - inspect coalesced messages, plans, commands, file changes, approvals, and
64
67
  turn state;
65
68
  - ask the host-local conductor a room question;
@@ -88,11 +91,18 @@ invite, and each lane.
88
91
  - The invite capability creates new lanes.
89
92
  - A lane capability resumes one lane, publishes its events, and receives its
90
93
  permitted commands.
94
+ - `--terminal-mirror` explicitly shares that lane's rendered terminal output
95
+ with the host and every active room participant.
96
+ - Terminal bytes are held in a bounded in-memory replay buffer and are never
97
+ written into room state or persisted to disk.
98
+ - Terminal mirrors are read-only. Conductor steering still uses visible,
99
+ policy-checked Codex app-server commands.
91
100
  - Removing a lane revokes its capability and disconnects its managed bridge.
92
101
  - Capabilities do not appear in public room snapshots.
93
102
  - The participant's Codex app-server always binds to loopback.
94
- - Repository contents, credentials, hidden reasoning, and complete terminal
95
- streams are not published.
103
+ - Repository contents, credentials, and hidden reasoning are not published.
104
+ Terminal output can contain sensitive text, so participants should only use
105
+ `--terminal-mirror` in rooms they trust.
96
106
 
97
107
  Room and lane state is written under `.multicodex/` with owner-only
98
108
  permissions.
@@ -130,24 +140,9 @@ multicodex join -> lane capability -> room server
130
140
  |
131
141
  |- local event spool and policy enforcement
132
142
  |- loopback Codex app-server
133
- `- normal local Codex TUI
143
+ |- normal local Codex TUI
144
+ `- optional ephemeral read-only PTY mirror
134
145
  ```
135
146
 
136
147
  Crabfleet, Crabbox, server OpenAI keys, server GitHub tokens, and hosted
137
148
  terminals are not required by the self-contained product.
138
-
139
- The repository still contains the earlier Cloudflare/Crabfleet event-room
140
- implementation while the local-first replacement lands. It is not the default
141
- MultiCodex runtime.
142
-
143
- ## Legacy Worker Development
144
-
145
- The earlier Worker product remains testable during replacement:
146
-
147
- ```bash
148
- pnpm db:local
149
- pnpm dev
150
- ```
151
-
152
- `pnpm dev` enables simulation only for the local Wrangler process. Production
153
- keeps simulation explicitly disabled.
package/dist/cli.mjs CHANGED
@@ -273,6 +273,188 @@ function safeName(value) {
273
273
  return value.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/^-|-$/g, "") || "builder";
274
274
  }
275
275
 
276
+ // packages/cli/src/pty-tui.ts
277
+ import process2 from "node:process";
278
+ import { attachLocalStdio, spawnLocalPty } from "@openclaw/libterminal/node";
279
+ async function startMirroredTui(input) {
280
+ const outputDecoder = new TextDecoder();
281
+ const terminal = await spawnLocalPty({
282
+ command: input.command,
283
+ args: input.args,
284
+ cwd: input.cwd,
285
+ size: { columns: terminalColumns(), rows: terminalRows() },
286
+ onOutput: (bytes) => input.onOutput(outputDecoder.decode(bytes, { stream: true }))
287
+ });
288
+ const attached = attachLocalStdio(terminal, {
289
+ onResize: ({ columns, rows }) => input.onResize(columns, rows)
290
+ });
291
+ const done = Promise.all([terminal.exit, attached]).then(([exit]) => {
292
+ const trailingOutput = outputDecoder.decode();
293
+ if (trailingOutput) input.onOutput(trailingOutput);
294
+ if (exit.code !== 0 && exit.signal === null) {
295
+ throw new Error(`Codex TUI exited with ${exit.code}`);
296
+ }
297
+ }).catch((error) => {
298
+ terminal.kill();
299
+ throw error;
300
+ });
301
+ return { done, kill: () => terminal.kill() };
302
+ }
303
+ function terminalColumns() {
304
+ return Math.max(20, process2.stdout.columns || 120);
305
+ }
306
+ function terminalRows() {
307
+ return Math.max(10, process2.stdout.rows || 34);
308
+ }
309
+
310
+ // packages/cli/src/terminal-mirror.ts
311
+ import {
312
+ BatchPublisher,
313
+ TerminalFanout
314
+ } from "@openclaw/libterminal/stream";
315
+ var MAX_REPLAY_BYTES = 512 * 1024;
316
+ var MAX_VIEWER_BUFFER_BYTES = 1024 * 1024;
317
+ var PUBLISH_INTERVAL_MS = 40;
318
+ var PUBLISH_BATCH_BYTES = 64 * 1024;
319
+ var TerminalMirrorHub = class {
320
+ lanes = /* @__PURE__ */ new Map();
321
+ viewers = /* @__PURE__ */ new Map();
322
+ publish(laneId, data) {
323
+ this.lane(laneId).publish(data);
324
+ }
325
+ subscribe(laneId, viewerToken, response) {
326
+ response.writeHead(200, {
327
+ "content-type": "application/octet-stream",
328
+ "cache-control": "no-store",
329
+ "x-content-type-options": "nosniff",
330
+ "x-accel-buffering": "no"
331
+ });
332
+ response.flushHeaders();
333
+ const subscription = this.lane(laneId).subscribe(crypto.randomUUID());
334
+ this.viewer(viewerToken).add(subscription);
335
+ response.once("close", () => subscription.close("viewer disconnected"));
336
+ void streamSubscription(subscription, response).catch(() => response.destroy()).finally(() => this.removeViewerSubscription(viewerToken, subscription));
337
+ }
338
+ closeViewer(viewerToken) {
339
+ const subscriptions = this.viewers.get(viewerToken);
340
+ if (!subscriptions) return;
341
+ this.viewers.delete(viewerToken);
342
+ for (const subscription of subscriptions) subscription.close("viewer capability revoked");
343
+ }
344
+ closeLane(laneId) {
345
+ const lane = this.lanes.get(laneId);
346
+ if (!lane) return;
347
+ lane.close("terminal mirror closed");
348
+ this.lanes.delete(laneId);
349
+ }
350
+ closeAll() {
351
+ for (const laneId of this.lanes.keys()) this.closeLane(laneId);
352
+ this.viewers.clear();
353
+ }
354
+ lane(laneId) {
355
+ let lane = this.lanes.get(laneId);
356
+ if (!lane) {
357
+ lane = new TerminalFanout({
358
+ replayBytes: MAX_REPLAY_BYTES,
359
+ subscriberBufferBytes: MAX_VIEWER_BUFFER_BYTES,
360
+ slowSubscriberPolicy: "disconnect"
361
+ });
362
+ this.lanes.set(laneId, lane);
363
+ }
364
+ return lane;
365
+ }
366
+ viewer(viewerToken) {
367
+ let subscriptions = this.viewers.get(viewerToken);
368
+ if (!subscriptions) {
369
+ subscriptions = /* @__PURE__ */ new Set();
370
+ this.viewers.set(viewerToken, subscriptions);
371
+ }
372
+ return subscriptions;
373
+ }
374
+ removeViewerSubscription(viewerToken, subscription) {
375
+ const subscriptions = this.viewers.get(viewerToken);
376
+ if (!subscriptions) return;
377
+ subscriptions.delete(subscription);
378
+ if (subscriptions.size === 0) this.viewers.delete(viewerToken);
379
+ }
380
+ };
381
+ var TerminalMirrorPublisher = class {
382
+ endpoint;
383
+ sizeEndpoint;
384
+ token;
385
+ publisher;
386
+ stopped = false;
387
+ publisherStopped = false;
388
+ constructor(server, laneId, token) {
389
+ this.endpoint = new URL(`/api/lanes/${encodeURIComponent(laneId)}/terminal`, server);
390
+ this.sizeEndpoint = new URL(`/api/lanes/${encodeURIComponent(laneId)}/terminal-size`, server);
391
+ this.token = token;
392
+ this.publisher = new BatchPublisher((bytes) => this.publish(bytes), {
393
+ flushIntervalMs: PUBLISH_INTERVAL_MS,
394
+ maxBatchBytes: PUBLISH_BATCH_BYTES,
395
+ onError: () => void 0
396
+ });
397
+ }
398
+ write(data) {
399
+ if (this.stopped) return;
400
+ this.publisher.write(typeof data === "string" ? new TextEncoder().encode(data) : data);
401
+ }
402
+ async stop() {
403
+ if (this.publisherStopped) return;
404
+ this.stopped = true;
405
+ this.publisherStopped = true;
406
+ await this.publisher.stop().catch(() => void 0);
407
+ await fetch(this.endpoint, {
408
+ method: "DELETE",
409
+ headers: { authorization: `Bearer ${this.token}` }
410
+ }).catch(() => void 0);
411
+ }
412
+ resize(columns, rows) {
413
+ if (this.stopped) return;
414
+ void fetch(this.sizeEndpoint, {
415
+ method: "POST",
416
+ headers: {
417
+ authorization: `Bearer ${this.token}`,
418
+ "content-type": "application/json"
419
+ },
420
+ body: JSON.stringify({ columns, rows })
421
+ }).catch(() => void 0);
422
+ }
423
+ async publish(bytes) {
424
+ const response = await fetch(this.endpoint, {
425
+ method: "POST",
426
+ headers: {
427
+ authorization: `Bearer ${this.token}`,
428
+ "content-type": "application/octet-stream"
429
+ },
430
+ body: Buffer.from(bytes)
431
+ });
432
+ if (response.status === 410) this.stopped = true;
433
+ }
434
+ };
435
+ async function streamSubscription(subscription, response) {
436
+ try {
437
+ for await (const chunk of subscription) {
438
+ if (response.destroyed || response.writableEnded) return;
439
+ if (!response.write(chunk)) await waitForDrainOrClose(response);
440
+ }
441
+ if (!response.destroyed && !response.writableEnded) response.end();
442
+ } finally {
443
+ subscription.close("viewer stream ended");
444
+ }
445
+ }
446
+ function waitForDrainOrClose(response) {
447
+ return new Promise((resolve) => {
448
+ const done = () => {
449
+ response.off("drain", done);
450
+ response.off("close", done);
451
+ resolve();
452
+ };
453
+ response.once("drain", done);
454
+ response.once("close", done);
455
+ });
456
+ }
457
+
276
458
  // packages/cli/src/builder.ts
277
459
  var BuilderBridge = class {
278
460
  input;
@@ -369,7 +551,7 @@ var BuilderBridge = class {
369
551
  this.resolveStopped = null;
370
552
  this.client?.close();
371
553
  this.appServerStop?.();
372
- if (this.tuiChild && !this.tuiChild.killed) this.tuiChild.kill("SIGTERM");
554
+ this.tuiChild?.kill();
373
555
  }
374
556
  async joinOrResume() {
375
557
  if (this.input.fresh) await this.stateStore.clear();
@@ -387,7 +569,8 @@ var BuilderBridge = class {
387
569
  body: JSON.stringify({
388
570
  displayName: this.input.displayName,
389
571
  repo: this.input.repo,
390
- policy: this.input.policy
572
+ policy: this.input.policy,
573
+ terminalMirror: this.input.terminalMirror
391
574
  })
392
575
  }
393
576
  );
@@ -416,7 +599,8 @@ var BuilderBridge = class {
416
599
  body: JSON.stringify({
417
600
  displayName: this.input.displayName,
418
601
  repo: this.input.repo,
419
- policy: this.input.policy
602
+ policy: this.input.policy,
603
+ terminalMirror: this.input.terminalMirror
420
604
  })
421
605
  });
422
606
  const payload = await response.json();
@@ -601,14 +785,30 @@ policy: ${this.input.policy}
601
785
  );
602
786
  const args2 = this.threadId ? ["resume", "--remote", endpoint, "-C", this.input.repo, this.threadId] : ["--remote", endpoint, "-C", this.input.repo];
603
787
  if (this.input.prompt) args2.push(this.input.prompt);
604
- const child = spawn2(this.input.codexPath, args2, {
605
- stdio: "inherit"
788
+ if (!this.input.terminalMirror) {
789
+ const child = spawn2(this.input.codexPath, args2, { stdio: "inherit" });
790
+ this.tuiChild = child;
791
+ try {
792
+ await waitForChild(child);
793
+ } finally {
794
+ this.tuiChild = null;
795
+ }
796
+ return;
797
+ }
798
+ const publisher = new TerminalMirrorPublisher(this.input.server, this.laneId, this.token);
799
+ const tui = await startMirroredTui({
800
+ command: this.input.codexPath,
801
+ args: args2,
802
+ cwd: this.input.repo,
803
+ onOutput: (data) => publisher.write(data),
804
+ onResize: (columns, rows) => publisher.resize(columns, rows)
606
805
  });
607
- this.tuiChild = child;
806
+ this.tuiChild = tui;
608
807
  try {
609
- await waitForChild(child);
808
+ await tui.done;
610
809
  } finally {
611
810
  this.tuiChild = null;
811
+ await publisher.stop();
612
812
  }
613
813
  }
614
814
  participantViewUrl() {
@@ -862,24 +1062,46 @@ function compactSnapshot(snapshot) {
862
1062
  }
863
1063
 
864
1064
  // packages/cli/src/local-room.ts
865
- import fs3 from "node:fs/promises";
1065
+ import fs4 from "node:fs/promises";
866
1066
  import http from "node:http";
867
1067
  import path5 from "node:path";
868
1068
 
1069
+ // packages/cli/src/terminal-assets.ts
1070
+ import fs3 from "node:fs/promises";
1071
+ import { fileURLToPath } from "node:url";
1072
+ import { readGhosttyAsset } from "@openclaw/libterminal/node";
1073
+ var libterminalBrowserPath = fileURLToPath(import.meta.resolve("@openclaw/libterminal/browser"));
1074
+ var libterminalIndexPath = fileURLToPath(import.meta.resolve("@openclaw/libterminal"));
1075
+ var libterminalAssets = /* @__PURE__ */ new Map([
1076
+ ["/vendor/libterminal/browser.js", libterminalBrowserPath],
1077
+ ["/vendor/libterminal/index.js", libterminalIndexPath]
1078
+ ]);
1079
+ async function readTerminalAsset(pathname) {
1080
+ const libterminalAsset = libterminalAssets.get(pathname);
1081
+ if (libterminalAsset) {
1082
+ return {
1083
+ body: await fs3.readFile(libterminalAsset),
1084
+ contentType: "text/javascript; charset=utf-8"
1085
+ };
1086
+ }
1087
+ return readGhosttyAsset(pathname);
1088
+ }
1089
+
869
1090
  // packages/cli/src/ui.ts
1091
+ import { GHOSTTY_ASSET_PATHS } from "@openclaw/libterminal/node";
870
1092
  function localRoomHtml() {
871
1093
  return `<!doctype html>
872
1094
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
873
1095
  <title>MultiCodex control room</title><style>
874
- :root{font-family:"Avenir Next","Segoe UI",sans-serif;color:#19211e;background:#e9ede8;--paper:#fbfcf8;--soft:#eef2ec;--line:#d4dbd4;--ink-soft:#65716b;--red:#dd503c;--green:#16825d;--blue:#3267d6;--yellow:#d59b18;--terminal:#171d1a;--terminal-line:#2c3731;--terminal-copy:#d9e4dc;--role:#16825d}*{box-sizing:border-box}body{margin:0;min-width:320px;min-height:100vh}button,input,select{font:inherit;letter-spacing:0}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.42}h1,h2,h3,p{margin:0}.mono,code,.eyebrow,.status,.event-time,.event-kind,.policy,.terminal-meta{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace}.topbar{align-items:center;background:var(--paper);border-bottom:1px solid var(--line);display:grid;gap:18px;grid-template-columns:auto minmax(180px,1fr) auto;padding:0 18px;min-height:64px;position:sticky;top:0;z-index:10}.brand{align-items:center;display:flex;gap:10px}.brand-mark{background:#19211e;color:#f9fbf7;display:grid;font-weight:850;height:34px;place-items:center;position:relative;width:34px}.brand-mark:after{background:var(--red);bottom:-3px;content:"";height:9px;position:absolute;right:-3px;width:9px}.brand strong{font-size:17px}.room-title{min-width:0}.room-title strong,.room-title span{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.room-title strong{font-size:13px}.room-title span{color:var(--ink-soft);font-size:10px;margin-top:2px}.top-actions{align-items:center;display:flex;gap:7px}.button,.icon-button{align-items:center;border:1px solid var(--line);border-radius:4px;display:inline-flex;justify-content:center}.button{background:var(--paper);font-size:12px;font-weight:700;gap:6px;min-height:34px;padding:0 11px}.button.primary{background:#19211e;border-color:#19211e;color:#f9fbf7}.button.danger{background:#fff2ef;border-color:#efb8af;color:#9f3020}.button:hover,.icon-button:hover{border-color:#96a39b}.icon-button{background:var(--paper);color:#3d4943;font-size:17px;height:32px;padding:0;width:32px}.live-pill{align-items:center;color:var(--ink-soft);display:flex;font-size:10px;gap:7px;white-space:nowrap}.live-dot{background:var(--green);border-radius:50%;box-shadow:0 0 0 4px rgba(22,130,93,.1);height:7px;width:7px}.shell{display:grid;grid-template-columns:minmax(225px,260px) minmax(440px,1fr) minmax(300px,365px);margin:0 auto;max-width:1900px;min-height:calc(100vh - 64px)}.team,.conductor{background:var(--paper);height:calc(100vh - 64px);position:sticky;top:64px}.team{border-right:1px solid var(--line);display:flex;flex-direction:column}.conductor{border-left:1px solid var(--line);display:grid;grid-template-rows:auto minmax(160px,1fr) auto auto;min-width:0}.panel-heading{align-items:center;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;min-height:66px;padding:12px 14px}.eyebrow{color:var(--ink-soft);display:block;font-size:9px;font-weight:700;text-transform:uppercase}.panel-heading h2{font-size:15px;margin-top:3px}.conductor-seat{align-items:center;background:#e6eee8;border-bottom:1px solid var(--line);display:grid;gap:9px;grid-template-columns:auto 1fr auto;padding:11px 14px}.avatar{align-items:center;background:var(--role);border-radius:50%;color:#fff;display:flex;flex:0 0 auto;font-size:10px;font-weight:800;height:31px;justify-content:center;width:31px}.conductor-seat .avatar{background:#19211e}.seat-copy{display:grid;min-width:0}.person .seat-copy{background:transparent;border:0;padding:0;text-align:left}.seat-copy strong{font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.seat-copy span{color:var(--ink-soft);font-size:9px;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.presence{background:#a5aea9;border-radius:50%;height:7px;width:7px}.presence.on{background:var(--green);box-shadow:0 0 0 3px rgba(22,130,93,.1)}.people{flex:1;overflow:auto}.person{align-items:center;background:transparent;border:0;border-bottom:1px solid var(--line);border-left:3px solid var(--role);display:grid;gap:8px;grid-template-columns:auto minmax(0,1fr) auto;padding:10px 9px;text-align:left;width:100%}.person:hover,.person.selected{background:#eef3ee}.person.removed{filter:saturate(.25);opacity:.65}.person-actions{display:flex;gap:3px}.person-actions .icon-button{height:25px;width:25px}.policy{background:#e4ebe5;color:#526059;display:inline-block;font-size:8px;margin-top:5px;padding:2px 4px;width:max-content}.add-person{border-top:1px solid var(--line);display:grid;gap:7px;padding:11px}.add-person-row{display:grid;gap:6px;grid-template-columns:minmax(0,1fr) 86px}.field,input,select{background:#fff;border:1px solid var(--line);border-radius:4px;color:inherit;min-width:0;outline:none}.field,input,select{height:36px;padding:0 9px}input:focus,select:focus{border-color:var(--blue);box-shadow:0 0 0 2px rgba(50,103,214,.12)}.stage{min-width:0;padding:18px}.stage-heading{align-items:flex-start;display:flex;gap:16px;justify-content:space-between;margin-bottom:14px}.stage-heading h1{font-size:clamp(19px,2.1vw,29px);line-height:1.05;margin-top:5px}.stage-heading p{color:var(--ink-soft);font-size:10px;margin-top:7px}.lane-state{align-items:center;background:var(--paper);border:1px solid var(--line);display:flex;gap:8px;padding:7px 9px}.lane-state .presence{background:var(--yellow)}.lane-state .presence.on{background:var(--green)}.terminal{background:var(--terminal);border:1px solid #111613;box-shadow:9px 9px 0 rgba(25,33,30,.1);color:var(--terminal-copy);min-height:470px;overflow:hidden}.terminal-bar{align-items:center;background:#222b26;border-bottom:1px solid var(--terminal-line);display:grid;gap:10px;grid-template-columns:auto minmax(0,1fr) auto;padding:10px 12px}.traffic{display:flex;gap:5px}.traffic i{background:#617068;border-radius:50%;height:7px;width:7px}.traffic i:first-child{background:var(--red)}.traffic i:nth-child(2){background:var(--yellow)}.traffic i:last-child{background:var(--green)}.terminal-title{color:#aebdb3;font-size:10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.terminal-meta{color:#819188;font-size:8px}.terminal-stream{height:min(66vh,680px);min-height:420px;overflow:auto;padding:8px 0 28px}.terminal-empty{align-items:center;color:#78877e;display:flex;font-size:11px;justify-content:center;min-height:390px;padding:35px;text-align:center}.event{border-bottom:1px solid rgba(255,255,255,.035);display:grid;gap:10px;grid-template-columns:62px 116px minmax(0,1fr);padding:8px 12px}.event:hover{background:rgba(255,255,255,.025)}.event-time{color:#738179;font-size:8px}.event-kind{color:#84a490;font-size:8px;overflow-wrap:anywhere}.event-copy{color:#d3ded6;font-size:11px;line-height:1.5;overflow-wrap:anywhere;white-space:pre-wrap}.event.agent-message .event-copy{color:#f1f6f2}.event.agent-plan .event-kind{color:#dbb95d}.event.command-started .event-kind,.event.command-completed .event-kind{color:#77a8ee}.event.files-changed .event-kind{color:#e28d7f}.event.turn-started .event-kind,.event.turn-completed .event-kind{color:#71c99e}.event.command-result .event-kind{color:#d5a557}.cursor-line{align-items:center;color:#91a198;display:flex;font:10px "SFMono-Regular",Consolas,monospace;gap:8px;padding:12px}.cursor{animation:blink 1s steps(1,end) infinite;background:#72bd93;height:13px;width:7px}@keyframes blink{50%{opacity:0}}.activity-band{display:grid;gap:8px;grid-template-columns:repeat(3,minmax(0,1fr));margin-top:18px}.metric{border-top:3px solid var(--role);padding:9px 2px}.metric strong{display:block;font-size:16px}.metric span{color:var(--ink-soft);font-size:9px}.messages{overflow:auto;padding:8px 12px}.message{border-bottom:1px solid #e6eae5;padding:11px 3px}.message.conductor-message{background:#eaf1eb;border-left:3px solid var(--green);margin:5px -4px;padding:11px 8px}.message strong{font-size:9px;text-transform:uppercase}.message p{font-size:11px;line-height:1.45;margin-top:4px;white-space:pre-wrap}.message time{color:var(--ink-soft);font-size:8px;margin-left:5px}.form{border-top:1px solid var(--line);display:grid;gap:7px;padding:11px}.command-grid{display:grid;gap:6px;grid-template-columns:1fr 1fr}.form input,.form select{font-size:11px}.form .button{width:100%}.connection{border-top:1px solid var(--line);color:var(--ink-soft);font-size:9px;padding:9px 12px}.readonly{background:#fff3d8;border-bottom:1px solid #e6cf91;color:#76580a;font-size:10px;padding:9px 12px}.toast{background:#19211e;bottom:16px;color:#fff;font-size:11px;left:50%;opacity:0;padding:9px 12px;pointer-events:none;position:fixed;transform:translate(-50%,10px);transition:opacity .15s ease,transform .15s ease;z-index:30}.toast.show{opacity:1;transform:translate(-50%,0)}@media(max-width:1120px){.shell{grid-template-columns:230px minmax(0,1fr)}.conductor{border-left:0;border-top:1px solid var(--line);grid-column:1/-1;height:auto;position:static}.messages{max-height:320px}.terminal-stream{height:58vh}}@media(max-width:740px){.topbar{gap:9px;grid-template-columns:auto 1fr;padding:0 10px}.brand strong,.top-actions .button{display:none}.shell{display:block}.team,.conductor{height:auto;position:static}.team{border-right:0}.people{max-height:260px}.stage{padding:13px 10px}.stage-heading{align-items:flex-start;display:grid}.terminal{box-shadow:none}.terminal-stream{height:60vh;min-height:360px}.event{gap:5px;grid-template-columns:48px 88px minmax(0,1fr);padding:7px 8px}.activity-band{grid-template-columns:1fr 1fr 1fr}.command-grid{grid-template-columns:1fr}.top-actions{justify-self:end}.live-pill{display:none}}
875
- </style></head><body>
876
- <header class="topbar"><div class="brand"><span class="brand-mark">M</span><strong>multicodex</strong></div><div class="room-title"><strong id="room-title">loading room</strong><span id="room-meta">connecting to host-local room</span></div><div class="top-actions"><span class="live-pill"><i class="live-dot"></i><span id="top-status">connecting</span></span><button class="button" id="copy-invite">copy invite</button></div></header>
877
- <div class="shell"><aside class="team"><div class="panel-heading"><div><span class="eyebrow">team</span><h2 id="seat-count">0 active lanes</h2></div><button class="icon-button" id="quick-copy" title="copy invite command">+</button></div><div class="conductor-seat"><span class="avatar">AI</span><div class="seat-copy"><strong>conductor</strong><span>host-local coordination</span></div><i class="presence on"></i></div><div class="people" id="people"></div><form class="add-person" id="add-person"><span class="eyebrow">add a person</span><div class="add-person-row"><input name="name" placeholder="name" required maxlength="80"><select name="policy"><option value="suggest">suggest</option><option value="steer">steer</option><option value="observe">observe</option></select></div><button class="button primary">copy join command</button></form></aside>
878
- <main class="stage"><div class="stage-heading"><div><span class="eyebrow">live lane</span><h1 id="lane-title">waiting for a builder</h1><p id="lane-detail">copy an invite command to connect a normal local Codex TUI.</p></div><div class="lane-state"><i class="presence" id="lane-presence"></i><span class="status" id="lane-state">idle</span></div></div><section class="terminal"><div class="terminal-bar"><span class="traffic"><i></i><i></i><i></i></span><span class="terminal-title" id="terminal-title">multicodex://room/lane</span><span class="terminal-meta" id="terminal-meta">structured activity</span></div><div class="terminal-stream" id="terminal-stream"></div></section><section class="activity-band"><div class="metric" style="--role:var(--green)"><strong id="metric-active">0</strong><span>connected lanes</span></div><div class="metric" style="--role:var(--blue)"><strong id="metric-turns">0</strong><span>turns completed</span></div><div class="metric" style="--role:var(--yellow)"><strong id="metric-actions">0</strong><span>conductor actions</span></div></section></main>
1096
+ :root{font-family:"Avenir Next","Segoe UI",sans-serif;color:#19211e;background:#e9ede8;--paper:#fbfcf8;--soft:#eef2ec;--line:#d4dbd4;--ink-soft:#65716b;--red:#dd503c;--green:#16825d;--blue:#3267d6;--yellow:#d59b18;--terminal:#171d1a;--terminal-line:#2c3731;--terminal-copy:#d9e4dc;--role:#16825d}*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0;min-width:320px;min-height:100vh;overflow-x:hidden}button,input,select{font:inherit;letter-spacing:0}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.42}h1,h2,h3,p{margin:0}.mono,code,.eyebrow,.status,.event-time,.event-kind,.policy,.terminal-meta{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace}.topbar{align-items:center;background:var(--paper);border-bottom:1px solid var(--line);display:grid;gap:18px;grid-template-columns:auto minmax(180px,1fr) auto;padding:0 18px;min-height:64px;position:sticky;top:0;z-index:10}.brand{align-items:center;display:flex;gap:10px}.brand-mark{background:#19211e;color:#f9fbf7;display:grid;font-weight:850;height:34px;place-items:center;position:relative;width:34px}.brand-mark:after{background:var(--red);bottom:-3px;content:"";height:9px;position:absolute;right:-3px;width:9px}.brand strong{font-size:17px}.room-title{min-width:0}.room-title strong,.room-title span{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.room-title strong{font-size:13px}.room-title span{color:var(--ink-soft);font-size:10px;margin-top:2px}.top-actions{align-items:center;display:flex;gap:7px}.button,.icon-button{align-items:center;border:1px solid var(--line);border-radius:4px;display:inline-flex;justify-content:center}.button{background:var(--paper);font-size:12px;font-weight:700;gap:6px;min-height:34px;padding:0 11px}.button.primary{background:#19211e;border-color:#19211e;color:#f9fbf7}.button.danger{background:#fff2ef;border-color:#efb8af;color:#9f3020}.button:hover,.icon-button:hover{border-color:#96a39b}.icon-button{background:var(--paper);color:#3d4943;font-size:17px;height:32px;padding:0;width:32px}.live-pill{align-items:center;color:var(--ink-soft);display:flex;font-size:10px;gap:7px;white-space:nowrap}.live-dot{background:var(--green);border-radius:50%;box-shadow:0 0 0 4px rgba(22,130,93,.1);height:7px;width:7px}.shell{display:grid;grid-template-columns:minmax(225px,260px) minmax(440px,1fr) minmax(300px,365px);margin:0 auto;max-width:1900px;min-height:calc(100vh - 64px)}.team,.conductor{background:var(--paper);height:calc(100vh - 64px);position:sticky;top:64px}.team{border-right:1px solid var(--line);display:flex;flex-direction:column}.conductor{border-left:1px solid var(--line);display:grid;grid-template-rows:auto minmax(160px,1fr) auto auto;min-width:0}.panel-heading{align-items:center;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;min-height:66px;padding:12px 14px}.eyebrow{color:var(--ink-soft);display:block;font-size:9px;font-weight:700;text-transform:uppercase}.panel-heading h2{font-size:15px;margin-top:3px}.conductor-seat{align-items:center;background:#e6eee8;border-bottom:1px solid var(--line);display:grid;gap:9px;grid-template-columns:auto 1fr auto;padding:11px 14px}.avatar{align-items:center;background:var(--role);border-radius:50%;color:#fff;display:flex;flex:0 0 auto;font-size:10px;font-weight:800;height:31px;justify-content:center;width:31px}.conductor-seat .avatar{background:#19211e}.seat-copy{display:grid;min-width:0}.person .seat-copy{background:transparent;border:0;padding:0;text-align:left}.seat-copy strong{font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.seat-copy span{color:var(--ink-soft);font-size:9px;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.presence{background:#a5aea9;border-radius:50%;height:7px;width:7px}.presence.on{background:var(--green);box-shadow:0 0 0 3px rgba(22,130,93,.1)}.people{flex:1;overflow:auto}.person{align-items:center;background:transparent;border:0;border-bottom:1px solid var(--line);border-left:3px solid var(--role);display:grid;gap:8px;grid-template-columns:auto minmax(0,1fr) auto;padding:10px 9px;text-align:left;width:100%}.person:hover,.person.selected{background:#eef3ee}.person.pending{background:#f5f7f3;border-left-style:dashed}.person.pending .avatar{background:#78877e}.person-actions{display:flex;gap:3px}.person-actions .icon-button{font-size:13px;height:25px;width:25px}.policy{background:#e4ebe5;color:#526059;display:inline-block;font-size:8px;margin-top:5px;padding:2px 4px;width:max-content}.team-empty{background:transparent;border:0;color:inherit;display:grid;gap:4px;padding:18px 14px;text-align:left;width:100%}.team-empty strong{font-size:11px}.team-empty span{color:var(--ink-soft);font-size:9px;line-height:1.45}.add-person{border-top:1px solid var(--line);display:grid;gap:7px;padding:11px}.invite-heading{align-items:center;display:flex;justify-content:space-between}.invite-feedback{color:var(--green);font-size:8px}.add-person-row{align-items:stretch;display:grid;gap:6px;grid-template-columns:88px minmax(0,1fr)}.mirror-option{align-items:center;background:#eef2ec;border:1px solid var(--line);border-radius:4px;color:#526059;display:flex;font-size:9px;gap:6px;min-width:0;padding:0 7px}.mirror-option input{accent-color:var(--green);height:14px;margin:0;padding:0;width:14px}.field,input,select{background:#fff;border:1px solid var(--line);border-radius:4px;color:inherit;min-width:0;outline:none}.field,input,select{height:36px;padding:0 9px}input:focus,select:focus{border-color:var(--blue);box-shadow:0 0 0 2px rgba(50,103,214,.12)}.stage{min-width:0;padding:18px}.stage-heading{align-items:flex-start;display:flex;gap:16px;justify-content:space-between;margin-bottom:14px}.stage-heading h1{font-size:clamp(19px,2.1vw,29px);line-height:1.05;margin-top:5px}.stage-heading p{color:var(--ink-soft);font-size:10px;margin-top:7px}.lane-state{align-items:center;background:var(--paper);border:1px solid var(--line);display:flex;gap:8px;padding:7px 9px}.lane-state .presence{background:var(--yellow)}.lane-state .presence.on{background:var(--green)}.terminal{background:var(--terminal);border:1px solid #111613;box-shadow:9px 9px 0 rgba(25,33,30,.1);color:var(--terminal-copy);min-height:470px;overflow:hidden}.terminal-bar{align-items:center;background:#222b26;border-bottom:1px solid var(--terminal-line);display:grid;gap:10px;grid-template-columns:auto minmax(0,1fr) auto auto;padding:8px 10px}.traffic{display:flex;gap:5px}.traffic i{background:#617068;border-radius:50%;height:7px;width:7px}.traffic i:first-child{background:var(--red)}.traffic i:nth-child(2){background:var(--yellow)}.traffic i:last-child{background:var(--green)}.terminal-title{color:#aebdb3;font-size:10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.terminal-meta{color:#819188;font-size:8px}.terminal-modes{background:#151b18;border:1px solid #354239;display:flex;gap:2px;padding:2px}.mode-button{background:transparent;border:0;color:#819188;font-size:8px;font-weight:800;height:22px;padding:0 7px;text-transform:uppercase}.mode-button.active{background:#344139;color:#eff6f0}.terminal-view{display:none;height:min(66vh,680px);min-height:420px}.terminal-view.active{display:block}.terminal-live{contain:layout paint;max-width:100%;overflow:auto;padding:7px}.ghostty-terminal{height:100%;max-width:100%;min-height:406px;min-width:0;outline:none;width:100%}.ghostty-terminal canvas{display:block}.terminal-stream{overflow:auto;padding:8px 0 28px}.terminal-empty{align-items:center;color:#78877e;display:flex;font-size:11px;justify-content:center;min-height:390px;padding:35px;text-align:center}.event{border-bottom:1px solid rgba(255,255,255,.035);display:grid;gap:10px;grid-template-columns:62px 116px minmax(0,1fr);padding:8px 12px}.event:hover{background:rgba(255,255,255,.025)}.event-time{color:#738179;font-size:8px}.event-kind{color:#84a490;font-size:8px;overflow-wrap:anywhere}.event-copy{color:#d3ded6;font-size:11px;line-height:1.5;overflow-wrap:anywhere;white-space:pre-wrap}.event.agent-message .event-copy{color:#f1f6f2}.event.agent-plan .event-kind{color:#dbb95d}.event.command-started .event-kind,.event.command-completed .event-kind{color:#77a8ee}.event.files-changed .event-kind{color:#e28d7f}.event.turn-started .event-kind,.event.turn-completed .event-kind{color:#71c99e}.event.command-result .event-kind{color:#d5a557}.cursor-line{align-items:center;color:#91a198;display:flex;font:10px "SFMono-Regular",Consolas,monospace;gap:8px;padding:12px}.cursor{animation:blink 1s steps(1,end) infinite;background:#72bd93;height:13px;width:7px}@keyframes blink{50%{opacity:0}}.activity-band{display:grid;gap:8px;grid-template-columns:repeat(3,minmax(0,1fr));margin-top:18px}.metric{border-top:3px solid var(--role);padding:9px 2px}.metric strong{display:block;font-size:16px}.metric span{color:var(--ink-soft);font-size:9px}.messages{overflow:auto;padding:8px 12px}.message{border-bottom:1px solid #e6eae5;padding:11px 3px}.message.conductor-message{background:#eaf1eb;border-left:3px solid var(--green);margin:5px -4px;padding:11px 8px}.message strong{font-size:9px;text-transform:uppercase}.message p{font-size:11px;line-height:1.45;margin-top:4px;white-space:pre-wrap}.message time{color:var(--ink-soft);font-size:8px;margin-left:5px}.form{border-top:1px solid var(--line);display:grid;gap:7px;padding:11px}.command-grid{display:grid;gap:6px;grid-template-columns:1fr 1fr}.form input,.form select{font-size:11px}.form .button{width:100%}.connection{border-top:1px solid var(--line);color:var(--ink-soft);font-size:9px;padding:9px 12px}.readonly{background:#fff3d8;border-bottom:1px solid #e6cf91;color:#76580a;font-size:10px;padding:9px 12px}.toast{background:#19211e;bottom:16px;color:#fff;font-size:11px;left:50%;opacity:0;padding:9px 12px;pointer-events:none;position:fixed;transform:translate(-50%,10px);transition:opacity .15s ease,transform .15s ease;z-index:30}.toast.show{opacity:1;transform:translate(-50%,0)}@media(max-width:1120px){.shell{grid-template-columns:230px minmax(0,1fr)}.conductor{border-left:0;border-top:1px solid var(--line);grid-column:1/-1;height:auto;position:static}.messages{max-height:320px}.terminal-view{height:58vh}}@media(max-width:740px){.topbar{gap:9px;grid-template-columns:auto 1fr;padding:0 10px}.brand strong,.top-actions .button{display:none}.shell{display:block}.team,.conductor{height:auto;position:static}.team{border-right:0}.people{max-height:300px}.stage{padding:13px 10px}.stage-heading{align-items:flex-start;display:grid}.terminal{box-shadow:none}.terminal-bar{grid-template-columns:auto minmax(0,1fr) auto}.terminal-meta{display:none}.terminal-view{height:60vh;min-height:360px}.ghostty-terminal{min-height:346px}.event{gap:5px;grid-template-columns:48px 88px minmax(0,1fr);padding:7px 8px}.activity-band{grid-template-columns:1fr 1fr 1fr}.command-grid{grid-template-columns:1fr}.top-actions{justify-self:end}.live-pill{display:none}}
1097
+ </style><script type="importmap">{"imports":{"ghostty-web":"${GHOSTTY_ASSET_PATHS.module}"}}</script></head><body>
1098
+ <header class="topbar"><div class="brand"><span class="brand-mark">M</span><strong>multicodex</strong></div><div class="room-title"><strong id="room-title">loading room</strong><span id="room-meta">connecting to host-local room</span></div><div class="top-actions"><span class="live-pill"><i class="live-dot"></i><span id="top-status">connecting</span></span><button class="button" id="copy-invite">invite teammate</button></div></header>
1099
+ <div class="shell"><aside class="team"><div class="panel-heading"><div><span class="eyebrow">team</span><h2 id="seat-count">0 active - 0 invited</h2></div><button class="icon-button" id="quick-copy" title="invite teammate">+</button></div><div class="conductor-seat"><span class="avatar">AI</span><div class="seat-copy"><strong>conductor</strong><span>host-local coordination</span></div><i class="presence on"></i></div><div class="people" id="people"></div><form class="add-person" id="add-person"><div class="invite-heading"><span class="eyebrow">invite teammate</span><span class="invite-feedback" id="invite-feedback"></span></div><input id="invite-name" name="name" placeholder="teammate name" required maxlength="80"><div class="add-person-row"><select name="policy"><option value="suggest">suggest</option><option value="steer">steer</option><option value="observe">observe</option></select><label class="mirror-option"><input name="terminalMirror" type="checkbox" checked><span>share live terminal</span></label></div><button class="button primary">create &amp; copy invite</button></form></aside>
1100
+ <main class="stage"><div class="stage-heading"><div><span class="eyebrow">live lane</span><h1 id="lane-title">waiting for a builder</h1><p id="lane-detail">copy an invite command to connect a normal local Codex TUI.</p></div><div class="lane-state"><i class="presence" id="lane-presence"></i><span class="status" id="lane-state">idle</span></div></div><section class="terminal"><div class="terminal-bar"><span class="traffic"><i></i><i></i><i></i></span><span class="terminal-title" id="terminal-title">multicodex://room/lane</span><span class="terminal-meta" id="terminal-meta">structured activity</span><span class="terminal-modes"><button class="mode-button active" id="mode-live" type="button">live</button><button class="mode-button" id="mode-activity" type="button">activity</button></span></div><div class="terminal-view terminal-live active" id="terminal-live"><div class="ghostty-terminal" id="ghostty-terminal"></div></div><div class="terminal-view terminal-stream" id="terminal-stream"></div></section><section class="activity-band"><div class="metric" style="--role:var(--green)"><strong id="metric-active">0</strong><span>connected lanes</span></div><div class="metric" style="--role:var(--blue)"><strong id="metric-turns">0</strong><span>turns completed</span></div><div class="metric" style="--role:var(--yellow)"><strong id="metric-actions">0</strong><span>conductor actions</span></div></section></main>
879
1101
  <aside class="conductor"><div class="panel-heading"><div><span class="eyebrow">conductor</span><h2>visible control</h2></div><span class="status" id="host-mode">host</span></div><div class="messages" id="messages"></div><form class="form" id="message-form"><input name="text" placeholder="message the conductor" required maxlength="2000"><button class="button primary">send to conductor</button></form><form class="form" id="command-form"><span class="eyebrow">coordinate selected lane</span><div class="command-grid"><select name="laneId" id="lane-select" required></select><select name="kind" id="kind-select"><option value="suggest">suggest</option><option value="request_status">request status</option><option value="start_followup">start follow-up</option><option value="steer_active_turn">steer active turn</option><option value="request_interrupt">interrupt active turn</option></select></div><input name="text" id="command-text" placeholder="what should the lane know or do?" required maxlength="2000"><button class="button">send visible action</button></form><div class="connection status" id="connection">connecting...</div></aside></div><div class="toast" id="toast"></div>
880
1102
  <script>
881
1103
  const esc=value=>String(value??'').replace(/[&<>"']/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[char]));
882
- const ui={roomTitle:document.querySelector('#room-title'),roomMeta:document.querySelector('#room-meta'),topStatus:document.querySelector('#top-status'),seatCount:document.querySelector('#seat-count'),people:document.querySelector('#people'),laneTitle:document.querySelector('#lane-title'),laneDetail:document.querySelector('#lane-detail'),lanePresence:document.querySelector('#lane-presence'),laneState:document.querySelector('#lane-state'),terminalTitle:document.querySelector('#terminal-title'),terminalMeta:document.querySelector('#terminal-meta'),terminalStream:document.querySelector('#terminal-stream'),messages:document.querySelector('#messages'),laneSelect:document.querySelector('#lane-select'),kindSelect:document.querySelector('#kind-select'),commandText:document.querySelector('#command-text'),connection:document.querySelector('#connection'),hostMode:document.querySelector('#host-mode'),metricActive:document.querySelector('#metric-active'),metricTurns:document.querySelector('#metric-turns'),metricActions:document.querySelector('#metric-actions'),toast:document.querySelector('#toast')};
1104
+ const ui={roomTitle:document.querySelector('#room-title'),roomMeta:document.querySelector('#room-meta'),topStatus:document.querySelector('#top-status'),seatCount:document.querySelector('#seat-count'),people:document.querySelector('#people'),inviteName:document.querySelector('#invite-name'),inviteFeedback:document.querySelector('#invite-feedback'),laneTitle:document.querySelector('#lane-title'),laneDetail:document.querySelector('#lane-detail'),lanePresence:document.querySelector('#lane-presence'),laneState:document.querySelector('#lane-state'),terminalTitle:document.querySelector('#terminal-title'),terminalMeta:document.querySelector('#terminal-meta'),terminalLive:document.querySelector('#terminal-live'),ghosttyTerminal:document.querySelector('#ghostty-terminal'),terminalStream:document.querySelector('#terminal-stream'),modeLive:document.querySelector('#mode-live'),modeActivity:document.querySelector('#mode-activity'),messages:document.querySelector('#messages'),laneSelect:document.querySelector('#lane-select'),kindSelect:document.querySelector('#kind-select'),commandText:document.querySelector('#command-text'),connection:document.querySelector('#connection'),hostMode:document.querySelector('#host-mode'),metricActive:document.querySelector('#metric-active'),metricTurns:document.querySelector('#metric-turns'),metricActions:document.querySelector('#metric-actions'),toast:document.querySelector('#toast')};
883
1105
  const roleColors=['#dd503c','#3267d6','#16825d','#d59b18','#835bb5'];
884
1106
  const fragment=new URLSearchParams(location.hash.replace(/^#/,''));
885
1107
  const hostToken=fragment.get('host')||sessionStorage.getItem('multicodex-host-token')||'';
@@ -888,34 +1110,45 @@ const viewerLaneId=fragment.get('lane')||sessionStorage.getItem('multicodex-lane
888
1110
  if(hostToken)sessionStorage.setItem('multicodex-host-token',hostToken);
889
1111
  if(laneToken)sessionStorage.setItem('multicodex-lane-token',laneToken);
890
1112
  if(viewerLaneId)sessionStorage.setItem('multicodex-lane-id',viewerLaneId);
891
- let snapshot=null,hostConfig=null,selectedLaneId='',refreshing=false;
1113
+ let snapshot=null,hostConfig=null,selectedLaneId='',refreshing=false,terminalMode='live',terminalSession=null,terminalGeneration=0,libterminalBrowserPromise=null;
892
1114
  async function api(path,options={}){const headers={...(options.body?{'content-type':'application/json'}:{}),...(options.host&&hostToken?{authorization:'Bearer '+hostToken}:{}),...(options.lane&&laneToken?{authorization:'Bearer '+laneToken}:{})};const response=await fetch(path,{method:options.method||'GET',headers,body:options.body?JSON.stringify(options.body):undefined});const payload=await response.json();if(!response.ok)throw new Error(payload.error||response.statusText);return payload}
893
1115
  function initials(name){return String(name||'?').split(/\\s+/).map(part=>part[0]).join('').slice(0,2).toUpperCase()}
894
1116
  function shellQuote(value){const quote=String.fromCharCode(39),double=String.fromCharCode(34);return quote+String(value).replaceAll(quote,quote+double+quote+double+quote)+quote}
895
1117
  function toast(message){ui.toast.textContent=message;ui.toast.classList.add('show');setTimeout(()=>ui.toast.classList.remove('show'),1700)}
896
1118
  async function copy(value,message='copied'){await navigator.clipboard.writeText(value);toast(message)}
897
1119
  function activeLanes(){return snapshot?snapshot.lanes.filter(lane=>!lane.removedAt):[]}
1120
+ function pendingInvites(){return snapshot?snapshot.invites.filter(invite=>!invite.claimedAt&&!invite.revokedAt):[]}
898
1121
  function selectedLane(){const lanes=activeLanes();return lanes.find(lane=>lane.id===selectedLaneId)||lanes[0]||snapshot?.lanes[0]||null}
899
1122
  function laneEvents(laneId){const source=(snapshot?.events||[]).filter(event=>event.laneId===laneId);const collapsed=[];for(const event of source){const previous=collapsed.at(-1);if(previous&&event.kind==='agent.message'&&previous.kind===event.kind){previous.summary+=event.summary;previous.at=event.at}else collapsed.push({...event})}return collapsed.slice(-100)}
900
1123
  function eventClass(kind){return kind.replaceAll('.','-')}
901
1124
  function eventLabel(kind){return kind.replace('agent.','ai.').replace('command.','cmd.').replace('turn.','turn.').replace('lane.','lane.').replace('files.changed','files')}
902
1125
  function time(at){return new Date(at).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'})}
903
- function renderPeople(){const lanes=snapshot?.lanes||[];const active=lanes.filter(lane=>!lane.removedAt);ui.seatCount.textContent=active.length+' active '+(active.length===1?'lane':'lanes');ui.people.innerHTML=lanes.length?lanes.map((lane,index)=>'<div class="person '+(lane.id===selectedLaneId?'selected ':'')+(lane.removedAt?'removed':'')+'" style="--role:'+roleColors[index%roleColors.length]+'" data-lane="'+esc(lane.id)+'"><span class="avatar">'+esc(initials(lane.displayName))+'</span><button class="seat-copy" data-select="'+esc(lane.id)+'"><strong>'+esc(lane.displayName)+'</strong><span>'+esc(lane.status)+'</span><i class="policy">'+esc(lane.removedAt?'removed':lane.policy)+'</i></button><span class="person-actions">'+(!lane.removedAt&&hostToken?'<button class="icon-button" data-remove="'+esc(lane.id)+'" title="remove '+esc(lane.displayName)+'">x</button>':'')+'</span></div>').join(''):'<div class="terminal-empty">add a person to connect the first local Codex lane.</div>';ui.people.querySelectorAll('[data-select]').forEach(button=>button.onclick=()=>{selectedLaneId=button.dataset.select;render()});ui.people.querySelectorAll('[data-remove]').forEach(button=>button.onclick=()=>removeLane(button.dataset.remove))}
904
- function renderTerminal(){const lane=selectedLane();if(!lane){ui.laneTitle.textContent='waiting for a builder';ui.laneDetail.textContent='copy an invite command to connect a normal local Codex TUI.';ui.laneState.textContent='idle';ui.lanePresence.className='presence';ui.terminalStream.innerHTML='<div class="terminal-empty">no lane selected<br>the real Codex terminal stays on each participant machine.</div>';return}selectedLaneId=lane.id;ui.laneTitle.textContent=lane.displayName;ui.laneDetail.textContent=lane.repo+' \xB7 '+lane.policy+' policy \xB7 '+(lane.threadId?'thread '+lane.threadId:'thread pending');ui.laneState.textContent=lane.removedAt?'removed':lane.currentTurnId?'active turn':lane.connected?'connected':'disconnected';ui.lanePresence.className='presence '+(lane.connected&&!lane.removedAt?'on':'');ui.terminalTitle.textContent='multicodex://'+snapshot.id+'/'+lane.displayName.toLowerCase().replaceAll(/[^a-z0-9]+/g,'-');ui.terminalMeta.textContent=lane.policy+' \xB7 '+(lane.threadId?'attached':'pending');const events=laneEvents(lane.id);ui.terminalStream.innerHTML=(events.length?events.map(event=>'<div class="event '+eventClass(event.kind)+'"><time class="event-time">'+time(event.at)+'</time><code class="event-kind">'+esc(eventLabel(event.kind))+'</code><div class="event-copy">'+esc(event.summary)+'</div></div>').join(''):'<div class="terminal-empty">lane connected; waiting for meaningful Codex activity.</div>')+(lane.currentTurnId?'<div class="cursor-line"><span>&gt;</span><i class="cursor"></i><span>Codex turn in progress</span></div>':'');ui.terminalStream.scrollTop=ui.terminalStream.scrollHeight}
1126
+ function terminalToken(){return hostToken||laneToken}
1127
+ function stopLiveTerminal(){terminalGeneration+=1;terminalSession?.controller?.abort();try{terminalSession?.terminal?.dispose?.()}catch{}terminalSession=null;ui.ghosttyTerminal.innerHTML=''}
1128
+ function terminalEmpty(message){ui.ghosttyTerminal.innerHTML='<div class="terminal-empty">'+esc(message)+'</div>'}
1129
+ async function libterminalBrowser(){libterminalBrowserPromise||=import('/vendor/libterminal/browser.js');return libterminalBrowserPromise}
1130
+ async function connectLiveTerminal(lane){if(terminalSession?.laneId===lane.id)return;stopLiveTerminal();const generation=terminalGeneration;const controller=new AbortController();terminalSession={laneId:lane.id,controller,terminal:null};terminalEmpty('connecting read-only terminal mirror...');try{const module=await libterminalBrowser();if(generation!==terminalGeneration)return;ui.ghosttyTerminal.innerHTML='';const columns=lane.terminalColumns||120,rows=lane.terminalRows||34;const terminal=await module.createGhosttyTerminal({parent:ui.ghosttyTerminal,runtimeOptions:{wasmUrl:'${GHOSTTY_ASSET_PATHS.wasm}'},readOnly:true,autoFit:false,size:{columns,rows},signal:controller.signal,terminalOptions:{fontSize:13,theme:{background:'#171d1a',foreground:'#d9e4dc',cursor:'#72bd93',selectionBackground:'#435047',black:'#171d1a',red:'#ed7868',green:'#72bd93',yellow:'#ddb85c',blue:'#77a8ee',magenta:'#b89bd6',cyan:'#73b7ad',white:'#d9e4dc',brightBlack:'#78877e',brightRed:'#f29b8f',brightGreen:'#9bd2ae',brightYellow:'#ead18d',brightBlue:'#a1c2ef',brightMagenta:'#ccb8df',brightCyan:'#9dd0c8',brightWhite:'#f1f6f2'}}});terminalSession={laneId:lane.id,controller,terminal,columns,rows};const response=await fetch('/api/lanes/'+encodeURIComponent(lane.id)+'/terminal',{headers:{authorization:'Bearer '+terminalToken()},signal:controller.signal});if(!response.ok)throw new Error('mirror unavailable ('+response.status+')');if(!response.body)throw new Error('mirror stream unavailable');ui.terminalMeta.textContent='live mirror - read-only';await terminal.attach(response.body,controller.signal);if(generation===terminalGeneration)ui.terminalMeta.textContent='live mirror ended'}catch(error){if(generation!==terminalGeneration||error.name==='AbortError')return;terminalEmpty(error.message||'live terminal unavailable');ui.terminalMeta.textContent='structured activity available'}}
1131
+ function syncTerminalMode(lane){const canLive=Boolean(lane?.terminalMirror&&(hostToken||laneToken));const live=terminalMode==='live'&&canLive;ui.modeLive.disabled=!canLive;ui.modeLive.classList.toggle('active',live);ui.modeActivity.classList.toggle('active',!live);ui.terminalLive.classList.toggle('active',live);ui.terminalStream.classList.toggle('active',!live);if(live){const columns=lane.terminalColumns||120,rows=lane.terminalRows||34;if(terminalSession?.terminal&&(terminalSession.columns!==columns||terminalSession.rows!==rows)){terminalSession.terminal.resize({columns,rows});terminalSession.columns=columns;terminalSession.rows=rows}void connectLiveTerminal(lane)}else stopLiveTerminal()}
1132
+ function renderPeople(){const active=activeLanes(),pending=pendingInvites();ui.seatCount.textContent=active.length+' active - '+pending.length+' invited';const people=active.map((lane,index)=>'<div class="person '+(lane.id===selectedLaneId?'selected ':'')+'" style="--role:'+roleColors[index%roleColors.length]+'"><span class="avatar">'+esc(initials(lane.displayName))+'</span><button class="seat-copy" data-select="'+esc(lane.id)+'"><strong>'+esc(lane.displayName)+'</strong><span>'+esc(lane.connected?lane.status:'offline - '+lane.status)+'</span><i class="policy">'+esc(lane.policy)+'</i></button><span class="person-actions">'+(hostToken?'<button class="icon-button" data-remove="'+esc(lane.id)+'" title="remove '+esc(lane.displayName)+'">&times;</button>':'')+'</span></div>').join('');const invites=pending.map(invite=>'<div class="person pending"><span class="avatar">+</span><div class="seat-copy"><strong>'+esc(invite.displayName)+'</strong><span>invite ready - waiting to join</span><i class="policy">'+esc(invite.policy)+(invite.terminalMirror?' - terminal':'')+'</i></div><span class="person-actions">'+(hostToken?'<button class="icon-button" data-copy-invite="'+esc(invite.id)+'" title="copy invite for '+esc(invite.displayName)+'">&#x29c9;</button><button class="icon-button" data-revoke-invite="'+esc(invite.id)+'" title="revoke invite for '+esc(invite.displayName)+'">&times;</button>':'')+'</span></div>').join('');ui.people.innerHTML=people+invites||(hostToken?'<button class="team-empty" data-focus-invite><strong>invite the first teammate</strong><span>create a named command they can run in their own repo</span></button>':'<div class="team-empty"><strong>waiting for teammates</strong><span>the host has not invited anyone yet</span></div>');ui.people.querySelectorAll('[data-select]').forEach(button=>button.onclick=()=>{selectedLaneId=button.dataset.select;render()});ui.people.querySelectorAll('[data-remove]').forEach(button=>button.onclick=()=>removeLane(button.dataset.remove));ui.people.querySelectorAll('[data-copy-invite]').forEach(button=>button.onclick=()=>copy(inviteCommand(button.dataset.copyInvite),'invite copied'));ui.people.querySelectorAll('[data-revoke-invite]').forEach(button=>button.onclick=()=>revokeInvite(button.dataset.revokeInvite));ui.people.querySelector('[data-focus-invite]')?.addEventListener('click',focusInvite)}
1133
+ function renderTerminal(){const lane=selectedLane();if(!lane){ui.laneTitle.textContent='waiting for a builder';ui.laneDetail.textContent='copy an invite command to connect a normal local Codex TUI.';ui.laneState.textContent='idle';ui.lanePresence.className='presence';ui.terminalMeta.textContent='structured activity';ui.terminalStream.innerHTML='<div class="terminal-empty">no lane selected<br>the real Codex terminal stays on each participant machine.</div>';syncTerminalMode(null);return}selectedLaneId=lane.id;ui.laneTitle.textContent=lane.displayName;ui.laneDetail.textContent=lane.repo+' - '+lane.policy+' policy - '+(lane.threadId?'thread '+lane.threadId:'thread pending')+(lane.terminalMirror?' - terminal mirrored':'');ui.laneState.textContent=lane.removedAt?'removed':lane.currentTurnId?'active turn':lane.connected?'connected':'disconnected';ui.lanePresence.className='presence '+(lane.connected&&!lane.removedAt?'on':'');ui.terminalTitle.textContent='multicodex://'+snapshot.id+'/'+lane.displayName.toLowerCase().replaceAll(/[^a-z0-9]+/g,'-');ui.terminalMeta.textContent=lane.terminalMirror?'live mirror - read-only':lane.policy+' - structured activity';const events=laneEvents(lane.id);ui.terminalStream.innerHTML=(events.length?events.map(event=>'<div class="event '+eventClass(event.kind)+'"><time class="event-time">'+time(event.at)+'</time><code class="event-kind">'+esc(eventLabel(event.kind))+'</code><div class="event-copy">'+esc(event.summary)+'</div></div>').join(''):'<div class="terminal-empty">lane connected; waiting for meaningful Codex activity.</div>')+(lane.currentTurnId?'<div class="cursor-line"><span>&gt;</span><i class="cursor"></i><span>Codex turn in progress</span></div>':'');ui.terminalStream.scrollTop=ui.terminalStream.scrollHeight;syncTerminalMode(lane)}
905
1134
  function renderMessages(){const messages=snapshot?.conductorMessages||[];ui.messages.innerHTML=messages.length?messages.slice(-80).map(message=>'<div class="message '+(message.author==='conductor'?'conductor-message':'')+'"><strong>'+esc(message.authorName||message.author)+'</strong><time>'+time(message.at)+'</time><p>'+esc(message.body)+'</p></div>').join(''):'<div class="terminal-empty">message the conductor to begin visible coordination.</div>';ui.messages.scrollTop=ui.messages.scrollHeight}
906
- function renderControls(){const lanes=activeLanes();const previous=ui.laneSelect.value||selectedLaneId;ui.laneSelect.innerHTML=lanes.map(lane=>'<option value="'+esc(lane.id)+'">'+esc(lane.displayName)+'</option>').join('');ui.laneSelect.value=lanes.some(lane=>lane.id===previous)?previous:(lanes[0]?.id||'');const hostLocked=!hostToken;document.querySelectorAll('#add-person input,#add-person select,#add-person button,#command-form input,#command-form select,#command-form button,#copy-invite,#quick-copy').forEach(element=>element.disabled=hostLocked);document.querySelectorAll('#message-form input,#message-form button').forEach(element=>element.disabled=!hostToken&&!laneToken);const mode=hostToken?'host':laneToken?'participant':'observer';ui.hostMode.textContent=mode;ui.connection.textContent=(mode==='observer'?'read-only \xB7 ':'live \xB7 ')+new Date().toLocaleTimeString();ui.topStatus.textContent=mode==='host'?'host control':mode+' view'}
1135
+ function renderControls(){const lanes=activeLanes();const previous=ui.laneSelect.value||selectedLaneId;ui.laneSelect.innerHTML=lanes.map(lane=>'<option value="'+esc(lane.id)+'">'+esc(lane.displayName)+'</option>').join('');ui.laneSelect.value=lanes.some(lane=>lane.id===previous)?previous:(lanes[0]?.id||'');const hostLocked=!hostToken;document.querySelector('#add-person').hidden=hostLocked;document.querySelector('#command-form').hidden=hostLocked;document.querySelector('#copy-invite').hidden=hostLocked;document.querySelector('#quick-copy').hidden=hostLocked;document.querySelectorAll('#message-form input,#message-form button').forEach(element=>element.disabled=!hostToken&&!laneToken);const mode=hostToken?'host':laneToken?'participant':'observer';ui.hostMode.textContent=mode;ui.connection.textContent=(mode==='observer'?'read-only - ':'live - ')+new Date().toLocaleTimeString();ui.topStatus.textContent=mode==='host'?'host control':mode+' team view'}
907
1136
  function renderMetrics(){const events=snapshot?.events||[];ui.metricActive.textContent=String(activeLanes().filter(lane=>lane.connected).length);ui.metricTurns.textContent=String(events.filter(event=>event.kind==='turn.completed').length);ui.metricActions.textContent=String(events.filter(event=>event.kind==='command.result').length)}
908
- function render(){if(!snapshot)return;ui.roomTitle.textContent=snapshot.title;ui.roomMeta.textContent=snapshot.repo+' \xB7 '+snapshot.id;renderPeople();renderTerminal();renderMessages();renderControls();renderMetrics()}
909
- async function refresh(){if(refreshing)return;refreshing=true;try{snapshot=await api('/api/snapshot');if(hostToken&&!hostConfig)hostConfig=await api('/api/host/config',{host:true});if(!selectedLaneId)selectedLaneId=activeLanes()[0]?.id||'';render()}catch(error){ui.connection.textContent=error.message;ui.topStatus.textContent='offline'}finally{refreshing=false}}
1137
+ function render(){if(!snapshot)return;ui.roomTitle.textContent=snapshot.title;ui.roomMeta.textContent=snapshot.repo+' - '+snapshot.id;renderPeople();renderTerminal();renderMessages();renderControls();renderMetrics()}
1138
+ async function refresh(){if(refreshing)return;refreshing=true;try{snapshot=await api('/api/snapshot');if(hostToken)hostConfig=await api('/api/host/config',{host:true});if(!selectedLaneId)selectedLaneId=activeLanes()[0]?.id||'';render()}catch(error){ui.connection.textContent=error.message;ui.topStatus.textContent='offline'}finally{refreshing=false}}
910
1139
  async function removeLane(laneId){const lane=snapshot.lanes.find(candidate=>candidate.id===laneId);if(!lane||!confirm('Remove '+lane.displayName+' and revoke this lane?'))return;await api('/api/lanes/'+encodeURIComponent(laneId),{method:'DELETE',host:true});toast(lane.displayName+' removed');await refresh()}
911
- function joinCommand(name='Builder',policy='suggest'){if(!hostConfig)return'';return hostConfig.joinCommand+' --name '+shellQuote(name)+' --policy '+policy}
912
- document.querySelector('#copy-invite').onclick=()=>copy(joinCommand(),'invite command copied');
913
- document.querySelector('#quick-copy').onclick=()=>copy(joinCommand(),'invite command copied');
914
- document.querySelector('#add-person').onsubmit=async event=>{event.preventDefault();const data=new FormData(event.currentTarget);await copy(joinCommand(String(data.get('name')),String(data.get('policy'))),'join command copied');event.currentTarget.querySelector('input').select()};
1140
+ function focusInvite(){ui.inviteName.focus();ui.inviteName.select()}
1141
+ function inviteCommand(inviteId){return hostConfig?.invites?.find(invite=>invite.id===inviteId)?.joinCommand||''}
1142
+ async function revokeInvite(inviteId){const invite=snapshot.invites.find(candidate=>candidate.id===inviteId);if(!invite||!confirm('Revoke the invite for '+invite.displayName+'?'))return;await api('/api/invites/'+encodeURIComponent(inviteId),{method:'DELETE',host:true});toast(invite.displayName+' invite revoked');await refresh()}
1143
+ document.querySelector('#copy-invite').onclick=focusInvite;
1144
+ document.querySelector('#quick-copy').onclick=focusInvite;
1145
+ document.querySelector('#add-person').onsubmit=async event=>{event.preventDefault();const data=new FormData(event.currentTarget);const name=String(data.get('name'));const created=await api('/api/invites',{method:'POST',host:true,body:{displayName:name,policy:data.get('policy'),terminalMirror:data.get('terminalMirror')==='on'}});await copy(created.joinCommand,'invite copied for '+name);ui.inviteFeedback.textContent=name+' invited';ui.inviteName.value='';await refresh();focusInvite()};
915
1146
  document.querySelector('#message-form').onsubmit=async event=>{event.preventDefault();const data=new FormData(event.currentTarget);const path=hostToken?'/api/conductor/message':'/api/lanes/'+encodeURIComponent(viewerLaneId)+'/message';const headers=hostToken?{method:'POST',host:true,body:{text:data.get('text')}}:{method:'POST',body:{text:data.get('text')}};if(!hostToken)headers.lane=true;await api(path,headers);event.currentTarget.reset();toast('sent to room and conductor');await refresh()};
916
1147
  document.querySelector('#command-form').onsubmit=async event=>{event.preventDefault();const data=new FormData(event.currentTarget);await api('/api/conductor/command',{method:'POST',host:true,body:{laneId:data.get('laneId'),kind:data.get('kind'),text:data.get('text')}});ui.commandText.value='';toast('visible action queued');await refresh()};
917
1148
  ui.laneSelect.onchange=()=>{selectedLaneId=ui.laneSelect.value;render()};
918
1149
  ui.kindSelect.onchange=()=>{ui.commandText.placeholder={suggest:'what should the lane know?',request_status:'what status do you need?',start_followup:'what should Codex do next?',steer_active_turn:'how should the active turn change?',request_interrupt:'why should the active turn stop?'}[ui.kindSelect.value]||'visible action'};
1150
+ ui.modeLive.onclick=()=>{terminalMode='live';syncTerminalMode(selectedLane())};
1151
+ ui.modeActivity.onclick=()=>{terminalMode='activity';syncTerminalMode(selectedLane())};
919
1152
  setInterval(refresh,900);refresh();
920
1153
  </script></body></html>`;
921
1154
  }
@@ -930,7 +1163,7 @@ var LocalRoomStore = class _LocalRoomStore {
930
1163
  this.state = state;
931
1164
  }
932
1165
  static async create(input) {
933
- await fs3.mkdir(input.stateDir, { recursive: true, mode: 448 });
1166
+ await fs4.mkdir(input.stateDir, { recursive: true, mode: 448 });
934
1167
  const statePath = path5.join(input.stateDir, "room.json");
935
1168
  const state = {
936
1169
  version: protocolVersion,
@@ -938,6 +1171,7 @@ var LocalRoomStore = class _LocalRoomStore {
938
1171
  title: input.title,
939
1172
  repo: input.repo,
940
1173
  createdAt: Date.now(),
1174
+ invites: [],
941
1175
  lanes: [],
942
1176
  events: [],
943
1177
  conductorMessages: [],
@@ -952,10 +1186,16 @@ var LocalRoomStore = class _LocalRoomStore {
952
1186
  }
953
1187
  static async load(stateDir) {
954
1188
  const statePath = path5.join(stateDir, "room.json");
955
- const state = JSON.parse(await fs3.readFile(statePath, "utf8"));
1189
+ const state = JSON.parse(await fs4.readFile(statePath, "utf8"));
956
1190
  state.hostToken ||= crypto.randomUUID();
957
1191
  state.inviteToken ||= crypto.randomUUID();
958
- for (const lane of state.lanes) lane.removedAt ??= null;
1192
+ state.invites ??= [];
1193
+ for (const lane of state.lanes) {
1194
+ lane.removedAt ??= null;
1195
+ lane.terminalMirror ??= false;
1196
+ lane.terminalColumns ??= null;
1197
+ lane.terminalRows ??= null;
1198
+ }
959
1199
  const store = new _LocalRoomStore(statePath, state);
960
1200
  await store.save();
961
1201
  return store;
@@ -967,6 +1207,7 @@ var LocalRoomStore = class _LocalRoomStore {
967
1207
  title: this.state.title,
968
1208
  repo: publicRepoName(this.state.repo),
969
1209
  createdAt: this.state.createdAt,
1210
+ invites: this.state.invites.map(({ token: _token, ...invite }) => invite),
970
1211
  lanes: this.state.lanes.map(({ token: _token, ...lane }) => ({
971
1212
  ...lane,
972
1213
  repo: publicRepoName(lane.repo)
@@ -979,8 +1220,12 @@ var LocalRoomStore = class _LocalRoomStore {
979
1220
  const inviteUrl = capabilityUrl(publicUrl, "invite", this.state.inviteToken);
980
1221
  return {
981
1222
  inviteUrl,
982
- joinCommand: `npx --yes @vincentkoc/multicodex@latest join ${shellQuote(inviteUrl)} --repo .`,
983
- activeLanes: this.state.lanes.filter((lane) => !lane.removedAt).length
1223
+ joinCommand: joinCommand(inviteUrl),
1224
+ activeLanes: this.state.lanes.filter((lane) => !lane.removedAt).length,
1225
+ invites: this.state.invites.filter((invite) => !invite.claimedAt && !invite.revokedAt).map((invite) => ({
1226
+ id: invite.id,
1227
+ joinCommand: joinCommand(capabilityUrl(publicUrl, "invite", invite.token), invite)
1228
+ }))
984
1229
  };
985
1230
  }
986
1231
  hostUrl(publicUrl) {
@@ -995,6 +1240,55 @@ var LocalRoomStore = class _LocalRoomStore {
995
1240
  authorizeInvite(token) {
996
1241
  return token === this.state.inviteToken;
997
1242
  }
1243
+ async createInvite(input) {
1244
+ const invite = {
1245
+ id: `invite_${crypto.randomUUID()}`,
1246
+ token: crypto.randomUUID(),
1247
+ displayName: input.displayName,
1248
+ policy: input.policy,
1249
+ terminalMirror: input.terminalMirror !== false,
1250
+ createdAt: Date.now(),
1251
+ claimedAt: null,
1252
+ claimedLaneId: null,
1253
+ revokedAt: null
1254
+ };
1255
+ this.state.invites.push(invite);
1256
+ await this.save();
1257
+ const { token, ...publicInvite } = invite;
1258
+ return { invite: publicInvite, token };
1259
+ }
1260
+ async revokeInvite(inviteId) {
1261
+ const invite = this.state.invites.find((candidate) => candidate.id === inviteId);
1262
+ if (!invite) throw new RoomError(404, "invite not found");
1263
+ if (invite.claimedAt) throw new RoomError(409, "invite already claimed");
1264
+ if (invite.revokedAt) return;
1265
+ invite.revokedAt = Date.now();
1266
+ await this.save();
1267
+ }
1268
+ async joinFromInvite(inviteToken, input) {
1269
+ if (this.authorizeInvite(inviteToken)) return this.join(input);
1270
+ const invite = this.state.invites.find((candidate) => candidate.token === inviteToken);
1271
+ if (!invite) throw new RoomError(401, "valid room invite required");
1272
+ if (invite.revokedAt) throw new RoomError(410, "invite revoked by host");
1273
+ if (invite.claimedAt) throw new RoomError(410, "invite already claimed");
1274
+ invite.claimedAt = Date.now();
1275
+ await this.save();
1276
+ try {
1277
+ const joined = await this.join({
1278
+ displayName: invite.displayName,
1279
+ repo: input.repo,
1280
+ policy: invite.policy,
1281
+ terminalMirror: invite.terminalMirror && input.terminalMirror === true
1282
+ });
1283
+ invite.claimedLaneId = joined.lane.id;
1284
+ await this.save();
1285
+ return joined;
1286
+ } catch (cause) {
1287
+ invite.claimedAt = null;
1288
+ await this.save();
1289
+ throw cause;
1290
+ }
1291
+ }
998
1292
  async join(input) {
999
1293
  const now = Date.now();
1000
1294
  const lane = {
@@ -1003,6 +1297,9 @@ var LocalRoomStore = class _LocalRoomStore {
1003
1297
  displayName: input.displayName,
1004
1298
  repo: input.repo,
1005
1299
  policy: input.policy,
1300
+ terminalMirror: Boolean(input.terminalMirror),
1301
+ terminalColumns: null,
1302
+ terminalRows: null,
1006
1303
  connected: false,
1007
1304
  threadId: null,
1008
1305
  currentTurnId: null,
@@ -1027,6 +1324,7 @@ var LocalRoomStore = class _LocalRoomStore {
1027
1324
  lane.displayName = input.displayName;
1028
1325
  lane.repo = input.repo;
1029
1326
  lane.policy = input.policy;
1327
+ lane.terminalMirror = Boolean(input.terminalMirror);
1030
1328
  lane.updatedAt = Date.now();
1031
1329
  lane.status = "reconnecting";
1032
1330
  await this.save();
@@ -1038,6 +1336,34 @@ var LocalRoomStore = class _LocalRoomStore {
1038
1336
  (lane) => lane.id === laneId && lane.token === token && !lane.removedAt
1039
1337
  ) ?? null;
1040
1338
  }
1339
+ authorizeParticipant(token) {
1340
+ return this.state.lanes.some((lane) => lane.token === token && !lane.removedAt);
1341
+ }
1342
+ authorizeTerminalViewer(laneId, token) {
1343
+ const lane = this.state.lanes.find((candidate) => candidate.id === laneId);
1344
+ if (!lane || lane.removedAt || !lane.terminalMirror) return false;
1345
+ return token === this.state.hostToken || this.authorizeParticipant(token);
1346
+ }
1347
+ authorizeTerminalPublisher(laneId, token) {
1348
+ const lane = this.authorizeLane(laneId, token);
1349
+ return Boolean(lane?.terminalMirror);
1350
+ }
1351
+ async updateTerminalSize(laneId, token, columns, rows) {
1352
+ const lane = this.requireLane(laneId, token);
1353
+ if (!lane.terminalMirror) throw new RoomError(403, "terminal mirror unavailable");
1354
+ lane.terminalColumns = terminalDimension(columns, 20, 400);
1355
+ lane.terminalRows = terminalDimension(rows, 10, 200);
1356
+ lane.updatedAt = Date.now();
1357
+ await this.save();
1358
+ }
1359
+ async disableTerminalMirror(laneId, token) {
1360
+ const lane = this.requireLane(laneId, token);
1361
+ lane.terminalMirror = false;
1362
+ lane.terminalColumns = null;
1363
+ lane.terminalRows = null;
1364
+ lane.updatedAt = Date.now();
1365
+ await this.save();
1366
+ }
1041
1367
  async appendEvents(laneId, token, events) {
1042
1368
  const lane = this.requireLane(laneId, token);
1043
1369
  for (const event of events) {
@@ -1097,7 +1423,7 @@ var LocalRoomStore = class _LocalRoomStore {
1097
1423
  async removeLane(laneId) {
1098
1424
  const lane = this.state.lanes.find((candidate) => candidate.id === laneId);
1099
1425
  if (!lane) throw new RoomError(404, "lane not found");
1100
- if (lane.removedAt) return;
1426
+ if (lane.removedAt) return lane.token;
1101
1427
  const now = Date.now();
1102
1428
  lane.removedAt = now;
1103
1429
  lane.connected = false;
@@ -1105,6 +1431,7 @@ var LocalRoomStore = class _LocalRoomStore {
1105
1431
  lane.status = "removed by host";
1106
1432
  lane.updatedAt = now;
1107
1433
  await this.addConductorMessage("system", `${lane.displayName} removed by host`);
1434
+ return lane.token;
1108
1435
  }
1109
1436
  async addParticipantMessage(laneId, token, body) {
1110
1437
  const lane = this.requireLane(laneId, token);
@@ -1138,9 +1465,9 @@ var LocalRoomStore = class _LocalRoomStore {
1138
1465
  async save() {
1139
1466
  this.savePromise = this.savePromise.then(async () => {
1140
1467
  const temporary = `${this.statePath}.tmp`;
1141
- await fs3.writeFile(temporary, `${JSON.stringify(this.state, null, 2)}
1468
+ await fs4.writeFile(temporary, `${JSON.stringify(this.state, null, 2)}
1142
1469
  `, { mode: 384 });
1143
- await fs3.rename(temporary, this.statePath);
1470
+ await fs4.rename(temporary, this.statePath);
1144
1471
  });
1145
1472
  await this.savePromise;
1146
1473
  }
@@ -1152,10 +1479,19 @@ async function startLocalRoomServer(input) {
1152
1479
  }
1153
1480
  if (input.publicUrl) new URL(input.publicUrl);
1154
1481
  let publicUrl = "";
1482
+ const terminalHub = new TerminalMirrorHub();
1155
1483
  const server = http.createServer((request, response) => {
1156
- void handleRequest(input.store, input.handlers, publicUrl, request, response).catch((cause) => {
1484
+ void handleRequest(
1485
+ input.store,
1486
+ input.handlers,
1487
+ terminalHub,
1488
+ publicUrl,
1489
+ request,
1490
+ response
1491
+ ).catch((cause) => {
1157
1492
  const error = cause instanceof RoomError ? cause : new RoomError(500, errorMessage(cause));
1158
- sendJson(response, error.status, { error: error.message });
1493
+ if (!response.headersSent) sendJson(response, error.status, { error: error.message });
1494
+ else response.destroy();
1159
1495
  });
1160
1496
  });
1161
1497
  await new Promise((resolve, reject) => {
@@ -1170,18 +1506,26 @@ async function startLocalRoomServer(input) {
1170
1506
  hostUrl: input.store.hostUrl(publicUrl),
1171
1507
  inviteUrl: input.store.inviteUrl(publicUrl),
1172
1508
  close: async () => {
1509
+ terminalHub.closeAll();
1173
1510
  await new Promise(
1174
1511
  (resolve, reject) => server.close((cause) => cause ? reject(cause) : resolve())
1175
1512
  );
1176
1513
  }
1177
1514
  };
1178
1515
  }
1179
- async function handleRequest(store, handlers, publicUrl, request, response) {
1516
+ async function handleRequest(store, handlers, terminalHub, publicUrl, request, response) {
1180
1517
  const url = new URL(request.url ?? "/", "http://127.0.0.1");
1181
1518
  if (request.method === "GET" && url.pathname === "/") {
1182
1519
  sendText(response, 200, localRoomHtml(), "text/html; charset=utf-8");
1183
1520
  return;
1184
1521
  }
1522
+ if (request.method === "GET") {
1523
+ const asset = await readTerminalAsset(url.pathname);
1524
+ if (asset) {
1525
+ sendBinary(response, 200, asset.body, asset.contentType);
1526
+ return;
1527
+ }
1528
+ }
1185
1529
  if (request.method === "GET" && url.pathname === "/api/snapshot") {
1186
1530
  sendJson(response, 200, store.snapshot());
1187
1531
  return;
@@ -1191,19 +1535,37 @@ async function handleRequest(store, handlers, publicUrl, request, response) {
1191
1535
  sendJson(response, 200, store.hostConfig(publicUrl));
1192
1536
  return;
1193
1537
  }
1538
+ if (request.method === "POST" && url.pathname === "/api/invites") {
1539
+ requireHost(store, request);
1540
+ const body = await readJson(request);
1541
+ const policy = requiredPolicy(body.policy);
1542
+ const created = await store.createInvite({
1543
+ displayName: requiredString(body.displayName, "displayName"),
1544
+ policy,
1545
+ terminalMirror: body.terminalMirror !== false
1546
+ });
1547
+ const inviteUrl = capabilityUrl(publicUrl, "invite", created.token);
1548
+ sendJson(response, 201, {
1549
+ invite: created.invite,
1550
+ inviteUrl,
1551
+ joinCommand: joinCommand(inviteUrl, created.invite)
1552
+ });
1553
+ return;
1554
+ }
1555
+ const inviteMatch = url.pathname.match(/^\/api\/invites\/([^/]+)$/);
1556
+ if (request.method === "DELETE" && inviteMatch) {
1557
+ requireHost(store, request);
1558
+ await store.revokeInvite(decodeURIComponent(inviteMatch[1]));
1559
+ sendJson(response, 200, { revoked: true });
1560
+ return;
1561
+ }
1194
1562
  if (request.method === "POST" && url.pathname === "/api/join") {
1195
- if (!store.authorizeInvite(bearer(request))) {
1196
- throw new RoomError(401, "valid room invite required");
1197
- }
1198
1563
  const body = await readJson(request);
1199
- const policy = body.policy;
1200
- if (!["observe", "suggest", "steer"].includes(String(policy))) {
1201
- throw new RoomError(400, "policy must be observe, suggest, or steer");
1202
- }
1203
- const joined = await store.join({
1564
+ const joined = await store.joinFromInvite(bearer(request), {
1204
1565
  displayName: requiredString(body.displayName, "displayName"),
1205
1566
  repo: requiredString(body.repo, "repo"),
1206
- policy
1567
+ policy: requiredPolicy(body.policy),
1568
+ terminalMirror: body.terminalMirror === true
1207
1569
  });
1208
1570
  sendJson(response, 201, { room: store.snapshot(), ...joined });
1209
1571
  return;
@@ -1211,15 +1573,15 @@ async function handleRequest(store, handlers, publicUrl, request, response) {
1211
1573
  const resumeMatch = url.pathname.match(/^\/api\/lanes\/([^/]+)\/resume$/);
1212
1574
  if (request.method === "POST" && resumeMatch) {
1213
1575
  const body = await readJson(request);
1214
- const policy = body.policy;
1215
- if (!["observe", "suggest", "steer"].includes(String(policy))) {
1216
- throw new RoomError(400, "policy must be observe, suggest, or steer");
1217
- }
1218
- const resumed = await store.resumeLane(decodeURIComponent(resumeMatch[1]), bearer(request), {
1576
+ const laneId = decodeURIComponent(resumeMatch[1]);
1577
+ const terminalMirror = body.terminalMirror === true;
1578
+ const resumed = await store.resumeLane(laneId, bearer(request), {
1219
1579
  displayName: requiredString(body.displayName, "displayName"),
1220
1580
  repo: requiredString(body.repo, "repo"),
1221
- policy
1581
+ policy: requiredPolicy(body.policy),
1582
+ terminalMirror
1222
1583
  });
1584
+ if (!terminalMirror) terminalHub.closeLane(laneId);
1223
1585
  sendJson(response, 200, { room: store.snapshot(), ...resumed });
1224
1586
  return;
1225
1587
  }
@@ -1239,6 +1601,44 @@ async function handleRequest(store, handlers, publicUrl, request, response) {
1239
1601
  sendJson(response, 200, { commands: store.commandsAfter(laneId, bearer(request), after) });
1240
1602
  return;
1241
1603
  }
1604
+ const terminalMatch = url.pathname.match(/^\/api\/lanes\/([^/]+)\/terminal$/);
1605
+ if (terminalMatch) {
1606
+ const laneId = decodeURIComponent(terminalMatch[1]);
1607
+ const token = optionalBearer(request);
1608
+ if (request.method === "GET") {
1609
+ if (!store.authorizeTerminalViewer(laneId, token)) {
1610
+ throw new RoomError(403, "terminal mirror unavailable");
1611
+ }
1612
+ terminalHub.subscribe(laneId, token, response);
1613
+ return;
1614
+ }
1615
+ if (request.method === "POST") {
1616
+ if (!store.authorizeTerminalPublisher(laneId, token)) {
1617
+ throw new RoomError(403, "terminal mirror unavailable");
1618
+ }
1619
+ for await (const chunk of request) terminalHub.publish(laneId, Buffer.from(chunk));
1620
+ sendJson(response, 202, { accepted: true });
1621
+ return;
1622
+ }
1623
+ if (request.method === "DELETE") {
1624
+ await store.disableTerminalMirror(laneId, token);
1625
+ terminalHub.closeLane(laneId);
1626
+ sendJson(response, 200, { disabled: true });
1627
+ return;
1628
+ }
1629
+ }
1630
+ const terminalSizeMatch = url.pathname.match(/^\/api\/lanes\/([^/]+)\/terminal-size$/);
1631
+ if (request.method === "POST" && terminalSizeMatch) {
1632
+ const body = await readJson(request);
1633
+ await store.updateTerminalSize(
1634
+ decodeURIComponent(terminalSizeMatch[1]),
1635
+ bearer(request),
1636
+ Number(body.columns),
1637
+ Number(body.rows)
1638
+ );
1639
+ sendJson(response, 202, { accepted: true });
1640
+ return;
1641
+ }
1242
1642
  const laneMessageMatch = url.pathname.match(/^\/api\/lanes\/([^/]+)\/message$/);
1243
1643
  if (request.method === "POST" && laneMessageMatch) {
1244
1644
  const laneId = decodeURIComponent(laneMessageMatch[1]);
@@ -1265,7 +1665,10 @@ async function handleRequest(store, handlers, publicUrl, request, response) {
1265
1665
  const removeMatch = url.pathname.match(/^\/api\/lanes\/([^/]+)$/);
1266
1666
  if (request.method === "DELETE" && removeMatch) {
1267
1667
  requireHost(store, request);
1268
- await store.removeLane(decodeURIComponent(removeMatch[1]));
1668
+ const laneId = decodeURIComponent(removeMatch[1]);
1669
+ const removedToken = await store.removeLane(laneId);
1670
+ terminalHub.closeViewer(removedToken);
1671
+ terminalHub.closeLane(laneId);
1269
1672
  sendJson(response, 200, { removed: true });
1270
1673
  return;
1271
1674
  }
@@ -1341,6 +1744,10 @@ function bearer(request) {
1341
1744
  if (!value?.startsWith("Bearer ")) throw new RoomError(401, "lane capability required");
1342
1745
  return value.slice("Bearer ".length);
1343
1746
  }
1747
+ function optionalBearer(request) {
1748
+ const value = request.headers.authorization;
1749
+ return value?.startsWith("Bearer ") ? value.slice("Bearer ".length) : "";
1750
+ }
1344
1751
  function requireHost(store, request) {
1345
1752
  if (!store.authorizeHost(bearer(request))) throw new RoomError(401, "host capability required");
1346
1753
  }
@@ -1356,6 +1763,16 @@ function requiredCommandKind(value) {
1356
1763
  }
1357
1764
  return value;
1358
1765
  }
1766
+ function requiredPolicy(value) {
1767
+ if (!["observe", "suggest", "steer"].includes(String(value))) {
1768
+ throw new RoomError(400, "policy must be observe, suggest, or steer");
1769
+ }
1770
+ return value;
1771
+ }
1772
+ function terminalDimension(value, minimum, maximum) {
1773
+ if (!Number.isFinite(value)) throw new RoomError(400, "valid terminal dimensions required");
1774
+ return Math.min(maximum, Math.max(minimum, Math.trunc(value)));
1775
+ }
1359
1776
  function advertisedUrl(host2, port, configured) {
1360
1777
  if (configured) return new URL(configured).toString().replace(/\/$/, "");
1361
1778
  if (["0.0.0.0", "::"].includes(host2)) {
@@ -1372,9 +1789,23 @@ function capabilityUrl(base, kind, token) {
1372
1789
  function shellQuote(value) {
1373
1790
  return `'${value.replaceAll("'", `'"'"'`)}'`;
1374
1791
  }
1792
+ function joinCommand(inviteUrl, invite) {
1793
+ const command2 = `npx --yes @vincentkoc/multicodex@latest join ${shellQuote(inviteUrl)} --repo .`;
1794
+ if (!invite) return `${command2} --terminal-mirror`;
1795
+ return `${command2} --name ${shellQuote(invite.displayName)} --policy ${invite.policy}${invite.terminalMirror ? " --terminal-mirror" : ""}`;
1796
+ }
1375
1797
  function sendJson(response, status2, body) {
1376
1798
  sendText(response, status2, JSON.stringify(body), "application/json; charset=utf-8");
1377
1799
  }
1800
+ function sendBinary(response, status2, body, contentType) {
1801
+ response.writeHead(status2, {
1802
+ "content-type": contentType,
1803
+ "content-length": String(body.byteLength),
1804
+ "cache-control": "public, max-age=31536000, immutable",
1805
+ "x-content-type-options": "nosniff"
1806
+ });
1807
+ response.end(body);
1808
+ }
1378
1809
  function sendText(response, status2, body, contentType) {
1379
1810
  response.writeHead(status2, {
1380
1811
  "content-type": contentType,
@@ -1446,8 +1877,8 @@ async function host(args2) {
1446
1877
  "",
1447
1878
  "MultiCodex room ready",
1448
1879
  `control: ${server.hostUrl}`,
1449
- `invite: npx --yes @vincentkoc/multicodex@latest join ${shellQuote2(server.inviteUrl)} --repo . --name Builder --policy suggest`,
1450
- `dev join: pnpm multicodex join ${shellQuote2(server.inviteUrl)} --repo . --name Builder --policy suggest`,
1880
+ `invite: npx --yes @vincentkoc/multicodex@latest join ${shellQuote2(server.inviteUrl)} --repo . --name Builder --policy suggest --terminal-mirror`,
1881
+ `dev join: pnpm multicodex join ${shellQuote2(server.inviteUrl)} --repo . --name Builder --policy suggest --terminal-mirror`,
1451
1882
  "conductor: local ACPx / Codex",
1452
1883
  "runtime: no Crabfleet, Crabbox, server OpenAI key, or GitHub token",
1453
1884
  ""
@@ -1463,6 +1894,9 @@ async function join(args2) {
1463
1894
  if (!["observe", "suggest", "steer"].includes(policy)) {
1464
1895
  throw new Error("policy must be observe, suggest, or steer");
1465
1896
  }
1897
+ const noTui = Boolean(options["no-tui"]);
1898
+ const terminalMirror = Boolean(options["terminal-mirror"]);
1899
+ if (noTui && terminalMirror) throw new Error("--terminal-mirror requires the normal Codex TUI");
1466
1900
  const codexPath = await resolveUserCodexPath({ explicit: options.codex });
1467
1901
  if (!codexPath) {
1468
1902
  throw new Error(
@@ -1475,7 +1909,8 @@ async function join(args2) {
1475
1909
  displayName: options.name ?? process.env.USER ?? "Builder",
1476
1910
  policy,
1477
1911
  codexPath,
1478
- noTui: Boolean(options["no-tui"]),
1912
+ noTui,
1913
+ terminalMirror,
1479
1914
  prompt: options.prompt,
1480
1915
  fresh: Boolean(options.fresh),
1481
1916
  statePath: options.state ? path6.resolve(options.state) : void 0
@@ -1489,8 +1924,8 @@ async function doctor() {
1489
1924
  const [nodeMajor = 0, nodeMinor = 0] = process.versions.node.split(".").map(Number);
1490
1925
  checks.push([
1491
1926
  "Node",
1492
- nodeMajor > 22 || nodeMajor === 22 && nodeMinor >= 13,
1493
- `${process.version} (requires >=22.13.0)`
1927
+ nodeMajor > 24 || nodeMajor === 24 && nodeMinor >= 11 || nodeMajor === 22 && nodeMinor >= 18,
1928
+ `${process.version} (requires ^22.18.0 or >=24.11.0)`
1494
1929
  ]);
1495
1930
  const codexPath = await resolveUserCodexPath();
1496
1931
  if (!codexPath) {
@@ -1542,6 +1977,7 @@ Usage:
1542
1977
 
1543
1978
  Options:
1544
1979
  --no-tui connect the bridge without launching the normal Codex TUI
1980
+ --terminal-mirror share an ephemeral read-only TUI mirror with the room
1545
1981
  --prompt <text> start a builder turn after connecting
1546
1982
  --fresh create a new lane instead of resuming local lane state
1547
1983
  --state <path> override the builder lane state file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentkoc/multicodex",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "description": "A self-contained multiplayer control room for normal local Codex sessions",
6
6
  "homepage": "https://github.com/vincentkoc/multicodex#readme",
@@ -23,41 +23,39 @@
23
23
  "access": "public"
24
24
  },
25
25
  "scripts": {
26
- "alpha:host": "node --experimental-strip-types packages/cli/src/index.ts host --repo .",
27
- "build": "pnpm build:cli && pnpm smoke:cli && pnpm legacy:build",
26
+ "build": "pnpm build:cli && pnpm smoke:cli",
28
27
  "build:cli": "node scripts/build-cli.mjs",
29
28
  "check": "pnpm typecheck && pnpm test && pnpm lint && pnpm format",
30
- "db:local": "wrangler d1 migrations apply DB --local",
31
- "deploy": "pnpm build && wrangler d1 migrations apply DB --remote && wrangler deploy",
32
- "dev": "node scripts/dev.mjs",
33
29
  "format": "oxfmt --check .",
34
30
  "format:fix": "oxfmt .",
35
31
  "lint": "oxlint .",
36
- "legacy:build": "wrangler types && vite build && tsc --noEmit",
37
32
  "multicodex": "node --experimental-strip-types packages/cli/src/index.ts",
38
33
  "prepack": "pnpm build:cli",
39
34
  "smoke:cli": "node dist/cli.mjs --help",
40
35
  "test": "node --test --experimental-strip-types tests/*.test.ts",
41
- "typecheck": "wrangler types && tsc --noEmit"
36
+ "typecheck": "tsc --noEmit"
42
37
  },
43
38
  "dependencies": {
44
39
  "@agentclientprotocol/codex-acp": "0.0.46",
40
+ "@openclaw/libterminal": "0.1.0",
45
41
  "acpx": "0.10.0",
46
- "lucide-preact": "^0.468.0",
47
- "preact": "^10.29.2"
42
+ "node-pty": "1.1.0"
48
43
  },
49
44
  "devDependencies": {
50
- "@preact/preset-vite": "^2.10.5",
51
45
  "@types/node": "^24.0.0",
52
46
  "esbuild": "0.28.1",
53
47
  "oxfmt": "^0.54.0",
54
48
  "oxlint": "^1.69.0",
55
- "typescript": "^5.9.3",
56
- "vite": "^8.0.16",
57
- "wrangler": "^4.100.0"
49
+ "typescript": "^5.9.3"
58
50
  },
59
51
  "engines": {
60
- "node": ">=22.13.0"
61
- },
62
- "packageManager": "pnpm@10.23.0"
52
+ "node": "^22.18.0 || >=24.11.0"
53
+ },
54
+ "packageManager": "pnpm@10.23.0",
55
+ "pnpm": {
56
+ "onlyBuiltDependencies": [
57
+ "esbuild",
58
+ "node-pty"
59
+ ]
60
+ }
63
61
  }