@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.
- package/README.md +22 -27
- package/dist/cli.mjs +492 -56
- 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.
|
|
10
|
-
|
|
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 **
|
|
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
|
-
-
|
|
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
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
806
|
+
this.tuiChild = tui;
|
|
608
807
|
try {
|
|
609
|
-
await
|
|
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
|
|
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">
|
|
877
|
-
<div class="shell"><aside class="team"><div class="panel-heading"><div><span class="eyebrow">team</span><h2 id="seat-count">0 active
|
|
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 & 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
|
|
904
|
-
function
|
|
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)+'">×</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)+'">⧉</button><button class="icon-button" data-revoke-invite="'+esc(invite.id)+'" title="revoke invite for '+esc(invite.displayName)+'">×</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>></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.
|
|
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+'
|
|
909
|
-
async function refresh(){if(refreshing)return;refreshing=true;try{snapshot=await api('/api/snapshot');if(hostToken
|
|
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
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
document.querySelector('#
|
|
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
|
|
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
|
|
1189
|
+
const state = JSON.parse(await fs4.readFile(statePath, "utf8"));
|
|
956
1190
|
state.hostToken ||= crypto.randomUUID();
|
|
957
1191
|
state.inviteToken ||= crypto.randomUUID();
|
|
958
|
-
|
|
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:
|
|
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
|
|
1468
|
+
await fs4.writeFile(temporary, `${JSON.stringify(this.state, null, 2)}
|
|
1142
1469
|
`, { mode: 384 });
|
|
1143
|
-
await
|
|
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(
|
|
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
|
|
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
|
|
1215
|
-
|
|
1216
|
-
|
|
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
|
-
|
|
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
|
|
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 >
|
|
1493
|
-
`${process.version} (requires
|
|
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.
|
|
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
|
-
"
|
|
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": "
|
|
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
|
-
"
|
|
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": "
|
|
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
|
}
|