This repository has no description
7.4 kB
173 lines
1diff --git a/packages/coding-agent/src/collab/host.ts b/packages/coding-agent/src/collab/host.ts
2index 5d9d103..722b01b 100644
3--- a/packages/coding-agent/src/collab/host.ts
4+++ b/packages/coding-agent/src/collab/host.ts
5@@ -21,6 +21,7 @@ import type {
6 AgentEvent as WireAgentEvent,
7 SessionEntry as WireSessionEntry,
8 } from "@oh-my-pi/pi-wire";
9+import { ROOM_KEY_BYTES, WRITE_TOKEN_BYTES } from "@oh-my-pi/pi-wire";
10 import type { InteractiveModeContext } from "../modes/types";
11 import { AgentLifecycleManager } from "../registry/agent-lifecycle";
12 import { type AgentRef, AgentRegistry } from "../registry/agent-registry";
13@@ -207,10 +208,14 @@ export class CollabHost {
14 }
15 }
16
17- async start(relayUrl: string, webUrl = ""): Promise<void> {
18- const rawKey = generateRoomKey();
19- const writeToken = generateWriteToken();
20- const roomId = generateRoomId();
21+ async start(
22+ relayUrl: string,
23+ webUrl = "",
24+ preset?: { roomId: string; key: Uint8Array; writeToken: Uint8Array },
25+ ): Promise<void> {
26+ const rawKey = preset?.key ?? generateRoomKey();
27+ const writeToken = preset?.writeToken ?? generateWriteToken();
28+ const roomId = preset?.roomId ?? generateRoomId();
29 this.#writeToken = writeToken;
30 this.#link = formatCollabLink(relayUrl, roomId, rawKey, writeToken);
31 this.#webLink = formatCollabWebLink(relayUrl, roomId, rawKey, writeToken, webUrl);
32@@ -289,6 +294,9 @@ export class CollabHost {
33 /** Broadcast a goodbye, detach all taps, and close the socket. */
34 async stop(reason: string): Promise<void> {
35 if (this.#stopped) return;
36+ // Only the slash commands stop with this reason; record it so the covibe
37+ // supervisor treats it as intent and does not resurrect the room.
38+ if (reason === USER_STOP_REASON) userStoppedHosting = true;
39 this.#socket?.send({ t: "bye", reason });
40 await this.#teardown();
41 }
42@@ -689,3 +697,103 @@ export class CollabHost {
43 this.#ctx.ui.requestRender();
44 }
45 }
46+
47+/**
48+ * covibe integration: host headlessly on a caller-owned room.
49+ *
50+ * OMP_COLLAB_RELAY/_ROOM/_KEY (key = base64url of key‖writeToken) let the
51+ * orchestrator own a stable, enumerable link instead of minting or scraping one.
52+ * Hosting is supervised because a host that goes away never comes back on its
53+ * own: CollabSocket treats 4001/4004/4009/4029 as fatal (no reconnect), and the
54+ * host also stops itself on a session switch — which happens seconds into a
55+ * fresh session, when the first prompt creates the real session id. A one-shot
56+ * start therefore loses the room for the rest of a multi-hour session. Since the
57+ * room id and key are fixed by the orchestrator, re-hosting restores the very
58+ * same link, so already-shared links and QR codes keep working.
59+ */
60+
61+/** Reason the /collab stop and /leave commands pass to {@link CollabHost.stop}. */
62+const USER_STOP_REASON = "host stopped";
63+const AUTOSTART_POLL_MS = 2_000;
64+const AUTOSTART_BACKOFF_BASE_MS = 1_000;
65+const AUTOSTART_BACKOFF_MAX_MS = 30_000;
66+
67+/** Set once the user explicitly stops hosting, disabling re-hosting. */
68+let userStoppedHosting = false;
69+
70+export interface CollabAutostartConfig {
71+ relayUrl: string;
72+ webUrl: string;
73+ preset?: { roomId: string; key: Uint8Array; writeToken: Uint8Array };
74+}
75+
76+/**
77+ * Read the autostart env and remove it from process.env. Consuming it matters:
78+ * children inherit the environment, so a nested omp (or a worker daemon) would
79+ * otherwise try to host the same room and the relay rejects one of them with
80+ * 4009 — if the child wins the race, the real session is locked out.
81+ */
82+export function readCollabAutostartEnv(): CollabAutostartConfig | undefined {
83+ const relayUrl = process.env.OMP_COLLAB_RELAY;
84+ const webUrl = process.env.OMP_COLLAB_WEB ?? "";
85+ const roomId = process.env.OMP_COLLAB_ROOM;
86+ const keyB64 = process.env.OMP_COLLAB_KEY;
87+ delete process.env.OMP_COLLAB_RELAY;
88+ delete process.env.OMP_COLLAB_WEB;
89+ delete process.env.OMP_COLLAB_ROOM;
90+ delete process.env.OMP_COLLAB_KEY;
91+ if (!relayUrl) return undefined;
92+ let preset: CollabAutostartConfig["preset"];
93+ if (roomId && keyB64) {
94+ const secret = new Uint8Array(Buffer.from(keyB64, "base64url"));
95+ if (secret.byteLength === ROOM_KEY_BYTES + WRITE_TOKEN_BYTES) {
96+ preset = {
97+ roomId,
98+ key: secret.subarray(0, ROOM_KEY_BYTES),
99+ writeToken: secret.subarray(ROOM_KEY_BYTES),
100+ };
101+ } else {
102+ logger.warn("collab autostart: OMP_COLLAB_KEY has unexpected length", {
103+ bytes: secret.byteLength,
104+ want: ROOM_KEY_BYTES + WRITE_TOKEN_BYTES,
105+ });
106+ }
107+ }
108+ return { relayUrl, webUrl, preset };
109+}
110+
111+/**
112+ * Host on the configured room and keep hosting for the life of the process:
113+ * whenever the host goes away without the user asking, start a new one on the
114+ * same room. No-ops when no relay is configured.
115+ */
116+export async function superviseAutostartCollab(ctx: InteractiveModeContext): Promise<void> {
117+ const cfg = readCollabAutostartEnv();
118+ if (!cfg) return;
119+ let attempt = 0;
120+ for (;;) {
121+ if (userStoppedHosting) return;
122+ // Joining someone else's session takes precedence over hosting ours.
123+ if (!ctx.collabHost && !ctx.collabGuest) {
124+ try {
125+ const host = new CollabHost(ctx);
126+ await host.start(cfg.relayUrl, cfg.webUrl, cfg.preset);
127+ ctx.collabHost = host;
128+ attempt = 0;
129+ logger.info("collab autostart: hosting", { room: cfg.preset?.roomId, relay: cfg.relayUrl });
130+ } catch (err) {
131+ attempt++;
132+ const delay = Math.min(AUTOSTART_BACKOFF_MAX_MS, AUTOSTART_BACKOFF_BASE_MS * 2 ** (attempt - 1));
133+ logger.warn("collab autostart: host start failed, retrying", {
134+ error: String(err),
135+ attempt,
136+ delayMs: delay,
137+ room: cfg.preset?.roomId,
138+ });
139+ await Bun.sleep(delay);
140+ continue;
141+ }
142+ }
143+ await Bun.sleep(AUTOSTART_POLL_MS);
144+ }
145+}
146diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts
147index 9a444a4..d38f4a2 100644
148--- a/packages/coding-agent/src/modes/interactive-mode.ts
149+++ b/packages/coding-agent/src/modes/interactive-mode.ts
150@@ -55,6 +55,7 @@ import chalk from "chalk";
151 import { reset as resetCapabilities } from "../capability";
152 import type { CollabGuestLink } from "../collab/guest";
153 import type { CollabHost } from "../collab/host";
154+import { superviseAutostartCollab } from "../collab/host";
155 import { KeybindingsManager } from "../config/keybindings";
156 import { formatModelString, type ResolvedModelRoleValue } from "../config/model-resolver";
157 import { applyProviderGlobalsFromSettings } from "../config/provider-globals";
158@@ -1137,6 +1138,15 @@ export class InteractiveMode implements InteractiveModeContext {
159 this.statusLine.watchBranch(() => {
160 this.ui.requestRender();
161 });
162+
163+ // covibe: headless collab hosting from env (OMP_COLLAB_*). Supervised and
164+ // fire-and-forget: the TUI never waits on the relay, and an involuntary
165+ // stop (relay drop, freed room, or the session-switch self-stop that fires
166+ // when the first prompt creates the real session id) re-hosts the same
167+ // room, so links and QR codes already shared stay valid.
168+ void superviseAutostartCollab(this).catch((err: unknown) =>
169+ logger.warn(`collab autostart supervisor failed: ${String(err)}`),
170+ );
171 }
172
173 /** Reload the title-generation system prompt override for the provided working