@cecwxf/dsh-room 0.3.2 → 0.3.4

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 });
2037
2130
  return;
2038
2131
  }
2039
- const ok2 = await verifyChallenge(frame.publicKey, s.challenge, frame.challengeResp);
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 });
2135
+ return;
2136
+ }
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
@@ -2768,19 +2915,35 @@ ${HELP}` };
2768
2915
  }
2769
2916
 
2770
2917
  // src/plugin.ts
2918
+ import { createRequire } from "node:module";
2919
+ var requirePkg = createRequire(import.meta.url);
2920
+ var PLUGIN_VERSION = (() => {
2921
+ try {
2922
+ return requirePkg("../package.json").version;
2923
+ } catch {
2924
+ return "0.3.4";
2925
+ }
2926
+ })();
2771
2927
  var SUMMARY_MAX = 120;
2772
2928
  var MESSAGES_MAX = 300;
2773
2929
  var AGENT_SERVICES = ["agents", "agentDefaultModel", "agentPresets", "permissionPresets", "sessionTitle", "workspaceRegistry"];
2774
2930
  var text = (s) => [{ type: "text", text: s }];
2775
2931
  var jsonRender = (_args, value) => text(JSON.stringify(value));
2776
- function withTimeout(p, ms, label) {
2932
+ function withTimeout(p, ms, label, signal) {
2777
2933
  let t;
2934
+ let onAbort;
2778
2935
  const timeout = new Promise((_, rej) => {
2779
2936
  t = setTimeout(() => rej(new Error(`${label} timed out after ${ms}ms`)), ms);
2780
2937
  t.unref?.();
2781
2938
  });
2782
- 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(() => {
2783
2945
  if (t) clearTimeout(t);
2946
+ if (onAbort) signal?.removeEventListener("abort", onAbort);
2784
2947
  });
2785
2948
  }
2786
2949
  async function loadIdentity(path, agentName) {
@@ -2850,6 +3013,7 @@ var RoomConn = class {
2850
3013
  locks;
2851
3014
  git;
2852
3015
  historyTimer;
3016
+ historySave;
2853
3017
  announceTimer;
2854
3018
  normalized;
2855
3019
  /** Normalized once; the same object afterwards so `conn.local.x = …` always sticks. */
@@ -2911,6 +3075,36 @@ var CollabBridge = class {
2911
3075
  sessionAgents = /* @__PURE__ */ new Map();
2912
3076
  /** Override how a local agent run executes (tests / mock provider). */
2913
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
+ }
2914
3108
  log(level, ...a) {
2915
3109
  this.ctx.logger[level]("[collab]", ...a);
2916
3110
  const dbg = process.env.DSH_ROOM_DEBUG_LOG;
@@ -2941,6 +3135,7 @@ var CollabBridge = class {
2941
3135
  start() {
2942
3136
  this.ready ??= (async () => {
2943
3137
  await this.self();
3138
+ await this.refreshModels();
2944
3139
  const st = await loadState(this.config.statePath);
2945
3140
  for (const w of st?.workspaces ?? []) this.workspaces.set(w.id, w);
2946
3141
  if (!this.workspaces.size) {
@@ -2949,14 +3144,26 @@ var CollabBridge = class {
2949
3144
  }
2950
3145
  this.activeWorkspaceId = st?.activeWorkspaceId ?? [...this.workspaces.keys()][0] ?? null;
2951
3146
  const metas = st?.rooms ?? [];
2952
- 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)) {
2953
3148
  const { roomId } = CollabClient.parseInvite(this.config.invite);
2954
3149
  const wsId = this.activeWorkspaceId;
2955
3150
  metas.push({ roomId, invite: this.config.invite, name: roomId, description: "", tags: [], public: false, owner: false, joinedAt: Date.now(), workspaceId: wsId });
2956
3151
  }
2957
3152
  this.activeRoomId = st?.activeRoomId ?? metas[0]?.roomId ?? null;
2958
- await Promise.all(metas.map((m) => this.open(m).catch((e) => this.log("error", `rejoin ${m.roomId} failed: ${e.message}`))));
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
+ }
2959
3158
  await this.persist();
3159
+ this.emit({ type: "rooms" });
3160
+ void Promise.all(
3161
+ metas.map(
3162
+ (m) => withTimeout(this.open(m), 2e4, `rejoin ${m.roomId}`).catch(
3163
+ (e) => this.log("error", `rejoin ${m.roomId} failed: ${e.message}`)
3164
+ )
3165
+ )
3166
+ ).then(() => this.emit({ type: "rooms" }));
2960
3167
  })();
2961
3168
  return this.ready;
2962
3169
  }
@@ -2966,11 +3173,13 @@ var CollabBridge = class {
2966
3173
  // ── room lifecycle ───────────────────────────────────────────────────────
2967
3174
  async open(meta) {
2968
3175
  const existing = this.rooms.get(meta.roomId);
2969
- if (existing) return existing;
2970
- const conn = new RoomConn(meta);
2971
- conn.onAdd = () => this.scheduleHistorySave(conn);
2972
- this.rooms.set(meta.roomId, conn);
2973
- this.emit({ type: "rooms" });
3176
+ if (existing?.client?.isJoined()) return existing;
3177
+ const conn = existing ?? new RoomConn(meta);
3178
+ if (!existing) {
3179
+ conn.onAdd = () => this.scheduleHistorySave(conn);
3180
+ this.rooms.set(meta.roomId, conn);
3181
+ this.emit({ type: "rooms" });
3182
+ }
2974
3183
  try {
2975
3184
  await this.connect(conn);
2976
3185
  } catch (e) {
@@ -2988,7 +3197,7 @@ var CollabBridge = class {
2988
3197
  const roomId = parsed.roomId;
2989
3198
  const roomKeyB64 = parsed.roomKeyB64;
2990
3199
  conn.meta.transport = parsed.mode;
2991
- const wsUrl = inviteWsUrl(parsed);
3200
+ const wsUrl = this.inviteUrl(parsed);
2992
3201
  if (!conn.meta.memberConfig) {
2993
3202
  conn.meta.memberConfig = defaultMemberConfig(this.config.workspacePath);
2994
3203
  }
@@ -3055,18 +3264,25 @@ var CollabBridge = class {
3055
3264
  conn.presenceTimer.unref?.();
3056
3265
  }
3057
3266
  this.log("info", `joined room ${roomId} as ${identity.agentId} via ${loopback ? "in-process loopback" : wsUrl} (${loopback ? "local" : parsed.mode})`);
3058
- if (loopback || conn.local.agents.length) await this.loadLocalHistory(conn);
3267
+ if (loopback || conn.local.agents.length) {
3268
+ await withTimeout(this.loadLocalHistory(conn), 3e3, `local-history ${roomId}`).catch(
3269
+ (e) => this.log("warn", `local history ${roomId}: ${e.message}`)
3270
+ );
3271
+ }
3059
3272
  try {
3060
- for (const p of await client.history(0)) {
3273
+ const hist = await withTimeout(client.history(0), 5e3, `history ${roomId}`);
3274
+ for (const p of hist) {
3061
3275
  const m = toMessage(p, identity.agentId);
3062
3276
  if (m) conn.add(m);
3063
3277
  }
3064
3278
  } catch (e) {
3065
3279
  this.log("warn", `history for ${roomId} failed: ${e.message}`);
3066
3280
  }
3067
- await this.connectAgents(conn).catch((e) => this.log("warn", `local agents for ${roomId} failed: ${e.message}`));
3281
+ await withTimeout(this.connectAgents(conn), loopback ? 8e3 : 2e4, `connectAgents ${roomId}`).catch(
3282
+ (e) => this.log("warn", `local agents for ${roomId} failed: ${e.message}`)
3283
+ );
3068
3284
  try {
3069
- await this.announceMemberConfig(conn);
3285
+ await withTimeout(this.announceMemberConfig(conn), 5e3, `announce ${roomId}`);
3070
3286
  } catch (e) {
3071
3287
  this.log("warn", `announce member.config failed: ${e.message}`);
3072
3288
  }
@@ -3204,7 +3420,13 @@ var CollabBridge = class {
3204
3420
  }
3205
3421
  };
3206
3422
  meta.local = normalizeLocalSettings(meta.local);
3207
- const conn = await withTimeout(this.open(meta), 25e3, "create room join");
3423
+ this.log("info", `createRoom begin name=${meta.name} localOnly=${!!opts.localOnly} agents=${(opts.agents ?? []).length}`);
3424
+ const conn = new RoomConn(meta);
3425
+ conn.onAdd = () => this.scheduleHistorySave(conn);
3426
+ this.rooms.set(meta.roomId, conn);
3427
+ conn.status = "connecting";
3428
+ this.activeRoomId = meta.roomId;
3429
+ this.emit({ type: "rooms" });
3208
3430
  for (const entry of opts.agents ?? []) {
3209
3431
  if (typeof entry === "string") {
3210
3432
  if (AGENT_PRESETS.some((p) => p.key === entry)) await this.addLocalAgent(meta.roomId, { preset: entry }, { connect: false });
@@ -3212,24 +3434,35 @@ var CollabBridge = class {
3212
3434
  await this.addLocalAgent(meta.roomId, entry, { connect: false });
3213
3435
  }
3214
3436
  }
3215
- this.activeRoomId = meta.roomId;
3216
3437
  if (bindTo) this.bind(conn, bindTo);
3217
3438
  await this.persist();
3218
- if (conn.local.agents.length) {
3219
- if (conn.local.loopback) {
3220
- await withTimeout(this.connectAgents(conn), 6e3, "local agents join").catch(
3221
- (e) => this.log("warn", `local agents for ${meta.roomId}: ${e.message}`)
3439
+ this.log("info", `createRoom persisted ${meta.roomId} with ${conn.local.agents.length} agent(s); connecting in background`);
3440
+ void this.finishCreateConnect(conn, meta.public).catch(
3441
+ (e) => this.log("error", `createRoom background connect ${meta.roomId}: ${e.message}`)
3442
+ );
3443
+ return { ...this.view(conn), invite: meta.invite, listed: false };
3444
+ }
3445
+ /** Background: host join + local agent joins + optional directory publish. Never awaited by HTTP create. */
3446
+ async finishCreateConnect(conn, isPublic) {
3447
+ try {
3448
+ await withTimeout(this.connect(conn), conn.local.loopback ? 8e3 : 2e4, `connect ${conn.meta.roomId}`);
3449
+ if (conn.local.agents.length) {
3450
+ await withTimeout(this.connectAgents(conn), conn.local.loopback ? 8e3 : 25e3, `agents ${conn.meta.roomId}`).catch(
3451
+ (e) => this.log("warn", `local agents for ${conn.meta.roomId}: ${e.message}`)
3222
3452
  );
3223
- } else {
3224
- void this.connectAgents(conn).catch((e) => this.log("warn", `background local agents for ${meta.roomId}: ${e.message}`));
3225
3453
  }
3454
+ if (isPublic && conn.meta.owner) {
3455
+ this.startHeartbeat(conn);
3456
+ await this.publish(conn).catch((e) => this.log("warn", `directory publish failed: ${e.message}`));
3457
+ }
3458
+ this.log("info", `createRoom connected ${conn.meta.roomId} status=${conn.status}`);
3459
+ } catch (e) {
3460
+ conn.status = "offline";
3461
+ conn.error = e.message;
3462
+ this.emit({ type: "rooms" });
3463
+ throw e;
3226
3464
  }
3227
- let listing;
3228
- if (meta.public) listing = await this.publish(conn).catch((e) => {
3229
- this.log("warn", `directory publish failed: ${e.message}`);
3230
- return void 0;
3231
- });
3232
- return { ...this.view(conn), invite: meta.invite, listed: !!listing };
3465
+ this.emit({ type: "rooms" });
3233
3466
  }
3234
3467
  /** Join by invite. `info` carries directory metadata when joining from search results. */
3235
3468
  async joinInvite(invite, info = {}, bindTo) {
@@ -3314,6 +3547,7 @@ var CollabBridge = class {
3314
3547
  this.emit({ type: "message", roomId: conn.meta.roomId, message: m });
3315
3548
  this.routeLocal(conn, full);
3316
3549
  }
3550
+ if (conn.local.loopback || conn.local.agents.length) await this.saveLocalHistory(conn);
3317
3551
  return { roomId: conn.meta.roomId, ...r };
3318
3552
  }
3319
3553
  send(roomId, textIn, mentions, replyTo) {
@@ -3382,7 +3616,7 @@ var CollabBridge = class {
3382
3616
  error: conn.error,
3383
3617
  members: (() => {
3384
3618
  const localAgents = conn.local.agents;
3385
- for (const a of localAgents) if (conn.agents.get(a.agentId)?.isJoined()) live.add(a.agentId);
3619
+ for (const a of localAgents) live.add(a.agentId);
3386
3620
  const ids = [...live];
3387
3621
  const cfgs = this.peerConfigs.get(conn.meta.roomId);
3388
3622
  return ids.map((agentId) => {
@@ -3405,7 +3639,7 @@ var CollabBridge = class {
3405
3639
  bound: !!sessionId,
3406
3640
  sessionId,
3407
3641
  transport: conn.local.loopback ? "local" : conn.meta.transport ?? (conn.meta.invite.startsWith("collab://lan/") ? "lan" : "relay"),
3408
- 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,
3409
3643
  peerConfigs: Object.fromEntries(this.peerConfigs.get(conn.meta.roomId) ?? []),
3410
3644
  local: this.localView(conn),
3411
3645
  runs: this.scheduler.list(conn.meta.roomId)
@@ -3413,6 +3647,9 @@ var CollabBridge = class {
3413
3647
  }
3414
3648
  state() {
3415
3649
  return {
3650
+ version: PLUGIN_VERSION,
3651
+ models: this.models,
3652
+ defaultModel: this.resolvedModel(),
3416
3653
  self: this.identity?.agentId ?? null,
3417
3654
  relayUrl: this.config.relayUrl,
3418
3655
  lanRelayPort: this.lanRelay?.port ?? null,
@@ -3443,7 +3680,20 @@ var CollabBridge = class {
3443
3680
  workspacePath: patch.workspacePath ?? cur.workspacePath,
3444
3681
  workspaceLabel: patch.workspaceLabel ?? cur.workspaceLabel
3445
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));
3446
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
+ }
3447
3697
  this.mcpByRoom.get(roomId)?.updateConfig(next);
3448
3698
  await this.persist();
3449
3699
  if (conn.client) await this.announceMemberConfig(conn);
@@ -3621,7 +3871,7 @@ var CollabBridge = class {
3621
3871
  const summary = `collab ${kind} from ${from} in ${conn.meta.name}`.slice(0, SUMMARY_MAX);
3622
3872
  return Object.freeze({
3623
3873
  role: "user",
3624
- id: randomUUID2(),
3874
+ id: randomUUID3(),
3625
3875
  content: Object.freeze([Object.freeze({ type: "text", text: body })]),
3626
3876
  source: Object.freeze({ kind: "collab", roomId: conn.meta.roomId, from, messageId, form: "notice", summary })
3627
3877
  });
@@ -3685,13 +3935,13 @@ Do what is asked using your tools. Answer in the room by calling collab_send wit
3685
3935
  const wsPath = roomPath && roomPath.trim() ? roomPath : cfg.workspacePath;
3686
3936
  await mkdir5(wsPath, { recursive: true });
3687
3937
  const workspace = await svc.workspaceRegistry.create(wsPath, wsPath === cfg.workspacePath ? "collab" : void 0);
3688
- const selected = svc.agentDefaultModel.currentSelection();
3689
- const sessionId = `collab-${roomId}-${randomUUID2().slice(0, 8)}`;
3938
+ const selected = this.selectedModel(conn);
3939
+ const sessionId = `collab-${roomId}-${randomUUID3().slice(0, 8)}`;
3690
3940
  const handle = await svc.agents.create({
3691
3941
  sessionId,
3692
3942
  signal: this.abort.signal,
3693
3943
  meta: { cwd: workspace.path, agentPreset: preset.id },
3694
- agentOptions: { provider: selected.provider, model: selected.model },
3944
+ agentOptions: { provider: selected.provider, model: selected.model, ...selected.reasoningEffort ? { reasoningEffort: selected.reasoningEffort } : {} },
3695
3945
  setup: async (agentCtx) => {
3696
3946
  await svc.agentPresets.mount(agentCtx, preset.id);
3697
3947
  }
@@ -3712,6 +3962,9 @@ Do what is asked using your tools. Answer in the room by calling collab_send wit
3712
3962
  return pending;
3713
3963
  }
3714
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
+ }
3715
3968
  transportFor(conn, wsUrl) {
3716
3969
  return conn.local.loopback ? new MockTransport(this.hub, `loopback:${conn.meta.roomId}`) : new WsTransport(wsUrl);
3717
3970
  }
@@ -3738,7 +3991,7 @@ Do what is asked using your tools. Answer in the room by calling collab_send wit
3738
3991
  identity,
3739
3992
  roomId: parsed.roomId,
3740
3993
  roomKey: parsed.roomKeyB64,
3741
- transport: this.transportFor(conn, inviteWsUrl(parsed)),
3994
+ transport: this.transportFor(conn, this.inviteUrl(parsed)),
3742
3995
  autoReply: "off",
3743
3996
  reconnect: !conn.local.loopback,
3744
3997
  capabilities: ["collab.v1", "dsh", "member.config", "local-agent"]
@@ -3837,13 +4090,11 @@ Do what is asked using your tools. Answer in the room by calling collab_send wit
3837
4090
  const clean = sanitizeAgentInput(input);
3838
4091
  const sessionKeys = ["model", "provider", "reasoningEffort", "scope"];
3839
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");
3840
4094
  if (clean.moderator) for (const x of conn.local.agents) x.moderator = false;
3841
4095
  Object.assign(a, clean, { id: a.id, agentId: a.agentId });
3842
4096
  if (resetSession) {
3843
- this.agentHandles.delete(`${conn.meta.roomId}:${a.agentId}`);
3844
- if (conn.meta.agentSessions) delete conn.meta.agentSessions[a.agentId];
3845
- conn.meta.agentNoEffort = conn.meta.agentNoEffort?.filter((x) => x !== a.agentId);
3846
- this.noEffort.delete(`${conn.meta.roomId}:${a.agentId}`);
4097
+ await this.resetAgentSession(conn, a);
3847
4098
  }
3848
4099
  await this.persist();
3849
4100
  await this.announceAgentConfig(conn, a).catch(() => {
@@ -3901,6 +4152,7 @@ Do what is asked using your tools. Answer in the room by calling collab_send wit
3901
4152
  await conn.client?.dispose().catch(() => {
3902
4153
  });
3903
4154
  conn.client = null;
4155
+ conn.lastSeenPeers.clear();
3904
4156
  conn.local.loopback = wantLoopback;
3905
4157
  conn.status = "connecting";
3906
4158
  this.emit({ type: "rooms" });
@@ -3914,19 +4166,31 @@ Do what is asked using your tools. Answer in the room by calling collab_send wit
3914
4166
  return join3(dirname4(this.config.statePath), "local-history", `${roomId.replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
3915
4167
  }
3916
4168
  scheduleHistorySave(conn) {
3917
- if (!conn.local.loopback || conn.historyTimer) return;
4169
+ if (!conn.local.loopback && !conn.local.agents.length || conn.historyTimer) return;
3918
4170
  conn.historyTimer = setTimeout(() => {
3919
4171
  conn.historyTimer = void 0;
3920
- void this.saveLocalHistory(conn);
4172
+ void this.saveLocalHistory(conn).catch((e) => this.log("error", `save history ${conn.meta.roomId}: ${e.message}`));
3921
4173
  }, 500);
3922
4174
  conn.historyTimer.unref?.();
3923
4175
  }
3924
- async saveLocalHistory(conn) {
4176
+ saveLocalHistory(conn) {
4177
+ clearTimeout(conn.historyTimer);
4178
+ conn.historyTimer = void 0;
3925
4179
  const f = this.historyPath(conn.meta.roomId);
3926
- await mkdir5(dirname4(f), { recursive: true, mode: 448 }).catch(() => {
3927
- });
3928
- 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
+ }
3929
4191
  });
4192
+ conn.historySave = save;
4193
+ return save;
3930
4194
  }
3931
4195
  async loadLocalHistory(conn) {
3932
4196
  try {
@@ -4001,7 +4265,7 @@ Do what is asked using your tools. Answer in the room by calling collab_send wit
4001
4265
  tasks: opts.tasks
4002
4266
  };
4003
4267
  if (a.approvalRequired) {
4004
- 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";
4005
4269
  }
4006
4270
  this.scheduler.enqueue(req);
4007
4271
  }
@@ -4030,13 +4294,14 @@ Do what is asked using your tools. Answer in the room by calling collab_send wit
4030
4294
  conn.runThread.set(a.agentId, run.threadId);
4031
4295
  try {
4032
4296
  const cwd = await this.agentCwd(conn, a);
4297
+ signal.throwIfAborted();
4033
4298
  const prompt = this.buildLocalPrompt(conn, a, mode, req.trigger, run.threadId, cwd, req.tasks);
4034
4299
  const ctx = { bridge: this, roomId: conn.meta.roomId, agent: a, prompt, mode, trigger: req.trigger, threadId: run.threadId, cwd, signal };
4035
4300
  const wtBefore = conn.worktrees.get(a.agentId);
4036
4301
  const before = wtBefore ? await mainStatus(wtBefore.repo).catch(() => null) : null;
4037
4302
  try {
4038
4303
  if (this.localRunner) await this.localRunner(ctx);
4039
- else if (this.config.localAgentRunner === "mock" || !this.agentServices()) await mockLocalRunner(ctx);
4304
+ else if (this.config.localAgentRunner === "mock") await mockLocalRunner(ctx);
4040
4305
  else await this.runInSession(conn, a, ctx);
4041
4306
  } finally {
4042
4307
  if (wtBefore && before) {
@@ -4080,49 +4345,59 @@ Do what is asked using your tools. Answer in the room by calling collab_send wit
4080
4345
  return root;
4081
4346
  }
4082
4347
  async runInSession(conn, a, ctx, retried = false) {
4348
+ ctx.signal.throwIfAborted();
4083
4349
  const handle = await this.agentSessionFor(conn, a, ctx.cwd);
4350
+ ctx.signal.throwIfAborted();
4084
4351
  const agent = handle.agent;
4085
- if (agent.whenIdle) await Promise.race([agent.whenIdle(), new Promise((r) => setTimeout(r, 3e4))]);
4086
- const seq0 = agent.session?.seq;
4087
- agent.followup(this.notice(conn, ctx.prompt, ctx.trigger?.sender ?? "room", ctx.trigger?.messageId ?? randomUUID2(), `${ctx.mode} \u2192 ${a.name}`));
4088
- if (!agent.whenIdle) return;
4089
- const aborted = new Promise((resolve3) => ctx.signal.addEventListener("abort", () => resolve3(), { once: true }));
4090
- const timeout = new Promise((resolve3) => {
4091
- const t = setTimeout(resolve3, 20 * 6e4);
4092
- t.unref?.();
4093
- });
4094
- const settled = (async () => {
4095
- for (let i = 0; i < 40; i++) {
4096
- await agent.whenIdle();
4097
- if (seq0 === void 0 || agent.session?.seq !== seq0) return;
4098
- await new Promise((r) => setTimeout(r, 250));
4099
- }
4100
- })();
4101
- await Promise.race([settled, aborted, timeout]);
4102
- this.log("info", `run ${a.agentId}: seq ${seq0} -> ${agent.session?.seq}`);
4103
- if (process.env.DSH_ROOM_DEBUG_LOG && seq0 !== void 0) {
4104
- const sess = agent.session;
4105
- for (let i = seq0; i < (sess.seq ?? seq0); i++) this.log("info", ` ev ${i}: ${JSON.stringify(sess.eventAt?.(i)).slice(0, 700)}`);
4106
- }
4107
- if (!ctx.signal.aborted && seq0 !== void 0 && agent.session?.seq === seq0) throw new Error("agent session did not start a turn");
4108
- const failure = !ctx.signal.aborted && seq0 !== void 0 ? this.turnError(agent.session, seq0) : void 0;
4109
- if (failure) {
4110
- const key = `${conn.meta.roomId}:${a.agentId}`;
4111
- if (!retried && failure.code === "UNSUPPORTED_REASONING_EFFORT" && a.reasoningEffort) {
4112
- this.log("warn", `${a.agentId}: ${failure.message}; recreating its session with the model's default effort`);
4113
- this.noEffort.add(key);
4114
- conn.meta.agentNoEffort = [.../* @__PURE__ */ new Set([...conn.meta.agentNoEffort ?? [], a.agentId])];
4115
- this.agentHandles.delete(key);
4116
- if (conn.meta.agentSessions) delete conn.meta.agentSessions[a.agentId];
4117
- return this.runInSession(conn, a, ctx, true);
4118
- }
4119
- throw new Error(failure.message);
4120
- }
4121
- if (ctx.signal.aborted) {
4352
+ const cancel = () => {
4122
4353
  try {
4123
4354
  agent.cancel?.({ kind: "user" });
4124
4355
  } catch {
4125
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);
4126
4401
  }
4127
4402
  }
4128
4403
  /** Last turn/end error since `from`, if the agent's turn failed (provider/model errors). */
@@ -4160,15 +4435,15 @@ Do what is asked using your tools. Answer in the room by calling collab_send wit
4160
4435
  try {
4161
4436
  const root = conn.meta.memberConfig?.workspacePath?.trim() || this.config.workspacePath;
4162
4437
  const workspace = await svc.workspaceRegistry.create(root, root === this.config.workspacePath ? "collab" : void 0);
4163
- const selected = svc.agentDefaultModel.currentSelection();
4164
- const base = { provider: a.provider ?? selected.provider, model: a.model ?? selected.model };
4165
- 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);
4166
4441
  const resume = svc.agents.resume;
4167
4442
  if (prevSid && resume) {
4168
4443
  try {
4169
4444
  const h = await resume.call(svc.agents, {
4170
4445
  resumeSessionId: prevSid,
4171
- agentOptions: withEffort ? { ...base, reasoningEffort: a.reasoningEffort } : base,
4446
+ agentOptions: withEffort ? { ...base, reasoningEffort: effective.reasoningEffort } : base,
4172
4447
  setup: async (agentCtx) => {
4173
4448
  await svc.agentPresets.mount(agentCtx, preset.id);
4174
4449
  }
@@ -4186,7 +4461,7 @@ Do what is asked using your tools. Answer in the room by calling collab_send wit
4186
4461
  this.log("warn", `resume ${prevSid} for ${a.agentId} failed (${e.message}); creating a new session`);
4187
4462
  }
4188
4463
  }
4189
- 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)}`;
4190
4465
  const create = (agentOptions, cwdPath) => svc.agents.create({
4191
4466
  sessionId,
4192
4467
  signal: this.abort.signal,
@@ -4198,7 +4473,7 @@ Do what is asked using your tools. Answer in the room by calling collab_send wit
4198
4473
  });
4199
4474
  let handle;
4200
4475
  try {
4201
- handle = await create(withEffort ? { ...base, reasoningEffort: a.reasoningEffort } : base, root);
4476
+ handle = await create(withEffort ? { ...base, reasoningEffort: effective.reasoningEffort } : base, root);
4202
4477
  } catch (e) {
4203
4478
  this.log("warn", `session for ${a.agentId} with reasoning effort failed (${e.message}); retrying with defaults`);
4204
4479
  handle = await create(base, root);
@@ -4317,6 +4592,7 @@ Do what is asked, then answer with collab_send replyTo "${p.messageId}".`;
4317
4592
  this.emit({ type: "message", roomId: conn.meta.roomId, message: m });
4318
4593
  this.routeLocal(conn, full);
4319
4594
  }
4595
+ if (conn.local.loopback || conn.local.agents.length) await this.saveLocalHistory(conn);
4320
4596
  return { roomId: conn.meta.roomId, ...r };
4321
4597
  }
4322
4598
  // ── merge view: per-agent worktree diffs ─────────────────────────────────
@@ -4353,7 +4629,7 @@ Do what is asked, then answer with collab_send replyTo "${p.messageId}".`;
4353
4629
  if (!approved) throw new Error("merge needs explicit approval");
4354
4630
  const conn = this.room(roomId);
4355
4631
  const a = this.localAgent(conn, agentId);
4356
- 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`);
4357
4633
  const wt = conn.worktrees.get(a.agentId) ?? await this.findWorktree(conn, a);
4358
4634
  if (!wt) throw new Error(`${a.name} has no worktree yet`);
4359
4635
  const r = await mergeWorktree(wt, a.agentId);
@@ -4367,7 +4643,7 @@ Do what is asked, then answer with collab_send replyTo "${p.messageId}".`;
4367
4643
  if (!approved) throw new Error("discard needs explicit approval");
4368
4644
  const conn = this.room(roomId);
4369
4645
  const a = this.localAgent(conn, agentId);
4370
- 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`);
4371
4647
  const wt = conn.worktrees.get(a.agentId) ?? await this.findWorktree(conn, a);
4372
4648
  if (!wt) return { ok: true, discarded: false };
4373
4649
  await discardWorktree(wt);
@@ -4401,6 +4677,7 @@ Do what is asked, then answer with collab_send replyTo "${p.messageId}".`;
4401
4677
  const wt = conn.worktrees.get(a.agentId);
4402
4678
  return {
4403
4679
  ...a,
4680
+ resolvedModel: this.resolvedModel(conn, a),
4404
4681
  sessionId: conn.meta.agentSessions?.[a.agentId],
4405
4682
  online: online.has(a.agentId) || !!conn.agents.get(a.agentId)?.isJoined(),
4406
4683
  state: r?.state ?? "idle",
@@ -4426,8 +4703,10 @@ Do what is asked, then answer with collab_send replyTo "${p.messageId}".`;
4426
4703
  for (const conn of this.rooms.values()) {
4427
4704
  if (conn.heartbeat) clearInterval(conn.heartbeat);
4428
4705
  if (conn.presenceTimer) clearInterval(conn.presenceTimer);
4706
+ clearTimeout(conn.announceTimer);
4707
+ clearTimeout(conn.historyTimer);
4429
4708
  this.scheduler.stop(conn.meta.roomId);
4430
- if (conn.local.loopback) await this.saveLocalHistory(conn);
4709
+ if (conn.local.loopback || conn.local.agents.length) await this.saveLocalHistory(conn);
4431
4710
  await this.disconnectAgents(conn);
4432
4711
  await conn.client?.dispose().catch(() => {
4433
4712
  });
@@ -4454,7 +4733,7 @@ Do what is asked, then answer with collab_send replyTo "${p.messageId}".`;
4454
4733
  }
4455
4734
  const who = bridge.callerAgent(exec);
4456
4735
  bridge.log("info", `tool ${name2} by ${who?.agent.agentId ?? "host"}`);
4457
- return await run(a, who) ?? { ok: true };
4736
+ return JSON.parse(JSON.stringify(await run(a, who) ?? { ok: true }));
4458
4737
  }
4459
4738
  });
4460
4739
  const str2 = (v) => typeof v === "string" && v ? v : void 0;
@@ -4516,8 +4795,8 @@ Do what is asked, then answer with collab_send replyTo "${p.messageId}".`;
4516
4795
  ];
4517
4796
  }
4518
4797
  };
4519
- var sleep2 = (ms, signal) => new Promise((resolve3, reject) => {
4520
- const t = setTimeout(resolve3, ms);
4798
+ var sleep2 = (ms, signal) => new Promise((resolve4, reject) => {
4799
+ const t = setTimeout(resolve4, ms);
4521
4800
  signal?.addEventListener("abort", () => {
4522
4801
  clearTimeout(t);
4523
4802
  reject(new Error("stopped"));
@@ -4565,7 +4844,7 @@ Written by ${a.agentId} (${a.name}) at ${(/* @__PURE__ */ new Date()).toISOStrin
4565
4844
 
4566
4845
  // src/config.ts
4567
4846
  import { homedir } from "node:os";
4568
- import { join as join4, isAbsolute as isAbsolute2 } from "node:path";
4847
+ import { join as join4, isAbsolute as isAbsolute3 } from "node:path";
4569
4848
  var AUTO = /* @__PURE__ */ new Set(["off", "mention", "mention-or-task"]);
4570
4849
  function expand(p) {
4571
4850
  return p.startsWith("~/") ? join4(homedir(), p.slice(2)) : p;
@@ -4578,7 +4857,7 @@ function normalizeConfig(raw) {
4578
4857
  const relayUrl = str2("relayUrl") ?? DEFAULT_RELAY_URL;
4579
4858
  if (!/^wss?:\/\//.test(relayUrl)) throw new Error(`dsh-room: relayUrl must be ws:// or wss://, got ${relayUrl}`);
4580
4859
  const workspacePath = expand(str2("workspacePath") ?? "~/.collab/workspace");
4581
- 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");
4582
4861
  return {
4583
4862
  relayUrl,
4584
4863
  invite: str2("invite"),
@@ -4794,6 +5073,27 @@ async function readJson2(req) {
4794
5073
  }
4795
5074
  var str = (v) => typeof v === "string" && v.trim() ? v.trim() : void 0;
4796
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
+ }
4797
5097
  function createApiHandler(bridge, connection) {
4798
5098
  return async (req, res) => {
4799
5099
  const conn = connection();
@@ -4810,6 +5110,7 @@ function createApiHandler(bridge, connection) {
4810
5110
  await bridge.start();
4811
5111
  if (method === "GET" && path === "/stream") return stream(bridge, req, res);
4812
5112
  if (method === "GET" && path === "/state") return sendJson2(res, 200, bridge.state());
5113
+ if (method === "GET" && path === "/version") return sendJson2(res, 200, { name: "@cecwxf/dsh-room", version: bridge.state().version ?? "unknown" });
4813
5114
  if (method === "GET" && path === "/directory") return sendJson2(res, 200, { rooms: await bridge.search(str(url.searchParams.get("q")), str(url.searchParams.get("tag"))) });
4814
5115
  if (method === "POST" && path === "/rooms") {
4815
5116
  const b = await readJson2(req);
@@ -4916,6 +5217,7 @@ function createApiHandler(bridge, connection) {
4916
5217
  if (k in b) patch[k] = b[k];
4917
5218
  }
4918
5219
  if (b.permissions && typeof b.permissions === "object") patch.permissions = b.permissions;
5220
+ if ("peerGrants" in b) patch.peerGrants = peerGrants(b.peerGrants);
4919
5221
  return sendJson2(res, 200, await bridge.updateMemberConfig(roomId, patch));
4920
5222
  }
4921
5223
  if (method === "GET" && action === "mcp") return sendJson2(res, 200, { tools: (await Promise.resolve().then(() => (init_mcp_workspace(), mcp_workspace_exports))).MCP_TOOL_DEFS });
@@ -4931,7 +5233,7 @@ function createApiHandler(bridge, connection) {
4931
5233
  }
4932
5234
  throw new HttpError(404, `no route ${method} ${API}${path}`);
4933
5235
  } catch (e) {
4934
- 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;
4935
5237
  if (!res.headersSent) sendJson2(res, status, { error: e.message });
4936
5238
  }
4937
5239
  };
@@ -4982,6 +5284,9 @@ function apply(ctx, config) {
4982
5284
  ctx.inject?.(["webServer"], (c) => {
4983
5285
  c.effect(() => registerRoutes(bridge, c.webServer, () => c.get("connection")), "dsh-room: /api/collab routes");
4984
5286
  });
5287
+ ctx.inject?.(["llm", "agentDefaultModel"], () => {
5288
+ void bridge.refreshModels().catch((e) => bridge.log("warn", `model catalog: ${e.message}`));
5289
+ });
4985
5290
  bridge.start().catch((e) => bridge.log("error", `start failed: ${e.message}`));
4986
5291
  bridge.log("info", `loaded (relay ${bridge.config.relayUrl}, autoReply ${bridge.config.autoReply}, ${unregister.length} tools)`);
4987
5292
  }
@@ -4989,6 +5294,7 @@ export {
4989
5294
  AGENT_PRESETS,
4990
5295
  CollabBridge,
4991
5296
  Config,
5297
+ PLUGIN_VERSION,
4992
5298
  apply,
4993
5299
  inject,
4994
5300
  mockLocalRunner,