@vincentkoc/multicodex 0.1.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 +153 -0
  2. package/dist/cli.mjs +1560 -0
  3. package/package.json +63 -0
package/dist/cli.mjs ADDED
@@ -0,0 +1,1560 @@
1
+ #!/usr/bin/env node
2
+
3
+ // packages/cli/src/index.ts
4
+ import { execFile } from "node:child_process";
5
+ import path6 from "node:path";
6
+ import { promisify } from "node:util";
7
+
8
+ // packages/cli/src/builder.ts
9
+ import { spawn as spawn2 } from "node:child_process";
10
+ import path2 from "node:path";
11
+
12
+ // packages/protocol/src/index.ts
13
+ var protocolVersion = 1;
14
+ function requiredPolicyForCommand(kind) {
15
+ switch (kind) {
16
+ case "suggest":
17
+ case "request_status":
18
+ return "suggest";
19
+ case "start_followup":
20
+ case "steer_active_turn":
21
+ case "request_interrupt":
22
+ return "steer";
23
+ }
24
+ }
25
+ function policyAllows(policy, command2) {
26
+ const rank = { observe: 0, suggest: 1, steer: 2 };
27
+ return rank[policy] >= rank[requiredPolicyForCommand(command2)];
28
+ }
29
+ function createLaneEvent(input) {
30
+ return {
31
+ ...input,
32
+ version: protocolVersion,
33
+ id: input.id ?? crypto.randomUUID(),
34
+ at: input.at ?? Date.now()
35
+ };
36
+ }
37
+
38
+ // packages/cli/src/app-server.ts
39
+ import { spawn } from "node:child_process";
40
+ import net from "node:net";
41
+ var CodexAppServerClient = class {
42
+ endpoint;
43
+ socket = null;
44
+ nextRequestId = 1;
45
+ pending = /* @__PURE__ */ new Map();
46
+ notificationHandlers = /* @__PURE__ */ new Set();
47
+ constructor(endpoint) {
48
+ this.endpoint = endpoint;
49
+ }
50
+ async connect(timeoutMs = 15e3) {
51
+ const deadline = Date.now() + timeoutMs;
52
+ let lastError = new Error("app-server connection timed out");
53
+ while (Date.now() < deadline) {
54
+ try {
55
+ await this.open();
56
+ await this.request("initialize", {
57
+ clientInfo: { name: "multicodex-alpha", title: "MultiCodex Alpha", version: "0.1.0" },
58
+ capabilities: { experimentalApi: true }
59
+ });
60
+ this.notify("initialized", {});
61
+ return;
62
+ } catch (cause) {
63
+ lastError = cause instanceof Error ? cause : new Error(String(cause));
64
+ this.socket?.close();
65
+ this.socket = null;
66
+ await delay(150);
67
+ }
68
+ }
69
+ throw lastError;
70
+ }
71
+ onNotification(handler) {
72
+ this.notificationHandlers.add(handler);
73
+ return () => this.notificationHandlers.delete(handler);
74
+ }
75
+ async startThread(cwd) {
76
+ const result = await this.request("thread/start", {
77
+ cwd,
78
+ developerInstructions: "You are a builder lane in a MultiCodex room. Keep user-visible progress concise and continue to follow local approvals."
79
+ });
80
+ const threadId = result.thread?.id;
81
+ if (!threadId) throw new Error("Codex app-server did not return a thread id");
82
+ return threadId;
83
+ }
84
+ async resumeThread(threadId) {
85
+ await this.request("thread/resume", {
86
+ threadId,
87
+ excludeTurns: true
88
+ });
89
+ }
90
+ async startTurn(threadId, text2) {
91
+ const result = await this.request("turn/start", {
92
+ threadId,
93
+ input: [{ type: "text", text: text2, text_elements: [] }]
94
+ });
95
+ const turnId = result.turn?.id;
96
+ if (!turnId) throw new Error("Codex app-server did not return a turn id");
97
+ return turnId;
98
+ }
99
+ async steer(threadId, turnId, text2) {
100
+ await this.request("turn/steer", {
101
+ threadId,
102
+ expectedTurnId: turnId,
103
+ input: [{ type: "text", text: text2, text_elements: [] }]
104
+ });
105
+ }
106
+ async interrupt(threadId, turnId) {
107
+ await this.request("turn/interrupt", { threadId, turnId });
108
+ }
109
+ close() {
110
+ const socket = this.socket;
111
+ this.socket = null;
112
+ socket?.close();
113
+ for (const pending of this.pending.values()) {
114
+ pending.reject(new Error("Codex app-server connection closed"));
115
+ }
116
+ this.pending.clear();
117
+ }
118
+ async open() {
119
+ await new Promise((resolve, reject) => {
120
+ const socket = new WebSocket(this.endpoint);
121
+ const timer = setTimeout(() => {
122
+ socket.close();
123
+ reject(new Error("Codex app-server connection timed out"));
124
+ }, 2e3);
125
+ socket.addEventListener("open", () => {
126
+ clearTimeout(timer);
127
+ this.socket = socket;
128
+ resolve();
129
+ });
130
+ socket.addEventListener("error", () => {
131
+ clearTimeout(timer);
132
+ reject(new Error("Codex app-server is not ready"));
133
+ });
134
+ socket.addEventListener("message", (event) => this.receive(String(event.data)));
135
+ socket.addEventListener("close", () => {
136
+ if (this.socket === socket) this.close();
137
+ });
138
+ });
139
+ }
140
+ request(method, params) {
141
+ if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
142
+ return Promise.reject(new Error("Codex app-server is not connected"));
143
+ }
144
+ const id = this.nextRequestId++;
145
+ return new Promise((resolve, reject) => {
146
+ this.pending.set(id, { resolve, reject });
147
+ this.socket.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
148
+ });
149
+ }
150
+ notify(method, params) {
151
+ this.socket?.send(JSON.stringify({ jsonrpc: "2.0", method, params }));
152
+ }
153
+ receive(raw) {
154
+ let message;
155
+ try {
156
+ message = JSON.parse(raw);
157
+ } catch {
158
+ return;
159
+ }
160
+ if (typeof message.id === "number" && this.pending.has(message.id)) {
161
+ const pending = this.pending.get(message.id);
162
+ this.pending.delete(message.id);
163
+ if (message.error)
164
+ pending.reject(new Error(message.error.message ?? "app-server request failed"));
165
+ else pending.resolve(message.result);
166
+ return;
167
+ }
168
+ if (message.method) {
169
+ const notification = { method: message.method, params: message.params ?? {} };
170
+ for (const handler of this.notificationHandlers) handler(notification);
171
+ }
172
+ }
173
+ };
174
+ async function startCodexAppServer(codexPath = "codex") {
175
+ const port = await freePort();
176
+ const endpoint = `ws://127.0.0.1:${port}`;
177
+ const child = spawn(codexPath, ["app-server", "--listen", endpoint], {
178
+ stdio: ["ignore", "ignore", "pipe"]
179
+ });
180
+ let recentError = "";
181
+ child.stderr?.on("data", (chunk) => {
182
+ recentError = `${recentError}${String(chunk)}`.slice(-4e3);
183
+ });
184
+ await Promise.race([
185
+ delay(100),
186
+ new Promise(
187
+ (_, reject) => child.once(
188
+ "exit",
189
+ (code) => reject(new Error(`Codex app-server exited (${code ?? "signal"}): ${recentError}`))
190
+ )
191
+ )
192
+ ]);
193
+ return {
194
+ endpoint,
195
+ child,
196
+ stop: () => {
197
+ if (!child.killed) child.kill("SIGTERM");
198
+ }
199
+ };
200
+ }
201
+ async function freePort() {
202
+ return await new Promise((resolve, reject) => {
203
+ const server = net.createServer();
204
+ server.unref();
205
+ server.once("error", reject);
206
+ server.listen({ host: "127.0.0.1", port: 0 }, () => {
207
+ const address = server.address();
208
+ const port = typeof address === "object" && address ? address.port : 0;
209
+ server.close(() => port ? resolve(port) : reject(new Error("failed to allocate port")));
210
+ });
211
+ });
212
+ }
213
+ function delay(milliseconds) {
214
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
215
+ }
216
+
217
+ // packages/cli/src/builder-state.ts
218
+ import { createHash } from "node:crypto";
219
+ import fs from "node:fs/promises";
220
+ import path from "node:path";
221
+ var BuilderStateStore = class {
222
+ statePath;
223
+ savePromise = Promise.resolve();
224
+ constructor(input) {
225
+ this.statePath = input.statePath ?? path.join(
226
+ input.repo,
227
+ ".multicodex-alpha",
228
+ "lanes",
229
+ `${safeName(input.displayName)}-${digest(normalizeServer(input.server))}.json`
230
+ );
231
+ }
232
+ async load() {
233
+ try {
234
+ const state = JSON.parse(await fs.readFile(this.statePath, "utf8"));
235
+ return state.version === 1 ? state : null;
236
+ } catch (cause) {
237
+ const code = cause && typeof cause === "object" && "code" in cause ? cause.code : null;
238
+ if (code === "ENOENT") return null;
239
+ throw cause;
240
+ }
241
+ }
242
+ async save(state) {
243
+ const snapshot = `${JSON.stringify(state, null, 2)}
244
+ `;
245
+ this.savePromise = this.savePromise.then(async () => {
246
+ await fs.mkdir(path.dirname(this.statePath), { recursive: true, mode: 448 });
247
+ const temporary = `${this.statePath}.${process.pid}.tmp`;
248
+ await fs.writeFile(temporary, snapshot, { mode: 384 });
249
+ await fs.rename(temporary, this.statePath);
250
+ });
251
+ await this.savePromise;
252
+ }
253
+ async clear() {
254
+ await this.savePromise;
255
+ await fs.rm(this.statePath, { force: true });
256
+ }
257
+ };
258
+ function normalizeServer(server) {
259
+ return parseRoomEndpoint(server).server;
260
+ }
261
+ function parseRoomEndpoint(server) {
262
+ const url = new URL(server);
263
+ const fragment = new URLSearchParams(url.hash.replace(/^#/, ""));
264
+ const inviteToken = url.searchParams.get("invite") ?? fragment.get("invite") ?? void 0;
265
+ url.searchParams.delete("invite");
266
+ url.hash = "";
267
+ return { server: url.toString().replace(/\/$/, ""), inviteToken };
268
+ }
269
+ function digest(value) {
270
+ return createHash("sha256").update(value).digest("hex").slice(0, 12);
271
+ }
272
+ function safeName(value) {
273
+ return value.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/^-|-$/g, "") || "builder";
274
+ }
275
+
276
+ // packages/cli/src/builder.ts
277
+ var BuilderBridge = class {
278
+ input;
279
+ stateStore;
280
+ inviteToken;
281
+ roomId = "";
282
+ laneId = "";
283
+ token = "";
284
+ sequence = 0;
285
+ commandSequence = 0;
286
+ threadId = "";
287
+ activeTurnId = null;
288
+ spool = [];
289
+ client = null;
290
+ appServerStop = null;
291
+ tuiChild = null;
292
+ stopping = false;
293
+ waitingForTuiThread = false;
294
+ pendingTuiThreadId = null;
295
+ attachingTuiThread = false;
296
+ persistenceError = null;
297
+ activity = new ActivityDeltaBuffer();
298
+ stopped;
299
+ resolveStopped = null;
300
+ constructor(input) {
301
+ const endpoint = parseRoomEndpoint(input.server);
302
+ this.input = {
303
+ ...input,
304
+ server: endpoint.server,
305
+ repo: path2.resolve(input.repo)
306
+ };
307
+ this.inviteToken = endpoint.inviteToken;
308
+ this.stopped = new Promise((resolve) => {
309
+ this.resolveStopped = resolve;
310
+ });
311
+ this.stateStore = new BuilderStateStore({
312
+ repo: this.input.repo,
313
+ server: this.input.server,
314
+ displayName: this.input.displayName,
315
+ statePath: this.input.statePath
316
+ });
317
+ }
318
+ async run() {
319
+ await this.joinOrResume();
320
+ const appServer = await startCodexAppServer(this.input.codexPath);
321
+ this.appServerStop = appServer.stop;
322
+ this.client = new CodexAppServerClient(appServer.endpoint);
323
+ await this.client.connect();
324
+ this.client.onNotification((notification) => this.receiveNotification(notification));
325
+ this.emit("lane.connected", `local bridge connected (${this.input.policy})`, {
326
+ endpoint: "loopback"
327
+ });
328
+ if (this.threadId) {
329
+ try {
330
+ await this.client.resumeThread(this.threadId);
331
+ this.emit("lane.thread_attached", "persisted Codex thread resumed", {
332
+ threadId: this.threadId,
333
+ codexEndpoint: appServer.endpoint
334
+ });
335
+ } catch {
336
+ this.threadId = "";
337
+ this.emit("lane.status", "persisted Codex thread unavailable; starting a new thread");
338
+ }
339
+ }
340
+ if (!this.threadId && this.input.noTui) {
341
+ this.threadId = await this.client.startThread(this.input.repo);
342
+ this.emit("lane.thread_attached", "headless Codex thread attached", {
343
+ threadId: this.threadId,
344
+ codexEndpoint: appServer.endpoint
345
+ });
346
+ } else if (!this.threadId) {
347
+ this.waitingForTuiThread = true;
348
+ }
349
+ await this.flush();
350
+ const poll = setInterval(() => void this.tick(), 700);
351
+ try {
352
+ if (this.input.noTui && this.input.prompt) {
353
+ await this.client.startTurn(this.threadId, this.input.prompt);
354
+ }
355
+ if (this.input.noTui) await this.stopped;
356
+ else await this.runTui(appServer.endpoint);
357
+ } finally {
358
+ clearInterval(poll);
359
+ this.flushActivity();
360
+ this.emit("lane.disconnected", "local Codex TUI disconnected");
361
+ await this.flush().catch(() => void 0);
362
+ this.stop();
363
+ }
364
+ }
365
+ stop() {
366
+ if (this.stopping) return;
367
+ this.stopping = true;
368
+ this.resolveStopped?.();
369
+ this.resolveStopped = null;
370
+ this.client?.close();
371
+ this.appServerStop?.();
372
+ if (this.tuiChild && !this.tuiChild.killed) this.tuiChild.kill("SIGTERM");
373
+ }
374
+ async joinOrResume() {
375
+ if (this.input.fresh) await this.stateStore.clear();
376
+ const restored = this.input.fresh ? null : await this.stateStore.load();
377
+ if (restored && this.matches(restored)) {
378
+ this.restore(restored);
379
+ const response2 = await fetch(
380
+ new URL(`/api/lanes/${encodeURIComponent(this.laneId)}/resume`, this.input.server),
381
+ {
382
+ method: "POST",
383
+ headers: {
384
+ "content-type": "application/json",
385
+ authorization: `Bearer ${this.token}`
386
+ },
387
+ body: JSON.stringify({
388
+ displayName: this.input.displayName,
389
+ repo: this.input.repo,
390
+ policy: this.input.policy
391
+ })
392
+ }
393
+ );
394
+ const payload2 = await response2.json();
395
+ if (response2.ok) {
396
+ this.roomId = payload2.room.id;
397
+ this.sequence = Math.max(this.sequence, payload2.lane.lastEventSequence);
398
+ this.spool = this.spool.filter(
399
+ (event) => event.roomId === this.roomId && event.laneId === this.laneId && event.sequence > payload2.lane.lastEventSequence
400
+ );
401
+ await this.persistState();
402
+ return;
403
+ }
404
+ if (![401, 404, 410].includes(response2.status)) {
405
+ throw new Error(payload2.error ?? `lane resume failed (${response2.status})`);
406
+ }
407
+ await this.stateStore.clear();
408
+ this.reset();
409
+ }
410
+ const response = await fetch(new URL("/api/join", this.input.server), {
411
+ method: "POST",
412
+ headers: {
413
+ "content-type": "application/json",
414
+ ...this.inviteToken ? { authorization: `Bearer ${this.inviteToken}` } : {}
415
+ },
416
+ body: JSON.stringify({
417
+ displayName: this.input.displayName,
418
+ repo: this.input.repo,
419
+ policy: this.input.policy
420
+ })
421
+ });
422
+ const payload = await response.json();
423
+ if (!response.ok) throw new Error(payload.error ?? `join failed (${response.status})`);
424
+ this.roomId = payload.room.id;
425
+ this.laneId = payload.lane.id;
426
+ this.token = payload.token;
427
+ await this.persistState();
428
+ }
429
+ async tick() {
430
+ if (this.stopping) return;
431
+ await this.attachPendingTuiThread().catch(() => void 0);
432
+ await this.flush().catch(() => void 0);
433
+ await this.pollCommands().catch(() => void 0);
434
+ }
435
+ emit(kind, summary, payload) {
436
+ this.sequence += 1;
437
+ this.spool.push(
438
+ createLaneEvent({
439
+ roomId: this.roomId,
440
+ laneId: this.laneId,
441
+ sequence: this.sequence,
442
+ kind,
443
+ summary: redact(summary, this.input.repo),
444
+ payload: payload ? redactObject(payload, this.input.repo) : void 0
445
+ })
446
+ );
447
+ this.schedulePersist();
448
+ }
449
+ async flush() {
450
+ if (this.persistenceError) throw this.persistenceError;
451
+ if (!this.spool.length) return;
452
+ await this.persistState();
453
+ const response = await fetch(
454
+ new URL(`/api/lanes/${encodeURIComponent(this.laneId)}/events`, this.input.server),
455
+ {
456
+ method: "POST",
457
+ headers: {
458
+ "content-type": "application/json",
459
+ authorization: `Bearer ${this.token}`
460
+ },
461
+ body: JSON.stringify({ events: this.spool })
462
+ }
463
+ );
464
+ const payload = await response.json();
465
+ this.stopIfRevoked(response, payload.error);
466
+ if (!response.ok) throw new Error(payload.error ?? `event flush failed (${response.status})`);
467
+ this.spool = this.spool.filter((event) => event.sequence > (payload.ackSequence ?? 0));
468
+ await this.persistState();
469
+ }
470
+ async pollCommands() {
471
+ const url = new URL(
472
+ `/api/lanes/${encodeURIComponent(this.laneId)}/commands`,
473
+ this.input.server
474
+ );
475
+ url.searchParams.set("after", String(this.commandSequence));
476
+ const response = await fetch(url, { headers: { authorization: `Bearer ${this.token}` } });
477
+ const payload = await response.json();
478
+ this.stopIfRevoked(response, payload.error);
479
+ if (!response.ok) throw new Error(payload.error ?? `command poll failed (${response.status})`);
480
+ for (const command2 of payload.commands ?? []) {
481
+ this.commandSequence = Math.max(this.commandSequence, command2.sequence);
482
+ await this.persistState();
483
+ await this.handleCommand(command2);
484
+ }
485
+ }
486
+ async handleCommand(command2) {
487
+ let result = "accepted";
488
+ let detail = "";
489
+ try {
490
+ if (command2.expiresAt < Date.now()) result = "expired";
491
+ else if (!policyAllows(this.input.policy, command2.kind)) result = "rejected_policy";
492
+ else if (!this.client) result = "failed";
493
+ else {
494
+ switch (command2.kind) {
495
+ case "suggest":
496
+ process.stdout.write(`
497
+ [multicodex conductor suggestion] ${command2.text}
498
+ `);
499
+ break;
500
+ case "request_status":
501
+ this.emit("lane.status", `conductor requested status: ${command2.text}`);
502
+ break;
503
+ case "start_followup":
504
+ await this.client.startTurn(this.threadId, command2.text);
505
+ break;
506
+ case "steer_active_turn":
507
+ if (!this.activeTurnId) {
508
+ result = "failed";
509
+ detail = "no active turn";
510
+ } else await this.client.steer(this.threadId, this.activeTurnId, command2.text);
511
+ break;
512
+ case "request_interrupt":
513
+ if (!this.activeTurnId) {
514
+ result = "failed";
515
+ detail = "no active turn";
516
+ } else await this.client.interrupt(this.threadId, this.activeTurnId);
517
+ break;
518
+ }
519
+ }
520
+ } catch (cause) {
521
+ result = "failed";
522
+ detail = cause instanceof Error ? cause.message : String(cause);
523
+ }
524
+ this.emit("command.result", `${command2.kind}: ${result}${detail ? ` (${detail})` : ""}`, {
525
+ commandId: command2.id,
526
+ result
527
+ });
528
+ await this.flush();
529
+ }
530
+ stopIfRevoked(response, detail) {
531
+ if (response.status !== 410 || this.stopping) return;
532
+ process.stderr.write(`
533
+ [multicodex] ${detail || "lane removed by host"}
534
+ `);
535
+ this.stop();
536
+ }
537
+ receiveNotification(notification) {
538
+ const params = notification.params;
539
+ switch (notification.method) {
540
+ case "thread/started": {
541
+ if (this.waitingForTuiThread) this.captureTuiThread(object(params.thread));
542
+ break;
543
+ }
544
+ case "turn/started": {
545
+ this.flushActivity();
546
+ const turn = object(params.turn);
547
+ this.activeTurnId = text(turn.id);
548
+ this.emit("turn.started", "Codex turn started", { turnId: this.activeTurnId });
549
+ break;
550
+ }
551
+ case "turn/completed": {
552
+ this.flushActivity();
553
+ const turn = object(params.turn);
554
+ const turnId = text(turn.id);
555
+ this.emit("turn.completed", `Codex turn ${JSON.stringify(turn.status)}`, { turnId });
556
+ if (turnId === this.activeTurnId) this.activeTurnId = null;
557
+ break;
558
+ }
559
+ case "item/agentMessage/delta":
560
+ this.activity.append("agent.message", params.delta);
561
+ break;
562
+ case "item/plan/delta":
563
+ this.activity.append("agent.plan", params.delta);
564
+ break;
565
+ case "item/started":
566
+ this.flushActivity();
567
+ this.itemEvent("started", object(params.item));
568
+ break;
569
+ case "item/completed":
570
+ this.flushActivity();
571
+ this.itemEvent("completed", object(params.item));
572
+ break;
573
+ default:
574
+ if (notification.method.includes("requestApproval")) {
575
+ this.emit("approval.requested", "local Codex approval requested");
576
+ }
577
+ }
578
+ }
579
+ flushActivity() {
580
+ for (const activity of this.activity.drain()) {
581
+ this.emit(activity.kind, activity.summary);
582
+ }
583
+ }
584
+ itemEvent(stage, item) {
585
+ const type = text(item.type) || "work";
586
+ const summary = text(item.command) || text(item.path) || text(item.name) || type;
587
+ if (/command/i.test(type)) {
588
+ this.emit(stage === "started" ? "command.started" : "command.completed", summary, { type });
589
+ } else if (/file/i.test(type)) this.emit("files.changed", summary, { type });
590
+ }
591
+ async runTui(endpoint) {
592
+ process.stdout.write(
593
+ `
594
+ MultiCodex lane ready
595
+ room: ${this.input.server}
596
+ room view: ${this.participantViewUrl()}
597
+ thread: attaching from normal TUI
598
+ policy: ${this.input.policy}
599
+
600
+ `
601
+ );
602
+ const args2 = this.threadId ? ["resume", "--remote", endpoint, "-C", this.input.repo, this.threadId] : ["--remote", endpoint, "-C", this.input.repo];
603
+ if (this.input.prompt) args2.push(this.input.prompt);
604
+ const child = spawn2(this.input.codexPath, args2, {
605
+ stdio: "inherit"
606
+ });
607
+ this.tuiChild = child;
608
+ try {
609
+ await waitForChild(child);
610
+ } finally {
611
+ this.tuiChild = null;
612
+ }
613
+ }
614
+ participantViewUrl() {
615
+ const url = new URL(this.input.server);
616
+ url.hash = new URLSearchParams({ lane: this.laneId, token: this.token }).toString();
617
+ return url.toString();
618
+ }
619
+ captureTuiThread(thread) {
620
+ if (!this.client || !this.waitingForTuiThread) return;
621
+ const threadId = text(thread.id);
622
+ const cwd = text(thread.cwd);
623
+ if (!threadId || path2.resolve(cwd) !== path2.resolve(this.input.repo)) return;
624
+ this.waitingForTuiThread = false;
625
+ this.threadId = threadId;
626
+ this.pendingTuiThreadId = threadId;
627
+ this.emit("lane.status", "normal Codex TUI ready; bridge attaches on first turn", { threadId });
628
+ void this.flush().catch(() => void 0);
629
+ }
630
+ async attachPendingTuiThread() {
631
+ if (!this.client || !this.pendingTuiThreadId || this.attachingTuiThread) return;
632
+ this.attachingTuiThread = true;
633
+ try {
634
+ await this.client.resumeThread(this.pendingTuiThreadId);
635
+ this.emit("lane.thread_attached", "normal Codex TUI thread attached", {
636
+ threadId: this.pendingTuiThreadId
637
+ });
638
+ this.pendingTuiThreadId = null;
639
+ } catch {
640
+ } finally {
641
+ this.attachingTuiThread = false;
642
+ }
643
+ await this.flush().catch(() => void 0);
644
+ }
645
+ matches(state) {
646
+ return normalizeServer(state.server) === this.input.server && path2.resolve(state.repo) === this.input.repo && state.displayName === this.input.displayName;
647
+ }
648
+ restore(state) {
649
+ this.roomId = state.roomId;
650
+ this.laneId = state.laneId;
651
+ this.token = state.token;
652
+ this.sequence = state.sequence;
653
+ this.commandSequence = state.commandSequence;
654
+ this.threadId = state.threadId;
655
+ this.spool = state.spool;
656
+ }
657
+ reset() {
658
+ this.roomId = "";
659
+ this.laneId = "";
660
+ this.token = "";
661
+ this.sequence = 0;
662
+ this.commandSequence = 0;
663
+ this.threadId = "";
664
+ this.spool = [];
665
+ }
666
+ schedulePersist() {
667
+ void this.persistState().catch((cause) => {
668
+ this.persistenceError = cause instanceof Error ? cause : new Error(String(cause));
669
+ });
670
+ }
671
+ async persistState() {
672
+ if (!this.roomId || !this.laneId || !this.token) return;
673
+ await this.stateStore.save({
674
+ version: 1,
675
+ server: this.input.server,
676
+ roomId: this.roomId,
677
+ laneId: this.laneId,
678
+ token: this.token,
679
+ displayName: this.input.displayName,
680
+ repo: this.input.repo,
681
+ policy: this.input.policy,
682
+ sequence: this.sequence,
683
+ commandSequence: this.commandSequence,
684
+ threadId: this.threadId,
685
+ spool: this.spool
686
+ });
687
+ }
688
+ };
689
+ var ActivityDeltaBuffer = class {
690
+ chunks = /* @__PURE__ */ new Map();
691
+ append(kind, delta) {
692
+ const chunk = text(delta);
693
+ if (!chunk) return;
694
+ const chunks = this.chunks.get(kind) ?? [];
695
+ chunks.push(chunk);
696
+ this.chunks.set(kind, chunks);
697
+ }
698
+ drain() {
699
+ const events = [];
700
+ for (const [kind, chunks] of this.chunks) {
701
+ const summary = chunks.join("").trim();
702
+ if (summary) events.push({ kind, summary });
703
+ }
704
+ this.chunks.clear();
705
+ return events;
706
+ }
707
+ };
708
+ function object(value) {
709
+ return value && typeof value === "object" ? value : {};
710
+ }
711
+ function text(value) {
712
+ return typeof value === "string" ? value : "";
713
+ }
714
+ function redact(value, repo) {
715
+ return value.replaceAll(path2.resolve(repo), "<repo>").slice(0, 2e3);
716
+ }
717
+ function redactObject(value, repo) {
718
+ return JSON.parse(redact(JSON.stringify(value), repo));
719
+ }
720
+ async function waitForChild(child) {
721
+ await new Promise((resolve, reject) => {
722
+ child.once("exit", (code, signal) => {
723
+ if (code === 0 || signal) resolve();
724
+ else reject(new Error(`Codex TUI exited with ${code}`));
725
+ });
726
+ child.once("error", reject);
727
+ });
728
+ }
729
+
730
+ // packages/cli/src/codex-path.ts
731
+ import fs2 from "node:fs/promises";
732
+ import path3 from "node:path";
733
+ async function resolveUserCodexPath(input) {
734
+ if (input?.explicit) return input.explicit;
735
+ const platform = input?.platform ?? process.platform;
736
+ const names = platform === "win32" ? ["codex.cmd", "codex.exe", "codex.bat", "codex"] : ["codex"];
737
+ for (const directory of (input?.pathValue ?? process.env.PATH ?? "").split(path3.delimiter)) {
738
+ if (!directory || isPackageBin(directory)) continue;
739
+ for (const name of names) {
740
+ const candidate = path3.join(directory, name);
741
+ try {
742
+ await fs2.access(candidate, fs2.constants.X_OK);
743
+ return candidate;
744
+ } catch {
745
+ }
746
+ }
747
+ }
748
+ return null;
749
+ }
750
+ function isPackageBin(directory) {
751
+ return /(^|[/\\])node_modules[/\\]\.bin[/\\]?$/.test(directory);
752
+ }
753
+
754
+ // packages/cli/src/conductor.ts
755
+ import path4 from "node:path";
756
+ import {
757
+ createAcpRuntime,
758
+ createAgentRegistry,
759
+ createRuntimeStore
760
+ } from "acpx/runtime";
761
+ var LocalConductor = class {
762
+ store;
763
+ runtime;
764
+ handle = null;
765
+ queue = Promise.resolve();
766
+ constructor(store, input) {
767
+ this.store = store;
768
+ this.runtime = createAcpRuntime({
769
+ cwd: input.repo,
770
+ sessionStore: createRuntimeStore({ stateDir: path4.join(input.stateDir, "acpx") }),
771
+ agentRegistry: createAgentRegistry(),
772
+ permissionMode: "deny-all",
773
+ nonInteractivePermissions: "deny",
774
+ timeoutMs: 18e4
775
+ });
776
+ }
777
+ async initialize() {
778
+ this.handle = await this.runtime.ensureSession({
779
+ sessionKey: `multicodex:${this.store.snapshot().id}:conductor`,
780
+ agent: "codex",
781
+ mode: "persistent",
782
+ sessionOptions: {
783
+ systemPrompt: {
784
+ append: "You are the conductor for a local-first MultiCodex room. Be concise. Keep orchestration visible. Never request or approve arbitrary shell access. Participants retain local authority."
785
+ }
786
+ }
787
+ });
788
+ await this.store.addConductorMessage("system", "host-local ACPx conductor ready");
789
+ }
790
+ message(text2) {
791
+ return this.enqueue(async () => {
792
+ const response = await this.turn(
793
+ `Room snapshot:
794
+ ${compactSnapshot(this.store.snapshot())}
795
+
796
+ Host message:
797
+ ${text2}
798
+
799
+ Respond to the room concisely.`
800
+ );
801
+ await this.store.addConductorMessage("conductor", response || "acknowledged");
802
+ });
803
+ }
804
+ steer(laneId, text2) {
805
+ return this.command(laneId, "steer_active_turn", text2);
806
+ }
807
+ command(laneId, kind, text2) {
808
+ return this.enqueue(async () => {
809
+ const response = await this.turn(
810
+ `Room snapshot:
811
+ ${compactSnapshot(this.store.snapshot())}
812
+
813
+ The host asks you to deliver the visible ${kind} action to lane ${laneId}:
814
+ ${text2}
815
+
816
+ Briefly state why this action helps.`
817
+ );
818
+ if (response) await this.store.addConductorMessage("conductor", response);
819
+ await this.store.queueCommand(laneId, kind, text2);
820
+ });
821
+ }
822
+ enqueue(run) {
823
+ this.queue = this.queue.then(run, run);
824
+ return this.queue;
825
+ }
826
+ async turn(text2) {
827
+ if (!this.handle) throw new Error("conductor is not initialized");
828
+ const turn = this.runtime.startTurn({
829
+ handle: this.handle,
830
+ text: text2,
831
+ mode: "prompt",
832
+ requestId: crypto.randomUUID(),
833
+ timeoutMs: 18e4
834
+ });
835
+ let output = "";
836
+ for await (const event of turn.events) {
837
+ if (event.type === "text_delta" && event.stream !== "thought") output += event.text;
838
+ }
839
+ const result = await turn.result;
840
+ if (result.status === "failed") throw new Error(result.error.message);
841
+ return output.trim();
842
+ }
843
+ };
844
+ function compactSnapshot(snapshot) {
845
+ return JSON.stringify({
846
+ room: { id: snapshot.id, title: snapshot.title, repo: snapshot.repo },
847
+ lanes: snapshot.lanes.map((lane) => ({
848
+ id: lane.id,
849
+ name: lane.displayName,
850
+ policy: lane.policy,
851
+ connected: lane.connected,
852
+ status: lane.status,
853
+ activeTurn: lane.currentTurnId,
854
+ removed: Boolean(lane.removedAt)
855
+ })),
856
+ recentEvents: snapshot.events.slice(-12).map((event) => ({
857
+ laneId: event.laneId,
858
+ kind: event.kind,
859
+ summary: event.summary
860
+ }))
861
+ });
862
+ }
863
+
864
+ // packages/cli/src/local-room.ts
865
+ import fs3 from "node:fs/promises";
866
+ import http from "node:http";
867
+ import path5 from "node:path";
868
+
869
+ // packages/cli/src/ui.ts
870
+ function localRoomHtml() {
871
+ return `<!doctype html>
872
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
873
+ <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>
879
+ <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
+ <script>
881
+ 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')};
883
+ const roleColors=['#dd503c','#3267d6','#16825d','#d59b18','#835bb5'];
884
+ const fragment=new URLSearchParams(location.hash.replace(/^#/,''));
885
+ const hostToken=fragment.get('host')||sessionStorage.getItem('multicodex-host-token')||'';
886
+ const laneToken=fragment.get('token')||sessionStorage.getItem('multicodex-lane-token')||'';
887
+ const viewerLaneId=fragment.get('lane')||sessionStorage.getItem('multicodex-lane-id')||'';
888
+ if(hostToken)sessionStorage.setItem('multicodex-host-token',hostToken);
889
+ if(laneToken)sessionStorage.setItem('multicodex-lane-token',laneToken);
890
+ if(viewerLaneId)sessionStorage.setItem('multicodex-lane-id',viewerLaneId);
891
+ let snapshot=null,hostConfig=null,selectedLaneId='',refreshing=false;
892
+ 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
+ function initials(name){return String(name||'?').split(/\\s+/).map(part=>part[0]).join('').slice(0,2).toUpperCase()}
894
+ 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
+ function toast(message){ui.toast.textContent=message;ui.toast.classList.add('show');setTimeout(()=>ui.toast.classList.remove('show'),1700)}
896
+ async function copy(value,message='copied'){await navigator.clipboard.writeText(value);toast(message)}
897
+ function activeLanes(){return snapshot?snapshot.lanes.filter(lane=>!lane.removedAt):[]}
898
+ function selectedLane(){const lanes=activeLanes();return lanes.find(lane=>lane.id===selectedLaneId)||lanes[0]||snapshot?.lanes[0]||null}
899
+ 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
+ function eventClass(kind){return kind.replaceAll('.','-')}
901
+ function eventLabel(kind){return kind.replace('agent.','ai.').replace('command.','cmd.').replace('turn.','turn.').replace('lane.','lane.').replace('files.changed','files')}
902
+ 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}
905
+ 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'}
907
+ 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}}
910
+ 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()};
915
+ 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
+ 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
+ ui.laneSelect.onchange=()=>{selectedLaneId=ui.laneSelect.value;render()};
918
+ 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'};
919
+ setInterval(refresh,900);refresh();
920
+ </script></body></html>`;
921
+ }
922
+
923
+ // packages/cli/src/local-room.ts
924
+ var LocalRoomStore = class _LocalRoomStore {
925
+ statePath;
926
+ state;
927
+ savePromise = Promise.resolve();
928
+ constructor(statePath, state) {
929
+ this.statePath = statePath;
930
+ this.state = state;
931
+ }
932
+ static async create(input) {
933
+ await fs3.mkdir(input.stateDir, { recursive: true, mode: 448 });
934
+ const statePath = path5.join(input.stateDir, "room.json");
935
+ const state = {
936
+ version: protocolVersion,
937
+ id: `room_${crypto.randomUUID()}`,
938
+ title: input.title,
939
+ repo: input.repo,
940
+ createdAt: Date.now(),
941
+ lanes: [],
942
+ events: [],
943
+ conductorMessages: [],
944
+ commands: [],
945
+ eventIds: [],
946
+ hostToken: crypto.randomUUID(),
947
+ inviteToken: crypto.randomUUID()
948
+ };
949
+ const store = new _LocalRoomStore(statePath, state);
950
+ await store.save();
951
+ return store;
952
+ }
953
+ static async load(stateDir) {
954
+ const statePath = path5.join(stateDir, "room.json");
955
+ const state = JSON.parse(await fs3.readFile(statePath, "utf8"));
956
+ state.hostToken ||= crypto.randomUUID();
957
+ state.inviteToken ||= crypto.randomUUID();
958
+ for (const lane of state.lanes) lane.removedAt ??= null;
959
+ const store = new _LocalRoomStore(statePath, state);
960
+ await store.save();
961
+ return store;
962
+ }
963
+ snapshot() {
964
+ return {
965
+ version: this.state.version,
966
+ id: this.state.id,
967
+ title: this.state.title,
968
+ repo: publicRepoName(this.state.repo),
969
+ createdAt: this.state.createdAt,
970
+ lanes: this.state.lanes.map(({ token: _token, ...lane }) => ({
971
+ ...lane,
972
+ repo: publicRepoName(lane.repo)
973
+ })),
974
+ events: this.state.events.slice(-300),
975
+ conductorMessages: this.state.conductorMessages.slice(-100)
976
+ };
977
+ }
978
+ hostConfig(publicUrl) {
979
+ const inviteUrl = capabilityUrl(publicUrl, "invite", this.state.inviteToken);
980
+ return {
981
+ inviteUrl,
982
+ joinCommand: `npx --yes @vincentkoc/multicodex@latest join ${shellQuote(inviteUrl)} --repo .`,
983
+ activeLanes: this.state.lanes.filter((lane) => !lane.removedAt).length
984
+ };
985
+ }
986
+ hostUrl(publicUrl) {
987
+ return capabilityUrl(publicUrl, "host", this.state.hostToken);
988
+ }
989
+ inviteUrl(publicUrl) {
990
+ return capabilityUrl(publicUrl, "invite", this.state.inviteToken);
991
+ }
992
+ authorizeHost(token) {
993
+ return token === this.state.hostToken;
994
+ }
995
+ authorizeInvite(token) {
996
+ return token === this.state.inviteToken;
997
+ }
998
+ async join(input) {
999
+ const now = Date.now();
1000
+ const lane = {
1001
+ id: `lane_${crypto.randomUUID()}`,
1002
+ token: crypto.randomUUID(),
1003
+ displayName: input.displayName,
1004
+ repo: input.repo,
1005
+ policy: input.policy,
1006
+ connected: false,
1007
+ threadId: null,
1008
+ currentTurnId: null,
1009
+ lastEventSequence: 0,
1010
+ lastCommandSequence: 0,
1011
+ status: "joining",
1012
+ joinedAt: now,
1013
+ updatedAt: now,
1014
+ removedAt: null
1015
+ };
1016
+ this.state.lanes.push(lane);
1017
+ await this.save();
1018
+ const { token, ...publicLane } = lane;
1019
+ return { lane: publicLane, token };
1020
+ }
1021
+ async resumeLane(laneId, token, input) {
1022
+ const lane = this.state.lanes.find(
1023
+ (candidate) => candidate.id === laneId && candidate.token === token
1024
+ );
1025
+ if (!lane) throw new RoomError(401, "valid lane capability required");
1026
+ if (lane.removedAt) throw new RoomError(410, "lane removed by host");
1027
+ lane.displayName = input.displayName;
1028
+ lane.repo = input.repo;
1029
+ lane.policy = input.policy;
1030
+ lane.updatedAt = Date.now();
1031
+ lane.status = "reconnecting";
1032
+ await this.save();
1033
+ const { token: _token, ...publicLane } = lane;
1034
+ return { lane: publicLane };
1035
+ }
1036
+ authorizeLane(laneId, token) {
1037
+ return this.state.lanes.find(
1038
+ (lane) => lane.id === laneId && lane.token === token && !lane.removedAt
1039
+ ) ?? null;
1040
+ }
1041
+ async appendEvents(laneId, token, events) {
1042
+ const lane = this.requireLane(laneId, token);
1043
+ for (const event of events) {
1044
+ if (event.version !== protocolVersion || event.roomId !== this.state.id || event.laneId !== lane.id) {
1045
+ throw new RoomError(400, "invalid lane event envelope");
1046
+ }
1047
+ if (this.state.eventIds.includes(event.id) || event.sequence <= lane.lastEventSequence)
1048
+ continue;
1049
+ if (event.sequence !== lane.lastEventSequence + 1) {
1050
+ throw new RoomError(409, `event gap: expected ${lane.lastEventSequence + 1}`);
1051
+ }
1052
+ this.state.events.push(event);
1053
+ this.state.eventIds.push(event.id);
1054
+ lane.lastEventSequence = event.sequence;
1055
+ lane.updatedAt = event.at;
1056
+ applyLaneEvent(lane, event);
1057
+ }
1058
+ this.state.events.splice(0, Math.max(0, this.state.events.length - 1e3));
1059
+ this.state.eventIds.splice(0, Math.max(0, this.state.eventIds.length - 2e3));
1060
+ await this.save();
1061
+ return lane.lastEventSequence;
1062
+ }
1063
+ commandsAfter(laneId, token, after) {
1064
+ this.requireLane(laneId, token);
1065
+ return this.state.commands.filter(
1066
+ (command2) => command2.laneId === laneId && command2.sequence > after
1067
+ );
1068
+ }
1069
+ async queueCommand(laneId, kind, text2) {
1070
+ const lane = this.state.lanes.find((candidate) => candidate.id === laneId);
1071
+ if (!lane) throw new RoomError(404, "lane not found");
1072
+ if (lane.removedAt) throw new RoomError(410, "lane removed by host");
1073
+ if (!policyAllows(lane.policy, kind)) {
1074
+ throw new RoomError(409, `${kind} requires ${requiredPolicyForCommand(kind)} policy`);
1075
+ }
1076
+ const now = Date.now();
1077
+ const command2 = {
1078
+ version: protocolVersion,
1079
+ id: `command_${crypto.randomUUID()}`,
1080
+ roomId: this.state.id,
1081
+ laneId,
1082
+ sequence: lane.lastCommandSequence + 1,
1083
+ at: now,
1084
+ expiresAt: now + 5 * 6e4,
1085
+ kind,
1086
+ text: text2,
1087
+ source: "conductor",
1088
+ requiredPolicy: requiredPolicyForCommand(kind)
1089
+ };
1090
+ lane.lastCommandSequence = command2.sequence;
1091
+ lane.updatedAt = now;
1092
+ this.state.commands.push(command2);
1093
+ this.state.commands.splice(0, Math.max(0, this.state.commands.length - 500));
1094
+ await this.save();
1095
+ return command2;
1096
+ }
1097
+ async removeLane(laneId) {
1098
+ const lane = this.state.lanes.find((candidate) => candidate.id === laneId);
1099
+ if (!lane) throw new RoomError(404, "lane not found");
1100
+ if (lane.removedAt) return;
1101
+ const now = Date.now();
1102
+ lane.removedAt = now;
1103
+ lane.connected = false;
1104
+ lane.currentTurnId = null;
1105
+ lane.status = "removed by host";
1106
+ lane.updatedAt = now;
1107
+ await this.addConductorMessage("system", `${lane.displayName} removed by host`);
1108
+ }
1109
+ async addParticipantMessage(laneId, token, body) {
1110
+ const lane = this.requireLane(laneId, token);
1111
+ await this.addConductorMessage("participant", body, {
1112
+ authorName: lane.displayName,
1113
+ laneId: lane.id
1114
+ });
1115
+ return lane.displayName;
1116
+ }
1117
+ async addConductorMessage(author, body, detail) {
1118
+ const previous = this.state.conductorMessages.at(-1);
1119
+ if (previous?.author === author && previous.body === body && previous.authorName === detail?.authorName && previous.laneId === detail?.laneId) {
1120
+ return;
1121
+ }
1122
+ this.state.conductorMessages.push({
1123
+ id: `message_${crypto.randomUUID()}`,
1124
+ author,
1125
+ ...detail,
1126
+ body,
1127
+ at: Date.now()
1128
+ });
1129
+ this.state.conductorMessages.splice(0, Math.max(0, this.state.conductorMessages.length - 300));
1130
+ await this.save();
1131
+ }
1132
+ requireLane(laneId, token) {
1133
+ const lane = this.state.lanes.find((candidate) => candidate.id === laneId);
1134
+ if (!lane || lane.token !== token) throw new RoomError(401, "valid lane capability required");
1135
+ if (lane.removedAt) throw new RoomError(410, "lane removed by host");
1136
+ return lane;
1137
+ }
1138
+ async save() {
1139
+ this.savePromise = this.savePromise.then(async () => {
1140
+ const temporary = `${this.statePath}.tmp`;
1141
+ await fs3.writeFile(temporary, `${JSON.stringify(this.state, null, 2)}
1142
+ `, { mode: 384 });
1143
+ await fs3.rename(temporary, this.statePath);
1144
+ });
1145
+ await this.savePromise;
1146
+ }
1147
+ };
1148
+ async function startLocalRoomServer(input) {
1149
+ const host2 = input.host ?? "127.0.0.1";
1150
+ if (["0.0.0.0", "::"].includes(host2) && !input.publicUrl) {
1151
+ throw new RoomError(400, "--public-url is required when binding to all interfaces");
1152
+ }
1153
+ if (input.publicUrl) new URL(input.publicUrl);
1154
+ let publicUrl = "";
1155
+ const server = http.createServer((request, response) => {
1156
+ void handleRequest(input.store, input.handlers, publicUrl, request, response).catch((cause) => {
1157
+ const error = cause instanceof RoomError ? cause : new RoomError(500, errorMessage(cause));
1158
+ sendJson(response, error.status, { error: error.message });
1159
+ });
1160
+ });
1161
+ await new Promise((resolve, reject) => {
1162
+ server.once("error", reject);
1163
+ server.listen(input.port, host2, resolve);
1164
+ });
1165
+ const address = server.address();
1166
+ const port = typeof address === "object" && address ? address.port : input.port;
1167
+ publicUrl = advertisedUrl(host2, port, input.publicUrl);
1168
+ return {
1169
+ url: publicUrl,
1170
+ hostUrl: input.store.hostUrl(publicUrl),
1171
+ inviteUrl: input.store.inviteUrl(publicUrl),
1172
+ close: async () => {
1173
+ await new Promise(
1174
+ (resolve, reject) => server.close((cause) => cause ? reject(cause) : resolve())
1175
+ );
1176
+ }
1177
+ };
1178
+ }
1179
+ async function handleRequest(store, handlers, publicUrl, request, response) {
1180
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
1181
+ if (request.method === "GET" && url.pathname === "/") {
1182
+ sendText(response, 200, localRoomHtml(), "text/html; charset=utf-8");
1183
+ return;
1184
+ }
1185
+ if (request.method === "GET" && url.pathname === "/api/snapshot") {
1186
+ sendJson(response, 200, store.snapshot());
1187
+ return;
1188
+ }
1189
+ if (request.method === "GET" && url.pathname === "/api/host/config") {
1190
+ requireHost(store, request);
1191
+ sendJson(response, 200, store.hostConfig(publicUrl));
1192
+ return;
1193
+ }
1194
+ 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
+ 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({
1204
+ displayName: requiredString(body.displayName, "displayName"),
1205
+ repo: requiredString(body.repo, "repo"),
1206
+ policy
1207
+ });
1208
+ sendJson(response, 201, { room: store.snapshot(), ...joined });
1209
+ return;
1210
+ }
1211
+ const resumeMatch = url.pathname.match(/^\/api\/lanes\/([^/]+)\/resume$/);
1212
+ if (request.method === "POST" && resumeMatch) {
1213
+ 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), {
1219
+ displayName: requiredString(body.displayName, "displayName"),
1220
+ repo: requiredString(body.repo, "repo"),
1221
+ policy
1222
+ });
1223
+ sendJson(response, 200, { room: store.snapshot(), ...resumed });
1224
+ return;
1225
+ }
1226
+ const eventMatch = url.pathname.match(/^\/api\/lanes\/([^/]+)\/events$/);
1227
+ if (request.method === "POST" && eventMatch) {
1228
+ const laneId = decodeURIComponent(eventMatch[1]);
1229
+ const body = await readJson(request);
1230
+ const events = Array.isArray(body.events) ? body.events : [];
1231
+ const ackSequence = await store.appendEvents(laneId, bearer(request), events);
1232
+ sendJson(response, 200, { ackSequence });
1233
+ return;
1234
+ }
1235
+ const commandMatch = url.pathname.match(/^\/api\/lanes\/([^/]+)\/commands$/);
1236
+ if (request.method === "GET" && commandMatch) {
1237
+ const laneId = decodeURIComponent(commandMatch[1]);
1238
+ const after = Number(url.searchParams.get("after") ?? "0");
1239
+ sendJson(response, 200, { commands: store.commandsAfter(laneId, bearer(request), after) });
1240
+ return;
1241
+ }
1242
+ const laneMessageMatch = url.pathname.match(/^\/api\/lanes\/([^/]+)\/message$/);
1243
+ if (request.method === "POST" && laneMessageMatch) {
1244
+ const laneId = decodeURIComponent(laneMessageMatch[1]);
1245
+ const body = await readJson(request);
1246
+ const text2 = requiredString(body.text, "text");
1247
+ const displayName = await store.addParticipantMessage(laneId, bearer(request), text2);
1248
+ void handlers.onConductorMessage(`${displayName}: ${text2}`).catch(
1249
+ (cause) => store.addConductorMessage("system", `conductor failed: ${errorMessage(cause)}`)
1250
+ );
1251
+ sendJson(response, 202, { accepted: true });
1252
+ return;
1253
+ }
1254
+ if (request.method === "POST" && url.pathname === "/api/conductor/message") {
1255
+ requireHost(store, request);
1256
+ const body = await readJson(request);
1257
+ const text2 = requiredString(body.text, "text");
1258
+ await store.addConductorMessage("host", text2);
1259
+ void handlers.onConductorMessage(text2).catch(
1260
+ (cause) => store.addConductorMessage("system", `conductor failed: ${errorMessage(cause)}`)
1261
+ );
1262
+ sendJson(response, 202, { accepted: true });
1263
+ return;
1264
+ }
1265
+ const removeMatch = url.pathname.match(/^\/api\/lanes\/([^/]+)$/);
1266
+ if (request.method === "DELETE" && removeMatch) {
1267
+ requireHost(store, request);
1268
+ await store.removeLane(decodeURIComponent(removeMatch[1]));
1269
+ sendJson(response, 200, { removed: true });
1270
+ return;
1271
+ }
1272
+ if (request.method === "POST" && url.pathname === "/api/conductor/command") {
1273
+ requireHost(store, request);
1274
+ const body = await readJson(request);
1275
+ const laneId = requiredString(body.laneId, "laneId");
1276
+ const kind = requiredCommandKind(body.kind);
1277
+ const text2 = requiredString(body.text, "text");
1278
+ void handlers.onConductorCommand(laneId, kind, text2).catch(
1279
+ (cause) => store.addConductorMessage("system", `conductor command failed: ${errorMessage(cause)}`)
1280
+ );
1281
+ sendJson(response, 202, { accepted: true });
1282
+ return;
1283
+ }
1284
+ if (request.method === "POST" && url.pathname === "/api/conductor/steer") {
1285
+ requireHost(store, request);
1286
+ const body = await readJson(request);
1287
+ const laneId = requiredString(body.laneId, "laneId");
1288
+ const text2 = requiredString(body.text, "text");
1289
+ await store.addConductorMessage("host", `requested steer -> ${laneId}: ${text2}`);
1290
+ void handlers.onConductorCommand(laneId, "steer_active_turn", text2).catch(
1291
+ (cause) => store.addConductorMessage("system", `conductor steer failed: ${errorMessage(cause)}`)
1292
+ );
1293
+ sendJson(response, 202, { accepted: true });
1294
+ return;
1295
+ }
1296
+ throw new RoomError(404, "not found");
1297
+ }
1298
+ function applyLaneEvent(lane, event) {
1299
+ lane.status = event.summary;
1300
+ if (event.kind === "lane.connected") lane.connected = true;
1301
+ if (event.kind === "lane.disconnected") lane.connected = false;
1302
+ if (event.kind === "lane.thread_attached") lane.threadId = stringPayload(event, "threadId");
1303
+ if (event.kind === "turn.started") lane.currentTurnId = stringPayload(event, "turnId");
1304
+ if (["turn.completed", "turn.failed"].includes(event.kind)) lane.currentTurnId = null;
1305
+ }
1306
+ function stringPayload(event, key) {
1307
+ const value = event.payload?.[key];
1308
+ return typeof value === "string" ? value : null;
1309
+ }
1310
+ function publicRepoName(repo) {
1311
+ return path5.basename(path5.resolve(repo)) || "<repo>";
1312
+ }
1313
+ var RoomError = class extends Error {
1314
+ status;
1315
+ constructor(status2, message) {
1316
+ super(message);
1317
+ this.status = status2;
1318
+ }
1319
+ };
1320
+ async function readJson(request) {
1321
+ const chunks = [];
1322
+ let size = 0;
1323
+ for await (const chunk of request) {
1324
+ const buffer = Buffer.from(chunk);
1325
+ size += buffer.length;
1326
+ if (size > 256e3) throw new RoomError(413, "request too large");
1327
+ chunks.push(buffer);
1328
+ }
1329
+ try {
1330
+ return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
1331
+ } catch {
1332
+ throw new RoomError(400, "valid JSON required");
1333
+ }
1334
+ }
1335
+ function requiredString(value, name) {
1336
+ if (typeof value !== "string" || !value.trim()) throw new RoomError(400, `${name} is required`);
1337
+ return value.trim();
1338
+ }
1339
+ function bearer(request) {
1340
+ const value = request.headers.authorization;
1341
+ if (!value?.startsWith("Bearer ")) throw new RoomError(401, "lane capability required");
1342
+ return value.slice("Bearer ".length);
1343
+ }
1344
+ function requireHost(store, request) {
1345
+ if (!store.authorizeHost(bearer(request))) throw new RoomError(401, "host capability required");
1346
+ }
1347
+ function requiredCommandKind(value) {
1348
+ if (![
1349
+ "suggest",
1350
+ "start_followup",
1351
+ "steer_active_turn",
1352
+ "request_status",
1353
+ "request_interrupt"
1354
+ ].includes(String(value))) {
1355
+ throw new RoomError(400, "valid command kind required");
1356
+ }
1357
+ return value;
1358
+ }
1359
+ function advertisedUrl(host2, port, configured) {
1360
+ if (configured) return new URL(configured).toString().replace(/\/$/, "");
1361
+ if (["0.0.0.0", "::"].includes(host2)) {
1362
+ throw new RoomError(400, "--public-url is required when binding to all interfaces");
1363
+ }
1364
+ const displayHost = host2.includes(":") ? `[${host2}]` : host2;
1365
+ return `http://${displayHost}:${port}`;
1366
+ }
1367
+ function capabilityUrl(base, kind, token) {
1368
+ const url = new URL(base);
1369
+ url.hash = new URLSearchParams({ [kind]: token }).toString();
1370
+ return url.toString();
1371
+ }
1372
+ function shellQuote(value) {
1373
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
1374
+ }
1375
+ function sendJson(response, status2, body) {
1376
+ sendText(response, status2, JSON.stringify(body), "application/json; charset=utf-8");
1377
+ }
1378
+ function sendText(response, status2, body, contentType) {
1379
+ response.writeHead(status2, {
1380
+ "content-type": contentType,
1381
+ "cache-control": "no-store",
1382
+ "x-content-type-options": "nosniff"
1383
+ });
1384
+ response.end(body);
1385
+ }
1386
+ function errorMessage(cause) {
1387
+ return cause instanceof Error ? cause.message : String(cause);
1388
+ }
1389
+
1390
+ // packages/cli/src/index.ts
1391
+ var execFileAsync = promisify(execFile);
1392
+ var [command = "help", ...args] = process.argv.slice(2);
1393
+ try {
1394
+ switch (command) {
1395
+ case "host":
1396
+ await host(args);
1397
+ break;
1398
+ case "join":
1399
+ await join(args);
1400
+ break;
1401
+ case "doctor":
1402
+ await doctor();
1403
+ break;
1404
+ case "status":
1405
+ await status(args);
1406
+ break;
1407
+ default:
1408
+ help();
1409
+ process.exitCode = command === "help" || command === "--help" ? 0 : 2;
1410
+ }
1411
+ } catch (cause) {
1412
+ process.stderr.write(`multicodex: ${cause instanceof Error ? cause.message : String(cause)}
1413
+ `);
1414
+ process.exitCode = 1;
1415
+ }
1416
+ async function host(args2) {
1417
+ const options = parseArgs(args2);
1418
+ const repo = path6.resolve(options.repo ?? ".");
1419
+ const port = Number(options.port ?? "7331");
1420
+ const bind = options.bind ?? "127.0.0.1";
1421
+ const stateDir = path6.resolve(options.state ?? path6.join(repo, ".multicodex", "host"));
1422
+ const store = options.resume ? await LocalRoomStore.load(stateDir) : await LocalRoomStore.create({
1423
+ stateDir,
1424
+ title: options.title ?? "MultiCodex local alpha",
1425
+ repo
1426
+ });
1427
+ const conductor = new LocalConductor(store, { repo, stateDir });
1428
+ const server = await startLocalRoomServer({
1429
+ store,
1430
+ port,
1431
+ host: bind,
1432
+ publicUrl: options["public-url"],
1433
+ handlers: {
1434
+ onConductorMessage: (text2) => conductor.message(text2),
1435
+ onConductorCommand: (laneId, kind, text2) => conductor.command(laneId, kind, text2)
1436
+ }
1437
+ });
1438
+ try {
1439
+ await conductor.initialize();
1440
+ } catch (cause) {
1441
+ await server.close();
1442
+ throw cause;
1443
+ }
1444
+ process.stdout.write(
1445
+ [
1446
+ "",
1447
+ "MultiCodex room ready",
1448
+ `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`,
1451
+ "conductor: local ACPx / Codex",
1452
+ "runtime: no Crabfleet, Crabbox, server OpenAI key, or GitHub token",
1453
+ ""
1454
+ ].join("\n")
1455
+ );
1456
+ await waitForSignal(async () => server.close());
1457
+ }
1458
+ async function join(args2) {
1459
+ const positional = args2.find((arg) => !arg.startsWith("-"));
1460
+ const options = parseArgs(positional ? args2.filter((arg) => arg !== positional) : args2);
1461
+ const server = positional ?? options.server ?? "http://127.0.0.1:7331";
1462
+ const policy = options.policy ?? "suggest";
1463
+ if (!["observe", "suggest", "steer"].includes(policy)) {
1464
+ throw new Error("policy must be observe, suggest, or steer");
1465
+ }
1466
+ const codexPath = await resolveUserCodexPath({ explicit: options.codex });
1467
+ if (!codexPath) {
1468
+ throw new Error(
1469
+ "user Codex not found outside package-local dependencies; install Codex or pass --codex"
1470
+ );
1471
+ }
1472
+ const bridge = new BuilderBridge({
1473
+ server,
1474
+ repo: path6.resolve(options.repo ?? "."),
1475
+ displayName: options.name ?? process.env.USER ?? "Builder",
1476
+ policy,
1477
+ codexPath,
1478
+ noTui: Boolean(options["no-tui"]),
1479
+ prompt: options.prompt,
1480
+ fresh: Boolean(options.fresh),
1481
+ statePath: options.state ? path6.resolve(options.state) : void 0
1482
+ });
1483
+ process.once("SIGINT", () => bridge.stop());
1484
+ process.once("SIGTERM", () => bridge.stop());
1485
+ await bridge.run();
1486
+ }
1487
+ async function doctor() {
1488
+ const checks = [];
1489
+ const [nodeMajor = 0, nodeMinor = 0] = process.versions.node.split(".").map(Number);
1490
+ checks.push([
1491
+ "Node",
1492
+ nodeMajor > 22 || nodeMajor === 22 && nodeMinor >= 13,
1493
+ `${process.version} (requires >=22.13.0)`
1494
+ ]);
1495
+ const codexPath = await resolveUserCodexPath();
1496
+ if (!codexPath) {
1497
+ checks.push(["Codex", false, "user install not found outside package-local dependencies"]);
1498
+ } else {
1499
+ try {
1500
+ const { stdout } = await execFileAsync(codexPath, ["--version"]);
1501
+ checks.push(["Codex", true, stdout.trim()]);
1502
+ } catch {
1503
+ checks.push(["Codex", false, "not found"]);
1504
+ }
1505
+ }
1506
+ checks.push(["WebSocket", typeof WebSocket === "function", "Node WebSocket client"]);
1507
+ for (const [name, ok, detail] of checks)
1508
+ process.stdout.write(`${ok ? "ok" : "fail"} ${name}: ${detail}
1509
+ `);
1510
+ if (checks.some(([, ok]) => !ok)) process.exitCode = 1;
1511
+ }
1512
+ async function status(args2) {
1513
+ const server = args2.find((arg) => !arg.startsWith("-")) ?? "http://127.0.0.1:7331";
1514
+ const response = await fetch(new URL("/api/snapshot", server));
1515
+ if (!response.ok) throw new Error(`status failed (${response.status})`);
1516
+ process.stdout.write(`${JSON.stringify(await response.json(), null, 2)}
1517
+ `);
1518
+ }
1519
+ function parseArgs(args2) {
1520
+ const values = {};
1521
+ for (let index = 0; index < args2.length; index += 1) {
1522
+ const argument = args2[index];
1523
+ if (!argument.startsWith("--")) continue;
1524
+ const key = argument.slice(2);
1525
+ const next = args2[index + 1];
1526
+ if (!next || next.startsWith("--")) values[key] = "true";
1527
+ else {
1528
+ values[key] = next;
1529
+ index += 1;
1530
+ }
1531
+ }
1532
+ return values;
1533
+ }
1534
+ function help() {
1535
+ process.stdout.write(`MultiCodex self-contained room
1536
+
1537
+ Usage:
1538
+ multicodex doctor
1539
+ multicodex host --repo . [--port 7331] [--bind 127.0.0.1] [--public-url <url>]
1540
+ multicodex join <invite-url> --repo . --name Builder [--policy observe|suggest|steer]
1541
+ multicodex status [room-url]
1542
+
1543
+ Options:
1544
+ --no-tui connect the bridge without launching the normal Codex TUI
1545
+ --prompt <text> start a builder turn after connecting
1546
+ --fresh create a new lane instead of resuming local lane state
1547
+ --state <path> override the builder lane state file
1548
+ --resume resume the host room state from --state
1549
+ `);
1550
+ }
1551
+ async function waitForSignal(cleanup) {
1552
+ await new Promise((resolve) => {
1553
+ const stop = () => void cleanup().finally(resolve);
1554
+ process.once("SIGINT", stop);
1555
+ process.once("SIGTERM", stop);
1556
+ });
1557
+ }
1558
+ function shellQuote2(value) {
1559
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
1560
+ }