@gorlitzer-labs/apiary 1.13.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.
- package/LICENSE +21 -0
- package/README.md +328 -0
- package/dist/agent/index.d.ts +689 -0
- package/dist/agent/index.js +34 -0
- package/dist/agent/index.js.map +1 -0
- package/dist/chunk-5V7QLCGB.js +184 -0
- package/dist/chunk-5V7QLCGB.js.map +1 -0
- package/dist/chunk-AYQJIAR5.js +152 -0
- package/dist/chunk-AYQJIAR5.js.map +1 -0
- package/dist/chunk-BZ4VDWC2.js +76 -0
- package/dist/chunk-BZ4VDWC2.js.map +1 -0
- package/dist/chunk-CFY6Z6Q6.js +95 -0
- package/dist/chunk-CFY6Z6Q6.js.map +1 -0
- package/dist/chunk-CZHO4DBN.js +1719 -0
- package/dist/chunk-CZHO4DBN.js.map +1 -0
- package/dist/chunk-D7DXZQWT.js +208 -0
- package/dist/chunk-D7DXZQWT.js.map +1 -0
- package/dist/chunk-ED6ROH6B.js +2021 -0
- package/dist/chunk-ED6ROH6B.js.map +1 -0
- package/dist/chunk-PEB6HEFT.js +606 -0
- package/dist/chunk-PEB6HEFT.js.map +1 -0
- package/dist/chunk-QL66XSDB.js +157 -0
- package/dist/chunk-QL66XSDB.js.map +1 -0
- package/dist/chunk-TPNFOQA7.js +288 -0
- package/dist/chunk-TPNFOQA7.js.map +1 -0
- package/dist/chunk-ZEFRNAX6.js +1771 -0
- package/dist/chunk-ZEFRNAX6.js.map +1 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +2432 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index-Dp3QSGog.d.ts +837 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/invites-OFXIQLLN.js +23 -0
- package/dist/invites-OFXIQLLN.js.map +1 -0
- package/dist/join-CQXEF65B.js +15 -0
- package/dist/join-CQXEF65B.js.map +1 -0
- package/dist/room-Z7KDRVYD.js +1099 -0
- package/dist/room-Z7KDRVYD.js.map +1 -0
- package/dist/run-DOADB4T7.js +90 -0
- package/dist/run-DOADB4T7.js.map +1 -0
- package/dist/update-JGE3WCQB.js +11 -0
- package/dist/update-JGE3WCQB.js.map +1 -0
- package/package.json +72 -0
|
@@ -0,0 +1,1719 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FileBackedStorage,
|
|
3
|
+
Room
|
|
4
|
+
} from "./chunk-PEB6HEFT.js";
|
|
5
|
+
import {
|
|
6
|
+
TokenManager,
|
|
7
|
+
buildShareUrl
|
|
8
|
+
} from "./chunk-AYQJIAR5.js";
|
|
9
|
+
import {
|
|
10
|
+
randomName,
|
|
11
|
+
randomRoomName
|
|
12
|
+
} from "./chunk-D7DXZQWT.js";
|
|
13
|
+
import {
|
|
14
|
+
can,
|
|
15
|
+
formatTimestamp
|
|
16
|
+
} from "./chunk-QL66XSDB.js";
|
|
17
|
+
import {
|
|
18
|
+
AttachmentSchema,
|
|
19
|
+
createEvent
|
|
20
|
+
} from "./chunk-BZ4VDWC2.js";
|
|
21
|
+
|
|
22
|
+
// src/cli/bind.ts
|
|
23
|
+
import { networkInterfaces } from "os";
|
|
24
|
+
function isTailnetAddress(address) {
|
|
25
|
+
const octets = address.split(".").map(Number);
|
|
26
|
+
if (octets.length !== 4 || octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
return octets[0] === 100 && octets[1] >= 64 && octets[1] <= 127;
|
|
30
|
+
}
|
|
31
|
+
function localAddresses() {
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const [iface, addrs] of Object.entries(networkInterfaces())) {
|
|
34
|
+
for (const addr of addrs ?? []) {
|
|
35
|
+
if (addr.family === "IPv4" && !addr.internal) out.push({ iface, address: addr.address });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
function tailnetAddress(addrs = localAddresses()) {
|
|
41
|
+
return addrs.find((a) => isTailnetAddress(a.address))?.address ?? null;
|
|
42
|
+
}
|
|
43
|
+
function lanAddress(addrs = localAddresses()) {
|
|
44
|
+
return addrs.find((a) => !isTailnetAddress(a.address))?.address ?? null;
|
|
45
|
+
}
|
|
46
|
+
var ALL_INTERFACES = "0.0.0.0";
|
|
47
|
+
var LOOPBACK = "127.0.0.1";
|
|
48
|
+
function resolveBindAddress(spec) {
|
|
49
|
+
const value = spec.trim();
|
|
50
|
+
if (!value) return { ok: false, error: "empty --bind value" };
|
|
51
|
+
switch (value.toLowerCase()) {
|
|
52
|
+
case "tailscale":
|
|
53
|
+
case "tailnet":
|
|
54
|
+
case "ts": {
|
|
55
|
+
const address = tailnetAddress();
|
|
56
|
+
if (!address) {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
error: "no Tailscale address found (100.64.0.0/10). Is Tailscale up? Check with: tailscale ip -4"
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return { ok: true, address, note: "tailnet \u2014 reachable from your other tailnet devices only" };
|
|
63
|
+
}
|
|
64
|
+
case "lan": {
|
|
65
|
+
const address = lanAddress();
|
|
66
|
+
if (!address) return { ok: false, error: "no LAN address found on this machine" };
|
|
67
|
+
return { ok: true, address, note: "LAN \u2014 reachable from this network only" };
|
|
68
|
+
}
|
|
69
|
+
case "all":
|
|
70
|
+
case "any":
|
|
71
|
+
case ALL_INTERFACES:
|
|
72
|
+
return {
|
|
73
|
+
ok: true,
|
|
74
|
+
address: ALL_INTERFACES,
|
|
75
|
+
note: "ALL interfaces \u2014 including any untrusted network this machine joins"
|
|
76
|
+
};
|
|
77
|
+
case "localhost":
|
|
78
|
+
case LOOPBACK:
|
|
79
|
+
return { ok: true, address: LOOPBACK, note: "this machine only" };
|
|
80
|
+
default:
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
const owner = localAddresses().find((a) => a.address === value);
|
|
84
|
+
if (!owner) {
|
|
85
|
+
const available = localAddresses().map((a) => `${a.address} (${a.iface})`).join(", ");
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
error: `"${value}" is not an address on this machine.` + (available ? ` Available: ${available}` : "") + ` Or use: tailscale, lan, all, localhost.`
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
address: value,
|
|
94
|
+
note: isTailnetAddress(value) ? `tailnet (${owner.iface}) \u2014 reachable from your other tailnet devices only` : `${owner.iface}`
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function advertisedAddress(bound, addrs = localAddresses()) {
|
|
98
|
+
if (bound !== ALL_INTERFACES) return bound;
|
|
99
|
+
return tailnetAddress(addrs) ?? lanAddress(addrs) ?? LOOPBACK;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/core/rules.ts
|
|
103
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
104
|
+
import { homedir } from "os";
|
|
105
|
+
import { join as pathJoin, dirname } from "path";
|
|
106
|
+
var DEFAULT_RULES = [
|
|
107
|
+
"Never push to main. Open a PR.",
|
|
108
|
+
"Don't touch repos you don't own \u2014 your cwd is your hive.",
|
|
109
|
+
"No force-push, no `git reset --hard`, no `rm -rf` on anything you didn't make this session.",
|
|
110
|
+
'Announce destructive intent before you act. "About to delete X" beats "I deleted X, sorry."',
|
|
111
|
+
"If a human is in the room, wait for their ack before merging, deploying, or running anything irreversible.",
|
|
112
|
+
"Stay in your lane. If a task belongs to another agent, @mention them \u2014 don't poach.",
|
|
113
|
+
"When uncertain, ask in the room with send_message \u2014 nobody reads your terminal. A clarifying question costs a token; a wrong commit costs an afternoon."
|
|
114
|
+
];
|
|
115
|
+
var RULES_MAX = 12;
|
|
116
|
+
var APIARY_DIR = pathJoin(homedir(), ".apiary");
|
|
117
|
+
var GLOBAL_DEFAULT_PATH = pathJoin(APIARY_DIR, "rules.default.md");
|
|
118
|
+
var ROOMS_DIR = pathJoin(APIARY_DIR, "rooms");
|
|
119
|
+
function roomRulesPath(roomName) {
|
|
120
|
+
return pathJoin(ROOMS_DIR, `${roomName}.rules.md`);
|
|
121
|
+
}
|
|
122
|
+
function parseRulesMarkdown(content) {
|
|
123
|
+
let body = content.trimStart();
|
|
124
|
+
if (body.startsWith("---")) {
|
|
125
|
+
const end = body.indexOf("\n---", 3);
|
|
126
|
+
if (end !== -1) body = body.slice(end + 4);
|
|
127
|
+
}
|
|
128
|
+
const rules = [];
|
|
129
|
+
for (const rawLine of body.split("\n")) {
|
|
130
|
+
const line = rawLine.trim();
|
|
131
|
+
if (line.startsWith("- ")) rules.push(line.slice(2).trim());
|
|
132
|
+
else if (line.startsWith("* ")) rules.push(line.slice(2).trim());
|
|
133
|
+
}
|
|
134
|
+
return rules.filter((r) => r.length > 0);
|
|
135
|
+
}
|
|
136
|
+
function formatRulesMarkdown(rules) {
|
|
137
|
+
const header = "# Room rules\n\n";
|
|
138
|
+
const body = rules.map((r) => `- ${r}`).join("\n");
|
|
139
|
+
return header + body + "\n";
|
|
140
|
+
}
|
|
141
|
+
function loadRulesForRoom(roomName) {
|
|
142
|
+
const roomPath = roomRulesPath(roomName);
|
|
143
|
+
const candidates = [roomPath, GLOBAL_DEFAULT_PATH];
|
|
144
|
+
for (const p of candidates) {
|
|
145
|
+
if (existsSync(p)) {
|
|
146
|
+
try {
|
|
147
|
+
const parsed = parseRulesMarkdown(readFileSync(p, "utf-8"));
|
|
148
|
+
if (parsed.length > 0) return parsed.slice(0, RULES_MAX);
|
|
149
|
+
} catch {
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return [...DEFAULT_RULES];
|
|
154
|
+
}
|
|
155
|
+
function saveRulesForRoom(roomName, rules) {
|
|
156
|
+
const p = roomRulesPath(roomName);
|
|
157
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
158
|
+
writeFileSync(p, formatRulesMarkdown(rules.slice(0, RULES_MAX)));
|
|
159
|
+
}
|
|
160
|
+
function seedRulesIfMissing(roomName) {
|
|
161
|
+
const p = roomRulesPath(roomName);
|
|
162
|
+
if (existsSync(p)) return null;
|
|
163
|
+
const initial = existsSync(GLOBAL_DEFAULT_PATH) ? parseRulesMarkdown(readFileSync(GLOBAL_DEFAULT_PATH, "utf-8")) : [...DEFAULT_RULES];
|
|
164
|
+
saveRulesForRoom(roomName, initial.length > 0 ? initial : [...DEFAULT_RULES]);
|
|
165
|
+
return p;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// src/cli/config.ts
|
|
169
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
170
|
+
import { homedir as homedir2 } from "os";
|
|
171
|
+
import { join } from "path";
|
|
172
|
+
var CONFIG_DIR = join(homedir2(), ".apiary");
|
|
173
|
+
var CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
174
|
+
function loadConfig() {
|
|
175
|
+
if (!existsSync2(CONFIG_PATH)) return {};
|
|
176
|
+
try {
|
|
177
|
+
return JSON.parse(readFileSync2(CONFIG_PATH, "utf-8"));
|
|
178
|
+
} catch {
|
|
179
|
+
return {};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function saveConfig(config) {
|
|
183
|
+
if (!existsSync2(CONFIG_DIR)) mkdirSync2(CONFIG_DIR, { recursive: true });
|
|
184
|
+
writeFileSync2(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
|
|
185
|
+
}
|
|
186
|
+
function stableIndex(seed, poolSize) {
|
|
187
|
+
let hash = 0;
|
|
188
|
+
for (const c of seed) hash = (hash << 5) - hash + c.charCodeAt(0) | 0;
|
|
189
|
+
return (hash % poolSize + poolSize) % poolSize;
|
|
190
|
+
}
|
|
191
|
+
var BUG_POOL = [
|
|
192
|
+
"\u{1F41D}",
|
|
193
|
+
"\u{1F41B}",
|
|
194
|
+
"\u{1F98B}",
|
|
195
|
+
"\u{1F41E}",
|
|
196
|
+
"\u{1FAB2}",
|
|
197
|
+
"\u{1F41C}",
|
|
198
|
+
"\u{1F997}",
|
|
199
|
+
"\u{1FAB3}",
|
|
200
|
+
"\u{1F982}",
|
|
201
|
+
"\u{1F577}\uFE0F",
|
|
202
|
+
"\u{1FAB0}",
|
|
203
|
+
"\u{1F99F}",
|
|
204
|
+
"\u{1F40C}",
|
|
205
|
+
"\u{1F419}",
|
|
206
|
+
"\u{1F991}",
|
|
207
|
+
"\u{1F980}",
|
|
208
|
+
"\u{1FAB1}",
|
|
209
|
+
"\u{1F9A0}",
|
|
210
|
+
"\u{1F9EC}",
|
|
211
|
+
"\u{1F52C}"
|
|
212
|
+
];
|
|
213
|
+
var HIVE_POOL = ["\u{1F36F}", "\u{1F41D}", "\u{1F3E0}", "\u{1FABA}", "\u{1F338}"];
|
|
214
|
+
function agentEmoji(agentName) {
|
|
215
|
+
return BUG_POOL[stableIndex(agentName, BUG_POOL.length)];
|
|
216
|
+
}
|
|
217
|
+
function roomEmoji(roomName) {
|
|
218
|
+
return HIVE_POOL[stableIndex(roomName, HIVE_POOL.length)];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// src/cli/agent-session.ts
|
|
222
|
+
import { writeFileSync as writeFileSync3, readFileSync as readFileSync3, readdirSync, existsSync as existsSync3, mkdirSync as mkdirSync3, rmSync } from "fs";
|
|
223
|
+
import { join as join2 } from "path";
|
|
224
|
+
import { homedir as homedir3 } from "os";
|
|
225
|
+
var SESSION_DIR = join2(homedir3(), ".apiary", "sessions");
|
|
226
|
+
function sessionPath(runtime, name) {
|
|
227
|
+
return join2(SESSION_DIR, `${runtime}_${name}.json`);
|
|
228
|
+
}
|
|
229
|
+
function saveAgentSession(session) {
|
|
230
|
+
if (!existsSync3(SESSION_DIR)) mkdirSync3(SESSION_DIR, { recursive: true });
|
|
231
|
+
writeFileSync3(sessionPath(session.runtime, session.agentName), JSON.stringify(session, null, 2));
|
|
232
|
+
}
|
|
233
|
+
function clearAgentSession(runtime, name) {
|
|
234
|
+
try {
|
|
235
|
+
rmSync(sessionPath(runtime, name));
|
|
236
|
+
} catch {
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function listAgentSessions(runtime) {
|
|
240
|
+
if (!existsSync3(SESSION_DIR)) return [];
|
|
241
|
+
const prefix = `${runtime}_`;
|
|
242
|
+
const sessions = [];
|
|
243
|
+
for (const f of readdirSync(SESSION_DIR)) {
|
|
244
|
+
if (!f.startsWith(prefix) || !f.endsWith(".json")) continue;
|
|
245
|
+
try {
|
|
246
|
+
sessions.push(JSON.parse(readFileSync3(join2(SESSION_DIR, f), "utf-8")));
|
|
247
|
+
} catch {
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return sessions;
|
|
251
|
+
}
|
|
252
|
+
function isAgentAlive(session) {
|
|
253
|
+
try {
|
|
254
|
+
process.kill(session.pid, 0);
|
|
255
|
+
return true;
|
|
256
|
+
} catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// src/cli/tmux.ts
|
|
262
|
+
import { execFileSync, spawn } from "child_process";
|
|
263
|
+
function sanitizeSessionName(name) {
|
|
264
|
+
return name.replace(/[.:$%]/g, "_");
|
|
265
|
+
}
|
|
266
|
+
function tmuxAvailable() {
|
|
267
|
+
try {
|
|
268
|
+
execFileSync("tmux", ["-V"], { stdio: "ignore" });
|
|
269
|
+
return true;
|
|
270
|
+
} catch {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function tmuxSessionExists(session) {
|
|
275
|
+
try {
|
|
276
|
+
const name = sanitizeSessionName(session);
|
|
277
|
+
execFileSync("tmux", ["has-session", "-t", name], { stdio: "ignore" });
|
|
278
|
+
return true;
|
|
279
|
+
} catch {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
var AGENT_PANE_COLS = 200;
|
|
284
|
+
var AGENT_PANE_ROWS = 50;
|
|
285
|
+
function tmuxCreateSession(session, title, size = { cols: AGENT_PANE_COLS, rows: AGENT_PANE_ROWS }) {
|
|
286
|
+
const name = sanitizeSessionName(session);
|
|
287
|
+
execFileSync("tmux", [
|
|
288
|
+
"new-session",
|
|
289
|
+
"-d",
|
|
290
|
+
"-s",
|
|
291
|
+
name,
|
|
292
|
+
"-x",
|
|
293
|
+
String(size.cols),
|
|
294
|
+
"-y",
|
|
295
|
+
String(size.rows)
|
|
296
|
+
]);
|
|
297
|
+
execFileSync("tmux", ["set", "-t", name, "status", "off"]);
|
|
298
|
+
try {
|
|
299
|
+
execFileSync("tmux", ["set-option", "-t", name, "window-size", "manual"]);
|
|
300
|
+
execFileSync("tmux", ["resize-window", "-t", name, "-x", String(size.cols), "-y", String(size.rows)]);
|
|
301
|
+
} catch {
|
|
302
|
+
}
|
|
303
|
+
if (title) {
|
|
304
|
+
execFileSync("tmux", ["set", "-t", name, "set-titles", "on"]);
|
|
305
|
+
execFileSync("tmux", ["set", "-t", name, "set-titles-string", title]);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function tmuxSendCommand(session, command) {
|
|
309
|
+
const name = sanitizeSessionName(session);
|
|
310
|
+
execFileSync("tmux", ["send-keys", "-t", name, "-l", command]);
|
|
311
|
+
execFileSync("tmux", ["send-keys", "-t", name, "Enter"]);
|
|
312
|
+
}
|
|
313
|
+
function tmuxInjectText(session, text) {
|
|
314
|
+
const name = sanitizeSessionName(session);
|
|
315
|
+
execFileSync("tmux", ["send-keys", "-t", name, "-l", text]);
|
|
316
|
+
}
|
|
317
|
+
function tmuxInjectPaste(session, text) {
|
|
318
|
+
const name = sanitizeSessionName(session);
|
|
319
|
+
execFileSync("tmux", ["send-keys", "-t", name, "-l", `\x1B[200~${text}\x1B[201~`]);
|
|
320
|
+
}
|
|
321
|
+
function tmuxSendEnter(session) {
|
|
322
|
+
const name = sanitizeSessionName(session);
|
|
323
|
+
execFileSync("tmux", ["send-keys", "-t", name, "Enter"]);
|
|
324
|
+
}
|
|
325
|
+
function tmuxAttach(session) {
|
|
326
|
+
const name = sanitizeSessionName(session);
|
|
327
|
+
if (process.env.TMUX) {
|
|
328
|
+
try {
|
|
329
|
+
execFileSync("tmux", ["switch-client", "-t", name], { stdio: "ignore" });
|
|
330
|
+
} catch {
|
|
331
|
+
}
|
|
332
|
+
return new Promise((resolve2) => {
|
|
333
|
+
const poll = setInterval(() => {
|
|
334
|
+
try {
|
|
335
|
+
execFileSync("tmux", ["has-session", "-t", name], { stdio: "ignore" });
|
|
336
|
+
} catch {
|
|
337
|
+
clearInterval(poll);
|
|
338
|
+
resolve2();
|
|
339
|
+
}
|
|
340
|
+
}, 500);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
return new Promise((resolve2) => {
|
|
344
|
+
const child = spawn("tmux", ["attach", "-t", name], { stdio: "inherit" });
|
|
345
|
+
child.on("exit", () => resolve2());
|
|
346
|
+
child.on("error", () => resolve2());
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
function tmuxCapturePane(session) {
|
|
350
|
+
try {
|
|
351
|
+
const name = sanitizeSessionName(session);
|
|
352
|
+
const output = execFileSync("tmux", ["capture-pane", "-t", name, "-p"], {
|
|
353
|
+
encoding: "utf-8"
|
|
354
|
+
});
|
|
355
|
+
return trimTrailingBlankLines(output.split("\n"));
|
|
356
|
+
} catch {
|
|
357
|
+
return [];
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function trimTrailingBlankLines(lines) {
|
|
361
|
+
let end = lines.length;
|
|
362
|
+
while (end > 0 && lines[end - 1].trim() === "") end--;
|
|
363
|
+
return lines.slice(0, end);
|
|
364
|
+
}
|
|
365
|
+
function tmuxSendKey(session, key) {
|
|
366
|
+
const name = sanitizeSessionName(session);
|
|
367
|
+
execFileSync("tmux", ["send-keys", "-t", name, key]);
|
|
368
|
+
}
|
|
369
|
+
function tmuxKillSession(session) {
|
|
370
|
+
try {
|
|
371
|
+
const name = sanitizeSessionName(session);
|
|
372
|
+
execFileSync("tmux", ["kill-session", "-t", name], { stdio: "ignore" });
|
|
373
|
+
} catch {
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function resetTerminal() {
|
|
377
|
+
if (!process.stdin.isTTY) return;
|
|
378
|
+
try {
|
|
379
|
+
execFileSync("stty", ["sane"], { stdio: "inherit" });
|
|
380
|
+
} catch {
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
var SHELL_COMMANDS = /* @__PURE__ */ new Set([
|
|
384
|
+
"sh",
|
|
385
|
+
"bash",
|
|
386
|
+
"zsh",
|
|
387
|
+
"fish",
|
|
388
|
+
"dash",
|
|
389
|
+
"ksh",
|
|
390
|
+
"mksh",
|
|
391
|
+
"tcsh",
|
|
392
|
+
"csh",
|
|
393
|
+
"nu",
|
|
394
|
+
"pwsh",
|
|
395
|
+
"powershell",
|
|
396
|
+
"login",
|
|
397
|
+
"elvish",
|
|
398
|
+
"xonsh"
|
|
399
|
+
]);
|
|
400
|
+
function tmuxPaneCommand(session) {
|
|
401
|
+
try {
|
|
402
|
+
const name = sanitizeSessionName(session);
|
|
403
|
+
const out = execFileSync("tmux", ["display-message", "-p", "-t", name, "#{pane_current_command}"], {
|
|
404
|
+
encoding: "utf-8",
|
|
405
|
+
timeout: 5e3
|
|
406
|
+
}).trim();
|
|
407
|
+
return out || null;
|
|
408
|
+
} catch {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
function tmuxPaneIsShell(session) {
|
|
413
|
+
const cmd = tmuxPaneCommand(session);
|
|
414
|
+
if (!cmd) return false;
|
|
415
|
+
return SHELL_COMMANDS.has(cmd.toLowerCase().replace(/^-/, ""));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// src/cli/serve.ts
|
|
419
|
+
import { createServer } from "http";
|
|
420
|
+
import { spawn as spawn2, execFileSync as execFileSync2 } from "child_process";
|
|
421
|
+
import { randomUUID } from "crypto";
|
|
422
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync4, rmSync as rmSync2 } from "fs";
|
|
423
|
+
import { createRequire } from "module";
|
|
424
|
+
import { homedir as homedir4, tmpdir } from "os";
|
|
425
|
+
import { join as pathJoin2, resolve, sep } from "path";
|
|
426
|
+
import { watchFile, unwatchFile } from "fs";
|
|
427
|
+
|
|
428
|
+
// src/cli/terminate-agent.ts
|
|
429
|
+
var RUNTIMES = ["claude", "codex", "opencode"];
|
|
430
|
+
function terminateLocalAgent(agentName, deps = {}) {
|
|
431
|
+
const list = deps.list ?? listAgentSessions;
|
|
432
|
+
const alive = deps.alive ?? isAgentAlive;
|
|
433
|
+
const kill = deps.kill ?? ((pid, signal) => {
|
|
434
|
+
process.kill(pid, signal);
|
|
435
|
+
});
|
|
436
|
+
const clear = deps.clear ?? clearAgentSession;
|
|
437
|
+
for (const runtime of RUNTIMES) {
|
|
438
|
+
const session = list(runtime).find((s) => s.agentName === agentName);
|
|
439
|
+
if (!session) continue;
|
|
440
|
+
if (!alive(session)) {
|
|
441
|
+
clear(runtime, agentName);
|
|
442
|
+
return { stopped: false, reason: "already-exited" };
|
|
443
|
+
}
|
|
444
|
+
try {
|
|
445
|
+
kill(session.pid, "SIGTERM");
|
|
446
|
+
} catch {
|
|
447
|
+
}
|
|
448
|
+
if (session.tmuxSession && deps.killTmux) {
|
|
449
|
+
try {
|
|
450
|
+
deps.killTmux(session.tmuxSession);
|
|
451
|
+
} catch {
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
clear(runtime, agentName);
|
|
455
|
+
return {
|
|
456
|
+
stopped: true,
|
|
457
|
+
runtime,
|
|
458
|
+
pid: session.pid,
|
|
459
|
+
tmuxSession: session.tmuxSession ?? ""
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
return { stopped: false, reason: "not-local" };
|
|
463
|
+
}
|
|
464
|
+
function seatsToEvict(seats, joining) {
|
|
465
|
+
if (joining.type !== "agent") return [];
|
|
466
|
+
return seats.filter((s) => s.type === "agent" && s.name === joining.name);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// src/cli/serve.ts
|
|
470
|
+
var SESSION_DIR2 = pathJoin2(homedir4(), ".apiary", "sessions");
|
|
471
|
+
var INVITES_DIR = pathJoin2(homedir4(), ".apiary", "invites");
|
|
472
|
+
function roomSessionPath(name) {
|
|
473
|
+
return pathJoin2(SESSION_DIR2, `room_${name}.json`);
|
|
474
|
+
}
|
|
475
|
+
function saveRoomSession(session) {
|
|
476
|
+
if (!existsSync4(SESSION_DIR2)) mkdirSync4(SESSION_DIR2, { recursive: true });
|
|
477
|
+
writeFileSync4(roomSessionPath(session.roomName), JSON.stringify(session, null, 2));
|
|
478
|
+
}
|
|
479
|
+
function clearRoomSession(name) {
|
|
480
|
+
try {
|
|
481
|
+
rmSync2(roomSessionPath(name));
|
|
482
|
+
} catch {
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
function listRoomSessions() {
|
|
486
|
+
if (!existsSync4(SESSION_DIR2)) return [];
|
|
487
|
+
const files = readdirSync2(SESSION_DIR2);
|
|
488
|
+
const sessions = [];
|
|
489
|
+
for (const f of files) {
|
|
490
|
+
if (!f.startsWith("room_") || !f.endsWith(".json")) continue;
|
|
491
|
+
try {
|
|
492
|
+
sessions.push(JSON.parse(readFileSync4(pathJoin2(SESSION_DIR2, f), "utf-8")));
|
|
493
|
+
} catch {
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return sessions;
|
|
497
|
+
}
|
|
498
|
+
function removeRoomSession(name) {
|
|
499
|
+
try {
|
|
500
|
+
rmSync2(pathJoin2(SESSION_DIR2, `room_${name}.json`));
|
|
501
|
+
} catch {
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
async function enrichAndSend(res, event, room) {
|
|
505
|
+
if (event.type === "MessageSent" && event.message.reply_to_id) {
|
|
506
|
+
const replyMsg = await room.getMessage(event.message.reply_to_id);
|
|
507
|
+
const enriched = {
|
|
508
|
+
...event,
|
|
509
|
+
_replyToName: replyMsg?.sender_name ?? null
|
|
510
|
+
};
|
|
511
|
+
res.write(`data: ${JSON.stringify(enriched)}
|
|
512
|
+
|
|
513
|
+
`);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
res.write(`data: ${JSON.stringify(event)}
|
|
517
|
+
|
|
518
|
+
`);
|
|
519
|
+
}
|
|
520
|
+
var ROOMS_DIR2 = pathJoin2(homedir4(), ".apiary", "rooms");
|
|
521
|
+
function validateSavePath(p) {
|
|
522
|
+
const resolved = resolve(p);
|
|
523
|
+
if (!resolved.endsWith(".json")) throw new Error("Save/load path must end with .json");
|
|
524
|
+
const cwd = process.cwd();
|
|
525
|
+
const tmp = tmpdir();
|
|
526
|
+
const apiaryDir = pathJoin2(homedir4(), ".apiary");
|
|
527
|
+
if (!resolved.startsWith(cwd + sep) && !resolved.startsWith(tmp + sep) && !resolved.startsWith(apiaryDir + sep))
|
|
528
|
+
throw new Error(`Path must be under ${cwd}, ${tmp}, or ${apiaryDir}`);
|
|
529
|
+
return resolved;
|
|
530
|
+
}
|
|
531
|
+
async function serve(options) {
|
|
532
|
+
const roomName = options.room ?? randomRoomName();
|
|
533
|
+
const port = options.port ?? 7890;
|
|
534
|
+
const boundAddress = options.bind ?? (options.expose ? ALL_INTERFACES : LOOPBACK);
|
|
535
|
+
const serverUrl = `http://${advertisedAddress(boundAddress)}:${port}`;
|
|
536
|
+
const log = options.headless ? () => {
|
|
537
|
+
} : logServer;
|
|
538
|
+
let publicUrl = serverUrl;
|
|
539
|
+
let tunnelProcess = null;
|
|
540
|
+
if (options.save) options.save = validateSavePath(options.save);
|
|
541
|
+
if (options.load) options.load = validateSavePath(options.load);
|
|
542
|
+
mkdirSync4(ROOMS_DIR2, { recursive: true });
|
|
543
|
+
const defaultPath = pathJoin2(ROOMS_DIR2, `${roomName}.json`);
|
|
544
|
+
const savePath = options.save ?? options.load ?? defaultPath;
|
|
545
|
+
const shouldAutoLoad = !options.save && !options.load && existsSync4(defaultPath);
|
|
546
|
+
let storage;
|
|
547
|
+
if (options.load || shouldAutoLoad) {
|
|
548
|
+
const loadPath = options.load ?? defaultPath;
|
|
549
|
+
try {
|
|
550
|
+
storage = await FileBackedStorage.load(loadPath);
|
|
551
|
+
log(`loaded room state from ${loadPath}`);
|
|
552
|
+
} catch (err) {
|
|
553
|
+
if (err.code === "ENOENT") {
|
|
554
|
+
storage = new FileBackedStorage(loadPath);
|
|
555
|
+
log(`no existing file at ${loadPath}, starting fresh`);
|
|
556
|
+
} else {
|
|
557
|
+
throw err;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
} else {
|
|
561
|
+
storage = new FileBackedStorage(savePath);
|
|
562
|
+
}
|
|
563
|
+
const room = new Room(roomName, storage);
|
|
564
|
+
seedRulesIfMissing(roomName);
|
|
565
|
+
let currentRules = loadRulesForRoom(roomName);
|
|
566
|
+
const rulesFilePath = roomRulesPath(roomName);
|
|
567
|
+
const onRulesFileChange = (curr, prev) => {
|
|
568
|
+
if (curr.mtimeMs === prev.mtimeMs) return;
|
|
569
|
+
try {
|
|
570
|
+
const next = loadRulesForRoom(roomName);
|
|
571
|
+
if (JSON.stringify(next) === JSON.stringify(currentRules)) return;
|
|
572
|
+
currentRules = next;
|
|
573
|
+
broadcastRulesChanged(currentRules, "[system]");
|
|
574
|
+
log(`rules reloaded from disk (${currentRules.length} rules)`);
|
|
575
|
+
} catch {
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
watchFile(rulesFilePath, { interval: 2e3 }, onRulesFileChange);
|
|
579
|
+
const tokens = new TokenManager({ shareTtlMs: options.shareTtlMs });
|
|
580
|
+
const participants = /* @__PURE__ */ new Map();
|
|
581
|
+
const guests = /* @__PURE__ */ new Map();
|
|
582
|
+
const idToSession = /* @__PURE__ */ new Map();
|
|
583
|
+
const sseConnections = /* @__PURE__ */ new Map();
|
|
584
|
+
const lastSeenAt = /* @__PURE__ */ new Map();
|
|
585
|
+
const presenceStatus = /* @__PURE__ */ new Map();
|
|
586
|
+
const UNRESPONSIVE_AFTER_MS = parseInt(process.env.APIARY_UNRESPONSIVE_MS ?? "90000", 10);
|
|
587
|
+
const OFFLINE_AFTER_MS = parseInt(process.env.APIARY_OFFLINE_MS ?? String(UNRESPONSIVE_AFTER_MS * 2), 10);
|
|
588
|
+
const PRESENCE_CHECK_INTERVAL_MS = parseInt(process.env.APIARY_PRESENCE_CHECK_MS ?? "30000", 10);
|
|
589
|
+
function touchParticipant(id) {
|
|
590
|
+
lastSeenAt.set(id, Date.now());
|
|
591
|
+
}
|
|
592
|
+
function broadcastToAll(event) {
|
|
593
|
+
const frame = `data: ${JSON.stringify(event)}
|
|
594
|
+
|
|
595
|
+
`;
|
|
596
|
+
for (const [, sseRes] of sseConnections) {
|
|
597
|
+
sseRes.write(frame);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const agentStates = /* @__PURE__ */ new Map();
|
|
601
|
+
const AGENT_STATE_TTL_MS = 15e3;
|
|
602
|
+
function agentStateFor(participantId) {
|
|
603
|
+
const entry = agentStates.get(participantId);
|
|
604
|
+
if (!entry) return void 0;
|
|
605
|
+
return Date.now() - entry.at < AGENT_STATE_TTL_MS ? entry.state : void 0;
|
|
606
|
+
}
|
|
607
|
+
function participantSummaries() {
|
|
608
|
+
return room.listParticipants().map((p) => ({
|
|
609
|
+
id: p.id,
|
|
610
|
+
name: p.name,
|
|
611
|
+
type: p.type,
|
|
612
|
+
authority: p.authority ?? "member",
|
|
613
|
+
/** "idle" | "working" | "blocked", or absent if the runtime doesn't report. */
|
|
614
|
+
agentState: agentStateFor(p.id)
|
|
615
|
+
}));
|
|
616
|
+
}
|
|
617
|
+
function broadcastStatusChange(participantId, name, status, previousStatus, reason) {
|
|
618
|
+
const event = createEvent({
|
|
619
|
+
type: "StatusChanged",
|
|
620
|
+
category: "PRESENCE",
|
|
621
|
+
room_id: room.roomId,
|
|
622
|
+
participant_id: participantId,
|
|
623
|
+
name,
|
|
624
|
+
status,
|
|
625
|
+
previous_status: previousStatus,
|
|
626
|
+
reason
|
|
627
|
+
});
|
|
628
|
+
broadcastToAll(event);
|
|
629
|
+
}
|
|
630
|
+
function broadcastRulesChanged(rules, updatedBy) {
|
|
631
|
+
const event = createEvent({
|
|
632
|
+
type: "RulesChanged",
|
|
633
|
+
category: "ACTIVITY",
|
|
634
|
+
room_id: room.roomId,
|
|
635
|
+
participant_id: "[system]",
|
|
636
|
+
rules,
|
|
637
|
+
updated_by: updatedBy
|
|
638
|
+
});
|
|
639
|
+
broadcastToAll(event);
|
|
640
|
+
}
|
|
641
|
+
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
642
|
+
const MAX_ATTACHMENT_STORE_BYTES = 256 * 1024 * 1024;
|
|
643
|
+
const attachmentStore = /* @__PURE__ */ new Map();
|
|
644
|
+
let attachmentStoreBytes = 0;
|
|
645
|
+
function evictAttachmentsToFit(neededBytes) {
|
|
646
|
+
if (neededBytes > MAX_ATTACHMENT_STORE_BYTES) return;
|
|
647
|
+
while (attachmentStoreBytes + neededBytes > MAX_ATTACHMENT_STORE_BYTES) {
|
|
648
|
+
const oldest = attachmentStore.entries().next();
|
|
649
|
+
if (oldest.done) break;
|
|
650
|
+
const [oldestId, oldestAtt] = oldest.value;
|
|
651
|
+
attachmentStore.delete(oldestId);
|
|
652
|
+
attachmentStoreBytes -= oldestAtt.size;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
function attachmentUrl(id) {
|
|
656
|
+
return `${publicUrl}/attachment/${id}`;
|
|
657
|
+
}
|
|
658
|
+
async function parseBody(req) {
|
|
659
|
+
const chunks = [];
|
|
660
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
661
|
+
try {
|
|
662
|
+
return JSON.parse(Buffer.concat(chunks).toString());
|
|
663
|
+
} catch {
|
|
664
|
+
return {};
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
function extractSessionToken(req, url, body) {
|
|
668
|
+
const h = req.headers.authorization;
|
|
669
|
+
if (h?.startsWith("Bearer ")) return h.slice(7);
|
|
670
|
+
return url.searchParams.get("token") ?? (body?.token ? String(body.token) : null);
|
|
671
|
+
}
|
|
672
|
+
function getSession(token) {
|
|
673
|
+
if (!token) return null;
|
|
674
|
+
const p = participants.get(token);
|
|
675
|
+
if (p) return { ...p, kind: "participant" };
|
|
676
|
+
const g = guests.get(token);
|
|
677
|
+
if (g) return { ...g, kind: "guest" };
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
function clampInt(val, def, min, max) {
|
|
681
|
+
const n = parseInt(val ?? String(def), 10);
|
|
682
|
+
return isNaN(n) ? def : Math.max(min, Math.min(max, n));
|
|
683
|
+
}
|
|
684
|
+
function jsonError(res, status, error) {
|
|
685
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
686
|
+
res.end(JSON.stringify({ error }));
|
|
687
|
+
}
|
|
688
|
+
function jsonOk(res, data = {}) {
|
|
689
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
690
|
+
res.end(JSON.stringify({ ok: true, ...data }));
|
|
691
|
+
}
|
|
692
|
+
const allowedOrigins = /* @__PURE__ */ new Set([
|
|
693
|
+
`http://127.0.0.1:${port}`,
|
|
694
|
+
`http://localhost:${port}`,
|
|
695
|
+
...options.corsOrigins ?? []
|
|
696
|
+
]);
|
|
697
|
+
function addCorsOrigin(origin) {
|
|
698
|
+
allowedOrigins.add(origin);
|
|
699
|
+
}
|
|
700
|
+
function getCorsHeaders(req) {
|
|
701
|
+
const origin = req.headers.origin;
|
|
702
|
+
if (!origin) return {};
|
|
703
|
+
if (allowedOrigins.has(origin)) {
|
|
704
|
+
return {
|
|
705
|
+
"Access-Control-Allow-Origin": origin,
|
|
706
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
707
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization, Accept",
|
|
708
|
+
"Access-Control-Max-Age": "86400",
|
|
709
|
+
Vary: "Origin"
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
return {};
|
|
713
|
+
}
|
|
714
|
+
class RateLimiter {
|
|
715
|
+
constructor(_max, _windowMs) {
|
|
716
|
+
this._max = _max;
|
|
717
|
+
this._windowMs = _windowMs;
|
|
718
|
+
}
|
|
719
|
+
_max;
|
|
720
|
+
_windowMs;
|
|
721
|
+
_buckets = /* @__PURE__ */ new Map();
|
|
722
|
+
_opsSincePrune = 0;
|
|
723
|
+
check(key) {
|
|
724
|
+
const now = Date.now();
|
|
725
|
+
if (++this._opsSincePrune >= 256) {
|
|
726
|
+
this._opsSincePrune = 0;
|
|
727
|
+
for (const [k, b] of this._buckets) {
|
|
728
|
+
if (now >= b.resetAt) this._buckets.delete(k);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
const bucket = this._buckets.get(key);
|
|
732
|
+
if (!bucket || now >= bucket.resetAt) {
|
|
733
|
+
this._buckets.set(key, { count: 1, resetAt: now + this._windowMs });
|
|
734
|
+
return { allowed: true };
|
|
735
|
+
}
|
|
736
|
+
if (bucket.count >= this._max) {
|
|
737
|
+
return { allowed: false, retryAfter: Math.ceil((bucket.resetAt - now) / 1e3) };
|
|
738
|
+
}
|
|
739
|
+
bucket.count++;
|
|
740
|
+
return { allowed: true };
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
const joinLimiter = new RateLimiter(10, 6e4);
|
|
744
|
+
const messageLimiter = new RateLimiter(30, 6e4);
|
|
745
|
+
const shareLimiter = new RateLimiter(10, 6e4);
|
|
746
|
+
const getLimiter = new RateLimiter(60, 6e4);
|
|
747
|
+
const sseLimiter = new RateLimiter(5, 6e4);
|
|
748
|
+
function getClientIp(req) {
|
|
749
|
+
return req.socket.remoteAddress ?? "unknown";
|
|
750
|
+
}
|
|
751
|
+
function rateLimitError(res, retryAfter) {
|
|
752
|
+
res.writeHead(429, {
|
|
753
|
+
"Content-Type": "application/json",
|
|
754
|
+
"Retry-After": String(retryAfter)
|
|
755
|
+
});
|
|
756
|
+
res.end(JSON.stringify({ error: "Too many requests" }));
|
|
757
|
+
}
|
|
758
|
+
const httpServer = createServer(async (req, res) => {
|
|
759
|
+
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
760
|
+
if (req.method === "OPTIONS") {
|
|
761
|
+
const corsHeaders2 = getCorsHeaders(req);
|
|
762
|
+
if (Object.keys(corsHeaders2).length > 0) {
|
|
763
|
+
res.writeHead(204, corsHeaders2);
|
|
764
|
+
} else {
|
|
765
|
+
res.writeHead(204);
|
|
766
|
+
}
|
|
767
|
+
res.end();
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
const corsHeaders = getCorsHeaders(req);
|
|
771
|
+
for (const [k, v] of Object.entries(corsHeaders)) {
|
|
772
|
+
res.setHeader(k, v);
|
|
773
|
+
}
|
|
774
|
+
if (url.pathname === "/events" && (req.method === "GET" || req.method === "POST")) {
|
|
775
|
+
const sseCheck = sseLimiter.check(getClientIp(req));
|
|
776
|
+
if (!sseCheck.allowed) return rateLimitError(res, sseCheck.retryAfter);
|
|
777
|
+
const authHeader = req.headers.authorization;
|
|
778
|
+
const sessionToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
|
779
|
+
const session = getSession(sessionToken);
|
|
780
|
+
if (!session) {
|
|
781
|
+
jsonError(res, 401, "Invalid session token");
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
res.writeHead(200, {
|
|
785
|
+
"Content-Type": "text/event-stream",
|
|
786
|
+
"Cache-Control": "no-cache",
|
|
787
|
+
"Connection": "keep-alive"
|
|
788
|
+
});
|
|
789
|
+
res.flushHeaders();
|
|
790
|
+
res.socket?.setNoDelay(true);
|
|
791
|
+
sseConnections.set(session.id, res);
|
|
792
|
+
if (session.kind === "participant") {
|
|
793
|
+
touchParticipant(session.id);
|
|
794
|
+
const prev = presenceStatus.get(session.id);
|
|
795
|
+
if (prev === "unresponsive" || prev === "offline") {
|
|
796
|
+
presenceStatus.set(session.id, "online");
|
|
797
|
+
broadcastStatusChange(session.id, session.name, "online", prev, "recovered");
|
|
798
|
+
log(`${session.name} recovered`);
|
|
799
|
+
} else if (!prev) {
|
|
800
|
+
presenceStatus.set(session.id, "online");
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
let cleaned = false;
|
|
804
|
+
const cleanup = () => {
|
|
805
|
+
if (cleaned) return;
|
|
806
|
+
cleaned = true;
|
|
807
|
+
clearInterval(heartbeat);
|
|
808
|
+
sseConnections.delete(session.id);
|
|
809
|
+
try {
|
|
810
|
+
res.end();
|
|
811
|
+
} catch {
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
const heartbeat = setInterval(() => {
|
|
815
|
+
try {
|
|
816
|
+
const ok = res.write(":heartbeat\n\n");
|
|
817
|
+
if (!ok && res.destroyed) {
|
|
818
|
+
cleanup();
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
} catch {
|
|
822
|
+
cleanup();
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
if (session.kind === "participant") touchParticipant(session.id);
|
|
826
|
+
}, 3e4);
|
|
827
|
+
const history = await room.listEvents(void 0, 50);
|
|
828
|
+
for (const event of [...history.items].reverse()) {
|
|
829
|
+
if (event.type === "MessageSent" && (event.message.recipients?.length ?? 0) > 0) {
|
|
830
|
+
const isParticipant = event.message.recipients.includes(session.id) || event.message.sender_id === session.id;
|
|
831
|
+
if (!isParticipant) continue;
|
|
832
|
+
}
|
|
833
|
+
await enrichAndSend(res, event, room);
|
|
834
|
+
}
|
|
835
|
+
const streamEvents = async () => {
|
|
836
|
+
try {
|
|
837
|
+
for await (const event of session.channel) {
|
|
838
|
+
if (event.type === "MessageSent" && (event.message.recipients?.length ?? 0) > 0) {
|
|
839
|
+
const isParticipant = event.message.recipients.includes(session.id) || event.message.sender_id === session.id;
|
|
840
|
+
if (!isParticipant) continue;
|
|
841
|
+
}
|
|
842
|
+
await enrichAndSend(res, event, room);
|
|
843
|
+
if (session.kind === "participant") touchParticipant(session.id);
|
|
844
|
+
}
|
|
845
|
+
} catch {
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
streamEvents();
|
|
849
|
+
req.on("close", cleanup);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
if (req.method === "GET") {
|
|
853
|
+
const sessionToken = extractSessionToken(req, url);
|
|
854
|
+
const session = getSession(sessionToken);
|
|
855
|
+
if (sessionToken) {
|
|
856
|
+
const getCheck = getLimiter.check(sessionToken);
|
|
857
|
+
if (!getCheck.allowed) return rateLimitError(res, getCheck.retryAfter);
|
|
858
|
+
}
|
|
859
|
+
if (session && session.kind === "participant") touchParticipant(session.id);
|
|
860
|
+
if (url.pathname === "/participants") {
|
|
861
|
+
if (!session) return jsonError(res, 401, "Invalid session token");
|
|
862
|
+
const list = participantSummaries();
|
|
863
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
864
|
+
res.end(JSON.stringify({ participants: list }));
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
if (url.pathname === "/rules") {
|
|
868
|
+
if (!session) return jsonError(res, 401, "Invalid session token");
|
|
869
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
870
|
+
res.end(JSON.stringify({ rules: currentRules }));
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
if (url.pathname.startsWith("/message/")) {
|
|
874
|
+
if (!session) return jsonError(res, 401, "Invalid session token");
|
|
875
|
+
const messageId = url.pathname.slice("/message/".length);
|
|
876
|
+
const msg = await room.getMessage(messageId);
|
|
877
|
+
if (!msg) return jsonError(res, 404, "Message not found");
|
|
878
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
879
|
+
res.end(JSON.stringify({ message: msg }));
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
if (url.pathname === "/messages") {
|
|
883
|
+
if (!session) return jsonError(res, 401, "Invalid session token");
|
|
884
|
+
const count = clampInt(url.searchParams.get("count"), 30, 1, 100);
|
|
885
|
+
const cursor = url.searchParams.get("cursor") ?? null;
|
|
886
|
+
const result = await room.listMessages(count, cursor);
|
|
887
|
+
const filtered = {
|
|
888
|
+
...result,
|
|
889
|
+
items: result.items.filter((m) => {
|
|
890
|
+
if (!m.recipients?.length) return true;
|
|
891
|
+
if (!session.id) return false;
|
|
892
|
+
return m.sender_id === session.id || m.recipients.includes(session.id);
|
|
893
|
+
})
|
|
894
|
+
};
|
|
895
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
896
|
+
res.end(JSON.stringify(filtered));
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
if (url.pathname === "/events/history") {
|
|
900
|
+
if (!session) return jsonError(res, 401, "Invalid session token");
|
|
901
|
+
const category = url.searchParams.get("category") ?? null;
|
|
902
|
+
const count = clampInt(url.searchParams.get("count"), 50, 1, 200);
|
|
903
|
+
const cursor = url.searchParams.get("cursor") ?? null;
|
|
904
|
+
const result = await room.listEvents(category, count, cursor);
|
|
905
|
+
const filteredEvents = {
|
|
906
|
+
...result,
|
|
907
|
+
items: result.items.filter((e) => {
|
|
908
|
+
if (e.type !== "MessageSent" || !e.message.recipients?.length) return true;
|
|
909
|
+
if (!session.id) return false;
|
|
910
|
+
return e.message.sender_id === session.id || e.message.recipients.includes(session.id);
|
|
911
|
+
})
|
|
912
|
+
};
|
|
913
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
914
|
+
res.end(JSON.stringify(filteredEvents));
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
if (url.pathname === "/search") {
|
|
918
|
+
if (!session) return jsonError(res, 401, "Invalid session token");
|
|
919
|
+
const query = url.searchParams.get("query") ?? "";
|
|
920
|
+
if (!query) return jsonError(res, 400, "Missing query parameter");
|
|
921
|
+
const count = clampInt(url.searchParams.get("count"), 10, 1, 50);
|
|
922
|
+
const cursor = url.searchParams.get("cursor") ?? null;
|
|
923
|
+
const result = await room.searchMessages(query, count, cursor);
|
|
924
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
925
|
+
res.end(JSON.stringify(result));
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
if (url.pathname.startsWith("/attachment/")) {
|
|
929
|
+
if (!session) return jsonError(res, 401, "Invalid session token");
|
|
930
|
+
const id = url.pathname.slice("/attachment/".length);
|
|
931
|
+
const stored = attachmentStore.get(id);
|
|
932
|
+
if (!stored) return jsonError(res, 404, "Attachment not found");
|
|
933
|
+
res.writeHead(200, {
|
|
934
|
+
"Content-Type": stored.mimeType,
|
|
935
|
+
"Content-Length": String(stored.size),
|
|
936
|
+
"Content-Disposition": `inline; filename="${stored.name}"`
|
|
937
|
+
});
|
|
938
|
+
res.end(stored.bytes);
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
if (req.method === "POST" && url.pathname === "/attachment") {
|
|
943
|
+
const attSessionToken = extractSessionToken(req, url);
|
|
944
|
+
const attSession = getSession(attSessionToken);
|
|
945
|
+
if (!attSession) return jsonError(res, 401, "Invalid session token");
|
|
946
|
+
if (!can(attSession.authority, "send_message")) return jsonError(res, 403, "Guests cannot upload attachments");
|
|
947
|
+
const chunks = [];
|
|
948
|
+
let totalBytes = 0;
|
|
949
|
+
let tooBig = false;
|
|
950
|
+
for await (const chunk of req) {
|
|
951
|
+
totalBytes += chunk.length;
|
|
952
|
+
if (totalBytes > MAX_ATTACHMENT_BYTES) {
|
|
953
|
+
tooBig = true;
|
|
954
|
+
break;
|
|
955
|
+
}
|
|
956
|
+
chunks.push(chunk);
|
|
957
|
+
}
|
|
958
|
+
if (tooBig) return jsonError(res, 413, `Attachment too large (max ${MAX_ATTACHMENT_BYTES / 1024 / 1024}MB)`);
|
|
959
|
+
const bytes = Buffer.concat(chunks);
|
|
960
|
+
const mimeType = (req.headers["content-type"] ?? "application/octet-stream").split(";")[0].trim();
|
|
961
|
+
const disposition = req.headers["content-disposition"] ?? "";
|
|
962
|
+
const filenameMatch = disposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';\r\n]+)/i);
|
|
963
|
+
const rawName = filenameMatch?.[1]?.trim() ?? req.headers["x-filename"] ?? "attachment";
|
|
964
|
+
let name;
|
|
965
|
+
try {
|
|
966
|
+
name = decodeURIComponent(rawName);
|
|
967
|
+
} catch {
|
|
968
|
+
name = rawName;
|
|
969
|
+
}
|
|
970
|
+
const id = randomUUID();
|
|
971
|
+
evictAttachmentsToFit(bytes.length);
|
|
972
|
+
attachmentStore.set(id, {
|
|
973
|
+
id,
|
|
974
|
+
name,
|
|
975
|
+
mimeType,
|
|
976
|
+
size: bytes.length,
|
|
977
|
+
roomId: room.roomId,
|
|
978
|
+
bytes,
|
|
979
|
+
uploadedAt: Date.now()
|
|
980
|
+
});
|
|
981
|
+
attachmentStoreBytes += bytes.length;
|
|
982
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
983
|
+
res.end(JSON.stringify({ id, url: attachmentUrl(id), name, mime_type: mimeType, size: bytes.length }));
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
if (req.method === "PUT" && url.pathname === "/rules") {
|
|
987
|
+
const sessionToken = extractSessionToken(req, url);
|
|
988
|
+
const session = getSession(sessionToken);
|
|
989
|
+
if (!session) return jsonError(res, 401, "Invalid session token");
|
|
990
|
+
if (!can(session.authority, "clear_history")) {
|
|
991
|
+
return jsonError(res, 403, "Only admins can update rules");
|
|
992
|
+
}
|
|
993
|
+
const body = await parseBody(req);
|
|
994
|
+
const rawRules = body.rules;
|
|
995
|
+
if (!Array.isArray(rawRules) || rawRules.some((r) => typeof r !== "string")) {
|
|
996
|
+
return jsonError(res, 400, "Body must be { rules: string[] }");
|
|
997
|
+
}
|
|
998
|
+
const cleaned = rawRules.map((r) => r.trim()).filter((r) => r.length > 0).slice(0, RULES_MAX);
|
|
999
|
+
currentRules = cleaned;
|
|
1000
|
+
saveRulesForRoom(roomName, currentRules);
|
|
1001
|
+
const adminP = sessionToken ? participants.get(sessionToken) : void 0;
|
|
1002
|
+
broadcastRulesChanged(currentRules, adminP?.name ?? "admin");
|
|
1003
|
+
log(`rules updated by ${adminP?.name ?? session.id} (${currentRules.length} rules)`);
|
|
1004
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1005
|
+
res.end(JSON.stringify({ rules: currentRules }));
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
if (req.method === "POST") {
|
|
1009
|
+
const body = await parseBody(req);
|
|
1010
|
+
if (url.pathname === "/join") {
|
|
1011
|
+
const joinCheck = joinLimiter.check(getClientIp(req));
|
|
1012
|
+
if (!joinCheck.allowed) return rateLimitError(res, joinCheck.retryAfter);
|
|
1013
|
+
const shareToken = String(body.shareToken ?? body.token ?? "");
|
|
1014
|
+
const legacyType = String(body.type ?? "");
|
|
1015
|
+
let authority;
|
|
1016
|
+
if (shareToken) {
|
|
1017
|
+
const tokenAuthority = tokens.validateShareToken(shareToken);
|
|
1018
|
+
if (!tokenAuthority) return jsonError(res, 403, "Invalid share token");
|
|
1019
|
+
authority = tokenAuthority;
|
|
1020
|
+
} else if (legacyType === "guest") {
|
|
1021
|
+
authority = "guest";
|
|
1022
|
+
} else {
|
|
1023
|
+
authority = "member";
|
|
1024
|
+
}
|
|
1025
|
+
const participantType = String(body.type ?? "human");
|
|
1026
|
+
const name = String(body.name ?? randomName());
|
|
1027
|
+
if (authority === "guest") {
|
|
1028
|
+
const id2 = `obs_${randomUUID().slice(0, 8)}`;
|
|
1029
|
+
const channel2 = room.observe();
|
|
1030
|
+
const sessionToken3 = tokens.createSessionToken(id2, "guest");
|
|
1031
|
+
guests.set(sessionToken3, { id: id2, authority: "guest", channel: channel2, sessionToken: sessionToken3 });
|
|
1032
|
+
idToSession.set(id2, sessionToken3);
|
|
1033
|
+
const participantList2 = participantSummaries();
|
|
1034
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1035
|
+
res.end(JSON.stringify({
|
|
1036
|
+
sessionToken: sessionToken3,
|
|
1037
|
+
participantId: id2,
|
|
1038
|
+
roomName,
|
|
1039
|
+
roomId: room.roomId,
|
|
1040
|
+
participants: participantList2,
|
|
1041
|
+
authority: "guest"
|
|
1042
|
+
}));
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
{
|
|
1046
|
+
const entries = [...participants.entries()];
|
|
1047
|
+
const evicting = new Set(
|
|
1048
|
+
seatsToEvict(entries.map(([, p]) => p), { name, type: participantType }).map((s) => s.id)
|
|
1049
|
+
);
|
|
1050
|
+
for (const [tok, existing] of entries) {
|
|
1051
|
+
if (!evicting.has(existing.id)) continue;
|
|
1052
|
+
log(`agent "${name}" rejoined \u2014 replacing its previous seat (${existing.id})`);
|
|
1053
|
+
try {
|
|
1054
|
+
await existing.channel.disconnect(true);
|
|
1055
|
+
} catch {
|
|
1056
|
+
}
|
|
1057
|
+
participants.delete(tok);
|
|
1058
|
+
idToSession.delete(existing.id);
|
|
1059
|
+
tokens.revokeSessionToken(tok);
|
|
1060
|
+
sseConnections.get(existing.id)?.end();
|
|
1061
|
+
sseConnections.delete(existing.id);
|
|
1062
|
+
presenceStatus.delete(existing.id);
|
|
1063
|
+
lastSeenAt.delete(existing.id);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
const id = `${participantType}_${randomUUID().slice(0, 8)}`;
|
|
1067
|
+
const channel = await room.connect(id, name, { type: participantType, authority });
|
|
1068
|
+
const sessionToken2 = tokens.createSessionToken(id, authority);
|
|
1069
|
+
participants.set(sessionToken2, { id, name, type: participantType, authority, channel, sessionToken: sessionToken2 });
|
|
1070
|
+
idToSession.set(id, sessionToken2);
|
|
1071
|
+
touchParticipant(id);
|
|
1072
|
+
presenceStatus.set(id, "online");
|
|
1073
|
+
const participantList = room.listParticipants().map((p) => ({
|
|
1074
|
+
id: p.id,
|
|
1075
|
+
name: p.name,
|
|
1076
|
+
type: p.type,
|
|
1077
|
+
authority: p.authority ?? "member"
|
|
1078
|
+
}));
|
|
1079
|
+
log(`${name} joined (${authority})`);
|
|
1080
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1081
|
+
res.end(JSON.stringify({
|
|
1082
|
+
sessionToken: sessionToken2,
|
|
1083
|
+
participantId: id,
|
|
1084
|
+
roomName,
|
|
1085
|
+
roomId: room.roomId,
|
|
1086
|
+
participants: participantList,
|
|
1087
|
+
authority
|
|
1088
|
+
}));
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
const sessionToken = extractSessionToken(req, url, body) ?? "";
|
|
1092
|
+
const session = getSession(sessionToken);
|
|
1093
|
+
if (session && session.kind === "participant") touchParticipant(session.id);
|
|
1094
|
+
if (url.pathname === "/message") {
|
|
1095
|
+
if (!session) return jsonError(res, 401, "Invalid session token");
|
|
1096
|
+
if (sessionToken) {
|
|
1097
|
+
const msgCheck = messageLimiter.check(sessionToken);
|
|
1098
|
+
if (!msgCheck.allowed) return rateLimitError(res, msgCheck.retryAfter);
|
|
1099
|
+
}
|
|
1100
|
+
if (!can(session.authority, "send_message")) return jsonError(res, 403, "Guests cannot send messages");
|
|
1101
|
+
const content = String(body.content ?? "");
|
|
1102
|
+
const replyTo = body.replyTo ? String(body.replyTo) : void 0;
|
|
1103
|
+
if (!content) return jsonError(res, 400, "Empty message");
|
|
1104
|
+
if (content.length > 5e4) return jsonError(res, 400, "Message too long (max 50000 chars)");
|
|
1105
|
+
const p = participants.get(sessionToken);
|
|
1106
|
+
if (!p) return jsonError(res, 403, "Not a participant");
|
|
1107
|
+
let rawAttachments;
|
|
1108
|
+
if (Array.isArray(body.attachments) && body.attachments.length > 0) {
|
|
1109
|
+
const parsed = AttachmentSchema.array().safeParse(body.attachments);
|
|
1110
|
+
if (!parsed.success) return jsonError(res, 400, `Invalid attachments: ${parsed.error.issues[0]?.message ?? "bad shape"}`);
|
|
1111
|
+
rawAttachments = parsed.data;
|
|
1112
|
+
}
|
|
1113
|
+
let recipientIds;
|
|
1114
|
+
if (Array.isArray(body.recipients) && body.recipients.length > 0) {
|
|
1115
|
+
recipientIds = [];
|
|
1116
|
+
const allParticipants = room.listParticipants();
|
|
1117
|
+
for (const name of body.recipients) {
|
|
1118
|
+
const matches = allParticipants.filter((pp) => pp.name === name);
|
|
1119
|
+
if (matches.length === 0) return jsonError(res, 400, `Unknown participant "${name}"`);
|
|
1120
|
+
if (matches.length > 1) return jsonError(res, 400, `Ambiguous participant name "${name}"`);
|
|
1121
|
+
recipientIds.push(matches[0].id);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
const msg = await p.channel.sendMessage(content, replyTo, void 0, rawAttachments, recipientIds);
|
|
1125
|
+
if (recipientIds && recipientIds.length > 0) {
|
|
1126
|
+
const recipientNames = recipientIds.map((id) => room.listParticipants().find((pp) => pp.id === id)?.name ?? id);
|
|
1127
|
+
const whisperNotified = createEvent({
|
|
1128
|
+
type: "WhisperNotified",
|
|
1129
|
+
category: "ACTIVITY",
|
|
1130
|
+
room_id: room.roomId,
|
|
1131
|
+
participant_id: p.id,
|
|
1132
|
+
sender_id: p.id,
|
|
1133
|
+
sender_name: p.name,
|
|
1134
|
+
recipient_ids: recipientIds,
|
|
1135
|
+
recipient_names: recipientNames
|
|
1136
|
+
});
|
|
1137
|
+
for (const [participantId, sseRes] of sseConnections) {
|
|
1138
|
+
if (!recipientIds.includes(participantId) && participantId !== p.id) {
|
|
1139
|
+
sseRes.write(`data: ${JSON.stringify(whisperNotified)}
|
|
1140
|
+
|
|
1141
|
+
`);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
jsonOk(res, { messageId: msg.id });
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
if (url.pathname === "/event") {
|
|
1149
|
+
if (!session) return jsonError(res, 401, "Invalid session token");
|
|
1150
|
+
if (!can(session.authority, "send_message")) return jsonError(res, 403, "Guests cannot emit events");
|
|
1151
|
+
const event = body.event;
|
|
1152
|
+
if (!event) return jsonError(res, 400, "Missing event");
|
|
1153
|
+
const p = participants.get(sessionToken);
|
|
1154
|
+
if (!p) return jsonError(res, 403, "Not a participant");
|
|
1155
|
+
await p.channel.emit(event);
|
|
1156
|
+
jsonOk(res);
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
if (url.pathname === "/metrics") {
|
|
1160
|
+
if (!session) return jsonError(res, 401, "Invalid session token");
|
|
1161
|
+
const p = participants.get(sessionToken);
|
|
1162
|
+
if (!p) return jsonError(res, 403, "Not a participant");
|
|
1163
|
+
const detail = {
|
|
1164
|
+
input_tokens: Number(body.input_tokens) || 0,
|
|
1165
|
+
output_tokens: Number(body.output_tokens) || 0,
|
|
1166
|
+
cache_read_tokens: Number(body.cache_read_tokens) || 0,
|
|
1167
|
+
cache_create_tokens: Number(body.cache_create_tokens) || 0,
|
|
1168
|
+
last_ctx_tokens: Number(body.last_ctx_tokens) || 0,
|
|
1169
|
+
model: typeof body.model === "string" ? body.model : "",
|
|
1170
|
+
participant_name: p.name
|
|
1171
|
+
};
|
|
1172
|
+
await p.channel.emit(createEvent({
|
|
1173
|
+
type: "Activity",
|
|
1174
|
+
category: "ACTIVITY",
|
|
1175
|
+
room_id: room.roomId,
|
|
1176
|
+
participant_id: p.id,
|
|
1177
|
+
action: "metrics",
|
|
1178
|
+
detail
|
|
1179
|
+
}));
|
|
1180
|
+
jsonOk(res);
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
if (url.pathname === "/activity") {
|
|
1184
|
+
if (!session) return jsonError(res, 401, "Invalid session token");
|
|
1185
|
+
const p = participants.get(sessionToken);
|
|
1186
|
+
if (!p) return jsonError(res, 403, "Not a participant");
|
|
1187
|
+
const label = body.label === null || body.label === void 0 ? null : String(body.label).slice(0, 120);
|
|
1188
|
+
const rawState = body.state === void 0 ? void 0 : String(body.state);
|
|
1189
|
+
const state = rawState === "idle" || rawState === "working" || rawState === "blocked" ? rawState : void 0;
|
|
1190
|
+
if (state) agentStates.set(p.id, { state, at: Date.now() });
|
|
1191
|
+
await p.channel.emit(createEvent({
|
|
1192
|
+
type: "Activity",
|
|
1193
|
+
category: "ACTIVITY",
|
|
1194
|
+
room_id: room.roomId,
|
|
1195
|
+
participant_id: p.id,
|
|
1196
|
+
action: "status_label",
|
|
1197
|
+
detail: { label, participant_name: p.name, ...state ? { state } : {} }
|
|
1198
|
+
}));
|
|
1199
|
+
jsonOk(res);
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
if (url.pathname === "/set-mode") {
|
|
1203
|
+
if (!session) return jsonError(res, 401, "Invalid session token");
|
|
1204
|
+
const targetId = body.participantId ? String(body.participantId) : session.id;
|
|
1205
|
+
const mode = String(body.mode ?? "");
|
|
1206
|
+
if (!mode) return jsonError(res, 400, "Missing mode");
|
|
1207
|
+
if (targetId !== session.id && !can(session.authority, "set_mode_for")) {
|
|
1208
|
+
return jsonError(res, 403, "Only admins and product owners can change other participants' modes");
|
|
1209
|
+
}
|
|
1210
|
+
const p = participants.get(sessionToken);
|
|
1211
|
+
if (!p) return jsonError(res, 403, "Not a participant");
|
|
1212
|
+
await p.channel.emit(createEvent({
|
|
1213
|
+
type: "Activity",
|
|
1214
|
+
category: "ACTIVITY",
|
|
1215
|
+
room_id: room.roomId,
|
|
1216
|
+
participant_id: targetId,
|
|
1217
|
+
action: "mode_changed",
|
|
1218
|
+
detail: { mode }
|
|
1219
|
+
}));
|
|
1220
|
+
jsonOk(res);
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
if (url.pathname === "/set-authority") {
|
|
1224
|
+
if (!session) return jsonError(res, 401, "Invalid session token");
|
|
1225
|
+
if (!can(session.authority, "mute")) {
|
|
1226
|
+
return jsonError(res, 403, "Only admins and product owners can change authority");
|
|
1227
|
+
}
|
|
1228
|
+
const targetId = String(body.participantId ?? "");
|
|
1229
|
+
const newAuthority = String(body.authority ?? "");
|
|
1230
|
+
if (!targetId) return jsonError(res, 400, "Missing participantId");
|
|
1231
|
+
if (!["admin", "product_owner", "member", "guest"].includes(newAuthority)) {
|
|
1232
|
+
return jsonError(res, 400, "Invalid authority. Must be admin, product_owner, member, or guest.");
|
|
1233
|
+
}
|
|
1234
|
+
if (targetId === session.id) return jsonError(res, 400, "Cannot change own authority");
|
|
1235
|
+
if (session.authority === "product_owner") {
|
|
1236
|
+
if (!["member", "guest"].includes(newAuthority)) {
|
|
1237
|
+
return jsonError(res, 403, "Product owners can only set member or guest authority");
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
const targetSession = idToSession.get(targetId);
|
|
1241
|
+
if (!targetSession) return jsonError(res, 404, "Participant not found");
|
|
1242
|
+
const target = participants.get(targetSession);
|
|
1243
|
+
if (!target) return jsonError(res, 404, "Participant not found");
|
|
1244
|
+
if (session.authority === "product_owner") {
|
|
1245
|
+
const currentAuthority = target.authority ?? "member";
|
|
1246
|
+
if (currentAuthority === "admin" || currentAuthority === "product_owner") {
|
|
1247
|
+
return jsonError(res, 403, "Cannot change authority of admins or product owners");
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
target.authority = newAuthority;
|
|
1251
|
+
tokens.updateSessionAuthority(targetSession, newAuthority);
|
|
1252
|
+
room.setParticipantAuthority(targetId, newAuthority);
|
|
1253
|
+
const adminP = participants.get(sessionToken);
|
|
1254
|
+
const targetParticipant = room.listParticipants().find((p) => p.id === targetId);
|
|
1255
|
+
if (adminP && targetParticipant) {
|
|
1256
|
+
await adminP.channel.emit(createEvent({
|
|
1257
|
+
type: "AuthorityChanged",
|
|
1258
|
+
category: "PRESENCE",
|
|
1259
|
+
room_id: room.roomId,
|
|
1260
|
+
participant_id: targetId,
|
|
1261
|
+
participant: targetParticipant,
|
|
1262
|
+
new_authority: newAuthority,
|
|
1263
|
+
changed_by: adminP.name
|
|
1264
|
+
}));
|
|
1265
|
+
}
|
|
1266
|
+
log(`${target.name} authority \u2192 ${newAuthority}`);
|
|
1267
|
+
jsonOk(res);
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
if (url.pathname === "/ping") {
|
|
1271
|
+
if (!session) return jsonError(res, 401, "Invalid session token");
|
|
1272
|
+
if (!can(session.authority, "ping")) return jsonError(res, 403, "Guests cannot ping");
|
|
1273
|
+
const targetId = String(body.participantId ?? "");
|
|
1274
|
+
if (!targetId) return jsonError(res, 400, "Missing participantId");
|
|
1275
|
+
const p = participants.get(sessionToken);
|
|
1276
|
+
if (!p) return jsonError(res, 403, "Not a participant");
|
|
1277
|
+
await p.channel.ping(targetId);
|
|
1278
|
+
jsonOk(res);
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
if (url.pathname === "/kick") {
|
|
1282
|
+
if (!session) return jsonError(res, 401, "Invalid session token");
|
|
1283
|
+
if (!can(session.authority, "kick")) return jsonError(res, 403, "Only admins can kick");
|
|
1284
|
+
const targetId = String(body.participantId ?? "");
|
|
1285
|
+
if (!targetId) return jsonError(res, 400, "Missing participantId");
|
|
1286
|
+
const targetSession = idToSession.get(targetId);
|
|
1287
|
+
if (targetSession) {
|
|
1288
|
+
const targetParticipant = participants.get(targetSession);
|
|
1289
|
+
const targetGuest = guests.get(targetSession);
|
|
1290
|
+
const target = targetParticipant ?? targetGuest;
|
|
1291
|
+
if (target) {
|
|
1292
|
+
const adminP = participants.get(sessionToken);
|
|
1293
|
+
const roomParticipant = room.listParticipants().find((p) => p.id === targetId);
|
|
1294
|
+
if (adminP && roomParticipant) {
|
|
1295
|
+
await adminP.channel.emit(createEvent({
|
|
1296
|
+
type: "ParticipantKicked",
|
|
1297
|
+
category: "PRESENCE",
|
|
1298
|
+
room_id: room.roomId,
|
|
1299
|
+
participant_id: targetId,
|
|
1300
|
+
participant: roomParticipant,
|
|
1301
|
+
kicked_by: adminP.name
|
|
1302
|
+
}));
|
|
1303
|
+
}
|
|
1304
|
+
if (targetParticipant) {
|
|
1305
|
+
const prevStatus = presenceStatus.get(targetId) ?? "online";
|
|
1306
|
+
if (prevStatus !== "offline") {
|
|
1307
|
+
broadcastStatusChange(targetId, targetParticipant.name, "offline", prevStatus, "kicked");
|
|
1308
|
+
}
|
|
1309
|
+
lastSeenAt.delete(targetId);
|
|
1310
|
+
presenceStatus.delete(targetId);
|
|
1311
|
+
}
|
|
1312
|
+
await target.channel.disconnect(true);
|
|
1313
|
+
participants.delete(targetSession);
|
|
1314
|
+
guests.delete(targetSession);
|
|
1315
|
+
idToSession.delete(targetId);
|
|
1316
|
+
tokens.revokeSessionToken(targetSession);
|
|
1317
|
+
if (targetParticipant?.type === "agent") {
|
|
1318
|
+
const outcome = terminateLocalAgent(targetParticipant.name, { killTmux: tmuxKillSession });
|
|
1319
|
+
if (outcome.stopped) {
|
|
1320
|
+
log(`kicked ${targetParticipant.name}: stopped local ${outcome.runtime} runtime (pid ${outcome.pid})`);
|
|
1321
|
+
} else {
|
|
1322
|
+
log(`kicked ${targetParticipant.name}: disconnected only (${outcome.reason})`);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
const sse = sseConnections.get(targetId);
|
|
1326
|
+
if (sse) {
|
|
1327
|
+
sse.end();
|
|
1328
|
+
sseConnections.delete(targetId);
|
|
1329
|
+
}
|
|
1330
|
+
log(`kicked ${targetId}`);
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
jsonOk(res);
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
if (url.pathname === "/clear") {
|
|
1337
|
+
if (!session) return jsonError(res, 401, "Invalid session token");
|
|
1338
|
+
if (!can(session.authority, "clear_history")) return jsonError(res, 403, "Only admins can clear");
|
|
1339
|
+
await storage.clearRoom(room.roomId);
|
|
1340
|
+
for (const [id, att] of attachmentStore) {
|
|
1341
|
+
if (att.roomId === room.roomId) {
|
|
1342
|
+
attachmentStore.delete(id);
|
|
1343
|
+
attachmentStoreBytes -= att.size;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
const adminP = participants.get(sessionToken);
|
|
1347
|
+
const clearEvent = createEvent({
|
|
1348
|
+
type: "RoomCleared",
|
|
1349
|
+
category: "ACTIVITY",
|
|
1350
|
+
room_id: room.roomId,
|
|
1351
|
+
participant_id: session.id,
|
|
1352
|
+
cleared_by: adminP?.name ?? "admin"
|
|
1353
|
+
});
|
|
1354
|
+
broadcastToAll(clearEvent);
|
|
1355
|
+
log(`room cleared by ${adminP?.name ?? session.id}`);
|
|
1356
|
+
jsonOk(res);
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
if (url.pathname === "/share") {
|
|
1360
|
+
if (!session) return jsonError(res, 401, "Invalid session token");
|
|
1361
|
+
if (sessionToken) {
|
|
1362
|
+
const shareCheck = shareLimiter.check(sessionToken);
|
|
1363
|
+
if (!shareCheck.allowed) return rateLimitError(res, shareCheck.retryAfter);
|
|
1364
|
+
}
|
|
1365
|
+
if (!can(session.authority, "create_share_link")) return jsonError(res, 403, "Guests cannot create share links");
|
|
1366
|
+
const targetAuthority = body.authority ?? void 0;
|
|
1367
|
+
const links = {};
|
|
1368
|
+
if (targetAuthority) {
|
|
1369
|
+
const token = tokens.generateShareToken(session.authority, targetAuthority);
|
|
1370
|
+
if (!token) return jsonError(res, 403, `Cannot generate ${targetAuthority} link`);
|
|
1371
|
+
links[targetAuthority] = buildShareUrl(publicUrl, token);
|
|
1372
|
+
} else {
|
|
1373
|
+
const tiers = ["admin", "product_owner", "member", "guest"];
|
|
1374
|
+
for (const tier of tiers) {
|
|
1375
|
+
const token = tokens.generateShareToken(session.authority, tier);
|
|
1376
|
+
if (token) links[tier] = buildShareUrl(publicUrl, token);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1380
|
+
res.end(JSON.stringify({ links }));
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
if (url.pathname === "/tunnel") {
|
|
1384
|
+
if (!session || !can(session.authority, "start_tunnel")) return jsonError(res, 403, "Admin only");
|
|
1385
|
+
if (tunnelProcess) {
|
|
1386
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1387
|
+
res.end(JSON.stringify({ url: publicUrl, alreadyRunning: true }));
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
try {
|
|
1391
|
+
tunnelProcess = await startTunnel(port);
|
|
1392
|
+
if (!tunnelProcess) {
|
|
1393
|
+
return jsonError(res, 500, "Could not start cloudflared \u2014 is it installed?");
|
|
1394
|
+
}
|
|
1395
|
+
const tunnelUrl = await waitForTunnelUrl(tunnelProcess);
|
|
1396
|
+
if (!tunnelUrl) {
|
|
1397
|
+
tunnelProcess.kill();
|
|
1398
|
+
tunnelProcess = null;
|
|
1399
|
+
return jsonError(res, 504, "Tunnel started but timed out waiting for URL");
|
|
1400
|
+
}
|
|
1401
|
+
publicUrl = tunnelUrl;
|
|
1402
|
+
addCorsOrigin(tunnelUrl);
|
|
1403
|
+
saveRoomSession({ roomName, bind: boundAddress, serverUrl, publicUrl, adminToken, memberToken, pid: process.pid });
|
|
1404
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1405
|
+
res.end(JSON.stringify({ url: tunnelUrl, alreadyRunning: false }));
|
|
1406
|
+
} catch {
|
|
1407
|
+
return jsonError(res, 500, "Failed to start tunnel");
|
|
1408
|
+
}
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
if (url.pathname === "/rotate-token") {
|
|
1412
|
+
if (!session || !sessionToken) return jsonError(res, 401, "Invalid session token");
|
|
1413
|
+
const result = tokens.rotateSessionToken(sessionToken);
|
|
1414
|
+
if (!result) return jsonError(res, 401, "Token expired or invalid");
|
|
1415
|
+
const p = participants.get(sessionToken);
|
|
1416
|
+
if (p) {
|
|
1417
|
+
participants.delete(sessionToken);
|
|
1418
|
+
p.sessionToken = result.newToken;
|
|
1419
|
+
participants.set(result.newToken, p);
|
|
1420
|
+
idToSession.set(p.id, result.newToken);
|
|
1421
|
+
}
|
|
1422
|
+
const g = guests.get(sessionToken);
|
|
1423
|
+
if (g) {
|
|
1424
|
+
guests.delete(sessionToken);
|
|
1425
|
+
g.sessionToken = result.newToken;
|
|
1426
|
+
guests.set(result.newToken, g);
|
|
1427
|
+
idToSession.set(g.id, result.newToken);
|
|
1428
|
+
}
|
|
1429
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1430
|
+
res.end(JSON.stringify({ sessionToken: result.newToken }));
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1433
|
+
if (url.pathname === "/disconnect") {
|
|
1434
|
+
const targetToken = extractSessionToken(req, url, body) ?? "";
|
|
1435
|
+
if (targetToken) {
|
|
1436
|
+
const p = participants.get(targetToken);
|
|
1437
|
+
if (p) {
|
|
1438
|
+
const prevStatus = presenceStatus.get(p.id) ?? "online";
|
|
1439
|
+
if (prevStatus !== "offline") {
|
|
1440
|
+
broadcastStatusChange(p.id, p.name, "offline", prevStatus, "left");
|
|
1441
|
+
}
|
|
1442
|
+
await p.channel.disconnect();
|
|
1443
|
+
participants.delete(targetToken);
|
|
1444
|
+
idToSession.delete(p.id);
|
|
1445
|
+
tokens.revokeSessionToken(targetToken);
|
|
1446
|
+
lastSeenAt.delete(p.id);
|
|
1447
|
+
presenceStatus.delete(p.id);
|
|
1448
|
+
const sse = sseConnections.get(p.id);
|
|
1449
|
+
if (sse) {
|
|
1450
|
+
sse.end();
|
|
1451
|
+
sseConnections.delete(p.id);
|
|
1452
|
+
}
|
|
1453
|
+
log(`${p.name} disconnected`);
|
|
1454
|
+
}
|
|
1455
|
+
const g = guests.get(targetToken);
|
|
1456
|
+
if (g) {
|
|
1457
|
+
await g.channel.disconnect();
|
|
1458
|
+
guests.delete(targetToken);
|
|
1459
|
+
idToSession.delete(g.id);
|
|
1460
|
+
tokens.revokeSessionToken(targetToken);
|
|
1461
|
+
const sse = sseConnections.get(g.id);
|
|
1462
|
+
if (sse) {
|
|
1463
|
+
sse.end();
|
|
1464
|
+
sseConnections.delete(g.id);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
jsonOk(res);
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
res.writeHead(404).end("Not found");
|
|
1473
|
+
});
|
|
1474
|
+
httpServer.on("error", (err) => {
|
|
1475
|
+
if (err.code === "EADDRINUSE") {
|
|
1476
|
+
console.error(`
|
|
1477
|
+
Port ${port} is already in use. Another apiary instance may be running.`);
|
|
1478
|
+
console.error(` Kill it: lsof -ti :${port} | xargs kill`);
|
|
1479
|
+
console.error(` Or use: apiary --port ${port + 1}
|
|
1480
|
+
`);
|
|
1481
|
+
process.exit(1);
|
|
1482
|
+
}
|
|
1483
|
+
throw err;
|
|
1484
|
+
});
|
|
1485
|
+
await new Promise((resolve2) => {
|
|
1486
|
+
httpServer.listen(port, boundAddress, () => resolve2());
|
|
1487
|
+
});
|
|
1488
|
+
const pruneInterval = setInterval(() => tokens.pruneExpired(), 5 * 60 * 1e3);
|
|
1489
|
+
pruneInterval.unref();
|
|
1490
|
+
const OFFLINE_GC_MS = 24 * 60 * 60 * 1e3;
|
|
1491
|
+
const presenceInterval = setInterval(() => {
|
|
1492
|
+
const now = Date.now();
|
|
1493
|
+
for (const [sessionToken, p] of participants) {
|
|
1494
|
+
const last = lastSeenAt.get(p.id) ?? now;
|
|
1495
|
+
const elapsed = now - last;
|
|
1496
|
+
const current = presenceStatus.get(p.id) ?? "online";
|
|
1497
|
+
if (current === "online" && elapsed > UNRESPONSIVE_AFTER_MS) {
|
|
1498
|
+
presenceStatus.set(p.id, "unresponsive");
|
|
1499
|
+
broadcastStatusChange(p.id, p.name, "unresponsive", "online", "ping_timeout");
|
|
1500
|
+
log(`${p.name} unresponsive`);
|
|
1501
|
+
} else if (current === "unresponsive" && elapsed > OFFLINE_AFTER_MS) {
|
|
1502
|
+
presenceStatus.set(p.id, "offline");
|
|
1503
|
+
broadcastStatusChange(p.id, p.name, "offline", "unresponsive", "ping_timeout");
|
|
1504
|
+
log(`${p.name} offline (timeout)`);
|
|
1505
|
+
} else if (current === "offline" && elapsed > OFFLINE_GC_MS) {
|
|
1506
|
+
participants.delete(sessionToken);
|
|
1507
|
+
idToSession.delete(p.id);
|
|
1508
|
+
tokens.revokeSessionToken(sessionToken);
|
|
1509
|
+
const sse = sseConnections.get(p.id);
|
|
1510
|
+
if (sse) {
|
|
1511
|
+
try {
|
|
1512
|
+
sse.end();
|
|
1513
|
+
} catch {
|
|
1514
|
+
}
|
|
1515
|
+
sseConnections.delete(p.id);
|
|
1516
|
+
}
|
|
1517
|
+
lastSeenAt.delete(p.id);
|
|
1518
|
+
presenceStatus.delete(p.id);
|
|
1519
|
+
log(`${p.name} purged after ${Math.round(elapsed / 6e4)}min offline`);
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
}, PRESENCE_CHECK_INTERVAL_MS);
|
|
1523
|
+
presenceInterval.unref();
|
|
1524
|
+
if (options.share) {
|
|
1525
|
+
tunnelProcess = await startTunnel(port);
|
|
1526
|
+
if (tunnelProcess) {
|
|
1527
|
+
const tunnelUrl = await waitForTunnelUrl(tunnelProcess);
|
|
1528
|
+
if (tunnelUrl) {
|
|
1529
|
+
publicUrl = tunnelUrl;
|
|
1530
|
+
addCorsOrigin(tunnelUrl);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
const adminToken = tokens.generateShareToken("admin", "admin", { neverExpires: true });
|
|
1535
|
+
const memberToken = tokens.generateShareToken("admin", "member", { neverExpires: true });
|
|
1536
|
+
saveRoomSession({ roomName, bind: boundAddress, serverUrl, publicUrl, adminToken, memberToken, pid: process.pid });
|
|
1537
|
+
function obfuscate(token) {
|
|
1538
|
+
if (token.length <= 8) return "****";
|
|
1539
|
+
return token.slice(0, 4) + "..." + token.slice(-4);
|
|
1540
|
+
}
|
|
1541
|
+
if (options.headless) {
|
|
1542
|
+
process.stdout.write(JSON.stringify({ serverUrl, publicUrl, roomName, adminToken, memberToken, savePath }) + "\n");
|
|
1543
|
+
process.stderr.write("Warning: raw tokens in stdout \u2014 do not share this output.\n");
|
|
1544
|
+
} else if (!options.quiet) {
|
|
1545
|
+
let version = process.env.npm_package_version ?? "";
|
|
1546
|
+
if (!version) {
|
|
1547
|
+
try {
|
|
1548
|
+
const require2 = createRequire(import.meta.url);
|
|
1549
|
+
const pkg = require2("../../package.json");
|
|
1550
|
+
version = pkg.version ?? "unknown";
|
|
1551
|
+
} catch {
|
|
1552
|
+
version = "unknown";
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
const adminUrlObfuscated = buildShareUrl(publicUrl, obfuscate(adminToken));
|
|
1556
|
+
const joinUrlObfuscated = buildShareUrl(publicUrl, obfuscate(memberToken));
|
|
1557
|
+
process.stdout.write(`\x1B]0;${roomEmoji(roomName)} apiary \xB7 ${roomName}\x07`);
|
|
1558
|
+
const Y = "\x1B[33m";
|
|
1559
|
+
const C = "\x1B[36m";
|
|
1560
|
+
const D = "\x1B[2m";
|
|
1561
|
+
const B = "\x1B[1m";
|
|
1562
|
+
const R = "\x1B[0m";
|
|
1563
|
+
console.log(`
|
|
1564
|
+
${Y}${B}apiary${R} ${D}v${version}${R}
|
|
1565
|
+
|
|
1566
|
+
${D}Room:${R} ${Y}${roomName}${R}
|
|
1567
|
+
${D}Server:${R} ${C}${serverUrl}${R}${publicUrl !== serverUrl ? `
|
|
1568
|
+
${D}Tunnel:${R} ${C}${publicUrl}${R}` : ""}
|
|
1569
|
+
${D}Saving:${R} ${D}${savePath}${R}
|
|
1570
|
+
|
|
1571
|
+
${D}Share:${R} ${Y}${joinUrlObfuscated}${R}
|
|
1572
|
+
${D}Admin:${R} ${Y}${adminUrlObfuscated}${R}
|
|
1573
|
+
|
|
1574
|
+
${D}Join:${R} ${C}apiary join${R} ${D}<url>${R}
|
|
1575
|
+
${D}Agent:${R} ${C}apiary claude${R} ${D}<name>${R} ${D}\u2192 tell it the URL, it joins${R}
|
|
1576
|
+
`);
|
|
1577
|
+
}
|
|
1578
|
+
const shutdown = async () => {
|
|
1579
|
+
log("shutting down...");
|
|
1580
|
+
clearInterval(presenceInterval);
|
|
1581
|
+
unwatchFile(rulesFilePath, onRulesFileChange);
|
|
1582
|
+
clearRoomSession(roomName);
|
|
1583
|
+
if (!options.save && !options.load) {
|
|
1584
|
+
try {
|
|
1585
|
+
rmSync2(savePath);
|
|
1586
|
+
} catch {
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
if (tunnelProcess) {
|
|
1590
|
+
tunnelProcess.kill();
|
|
1591
|
+
tunnelProcess = null;
|
|
1592
|
+
}
|
|
1593
|
+
for (const [id, sse] of sseConnections) {
|
|
1594
|
+
sse.end();
|
|
1595
|
+
sseConnections.delete(id);
|
|
1596
|
+
}
|
|
1597
|
+
for (const p of participants.values()) {
|
|
1598
|
+
await p.channel.disconnect().catch(() => {
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
for (const g of guests.values()) {
|
|
1602
|
+
await g.channel.disconnect().catch(() => {
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1605
|
+
await new Promise((resolve2, reject) => {
|
|
1606
|
+
httpServer.close((err) => err ? reject(err) : resolve2());
|
|
1607
|
+
});
|
|
1608
|
+
process.exit(0);
|
|
1609
|
+
};
|
|
1610
|
+
process.on("SIGINT", shutdown);
|
|
1611
|
+
process.on("SIGTERM", shutdown);
|
|
1612
|
+
const reapTunnel = () => {
|
|
1613
|
+
if (tunnelProcess && !tunnelProcess.killed) {
|
|
1614
|
+
try {
|
|
1615
|
+
tunnelProcess.kill("SIGTERM");
|
|
1616
|
+
} catch {
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
};
|
|
1620
|
+
process.on("exit", reapTunnel);
|
|
1621
|
+
process.on("uncaughtException", (err) => {
|
|
1622
|
+
log(`fatal: uncaughtException \u2014 ${err.message}`);
|
|
1623
|
+
reapTunnel();
|
|
1624
|
+
process.exit(1);
|
|
1625
|
+
});
|
|
1626
|
+
process.on("unhandledRejection", (reason) => {
|
|
1627
|
+
log(`fatal: unhandledRejection \u2014 ${reason instanceof Error ? reason.message : String(reason)}`);
|
|
1628
|
+
reapTunnel();
|
|
1629
|
+
process.exit(1);
|
|
1630
|
+
});
|
|
1631
|
+
return { serverUrl, publicUrl, roomName, adminToken, memberToken };
|
|
1632
|
+
}
|
|
1633
|
+
function logServer(message) {
|
|
1634
|
+
console.log(` [${formatTimestamp(/* @__PURE__ */ new Date())}] ${message}`);
|
|
1635
|
+
}
|
|
1636
|
+
function cloudflaredAvailable() {
|
|
1637
|
+
try {
|
|
1638
|
+
execFileSync2("which", ["cloudflared"], { stdio: "ignore" });
|
|
1639
|
+
return true;
|
|
1640
|
+
} catch {
|
|
1641
|
+
return false;
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
async function startTunnel(port) {
|
|
1645
|
+
if (!cloudflaredAvailable()) {
|
|
1646
|
+
console.error(" --share requires cloudflared. Install: brew install cloudflared");
|
|
1647
|
+
return null;
|
|
1648
|
+
}
|
|
1649
|
+
const child = spawn2("cloudflared", ["tunnel", "--url", `http://localhost:${port}`], {
|
|
1650
|
+
stdio: ["ignore", "ignore", "pipe"]
|
|
1651
|
+
});
|
|
1652
|
+
child.on("error", () => {
|
|
1653
|
+
});
|
|
1654
|
+
return child;
|
|
1655
|
+
}
|
|
1656
|
+
function waitForTunnelUrl(child, timeoutMs = 15e3) {
|
|
1657
|
+
return new Promise((resolve2) => {
|
|
1658
|
+
let resolved = false;
|
|
1659
|
+
let buffer = "";
|
|
1660
|
+
const timer = setTimeout(() => {
|
|
1661
|
+
if (!resolved) {
|
|
1662
|
+
resolved = true;
|
|
1663
|
+
resolve2(null);
|
|
1664
|
+
}
|
|
1665
|
+
}, timeoutMs);
|
|
1666
|
+
child.stderr?.on("data", (chunk) => {
|
|
1667
|
+
buffer += chunk.toString();
|
|
1668
|
+
const match = buffer.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
1669
|
+
if (match && !resolved) {
|
|
1670
|
+
resolved = true;
|
|
1671
|
+
clearTimeout(timer);
|
|
1672
|
+
resolve2(match[0]);
|
|
1673
|
+
}
|
|
1674
|
+
});
|
|
1675
|
+
child.on("exit", () => {
|
|
1676
|
+
if (!resolved) {
|
|
1677
|
+
resolved = true;
|
|
1678
|
+
clearTimeout(timer);
|
|
1679
|
+
resolve2(null);
|
|
1680
|
+
}
|
|
1681
|
+
});
|
|
1682
|
+
});
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
export {
|
|
1686
|
+
tailnetAddress,
|
|
1687
|
+
lanAddress,
|
|
1688
|
+
ALL_INTERFACES,
|
|
1689
|
+
LOOPBACK,
|
|
1690
|
+
resolveBindAddress,
|
|
1691
|
+
roomRulesPath,
|
|
1692
|
+
loadConfig,
|
|
1693
|
+
saveConfig,
|
|
1694
|
+
agentEmoji,
|
|
1695
|
+
roomEmoji,
|
|
1696
|
+
saveAgentSession,
|
|
1697
|
+
clearAgentSession,
|
|
1698
|
+
listAgentSessions,
|
|
1699
|
+
isAgentAlive,
|
|
1700
|
+
tmuxAvailable,
|
|
1701
|
+
tmuxSessionExists,
|
|
1702
|
+
tmuxCreateSession,
|
|
1703
|
+
tmuxSendCommand,
|
|
1704
|
+
tmuxInjectText,
|
|
1705
|
+
tmuxInjectPaste,
|
|
1706
|
+
tmuxSendEnter,
|
|
1707
|
+
tmuxAttach,
|
|
1708
|
+
tmuxCapturePane,
|
|
1709
|
+
tmuxSendKey,
|
|
1710
|
+
tmuxKillSession,
|
|
1711
|
+
resetTerminal,
|
|
1712
|
+
tmuxPaneIsShell,
|
|
1713
|
+
INVITES_DIR,
|
|
1714
|
+
saveRoomSession,
|
|
1715
|
+
listRoomSessions,
|
|
1716
|
+
removeRoomSession,
|
|
1717
|
+
serve
|
|
1718
|
+
};
|
|
1719
|
+
//# sourceMappingURL=chunk-CZHO4DBN.js.map
|