@cecwxf/dsh-room 0.3.3 → 0.3.5

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/dist/index.js CHANGED
@@ -416,8 +416,8 @@ __export(mcp_workspace_exports, {
416
416
  MCP_TOOL_DEFS: () => MCP_TOOL_DEFS,
417
417
  WorkspaceMcp: () => WorkspaceMcp
418
418
  });
419
- import { mkdir as mkdir3, readFile as readFile3, readdir, rm, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
420
- import { dirname as dirname2, join as join2, relative as relative2, resolve as resolve2, sep } from "node:path";
419
+ import { lstat, mkdir as mkdir3, readFile as readFile3, readdir, realpath, rm, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
420
+ import { dirname as dirname2, isAbsolute as isAbsolute2, join as join2, relative as relative2, resolve as resolve2, sep } from "node:path";
421
421
  import { spawn } from "node:child_process";
422
422
  function actionFor(tool) {
423
423
  if (tool === "workspace_write" || tool === "workspace_mkdir") return "fs.write";
@@ -482,16 +482,27 @@ var init_mcp_workspace = __esm({
482
482
  const root = resolve2(this.cfg.workspacePath);
483
483
  const target = resolve2(root, rel || ".");
484
484
  const relToRoot = relative2(root, target);
485
- if (relToRoot.startsWith("..") || relToRoot.includes(`..${sep}`)) throw new Error("path escapes workspace");
486
- try {
487
- const { realpath } = await import("node:fs/promises");
488
- const real = await realpath(target).catch(() => target);
489
- const realRoot = await realpath(root).catch(() => root);
490
- if (!real.startsWith(realRoot + sep) && real !== realRoot) throw new Error("path escapes workspace (symlink)");
491
- } catch (e) {
492
- if (e.message.includes("escapes")) throw e;
485
+ const outside = (p) => isAbsolute2(p) || p === ".." || p.startsWith(`..${sep}`);
486
+ if (outside(relToRoot)) throw new Error("path escapes workspace");
487
+ const realRoot = await realpath(root);
488
+ let ancestor = resolve2(realRoot, relToRoot);
489
+ const suffix = [];
490
+ while (true) {
491
+ let real;
492
+ try {
493
+ real = await realpath(ancestor);
494
+ } catch (e) {
495
+ if (e.code !== "ENOENT") throw e;
496
+ if ((await lstat(ancestor).catch(() => null))?.isSymbolicLink()) throw new Error("path escapes workspace (dangling symlink)");
497
+ const parent = dirname2(ancestor);
498
+ if (parent === ancestor) throw e;
499
+ suffix.unshift(relative2(parent, ancestor));
500
+ ancestor = parent;
501
+ continue;
502
+ }
503
+ if (outside(relative2(realRoot, real))) throw new Error("path escapes workspace (symlink)");
504
+ return resolve2(real, ...suffix);
493
505
  }
494
- return target;
495
506
  }
496
507
  async maybeApprove(tool, summary, detail, from) {
497
508
  const action = actionFor(tool);
@@ -503,7 +514,7 @@ var init_mcp_workspace = __esm({
503
514
  if (cap) {
504
515
  const grant = resolvePeerGrant(this.cfg, from, cap);
505
516
  if (grant === "deny") throw new Error(`peer grant deny: ${cap} for ${from}`);
506
- if (grant === "auto") return;
517
+ if (grant === "auto" && (cap !== "read" || this.cfg.permissions.autoRead)) return;
507
518
  }
508
519
  if (!action && cap === "read") {
509
520
  const decision2 = await this.approvals.request({
@@ -541,6 +552,7 @@ var init_mcp_workspace = __esm({
541
552
  await mkdir3(this.cfg.workspacePath, { recursive: true });
542
553
  switch (tool) {
543
554
  case "workspace_list": {
555
+ await this.maybeApprove(tool, `list ${args.path ?? "."}`, "", from);
544
556
  const dir = await this.resolveSafe(String(args.path ?? "."));
545
557
  const entries = await readdir(dir, { withFileTypes: true });
546
558
  return {
@@ -549,18 +561,20 @@ var init_mcp_workspace = __esm({
549
561
  };
550
562
  }
551
563
  case "workspace_read": {
552
- if (!this.cfg.permissions.autoRead) await this.maybeApprove("workspace_read", `read ${args.path}`, "", from);
564
+ await this.maybeApprove(tool, `read ${args.path}`, "", from);
553
565
  const file = await this.resolveSafe(String(args.path ?? ""));
554
566
  const buf = await readFile3(file);
555
567
  if (buf.length > 256 * 1024) throw new Error("file too large (>256KiB)");
556
568
  return { path: String(args.path), content: buf.toString("utf8") };
557
569
  }
558
570
  case "workspace_stat": {
571
+ await this.maybeApprove(tool, `stat ${args.path}`, "", from);
559
572
  const file = await this.resolveSafe(String(args.path ?? ""));
560
573
  const st = await stat2(file);
561
574
  return { path: String(args.path), size: st.size, mtimeMs: st.mtimeMs, isDirectory: st.isDirectory(), isFile: st.isFile() };
562
575
  }
563
576
  case "workspace_search": {
577
+ await this.maybeApprove(tool, `search ${args.path ?? "."}`, String(args.query ?? ""), from);
564
578
  const q = String(args.query ?? "");
565
579
  if (!q) throw new Error("query required");
566
580
  const start = await this.resolveSafe(String(args.path ?? "."));
@@ -630,9 +644,22 @@ var init_mcp_workspace = __esm({
630
644
  }
631
645
  });
632
646
 
647
+ // src/models.ts
648
+ function resolveSelection(defaults, room = {}, agent = {}) {
649
+ return {
650
+ provider: agent.provider || room.provider || defaults.provider,
651
+ model: agent.model || room.model || defaults.model,
652
+ reasoningEffort: agent.reasoningEffort || room.reasoningEffort || defaults.reasoningEffort
653
+ };
654
+ }
655
+ function describeModel(selection, catalog) {
656
+ if (!selection.provider || !selection.model) return null;
657
+ return catalog.find((m) => m.provider === selection.provider && m.model === selection.model) ?? { provider: selection.provider, model: selection.model, name: selection.model };
658
+ }
659
+
633
660
  // src/plugin.ts
634
- import { randomUUID as randomUUID2 } from "node:crypto";
635
- import { mkdir as mkdir5, readFile as readFile5, writeFile as writeFile4, appendFile as appendFile2 } from "node:fs/promises";
661
+ import { randomUUID as randomUUID3 } from "node:crypto";
662
+ import { mkdir as mkdir5, readFile as readFile5, writeFile as writeFile4, appendFile as appendFile2, rename as rename2, rm as rm3 } from "node:fs/promises";
636
663
  import { dirname as dirname4, join as join3 } from "node:path";
637
664
 
638
665
  // ../protocol/dist/types.js
@@ -1229,7 +1256,7 @@ var WsTransport = class {
1229
1256
  return;
1230
1257
  this.teardownSocket();
1231
1258
  const gen = ++this.generation;
1232
- await new Promise((resolve3, reject) => {
1259
+ await new Promise((resolve4, reject) => {
1233
1260
  const ws = new WebSocket(this.url);
1234
1261
  this.ws = ws;
1235
1262
  let settled = false;
@@ -1250,7 +1277,7 @@ var WsTransport = class {
1250
1277
  reject(err);
1251
1278
  } else {
1252
1279
  this._connected = true;
1253
- resolve3();
1280
+ resolve4();
1254
1281
  }
1255
1282
  };
1256
1283
  const timer = setTimeout(() => finish(new Error(`WebSocket connect timeout (${timeoutMs}ms) to ${this.url}`)), timeoutMs);
@@ -1340,6 +1367,8 @@ var CollabClient = class _CollabClient {
1340
1367
  statusHandlers = [];
1341
1368
  waiters = [];
1342
1369
  joined = false;
1370
+ hasJoined = false;
1371
+ reconnecting;
1343
1372
  disposed = false;
1344
1373
  capabilities;
1345
1374
  reconnect;
@@ -1361,9 +1390,11 @@ var CollabClient = class _CollabClient {
1361
1390
  this.transport.onClose(() => {
1362
1391
  this.joined = false;
1363
1392
  this.setConnStatus("disconnected", "ws_closed");
1364
- if (this.reconnect && !this.disposed) {
1393
+ if (this.reconnect && !this.disposed && !this.reconnecting) {
1365
1394
  this.setConnStatus("connecting", "reconnect");
1366
- void this.reconnectLoop();
1395
+ this.reconnecting = this.reconnectLoop().finally(() => {
1396
+ this.reconnecting = void 0;
1397
+ });
1367
1398
  }
1368
1399
  });
1369
1400
  }
@@ -1441,7 +1472,15 @@ var CollabClient = class _CollabClient {
1441
1472
  await this.transport.connect();
1442
1473
  }
1443
1474
  async join(timeoutMs = 1e4) {
1475
+ if (this.disposed)
1476
+ throw new Error("disposed");
1477
+ if (this.joined)
1478
+ return;
1479
+ const catchUp = this.hasJoined || !!this.cursor;
1480
+ const since = this.cursor?.get() ?? this.seq;
1444
1481
  await this.connect();
1482
+ if (this.disposed)
1483
+ throw new Error("disposed");
1445
1484
  const challengeP = this.waitFor((f) => f.type === "ctrl" && (f.method === "challenge" || f.method === "error"), timeoutMs);
1446
1485
  this.transport.send({
1447
1486
  v: 1,
@@ -1481,14 +1520,16 @@ var CollabClient = class _CollabClient {
1481
1520
  throw new Error("expected joined");
1482
1521
  }
1483
1522
  this.joined = true;
1523
+ this.hasJoined = true;
1484
1524
  this.peers = joined.peers;
1485
1525
  this.seq = joined.seq;
1486
1526
  this.setConnStatus("connected");
1487
1527
  for (const h of this.peerHandlers)
1488
1528
  h(this.peers);
1489
- const since = this.cursor?.get() ?? 0;
1490
- if (since > 0 && since < this.seq) {
1491
- await this.poll(since);
1529
+ if (catchUp && since < joined.seq) {
1530
+ const missed = await this.pollFrames(since);
1531
+ for (const frame of missed.frames)
1532
+ await this.handleFwd(frame);
1492
1533
  }
1493
1534
  this.startPing();
1494
1535
  }
@@ -1583,6 +1624,18 @@ var CollabClient = class _CollabClient {
1583
1624
  }
1584
1625
  async poll(sinceSeq) {
1585
1626
  const since = sinceSeq ?? this.cursor?.get() ?? 0;
1627
+ const res = await this.pollFrames(since);
1628
+ const out = [];
1629
+ for (const fwd of res.frames) {
1630
+ const p = await this.decryptFwd(fwd);
1631
+ if (p)
1632
+ out.push(p);
1633
+ }
1634
+ this.seq = Math.max(this.seq, res.seq);
1635
+ this.cursor?.set(this.seq);
1636
+ return out;
1637
+ }
1638
+ async pollFrames(since) {
1586
1639
  const resP = this.waitFor((f) => f.type === "ctrl" && f.method === "poll_result", 5e3);
1587
1640
  this.transport.send({
1588
1641
  v: 1,
@@ -1594,16 +1647,8 @@ var CollabClient = class _CollabClient {
1594
1647
  });
1595
1648
  const res = await resP;
1596
1649
  if (res.type !== "ctrl" || res.method !== "poll_result")
1597
- return [];
1598
- const out = [];
1599
- for (const fwd of res.frames) {
1600
- const p = await this.decryptFwd(fwd);
1601
- if (p)
1602
- out.push(p);
1603
- }
1604
- this.seq = Math.max(this.seq, res.seq);
1605
- this.cursor?.set(this.seq);
1606
- return out;
1650
+ throw new Error("expected poll_result");
1651
+ return res;
1607
1652
  }
1608
1653
  async history(sinceSeq = 0) {
1609
1654
  return this.poll(sinceSeq);
@@ -1651,20 +1696,23 @@ var CollabClient = class _CollabClient {
1651
1696
  }
1652
1697
  async decryptFwd(frame) {
1653
1698
  try {
1699
+ if (frame.roomId !== this.roomId)
1700
+ return null;
1654
1701
  const aad = new TextEncoder().encode(`${frame.roomId}:${frame.from}`);
1655
1702
  const plain = await decrypt(this.roomKey, frame.nonce, frame.ciphertext, aad);
1656
- return decodePayload(plain);
1703
+ const payload = decodePayload(plain);
1704
+ return payload.sender === frame.from ? payload : null;
1657
1705
  } catch {
1658
1706
  return null;
1659
1707
  }
1660
1708
  }
1661
1709
  waitFor(pred, timeoutMs) {
1662
- const p = new Promise((resolve3, reject) => {
1710
+ const p = new Promise((resolve4, reject) => {
1663
1711
  const timer = setTimeout(() => {
1664
1712
  this.waiters = this.waiters.filter((w) => w.timer !== timer);
1665
1713
  reject(new Error("timeout waiting for frame"));
1666
1714
  }, timeoutMs);
1667
- this.waiters.push({ pred, resolve: resolve3, reject, timer });
1715
+ this.waiters.push({ pred, resolve: resolve4, reject, timer });
1668
1716
  });
1669
1717
  p.catch(() => {
1670
1718
  });
@@ -1818,6 +1866,7 @@ var RunScheduler = class {
1818
1866
  runs = [];
1819
1867
  reqs = /* @__PURE__ */ new Map();
1820
1868
  aborts = /* @__PURE__ */ new Map();
1869
+ gates = /* @__PURE__ */ new Map();
1821
1870
  idleWaiters = [];
1822
1871
  enqueue(req) {
1823
1872
  const run = {
@@ -1835,7 +1884,10 @@ var RunScheduler = class {
1835
1884
  this.reqs.set(run.id, req);
1836
1885
  this.changed();
1837
1886
  if (req.gate) {
1838
- req.gate().then(
1887
+ const gate = req.gate;
1888
+ const ac = new AbortController();
1889
+ this.gates.set(run.id, ac);
1890
+ Promise.resolve().then(() => ac.signal.aborted ? false : gate(ac.signal)).then(
1839
1891
  (ok2) => {
1840
1892
  if (run.state !== "approval") return;
1841
1893
  if (ok2) run.state = "queued";
@@ -1844,8 +1896,12 @@ var RunScheduler = class {
1844
1896
  },
1845
1897
  (e) => {
1846
1898
  if (run.state === "approval") this.finish(run, "failed", e.message);
1899
+ this.pump();
1847
1900
  }
1848
- );
1901
+ ).finally(() => {
1902
+ this.gates.delete(run.id);
1903
+ this.pump();
1904
+ });
1849
1905
  } else this.pump();
1850
1906
  return run;
1851
1907
  }
@@ -1853,11 +1909,14 @@ var RunScheduler = class {
1853
1909
  list(roomId, finished = 8) {
1854
1910
  const mine = this.runs.filter((r) => !roomId || r.roomId === roomId);
1855
1911
  const active = mine.filter((r) => r.state === "approval" || r.state === "queued" || r.state === "running");
1856
- const done = mine.filter((r) => !active.includes(r)).slice(-finished);
1912
+ const done = finished > 0 ? mine.filter((r) => !active.includes(r)).slice(-finished) : [];
1857
1913
  return [...active, ...done].map((r) => ({ ...r }));
1858
1914
  }
1859
1915
  running(roomId) {
1860
- return this.runs.filter((r) => r.roomId === roomId && r.state === "running").length;
1916
+ return this.runs.filter((r) => r.roomId === roomId && this.aborts.has(r.id)).length;
1917
+ }
1918
+ isExecuting(roomId, agentId) {
1919
+ return this.runs.some((r) => r.roomId === roomId && r.agentId === agentId && this.aborts.has(r.id));
1861
1920
  }
1862
1921
  /** Stop every queued/running run in a room (optionally one agent). Returns how many were stopped. */
1863
1922
  stop(roomId, agentId) {
@@ -1865,6 +1924,7 @@ var RunScheduler = class {
1865
1924
  for (const r of this.runs) {
1866
1925
  if (r.roomId !== roomId || agentId && r.agentId !== agentId) continue;
1867
1926
  if (r.state === "approval" || r.state === "queued") {
1927
+ this.gates.get(r.id)?.abort();
1868
1928
  this.finish(r, "stopped", "stopped by user");
1869
1929
  n++;
1870
1930
  } else if (r.state === "running") {
@@ -1878,18 +1938,18 @@ var RunScheduler = class {
1878
1938
  }
1879
1939
  /** Resolves when no run is approval/queued/running (tests, e2e). */
1880
1940
  whenIdle() {
1881
- if (!this.runs.some((r) => r.state === "approval" || r.state === "queued" || r.state === "running")) return Promise.resolve();
1941
+ if (!this.busy()) return Promise.resolve();
1882
1942
  return new Promise((r) => this.idleWaiters.push(r));
1883
1943
  }
1884
1944
  pump() {
1885
1945
  for (const run of this.runs) {
1886
1946
  if (run.state !== "queued") continue;
1887
1947
  if (this.running(run.roomId) >= Math.max(1, this.limitFor(run.roomId))) continue;
1888
- if (this.runs.some((r) => r.state === "running" && r.roomId === run.roomId && r.agentId === run.agentId)) continue;
1948
+ if (this.isExecuting(run.roomId, run.agentId)) continue;
1889
1949
  this.start(run);
1890
1950
  }
1891
1951
  this.changed();
1892
- if (!this.runs.some((r) => r.state === "approval" || r.state === "queued" || r.state === "running")) {
1952
+ if (!this.busy()) {
1893
1953
  const w = this.idleWaiters;
1894
1954
  this.idleWaiters = [];
1895
1955
  for (const fn of w) fn();
@@ -1901,7 +1961,9 @@ var RunScheduler = class {
1901
1961
  run.state = "running";
1902
1962
  run.startedAt = Date.now();
1903
1963
  const req = this.reqs.get(run.id);
1904
- Promise.resolve().then(() => this.exec(run, req, ac.signal)).then(
1964
+ Promise.resolve().then(() => {
1965
+ if (!ac.signal.aborted) return this.exec(run, req, ac.signal);
1966
+ }).then(
1905
1967
  () => run.state === "running" && this.finish(run, "done"),
1906
1968
  (e) => run.state === "running" && this.finish(run, ac.signal.aborted ? "stopped" : "failed", e?.message ?? String(e))
1907
1969
  ).finally(() => {
@@ -1914,9 +1976,17 @@ var RunScheduler = class {
1914
1976
  run.endedAt = Date.now();
1915
1977
  if (error) run.error = error;
1916
1978
  this.reqs.delete(run.id);
1917
- if (this.runs.length > 400) this.runs = this.runs.filter((r) => r.state === "approval" || r.state === "queued" || r.state === "running" || Date.now() - (r.endedAt ?? 0) < 36e5).slice(-300);
1979
+ if (this.runs.length > 400) {
1980
+ const active = this.runs.filter((r) => r.state === "approval" || r.state === "queued" || this.aborts.has(r.id) || this.gates.has(r.id));
1981
+ const finished = this.runs.filter((r) => !active.includes(r)).slice(-300);
1982
+ const keep = /* @__PURE__ */ new Set([...active, ...finished]);
1983
+ this.runs = this.runs.filter((r) => keep.has(r));
1984
+ }
1918
1985
  this.changed();
1919
1986
  }
1987
+ busy() {
1988
+ return this.aborts.size > 0 || this.gates.size > 0 || this.runs.some((r) => r.state === "approval" || r.state === "queued");
1989
+ }
1920
1990
  changed() {
1921
1991
  try {
1922
1992
  this.onChange();
@@ -1936,19 +2006,23 @@ import { WebSocketServer } from "ws";
1936
2006
  import { WebSocket as WebSocket2 } from "ws";
1937
2007
  var RING = 1e3;
1938
2008
  var RATE = 40;
2009
+ var MAX_CIPHERTEXT = 256 * 1024;
1939
2010
  var LanRoom = class {
1940
2011
  roomId;
1941
2012
  socks = /* @__PURE__ */ new Set();
1942
2013
  seq = 0;
1943
2014
  ring = [];
1944
2015
  roomRate = [];
2016
+ identities = /* @__PURE__ */ new Map();
1945
2017
  constructor(roomId) {
1946
2018
  this.roomId = roomId;
1947
2019
  }
1948
2020
  attach(ws) {
1949
- const s = { ws, joined: false, stamps: [] };
2021
+ const s = { ws, joined: false, stamps: [], processing: Promise.resolve() };
1950
2022
  this.socks.add(s);
1951
- ws.on("message", (data) => void this.onMessage(s, data.toString()));
2023
+ ws.on("message", (data) => {
2024
+ s.processing = s.processing.then(() => this.onMessage(s, data.toString())).catch(() => this.error(s, "bad_frame", "invalid frame"));
2025
+ });
1952
2026
  ws.on("close", () => this.onClose(s));
1953
2027
  ws.on("error", () => this.onClose(s));
1954
2028
  }
@@ -1965,6 +2039,9 @@ var LanRoom = class {
1965
2039
  this.send(s.ws, frame);
1966
2040
  }
1967
2041
  }
2042
+ error(s, code, message) {
2043
+ this.send(s.ws, { v: 1, type: "ctrl", method: "error", code, message, roomId: this.roomId });
2044
+ }
1968
2045
  rateOk(stamps, now, limit) {
1969
2046
  const recent = stamps.filter((t) => now - t < 1e3);
1970
2047
  if (recent.length >= limit)
@@ -1973,22 +2050,26 @@ var LanRoom = class {
1973
2050
  return { ok: true, next: recent };
1974
2051
  }
1975
2052
  async onMessage(s, text2) {
1976
- const now = Date.now();
1977
- const rr = this.rateOk(this.roomRate, now, 200);
1978
- this.roomRate = rr.next;
1979
- if (!rr.ok)
1980
- return;
1981
- const sr = this.rateOk(s.stamps, now, RATE);
1982
- s.stamps = sr.next;
1983
- if (!sr.ok)
1984
- return;
1985
2053
  for (const line of text2.split("\n")) {
2054
+ if (!this.socks.has(s))
2055
+ return;
1986
2056
  if (!line.trim())
1987
2057
  continue;
2058
+ const now = Date.now();
2059
+ const rr = this.rateOk(this.roomRate, now, 200);
2060
+ this.roomRate = rr.next;
2061
+ const sr = this.rateOk(s.stamps, now, RATE);
2062
+ s.stamps = sr.next;
2063
+ if (!rr.ok || !sr.ok)
2064
+ return this.error(s, "rate_limit", "too many frames");
1988
2065
  let frame;
1989
2066
  try {
1990
- frame = JSON.parse(line);
2067
+ const parsed = JSON.parse(line);
2068
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
2069
+ throw new Error("not an object");
2070
+ frame = parsed;
1991
2071
  } catch {
2072
+ this.error(s, "bad_json", "frame must be a JSON object");
1992
2073
  continue;
1993
2074
  }
1994
2075
  if (frame.v !== 1)
@@ -2009,11 +2090,19 @@ var LanRoom = class {
2009
2090
  }
2010
2091
  if (frame.type === "ctrl" && frame.method === "poll") {
2011
2092
  const since = frame.sinceSeq ?? 0;
2093
+ if (!Number.isSafeInteger(since) || since < 0) {
2094
+ this.error(s, "bad_poll", "invalid sinceSeq");
2095
+ continue;
2096
+ }
2012
2097
  const frames = this.ring.filter((f) => f.seq > since);
2013
2098
  this.send(s.ws, { v: 1, type: "ctrl", method: "poll_result", roomId: this.roomId, frames, seq: this.seq });
2014
2099
  continue;
2015
2100
  }
2016
2101
  if (frame.type === "fwd") {
2102
+ if (frame.roomId !== this.roomId || frame.from !== s.agentId || typeof frame.ciphertext !== "string" || !frame.ciphertext || frame.ciphertext.length > MAX_CIPHERTEXT || typeof frame.nonce !== "string" || !/^[A-Za-z0-9_-]{16}$/.test(frame.nonce)) {
2103
+ this.error(s, "bad_frame", "invalid encrypted frame");
2104
+ continue;
2105
+ }
2017
2106
  this.seq += 1;
2018
2107
  const fwd = { ...frame, seq: this.seq, roomId: this.roomId, from: s.agentId };
2019
2108
  this.ring.push(fwd);
@@ -2028,23 +2117,44 @@ var LanRoom = class {
2028
2117
  this.send(s.ws, { v: 1, type: "ctrl", method: "error", code: "bad_room", message: "room mismatch", roomId: this.roomId });
2029
2118
  return;
2030
2119
  }
2031
- if (!s.challenge || !frame.challengeResp) {
2032
- s.challenge = b64uEncode(randomBytes(32));
2033
- s.publicKey = frame.publicKey;
2034
- this.send(s.ws, { v: 1, type: "ctrl", method: "challenge", roomId: this.roomId, challenge: s.challenge });
2035
- s.agentId = frame.agentId;
2036
- s.capabilities = frame.capabilities;
2120
+ if (typeof frame.agentId !== "string" || !frame.agentId || frame.agentId.length > 128 || typeof frame.publicKey !== "string" || !frame.publicKey || frame.publicKey.length > 256 || typeof frame.challengeResp !== "string" || frame.challengeResp.length > 256 || frame.capabilities !== void 0 && (!Array.isArray(frame.capabilities) || frame.capabilities.length > 32 || frame.capabilities.some((c) => typeof c !== "string" || c.length > 128))) {
2121
+ return this.error(s, "bad_join", "invalid identity or capabilities");
2122
+ }
2123
+ const knownKey = this.identities.get(frame.agentId);
2124
+ if (knownKey && knownKey !== frame.publicKey)
2125
+ return this.error(s, "identity_conflict", "agentId belongs to another public key in this room");
2126
+ if (s.joined) {
2127
+ if (frame.agentId !== s.agentId || frame.publicKey !== s.publicKey)
2128
+ return this.error(s, "identity_locked", "joined socket identity cannot change");
2129
+ this.send(s.ws, { v: 1, type: "ctrl", method: "joined", roomId: this.roomId, agentId: s.agentId, peers: this.members(), seq: this.seq });
2130
+ return;
2131
+ }
2132
+ if (!s.pending || !frame.challengeResp || s.pending.agentId !== frame.agentId || s.pending.publicKey !== frame.publicKey) {
2133
+ s.pending = { challenge: b64uEncode(randomBytes(32)), agentId: frame.agentId, publicKey: frame.publicKey };
2134
+ this.send(s.ws, { v: 1, type: "ctrl", method: "challenge", roomId: this.roomId, challenge: s.pending.challenge });
2037
2135
  return;
2038
2136
  }
2039
- const ok2 = await verifyChallenge(frame.publicKey, s.challenge, frame.challengeResp);
2137
+ const pending = s.pending;
2138
+ s.pending = void 0;
2139
+ const ok2 = await verifyChallenge(frame.publicKey, pending.challenge, frame.challengeResp).catch(() => false);
2140
+ if (!this.socks.has(s))
2141
+ return;
2040
2142
  if (!ok2) {
2041
2143
  this.send(s.ws, { v: 1, type: "ctrl", method: "error", code: "bad_sig", message: "challenge failed", roomId: this.roomId });
2042
2144
  return;
2043
2145
  }
2146
+ const verifiedKey = this.identities.get(frame.agentId);
2147
+ if (verifiedKey && verifiedKey !== frame.publicKey)
2148
+ return this.error(s, "identity_conflict", "agentId belongs to another public key in this room");
2149
+ this.identities.set(frame.agentId, frame.publicKey);
2044
2150
  s.agentId = frame.agentId;
2045
2151
  s.publicKey = frame.publicKey;
2046
2152
  s.capabilities = frame.capabilities;
2047
2153
  s.joined = true;
2154
+ for (const old of [...this.socks]) {
2155
+ if (old !== s && old.joined && old.agentId === s.agentId)
2156
+ this.onClose(old);
2157
+ }
2048
2158
  const peers = this.members();
2049
2159
  this.send(s.ws, { v: 1, type: "ctrl", method: "joined", roomId: this.roomId, agentId: s.agentId, peers, seq: this.seq });
2050
2160
  this.broadcast({ v: 1, type: "ctrl", method: "peer_update", roomId: this.roomId, peers, event: "joined", agentId: s.agentId }, s.ws);
@@ -2158,10 +2268,10 @@ function browseLanRelays(opts) {
2158
2268
  }
2159
2269
  });
2160
2270
  dns.query({ questions: [{ name: MDNS_SERVICE, type: "PTR" }] });
2161
- return new Promise((resolve3) => {
2271
+ return new Promise((resolve4) => {
2162
2272
  setTimeout(() => {
2163
2273
  dns.destroy();
2164
- resolve3([...found.values()]);
2274
+ resolve4([...found.values()]);
2165
2275
  }, timeoutMs);
2166
2276
  });
2167
2277
  }
@@ -2472,19 +2582,33 @@ var LanRelayServer = class {
2472
2582
  res.writeHead(200, { "content-type": "text/plain" });
2473
2583
  res.end("Collab LAN / VPS Relay\nWS: /ws/:roomId\nDirectory: GET|POST /directory/rooms, DELETE /directory/rooms/:roomId\nHealth: /health\nmDNS: _collab._tcp.local\n");
2474
2584
  });
2475
- this.wss = new WebSocketServer({ noServer: true });
2585
+ this.wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
2476
2586
  this.http.on("upgrade", (req, socket, head) => {
2477
- const m = /^\/ws\/([^/?#]+)/.exec(req.url ?? "");
2587
+ const m = /^\/ws\/([^/?#]+)\/?(?:\?[^#]*)?$/.exec(req.url ?? "");
2478
2588
  if (!m) {
2479
2589
  socket.destroy();
2480
2590
  return;
2481
2591
  }
2482
- const roomId = decodeURIComponent(m[1]);
2592
+ let roomId;
2593
+ try {
2594
+ roomId = decodeURIComponent(m[1]);
2595
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(roomId))
2596
+ throw new Error("invalid roomId");
2597
+ } catch {
2598
+ socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
2599
+ return;
2600
+ }
2483
2601
  this.wss.handleUpgrade(req, socket, head, (ws) => {
2484
2602
  this.room(roomId).attach(ws);
2485
2603
  });
2486
2604
  });
2487
- await new Promise((resolve3) => this.http.listen(port, host, resolve3));
2605
+ await new Promise((resolve4, reject) => {
2606
+ this.http.once("error", reject);
2607
+ this.http.listen(port, host, () => {
2608
+ this.http.off("error", reject);
2609
+ resolve4();
2610
+ });
2611
+ });
2488
2612
  const addr = this.http.address();
2489
2613
  this.port = typeof addr === "object" && addr ? addr.port : port;
2490
2614
  if (this.opts.mdns !== false) {
@@ -2494,8 +2618,12 @@ var LanRelayServer = class {
2494
2618
  }
2495
2619
  async close() {
2496
2620
  this.stopMdns?.();
2497
- await new Promise((resolve3) => this.wss?.close(() => resolve3()));
2498
- await new Promise((resolve3) => this.http?.close(() => resolve3()));
2621
+ for (const ws of this.wss?.clients ?? [])
2622
+ ws.terminate();
2623
+ if (this.wss)
2624
+ await new Promise((resolve4) => this.wss.close(() => resolve4()));
2625
+ if (this.http)
2626
+ await new Promise((resolve4) => this.http.close(() => resolve4()));
2499
2627
  }
2500
2628
  };
2501
2629
 
@@ -2521,8 +2649,9 @@ var ApprovalQueue = class {
2521
2649
  }
2522
2650
  /** Ask for approval; resolves with allow|deny. Auto-denies after timeoutMs. */
2523
2651
  request(opts) {
2652
+ if (opts.signal?.aborted) return Promise.resolve("deny");
2524
2653
  const requestId = randomUUID();
2525
- return new Promise((resolve3) => {
2654
+ return new Promise((resolve4) => {
2526
2655
  const entry = {
2527
2656
  requestId,
2528
2657
  roomId: opts.roomId,
@@ -2532,22 +2661,22 @@ var ApprovalQueue = class {
2532
2661
  from: opts.from,
2533
2662
  createdAt: Date.now(),
2534
2663
  resolve: (d) => {
2664
+ if (!this.pending.has(requestId)) return;
2535
2665
  clearTimeout(timer);
2666
+ opts.signal?.removeEventListener("abort", abort);
2536
2667
  this.pending.delete(requestId);
2537
2668
  this.emit();
2538
- resolve3(d);
2669
+ resolve4(d);
2539
2670
  }
2540
2671
  };
2541
- this.pending.set(requestId, entry);
2542
- this.emit();
2672
+ const abort = () => entry.resolve("deny");
2543
2673
  const timer = setTimeout(() => {
2544
- if (this.pending.has(requestId)) {
2545
- this.pending.delete(requestId);
2546
- this.emit();
2547
- resolve3("deny");
2548
- }
2674
+ entry.resolve("deny");
2549
2675
  }, opts.timeoutMs ?? 12e4);
2550
2676
  timer.unref?.();
2677
+ this.pending.set(requestId, entry);
2678
+ opts.signal?.addEventListener("abort", abort, { once: true });
2679
+ this.emit();
2551
2680
  });
2552
2681
  }
2553
2682
  decide(requestId, decision) {
@@ -2564,7 +2693,8 @@ init_member_config();
2564
2693
 
2565
2694
  // src/state.ts
2566
2695
  import { chmod, mkdir as mkdir4, readFile as readFile4, rename, rm as rm2, writeFile as writeFile3 } from "node:fs/promises";
2567
- import { dirname as dirname3 } from "node:path";
2696
+ import { dirname as dirname3, resolve as resolve3 } from "node:path";
2697
+ import { randomUUID as randomUUID2 } from "node:crypto";
2568
2698
  function newId(prefix) {
2569
2699
  return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
2570
2700
  }
@@ -2608,12 +2738,25 @@ async function loadState(path) {
2608
2738
  return null;
2609
2739
  }
2610
2740
  }
2741
+ var pendingWrites = /* @__PURE__ */ new Map();
2611
2742
  async function saveState(path, state) {
2743
+ path = resolve3(path);
2744
+ const snapshot = structuredClone(state);
2745
+ const pending = (pendingWrites.get(path) ?? Promise.resolve()).catch(() => {
2746
+ }).then(() => writeState(path, snapshot));
2747
+ pendingWrites.set(path, pending);
2748
+ try {
2749
+ await pending;
2750
+ } finally {
2751
+ if (pendingWrites.get(path) === pending) pendingWrites.delete(path);
2752
+ }
2753
+ }
2754
+ async function writeState(path, state) {
2612
2755
  const dir = dirname3(path);
2613
2756
  await mkdir4(dir, { recursive: true, mode: 448 });
2614
2757
  await chmod(dir, 448).catch(() => {
2615
2758
  });
2616
- const tmp = `${path}.${process.pid}.tmp`;
2759
+ const tmp = `${path}.${process.pid}.${randomUUID2()}.tmp`;
2617
2760
  const body = {
2618
2761
  version: 3,
2619
2762
  activeWorkspaceId: state.activeWorkspaceId,
@@ -2622,9 +2765,13 @@ async function saveState(path, state) {
2622
2765
  rooms: state.rooms,
2623
2766
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2624
2767
  };
2625
- await writeFile3(tmp, JSON.stringify(body, null, 2), { mode: 384 });
2626
- await chmod(tmp, 384);
2627
- await rename(tmp, path);
2768
+ try {
2769
+ await writeFile3(tmp, JSON.stringify(body, null, 2), { mode: 384, flag: "wx" });
2770
+ await chmod(tmp, 384);
2771
+ await rename(tmp, path);
2772
+ } finally {
2773
+ await rm2(tmp, { force: true });
2774
+ }
2628
2775
  }
2629
2776
 
2630
2777
  // src/commands.ts
@@ -2774,7 +2921,7 @@ var PLUGIN_VERSION = (() => {
2774
2921
  try {
2775
2922
  return requirePkg("../package.json").version;
2776
2923
  } catch {
2777
- return "0.3.3";
2924
+ return "0.3.5";
2778
2925
  }
2779
2926
  })();
2780
2927
  var SUMMARY_MAX = 120;
@@ -2782,14 +2929,21 @@ var MESSAGES_MAX = 300;
2782
2929
  var AGENT_SERVICES = ["agents", "agentDefaultModel", "agentPresets", "permissionPresets", "sessionTitle", "workspaceRegistry"];
2783
2930
  var text = (s) => [{ type: "text", text: s }];
2784
2931
  var jsonRender = (_args, value) => text(JSON.stringify(value));
2785
- function withTimeout(p, ms, label) {
2932
+ function withTimeout(p, ms, label, signal) {
2786
2933
  let t;
2934
+ let onAbort;
2787
2935
  const timeout = new Promise((_, rej) => {
2788
2936
  t = setTimeout(() => rej(new Error(`${label} timed out after ${ms}ms`)), ms);
2789
2937
  t.unref?.();
2790
2938
  });
2791
- return Promise.race([p, timeout]).finally(() => {
2939
+ const aborted = new Promise((_, reject) => {
2940
+ onAbort = () => reject(signal?.reason ?? new Error("stopped by user"));
2941
+ signal?.addEventListener("abort", onAbort, { once: true });
2942
+ if (signal?.aborted) onAbort();
2943
+ });
2944
+ return Promise.race([p, timeout, aborted]).finally(() => {
2792
2945
  if (t) clearTimeout(t);
2946
+ if (onAbort) signal?.removeEventListener("abort", onAbort);
2793
2947
  });
2794
2948
  }
2795
2949
  async function loadIdentity(path, agentName) {
@@ -2859,6 +3013,7 @@ var RoomConn = class {
2859
3013
  locks;
2860
3014
  git;
2861
3015
  historyTimer;
3016
+ historySave;
2862
3017
  announceTimer;
2863
3018
  normalized;
2864
3019
  /** Normalized once; the same object afterwards so `conn.local.x = …` always sticks. */
@@ -2920,6 +3075,36 @@ var CollabBridge = class {
2920
3075
  sessionAgents = /* @__PURE__ */ new Map();
2921
3076
  /** Override how a local agent run executes (tests / mock provider). */
2922
3077
  localRunner = null;
3078
+ models = [];
3079
+ async refreshModels() {
3080
+ const llm = this.ctx.get("llm", false);
3081
+ if (!llm?.listProviders || !llm?.listModels) return;
3082
+ const results = await Promise.allSettled(llm.listProviders().map(async (p) => {
3083
+ const entries = await withTimeout(llm.listModels(p.id), 5e3, `models ${p.id}`);
3084
+ return entries.map((m) => ({ provider: p.id, model: m.id, name: m.name || m.id }));
3085
+ }));
3086
+ this.models = results.flatMap((r) => r.status === "fulfilled" ? r.value : []);
3087
+ this.emit({ type: "rooms" });
3088
+ }
3089
+ selectedModel(conn, agent) {
3090
+ const defaults = this.ctx.get("agentDefaultModel", false)?.currentSelection?.() ?? {};
3091
+ return resolveSelection(defaults, conn?.meta.memberConfig, agent);
3092
+ }
3093
+ resolvedModel(conn, agent) {
3094
+ return describeModel(this.selectedModel(conn, agent), this.models);
3095
+ }
3096
+ async resetAgentSession(conn, agent) {
3097
+ const key = `${conn.meta.roomId}:${agent.agentId}`;
3098
+ const pending = this.agentHandles.get(key);
3099
+ this.agentHandles.delete(key);
3100
+ const sid = conn.meta.agentSessions?.[agent.agentId];
3101
+ if (sid) this.sessionAgents.delete(sid);
3102
+ if (conn.meta.agentSessions) delete conn.meta.agentSessions[agent.agentId];
3103
+ conn.meta.agentNoEffort = conn.meta.agentNoEffort?.filter((id) => id !== agent.agentId);
3104
+ this.noEffort.delete(key);
3105
+ if (pending) await pending.then((h) => h.dispose()).catch(() => {
3106
+ });
3107
+ }
2923
3108
  log(level, ...a) {
2924
3109
  this.ctx.logger[level]("[collab]", ...a);
2925
3110
  const dbg = process.env.DSH_ROOM_DEBUG_LOG;
@@ -2950,6 +3135,7 @@ var CollabBridge = class {
2950
3135
  start() {
2951
3136
  this.ready ??= (async () => {
2952
3137
  await this.self();
3138
+ await this.refreshModels();
2953
3139
  const st = await loadState(this.config.statePath);
2954
3140
  for (const w of st?.workspaces ?? []) this.workspaces.set(w.id, w);
2955
3141
  if (!this.workspaces.size) {
@@ -2958,12 +3144,17 @@ var CollabBridge = class {
2958
3144
  }
2959
3145
  this.activeWorkspaceId = st?.activeWorkspaceId ?? [...this.workspaces.keys()][0] ?? null;
2960
3146
  const metas = st?.rooms ?? [];
2961
- if (this.config.invite && !metas.some((m) => this.config.invite.includes(`/join/${encodeURIComponent(m.roomId)}#`) || this.config.invite.includes(`/join/${m.roomId}#`))) {
3147
+ if (this.config.invite && !metas.some((m) => m.roomId === CollabClient.parseInvite(this.config.invite).roomId)) {
2962
3148
  const { roomId } = CollabClient.parseInvite(this.config.invite);
2963
3149
  const wsId = this.activeWorkspaceId;
2964
3150
  metas.push({ roomId, invite: this.config.invite, name: roomId, description: "", tags: [], public: false, owner: false, joinedAt: Date.now(), workspaceId: wsId });
2965
3151
  }
2966
3152
  this.activeRoomId = st?.activeRoomId ?? metas[0]?.roomId ?? null;
3153
+ for (const meta of metas) {
3154
+ const conn = new RoomConn(meta);
3155
+ conn.onAdd = () => this.scheduleHistorySave(conn);
3156
+ this.rooms.set(meta.roomId, conn);
3157
+ }
2967
3158
  await this.persist();
2968
3159
  this.emit({ type: "rooms" });
2969
3160
  void Promise.all(
@@ -3006,7 +3197,7 @@ var CollabBridge = class {
3006
3197
  const roomId = parsed.roomId;
3007
3198
  const roomKeyB64 = parsed.roomKeyB64;
3008
3199
  conn.meta.transport = parsed.mode;
3009
- const wsUrl = inviteWsUrl(parsed);
3200
+ const wsUrl = this.inviteUrl(parsed);
3010
3201
  if (!conn.meta.memberConfig) {
3011
3202
  conn.meta.memberConfig = defaultMemberConfig(this.config.workspacePath);
3012
3203
  }
@@ -3356,6 +3547,7 @@ var CollabBridge = class {
3356
3547
  this.emit({ type: "message", roomId: conn.meta.roomId, message: m });
3357
3548
  this.routeLocal(conn, full);
3358
3549
  }
3550
+ if (conn.local.loopback || conn.local.agents.length) await this.saveLocalHistory(conn);
3359
3551
  return { roomId: conn.meta.roomId, ...r };
3360
3552
  }
3361
3553
  send(roomId, textIn, mentions, replyTo) {
@@ -3447,7 +3639,7 @@ var CollabBridge = class {
3447
3639
  bound: !!sessionId,
3448
3640
  sessionId,
3449
3641
  transport: conn.local.loopback ? "local" : conn.meta.transport ?? (conn.meta.invite.startsWith("collab://lan/") ? "lan" : "relay"),
3450
- memberConfig: conn.meta.memberConfig ? { ...publicMemberConfig(conn.meta.memberConfig), workspacePath: conn.meta.memberConfig.workspacePath, peerGrants: conn.meta.memberConfig.peerGrants, permissions: conn.meta.memberConfig.permissions } : null,
3642
+ memberConfig: conn.meta.memberConfig ? { ...publicMemberConfig(conn.meta.memberConfig), workspacePath: conn.meta.memberConfig.workspacePath, resolvedModel: this.resolvedModel(conn), peerGrants: conn.meta.memberConfig.peerGrants, permissions: conn.meta.memberConfig.permissions } : null,
3451
3643
  peerConfigs: Object.fromEntries(this.peerConfigs.get(conn.meta.roomId) ?? []),
3452
3644
  local: this.localView(conn),
3453
3645
  runs: this.scheduler.list(conn.meta.roomId)
@@ -3456,6 +3648,8 @@ var CollabBridge = class {
3456
3648
  state() {
3457
3649
  return {
3458
3650
  version: PLUGIN_VERSION,
3651
+ models: this.models,
3652
+ defaultModel: this.resolvedModel(),
3459
3653
  self: this.identity?.agentId ?? null,
3460
3654
  relayUrl: this.config.relayUrl,
3461
3655
  lanRelayPort: this.lanRelay?.port ?? null,
@@ -3486,7 +3680,20 @@ var CollabBridge = class {
3486
3680
  workspacePath: patch.workspacePath ?? cur.workspacePath,
3487
3681
  workspaceLabel: patch.workspaceLabel ?? cur.workspaceLabel
3488
3682
  };
3683
+ const modelChanged = ["provider", "model", "reasoningEffort"].some((k) => cur[k] !== next[k]);
3684
+ if (modelChanged && conn.local.agents.some((a) => this.scheduler.isExecuting(roomId, a.agentId))) throw new Error("Stop running agents before changing the room model");
3685
+ const before = conn.local.agents.map((a) => this.selectedModel(conn, a));
3489
3686
  conn.meta.memberConfig = next;
3687
+ if (modelChanged) {
3688
+ const host = this.sessions.get(roomId);
3689
+ this.sessions.delete(roomId);
3690
+ delete conn.meta.sessionId;
3691
+ if (host) await host.then((h) => h.dispose()).catch(() => {
3692
+ });
3693
+ for (const [i, a] of conn.local.agents.entries()) {
3694
+ if (JSON.stringify(before[i]) !== JSON.stringify(this.selectedModel(conn, a))) await this.resetAgentSession(conn, a);
3695
+ }
3696
+ }
3490
3697
  this.mcpByRoom.get(roomId)?.updateConfig(next);
3491
3698
  await this.persist();
3492
3699
  if (conn.client) await this.announceMemberConfig(conn);
@@ -3664,7 +3871,7 @@ var CollabBridge = class {
3664
3871
  const summary = `collab ${kind} from ${from} in ${conn.meta.name}`.slice(0, SUMMARY_MAX);
3665
3872
  return Object.freeze({
3666
3873
  role: "user",
3667
- id: randomUUID2(),
3874
+ id: randomUUID3(),
3668
3875
  content: Object.freeze([Object.freeze({ type: "text", text: body })]),
3669
3876
  source: Object.freeze({ kind: "collab", roomId: conn.meta.roomId, from, messageId, form: "notice", summary })
3670
3877
  });
@@ -3728,13 +3935,13 @@ Do what is asked using your tools. Answer in the room by calling collab_send wit
3728
3935
  const wsPath = roomPath && roomPath.trim() ? roomPath : cfg.workspacePath;
3729
3936
  await mkdir5(wsPath, { recursive: true });
3730
3937
  const workspace = await svc.workspaceRegistry.create(wsPath, wsPath === cfg.workspacePath ? "collab" : void 0);
3731
- const selected = svc.agentDefaultModel.currentSelection();
3732
- const sessionId = `collab-${roomId}-${randomUUID2().slice(0, 8)}`;
3938
+ const selected = this.selectedModel(conn);
3939
+ const sessionId = `collab-${roomId}-${randomUUID3().slice(0, 8)}`;
3733
3940
  const handle = await svc.agents.create({
3734
3941
  sessionId,
3735
3942
  signal: this.abort.signal,
3736
3943
  meta: { cwd: workspace.path, agentPreset: preset.id },
3737
- agentOptions: { provider: selected.provider, model: selected.model },
3944
+ agentOptions: { provider: selected.provider, model: selected.model, ...selected.reasoningEffort ? { reasoningEffort: selected.reasoningEffort } : {} },
3738
3945
  setup: async (agentCtx) => {
3739
3946
  await svc.agentPresets.mount(agentCtx, preset.id);
3740
3947
  }
@@ -3755,6 +3962,9 @@ Do what is asked using your tools. Answer in the room by calling collab_send wit
3755
3962
  return pending;
3756
3963
  }
3757
3964
  // ── local multi-agent: members hosted by this dsh ────────────────────────
3965
+ inviteUrl(parsed) {
3966
+ return inviteWsUrl(parsed.uri.startsWith("collab://join/") ? { ...parsed, wsBase: this.config.relayUrl } : parsed);
3967
+ }
3758
3968
  transportFor(conn, wsUrl) {
3759
3969
  return conn.local.loopback ? new MockTransport(this.hub, `loopback:${conn.meta.roomId}`) : new WsTransport(wsUrl);
3760
3970
  }
@@ -3781,7 +3991,7 @@ Do what is asked using your tools. Answer in the room by calling collab_send wit
3781
3991
  identity,
3782
3992
  roomId: parsed.roomId,
3783
3993
  roomKey: parsed.roomKeyB64,
3784
- transport: this.transportFor(conn, inviteWsUrl(parsed)),
3994
+ transport: this.transportFor(conn, this.inviteUrl(parsed)),
3785
3995
  autoReply: "off",
3786
3996
  reconnect: !conn.local.loopback,
3787
3997
  capabilities: ["collab.v1", "dsh", "member.config", "local-agent"]
@@ -3880,13 +4090,11 @@ Do what is asked using your tools. Answer in the room by calling collab_send wit
3880
4090
  const clean = sanitizeAgentInput(input);
3881
4091
  const sessionKeys = ["model", "provider", "reasoningEffort", "scope"];
3882
4092
  const resetSession = sessionKeys.some((k) => k in clean && clean[k] !== a[k]);
4093
+ if (resetSession && this.scheduler.isExecuting(roomId, a.agentId)) throw new Error("Stop this agent before changing its model or scope");
3883
4094
  if (clean.moderator) for (const x of conn.local.agents) x.moderator = false;
3884
4095
  Object.assign(a, clean, { id: a.id, agentId: a.agentId });
3885
4096
  if (resetSession) {
3886
- this.agentHandles.delete(`${conn.meta.roomId}:${a.agentId}`);
3887
- if (conn.meta.agentSessions) delete conn.meta.agentSessions[a.agentId];
3888
- conn.meta.agentNoEffort = conn.meta.agentNoEffort?.filter((x) => x !== a.agentId);
3889
- this.noEffort.delete(`${conn.meta.roomId}:${a.agentId}`);
4097
+ await this.resetAgentSession(conn, a);
3890
4098
  }
3891
4099
  await this.persist();
3892
4100
  await this.announceAgentConfig(conn, a).catch(() => {
@@ -3944,6 +4152,7 @@ Do what is asked using your tools. Answer in the room by calling collab_send wit
3944
4152
  await conn.client?.dispose().catch(() => {
3945
4153
  });
3946
4154
  conn.client = null;
4155
+ conn.lastSeenPeers.clear();
3947
4156
  conn.local.loopback = wantLoopback;
3948
4157
  conn.status = "connecting";
3949
4158
  this.emit({ type: "rooms" });
@@ -3957,19 +4166,31 @@ Do what is asked using your tools. Answer in the room by calling collab_send wit
3957
4166
  return join3(dirname4(this.config.statePath), "local-history", `${roomId.replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
3958
4167
  }
3959
4168
  scheduleHistorySave(conn) {
3960
- if (!conn.local.loopback || conn.historyTimer) return;
4169
+ if (!conn.local.loopback && !conn.local.agents.length || conn.historyTimer) return;
3961
4170
  conn.historyTimer = setTimeout(() => {
3962
4171
  conn.historyTimer = void 0;
3963
- void this.saveLocalHistory(conn);
4172
+ void this.saveLocalHistory(conn).catch((e) => this.log("error", `save history ${conn.meta.roomId}: ${e.message}`));
3964
4173
  }, 500);
3965
4174
  conn.historyTimer.unref?.();
3966
4175
  }
3967
- async saveLocalHistory(conn) {
4176
+ saveLocalHistory(conn) {
4177
+ clearTimeout(conn.historyTimer);
4178
+ conn.historyTimer = void 0;
3968
4179
  const f = this.historyPath(conn.meta.roomId);
3969
- await mkdir5(dirname4(f), { recursive: true, mode: 448 }).catch(() => {
3970
- });
3971
- await writeFile4(f, JSON.stringify(conn.messages.slice(-MESSAGES_MAX)), { mode: 384 }).catch(() => {
4180
+ const body = JSON.stringify(conn.messages.slice(-MESSAGES_MAX));
4181
+ const save = (conn.historySave ?? Promise.resolve()).catch(() => {
4182
+ }).then(async () => {
4183
+ await mkdir5(dirname4(f), { recursive: true, mode: 448 });
4184
+ const tmp = `${f}.${randomUUID3()}.tmp`;
4185
+ try {
4186
+ await writeFile4(tmp, body, { mode: 384, flag: "wx" });
4187
+ await rename2(tmp, f);
4188
+ } finally {
4189
+ await rm3(tmp, { force: true });
4190
+ }
3972
4191
  });
4192
+ conn.historySave = save;
4193
+ return save;
3973
4194
  }
3974
4195
  async loadLocalHistory(conn) {
3975
4196
  try {
@@ -4044,7 +4265,7 @@ Do what is asked using your tools. Answer in the room by calling collab_send wit
4044
4265
  tasks: opts.tasks
4045
4266
  };
4046
4267
  if (a.approvalRequired) {
4047
- req.gate = async () => await this.approvals.request({ roomId: conn.meta.roomId, action: "shell.exec", summary: `Run local agent ${a.name} (${a.agentId})`, detail: summary, from: a.agentId, timeoutMs: 10 * 6e4 }) === "allow";
4268
+ req.gate = async (signal) => await this.approvals.request({ roomId: conn.meta.roomId, action: "shell.exec", summary: `Run local agent ${a.name} (${a.agentId})`, detail: summary, from: a.agentId, timeoutMs: 10 * 6e4, signal }) === "allow";
4048
4269
  }
4049
4270
  this.scheduler.enqueue(req);
4050
4271
  }
@@ -4073,13 +4294,14 @@ Do what is asked using your tools. Answer in the room by calling collab_send wit
4073
4294
  conn.runThread.set(a.agentId, run.threadId);
4074
4295
  try {
4075
4296
  const cwd = await this.agentCwd(conn, a);
4297
+ signal.throwIfAborted();
4076
4298
  const prompt = this.buildLocalPrompt(conn, a, mode, req.trigger, run.threadId, cwd, req.tasks);
4077
4299
  const ctx = { bridge: this, roomId: conn.meta.roomId, agent: a, prompt, mode, trigger: req.trigger, threadId: run.threadId, cwd, signal };
4078
4300
  const wtBefore = conn.worktrees.get(a.agentId);
4079
4301
  const before = wtBefore ? await mainStatus(wtBefore.repo).catch(() => null) : null;
4080
4302
  try {
4081
4303
  if (this.localRunner) await this.localRunner(ctx);
4082
- else if (this.config.localAgentRunner === "mock" || !this.agentServices()) await mockLocalRunner(ctx);
4304
+ else if (this.config.localAgentRunner === "mock") await mockLocalRunner(ctx);
4083
4305
  else await this.runInSession(conn, a, ctx);
4084
4306
  } finally {
4085
4307
  if (wtBefore && before) {
@@ -4123,49 +4345,59 @@ Do what is asked using your tools. Answer in the room by calling collab_send wit
4123
4345
  return root;
4124
4346
  }
4125
4347
  async runInSession(conn, a, ctx, retried = false) {
4348
+ ctx.signal.throwIfAborted();
4126
4349
  const handle = await this.agentSessionFor(conn, a, ctx.cwd);
4350
+ ctx.signal.throwIfAborted();
4127
4351
  const agent = handle.agent;
4128
- if (agent.whenIdle) await Promise.race([agent.whenIdle(), new Promise((r) => setTimeout(r, 3e4))]);
4129
- const seq0 = agent.session?.seq;
4130
- agent.followup(this.notice(conn, ctx.prompt, ctx.trigger?.sender ?? "room", ctx.trigger?.messageId ?? randomUUID2(), `${ctx.mode} \u2192 ${a.name}`));
4131
- if (!agent.whenIdle) return;
4132
- const aborted = new Promise((resolve3) => ctx.signal.addEventListener("abort", () => resolve3(), { once: true }));
4133
- const timeout = new Promise((resolve3) => {
4134
- const t = setTimeout(resolve3, 20 * 6e4);
4135
- t.unref?.();
4136
- });
4137
- const settled = (async () => {
4138
- for (let i = 0; i < 40; i++) {
4139
- await agent.whenIdle();
4140
- if (seq0 === void 0 || agent.session?.seq !== seq0) return;
4141
- await new Promise((r) => setTimeout(r, 250));
4142
- }
4143
- })();
4144
- await Promise.race([settled, aborted, timeout]);
4145
- this.log("info", `run ${a.agentId}: seq ${seq0} -> ${agent.session?.seq}`);
4146
- if (process.env.DSH_ROOM_DEBUG_LOG && seq0 !== void 0) {
4147
- const sess = agent.session;
4148
- for (let i = seq0; i < (sess.seq ?? seq0); i++) this.log("info", ` ev ${i}: ${JSON.stringify(sess.eventAt?.(i)).slice(0, 700)}`);
4149
- }
4150
- if (!ctx.signal.aborted && seq0 !== void 0 && agent.session?.seq === seq0) throw new Error("agent session did not start a turn");
4151
- const failure = !ctx.signal.aborted && seq0 !== void 0 ? this.turnError(agent.session, seq0) : void 0;
4152
- if (failure) {
4153
- const key = `${conn.meta.roomId}:${a.agentId}`;
4154
- if (!retried && failure.code === "UNSUPPORTED_REASONING_EFFORT" && a.reasoningEffort) {
4155
- this.log("warn", `${a.agentId}: ${failure.message}; recreating its session with the model's default effort`);
4156
- this.noEffort.add(key);
4157
- conn.meta.agentNoEffort = [.../* @__PURE__ */ new Set([...conn.meta.agentNoEffort ?? [], a.agentId])];
4158
- this.agentHandles.delete(key);
4159
- if (conn.meta.agentSessions) delete conn.meta.agentSessions[a.agentId];
4160
- return this.runInSession(conn, a, ctx, true);
4161
- }
4162
- throw new Error(failure.message);
4163
- }
4164
- if (ctx.signal.aborted) {
4352
+ const cancel = () => {
4165
4353
  try {
4166
4354
  agent.cancel?.({ kind: "user" });
4167
4355
  } catch {
4168
4356
  }
4357
+ };
4358
+ ctx.signal.addEventListener("abort", cancel, { once: true });
4359
+ try {
4360
+ if (agent.whenIdle) await withTimeout(agent.whenIdle(), 3e4, `session idle ${a.agentId}`, ctx.signal);
4361
+ ctx.signal.throwIfAborted();
4362
+ const seq0 = agent.session?.seq;
4363
+ agent.followup(this.notice(conn, ctx.prompt, ctx.trigger?.sender ?? "room", ctx.trigger?.messageId ?? randomUUID3(), `${ctx.mode} \u2192 ${a.name}`));
4364
+ if (!agent.whenIdle) return;
4365
+ const settled = (async () => {
4366
+ for (let i = 0; i < 40; i++) {
4367
+ ctx.signal.throwIfAborted();
4368
+ await agent.whenIdle();
4369
+ ctx.signal.throwIfAborted();
4370
+ if (seq0 === void 0 || agent.session?.seq !== seq0) return;
4371
+ await sleep2(250, ctx.signal);
4372
+ }
4373
+ })();
4374
+ await withTimeout(settled, 20 * 6e4, `agent turn ${a.agentId}`, ctx.signal);
4375
+ this.log("info", `run ${a.agentId}: seq ${seq0} -> ${agent.session?.seq}`);
4376
+ if (process.env.DSH_ROOM_DEBUG_LOG && seq0 !== void 0) {
4377
+ const sess = agent.session;
4378
+ for (let i = seq0; i < (sess.seq ?? seq0); i++) this.log("info", ` ev ${i}: ${JSON.stringify(sess.eventAt?.(i)).slice(0, 700)}`);
4379
+ }
4380
+ if (!ctx.signal.aborted && seq0 !== void 0 && agent.session?.seq === seq0) throw new Error("agent session did not start a turn");
4381
+ const failure = !ctx.signal.aborted && seq0 !== void 0 ? this.turnError(agent.session, seq0) : void 0;
4382
+ if (failure) {
4383
+ const key = `${conn.meta.roomId}:${a.agentId}`;
4384
+ if (!retried && failure.code === "UNSUPPORTED_REASONING_EFFORT" && this.selectedModel(conn, a).reasoningEffort) {
4385
+ this.log("warn", `${a.agentId}: ${failure.message}; recreating its session with the model's default effort`);
4386
+ this.noEffort.add(key);
4387
+ conn.meta.agentNoEffort = [.../* @__PURE__ */ new Set([...conn.meta.agentNoEffort ?? [], a.agentId])];
4388
+ this.agentHandles.delete(key);
4389
+ if (conn.meta.agentSessions) delete conn.meta.agentSessions[a.agentId];
4390
+ return this.runInSession(conn, a, ctx, true);
4391
+ }
4392
+ throw new Error(failure.message);
4393
+ }
4394
+ } catch (e) {
4395
+ cancel();
4396
+ if (agent.whenIdle) await withTimeout(agent.whenIdle(), 3e4, `agent stop ${a.agentId}`).catch(() => {
4397
+ });
4398
+ throw e;
4399
+ } finally {
4400
+ ctx.signal.removeEventListener("abort", cancel);
4169
4401
  }
4170
4402
  }
4171
4403
  /** Last turn/end error since `from`, if the agent's turn failed (provider/model errors). */
@@ -4203,15 +4435,15 @@ Do what is asked using your tools. Answer in the room by calling collab_send wit
4203
4435
  try {
4204
4436
  const root = conn.meta.memberConfig?.workspacePath?.trim() || this.config.workspacePath;
4205
4437
  const workspace = await svc.workspaceRegistry.create(root, root === this.config.workspacePath ? "collab" : void 0);
4206
- const selected = svc.agentDefaultModel.currentSelection();
4207
- const base = { provider: a.provider ?? selected.provider, model: a.model ?? selected.model };
4208
- const withEffort = a.reasoningEffort && !this.noEffort.has(key) && !conn.meta.agentNoEffort?.includes(a.agentId);
4438
+ const effective = this.selectedModel(conn, a);
4439
+ const base = { provider: effective.provider, model: effective.model };
4440
+ const withEffort = effective.reasoningEffort && !this.noEffort.has(key) && !conn.meta.agentNoEffort?.includes(a.agentId);
4209
4441
  const resume = svc.agents.resume;
4210
4442
  if (prevSid && resume) {
4211
4443
  try {
4212
4444
  const h = await resume.call(svc.agents, {
4213
4445
  resumeSessionId: prevSid,
4214
- agentOptions: withEffort ? { ...base, reasoningEffort: a.reasoningEffort } : base,
4446
+ agentOptions: withEffort ? { ...base, reasoningEffort: effective.reasoningEffort } : base,
4215
4447
  setup: async (agentCtx) => {
4216
4448
  await svc.agentPresets.mount(agentCtx, preset.id);
4217
4449
  }
@@ -4229,7 +4461,7 @@ Do what is asked using your tools. Answer in the room by calling collab_send wit
4229
4461
  this.log("warn", `resume ${prevSid} for ${a.agentId} failed (${e.message}); creating a new session`);
4230
4462
  }
4231
4463
  }
4232
- const sessionId = `room-${conn.meta.roomId.slice(-8)}-${a.id}-${randomUUID2().slice(0, 6)}`;
4464
+ const sessionId = `room-${conn.meta.roomId.slice(-8)}-${a.id}-${randomUUID3().slice(0, 6)}`;
4233
4465
  const create = (agentOptions, cwdPath) => svc.agents.create({
4234
4466
  sessionId,
4235
4467
  signal: this.abort.signal,
@@ -4241,7 +4473,7 @@ Do what is asked using your tools. Answer in the room by calling collab_send wit
4241
4473
  });
4242
4474
  let handle;
4243
4475
  try {
4244
- handle = await create(withEffort ? { ...base, reasoningEffort: a.reasoningEffort } : base, root);
4476
+ handle = await create(withEffort ? { ...base, reasoningEffort: effective.reasoningEffort } : base, root);
4245
4477
  } catch (e) {
4246
4478
  this.log("warn", `session for ${a.agentId} with reasoning effort failed (${e.message}); retrying with defaults`);
4247
4479
  handle = await create(base, root);
@@ -4360,6 +4592,7 @@ Do what is asked, then answer with collab_send replyTo "${p.messageId}".`;
4360
4592
  this.emit({ type: "message", roomId: conn.meta.roomId, message: m });
4361
4593
  this.routeLocal(conn, full);
4362
4594
  }
4595
+ if (conn.local.loopback || conn.local.agents.length) await this.saveLocalHistory(conn);
4363
4596
  return { roomId: conn.meta.roomId, ...r };
4364
4597
  }
4365
4598
  // ── merge view: per-agent worktree diffs ─────────────────────────────────
@@ -4396,7 +4629,7 @@ Do what is asked, then answer with collab_send replyTo "${p.messageId}".`;
4396
4629
  if (!approved) throw new Error("merge needs explicit approval");
4397
4630
  const conn = this.room(roomId);
4398
4631
  const a = this.localAgent(conn, agentId);
4399
- if (this.scheduler.list(conn.meta.roomId).some((r2) => r2.agentId === a.agentId && r2.state === "running")) throw new Error(`${a.name} is still running \u2014 stop it or wait`);
4632
+ if (this.scheduler.isExecuting(conn.meta.roomId, a.agentId)) throw new Error(`${a.name} is still running \u2014 wait for it to finish stopping`);
4400
4633
  const wt = conn.worktrees.get(a.agentId) ?? await this.findWorktree(conn, a);
4401
4634
  if (!wt) throw new Error(`${a.name} has no worktree yet`);
4402
4635
  const r = await mergeWorktree(wt, a.agentId);
@@ -4410,7 +4643,7 @@ Do what is asked, then answer with collab_send replyTo "${p.messageId}".`;
4410
4643
  if (!approved) throw new Error("discard needs explicit approval");
4411
4644
  const conn = this.room(roomId);
4412
4645
  const a = this.localAgent(conn, agentId);
4413
- if (this.scheduler.list(conn.meta.roomId).some((r) => r.agentId === a.agentId && r.state === "running")) throw new Error(`${a.name} is still running \u2014 stop it or wait`);
4646
+ if (this.scheduler.isExecuting(conn.meta.roomId, a.agentId)) throw new Error(`${a.name} is still running \u2014 wait for it to finish stopping`);
4414
4647
  const wt = conn.worktrees.get(a.agentId) ?? await this.findWorktree(conn, a);
4415
4648
  if (!wt) return { ok: true, discarded: false };
4416
4649
  await discardWorktree(wt);
@@ -4444,6 +4677,7 @@ Do what is asked, then answer with collab_send replyTo "${p.messageId}".`;
4444
4677
  const wt = conn.worktrees.get(a.agentId);
4445
4678
  return {
4446
4679
  ...a,
4680
+ resolvedModel: this.resolvedModel(conn, a),
4447
4681
  sessionId: conn.meta.agentSessions?.[a.agentId],
4448
4682
  online: online.has(a.agentId) || !!conn.agents.get(a.agentId)?.isJoined(),
4449
4683
  state: r?.state ?? "idle",
@@ -4469,8 +4703,10 @@ Do what is asked, then answer with collab_send replyTo "${p.messageId}".`;
4469
4703
  for (const conn of this.rooms.values()) {
4470
4704
  if (conn.heartbeat) clearInterval(conn.heartbeat);
4471
4705
  if (conn.presenceTimer) clearInterval(conn.presenceTimer);
4706
+ clearTimeout(conn.announceTimer);
4707
+ clearTimeout(conn.historyTimer);
4472
4708
  this.scheduler.stop(conn.meta.roomId);
4473
- if (conn.local.loopback) await this.saveLocalHistory(conn);
4709
+ if (conn.local.loopback || conn.local.agents.length) await this.saveLocalHistory(conn);
4474
4710
  await this.disconnectAgents(conn);
4475
4711
  await conn.client?.dispose().catch(() => {
4476
4712
  });
@@ -4497,7 +4733,7 @@ Do what is asked, then answer with collab_send replyTo "${p.messageId}".`;
4497
4733
  }
4498
4734
  const who = bridge.callerAgent(exec);
4499
4735
  bridge.log("info", `tool ${name2} by ${who?.agent.agentId ?? "host"}`);
4500
- return await run(a, who) ?? { ok: true };
4736
+ return JSON.parse(JSON.stringify(await run(a, who) ?? { ok: true }));
4501
4737
  }
4502
4738
  });
4503
4739
  const str2 = (v) => typeof v === "string" && v ? v : void 0;
@@ -4559,8 +4795,8 @@ Do what is asked, then answer with collab_send replyTo "${p.messageId}".`;
4559
4795
  ];
4560
4796
  }
4561
4797
  };
4562
- var sleep2 = (ms, signal) => new Promise((resolve3, reject) => {
4563
- const t = setTimeout(resolve3, ms);
4798
+ var sleep2 = (ms, signal) => new Promise((resolve4, reject) => {
4799
+ const t = setTimeout(resolve4, ms);
4564
4800
  signal?.addEventListener("abort", () => {
4565
4801
  clearTimeout(t);
4566
4802
  reject(new Error("stopped"));
@@ -4608,7 +4844,7 @@ Written by ${a.agentId} (${a.name}) at ${(/* @__PURE__ */ new Date()).toISOStrin
4608
4844
 
4609
4845
  // src/config.ts
4610
4846
  import { homedir } from "node:os";
4611
- import { join as join4, isAbsolute as isAbsolute2 } from "node:path";
4847
+ import { join as join4, isAbsolute as isAbsolute3 } from "node:path";
4612
4848
  var AUTO = /* @__PURE__ */ new Set(["off", "mention", "mention-or-task"]);
4613
4849
  function expand(p) {
4614
4850
  return p.startsWith("~/") ? join4(homedir(), p.slice(2)) : p;
@@ -4621,7 +4857,7 @@ function normalizeConfig(raw) {
4621
4857
  const relayUrl = str2("relayUrl") ?? DEFAULT_RELAY_URL;
4622
4858
  if (!/^wss?:\/\//.test(relayUrl)) throw new Error(`dsh-room: relayUrl must be ws:// or wss://, got ${relayUrl}`);
4623
4859
  const workspacePath = expand(str2("workspacePath") ?? "~/.collab/workspace");
4624
- if (!isAbsolute2(workspacePath)) throw new Error("dsh-room: workspacePath must be absolute");
4860
+ if (!isAbsolute3(workspacePath)) throw new Error("dsh-room: workspacePath must be absolute");
4625
4861
  return {
4626
4862
  relayUrl,
4627
4863
  invite: str2("invite"),
@@ -4837,6 +5073,27 @@ async function readJson2(req) {
4837
5073
  }
4838
5074
  var str = (v) => typeof v === "string" && v.trim() ? v.trim() : void 0;
4839
5075
  var strs = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : typeof v === "string" ? v.split(/[\s,]+/).filter(Boolean) : void 0;
5076
+ function peerGrants(value) {
5077
+ const object = (v) => {
5078
+ if (!v || typeof v !== "object" || Array.isArray(v)) throw new HttpError(400, "peerGrants must contain objects");
5079
+ return v;
5080
+ };
5081
+ const grants = (v) => {
5082
+ const input2 = object(v), out = {};
5083
+ for (const cap of ["read", "write", "exec", "delete"]) {
5084
+ if (!(cap in input2)) continue;
5085
+ const grant = input2[cap];
5086
+ if (grant !== "auto" && grant !== "ask" && grant !== "deny") throw new HttpError(400, `invalid peer grant for ${cap}`);
5087
+ out[cap] = grant;
5088
+ }
5089
+ return out;
5090
+ };
5091
+ const input = object(value);
5092
+ return {
5093
+ default: "default" in input ? grants(input.default) : {},
5094
+ byAgent: "byAgent" in input ? Object.fromEntries(Object.entries(object(input.byAgent)).map(([id, v]) => [id, grants(v)])) : {}
5095
+ };
5096
+ }
4840
5097
  function createApiHandler(bridge, connection) {
4841
5098
  return async (req, res) => {
4842
5099
  const conn = connection();
@@ -4960,6 +5217,7 @@ function createApiHandler(bridge, connection) {
4960
5217
  if (k in b) patch[k] = b[k];
4961
5218
  }
4962
5219
  if (b.permissions && typeof b.permissions === "object") patch.permissions = b.permissions;
5220
+ if ("peerGrants" in b) patch.peerGrants = peerGrants(b.peerGrants);
4963
5221
  return sendJson2(res, 200, await bridge.updateMemberConfig(roomId, patch));
4964
5222
  }
4965
5223
  if (method === "GET" && action === "mcp") return sendJson2(res, 200, { tools: (await Promise.resolve().then(() => (init_mcp_workspace(), mcp_workspace_exports))).MCP_TOOL_DEFS });
@@ -4975,7 +5233,7 @@ function createApiHandler(bridge, connection) {
4975
5233
  }
4976
5234
  throw new HttpError(404, `no route ${method} ${API}${path}`);
4977
5235
  } catch (e) {
4978
- const status = e instanceof HttpError ? e.status : /not in room|not in a collab room|no local agent/.test(e.message) ? 404 : /needs explicit approval/.test(e.message) ? 403 : 500;
5236
+ const status = e instanceof HttpError ? e.status : /not in room|not in a collab room|no local agent/.test(e.message) ? 404 : /needs explicit approval|peer grant deny|^denied:/.test(e.message) ? 403 : 500;
4979
5237
  if (!res.headersSent) sendJson2(res, status, { error: e.message });
4980
5238
  }
4981
5239
  };
@@ -5026,6 +5284,9 @@ function apply(ctx, config) {
5026
5284
  ctx.inject?.(["webServer"], (c) => {
5027
5285
  c.effect(() => registerRoutes(bridge, c.webServer, () => c.get("connection")), "dsh-room: /api/collab routes");
5028
5286
  });
5287
+ ctx.inject?.(["llm", "agentDefaultModel"], () => {
5288
+ void bridge.refreshModels().catch((e) => bridge.log("warn", `model catalog: ${e.message}`));
5289
+ });
5029
5290
  bridge.start().catch((e) => bridge.log("error", `start failed: ${e.message}`));
5030
5291
  bridge.log("info", `loaded (relay ${bridge.config.relayUrl}, autoReply ${bridge.config.autoReply}, ${unregister.length} tools)`);
5031
5292
  }