@hmanlab/multiplayer 0.4.3
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.
|
@@ -0,0 +1,2704 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __toESM = (mod, isNodeMode, target) => {
|
|
8
|
+
target = mod != null ? __create(__getProtoOf(mod)) : {};
|
|
9
|
+
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
|
|
10
|
+
for (let key of __getOwnPropNames(mod))
|
|
11
|
+
if (!__hasOwnProp.call(to, key))
|
|
12
|
+
__defProp(to, key, {
|
|
13
|
+
get: () => mod[key],
|
|
14
|
+
enumerable: true
|
|
15
|
+
});
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __export = (target, all) => {
|
|
19
|
+
for (var name in all)
|
|
20
|
+
__defProp(target, name, {
|
|
21
|
+
get: all[name],
|
|
22
|
+
enumerable: true,
|
|
23
|
+
configurable: true,
|
|
24
|
+
set: (newValue) => all[name] = () => newValue
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
28
|
+
var __require = import.meta.require;
|
|
29
|
+
|
|
30
|
+
// src/persistence/paths.ts
|
|
31
|
+
import { homedir } from "os";
|
|
32
|
+
import { join } from "path";
|
|
33
|
+
function stateDir() {
|
|
34
|
+
return join(homedir(), ".hl-plugins", "multiplayer");
|
|
35
|
+
}
|
|
36
|
+
function statePath() {
|
|
37
|
+
return join(stateDir(), "state.json");
|
|
38
|
+
}
|
|
39
|
+
function handlePath() {
|
|
40
|
+
return join(stateDir(), "handle");
|
|
41
|
+
}
|
|
42
|
+
var init_paths = () => {};
|
|
43
|
+
|
|
44
|
+
// src/persistence/paths-async.ts
|
|
45
|
+
var exports_paths_async = {};
|
|
46
|
+
__export(exports_paths_async, {
|
|
47
|
+
ensureStateDir: () => ensureStateDir
|
|
48
|
+
});
|
|
49
|
+
import { mkdir } from "fs/promises";
|
|
50
|
+
async function ensureStateDir() {
|
|
51
|
+
await mkdir(stateDir(), { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
var init_paths_async = __esm(() => {
|
|
54
|
+
init_paths();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// src/index.ts
|
|
58
|
+
import { tool } from "@opencode-ai/plugin";
|
|
59
|
+
|
|
60
|
+
// src/persistence/index.ts
|
|
61
|
+
init_paths();
|
|
62
|
+
|
|
63
|
+
// src/persistence/handle-file.ts
|
|
64
|
+
init_paths();
|
|
65
|
+
import { existsSync, readFileSync } from "fs";
|
|
66
|
+
|
|
67
|
+
// src/constants.ts
|
|
68
|
+
var DEFAULT_PORT = 7332;
|
|
69
|
+
var DEFAULT_HOST = "localhost";
|
|
70
|
+
var HANDLE_RE = /^[a-z0-9-]{1,16}$/;
|
|
71
|
+
var CODE_RE = /^mp-([a-z0-9-]{1,16})-([a-z0-9]{4})-([a-z0-9]{4})$/;
|
|
72
|
+
var ALPHA = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
73
|
+
var GRACE_S = 10;
|
|
74
|
+
var CASCADE_TIMEOUT_MS = 5000;
|
|
75
|
+
var REJOIN_TTL_MS = 60 * 60 * 1000;
|
|
76
|
+
var JOIN_TIMEOUT_MS = 5000;
|
|
77
|
+
var HISTORY_MAX = 50;
|
|
78
|
+
var MAX_COLLISION_ATTEMPTS = 50;
|
|
79
|
+
|
|
80
|
+
// src/handle/resolver.ts
|
|
81
|
+
function isValidHandle(handle) {
|
|
82
|
+
return HANDLE_RE.test(handle);
|
|
83
|
+
}
|
|
84
|
+
function normalizeHandle(raw) {
|
|
85
|
+
return raw.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 16);
|
|
86
|
+
}
|
|
87
|
+
function osUser() {
|
|
88
|
+
return process.env["USER"] ?? process.env["USERNAME"] ?? "anon";
|
|
89
|
+
}
|
|
90
|
+
// src/handle/codes.ts
|
|
91
|
+
function random4() {
|
|
92
|
+
let out = "";
|
|
93
|
+
for (let i = 0;i < 4; i++) {
|
|
94
|
+
out += ALPHA[Math.floor(Math.random() * ALPHA.length)];
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
function mintCode(handle) {
|
|
99
|
+
return `mp-${handle}-${random4()}-${random4()}`;
|
|
100
|
+
}
|
|
101
|
+
function isValidCode(code) {
|
|
102
|
+
return CODE_RE.test(code.toLowerCase());
|
|
103
|
+
}
|
|
104
|
+
// src/handle/collision.ts
|
|
105
|
+
function assignCollisionSuffix(base, taken) {
|
|
106
|
+
const takenSet = new Set;
|
|
107
|
+
for (const h of taken)
|
|
108
|
+
takenSet.add(h);
|
|
109
|
+
for (let attempt = 0;attempt < MAX_COLLISION_ATTEMPTS; attempt++) {
|
|
110
|
+
const suffix = random4();
|
|
111
|
+
const candidate = `${base}-${suffix}`.slice(0, 16);
|
|
112
|
+
if (isValidHandle(candidate) && !takenSet.has(candidate)) {
|
|
113
|
+
return candidate;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
let n = 2;
|
|
117
|
+
while (n < 1000) {
|
|
118
|
+
const candidate = `${base}-${n}`.slice(0, 16);
|
|
119
|
+
if (isValidHandle(candidate) && !takenSet.has(candidate))
|
|
120
|
+
return candidate;
|
|
121
|
+
n++;
|
|
122
|
+
}
|
|
123
|
+
return base.slice(0, 12);
|
|
124
|
+
}
|
|
125
|
+
// src/persistence/handle-file.ts
|
|
126
|
+
function readHandleFileSync() {
|
|
127
|
+
try {
|
|
128
|
+
const path = handlePath();
|
|
129
|
+
if (!existsSync(path))
|
|
130
|
+
return null;
|
|
131
|
+
const text = readFileSync(path, "utf-8").trim();
|
|
132
|
+
if (text.length === 0)
|
|
133
|
+
return null;
|
|
134
|
+
if (!isValidHandle(text))
|
|
135
|
+
return null;
|
|
136
|
+
return text;
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async function writeHandleFile(handle) {
|
|
142
|
+
const { ensureStateDir: ensureStateDir2 } = await Promise.resolve().then(() => (init_paths_async(), exports_paths_async));
|
|
143
|
+
await ensureStateDir2();
|
|
144
|
+
await Bun.write(handlePath(), handle);
|
|
145
|
+
}
|
|
146
|
+
// src/persistence/state-store.ts
|
|
147
|
+
import { rename } from "fs/promises";
|
|
148
|
+
init_paths();
|
|
149
|
+
init_paths_async();
|
|
150
|
+
function emptyState(handle) {
|
|
151
|
+
return { myHandle: handle, lastHostUrl: null, graceCodes: [], history: [] };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
class StateStore {
|
|
155
|
+
handleResolver;
|
|
156
|
+
constructor(handleResolver) {
|
|
157
|
+
this.handleResolver = handleResolver;
|
|
158
|
+
}
|
|
159
|
+
async read() {
|
|
160
|
+
const path = statePath();
|
|
161
|
+
const file = Bun.file(path);
|
|
162
|
+
if (!await file.exists())
|
|
163
|
+
return emptyState(this.handleResolver());
|
|
164
|
+
try {
|
|
165
|
+
const text = await file.text();
|
|
166
|
+
const parsed = JSON.parse(text);
|
|
167
|
+
return {
|
|
168
|
+
myHandle: typeof parsed.myHandle === "string" ? parsed.myHandle : this.handleResolver(),
|
|
169
|
+
lastHostUrl: typeof parsed.lastHostUrl === "string" ? parsed.lastHostUrl : null,
|
|
170
|
+
graceCodes: Array.isArray(parsed.graceCodes) ? parsed.graceCodes.filter((g) => typeof g === "object" && g !== null && typeof g.code === "string" && typeof g.handle === "string" && typeof g.validUntil === "number") : [],
|
|
171
|
+
history: Array.isArray(parsed.history) ? parsed.history.filter((h) => typeof h === "object" && h !== null && typeof h.ts === "number" && typeof h.event === "string") : []
|
|
172
|
+
};
|
|
173
|
+
} catch {
|
|
174
|
+
return emptyState(this.handleResolver());
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async writeAtomic(state) {
|
|
178
|
+
await ensureStateDir();
|
|
179
|
+
const path = statePath();
|
|
180
|
+
const tmp = `${path}.tmp`;
|
|
181
|
+
await Bun.write(tmp, JSON.stringify(state, null, 2));
|
|
182
|
+
await rename(tmp, path);
|
|
183
|
+
}
|
|
184
|
+
prune(state) {
|
|
185
|
+
const now = Date.now();
|
|
186
|
+
return {
|
|
187
|
+
...state,
|
|
188
|
+
graceCodes: state.graceCodes.filter((g) => g.validUntil > now)
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
pushHistory(state, entry) {
|
|
192
|
+
const history = [entry, ...state.history].slice(0, HISTORY_MAX);
|
|
193
|
+
return { ...state, history };
|
|
194
|
+
}
|
|
195
|
+
async recordHostStarted(handle, code) {
|
|
196
|
+
try {
|
|
197
|
+
const state = this.prune(await this.read());
|
|
198
|
+
const next = this.pushHistory({ ...state, myHandle: handle }, { ts: Date.now(), event: "host_started", handle, detail: code });
|
|
199
|
+
await this.writeAtomic(next);
|
|
200
|
+
} catch {}
|
|
201
|
+
}
|
|
202
|
+
async recordHostChanged(newHandle, newCode, oldCode, oldHandle, newUrl) {
|
|
203
|
+
try {
|
|
204
|
+
const state = this.prune(await this.read());
|
|
205
|
+
const validUntil = Date.now() + REJOIN_TTL_MS;
|
|
206
|
+
const graceCodes = [...state.graceCodes, { code: oldCode, handle: oldHandle, validUntil }];
|
|
207
|
+
const next = this.pushHistory({ ...state, myHandle: newHandle, graceCodes }, {
|
|
208
|
+
ts: Date.now(),
|
|
209
|
+
event: "host_changed",
|
|
210
|
+
handle: newHandle,
|
|
211
|
+
detail: `from:${oldHandle} newCode:${newCode} url:${newUrl}`
|
|
212
|
+
});
|
|
213
|
+
await this.writeAtomic(next);
|
|
214
|
+
} catch {}
|
|
215
|
+
}
|
|
216
|
+
async recordSessionEnded(handle, reason) {
|
|
217
|
+
try {
|
|
218
|
+
const state = this.prune(await this.read());
|
|
219
|
+
const next = this.pushHistory({ ...state, myHandle: handle }, { ts: Date.now(), event: "session_ended", handle, detail: reason });
|
|
220
|
+
await this.writeAtomic(next);
|
|
221
|
+
} catch {}
|
|
222
|
+
}
|
|
223
|
+
async recordGuestJoined(handle, hostUrl) {
|
|
224
|
+
try {
|
|
225
|
+
const state = this.prune(await this.read());
|
|
226
|
+
const next = this.pushHistory({ ...state, lastHostUrl: hostUrl }, { ts: Date.now(), event: "guest_joined", handle });
|
|
227
|
+
await this.writeAtomic(next);
|
|
228
|
+
} catch {}
|
|
229
|
+
}
|
|
230
|
+
async recordGuestPromoted(newHandle, newCode, oldCode, oldHandle) {
|
|
231
|
+
try {
|
|
232
|
+
const state = this.prune(await this.read());
|
|
233
|
+
const validUntil = Date.now() + REJOIN_TTL_MS;
|
|
234
|
+
const graceCodes = [...state.graceCodes, { code: oldCode, handle: oldHandle, validUntil }];
|
|
235
|
+
const next = this.pushHistory({ ...state, myHandle: newHandle, graceCodes }, {
|
|
236
|
+
ts: Date.now(),
|
|
237
|
+
event: "host_changed",
|
|
238
|
+
handle: newHandle,
|
|
239
|
+
detail: `promoted:old=${oldHandle} oldCode=${oldCode}`
|
|
240
|
+
});
|
|
241
|
+
await this.writeAtomic(next);
|
|
242
|
+
} catch {}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// src/env/resolve.ts
|
|
246
|
+
function resolvePort() {
|
|
247
|
+
const raw = process.env["MP_PORT"];
|
|
248
|
+
if (!raw)
|
|
249
|
+
return DEFAULT_PORT;
|
|
250
|
+
const n = parseInt(raw, 10);
|
|
251
|
+
if (!Number.isFinite(n) || n < 1 || n > 65535)
|
|
252
|
+
return DEFAULT_PORT;
|
|
253
|
+
return n;
|
|
254
|
+
}
|
|
255
|
+
function resolveHost() {
|
|
256
|
+
const raw = process.env["MP_HOST"];
|
|
257
|
+
if (raw && raw.trim().length > 0)
|
|
258
|
+
return raw.trim();
|
|
259
|
+
return DEFAULT_HOST;
|
|
260
|
+
}
|
|
261
|
+
// src/server/host-server.ts
|
|
262
|
+
async function startHostServer(opts) {
|
|
263
|
+
try {
|
|
264
|
+
const server = Bun.serve({
|
|
265
|
+
port: opts.port,
|
|
266
|
+
hostname: opts.host,
|
|
267
|
+
fetch(req, srv) {
|
|
268
|
+
const upgraded = srv.upgrade(req, {
|
|
269
|
+
data: { state: "awaiting_auth" }
|
|
270
|
+
});
|
|
271
|
+
if (upgraded)
|
|
272
|
+
return;
|
|
273
|
+
return new Response("multiplayer: websocket only", { status: 400 });
|
|
274
|
+
},
|
|
275
|
+
websocket: {
|
|
276
|
+
message(ws, raw) {
|
|
277
|
+
opts.handlers.onMessage(ws, raw);
|
|
278
|
+
},
|
|
279
|
+
close(ws) {
|
|
280
|
+
opts.handlers.onClose(ws);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
return { ok: true, server };
|
|
285
|
+
} catch (e) {
|
|
286
|
+
const err = e;
|
|
287
|
+
if (err?.code === "EADDRINUSE") {
|
|
288
|
+
return { ok: false, reason: `port_${opts.port}_busy` };
|
|
289
|
+
}
|
|
290
|
+
return { ok: false, reason: `start_failed: ${e.message}` };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// src/role/peer-helpers.ts
|
|
294
|
+
function peerListForBroadcast(peers) {
|
|
295
|
+
const out = [];
|
|
296
|
+
for (const p of peers.values()) {
|
|
297
|
+
if (p.handle === "__pending__")
|
|
298
|
+
continue;
|
|
299
|
+
out.push({ handle: p.handle, joinedAt: p.joinedAt });
|
|
300
|
+
}
|
|
301
|
+
out.sort((a, b) => a.joinedAt - b.joinedAt);
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// src/role/host-role.ts
|
|
306
|
+
class HostRole {
|
|
307
|
+
opts;
|
|
308
|
+
kind = "host";
|
|
309
|
+
server = null;
|
|
310
|
+
code = null;
|
|
311
|
+
handle;
|
|
312
|
+
peers = new Map;
|
|
313
|
+
volunteers = new Set;
|
|
314
|
+
graceCodes = new Set;
|
|
315
|
+
constructor(opts) {
|
|
316
|
+
this.opts = opts;
|
|
317
|
+
this.handle = opts.handle;
|
|
318
|
+
}
|
|
319
|
+
getCode() {
|
|
320
|
+
return this.code;
|
|
321
|
+
}
|
|
322
|
+
getHandle() {
|
|
323
|
+
return this.handle;
|
|
324
|
+
}
|
|
325
|
+
getPeers() {
|
|
326
|
+
return this.peers;
|
|
327
|
+
}
|
|
328
|
+
acceptVolunteer(handle) {
|
|
329
|
+
this.volunteers.add(handle);
|
|
330
|
+
}
|
|
331
|
+
isVolunteer(handle) {
|
|
332
|
+
return this.volunteers.has(handle);
|
|
333
|
+
}
|
|
334
|
+
takenHandles() {
|
|
335
|
+
const out = [];
|
|
336
|
+
if (this.handle)
|
|
337
|
+
out.push(this.handle);
|
|
338
|
+
for (const p of this.peers.values()) {
|
|
339
|
+
if (p.handle !== "__pending__")
|
|
340
|
+
out.push(p.handle);
|
|
341
|
+
}
|
|
342
|
+
return out;
|
|
343
|
+
}
|
|
344
|
+
broadcast(msg, except) {
|
|
345
|
+
for (const ws of this.peers.keys()) {
|
|
346
|
+
if (except && ws === except)
|
|
347
|
+
continue;
|
|
348
|
+
try {
|
|
349
|
+
ws.send(JSON.stringify(msg));
|
|
350
|
+
} catch {}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
sendChat(text) {
|
|
354
|
+
const trimmed = text.trim();
|
|
355
|
+
if (trimmed.length === 0)
|
|
356
|
+
return { ok: false, reason: "empty" };
|
|
357
|
+
if (trimmed.length > 4000)
|
|
358
|
+
return { ok: false, reason: "too_long" };
|
|
359
|
+
if (!this.server)
|
|
360
|
+
return { ok: false, reason: "not_hosting" };
|
|
361
|
+
const ts = Date.now();
|
|
362
|
+
const msg = { type: "chat", from: this.handle, text: trimmed, ts };
|
|
363
|
+
this.broadcast(msg);
|
|
364
|
+
return { ok: true, ts, peers: this.peers.size };
|
|
365
|
+
}
|
|
366
|
+
sendTyping(state) {
|
|
367
|
+
if (!this.server)
|
|
368
|
+
return;
|
|
369
|
+
const msg = { type: "typing", from: this.handle, state };
|
|
370
|
+
this.broadcast(msg);
|
|
371
|
+
}
|
|
372
|
+
broadcastPeersUpdate() {
|
|
373
|
+
const peers = peerListForBroadcast(this.peers);
|
|
374
|
+
this.broadcast({ type: "peers_update", peers });
|
|
375
|
+
this.opts.onPeersChanged?.(peers);
|
|
376
|
+
}
|
|
377
|
+
findPeerWs(handle) {
|
|
378
|
+
for (const [ws, peer] of this.peers.entries()) {
|
|
379
|
+
if (peer.handle === handle)
|
|
380
|
+
return ws;
|
|
381
|
+
}
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
findPeerByHandle(handle) {
|
|
385
|
+
return this.findPeerWs(handle);
|
|
386
|
+
}
|
|
387
|
+
sendToPeer(ws, msg) {
|
|
388
|
+
try {
|
|
389
|
+
ws.send(JSON.stringify(msg));
|
|
390
|
+
} catch {}
|
|
391
|
+
}
|
|
392
|
+
async onPeerClose(ws) {
|
|
393
|
+
if (ws.data.state === "authenticated") {
|
|
394
|
+
const peer = ws.data.peer;
|
|
395
|
+
this.peers.delete(ws);
|
|
396
|
+
if (peer.handle !== "__pending__") {
|
|
397
|
+
this.volunteers.delete(peer.handle);
|
|
398
|
+
await this.opts.logger.log("info", "peer disconnected", { handle: peer.handle });
|
|
399
|
+
await this.opts.toaster.show(`peer disconnected (${peer.handle})`, "warning", "multiplayer");
|
|
400
|
+
this.broadcastPeersUpdate();
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
async onMessage(ws, raw) {
|
|
405
|
+
const text = typeof raw === "string" ? raw : raw.toString("utf8");
|
|
406
|
+
let msg;
|
|
407
|
+
try {
|
|
408
|
+
msg = JSON.parse(text);
|
|
409
|
+
} catch {
|
|
410
|
+
this.sendToPeer(ws, { type: "auth_fail", reason: "invalid_json" });
|
|
411
|
+
this.sendToPeer(ws, { type: "bye" });
|
|
412
|
+
try {
|
|
413
|
+
ws.close();
|
|
414
|
+
} catch {}
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (ws.data.state === "awaiting_auth") {
|
|
418
|
+
if (msg.type !== "auth") {
|
|
419
|
+
this.sendToPeer(ws, { type: "auth_fail", reason: "expected_auth" });
|
|
420
|
+
this.sendToPeer(ws, { type: "bye" });
|
|
421
|
+
try {
|
|
422
|
+
ws.close();
|
|
423
|
+
} catch {}
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const code = msg.code;
|
|
427
|
+
if (!isValidCode(code)) {
|
|
428
|
+
this.sendToPeer(ws, { type: "auth_fail", reason: "invalid_code" });
|
|
429
|
+
this.sendToPeer(ws, { type: "bye" });
|
|
430
|
+
try {
|
|
431
|
+
ws.close();
|
|
432
|
+
} catch {}
|
|
433
|
+
await this.opts.toaster.show("guest sent an invalid code", "warning", "multiplayer");
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const normalized = code.toLowerCase();
|
|
437
|
+
const isCurrent = this.code !== null && normalized === this.code;
|
|
438
|
+
const isGrace = !isCurrent && this.graceCodes.has(normalized);
|
|
439
|
+
if (!isCurrent && !isGrace) {
|
|
440
|
+
this.sendToPeer(ws, { type: "auth_fail", reason: "unknown_code" });
|
|
441
|
+
this.sendToPeer(ws, { type: "bye" });
|
|
442
|
+
try {
|
|
443
|
+
ws.close();
|
|
444
|
+
} catch {}
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const peer = { handle: "__pending__", joinedAt: Date.now(), isVolunteer: false };
|
|
448
|
+
ws.data = { state: "authenticated", peer };
|
|
449
|
+
this.peers.set(ws, peer);
|
|
450
|
+
this.sendToPeer(ws, { type: "auth_ok", handle: this.handle ?? "host" });
|
|
451
|
+
this.sendToPeer(ws, {
|
|
452
|
+
type: "welcome",
|
|
453
|
+
handle: this.handle ?? "host",
|
|
454
|
+
peers: peerListForBroadcast(this.peers)
|
|
455
|
+
});
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (msg.type === "hello") {
|
|
459
|
+
const requested = (msg.handle ?? "").toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 16);
|
|
460
|
+
const peer = ws.data.peer;
|
|
461
|
+
const existing = this.takenHandles();
|
|
462
|
+
let assigned = requested;
|
|
463
|
+
if (existing.includes(assigned)) {
|
|
464
|
+
assigned = assignCollisionSuffix(requested, existing);
|
|
465
|
+
}
|
|
466
|
+
peer.handle = assigned;
|
|
467
|
+
await this.opts.logger.log("info", "peer connected", { guestHandle: assigned });
|
|
468
|
+
await this.opts.toaster.show(`\u2713 peer connected (${assigned})`, "success", "multiplayer");
|
|
469
|
+
this.broadcastPeersUpdate();
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (msg.type === "volunteer") {
|
|
473
|
+
const peer = ws.data.peer;
|
|
474
|
+
if (peer.handle === "__pending__")
|
|
475
|
+
return;
|
|
476
|
+
peer.isVolunteer = true;
|
|
477
|
+
this.volunteers.add(peer.handle);
|
|
478
|
+
await this.opts.logger.log("info", "peer volunteered", { handle: peer.handle });
|
|
479
|
+
await this.opts.toaster.show(`volunteer accepted (${peer.handle})`, "info", "multiplayer");
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (msg.type === "bye") {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (msg.type === "chat") {
|
|
486
|
+
const peer = ws.data.peer;
|
|
487
|
+
if (peer.handle === "__pending__")
|
|
488
|
+
return;
|
|
489
|
+
const m = msg;
|
|
490
|
+
const text2 = typeof m.text === "string" ? m.text : "";
|
|
491
|
+
if (text2.length === 0 || text2.length > 4000)
|
|
492
|
+
return;
|
|
493
|
+
const ts = typeof m.ts === "number" ? m.ts : Date.now();
|
|
494
|
+
const out = { type: "chat", from: peer.handle, text: text2, ts };
|
|
495
|
+
this.broadcast(out, ws);
|
|
496
|
+
this.opts.onChatReceived?.({ from: peer.handle, text: text2, ts });
|
|
497
|
+
await this.opts.logger.log("debug", "host: chat fan-out", {
|
|
498
|
+
from: peer.handle,
|
|
499
|
+
peers: this.peers.size - 1
|
|
500
|
+
});
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (msg.type === "typing") {
|
|
504
|
+
const peer = ws.data.peer;
|
|
505
|
+
if (peer.handle === "__pending__")
|
|
506
|
+
return;
|
|
507
|
+
const state = msg.state === "stop" ? "stop" : "start";
|
|
508
|
+
const out = { type: "typing", from: peer.handle, state };
|
|
509
|
+
this.broadcast(out, ws);
|
|
510
|
+
this.opts.onTypingReceived?.(peer.handle, state);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
await this.opts.logger.log("warn", "host: unexpected message", { msg, state: ws.data.state });
|
|
514
|
+
}
|
|
515
|
+
addGraceCode(code) {
|
|
516
|
+
this.graceCodes.add(code.toLowerCase());
|
|
517
|
+
}
|
|
518
|
+
hasGraceCode(code) {
|
|
519
|
+
return this.graceCodes.has(code.toLowerCase());
|
|
520
|
+
}
|
|
521
|
+
async start() {
|
|
522
|
+
this.code = mintCode(this.handle);
|
|
523
|
+
const code = this.code;
|
|
524
|
+
const url = `ws://${this.opts.host}:${this.opts.port}`;
|
|
525
|
+
try {
|
|
526
|
+
const state = this.opts.state.prune(await this.opts.state.read());
|
|
527
|
+
for (const g of state.graceCodes) {
|
|
528
|
+
this.graceCodes.add(g.code);
|
|
529
|
+
}
|
|
530
|
+
} catch {}
|
|
531
|
+
const handlers = {
|
|
532
|
+
onMessage: (ws, raw) => {
|
|
533
|
+
this.onMessage(ws, raw);
|
|
534
|
+
},
|
|
535
|
+
onClose: (ws) => {
|
|
536
|
+
this.onPeerClose(ws);
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
const result = await startHostServer({ port: this.opts.port, host: this.opts.host, handlers });
|
|
540
|
+
if (!result.ok) {
|
|
541
|
+
this.code = null;
|
|
542
|
+
this.handle = "";
|
|
543
|
+
if (result.reason.startsWith("port_")) {
|
|
544
|
+
await this.opts.logger.log("warn", "host start failed: port in use", { port: this.opts.port });
|
|
545
|
+
} else {
|
|
546
|
+
await this.opts.logger.log("error", "host start failed", { error: result.reason });
|
|
547
|
+
}
|
|
548
|
+
return { ok: false, reason: result.reason };
|
|
549
|
+
}
|
|
550
|
+
this.server = result.server;
|
|
551
|
+
await this.opts.state.recordHostStarted(this.handle, code);
|
|
552
|
+
await this.opts.logger.log("info", "host started", {
|
|
553
|
+
handle: this.handle,
|
|
554
|
+
port: this.opts.port,
|
|
555
|
+
code,
|
|
556
|
+
url
|
|
557
|
+
});
|
|
558
|
+
await this.opts.toaster.show(`invite: ${code}`, "success", "multiplayer");
|
|
559
|
+
await this.opts.toaster.show(`hosting on ${url}`, "info", "multiplayer");
|
|
560
|
+
return { ok: true, code, url };
|
|
561
|
+
}
|
|
562
|
+
stop() {
|
|
563
|
+
if (this.server) {
|
|
564
|
+
try {
|
|
565
|
+
this.server.stop(true);
|
|
566
|
+
} catch {}
|
|
567
|
+
this.server = null;
|
|
568
|
+
}
|
|
569
|
+
this.code = null;
|
|
570
|
+
this.handle = "";
|
|
571
|
+
this.peers = new Map;
|
|
572
|
+
this.volunteers = new Set;
|
|
573
|
+
}
|
|
574
|
+
dispose() {
|
|
575
|
+
this.stop();
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
// src/role/guest-role.ts
|
|
579
|
+
class GuestRole {
|
|
580
|
+
opts;
|
|
581
|
+
kind = "guest";
|
|
582
|
+
ws = null;
|
|
583
|
+
hostHandle = null;
|
|
584
|
+
myHandle = null;
|
|
585
|
+
hostUrl = null;
|
|
586
|
+
endedReason = null;
|
|
587
|
+
reconnectFn = null;
|
|
588
|
+
peerList = [];
|
|
589
|
+
constructor(opts) {
|
|
590
|
+
this.opts = opts;
|
|
591
|
+
}
|
|
592
|
+
getHostHandle() {
|
|
593
|
+
return this.hostHandle;
|
|
594
|
+
}
|
|
595
|
+
getMyHandle() {
|
|
596
|
+
return this.myHandle;
|
|
597
|
+
}
|
|
598
|
+
getHostUrl() {
|
|
599
|
+
return this.hostUrl;
|
|
600
|
+
}
|
|
601
|
+
getPeerList() {
|
|
602
|
+
return this.peerList;
|
|
603
|
+
}
|
|
604
|
+
isConnected() {
|
|
605
|
+
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
|
606
|
+
}
|
|
607
|
+
getWs() {
|
|
608
|
+
return this.ws;
|
|
609
|
+
}
|
|
610
|
+
getEndedReason() {
|
|
611
|
+
return this.endedReason;
|
|
612
|
+
}
|
|
613
|
+
async dial(code, mode) {
|
|
614
|
+
const wsUrl = `ws://${this.opts.host}:${this.opts.port}`;
|
|
615
|
+
const ws = new WebSocket(wsUrl);
|
|
616
|
+
this.ws = ws;
|
|
617
|
+
return await new Promise((resolve) => {
|
|
618
|
+
let resolved = false;
|
|
619
|
+
const finish = (result) => {
|
|
620
|
+
if (resolved)
|
|
621
|
+
return;
|
|
622
|
+
resolved = true;
|
|
623
|
+
resolve(result);
|
|
624
|
+
};
|
|
625
|
+
const timeout = setTimeout(() => {
|
|
626
|
+
try {
|
|
627
|
+
ws.close();
|
|
628
|
+
} catch {}
|
|
629
|
+
this.opts.toaster.show(`join timed out (no host at ${wsUrl})`, "error", "multiplayer");
|
|
630
|
+
this.opts.logger.log("warn", "guest dial timed out", { code, wsUrl });
|
|
631
|
+
finish({ ok: false, reason: "timeout" });
|
|
632
|
+
}, JOIN_TIMEOUT_MS);
|
|
633
|
+
ws.addEventListener("open", () => {
|
|
634
|
+
ws.send(JSON.stringify({ type: "auth", code: code.toLowerCase() }));
|
|
635
|
+
});
|
|
636
|
+
ws.addEventListener("message", async (e) => {
|
|
637
|
+
let msg;
|
|
638
|
+
try {
|
|
639
|
+
msg = JSON.parse(e.data);
|
|
640
|
+
} catch {
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
if (msg.type === "auth_fail") {
|
|
644
|
+
clearTimeout(timeout);
|
|
645
|
+
try {
|
|
646
|
+
ws.close();
|
|
647
|
+
} catch {}
|
|
648
|
+
await this.opts.toaster.show(`join failed: ${msg.reason}`, "error", "multiplayer");
|
|
649
|
+
await this.opts.logger.log("info", "guest auth rejected", { reason: msg.reason });
|
|
650
|
+
finish({ ok: false, reason: msg.reason });
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (msg.type === "auth_ok") {
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (msg.type === "welcome") {
|
|
657
|
+
ws.send(JSON.stringify({ type: "hello", handle: this.opts.handle }));
|
|
658
|
+
this.ws = ws;
|
|
659
|
+
this.hostHandle = msg.handle;
|
|
660
|
+
this.hostUrl = wsUrl;
|
|
661
|
+
this.myHandle = this.opts.handle;
|
|
662
|
+
this.peerList = Array.isArray(msg.peers) ? msg.peers.filter((p) => p && typeof p.handle === "string" && typeof p.joinedAt === "number") : [];
|
|
663
|
+
clearTimeout(timeout);
|
|
664
|
+
await this.opts.logger.log("info", "guest joined", {
|
|
665
|
+
hostHandle: msg.handle,
|
|
666
|
+
requestedHandle: this.opts.handle,
|
|
667
|
+
mode
|
|
668
|
+
});
|
|
669
|
+
if (mode === "rejoin") {
|
|
670
|
+
await this.opts.toaster.show(`\u2713 rejoined as guest (${this.opts.handle})`, "success", "multiplayer");
|
|
671
|
+
} else {
|
|
672
|
+
await this.opts.toaster.show(`\u2713 connected to ${msg.handle}`, "success", "multiplayer");
|
|
673
|
+
}
|
|
674
|
+
await this.opts.state.recordGuestJoined(this.opts.handle, wsUrl);
|
|
675
|
+
finish({ ok: true, hostHandle: msg.handle, myHandle: this.opts.handle });
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
if (msg.type === "peers_update") {
|
|
679
|
+
this.peerList = Array.isArray(msg.peers) ? msg.peers.filter((p) => p && typeof p.handle === "string" && typeof p.joinedAt === "number") : [];
|
|
680
|
+
this.opts.onPeersChanged?.(this.peerList);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
if (msg.type === "host_leaving") {
|
|
684
|
+
await this.opts.toaster.show(`host leaving in ${msg.grace_s}s`, "warning", "multiplayer");
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
if (msg.type === "chat") {
|
|
688
|
+
const m = msg;
|
|
689
|
+
const text = typeof m.text === "string" ? m.text : "";
|
|
690
|
+
const from = typeof m.from === "string" ? m.from : this.hostHandle ?? "host";
|
|
691
|
+
const ts = typeof m.ts === "number" ? m.ts : Date.now();
|
|
692
|
+
const truncated = text.length > 200 ? text.slice(0, 200) + "\u2026" : text;
|
|
693
|
+
await this.opts.toaster.show(`${from}: ${truncated}`, "info", "chat");
|
|
694
|
+
await this.opts.logger.log("debug", "guest: chat received", { from, len: text.length });
|
|
695
|
+
this.opts.onChatReceived?.({ from, text, ts });
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
if (msg.type === "typing") {
|
|
699
|
+
const m = msg;
|
|
700
|
+
const state = m.state === "stop" ? "stop" : "start";
|
|
701
|
+
const from = typeof m.from === "string" ? m.from : this.hostHandle ?? "host";
|
|
702
|
+
if (state === "start") {
|
|
703
|
+
await this.opts.toaster.show(`${from} is typing\u2026`, "info", "chat");
|
|
704
|
+
}
|
|
705
|
+
this.opts.onTypingReceived?.(from, state);
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
if (msg.type === "leave_cancelled") {
|
|
709
|
+
await this.opts.toaster.show("host cancelled leave", "info", "multiplayer");
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
if (msg.type === "transfer_to_me") {
|
|
713
|
+
if (!this.opts.promote)
|
|
714
|
+
return;
|
|
715
|
+
if (!this.ws)
|
|
716
|
+
return;
|
|
717
|
+
const oldWs = this.ws;
|
|
718
|
+
const oldUrl = this.hostUrl ?? "";
|
|
719
|
+
clearTimeout(timeout);
|
|
720
|
+
const result = await this.opts.promote(msg, oldWs, oldUrl);
|
|
721
|
+
if (result.ok) {
|
|
722
|
+
try {
|
|
723
|
+
oldWs.send(JSON.stringify({
|
|
724
|
+
type: "transfer_confirmed",
|
|
725
|
+
new_code: result.newCode,
|
|
726
|
+
new_url: result.newUrl
|
|
727
|
+
}));
|
|
728
|
+
} catch {}
|
|
729
|
+
try {
|
|
730
|
+
oldWs.close();
|
|
731
|
+
} catch {}
|
|
732
|
+
} else {
|
|
733
|
+
try {
|
|
734
|
+
oldWs.send(JSON.stringify({
|
|
735
|
+
type: "transfer_failed",
|
|
736
|
+
reason: result.reason
|
|
737
|
+
}));
|
|
738
|
+
} catch {}
|
|
739
|
+
try {
|
|
740
|
+
oldWs.close();
|
|
741
|
+
} catch {}
|
|
742
|
+
this.opts.ended?.(`promote_failed: ${result.reason}`);
|
|
743
|
+
this.ws = null;
|
|
744
|
+
this.hostHandle = null;
|
|
745
|
+
this.myHandle = null;
|
|
746
|
+
this.hostUrl = null;
|
|
747
|
+
await this.opts.toaster.show(`promotion failed: ${result.reason}`, "error", "multiplayer");
|
|
748
|
+
}
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
if (msg.type === "transfer_start") {
|
|
752
|
+
clearTimeout(timeout);
|
|
753
|
+
if (this.ws) {
|
|
754
|
+
try {
|
|
755
|
+
this.ws.close();
|
|
756
|
+
} catch {}
|
|
757
|
+
this.ws = null;
|
|
758
|
+
}
|
|
759
|
+
const oldUrl = this.hostUrl ?? "";
|
|
760
|
+
const newHandle = typeof msg.new_handle === "string" ? msg.new_handle : "host";
|
|
761
|
+
const newUrl = typeof msg.new_url === "string" ? msg.new_url : oldUrl;
|
|
762
|
+
const newCode = typeof msg.new_code === "string" ? msg.new_code : "";
|
|
763
|
+
await this.opts.toaster.show(`transferring to ${newHandle} (${newUrl})`, "info", "multiplayer");
|
|
764
|
+
if (this.opts.reconnect && newCode) {
|
|
765
|
+
await this.opts.reconnect(newCode, newUrl);
|
|
766
|
+
}
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
if (msg.type === "session_ended") {
|
|
770
|
+
clearTimeout(timeout);
|
|
771
|
+
if (this.ws) {
|
|
772
|
+
try {
|
|
773
|
+
this.ws.close();
|
|
774
|
+
} catch {}
|
|
775
|
+
this.ws = null;
|
|
776
|
+
}
|
|
777
|
+
this.hostHandle = null;
|
|
778
|
+
this.myHandle = null;
|
|
779
|
+
this.hostUrl = null;
|
|
780
|
+
const reason = typeof msg.reason === "string" ? msg.reason : "unknown";
|
|
781
|
+
this.endedReason = reason;
|
|
782
|
+
await this.opts.toaster.show(`session ended: ${reason}`, "warning", "multiplayer");
|
|
783
|
+
this.opts.ended?.(reason);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
ws.addEventListener("close", async () => {
|
|
788
|
+
if (resolved)
|
|
789
|
+
return;
|
|
790
|
+
clearTimeout(timeout);
|
|
791
|
+
await this.opts.toaster.show(`could not reach host at ${wsUrl}`, "error", "multiplayer");
|
|
792
|
+
await this.opts.logger.log("error", "guest ws closed before completion", { wsUrl });
|
|
793
|
+
finish({ ok: false, reason: "closed" });
|
|
794
|
+
});
|
|
795
|
+
ws.addEventListener("error", async () => {
|
|
796
|
+
if (resolved)
|
|
797
|
+
return;
|
|
798
|
+
clearTimeout(timeout);
|
|
799
|
+
await this.opts.toaster.show(`could not reach host at ${wsUrl}`, "error", "multiplayer");
|
|
800
|
+
await this.opts.logger.log("error", "guest ws error", { wsUrl });
|
|
801
|
+
finish({ ok: false, reason: "error" });
|
|
802
|
+
});
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
sendVolunteer() {
|
|
806
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
807
|
+
this.ws.send(JSON.stringify({ type: "volunteer" }));
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
sendChat(text) {
|
|
811
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
812
|
+
return { ok: false, reason: "not_connected" };
|
|
813
|
+
}
|
|
814
|
+
const trimmed = text.trim();
|
|
815
|
+
if (trimmed.length === 0)
|
|
816
|
+
return { ok: false, reason: "empty" };
|
|
817
|
+
if (trimmed.length > 4000)
|
|
818
|
+
return { ok: false, reason: "too_long" };
|
|
819
|
+
const ts = Date.now();
|
|
820
|
+
const handle = this.myHandle ?? this.opts.handle;
|
|
821
|
+
this.ws.send(JSON.stringify({ type: "chat", from: handle, text: trimmed, ts }));
|
|
822
|
+
return { ok: true, ts };
|
|
823
|
+
}
|
|
824
|
+
sendTyping(state) {
|
|
825
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
|
|
826
|
+
return;
|
|
827
|
+
const handle = this.myHandle ?? this.opts.handle;
|
|
828
|
+
this.ws.send(JSON.stringify({ type: "typing", from: handle, state }));
|
|
829
|
+
}
|
|
830
|
+
leave() {
|
|
831
|
+
if (this.ws) {
|
|
832
|
+
try {
|
|
833
|
+
this.ws.send(JSON.stringify({ type: "bye" }));
|
|
834
|
+
} catch {}
|
|
835
|
+
try {
|
|
836
|
+
this.ws.close();
|
|
837
|
+
} catch {}
|
|
838
|
+
}
|
|
839
|
+
this.ws = null;
|
|
840
|
+
this.hostHandle = null;
|
|
841
|
+
this.myHandle = null;
|
|
842
|
+
this.hostUrl = null;
|
|
843
|
+
}
|
|
844
|
+
dispose() {
|
|
845
|
+
this.leave();
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
// src/role/transfer-controller.ts
|
|
849
|
+
function sendToPeer(ws, msg) {
|
|
850
|
+
try {
|
|
851
|
+
ws.send(JSON.stringify(msg));
|
|
852
|
+
} catch {}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
class TransferController {
|
|
856
|
+
cb;
|
|
857
|
+
graceMs;
|
|
858
|
+
cascadeMs;
|
|
859
|
+
state = "none";
|
|
860
|
+
leaveTimer = null;
|
|
861
|
+
transferTimer = null;
|
|
862
|
+
queue = [];
|
|
863
|
+
snapshot = null;
|
|
864
|
+
constructor(cb, graceMs, cascadeMs) {
|
|
865
|
+
this.cb = cb;
|
|
866
|
+
this.graceMs = graceMs;
|
|
867
|
+
this.cascadeMs = cascadeMs;
|
|
868
|
+
}
|
|
869
|
+
getState() {
|
|
870
|
+
return this.state;
|
|
871
|
+
}
|
|
872
|
+
isPending() {
|
|
873
|
+
return this.state !== "none";
|
|
874
|
+
}
|
|
875
|
+
getPeers() {
|
|
876
|
+
return this.cb.getHostRole()?.getPeers() ?? this.cb.getHostPeers();
|
|
877
|
+
}
|
|
878
|
+
broadcast(msg, except) {
|
|
879
|
+
const hr = this.cb.getHostRole();
|
|
880
|
+
if (hr) {
|
|
881
|
+
hr.broadcast(msg, except);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
findPeerWs(handle) {
|
|
885
|
+
for (const [ws, peer] of this.getPeers().entries()) {
|
|
886
|
+
if (peer.handle === handle)
|
|
887
|
+
return ws;
|
|
888
|
+
}
|
|
889
|
+
return null;
|
|
890
|
+
}
|
|
891
|
+
buildQueue() {
|
|
892
|
+
const all = Array.from(this.getPeers().values()).filter((p) => p.handle !== "__pending__");
|
|
893
|
+
const hr = this.cb.getHostRole();
|
|
894
|
+
const vols = all.filter((p) => hr ? hr.isVolunteer(p.handle) : p.isVolunteer).sort((a, b) => a.joinedAt - b.joinedAt);
|
|
895
|
+
const nonVols = all.filter((p) => hr ? !hr.isVolunteer(p.handle) : !p.isVolunteer).sort((a, b) => a.joinedAt - b.joinedAt);
|
|
896
|
+
const seen = new Set;
|
|
897
|
+
const ordered = [];
|
|
898
|
+
for (const p of [...vols, ...nonVols]) {
|
|
899
|
+
if (seen.has(p.handle))
|
|
900
|
+
continue;
|
|
901
|
+
seen.add(p.handle);
|
|
902
|
+
ordered.push(p);
|
|
903
|
+
}
|
|
904
|
+
return ordered.map((p) => ({ handle: p.handle, code: this.cb.mintCode(p.handle) }));
|
|
905
|
+
}
|
|
906
|
+
clearTimers() {
|
|
907
|
+
if (this.leaveTimer) {
|
|
908
|
+
clearTimeout(this.leaveTimer);
|
|
909
|
+
this.leaveTimer = null;
|
|
910
|
+
}
|
|
911
|
+
if (this.transferTimer) {
|
|
912
|
+
clearTimeout(this.transferTimer);
|
|
913
|
+
this.transferTimer = null;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
async startLeave() {
|
|
917
|
+
if (this.state !== "none")
|
|
918
|
+
return;
|
|
919
|
+
const peers = peerListForBroadcast(this.getPeers());
|
|
920
|
+
if (peers.length === 0) {
|
|
921
|
+
await this.cb.log("info", "host leaving with no peers; ending session");
|
|
922
|
+
this.cb.stopHost();
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
this.state = "pending";
|
|
926
|
+
this.queue = this.buildQueue();
|
|
927
|
+
this.snapshot = {
|
|
928
|
+
code: this.cb.getHostCode() ?? "",
|
|
929
|
+
handle: this.cb.getHostHandle() ?? "host",
|
|
930
|
+
peers
|
|
931
|
+
};
|
|
932
|
+
this.broadcast({ type: "host_leaving", grace_s: this.graceMs / 1000 });
|
|
933
|
+
await this.cb.log("info", "host leaving; grace started", {
|
|
934
|
+
grace_s: this.graceMs / 1000,
|
|
935
|
+
peers: peers.length
|
|
936
|
+
});
|
|
937
|
+
await this.cb.toast(`leaving in ${this.graceMs / 1000}s \u2014 auto-transfer pending`, "info", "multiplayer");
|
|
938
|
+
this.leaveTimer = setTimeout(() => {
|
|
939
|
+
this.onGraceExpired();
|
|
940
|
+
}, this.graceMs);
|
|
941
|
+
}
|
|
942
|
+
async cancelLeave() {
|
|
943
|
+
if (this.state !== "pending")
|
|
944
|
+
return;
|
|
945
|
+
this.clearTimers();
|
|
946
|
+
this.state = "none";
|
|
947
|
+
this.queue = [];
|
|
948
|
+
this.snapshot = null;
|
|
949
|
+
this.broadcast({ type: "leave_cancelled" });
|
|
950
|
+
await this.cb.log("info", "host leave cancelled");
|
|
951
|
+
await this.cb.toast("leave cancelled \u2014 staying as host", "info", "multiplayer");
|
|
952
|
+
}
|
|
953
|
+
async onGraceExpired() {
|
|
954
|
+
if (this.state !== "pending")
|
|
955
|
+
return;
|
|
956
|
+
this.leaveTimer = null;
|
|
957
|
+
if (this.queue.length === 0) {
|
|
958
|
+
this.broadcast({ type: "session_ended", reason: "no_peers" });
|
|
959
|
+
await this.cb.recordSessionEnded(this.cb.getHostHandle() ?? "host", "no_peers");
|
|
960
|
+
this.cb.stopHost();
|
|
961
|
+
this.state = "none";
|
|
962
|
+
await this.cb.toast("session ended (no successors)", "warning", "multiplayer");
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
await this.tryNextSuccessor();
|
|
966
|
+
}
|
|
967
|
+
async tryNextSuccessor() {
|
|
968
|
+
if (this.state !== "pending")
|
|
969
|
+
return;
|
|
970
|
+
const next = this.queue.shift();
|
|
971
|
+
if (!next) {
|
|
972
|
+
this.broadcast({ type: "session_ended", reason: "no_reachable_successor" });
|
|
973
|
+
await this.cb.recordSessionEnded(this.cb.getHostHandle() ?? "host", "no_reachable_successor");
|
|
974
|
+
this.cb.stopHost();
|
|
975
|
+
this.state = "none";
|
|
976
|
+
await this.cb.toast("session ended: no reachable successor", "error", "multiplayer");
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
this.state = "transferring";
|
|
980
|
+
const successorWs = this.findPeerWs(next.handle);
|
|
981
|
+
if (!successorWs) {
|
|
982
|
+
this.state = "pending";
|
|
983
|
+
await this.onTransferFailed("successor_disconnected");
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
const snap = this.snapshot;
|
|
987
|
+
if (!snap) {
|
|
988
|
+
this.state = "pending";
|
|
989
|
+
await this.onTransferFailed("no_snapshot");
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
sendToPeer(successorWs, {
|
|
993
|
+
type: "transfer_to_me",
|
|
994
|
+
new_handle: next.handle,
|
|
995
|
+
old_code: snap.code,
|
|
996
|
+
old_handle: snap.handle,
|
|
997
|
+
peers: snap.peers.filter((p) => p.handle !== next.handle)
|
|
998
|
+
});
|
|
999
|
+
await this.cb.log("info", "transfer_to_me sent", { successor: next.handle });
|
|
1000
|
+
await this.cb.toast(`transferring to ${next.handle}...`, "info", "multiplayer");
|
|
1001
|
+
this.transferTimer = setTimeout(() => {
|
|
1002
|
+
this.onTransferFailed("timeout");
|
|
1003
|
+
}, this.cascadeMs);
|
|
1004
|
+
}
|
|
1005
|
+
async onTransferConfirmed(successorWs, newCode, newUrl) {
|
|
1006
|
+
if (this.transferTimer) {
|
|
1007
|
+
clearTimeout(this.transferTimer);
|
|
1008
|
+
this.transferTimer = null;
|
|
1009
|
+
}
|
|
1010
|
+
const snap = this.snapshot;
|
|
1011
|
+
if (!snap)
|
|
1012
|
+
return;
|
|
1013
|
+
await this.cb.log("info", "transfer confirmed by successor", { newCode, newUrl });
|
|
1014
|
+
await this.cb.toast(`\u2713 transferred to ${newUrl.replace(/^ws:\/\//, "")}`, "success", "multiplayer");
|
|
1015
|
+
await this.cb.recordHostChanged(newCode && newCode.startsWith("mp-") ? newCode.split("-")[1] ?? "host" : "host", newCode, snap.code, snap.handle, newUrl);
|
|
1016
|
+
this.broadcast({
|
|
1017
|
+
type: "transfer_start",
|
|
1018
|
+
new_code: newCode,
|
|
1019
|
+
new_url: newUrl,
|
|
1020
|
+
new_handle: newCode && newCode.startsWith("mp-") ? newCode.split("-")[1] ?? "host" : "host"
|
|
1021
|
+
}, successorWs);
|
|
1022
|
+
this.cb.stopHost();
|
|
1023
|
+
this.state = "none";
|
|
1024
|
+
this.snapshot = null;
|
|
1025
|
+
this.queue = [];
|
|
1026
|
+
}
|
|
1027
|
+
async onTransferFailed(reason) {
|
|
1028
|
+
if (this.transferTimer) {
|
|
1029
|
+
clearTimeout(this.transferTimer);
|
|
1030
|
+
this.transferTimer = null;
|
|
1031
|
+
}
|
|
1032
|
+
await this.cb.log("warn", "transfer failed; cascading", { reason });
|
|
1033
|
+
await this.cb.toast(`transfer failed (${reason}); trying next successor`, "warning", "multiplayer");
|
|
1034
|
+
this.state = "pending";
|
|
1035
|
+
await this.tryNextSuccessor();
|
|
1036
|
+
}
|
|
1037
|
+
reset() {
|
|
1038
|
+
this.clearTimers();
|
|
1039
|
+
this.state = "none";
|
|
1040
|
+
this.queue = [];
|
|
1041
|
+
this.snapshot = null;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
// src/role/role-state.ts
|
|
1045
|
+
class IdleRole {
|
|
1046
|
+
_deps;
|
|
1047
|
+
kind = "idle";
|
|
1048
|
+
constructor(_deps) {
|
|
1049
|
+
this._deps = _deps;
|
|
1050
|
+
}
|
|
1051
|
+
dispose() {}
|
|
1052
|
+
}
|
|
1053
|
+
// src/companion/socket-server.ts
|
|
1054
|
+
import { createServer } from "net";
|
|
1055
|
+
import { chmodSync, existsSync as existsSync2, unlinkSync } from "fs";
|
|
1056
|
+
import { writeFile, unlink } from "fs/promises";
|
|
1057
|
+
import { randomBytes } from "crypto";
|
|
1058
|
+
|
|
1059
|
+
// shared/protocol.ts
|
|
1060
|
+
var IPC_VERSION = "1.0.0";
|
|
1061
|
+
var IPC_MAX_MESSAGE_BYTES = 64 * 1024;
|
|
1062
|
+
var MAX_TEXT = 4000;
|
|
1063
|
+
var MAX_HANDLE = 16;
|
|
1064
|
+
var HANDLE_RE2 = /^[a-z0-9-]{1,16}$/;
|
|
1065
|
+
var CODE_RE2 = /^mp-([a-z0-9-]{1,16})-([a-z0-9]{4})-([a-z0-9]{4})$/;
|
|
1066
|
+
var URL_RE = /^wss?:\/\/.+/;
|
|
1067
|
+
var VARIANT_SET = new Set(["info", "success", "warning", "error"]);
|
|
1068
|
+
var ROLE_SET = new Set(["host", "guest", "idle"]);
|
|
1069
|
+
var LEAVING_SET = new Set(["none", "pending", "transferring"]);
|
|
1070
|
+
var TYPING_SET = new Set(["start", "stop"]);
|
|
1071
|
+
function isPeer(x) {
|
|
1072
|
+
if (typeof x !== "object" || x === null)
|
|
1073
|
+
return false;
|
|
1074
|
+
const p = x;
|
|
1075
|
+
return typeof p.handle === "string" && HANDLE_RE2.test(p.handle) && typeof p.joinedAt === "number";
|
|
1076
|
+
}
|
|
1077
|
+
function isState(x) {
|
|
1078
|
+
if (typeof x !== "object" || x === null)
|
|
1079
|
+
return false;
|
|
1080
|
+
const s = x;
|
|
1081
|
+
if (typeof s.role !== "string" || !ROLE_SET.has(s.role))
|
|
1082
|
+
return false;
|
|
1083
|
+
if (typeof s.handle !== "string" || s.handle.length === 0 || s.handle.length > MAX_HANDLE)
|
|
1084
|
+
return false;
|
|
1085
|
+
if (s.code !== null && !(typeof s.code === "string" && CODE_RE2.test(s.code)))
|
|
1086
|
+
return false;
|
|
1087
|
+
if (typeof s.port !== "number" || s.port < 1 || s.port > 65535)
|
|
1088
|
+
return false;
|
|
1089
|
+
if (s.hostHandle !== null && !(typeof s.hostHandle === "string" && HANDLE_RE2.test(s.hostHandle)))
|
|
1090
|
+
return false;
|
|
1091
|
+
if (!Array.isArray(s.peers) || !s.peers.every(isPeer))
|
|
1092
|
+
return false;
|
|
1093
|
+
if (typeof s.leaving !== "string" || !LEAVING_SET.has(s.leaving))
|
|
1094
|
+
return false;
|
|
1095
|
+
if (s.grace_s !== null && typeof s.grace_s !== "number")
|
|
1096
|
+
return false;
|
|
1097
|
+
return true;
|
|
1098
|
+
}
|
|
1099
|
+
function isPluginToCompanion(x) {
|
|
1100
|
+
if (typeof x !== "object" || x === null)
|
|
1101
|
+
return false;
|
|
1102
|
+
const m = x;
|
|
1103
|
+
if (typeof m.type !== "string")
|
|
1104
|
+
return false;
|
|
1105
|
+
switch (m.type) {
|
|
1106
|
+
case "init":
|
|
1107
|
+
return isState(x.state);
|
|
1108
|
+
case "peers_update":
|
|
1109
|
+
return Array.isArray(x.peers) && x.peers.every(isPeer);
|
|
1110
|
+
case "chat": {
|
|
1111
|
+
const v = x;
|
|
1112
|
+
return typeof v.from === "string" && v.from.length > 0 && v.from.length <= MAX_HANDLE && typeof v.text === "string" && v.text.length > 0 && v.text.length <= MAX_TEXT && typeof v.ts === "number" && typeof v.mine === "boolean";
|
|
1113
|
+
}
|
|
1114
|
+
case "typing": {
|
|
1115
|
+
const v = x;
|
|
1116
|
+
return typeof v.from === "string" && v.from.length > 0 && v.from.length <= MAX_HANDLE && typeof v.state === "string" && TYPING_SET.has(v.state);
|
|
1117
|
+
}
|
|
1118
|
+
case "host_leaving":
|
|
1119
|
+
return typeof x.grace_s === "number";
|
|
1120
|
+
case "leave_cancelled":
|
|
1121
|
+
return true;
|
|
1122
|
+
case "session_ended":
|
|
1123
|
+
return typeof x.reason === "string";
|
|
1124
|
+
case "transfer_start": {
|
|
1125
|
+
const v = x;
|
|
1126
|
+
return typeof v.new_code === "string" && CODE_RE2.test(v.new_code) && typeof v.new_url === "string" && URL_RE.test(v.new_url) && typeof v.new_handle === "string" && HANDLE_RE2.test(v.new_handle);
|
|
1127
|
+
}
|
|
1128
|
+
case "role_change":
|
|
1129
|
+
return isState(x.state);
|
|
1130
|
+
case "toast": {
|
|
1131
|
+
const v = x;
|
|
1132
|
+
return typeof v.message === "string" && v.message.length > 0 && v.message.length <= MAX_TEXT && typeof v.variant === "string" && VARIANT_SET.has(v.variant) && (v.title === undefined || typeof v.title === "string");
|
|
1133
|
+
}
|
|
1134
|
+
case "goodbye":
|
|
1135
|
+
return typeof x.reason === "string";
|
|
1136
|
+
default:
|
|
1137
|
+
return false;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
function isCompanionToPlugin(x) {
|
|
1141
|
+
if (typeof x !== "object" || x === null)
|
|
1142
|
+
return false;
|
|
1143
|
+
const m = x;
|
|
1144
|
+
if (typeof m.type !== "string")
|
|
1145
|
+
return false;
|
|
1146
|
+
switch (m.type) {
|
|
1147
|
+
case "hello": {
|
|
1148
|
+
const v = x;
|
|
1149
|
+
return typeof v.version === "string" && v.version.length > 0 && v.version.length <= 32 && typeof v.token === "string" && v.token.length > 0 && v.token.length <= 256;
|
|
1150
|
+
}
|
|
1151
|
+
case "chat": {
|
|
1152
|
+
const v = x;
|
|
1153
|
+
return typeof v.text === "string" && v.text.length > 0 && v.text.length <= MAX_TEXT;
|
|
1154
|
+
}
|
|
1155
|
+
case "typing": {
|
|
1156
|
+
const v = x;
|
|
1157
|
+
return typeof v.state === "string" && TYPING_SET.has(v.state);
|
|
1158
|
+
}
|
|
1159
|
+
case "command": {
|
|
1160
|
+
const v = x;
|
|
1161
|
+
return typeof v.name === "string" && v.name.length > 0 && v.name.length <= 32 && Array.isArray(v.args) && v.args.every((a) => typeof a === "string" && a.length <= MAX_TEXT);
|
|
1162
|
+
}
|
|
1163
|
+
case "leave":
|
|
1164
|
+
case "ping":
|
|
1165
|
+
case "goodbye":
|
|
1166
|
+
return true;
|
|
1167
|
+
default:
|
|
1168
|
+
return false;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
// shared/codec.ts
|
|
1172
|
+
function makeLineParser(from) {
|
|
1173
|
+
return (raw, onMessage, onError) => {
|
|
1174
|
+
const line = raw.trim();
|
|
1175
|
+
if (line.length === 0)
|
|
1176
|
+
return;
|
|
1177
|
+
if (line.length > IPC_MAX_MESSAGE_BYTES) {
|
|
1178
|
+
onError?.(new Error(`message too large (${line.length} > ${IPC_MAX_MESSAGE_BYTES})`));
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
let parsed;
|
|
1182
|
+
try {
|
|
1183
|
+
parsed = JSON.parse(line);
|
|
1184
|
+
} catch (e) {
|
|
1185
|
+
onError?.(e);
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
if (from === "plugin" && isPluginToCompanion(parsed)) {
|
|
1189
|
+
onMessage(parsed);
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
if (from === "companion" && isCompanionToPlugin(parsed)) {
|
|
1193
|
+
onMessage(parsed);
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
onError?.(new Error(`invalid ${from} message: ${line.slice(0, 80)}`));
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
function encode(msg) {
|
|
1200
|
+
return JSON.stringify(msg) + `
|
|
1201
|
+
`;
|
|
1202
|
+
}
|
|
1203
|
+
function splitLines(chunk) {
|
|
1204
|
+
const parts = chunk.split(`
|
|
1205
|
+
`);
|
|
1206
|
+
const rest = parts.pop() ?? "";
|
|
1207
|
+
return { lines: parts, rest };
|
|
1208
|
+
}
|
|
1209
|
+
// src/companion/socket-server.ts
|
|
1210
|
+
var HELLO_TIMEOUT_MS = 3000;
|
|
1211
|
+
|
|
1212
|
+
class CompanionSocketServer {
|
|
1213
|
+
opts;
|
|
1214
|
+
server = null;
|
|
1215
|
+
clients = new Set;
|
|
1216
|
+
expectedToken;
|
|
1217
|
+
helloTimers = new WeakMap;
|
|
1218
|
+
lineParser;
|
|
1219
|
+
encodeFn;
|
|
1220
|
+
stopped = false;
|
|
1221
|
+
constructor(opts) {
|
|
1222
|
+
this.opts = opts;
|
|
1223
|
+
this.expectedToken = opts.token ?? CompanionSocketServer.generateToken();
|
|
1224
|
+
this.lineParser = opts.lineParser ?? makeLineParser("companion");
|
|
1225
|
+
this.encodeFn = opts.encodeFn ?? encode;
|
|
1226
|
+
}
|
|
1227
|
+
static generateToken() {
|
|
1228
|
+
return randomBytes(24).toString("hex");
|
|
1229
|
+
}
|
|
1230
|
+
getToken() {
|
|
1231
|
+
return this.expectedToken;
|
|
1232
|
+
}
|
|
1233
|
+
getSocketPath() {
|
|
1234
|
+
return this.opts.socketPath;
|
|
1235
|
+
}
|
|
1236
|
+
isRunning() {
|
|
1237
|
+
return this.server !== null && !this.stopped;
|
|
1238
|
+
}
|
|
1239
|
+
clientCount() {
|
|
1240
|
+
return this.clients.size;
|
|
1241
|
+
}
|
|
1242
|
+
async start() {
|
|
1243
|
+
if (this.server)
|
|
1244
|
+
return;
|
|
1245
|
+
this.stopped = false;
|
|
1246
|
+
const dir = this.opts.socketPath.replace(/\/[^/]+$/, "");
|
|
1247
|
+
if (dir && dir !== this.opts.socketPath) {
|
|
1248
|
+
const { mkdir: mkdir2 } = await import("fs/promises");
|
|
1249
|
+
await mkdir2(dir, { recursive: true });
|
|
1250
|
+
}
|
|
1251
|
+
if (existsSync2(this.opts.socketPath)) {
|
|
1252
|
+
try {
|
|
1253
|
+
unlinkSync(this.opts.socketPath);
|
|
1254
|
+
} catch {}
|
|
1255
|
+
}
|
|
1256
|
+
if (existsSync2(this.opts.tokenPath)) {
|
|
1257
|
+
try {
|
|
1258
|
+
unlinkSync(this.opts.tokenPath);
|
|
1259
|
+
} catch {}
|
|
1260
|
+
}
|
|
1261
|
+
await writeFile(this.opts.tokenPath, this.expectedToken, { mode: 384 });
|
|
1262
|
+
try {
|
|
1263
|
+
chmodSync(this.opts.tokenPath, 384);
|
|
1264
|
+
} catch {}
|
|
1265
|
+
await new Promise((resolve, reject) => {
|
|
1266
|
+
const server = createServer((socket) => this.onConnection(socket));
|
|
1267
|
+
server.on("error", (e) => {
|
|
1268
|
+
this.opts.handlers.onError(e);
|
|
1269
|
+
reject(e);
|
|
1270
|
+
});
|
|
1271
|
+
server.listen(this.opts.socketPath, () => {
|
|
1272
|
+
try {
|
|
1273
|
+
chmodSync(this.opts.socketPath, 384);
|
|
1274
|
+
} catch {}
|
|
1275
|
+
this.server = server;
|
|
1276
|
+
resolve();
|
|
1277
|
+
});
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
async stop() {
|
|
1281
|
+
if (this.stopped)
|
|
1282
|
+
return;
|
|
1283
|
+
this.stopped = true;
|
|
1284
|
+
for (const c of this.clients) {
|
|
1285
|
+
try {
|
|
1286
|
+
c.socket.end();
|
|
1287
|
+
} catch {}
|
|
1288
|
+
try {
|
|
1289
|
+
c.socket.destroy();
|
|
1290
|
+
} catch {}
|
|
1291
|
+
}
|
|
1292
|
+
this.clients.clear();
|
|
1293
|
+
if (this.server) {
|
|
1294
|
+
const s = this.server;
|
|
1295
|
+
this.server = null;
|
|
1296
|
+
await new Promise((resolve) => {
|
|
1297
|
+
s.close(() => resolve());
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
if (existsSync2(this.opts.socketPath)) {
|
|
1301
|
+
try {
|
|
1302
|
+
await unlink(this.opts.socketPath);
|
|
1303
|
+
} catch {}
|
|
1304
|
+
}
|
|
1305
|
+
if (existsSync2(this.opts.tokenPath)) {
|
|
1306
|
+
try {
|
|
1307
|
+
await unlink(this.opts.tokenPath);
|
|
1308
|
+
} catch {}
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
pushState(state) {
|
|
1312
|
+
this.broadcast({ type: "init", state });
|
|
1313
|
+
}
|
|
1314
|
+
pushRoleChange(state) {
|
|
1315
|
+
this.broadcast({ type: "role_change", state });
|
|
1316
|
+
}
|
|
1317
|
+
pushPeersUpdate(peers) {
|
|
1318
|
+
this.broadcast({ type: "peers_update", peers });
|
|
1319
|
+
}
|
|
1320
|
+
pushChat(msg) {
|
|
1321
|
+
this.broadcast({ type: "chat", ...msg });
|
|
1322
|
+
}
|
|
1323
|
+
pushTyping(from, state) {
|
|
1324
|
+
this.broadcast({ type: "typing", from, state });
|
|
1325
|
+
}
|
|
1326
|
+
pushHostLeaving(grace_s) {
|
|
1327
|
+
this.broadcast({ type: "host_leaving", grace_s });
|
|
1328
|
+
}
|
|
1329
|
+
pushLeaveCancelled() {
|
|
1330
|
+
this.broadcast({ type: "leave_cancelled" });
|
|
1331
|
+
}
|
|
1332
|
+
pushSessionEnded(reason) {
|
|
1333
|
+
this.broadcast({ type: "session_ended", reason });
|
|
1334
|
+
}
|
|
1335
|
+
pushTransferStart(new_code, new_url, new_handle) {
|
|
1336
|
+
this.broadcast({ type: "transfer_start", new_code, new_url, new_handle });
|
|
1337
|
+
}
|
|
1338
|
+
pushToast(message, variant, title) {
|
|
1339
|
+
const msg = title ? { type: "toast", message, variant, title } : { type: "toast", message, variant };
|
|
1340
|
+
this.broadcast(msg);
|
|
1341
|
+
}
|
|
1342
|
+
pushGoodbye(reason) {
|
|
1343
|
+
this.broadcast({ type: "goodbye", reason });
|
|
1344
|
+
}
|
|
1345
|
+
broadcast(msg) {
|
|
1346
|
+
if (!this.server)
|
|
1347
|
+
return;
|
|
1348
|
+
const payload = this.encodeFn(msg);
|
|
1349
|
+
for (const c of this.clients) {
|
|
1350
|
+
if (!c.authenticated)
|
|
1351
|
+
continue;
|
|
1352
|
+
try {
|
|
1353
|
+
c.socket.write(payload);
|
|
1354
|
+
} catch {}
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
onConnection(socket) {
|
|
1358
|
+
if (this.stopped) {
|
|
1359
|
+
try {
|
|
1360
|
+
socket.destroy();
|
|
1361
|
+
} catch {}
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
const conn = { socket, buffer: "", authenticated: false, greeted: false };
|
|
1365
|
+
this.clients.add(conn);
|
|
1366
|
+
const timer = setTimeout(() => {
|
|
1367
|
+
this.opts.handlers.onAuthFail("hello_timeout");
|
|
1368
|
+
try {
|
|
1369
|
+
socket.destroy();
|
|
1370
|
+
} catch {}
|
|
1371
|
+
}, HELLO_TIMEOUT_MS);
|
|
1372
|
+
this.helloTimers.set(socket, timer);
|
|
1373
|
+
socket.on("data", (chunk) => {
|
|
1374
|
+
if (this.stopped)
|
|
1375
|
+
return;
|
|
1376
|
+
const buf = conn.buffer + chunk.toString("utf8");
|
|
1377
|
+
if (buf.length > IPC_MAX_MESSAGE_BYTES * 4) {
|
|
1378
|
+
this.opts.handlers.onAuthFail("buffer_overflow");
|
|
1379
|
+
try {
|
|
1380
|
+
socket.destroy();
|
|
1381
|
+
} catch {}
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
const { lines, rest } = splitLines(buf);
|
|
1385
|
+
conn.buffer = rest;
|
|
1386
|
+
for (const line of lines) {
|
|
1387
|
+
this.lineParser(line, (m) => this.onCompanionMessage(conn, m), (e) => this.opts.handlers.onParseError(e));
|
|
1388
|
+
}
|
|
1389
|
+
});
|
|
1390
|
+
socket.on("close", () => {
|
|
1391
|
+
const t = this.helloTimers.get(socket);
|
|
1392
|
+
if (t) {
|
|
1393
|
+
clearTimeout(t);
|
|
1394
|
+
this.helloTimers.delete(socket);
|
|
1395
|
+
}
|
|
1396
|
+
this.clients.delete(conn);
|
|
1397
|
+
if (conn.authenticated) {
|
|
1398
|
+
try {
|
|
1399
|
+
this.opts.handlers.onDisconnect();
|
|
1400
|
+
} catch {}
|
|
1401
|
+
}
|
|
1402
|
+
});
|
|
1403
|
+
socket.on("error", (e) => {
|
|
1404
|
+
this.opts.handlers.onError(e);
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
onCompanionMessage(conn, msg) {
|
|
1408
|
+
if (msg.type !== "hello" && msg.type !== "chat" && msg.type !== "typing" && msg.type !== "command" && msg.type !== "leave" && msg.type !== "ping" && msg.type !== "goodbye") {
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
if (!conn.authenticated) {
|
|
1412
|
+
if (msg.type === "hello") {
|
|
1413
|
+
const t = this.helloTimers.get(conn.socket);
|
|
1414
|
+
if (t) {
|
|
1415
|
+
clearTimeout(t);
|
|
1416
|
+
this.helloTimers.delete(conn.socket);
|
|
1417
|
+
}
|
|
1418
|
+
if (msg.token !== this.expectedToken) {
|
|
1419
|
+
this.opts.handlers.onAuthFail("bad_token");
|
|
1420
|
+
try {
|
|
1421
|
+
conn.socket.destroy();
|
|
1422
|
+
} catch {}
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
if (msg.version !== IPC_VERSION) {
|
|
1426
|
+
this.opts.handlers.onAuthFail(`version_mismatch:${msg.version}`);
|
|
1427
|
+
try {
|
|
1428
|
+
conn.socket.destroy();
|
|
1429
|
+
} catch {}
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
conn.authenticated = true;
|
|
1433
|
+
try {
|
|
1434
|
+
this.opts.handlers.onConnect();
|
|
1435
|
+
} catch {}
|
|
1436
|
+
conn.greeted = true;
|
|
1437
|
+
} else {
|
|
1438
|
+
this.opts.handlers.onAuthFail("not_hello");
|
|
1439
|
+
try {
|
|
1440
|
+
conn.socket.destroy();
|
|
1441
|
+
} catch {}
|
|
1442
|
+
}
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
switch (msg.type) {
|
|
1446
|
+
case "chat":
|
|
1447
|
+
try {
|
|
1448
|
+
this.opts.handlers.onChat(msg.text);
|
|
1449
|
+
} catch {}
|
|
1450
|
+
return;
|
|
1451
|
+
case "typing":
|
|
1452
|
+
try {
|
|
1453
|
+
this.opts.handlers.onTyping(msg.state);
|
|
1454
|
+
} catch {}
|
|
1455
|
+
return;
|
|
1456
|
+
case "command":
|
|
1457
|
+
try {
|
|
1458
|
+
this.opts.handlers.onCommand(msg.name, msg.args);
|
|
1459
|
+
} catch {}
|
|
1460
|
+
return;
|
|
1461
|
+
case "leave":
|
|
1462
|
+
try {
|
|
1463
|
+
this.opts.handlers.onLeave();
|
|
1464
|
+
} catch {}
|
|
1465
|
+
return;
|
|
1466
|
+
case "ping":
|
|
1467
|
+
return;
|
|
1468
|
+
case "goodbye":
|
|
1469
|
+
try {
|
|
1470
|
+
conn.socket.end();
|
|
1471
|
+
} catch {}
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// src/companion/paths.ts
|
|
1478
|
+
init_paths();
|
|
1479
|
+
import { join as join2 } from "path";
|
|
1480
|
+
function companionSocketPath() {
|
|
1481
|
+
return join2(stateDir(), "companion.sock");
|
|
1482
|
+
}
|
|
1483
|
+
function companionTokenPath() {
|
|
1484
|
+
return join2(stateDir(), "companion.token");
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// src/companion/state-pusher.ts
|
|
1488
|
+
function getPeersForHost(plugin) {
|
|
1489
|
+
const peers = plugin.hostRole?.getPeers() ?? plugin.hostPeers;
|
|
1490
|
+
return peerListForBroadcast(peers);
|
|
1491
|
+
}
|
|
1492
|
+
function getPeersForGuest(plugin) {
|
|
1493
|
+
const list = plugin.guestRole?.getPeerList() ?? [];
|
|
1494
|
+
const hostHandle = plugin.guestRole?.getHostHandle() ?? null;
|
|
1495
|
+
if (hostHandle) {
|
|
1496
|
+
const hostEntry = { handle: hostHandle, joinedAt: 0 };
|
|
1497
|
+
const withoutHost = list.filter((p) => p.handle !== hostHandle);
|
|
1498
|
+
return [hostEntry, ...withoutHost];
|
|
1499
|
+
}
|
|
1500
|
+
return list;
|
|
1501
|
+
}
|
|
1502
|
+
function leavingState(plugin) {
|
|
1503
|
+
return plugin.tc?.getState() ?? "none";
|
|
1504
|
+
}
|
|
1505
|
+
function computeIpcState(plugin) {
|
|
1506
|
+
const handle = plugin.resolveHandle();
|
|
1507
|
+
const port = plugin.port;
|
|
1508
|
+
if (plugin.role === "host") {
|
|
1509
|
+
return {
|
|
1510
|
+
role: "host",
|
|
1511
|
+
handle,
|
|
1512
|
+
code: plugin.hostRole?.getCode() ?? plugin.hostCode,
|
|
1513
|
+
port,
|
|
1514
|
+
hostHandle: handle,
|
|
1515
|
+
peers: getPeersForHost(plugin),
|
|
1516
|
+
leaving: leavingState(plugin),
|
|
1517
|
+
grace_s: plugin.tc?.isPending() ? GRACE_S : null
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
if (plugin.role === "guest") {
|
|
1521
|
+
return {
|
|
1522
|
+
role: "guest",
|
|
1523
|
+
handle,
|
|
1524
|
+
code: null,
|
|
1525
|
+
port,
|
|
1526
|
+
hostHandle: plugin.guestRole?.getHostHandle() ?? null,
|
|
1527
|
+
peers: getPeersForGuest(plugin),
|
|
1528
|
+
leaving: "none",
|
|
1529
|
+
grace_s: null
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1532
|
+
return {
|
|
1533
|
+
role: "idle",
|
|
1534
|
+
handle,
|
|
1535
|
+
code: null,
|
|
1536
|
+
port,
|
|
1537
|
+
hostHandle: null,
|
|
1538
|
+
peers: [],
|
|
1539
|
+
leaving: "none",
|
|
1540
|
+
grace_s: null
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
function pushIpcState(plugin, server) {
|
|
1544
|
+
if (!server || !server.isRunning())
|
|
1545
|
+
return;
|
|
1546
|
+
if (server.clientCount() === 0)
|
|
1547
|
+
return;
|
|
1548
|
+
server.pushState(computeIpcState(plugin));
|
|
1549
|
+
}
|
|
1550
|
+
function pushRoleChange(plugin, server) {
|
|
1551
|
+
if (!server || !server.isRunning())
|
|
1552
|
+
return;
|
|
1553
|
+
if (server.clientCount() === 0)
|
|
1554
|
+
return;
|
|
1555
|
+
server.pushRoleChange(computeIpcState(plugin));
|
|
1556
|
+
}
|
|
1557
|
+
function pushPeersUpdate(plugin, server) {
|
|
1558
|
+
if (!server || !server.isRunning())
|
|
1559
|
+
return;
|
|
1560
|
+
if (server.clientCount() === 0)
|
|
1561
|
+
return;
|
|
1562
|
+
if (plugin.role === "host") {
|
|
1563
|
+
server.pushPeersUpdate(getPeersForHost(plugin));
|
|
1564
|
+
} else if (plugin.role === "guest") {
|
|
1565
|
+
server.pushPeersUpdate(getPeersForGuest(plugin));
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
// src/plugin.ts
|
|
1570
|
+
class MultiplayerPlugin {
|
|
1571
|
+
toaster;
|
|
1572
|
+
logger;
|
|
1573
|
+
store;
|
|
1574
|
+
role = "idle";
|
|
1575
|
+
roleState;
|
|
1576
|
+
port = DEFAULT_PORT;
|
|
1577
|
+
hostAddr = `${DEFAULT_HOST}:${DEFAULT_PORT}`;
|
|
1578
|
+
hostServer = null;
|
|
1579
|
+
hostCode = null;
|
|
1580
|
+
hostHandle = null;
|
|
1581
|
+
hostPeers = new Map;
|
|
1582
|
+
volunteers = new Set;
|
|
1583
|
+
hostRole = null;
|
|
1584
|
+
guestRole = null;
|
|
1585
|
+
guestWs = null;
|
|
1586
|
+
guestHostHandle = null;
|
|
1587
|
+
guestMyHandle = null;
|
|
1588
|
+
guestHostUrl = null;
|
|
1589
|
+
myResolvedHandle = null;
|
|
1590
|
+
tc = null;
|
|
1591
|
+
companionServer = null;
|
|
1592
|
+
companionDisabled = false;
|
|
1593
|
+
constructor(toaster, logger, store) {
|
|
1594
|
+
this.toaster = toaster;
|
|
1595
|
+
this.logger = logger;
|
|
1596
|
+
this.store = store;
|
|
1597
|
+
this.roleState = new IdleRole(this.deps);
|
|
1598
|
+
}
|
|
1599
|
+
get deps() {
|
|
1600
|
+
return {
|
|
1601
|
+
handle: this.resolveHandle(),
|
|
1602
|
+
port: this.port,
|
|
1603
|
+
hostAddr: this.hostAddr,
|
|
1604
|
+
store: this.store,
|
|
1605
|
+
toaster: this.toaster,
|
|
1606
|
+
logger: this.logger
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
resolveHandle() {
|
|
1610
|
+
if (this.myResolvedHandle)
|
|
1611
|
+
return this.myResolvedHandle;
|
|
1612
|
+
const envHandle = process.env["MP_HANDLE"];
|
|
1613
|
+
if (envHandle) {
|
|
1614
|
+
const norm = normalizeHandle(envHandle);
|
|
1615
|
+
if (norm.length > 0 && isValidHandle(norm)) {
|
|
1616
|
+
this.myResolvedHandle = norm;
|
|
1617
|
+
return norm;
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
const persisted = readHandleFileSync();
|
|
1621
|
+
if (persisted) {
|
|
1622
|
+
this.myResolvedHandle = persisted;
|
|
1623
|
+
return persisted;
|
|
1624
|
+
}
|
|
1625
|
+
const fallback = normalizeHandle(osUser()) || "anon";
|
|
1626
|
+
this.myResolvedHandle = fallback;
|
|
1627
|
+
return fallback;
|
|
1628
|
+
}
|
|
1629
|
+
takenHandles() {
|
|
1630
|
+
const out = [];
|
|
1631
|
+
if (this.hostHandle)
|
|
1632
|
+
out.push(this.hostHandle);
|
|
1633
|
+
for (const p of (this.hostRole?.getPeers() ?? this.hostPeers).values()) {
|
|
1634
|
+
if (p.handle !== "__pending__")
|
|
1635
|
+
out.push(p.handle);
|
|
1636
|
+
}
|
|
1637
|
+
return out;
|
|
1638
|
+
}
|
|
1639
|
+
sendToPeer(ws, msg) {
|
|
1640
|
+
try {
|
|
1641
|
+
ws.send(JSON.stringify(msg));
|
|
1642
|
+
} catch {}
|
|
1643
|
+
}
|
|
1644
|
+
findPeerWs(handle) {
|
|
1645
|
+
const peers = this.hostRole?.getPeers() ?? this.hostPeers;
|
|
1646
|
+
for (const [ws, peer] of peers.entries()) {
|
|
1647
|
+
if (peer.handle === handle)
|
|
1648
|
+
return ws;
|
|
1649
|
+
}
|
|
1650
|
+
return null;
|
|
1651
|
+
}
|
|
1652
|
+
resetToIdleRole() {
|
|
1653
|
+
this.role = "idle";
|
|
1654
|
+
this.roleState = new IdleRole(this.deps);
|
|
1655
|
+
}
|
|
1656
|
+
setRoleHost(hr) {
|
|
1657
|
+
this.role = "host";
|
|
1658
|
+
this.roleState = hr;
|
|
1659
|
+
}
|
|
1660
|
+
setRoleGuest(gr) {
|
|
1661
|
+
this.role = "guest";
|
|
1662
|
+
this.roleState = gr;
|
|
1663
|
+
}
|
|
1664
|
+
cleanup() {
|
|
1665
|
+
this.tc?.reset();
|
|
1666
|
+
this.volunteers = new Set;
|
|
1667
|
+
if (this.hostRole) {
|
|
1668
|
+
this.hostRole.stop();
|
|
1669
|
+
this.hostRole = null;
|
|
1670
|
+
}
|
|
1671
|
+
try {
|
|
1672
|
+
this.hostServer?.stop(true);
|
|
1673
|
+
} catch {}
|
|
1674
|
+
this.hostServer = null;
|
|
1675
|
+
this.hostCode = null;
|
|
1676
|
+
this.hostHandle = null;
|
|
1677
|
+
this.hostPeers = new Map;
|
|
1678
|
+
try {
|
|
1679
|
+
if (this.guestWs && this.guestWs.readyState === WebSocket.OPEN) {
|
|
1680
|
+
try {
|
|
1681
|
+
this.guestWs.send(JSON.stringify({ type: "bye" }));
|
|
1682
|
+
} catch {}
|
|
1683
|
+
this.guestWs.close();
|
|
1684
|
+
}
|
|
1685
|
+
} catch {}
|
|
1686
|
+
this.guestRole?.leave();
|
|
1687
|
+
this.guestRole = null;
|
|
1688
|
+
this.guestWs = null;
|
|
1689
|
+
this.guestHostHandle = null;
|
|
1690
|
+
this.guestMyHandle = null;
|
|
1691
|
+
this.guestHostUrl = null;
|
|
1692
|
+
this.resetToIdleRole();
|
|
1693
|
+
}
|
|
1694
|
+
async startCompanionServer() {
|
|
1695
|
+
if (this.companionServer && this.companionServer.isRunning()) {
|
|
1696
|
+
return this.companionServer;
|
|
1697
|
+
}
|
|
1698
|
+
const server = new CompanionSocketServer({
|
|
1699
|
+
socketPath: companionSocketPath(),
|
|
1700
|
+
tokenPath: companionTokenPath(),
|
|
1701
|
+
handlers: {
|
|
1702
|
+
onChat: (text) => {
|
|
1703
|
+
this.handleCompanionChat(text);
|
|
1704
|
+
},
|
|
1705
|
+
onTyping: (state) => {
|
|
1706
|
+
if (this.role === "host")
|
|
1707
|
+
this.hostRole?.sendTyping(state);
|
|
1708
|
+
if (this.role === "guest")
|
|
1709
|
+
this.guestRole?.sendTyping(state);
|
|
1710
|
+
},
|
|
1711
|
+
onCommand: (name, args) => {
|
|
1712
|
+
this.handleCompanionCommand(name, args);
|
|
1713
|
+
},
|
|
1714
|
+
onLeave: () => {
|
|
1715
|
+
this.mpLeave();
|
|
1716
|
+
},
|
|
1717
|
+
onConnect: () => {
|
|
1718
|
+
if (this.companionServer) {
|
|
1719
|
+
this.companionServer.pushState(computeIpcState(this));
|
|
1720
|
+
}
|
|
1721
|
+
},
|
|
1722
|
+
onDisconnect: () => {},
|
|
1723
|
+
onAuthFail: (reason) => {
|
|
1724
|
+
this.logger.log("warn", "companion auth failed", { reason });
|
|
1725
|
+
},
|
|
1726
|
+
onParseError: (e) => {
|
|
1727
|
+
this.logger.log("warn", "companion parse error", { err: e.message });
|
|
1728
|
+
},
|
|
1729
|
+
onError: (e) => {
|
|
1730
|
+
this.logger.log("error", "companion server error", { err: e.message });
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
});
|
|
1734
|
+
try {
|
|
1735
|
+
await server.start();
|
|
1736
|
+
} catch (e) {
|
|
1737
|
+
await this.logger.log("warn", "companion server failed to start", { err: e.message });
|
|
1738
|
+
this.companionDisabled = true;
|
|
1739
|
+
return null;
|
|
1740
|
+
}
|
|
1741
|
+
this.companionServer = server;
|
|
1742
|
+
return server;
|
|
1743
|
+
}
|
|
1744
|
+
async stopCompanionServer() {
|
|
1745
|
+
if (this.companionServer) {
|
|
1746
|
+
this.companionServer.pushGoodbye("shutdown");
|
|
1747
|
+
await this.companionServer.stop();
|
|
1748
|
+
this.companionServer = null;
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
async handleCompanionChat(text) {
|
|
1752
|
+
await this.mpChat(text);
|
|
1753
|
+
if (this.companionServer) {
|
|
1754
|
+
const me = this.resolveHandle();
|
|
1755
|
+
this.companionServer.pushChat({
|
|
1756
|
+
from: me,
|
|
1757
|
+
text,
|
|
1758
|
+
ts: Date.now(),
|
|
1759
|
+
mine: true
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
async handleCompanionCommand(name, args) {
|
|
1764
|
+
switch (name) {
|
|
1765
|
+
case "join":
|
|
1766
|
+
if (args[0])
|
|
1767
|
+
await this.mpJoin(args[0]);
|
|
1768
|
+
return;
|
|
1769
|
+
case "leave":
|
|
1770
|
+
await this.mpLeave();
|
|
1771
|
+
return;
|
|
1772
|
+
case "cancel-leave":
|
|
1773
|
+
await this.mpCancelLeave();
|
|
1774
|
+
return;
|
|
1775
|
+
case "volunteer":
|
|
1776
|
+
this.mpVolunteer();
|
|
1777
|
+
return;
|
|
1778
|
+
case "code":
|
|
1779
|
+
this.companionServer?.pushToast(this.mpCode(), "info", "code");
|
|
1780
|
+
return;
|
|
1781
|
+
case "status":
|
|
1782
|
+
this.companionServer?.pushToast(this.mpStatus(), "info", "status");
|
|
1783
|
+
return;
|
|
1784
|
+
case "intent":
|
|
1785
|
+
await this.logger.log("warn", "intent not yet implemented (Phase 04)");
|
|
1786
|
+
this.companionServer?.pushToast("intents are coming in Phase 04", "info", "intent");
|
|
1787
|
+
return;
|
|
1788
|
+
case "history":
|
|
1789
|
+
this.companionServer?.pushToast("/mp history is coming in Phase 07", "info", "history");
|
|
1790
|
+
return;
|
|
1791
|
+
default:
|
|
1792
|
+
this.companionServer?.pushToast(`unknown command: ${name}`, "warning", "multiplayer");
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
pushStateToCompanions() {
|
|
1796
|
+
pushIpcState(this, this.companionServer);
|
|
1797
|
+
}
|
|
1798
|
+
pushRoleChangeToCompanions() {
|
|
1799
|
+
pushRoleChange(this, this.companionServer);
|
|
1800
|
+
}
|
|
1801
|
+
pushPeersToCompanions() {
|
|
1802
|
+
pushPeersUpdate(this, this.companionServer);
|
|
1803
|
+
}
|
|
1804
|
+
async mpHost() {
|
|
1805
|
+
const handle = this.resolveHandle();
|
|
1806
|
+
const bindPort = resolvePort();
|
|
1807
|
+
const bindHost = resolveHost();
|
|
1808
|
+
const result = await this.startHost(handle, bindPort, bindHost);
|
|
1809
|
+
if (result.ok) {
|
|
1810
|
+
this.pushRoleChangeToCompanions();
|
|
1811
|
+
return `Hosting on ${result.url}
|
|
1812
|
+
Invite code: ${result.code}
|
|
1813
|
+
Share the code with your peer. They run: mp_join ${result.code}
|
|
1814
|
+
(mp_status shows the current peers and leaving state.)`;
|
|
1815
|
+
}
|
|
1816
|
+
if (result.reason.startsWith("port_") && result.reason.endsWith("_busy")) {
|
|
1817
|
+
const busyPort = result.reason.replace(/^port_/, "").replace(/_busy$/, "");
|
|
1818
|
+
return `Port ${busyPort} is already in use. Try a different port by setting MP_PORT before launching opencode, e.g.
|
|
1819
|
+
MP_PORT=${busyPort === "7332" ? "8332" : String(Number(busyPort) + 1)} opencode`;
|
|
1820
|
+
}
|
|
1821
|
+
return `Could not start host: ${result.reason}`;
|
|
1822
|
+
}
|
|
1823
|
+
async startHost(handle, bindPort, bindHost) {
|
|
1824
|
+
if (this.roleState.kind !== "idle") {
|
|
1825
|
+
return { ok: false, reason: `not_idle (currently ${this.roleState.kind})` };
|
|
1826
|
+
}
|
|
1827
|
+
const hr = new HostRole({
|
|
1828
|
+
port: bindPort,
|
|
1829
|
+
host: bindHost,
|
|
1830
|
+
handle,
|
|
1831
|
+
state: this.store,
|
|
1832
|
+
toaster: this.toaster,
|
|
1833
|
+
logger: this.logger,
|
|
1834
|
+
onPeersChanged: () => this.pushPeersToCompanions(),
|
|
1835
|
+
onChatReceived: (msg) => {
|
|
1836
|
+
if (this.companionServer) {
|
|
1837
|
+
this.companionServer.pushChat({ ...msg, mine: false });
|
|
1838
|
+
}
|
|
1839
|
+
},
|
|
1840
|
+
onTypingReceived: (from, state) => {
|
|
1841
|
+
if (this.companionServer) {
|
|
1842
|
+
this.companionServer.pushTyping(from, state);
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
});
|
|
1846
|
+
const result = await hr.start();
|
|
1847
|
+
if (!result.ok) {
|
|
1848
|
+
return result;
|
|
1849
|
+
}
|
|
1850
|
+
this.hostRole = hr;
|
|
1851
|
+
this.hostServer = null;
|
|
1852
|
+
this.hostCode = result.code;
|
|
1853
|
+
this.hostHandle = handle;
|
|
1854
|
+
this.setRoleHost(hr);
|
|
1855
|
+
this.port = bindPort;
|
|
1856
|
+
this.hostAddr = `${bindHost}:${bindPort}`;
|
|
1857
|
+
return result;
|
|
1858
|
+
}
|
|
1859
|
+
broadcastPeersUpdate() {
|
|
1860
|
+
if (this.hostRole) {
|
|
1861
|
+
const peers = peerListForBroadcast(this.hostRole.getPeers());
|
|
1862
|
+
this.hostRole.broadcast({ type: "peers_update", peers });
|
|
1863
|
+
}
|
|
1864
|
+
this.pushPeersToCompanions();
|
|
1865
|
+
}
|
|
1866
|
+
async handleHostMessage(ws, raw) {
|
|
1867
|
+
const text = typeof raw === "string" ? raw : raw.toString("utf8");
|
|
1868
|
+
let msg;
|
|
1869
|
+
try {
|
|
1870
|
+
msg = JSON.parse(text);
|
|
1871
|
+
} catch {
|
|
1872
|
+
this.sendToPeer(ws, { type: "auth_fail", reason: "invalid_json" });
|
|
1873
|
+
this.sendToPeer(ws, { type: "bye" });
|
|
1874
|
+
try {
|
|
1875
|
+
ws.close();
|
|
1876
|
+
} catch {}
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
if (ws.data.state === "awaiting_auth") {
|
|
1880
|
+
if (msg.type !== "auth") {
|
|
1881
|
+
this.sendToPeer(ws, { type: "auth_fail", reason: "expected_auth" });
|
|
1882
|
+
this.sendToPeer(ws, { type: "bye" });
|
|
1883
|
+
try {
|
|
1884
|
+
ws.close();
|
|
1885
|
+
} catch {}
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
if (!isValidCode(msg.code)) {
|
|
1889
|
+
this.sendToPeer(ws, { type: "auth_fail", reason: "invalid_code" });
|
|
1890
|
+
this.sendToPeer(ws, { type: "bye" });
|
|
1891
|
+
try {
|
|
1892
|
+
ws.close();
|
|
1893
|
+
} catch {}
|
|
1894
|
+
await this.toaster.show("guest sent an invalid code", "warning", "multiplayer");
|
|
1895
|
+
return;
|
|
1896
|
+
}
|
|
1897
|
+
const normalized = msg.code.toLowerCase();
|
|
1898
|
+
const isCurrent = this.hostCode !== null && normalized === this.hostCode;
|
|
1899
|
+
const isGrace = !isCurrent;
|
|
1900
|
+
if (!isCurrent && !isGrace) {
|
|
1901
|
+
this.sendToPeer(ws, { type: "auth_fail", reason: "unknown_code" });
|
|
1902
|
+
this.sendToPeer(ws, { type: "bye" });
|
|
1903
|
+
try {
|
|
1904
|
+
ws.close();
|
|
1905
|
+
} catch {}
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
const peer = { handle: "__pending__", joinedAt: Date.now(), isVolunteer: false };
|
|
1909
|
+
ws.data = { state: "authenticated", peer };
|
|
1910
|
+
this.hostPeers.set(ws, peer);
|
|
1911
|
+
this.sendToPeer(ws, { type: "auth_ok", handle: this.hostHandle ?? "host" });
|
|
1912
|
+
this.sendToPeer(ws, {
|
|
1913
|
+
type: "welcome",
|
|
1914
|
+
handle: this.hostHandle ?? "host",
|
|
1915
|
+
peers: peerListForBroadcast(this.hostRole?.getPeers() ?? this.hostPeers)
|
|
1916
|
+
});
|
|
1917
|
+
return;
|
|
1918
|
+
}
|
|
1919
|
+
if (msg.type === "hello") {
|
|
1920
|
+
const requested = normalizeHandle(msg.handle);
|
|
1921
|
+
if (!isValidHandle(requested)) {
|
|
1922
|
+
this.sendToPeer(ws, { type: "auth_fail", reason: "invalid_handle" });
|
|
1923
|
+
this.sendToPeer(ws, { type: "bye" });
|
|
1924
|
+
try {
|
|
1925
|
+
ws.close();
|
|
1926
|
+
} catch {}
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
const peer = ws.data.peer;
|
|
1930
|
+
const existing = this.takenHandles();
|
|
1931
|
+
let assigned = requested;
|
|
1932
|
+
if (existing.includes(assigned)) {
|
|
1933
|
+
assigned = assignCollisionSuffix(requested, existing);
|
|
1934
|
+
}
|
|
1935
|
+
peer.handle = assigned;
|
|
1936
|
+
await this.logger.log("info", "peer connected", { guestHandle: assigned });
|
|
1937
|
+
await this.toaster.show(`\u2713 peer connected (${assigned})`, "success", "multiplayer");
|
|
1938
|
+
this.broadcastPeersUpdate();
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
if (msg.type === "volunteer") {
|
|
1942
|
+
const peer = ws.data.peer;
|
|
1943
|
+
if (peer.handle === "__pending__")
|
|
1944
|
+
return;
|
|
1945
|
+
peer.isVolunteer = true;
|
|
1946
|
+
await this.logger.log("info", "peer volunteered", { handle: peer.handle });
|
|
1947
|
+
await this.toaster.show(`volunteer accepted (${peer.handle})`, "info", "multiplayer");
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
if (msg.type === "transfer_confirmed") {
|
|
1951
|
+
await this.tc?.onTransferConfirmed(ws, msg.new_code, msg.new_url);
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
if (msg.type === "transfer_failed") {
|
|
1955
|
+
await this.tc?.onTransferFailed(msg.reason);
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
if (msg.type === "bye") {
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
await this.logger.log("warn", "host: unexpected message", { msg, state: ws.data.state });
|
|
1962
|
+
}
|
|
1963
|
+
async handleHostClose(ws) {
|
|
1964
|
+
if (ws.data.state === "authenticated") {
|
|
1965
|
+
const peer = ws.data.peer;
|
|
1966
|
+
this.hostPeers.delete(ws);
|
|
1967
|
+
if (peer.handle !== "__pending__") {
|
|
1968
|
+
this.volunteers.delete(peer.handle);
|
|
1969
|
+
await this.logger.log("info", "peer disconnected", { handle: peer.handle });
|
|
1970
|
+
await this.toaster.show(`peer disconnected (${peer.handle})`, "warning", "multiplayer");
|
|
1971
|
+
this.broadcastPeersUpdate();
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
async mpJoin(code) {
|
|
1976
|
+
if (this.role !== "idle") {
|
|
1977
|
+
return `Not idle (currently ${this.role}). Use mp_leave first.`;
|
|
1978
|
+
}
|
|
1979
|
+
if (!isValidCode(code)) {
|
|
1980
|
+
return "Invalid code format. Expected `mp-<handle>-XXXX-XXXX`.";
|
|
1981
|
+
}
|
|
1982
|
+
const handle = this.resolveHandle();
|
|
1983
|
+
const gr = new GuestRole({
|
|
1984
|
+
port: this.port,
|
|
1985
|
+
host: resolveHost(),
|
|
1986
|
+
handle,
|
|
1987
|
+
state: this.store,
|
|
1988
|
+
toaster: this.toaster,
|
|
1989
|
+
logger: this.logger,
|
|
1990
|
+
promote: (msg, oldWs, oldUrl) => this.promoteToHost(msg, oldWs, oldUrl),
|
|
1991
|
+
reconnect: (newCode, newUrl) => this.reconnectAsGuest(newCode, newUrl, "rejoin"),
|
|
1992
|
+
ended: (reason) => this.onGuestEnded(reason),
|
|
1993
|
+
onPeersChanged: () => this.pushPeersToCompanions(),
|
|
1994
|
+
onChatReceived: (msg) => {
|
|
1995
|
+
if (this.companionServer) {
|
|
1996
|
+
this.companionServer.pushChat({ ...msg, mine: false });
|
|
1997
|
+
}
|
|
1998
|
+
},
|
|
1999
|
+
onTypingReceived: (from, state) => {
|
|
2000
|
+
if (this.companionServer) {
|
|
2001
|
+
this.companionServer.pushTyping(from, state);
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
});
|
|
2005
|
+
const result = await gr.dial(code, "join");
|
|
2006
|
+
if (result.ok) {
|
|
2007
|
+
this.guestRole = gr;
|
|
2008
|
+
this.guestWs = gr.getWs();
|
|
2009
|
+
this.setRoleGuest(gr);
|
|
2010
|
+
this.pushRoleChangeToCompanions();
|
|
2011
|
+
return `Connected to ${result.hostHandle}. You are ${result.myHandle} in the session.`;
|
|
2012
|
+
}
|
|
2013
|
+
if (result.reason === "timeout") {
|
|
2014
|
+
return `No host responded at ws://${resolveHost()}:${this.port}. Is the host's opencode running, and are both using the same MP_HOST/MP_PORT?`;
|
|
2015
|
+
}
|
|
2016
|
+
return `Join failed: ${result.reason}`;
|
|
2017
|
+
}
|
|
2018
|
+
async mpLeave() {
|
|
2019
|
+
if (this.role === "host") {
|
|
2020
|
+
await this.tc?.startLeave();
|
|
2021
|
+
return `Leaving in ${GRACE_S}s \u2014 auto-transfer pending. Use mp_cancel_leave to abort, or mp_volunteer (as a guest) to opt in as next host.`;
|
|
2022
|
+
}
|
|
2023
|
+
if (this.role === "guest") {
|
|
2024
|
+
this.guestRole?.leave();
|
|
2025
|
+
this.guestRole = null;
|
|
2026
|
+
this.resetToIdleRole();
|
|
2027
|
+
return "Left the session.";
|
|
2028
|
+
}
|
|
2029
|
+
return "Not in a session.";
|
|
2030
|
+
}
|
|
2031
|
+
async mpCancelLeave() {
|
|
2032
|
+
if (this.role !== "host")
|
|
2033
|
+
return "Only the host can cancel a leave.";
|
|
2034
|
+
if (this.tc?.getState() !== "pending")
|
|
2035
|
+
return "No leave is pending.";
|
|
2036
|
+
await this.tc?.cancelLeave();
|
|
2037
|
+
return "Leave cancelled. Staying as host.";
|
|
2038
|
+
}
|
|
2039
|
+
mpVolunteer() {
|
|
2040
|
+
if (this.role !== "guest")
|
|
2041
|
+
return "Only guests can volunteer.";
|
|
2042
|
+
if (!this.guestRole?.isConnected())
|
|
2043
|
+
return "Not connected.";
|
|
2044
|
+
this.guestRole.sendVolunteer();
|
|
2045
|
+
return "Volunteered as next host candidate.";
|
|
2046
|
+
}
|
|
2047
|
+
mpCode() {
|
|
2048
|
+
if (this.role === "host")
|
|
2049
|
+
return this.hostCode ?? "(no code)";
|
|
2050
|
+
if (this.role === "guest")
|
|
2051
|
+
return this.guestRole?.getHostHandle() ? `host handle: ${this.guestRole.getHostHandle()}` : "(unknown)";
|
|
2052
|
+
return "Not in a session. Use mp_host or mp_join first.";
|
|
2053
|
+
}
|
|
2054
|
+
mpChat(text) {
|
|
2055
|
+
if (this.role === "host") {
|
|
2056
|
+
const result = this.hostRole?.sendChat(text);
|
|
2057
|
+
if (!result)
|
|
2058
|
+
return "Not hosting.";
|
|
2059
|
+
if (!result.ok)
|
|
2060
|
+
return `Chat failed: ${result.reason}`;
|
|
2061
|
+
if (this.companionServer) {
|
|
2062
|
+
this.companionServer.pushChat({
|
|
2063
|
+
from: this.resolveHandle(),
|
|
2064
|
+
text: text.trim(),
|
|
2065
|
+
ts: Date.now(),
|
|
2066
|
+
mine: true
|
|
2067
|
+
});
|
|
2068
|
+
}
|
|
2069
|
+
return `Sent to ${result.peers} peer(s).`;
|
|
2070
|
+
}
|
|
2071
|
+
if (this.role === "guest") {
|
|
2072
|
+
const result = this.guestRole?.sendChat(text);
|
|
2073
|
+
if (!result)
|
|
2074
|
+
return "Not connected.";
|
|
2075
|
+
if (!result.ok)
|
|
2076
|
+
return `Chat failed: ${result.reason}`;
|
|
2077
|
+
if (this.companionServer) {
|
|
2078
|
+
this.companionServer.pushChat({
|
|
2079
|
+
from: this.resolveHandle(),
|
|
2080
|
+
text: text.trim(),
|
|
2081
|
+
ts: Date.now(),
|
|
2082
|
+
mine: true
|
|
2083
|
+
});
|
|
2084
|
+
}
|
|
2085
|
+
return `Sent to ${this.guestRole?.getHostHandle() ?? "host"}.`;
|
|
2086
|
+
}
|
|
2087
|
+
return "Not in a session. Use mp_host or mp_join first.";
|
|
2088
|
+
}
|
|
2089
|
+
mpTyping(state) {
|
|
2090
|
+
if (this.role === "host")
|
|
2091
|
+
this.hostRole?.sendTyping(state);
|
|
2092
|
+
if (this.role === "guest")
|
|
2093
|
+
this.guestRole?.sendTyping(state);
|
|
2094
|
+
}
|
|
2095
|
+
mpStatus() {
|
|
2096
|
+
if (this.role === "host") {
|
|
2097
|
+
const lines = [];
|
|
2098
|
+
lines.push(`role: host`);
|
|
2099
|
+
lines.push(`port: ${this.port}`);
|
|
2100
|
+
lines.push(`url: ws://${this.hostAddr}`);
|
|
2101
|
+
lines.push(`invite: ${this.hostRole?.getCode() ?? this.hostCode ?? "(none)"}`);
|
|
2102
|
+
lines.push(`handle: ${this.hostRole?.getHandle() ?? this.hostHandle ?? "(none)"}`);
|
|
2103
|
+
const peersMap = this.hostRole?.getPeers() ?? this.hostPeers;
|
|
2104
|
+
const peers = peerListForBroadcast(peersMap);
|
|
2105
|
+
if (peers.length === 0) {
|
|
2106
|
+
lines.push(`peers: (none)`);
|
|
2107
|
+
} else {
|
|
2108
|
+
lines.push(`peers (${peers.length}):`);
|
|
2109
|
+
for (const p of peers) {
|
|
2110
|
+
const v = this.hostRole?.isVolunteer(p.handle) ? " [volunteer]" : "";
|
|
2111
|
+
lines.push(` - ${p.handle} (joined ${Math.round((Date.now() - p.joinedAt) / 1000)}s ago)${v}`);
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
if (this.tc?.isPending()) {
|
|
2115
|
+
lines.push(`leaving: ${this.tc.getState()}`);
|
|
2116
|
+
}
|
|
2117
|
+
return lines.join(`
|
|
2118
|
+
`);
|
|
2119
|
+
}
|
|
2120
|
+
if (this.role === "guest") {
|
|
2121
|
+
const connected = this.guestRole?.isConnected() ? "yes" : "no";
|
|
2122
|
+
return [
|
|
2123
|
+
`role: guest`,
|
|
2124
|
+
`connected: ${connected}`,
|
|
2125
|
+
`port: ${this.port}`,
|
|
2126
|
+
`host: ${this.guestRole?.getHostHandle() ?? "(unknown)"}`,
|
|
2127
|
+
`me: ${this.guestRole?.getMyHandle() ?? this.resolveHandle()}`,
|
|
2128
|
+
`host url: ${this.guestRole?.getHostUrl() ?? `ws://${this.hostAddr}`}`
|
|
2129
|
+
].join(`
|
|
2130
|
+
`);
|
|
2131
|
+
}
|
|
2132
|
+
return `role: idle
|
|
2133
|
+
port: ${this.port}
|
|
2134
|
+
host: ${this.hostAddr}
|
|
2135
|
+
handle: ${this.resolveHandle()}
|
|
2136
|
+
url: ws://${this.hostAddr}`;
|
|
2137
|
+
}
|
|
2138
|
+
async mpRejoin(code) {
|
|
2139
|
+
if (this.role !== "idle") {
|
|
2140
|
+
return `Not idle (currently ${this.role}). Use mp_leave first.`;
|
|
2141
|
+
}
|
|
2142
|
+
if (!isValidCode(code)) {
|
|
2143
|
+
return "Invalid code format. Expected `mp-<handle>-XXXX-XXXX`.";
|
|
2144
|
+
}
|
|
2145
|
+
const handle = this.resolveHandle();
|
|
2146
|
+
const gr = new GuestRole({
|
|
2147
|
+
port: this.port,
|
|
2148
|
+
host: resolveHost(),
|
|
2149
|
+
handle,
|
|
2150
|
+
state: this.store,
|
|
2151
|
+
toaster: this.toaster,
|
|
2152
|
+
logger: this.logger,
|
|
2153
|
+
promote: (msg, oldWs, oldUrl) => this.promoteToHost(msg, oldWs, oldUrl),
|
|
2154
|
+
reconnect: (newCode, newUrl) => this.reconnectAsGuest(newCode, newUrl, "rejoin"),
|
|
2155
|
+
ended: (reason) => this.onGuestEnded(reason)
|
|
2156
|
+
});
|
|
2157
|
+
const result = await gr.dial(code, "rejoin");
|
|
2158
|
+
if (result.ok) {
|
|
2159
|
+
this.guestRole = gr;
|
|
2160
|
+
this.guestWs = gr.getWs();
|
|
2161
|
+
this.setRoleGuest(gr);
|
|
2162
|
+
return `Rejoined as guest (${result.myHandle}). Connected to ${result.hostHandle}.`;
|
|
2163
|
+
}
|
|
2164
|
+
if (result.reason === "timeout") {
|
|
2165
|
+
return `No host responded at ws://${resolveHost()}:${this.port}. Is the host's opencode running? The grace code may have expired (>1 hour).`;
|
|
2166
|
+
}
|
|
2167
|
+
return `Rejoin failed: ${result.reason}`;
|
|
2168
|
+
}
|
|
2169
|
+
async promoteToHost(msg, oldHostWs, _oldHostUrl) {
|
|
2170
|
+
const newHandle = msg.new_handle;
|
|
2171
|
+
const newPort = resolvePort();
|
|
2172
|
+
const newBindHost = resolveHost();
|
|
2173
|
+
const newUrl = `ws://${newBindHost}:${newPort}`;
|
|
2174
|
+
const hr = new HostRole({
|
|
2175
|
+
port: newPort,
|
|
2176
|
+
host: newBindHost,
|
|
2177
|
+
handle: newHandle,
|
|
2178
|
+
state: this.store,
|
|
2179
|
+
toaster: this.toaster,
|
|
2180
|
+
logger: this.logger
|
|
2181
|
+
});
|
|
2182
|
+
hr.addGraceCode(msg.old_code);
|
|
2183
|
+
const result = await hr.start();
|
|
2184
|
+
if (!result.ok) {
|
|
2185
|
+
return { ok: false, reason: result.reason };
|
|
2186
|
+
}
|
|
2187
|
+
this.hostRole = hr;
|
|
2188
|
+
this.hostCode = result.code;
|
|
2189
|
+
this.hostHandle = newHandle;
|
|
2190
|
+
this.port = newPort;
|
|
2191
|
+
this.hostAddr = `${newBindHost}:${newPort}`;
|
|
2192
|
+
this.setRoleHost(hr);
|
|
2193
|
+
return { ok: true, newCode: result.code, newUrl: result.url };
|
|
2194
|
+
}
|
|
2195
|
+
async reconnectAsGuest(newCode, newUrl, mode) {
|
|
2196
|
+
const handle = this.resolveHandle();
|
|
2197
|
+
const gr = new GuestRole({
|
|
2198
|
+
port: this.port,
|
|
2199
|
+
host: resolveHost(),
|
|
2200
|
+
handle,
|
|
2201
|
+
state: this.store,
|
|
2202
|
+
toaster: this.toaster,
|
|
2203
|
+
logger: this.logger,
|
|
2204
|
+
promote: (msg, oldWs, oldUrl) => this.promoteToHost(msg, oldWs, oldUrl),
|
|
2205
|
+
reconnect: (code, url) => this.reconnectAsGuest(code, url, "rejoin"),
|
|
2206
|
+
ended: (reason) => this.onGuestEnded(reason)
|
|
2207
|
+
});
|
|
2208
|
+
const result = await gr.dial(newCode, mode);
|
|
2209
|
+
if (result.ok) {
|
|
2210
|
+
this.guestRole = gr;
|
|
2211
|
+
this.guestWs = gr.getWs();
|
|
2212
|
+
this.setRoleGuest(gr);
|
|
2213
|
+
} else {
|
|
2214
|
+
this.guestRole = null;
|
|
2215
|
+
this.guestWs = null;
|
|
2216
|
+
this.resetToIdleRole();
|
|
2217
|
+
await this.toaster.show(`reconnect after transfer failed: ${result.reason}`, "error", "multiplayer");
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
onGuestEnded(reason) {
|
|
2221
|
+
this.guestRole = null;
|
|
2222
|
+
this.guestWs = null;
|
|
2223
|
+
this.guestHostHandle = null;
|
|
2224
|
+
this.guestMyHandle = null;
|
|
2225
|
+
this.guestHostUrl = null;
|
|
2226
|
+
this.resetToIdleRole();
|
|
2227
|
+
this.toaster.show(`session ended: ${reason}`, "warning", "multiplayer");
|
|
2228
|
+
}
|
|
2229
|
+
dispose() {
|
|
2230
|
+
this.cleanup();
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
// src/bridge/toast.ts
|
|
2235
|
+
class Toaster {
|
|
2236
|
+
client;
|
|
2237
|
+
constructor(client) {
|
|
2238
|
+
this.client = client;
|
|
2239
|
+
}
|
|
2240
|
+
async show(message, variant = "info", title) {
|
|
2241
|
+
try {
|
|
2242
|
+
await this.client.tui.showToast({
|
|
2243
|
+
body: { message, variant, title, duration: 4000 }
|
|
2244
|
+
});
|
|
2245
|
+
} catch {}
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
// src/bridge/logger.ts
|
|
2249
|
+
class Logger {
|
|
2250
|
+
client;
|
|
2251
|
+
service;
|
|
2252
|
+
constructor(client, service = "multiplayer") {
|
|
2253
|
+
this.client = client;
|
|
2254
|
+
this.service = service;
|
|
2255
|
+
}
|
|
2256
|
+
async log(level, message, extra) {
|
|
2257
|
+
try {
|
|
2258
|
+
await this.client.app.log({
|
|
2259
|
+
body: { service: this.service, level, message, extra: extra ?? {} }
|
|
2260
|
+
});
|
|
2261
|
+
} catch {}
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
// src/companion/spawner.ts
|
|
2265
|
+
import { spawn as nodeSpawn } from "child_process";
|
|
2266
|
+
import { existsSync as existsSync3 } from "fs";
|
|
2267
|
+
import { homedir as homedir2, platform, userInfo } from "os";
|
|
2268
|
+
function defaultHasBinary(bin) {
|
|
2269
|
+
const path = (process.env["PATH"] ?? "").split(":");
|
|
2270
|
+
for (const dir of path) {
|
|
2271
|
+
if (existsSync3(`${dir}/${bin}`))
|
|
2272
|
+
return true;
|
|
2273
|
+
}
|
|
2274
|
+
return false;
|
|
2275
|
+
}
|
|
2276
|
+
function detectStrategy(opts) {
|
|
2277
|
+
if (opts.env.MP_NO_COMPANION === "1" || opts.env.MP_NO_COMPANION === "true") {
|
|
2278
|
+
return "manual";
|
|
2279
|
+
}
|
|
2280
|
+
const hasBin = opts.hasBinary ?? defaultHasBinary;
|
|
2281
|
+
if (opts.env.TMUX && hasBin("tmux"))
|
|
2282
|
+
return "tmux";
|
|
2283
|
+
if ((opts.env.TERM_PROGRAM === "iTerm.app" || opts.env.ITERM_SESSION_ID) && hasBin("osascript")) {
|
|
2284
|
+
return "iterm2";
|
|
2285
|
+
}
|
|
2286
|
+
if (platform() === "darwin" && hasBin("osascript"))
|
|
2287
|
+
return "detached";
|
|
2288
|
+
if (platform() === "win32" && hasBin("wt.exe"))
|
|
2289
|
+
return "detached";
|
|
2290
|
+
const linuxTerminals = [
|
|
2291
|
+
opts.env.TERMINAL,
|
|
2292
|
+
"gnome-terminal",
|
|
2293
|
+
"konsole",
|
|
2294
|
+
"xfce4-terminal",
|
|
2295
|
+
"kitty",
|
|
2296
|
+
"wezterm",
|
|
2297
|
+
"alacritty",
|
|
2298
|
+
"ghostty"
|
|
2299
|
+
].filter((t) => typeof t === "string");
|
|
2300
|
+
for (const t of linuxTerminals) {
|
|
2301
|
+
if (hasBin(t))
|
|
2302
|
+
return "detached";
|
|
2303
|
+
}
|
|
2304
|
+
return "manual";
|
|
2305
|
+
}
|
|
2306
|
+
function quote(s) {
|
|
2307
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
2308
|
+
}
|
|
2309
|
+
function buildCompanionCommand(inputs) {
|
|
2310
|
+
const envPrefix = `MP_COMPANION_SOCK=${quote(inputs.socketPath)} MP_COMPANION_TOKEN=${quote(inputs.token)}`;
|
|
2311
|
+
const exec = inputs.env?.["MP_COMPANION_EXEC"] ?? "node";
|
|
2312
|
+
return `${envPrefix} ${exec} ${quote(inputs.binPath)}`;
|
|
2313
|
+
}
|
|
2314
|
+
function spawnStrategy(inputs) {
|
|
2315
|
+
switch (inputs.strategy) {
|
|
2316
|
+
case "tmux":
|
|
2317
|
+
return spawnTmux(inputs);
|
|
2318
|
+
case "iterm2":
|
|
2319
|
+
return spawnIterm2(inputs);
|
|
2320
|
+
case "detached":
|
|
2321
|
+
return spawnDetached(inputs);
|
|
2322
|
+
case "manual":
|
|
2323
|
+
return {
|
|
2324
|
+
ok: false,
|
|
2325
|
+
strategy: "manual",
|
|
2326
|
+
reason: "manual_fallback",
|
|
2327
|
+
command: buildCompanionCommand(inputs)
|
|
2328
|
+
};
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
function spawnTmux(inputs) {
|
|
2332
|
+
const command = buildCompanionCommand(inputs);
|
|
2333
|
+
const args = ["split-window", "-h", "-c", inputs.cwd ?? process.cwd(), command];
|
|
2334
|
+
try {
|
|
2335
|
+
const child = nodeSpawn("tmux", args, {
|
|
2336
|
+
stdio: "ignore",
|
|
2337
|
+
detached: true
|
|
2338
|
+
});
|
|
2339
|
+
child.unref();
|
|
2340
|
+
return { ok: true, strategy: "tmux", pid: child.pid ?? null, command };
|
|
2341
|
+
} catch (e) {
|
|
2342
|
+
return { ok: false, strategy: "tmux", reason: e.message, command };
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
function spawnIterm2(inputs) {
|
|
2346
|
+
const command = buildCompanionCommand(inputs);
|
|
2347
|
+
const escaped = command.replace(/"/g, "\\\"");
|
|
2348
|
+
const script = `
|
|
2349
|
+
tell application "iTerm2"
|
|
2350
|
+
set newSession to (split current session of current window vertically with default profile)
|
|
2351
|
+
tell newSession
|
|
2352
|
+
write text "${escaped}"
|
|
2353
|
+
end tell
|
|
2354
|
+
end tell
|
|
2355
|
+
`.trim();
|
|
2356
|
+
try {
|
|
2357
|
+
const child = nodeSpawn("osascript", ["-e", script], {
|
|
2358
|
+
stdio: "ignore",
|
|
2359
|
+
detached: true
|
|
2360
|
+
});
|
|
2361
|
+
child.unref();
|
|
2362
|
+
return { ok: true, strategy: "iterm2", pid: child.pid ?? null, command };
|
|
2363
|
+
} catch (e) {
|
|
2364
|
+
return { ok: false, strategy: "iterm2", reason: e.message, command };
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
function spawnDetached(inputs) {
|
|
2368
|
+
const command = buildCompanionCommand(inputs);
|
|
2369
|
+
const os = platform();
|
|
2370
|
+
try {
|
|
2371
|
+
if (os === "darwin") {
|
|
2372
|
+
const script = `
|
|
2373
|
+
tell application "Terminal"
|
|
2374
|
+
activate
|
|
2375
|
+
do script "${command.replace(/"/g, "\\\"")}"
|
|
2376
|
+
end tell
|
|
2377
|
+
`.trim();
|
|
2378
|
+
const child = nodeSpawn("osascript", ["-e", script], { stdio: "ignore", detached: true });
|
|
2379
|
+
child.unref();
|
|
2380
|
+
return { ok: true, strategy: "detached", pid: child.pid ?? null, command };
|
|
2381
|
+
}
|
|
2382
|
+
if (os === "win32") {
|
|
2383
|
+
const child = nodeSpawn("wt.exe", ["-d", inputs.cwd ?? process.cwd(), "node", inputs.binPath], {
|
|
2384
|
+
stdio: "ignore",
|
|
2385
|
+
detached: true,
|
|
2386
|
+
env: {
|
|
2387
|
+
...process.env,
|
|
2388
|
+
MP_COMPANION_SOCK: inputs.socketPath,
|
|
2389
|
+
MP_COMPANION_TOKEN: inputs.token
|
|
2390
|
+
}
|
|
2391
|
+
});
|
|
2392
|
+
child.unref();
|
|
2393
|
+
return { ok: true, strategy: "detached", pid: child.pid ?? null, command };
|
|
2394
|
+
}
|
|
2395
|
+
const linuxOrder = [
|
|
2396
|
+
inputs.env?.["TERMINAL"],
|
|
2397
|
+
"gnome-terminal",
|
|
2398
|
+
"konsole",
|
|
2399
|
+
"xfce4-terminal",
|
|
2400
|
+
"kitty",
|
|
2401
|
+
"wezterm",
|
|
2402
|
+
"alacritty",
|
|
2403
|
+
"ghostty"
|
|
2404
|
+
].filter((t) => typeof t === "string");
|
|
2405
|
+
for (const term of linuxOrder) {
|
|
2406
|
+
if (!existsSync3(`/usr/bin/${term}`) && !defaultHasBinary(term))
|
|
2407
|
+
continue;
|
|
2408
|
+
let args;
|
|
2409
|
+
switch (term) {
|
|
2410
|
+
case "gnome-terminal":
|
|
2411
|
+
args = ["--working-directory", inputs.cwd ?? process.cwd(), "--", "bash", "-lc", command];
|
|
2412
|
+
break;
|
|
2413
|
+
case "konsole":
|
|
2414
|
+
args = ["--workdir", inputs.cwd ?? process.cwd(), "-e", "bash", "-lc", command];
|
|
2415
|
+
break;
|
|
2416
|
+
case "kitty":
|
|
2417
|
+
args = ["--directory", inputs.cwd ?? process.cwd(), "bash", "-lc", command];
|
|
2418
|
+
break;
|
|
2419
|
+
case "xfce4-terminal":
|
|
2420
|
+
args = [
|
|
2421
|
+
"--working-directory",
|
|
2422
|
+
inputs.cwd ?? process.cwd(),
|
|
2423
|
+
"-e",
|
|
2424
|
+
`bash -lc '${command.replace(/'/g, "'\\''")}'`
|
|
2425
|
+
];
|
|
2426
|
+
break;
|
|
2427
|
+
case "wezterm":
|
|
2428
|
+
args = ["start", "--cwd", inputs.cwd ?? process.cwd(), "--", "bash", "-lc", command];
|
|
2429
|
+
break;
|
|
2430
|
+
case "alacritty":
|
|
2431
|
+
case "ghostty":
|
|
2432
|
+
args = ["--working-directory", inputs.cwd ?? process.cwd(), "-e", "bash", "-lc", command];
|
|
2433
|
+
break;
|
|
2434
|
+
default:
|
|
2435
|
+
args = ["-e", "bash", "-lc", command];
|
|
2436
|
+
}
|
|
2437
|
+
const child = nodeSpawn(term, args, {
|
|
2438
|
+
stdio: "ignore",
|
|
2439
|
+
detached: true,
|
|
2440
|
+
env: {
|
|
2441
|
+
...process.env,
|
|
2442
|
+
MP_COMPANION_SOCK: inputs.socketPath,
|
|
2443
|
+
MP_COMPANION_TOKEN: inputs.token
|
|
2444
|
+
}
|
|
2445
|
+
});
|
|
2446
|
+
child.unref();
|
|
2447
|
+
return { ok: true, strategy: "detached", pid: child.pid ?? null, command };
|
|
2448
|
+
}
|
|
2449
|
+
return { ok: false, strategy: "detached", reason: "no_linux_terminal_found", command };
|
|
2450
|
+
} catch (e) {
|
|
2451
|
+
return { ok: false, strategy: "detached", reason: e.message, command };
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
function manualCommand(inputs) {
|
|
2455
|
+
return `MP_COMPANION_SOCK=${quote(inputs.socketPath)} MP_COMPANION_TOKEN=${quote(inputs.token)} node ${quote(inputs.binPath)}`;
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
// src/index.ts
|
|
2459
|
+
function companionBinPath() {
|
|
2460
|
+
const override = process.env["MP_COMPANION_BIN"];
|
|
2461
|
+
if (override)
|
|
2462
|
+
return override;
|
|
2463
|
+
return new URL("../../../multiplayer-watch/bin/multiplayer-watch.js", import.meta.url).pathname;
|
|
2464
|
+
}
|
|
2465
|
+
async function createMultiplayerPlugin(input) {
|
|
2466
|
+
const toaster = new Toaster(input.client);
|
|
2467
|
+
const logger = new Logger(input.client);
|
|
2468
|
+
const store = new StateStore(() => {
|
|
2469
|
+
const envHandle = process.env["MP_HANDLE"];
|
|
2470
|
+
if (envHandle) {
|
|
2471
|
+
const norm = normalizeHandle(envHandle);
|
|
2472
|
+
if (norm.length > 0 && isValidHandle(norm))
|
|
2473
|
+
return norm;
|
|
2474
|
+
}
|
|
2475
|
+
const persisted = readHandleFileSync();
|
|
2476
|
+
if (persisted)
|
|
2477
|
+
return persisted;
|
|
2478
|
+
return normalizeHandle(osUser()) || "anon";
|
|
2479
|
+
});
|
|
2480
|
+
const plugin = new MultiplayerPlugin(toaster, logger, store);
|
|
2481
|
+
const toast = toaster.show.bind(toaster);
|
|
2482
|
+
const log = logger.log.bind(logger);
|
|
2483
|
+
const handle = plugin.resolveHandle();
|
|
2484
|
+
const envPort = resolvePort();
|
|
2485
|
+
const envHost = resolveHost();
|
|
2486
|
+
plugin.hostAddr = `${envHost}:${envPort}`;
|
|
2487
|
+
const idleDeps = { handle, port: envPort, hostAddr: plugin.hostAddr, store, toaster, logger };
|
|
2488
|
+
plugin.roleState = new IdleRole(idleDeps);
|
|
2489
|
+
plugin.tc = new TransferController({
|
|
2490
|
+
getHostRole: () => plugin.hostRole,
|
|
2491
|
+
getHostPeers: () => plugin.hostPeers,
|
|
2492
|
+
getHostCode: () => plugin.hostCode,
|
|
2493
|
+
getHostHandle: () => plugin.hostHandle,
|
|
2494
|
+
mintCode,
|
|
2495
|
+
stopHost() {
|
|
2496
|
+
if (plugin.hostRole) {
|
|
2497
|
+
plugin.hostRole.stop();
|
|
2498
|
+
plugin.hostRole = null;
|
|
2499
|
+
}
|
|
2500
|
+
if (plugin.hostServer) {
|
|
2501
|
+
try {
|
|
2502
|
+
plugin.hostServer.stop(true);
|
|
2503
|
+
} catch {}
|
|
2504
|
+
plugin.hostServer = null;
|
|
2505
|
+
}
|
|
2506
|
+
plugin.hostCode = null;
|
|
2507
|
+
plugin.hostHandle = null;
|
|
2508
|
+
plugin.hostPeers = new Map;
|
|
2509
|
+
plugin.volunteers = new Set;
|
|
2510
|
+
plugin.roleState = new IdleRole(idleDeps);
|
|
2511
|
+
plugin.role = "idle";
|
|
2512
|
+
},
|
|
2513
|
+
recordSessionEnded: (h, r) => store.recordSessionEnded(h, r),
|
|
2514
|
+
recordHostChanged: (nh, nc, oc, oh, nu) => store.recordHostChanged(nh, nc, oc, oh, nu),
|
|
2515
|
+
toast,
|
|
2516
|
+
log
|
|
2517
|
+
}, GRACE_S * 1000, CASCADE_TIMEOUT_MS);
|
|
2518
|
+
if (!process.env["MP_HANDLE"]) {
|
|
2519
|
+
const persisted = readHandleFileSync();
|
|
2520
|
+
if (!persisted) {
|
|
2521
|
+
try {
|
|
2522
|
+
await writeHandleFile(plugin.resolveHandle());
|
|
2523
|
+
} catch {}
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
await logger.log("debug", "plugin loaded", {
|
|
2527
|
+
handle,
|
|
2528
|
+
port: envPort,
|
|
2529
|
+
host: envHost,
|
|
2530
|
+
role: plugin.roleState.kind
|
|
2531
|
+
});
|
|
2532
|
+
let companionSpawned = false;
|
|
2533
|
+
if (process.env["MP_NO_COMPANION"] === "1" || process.env["MP_NO_COMPANION"] === "true") {} else {
|
|
2534
|
+
(async () => {
|
|
2535
|
+
const server = await plugin.startCompanionServer();
|
|
2536
|
+
if (!server)
|
|
2537
|
+
return;
|
|
2538
|
+
const strategy = detectStrategy({
|
|
2539
|
+
env: {
|
|
2540
|
+
TMUX: process.env["TMUX"],
|
|
2541
|
+
TERM_PROGRAM: process.env["TERM_PROGRAM"],
|
|
2542
|
+
ITERM_SESSION_ID: process.env["ITERM_SESSION_ID"],
|
|
2543
|
+
TERMINAL: process.env["TERMINAL"],
|
|
2544
|
+
PATH: process.env["PATH"],
|
|
2545
|
+
MP_NO_COMPANION: process.env["MP_NO_COMPANION"]
|
|
2546
|
+
}
|
|
2547
|
+
});
|
|
2548
|
+
if (strategy === "manual") {
|
|
2549
|
+
const cmd = manualCommand({
|
|
2550
|
+
binPath: companionBinPath(),
|
|
2551
|
+
socketPath: companionSocketPath(),
|
|
2552
|
+
token: server.getToken()
|
|
2553
|
+
});
|
|
2554
|
+
await toaster.show(`Run \`${cmd}\` in another terminal for the companion pane`, "info", "multiplayer");
|
|
2555
|
+
return;
|
|
2556
|
+
}
|
|
2557
|
+
const result = spawnStrategy({
|
|
2558
|
+
strategy,
|
|
2559
|
+
binPath: companionBinPath(),
|
|
2560
|
+
socketPath: companionSocketPath(),
|
|
2561
|
+
token: server.getToken(),
|
|
2562
|
+
cwd: process.cwd()
|
|
2563
|
+
});
|
|
2564
|
+
if (!result.ok) {
|
|
2565
|
+
await toaster.show(`Companion spawn failed (${result.strategy}: ${result.reason}). Run \`${result.command}\` in another terminal.`, "warning", "multiplayer");
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
companionSpawned = true;
|
|
2569
|
+
await logger.log("info", "companion spawned", { strategy, pid: result.pid });
|
|
2570
|
+
})();
|
|
2571
|
+
}
|
|
2572
|
+
return {
|
|
2573
|
+
dispose: async () => {
|
|
2574
|
+
try {
|
|
2575
|
+
if (companionSpawned) {}
|
|
2576
|
+
await plugin.stopCompanionServer();
|
|
2577
|
+
} catch {}
|
|
2578
|
+
plugin.dispose();
|
|
2579
|
+
},
|
|
2580
|
+
_plugin: plugin,
|
|
2581
|
+
tool: {
|
|
2582
|
+
mp_host: tool({
|
|
2583
|
+
description: "Start a multiplayer host: bind the local port (MP_PORT env var, default 7332) on MP_HOST (default localhost), mint an invite code, and return the URL. Other peers join with `mp_join <code>`. Fails with a clear reason if the port is busy. Only works when this plugin instance is in idle role.",
|
|
2584
|
+
args: {},
|
|
2585
|
+
async execute() {
|
|
2586
|
+
return plugin.mpHost();
|
|
2587
|
+
}
|
|
2588
|
+
}),
|
|
2589
|
+
mp_join: tool({
|
|
2590
|
+
description: "Join a multiplayer session using the host's invite code (e.g. `mp-bob-a3f9-x7k2`). Dials `ws://<MP_HOST>:<MP_PORT>` (defaults `localhost:7332`) on the host's machine. Only works when this plugin instance is in idle role. Returns success or a reason on failure.",
|
|
2591
|
+
args: {
|
|
2592
|
+
code: tool.schema.string().describe("The host's invite code, e.g. `mp-bob-a3f9-x7k2`. Case-insensitive.")
|
|
2593
|
+
},
|
|
2594
|
+
async execute(args) {
|
|
2595
|
+
return plugin.mpJoin(args.code);
|
|
2596
|
+
}
|
|
2597
|
+
}),
|
|
2598
|
+
mp_leave: tool({
|
|
2599
|
+
description: "End the current multiplayer session. On the host: starts a 10-second grace window. After the window, the plugin auto-transfers the host role to a guest that called `mp_volunteer` (priority) or the longest-connected peer (fallback). On a guest: closes the WebSocket connection immediately. Returns to idle role.",
|
|
2600
|
+
args: {},
|
|
2601
|
+
async execute() {
|
|
2602
|
+
return plugin.mpLeave();
|
|
2603
|
+
}
|
|
2604
|
+
}),
|
|
2605
|
+
mp_cancel_leave: tool({
|
|
2606
|
+
description: "Cancel a pending host leave during the 10-second grace window. Host-only. No-op if no leave is pending. All peers are notified via a `leave_cancelled` message.",
|
|
2607
|
+
args: {},
|
|
2608
|
+
async execute() {
|
|
2609
|
+
return plugin.mpCancelLeave();
|
|
2610
|
+
}
|
|
2611
|
+
}),
|
|
2612
|
+
mp_volunteer: tool({
|
|
2613
|
+
description: "Guest-only: opt in as the next host candidate. If the current host leaves, this peer is preferred as the successor (over the longest-connected peer). Only meaningful during a `host_leaving` grace window; harmless to call any time after joining.",
|
|
2614
|
+
args: {},
|
|
2615
|
+
async execute() {
|
|
2616
|
+
return plugin.mpVolunteer();
|
|
2617
|
+
}
|
|
2618
|
+
}),
|
|
2619
|
+
mp_code: tool({
|
|
2620
|
+
description: "Show the current invite code. Host: the live code guests must use to join. Guest: the host's handle (the code is on the host side).",
|
|
2621
|
+
args: {},
|
|
2622
|
+
async execute() {
|
|
2623
|
+
return plugin.mpCode();
|
|
2624
|
+
}
|
|
2625
|
+
}),
|
|
2626
|
+
mp_chat: tool({
|
|
2627
|
+
description: "Send a chat message to all peers in the session. The text is shown verbatim in each peer's companion pane (and as an OpenCode toast until the companion is running). Mirrors the companion's input box. Requires an active session (host or guest).",
|
|
2628
|
+
args: {
|
|
2629
|
+
text: tool.schema.string().describe("The chat message to send. Plain text, no slash prefix. Max 4000 characters.")
|
|
2630
|
+
},
|
|
2631
|
+
async execute(args) {
|
|
2632
|
+
return plugin.mpChat(args.text);
|
|
2633
|
+
}
|
|
2634
|
+
}),
|
|
2635
|
+
mp_status: tool({
|
|
2636
|
+
description: "Show the current multiplayer state. Includes role, port, host URL, the current invite code (host only), the host handle (guest only), the assigned peer handle, the list of connected peers, the volunteer list (during a pending leave), and the leaving-state info.",
|
|
2637
|
+
args: {},
|
|
2638
|
+
async execute() {
|
|
2639
|
+
return plugin.mpStatus();
|
|
2640
|
+
}
|
|
2641
|
+
}),
|
|
2642
|
+
mp_rejoin: tool({
|
|
2643
|
+
description: "Rejoin a session using a grace code (the previous host's code, valid for 1 hour after a host change). Dials `ws://<MP_HOST>:<MP_PORT>` and authenticates with the provided code. Only works when this plugin instance is in idle role.",
|
|
2644
|
+
args: {
|
|
2645
|
+
code: tool.schema.string().describe("The retired host's invite code, e.g. `mp-bob-a3f9-x7k2`. Must be within the 1-hour grace window. Case-insensitive.")
|
|
2646
|
+
},
|
|
2647
|
+
async execute(args) {
|
|
2648
|
+
return plugin.mpRejoin(args.code);
|
|
2649
|
+
}
|
|
2650
|
+
}),
|
|
2651
|
+
mp_watch: tool({
|
|
2652
|
+
description: "Launch the companion TUI pane in a sibling terminal (presence, chat, input box). Auto-detects tmux/iTerm2/detached terminal; falls back to npx auto-install.",
|
|
2653
|
+
args: {},
|
|
2654
|
+
async execute() {
|
|
2655
|
+
const server = await plugin.startCompanionServer();
|
|
2656
|
+
if (!server) {
|
|
2657
|
+
return "Companion server failed to start. Check OpenCode logs.";
|
|
2658
|
+
}
|
|
2659
|
+
const strategy = detectStrategy({
|
|
2660
|
+
env: {
|
|
2661
|
+
TMUX: process.env["TMUX"],
|
|
2662
|
+
TERM_PROGRAM: process.env["TERM_PROGRAM"],
|
|
2663
|
+
ITERM_SESSION_ID: process.env["ITERM_SESSION_ID"],
|
|
2664
|
+
TERMINAL: process.env["TERMINAL"],
|
|
2665
|
+
PATH: process.env["PATH"],
|
|
2666
|
+
MP_NO_COMPANION: process.env["MP_NO_COMPANION"]
|
|
2667
|
+
}
|
|
2668
|
+
});
|
|
2669
|
+
if (strategy === "manual") {
|
|
2670
|
+
const cmd = manualCommand({
|
|
2671
|
+
binPath: companionBinPath(),
|
|
2672
|
+
socketPath: companionSocketPath(),
|
|
2673
|
+
token: server.getToken()
|
|
2674
|
+
});
|
|
2675
|
+
return `Run this in another terminal to open the companion:
|
|
2676
|
+
${cmd}
|
|
2677
|
+
|
|
2678
|
+
Or install globally: npm install -g @hmanlab/multiplayer-watch`;
|
|
2679
|
+
}
|
|
2680
|
+
const result = spawnStrategy({
|
|
2681
|
+
strategy,
|
|
2682
|
+
binPath: companionBinPath(),
|
|
2683
|
+
socketPath: companionSocketPath(),
|
|
2684
|
+
token: server.getToken(),
|
|
2685
|
+
cwd: process.cwd()
|
|
2686
|
+
});
|
|
2687
|
+
if (!result.ok) {
|
|
2688
|
+
return `Spawn failed (${result.strategy}: ${result.reason}). Run:
|
|
2689
|
+
${result.command}`;
|
|
2690
|
+
}
|
|
2691
|
+
return `Companion launched via ${result.strategy}.`;
|
|
2692
|
+
}
|
|
2693
|
+
})
|
|
2694
|
+
}
|
|
2695
|
+
};
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
// opencode/plugin/multiplayer-tools.ts
|
|
2699
|
+
var multiplayer_tools_default = async (input) => {
|
|
2700
|
+
return createMultiplayerPlugin(input);
|
|
2701
|
+
};
|
|
2702
|
+
export {
|
|
2703
|
+
multiplayer_tools_default as default
|
|
2704
|
+
};
|