pi-straw-boss 0.58.3 → 0.59.1

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.
@@ -1923,9 +1923,25 @@ function allyPort(link) {
1923
1923
  async taken(id, identity2) {
1924
1924
  await link()?.holder?.taken(id, identity2);
1925
1925
  },
1926
+ delivered(id, run3) {
1927
+ link()?.holder?.delivered(id, run3);
1928
+ },
1929
+ // A device is taken as gone only while the box can tell who is in the team: the policy file reads (a refused one answers `reply` for everybody, g346-F003) and the holder reads the roster. A team the unattended policy
1930
+ // no longer names has no holder run and never will: nobody is in it, so its controllers are gone (g346-F005b).
1931
+ known(team) {
1932
+ const service = link();
1933
+ if (!service) return false;
1934
+ const { mode, config } = service.policyLook();
1935
+ if (mode === "refused") return false;
1936
+ if (mode === "unattended" && !config.teams.has(team)) return service.holder?.active === true;
1937
+ return service.holder?.rosterKnown(team) ?? false;
1938
+ },
1926
1939
  standing(device, team) {
1927
1940
  const service = link();
1928
- return service?.holder?.isMember(team, device) ? { level: service.levelOf(device) } : void 0;
1941
+ if (!service) return void 0;
1942
+ const { mode, config } = service.policyLook();
1943
+ if (mode === "unattended" && !config.teams.has(team)) return void 0;
1944
+ return service.holder?.isMember(team, device) ? { level: service.levelOf(device) } : void 0;
1929
1945
  },
1930
1946
  project: (team) => link()?.config().teams.get(team)?.project,
1931
1947
  thread: (device) => link()?.store.readThread(device).slice(-THREAD_ROWS).map((r) => ({ dir: r.dir, at: r.at, kind: r.kind, text: r.text })) ?? []
@@ -8017,7 +8033,12 @@ function createGuiHost(o) {
8017
8033
  let forwardNet;
8018
8034
  return {
8019
8035
  /** The hub and a net reading that binds its dialogs, for the forwarder an unattended device runs. */
8020
- dialogs: () => ({ hub: theHub(), read: async () => void await (forwardNet ??= netSource({ ...o.tower ? { services: (workspace) => o.tower.view(workspace) } : {}, run: cfg.run, world: cfg.world, sessions, hub: theHub() })).read() }),
8036
+ dialogs: () => ({
8037
+ hub: theHub(),
8038
+ read: async () => void await (forwardNet ??= netSource({ ...o.tower ? { services: (workspace) => o.tower.view(workspace) } : {}, run: cfg.run, world: cfg.world, sessions, hub: theHub() })).read(),
8039
+ /** The unit the hub files dialogs under `key` (known after a `read`): its pane for the dispatcher, its name and role for the label a controller is told. */
8040
+ lookup: (key2) => forwardNet?.lookup(key2)
8041
+ }),
8021
8042
  /** At the server's start: the GUI on the recorded ports unless it was stopped on purpose (which only the boss's own command undoes). */
8022
8043
  async start() {
8023
8044
  theHub().track();
@@ -8411,12 +8432,23 @@ function decodePlaintext(kind, bytes) {
8411
8432
  }
8412
8433
  }
8413
8434
 
8435
+ // src/link/scope.ts
8436
+ var EVERYONE = "all";
8437
+ function reaches(scope, c, admin) {
8438
+ if (scope === "all") return true;
8439
+ if (admin(c.team, c.device) && (scope.team === void 0 || scope.team === c.team)) return true;
8440
+ return scope.device !== void 0 && scope.device === c.device && (scope.team === void 0 || scope.team === c.team);
8441
+ }
8442
+
8414
8443
  // src/link/dialogs-out.ts
8415
8444
  var MAX_OPEN2 = 32;
8416
8445
  var MAX_SEEN = 1024;
8417
8446
  var UNANSWERED_MS = 30 * 6e4;
8418
8447
  var REFRESH_MS = 10 * 6e4;
8419
8448
  var UNKNOWN_RECHECK_MS = 15e3;
8449
+ var PASS_STUCK_MS = 6e4;
8450
+ var CLOSE_TRIES = 5;
8451
+ var MAX_CLOSES = 256;
8420
8452
  var DialogForwarder = class {
8421
8453
  d;
8422
8454
  open = /* @__PURE__ */ new Map();
@@ -8424,12 +8456,29 @@ var DialogForwarder = class {
8424
8456
  seen = /* @__PURE__ */ new Set();
8425
8457
  off;
8426
8458
  polling;
8459
+ /** The closes not posted yet, per controller and dialog, drained by every pass until each is posted. */
8460
+ closes = /* @__PURE__ */ new Map();
8461
+ /** The passes let go as stuck that have not returned; at most one is outstanding, so a call that never returns costs one pending pass and not one a minute. */
8462
+ released = /* @__PURE__ */ new Set();
8463
+ /** The line that a second stuck pass is not let go was written for this stretch. */
8464
+ hung = false;
8427
8465
  again = false;
8466
+ /** The pass that counts: a pass let go as stuck has an older number and stops at its next turn. */
8467
+ gen = 0;
8468
+ /** When the pass in flight began, and the call it waits at (`dialog-pass-stuck` names it). */
8469
+ passAt = 0;
8470
+ step = "idle";
8471
+ /** What the last `dialog-controllers` line said, so a line is written when it changes and not at every pass. */
8472
+ counted;
8473
+ /** The room for open questions was used up, and the line that says so was written. */
8474
+ full = false;
8428
8475
  constructor(d) {
8429
8476
  this.d = d;
8430
8477
  }
8431
8478
  start() {
8432
- this.off ??= this.d.hub.onChange(() => void this.poll());
8479
+ if (this.off) return;
8480
+ this.off = this.d.hub.onChange(() => void this.poll());
8481
+ this.d.log({ event: "dialog-forwarder", state: "started" });
8433
8482
  }
8434
8483
  stop() {
8435
8484
  this.off?.();
@@ -8439,25 +8488,69 @@ var DialogForwarder = class {
8439
8488
  tick() {
8440
8489
  return this.poll();
8441
8490
  }
8442
- /** Looks at the hub now; calls that overlap run once more after the one in flight. */
8491
+ /** Looks at the hub now; calls that overlap run once more after the one in flight, and a pass in flight for `PASS_STUCK_MS` is let go (its next look is a new pass). */
8443
8492
  poll() {
8444
- if (this.polling) return this.again = true, this.polling;
8493
+ if (this.polling) {
8494
+ if (this.now() - this.passAt < PASS_STUCK_MS) return this.again = true, this.polling;
8495
+ if (this.released.size) {
8496
+ if (!this.hung) this.d.log({ event: "dialog-pass-hung", step: this.step, ms: this.now() - this.passAt });
8497
+ this.hung = true;
8498
+ return this.again = true, this.polling;
8499
+ }
8500
+ this.d.log({ event: "dialog-pass-stuck", step: this.step, ms: this.now() - this.passAt });
8501
+ this.gen++;
8502
+ this.released.add(this.polling);
8503
+ this.polling = void 0;
8504
+ for (const d of this.open.values()) d.sent.clear();
8505
+ }
8506
+ const gen = this.gen;
8445
8507
  const run3 = (async () => {
8446
8508
  do {
8447
8509
  this.again = false;
8510
+ this.passAt = this.now();
8448
8511
  try {
8449
- await this.pass();
8512
+ await this.pass(gen);
8450
8513
  } catch (error) {
8451
- this.d.log({ event: "dialog-forward-failed", error: String(error.message).slice(0, 200) });
8514
+ this.d.log({ event: "dialog-forward-failed", step: this.step, error: String(error.message).slice(0, 200) });
8452
8515
  }
8453
- } while (this.again);
8454
- })().finally(() => this.polling = void 0);
8516
+ } while (this.again && gen === this.gen);
8517
+ })().finally(() => {
8518
+ if (this.polling === run3) this.polling = void 0;
8519
+ this.released.delete(run3);
8520
+ if (!this.released.size) this.hung = false;
8521
+ });
8455
8522
  return this.polling = run3;
8456
8523
  }
8457
- async pass() {
8524
+ /** Whose unit asked: looked up until it is known, then kept. A lookup that fails leaves it unknown, which is the admins' alone. */
8525
+ async whose(d) {
8526
+ if (!this.d.dispatcher || d.who) return;
8527
+ d.who = await this.d.dispatcher(d.key).catch(() => void 0);
8528
+ }
8529
+ /** What the question's controllers are: every controller when the box cannot tell whose units are whose, else the dispatcher and the admins. */
8530
+ scopeOf(d) {
8531
+ return this.d.dispatcher ? d.who ? { device: d.who.device, ...d.who.team !== void 0 ? { team: d.who.team } : {} } : {} : EVERYONE;
8532
+ }
8533
+ /** Records where the pass in flight waits (`dialog-pass-stuck` names it); a pass let go as stuck no longer writes it. */
8534
+ at(gen, step) {
8535
+ if (gen === this.gen) this.step = step;
8536
+ }
8537
+ /** One line when the controllers the forwarder sees change in number or in whether the list is complete, so a box with nobody to tell says so once (and the first pass says how it starts). */
8538
+ count(controllers, known2) {
8539
+ const line2 = `${controllers}/${known2}`;
8540
+ if (line2 === this.counted) return;
8541
+ this.counted = line2;
8542
+ this.d.log({ event: "dialog-controllers", count: controllers, known: known2, open: this.open.size });
8543
+ }
8544
+ async pass(gen) {
8458
8545
  const to = this.d.controllers();
8546
+ this.count(to.length, this.d.controllersKnown?.() ?? true);
8547
+ if (this.closes.size && !await this.drain(gen)) return;
8459
8548
  if (!to.length && !this.open.size) return;
8460
- if (to.length) await this.d.read();
8549
+ if (to.length) {
8550
+ this.at(gen, "read");
8551
+ await this.d.read();
8552
+ if (gen !== this.gen) return;
8553
+ }
8461
8554
  for (const o of to.length ? this.d.hub.answerable() : []) {
8462
8555
  const id = o.target.answer.dialog;
8463
8556
  if (this.open.has(id) || this.skipped.has(id)) continue;
@@ -8465,17 +8558,33 @@ var DialogForwarder = class {
8465
8558
  if (Buffer.byteLength(text5) > LIMITS2.dialog) {
8466
8559
  this.skipped.add(id);
8467
8560
  this.d.log({ event: "dialog-oversize", dialog: id, bytes: Buffer.byteLength(text5) });
8468
- await this.d.notice?.(`oversize/${id}`, { kind: "dialog-oversize", unit: o.key, text: `A question of ${o.owner} was not forwarded: it is ${Buffer.byteLength(text5)} bytes, over the ${LIMITS2.dialog} the link carries. It waits at the device.` });
8561
+ const unit = { key: o.key };
8562
+ this.at(gen, "dispatcher");
8563
+ await this.whose(unit);
8564
+ this.at(gen, "notice");
8565
+ await this.d.notice?.(`oversize/${id}`, { kind: "dialog-oversize", unit: o.key, text: `A question of ${this.labelOf(o.key, o.owner)} was not forwarded: it is ${Buffer.byteLength(text5)} bytes, over the ${LIMITS2.dialog} the link carries. It waits at the device.` }, this.scopeOf(unit));
8566
+ if (gen !== this.gen) return;
8567
+ continue;
8568
+ }
8569
+ if (this.open.size >= MAX_OPEN2) {
8570
+ if (!this.full) this.d.log({ event: "dialog-full", open: this.open.size });
8571
+ this.full = true;
8469
8572
  continue;
8470
8573
  }
8471
- if (this.open.size >= MAX_OPEN2) continue;
8472
- this.open.set(id, { key: o.key, target: o.target, owner: o.owner, text: text5, sent: /* @__PURE__ */ new Map(), away: /* @__PURE__ */ new Set(), claimed: false, since: this.now(), told: false });
8574
+ this.open.set(id, { key: o.key, target: o.target, owner: o.owner, label: this.labelOf(o.key, o.owner), text: text5, sent: /* @__PURE__ */ new Map(), away: /* @__PURE__ */ new Set(), claimed: false, since: this.now(), told: false });
8575
+ this.d.log({ event: "dialog-seen", dialog: id, unit: o.key, owner: o.owner });
8473
8576
  }
8474
8577
  for (const [id, d] of [...this.open]) {
8578
+ this.at(gen, "dispatcher");
8579
+ await this.whose(d);
8580
+ if (gen !== this.gen) return;
8475
8581
  const state = this.d.hub.outcome(id);
8476
8582
  if (!state.open) {
8477
8583
  this.open.delete(id);
8478
- for (const k of d.sent.keys()) await this.send(k, "dialog-close", { dialogId: id, by: state.by });
8584
+ this.full = false;
8585
+ this.d.log({ event: "dialog-ended", dialog: id, by: state.by, sent: d.sent.size });
8586
+ for (const k of d.sent.keys()) this.hold(k, id, state.by);
8587
+ if (!await this.drain(gen)) return;
8479
8588
  continue;
8480
8589
  }
8481
8590
  if (d.claimed && d.unknownAt !== void 0 && this.now() - d.unknownAt >= UNKNOWN_RECHECK_MS) {
@@ -8483,17 +8592,25 @@ var DialogForwarder = class {
8483
8592
  d.unknownAt = void 0;
8484
8593
  d.sent.clear();
8485
8594
  }
8595
+ const mine = to.filter((c) => reaches(this.scopeOf(d), c, this.d.admin ?? (() => false)));
8486
8596
  for (const k of this.d.controllersKnown?.() === false ? [] : [...d.sent.keys()]) {
8487
- if (to.some((c) => `${c.team}
8597
+ if (mine.some((c) => `${c.team}
8488
8598
  ${c.device}` === k)) continue;
8489
8599
  d.sent.delete(k);
8490
- await this.send(k, "dialog-close", { dialogId: id, by: "withdrawn" });
8600
+ this.hold(k, id, "withdrawn");
8601
+ if (!await this.drain(gen)) return;
8491
8602
  }
8492
8603
  if (!d.told && !d.claimed && to.length && this.now() - d.since >= UNANSWERED_MS) {
8493
- d.told = true;
8494
- await this.d.notice?.(`unanswered/${id}`, { kind: "dialog-unanswered", unit: d.key, text: `A question of ${d.owner} has waited ${Math.round((this.now() - d.since) / 6e4)} minutes for an answer.` });
8604
+ this.at(gen, "notice");
8605
+ const sent = await this.d.notice?.(`unanswered/${id}`, { kind: "dialog-unanswered", unit: d.key, text: `A question of ${d.label} has waited ${Math.round((this.now() - d.since) / 6e4)} minutes for an answer.` }, this.scopeOf(d));
8606
+ if (gen !== this.gen) return;
8607
+ d.told = sent !== false;
8608
+ }
8609
+ if (!mine.length && !d.unrouted) {
8610
+ d.unrouted = true;
8611
+ this.d.log({ event: "dialog-unrouted", dialog: id, unit: d.key, owner: d.owner });
8495
8612
  }
8496
- for (const c of to) {
8613
+ for (const c of mine) {
8497
8614
  const k = `${c.team}
8498
8615
  ${c.device}`;
8499
8616
  const at = d.sent.get(k), up = this.d.online?.(c.team, c.device) ?? true;
@@ -8502,16 +8619,53 @@ ${c.device}`;
8502
8619
  if (at !== void 0) continue;
8503
8620
  } else if (at !== void 0 && !d.away.delete(k) && this.now() - at < REFRESH_MS) continue;
8504
8621
  d.sent.set(k, this.now());
8505
- if (!await this.send(k, "dialog-open", { dialogId: id, digest: d.target.answer.digest, owner: d.owner, dialog: d.text })) d.sent.delete(k);
8622
+ this.at(gen, "post");
8623
+ const posted = await this.send(k, "dialog-open", { dialogId: id, digest: d.target.answer.digest, owner: d.label, dialog: d.text });
8624
+ if (gen !== this.gen) return;
8625
+ if (!posted) d.sent.delete(k);
8506
8626
  }
8507
8627
  }
8508
8628
  for (const id of [...this.skipped]) if (!this.d.hub.outcome(id).open) this.skipped.delete(id);
8509
8629
  }
8630
+ /** Keeps a close to post: the oldest is given up when the room is used up. */
8631
+ hold(k, dialogId, by) {
8632
+ this.closes.set(`${k}
8633
+ ${dialogId}`, { k, dialogId, by, at: this.now(), tries: 0 });
8634
+ for (const [key2, c] of this.closes) {
8635
+ if (this.closes.size <= MAX_CLOSES) break;
8636
+ this.closes.delete(key2);
8637
+ this.d.log({ event: "dialog-close-dropped", dialog: c.dialogId, reason: "full" });
8638
+ }
8639
+ }
8640
+ /** Posts the held closes, each removed once posted. One that failed `CLOSE_TRIES` times, or whose card has expired at the controller (`OPEN_STALE_MS`), is given up with a line. False when this pass was let go meanwhile. */
8641
+ async drain(gen) {
8642
+ for (const [key2, c] of [...this.closes]) {
8643
+ if (this.now() - c.at > OPEN_STALE_MS) {
8644
+ this.closes.delete(key2);
8645
+ this.d.log({ event: "dialog-close-dropped", dialog: c.dialogId, reason: "stale" });
8646
+ continue;
8647
+ }
8648
+ this.at(gen, "post");
8649
+ const posted = await this.send(c.k, "dialog-close", { dialogId: c.dialogId, by: c.by });
8650
+ if (posted) this.closes.delete(key2);
8651
+ else if (++c.tries >= CLOSE_TRIES) {
8652
+ this.closes.delete(key2);
8653
+ this.d.log({ event: "dialog-close-dropped", dialog: c.dialogId, reason: "failed" });
8654
+ }
8655
+ if (gen !== this.gen) return false;
8656
+ }
8657
+ return true;
8658
+ }
8510
8659
  now = () => this.d.now?.() ?? Date.now();
8660
+ /** What the controllers are told asks: the unit's name when it is known, else the owner of the dialog. */
8661
+ labelOf(key2, owner) {
8662
+ return this.d.unit?.(key2) || owner;
8663
+ }
8511
8664
  async send(k, kind, payload) {
8512
8665
  const [team, device] = k.split("\n");
8513
8666
  try {
8514
8667
  await this.d.post(team, device, kind, payload);
8668
+ this.d.log({ event: "dialog-sent", kind, dialog: payload.dialogId, team, to: device });
8515
8669
  return true;
8516
8670
  } catch (error) {
8517
8671
  this.d.log({ event: "dialog-post-failed", kind, dialog: payload.dialogId, error: String(error.message).slice(0, 200) });
@@ -8532,6 +8686,8 @@ ${device}`);
8532
8686
  const drop = (reason) => this.d.log({ event: "drop", reason, kind: "dialog-answer", from: a.from, dialog: a.dialogId });
8533
8687
  const d = this.open.get(a.dialogId);
8534
8688
  if (!d) return drop("dialog-answer-unknown");
8689
+ await this.whose(d);
8690
+ if (!reaches(this.scopeOf(d), { team: a.team, device: a.from }, this.d.admin ?? (() => false))) return drop("dialog-answer-not-recipient");
8535
8691
  if (d.target.answer.digest !== a.digest) return drop("dialog-answer-digest");
8536
8692
  if (!ANSWER_ID2.test(a.answerId) || this.seen.has(a.answerId)) return drop("dialog-answer-id");
8537
8693
  this.seen.add(a.answerId);
@@ -8597,6 +8753,33 @@ ${dialogId}`;
8597
8753
  };
8598
8754
  var newAnswerId = () => randomBytes12(16).toString("hex");
8599
8755
 
8756
+ // src/server/dispatcher.ts
8757
+ function dispatcherOf(pane, listed2, allyPanes) {
8758
+ const mine = listed2.find((p) => p.pane_id === pane);
8759
+ if (!mine) return void 0;
8760
+ const ally = allyPanes.find((a) => a.pane === pane);
8761
+ if (ally) return { device: ally.device, ...ally.team ? { team: ally.team } : {} };
8762
+ const device = listed2.find((p) => p.tab_id === mine.tab_id && p.tokens?.sb_opened_by?.startsWith("link:"))?.tokens?.sb_opened_by?.replace(/^(link:)+/, "");
8763
+ return device && /^[a-z2-7]{26}$/.test(device) ? { device } : void 0;
8764
+ }
8765
+ function dispatcherLookup(d) {
8766
+ let listed2;
8767
+ return async (key2) => {
8768
+ const pane = d.paneOf(key2);
8769
+ if (!pane) return void 0;
8770
+ if (!listed2 || d.now() - listed2.at > 2e3) listed2 = { at: d.now(), panes: d.list().catch(() => void 0) };
8771
+ const panes3 = await listed2.panes;
8772
+ return panes3 ? dispatcherOf(pane, panes3, d.allyPanes()) : void 0;
8773
+ };
8774
+ }
8775
+ var KIND = { main: "main", member: "member", subagent: "subagent", ally: "ally run", run: "run" };
8776
+ function unitLabel(found) {
8777
+ const name2 = allyLabel(found?.name);
8778
+ if (!found || !/[\p{L}\p{N}]/u.test(name2)) return void 0;
8779
+ const kind = KIND[found.role];
8780
+ return kind ? `${kind} - ${name2}` : name2;
8781
+ }
8782
+
8600
8783
  // src/server/opentower.ts
8601
8784
  import { realpathSync as realpathSync7 } from "node:fs";
8602
8785
  import { basename as basename8 } from "node:path";
@@ -10708,8 +10891,14 @@ var LinkHolder = class {
10708
10891
  ...message.awaitsReply !== void 0 ? { awaitsReply: message.awaitsReply } : {}
10709
10892
  };
10710
10893
  this.o.store.putInbox(item);
10894
+ this.o.store.log({ event: "tower-in", kind: "tower", team: run3.team, from: header2.from, id: item.id, ...message.awaitsReply !== void 0 ? { ask: message.awaitsReply } : {} });
10711
10895
  this.thread(header2.from, { dir: "in", at: item.received, id: item.id, kind: "tower", ...message.awaitsReply !== void 0 ? { awaitsReply: message.awaitsReply } : {}, text: message.text });
10712
10896
  }
10897
+ /** An ally run took the inbox message `id` into its session: one `link.log` line names the run, the sender, the team and the ask, never the text (docs/specs/2026-11-05-link-shared-box.md, Audit). */
10898
+ delivered(id, run3) {
10899
+ const item = this.o.store.readInbox(id);
10900
+ if (item) this.o.store.log({ event: "tower-run", run: run3, team: item.team, from: item.from, id, ...item.awaitsReply !== void 0 ? { ask: item.awaitsReply } : {} });
10901
+ }
10713
10902
  /** A row of a device's thread, under the lock; a store that cannot write it does not lose the message. */
10714
10903
  thread(device, row2) {
10715
10904
  try {
@@ -10742,6 +10931,7 @@ var LinkHolder = class {
10742
10931
  ...taken ? { reply: { ask: message.inReplyTo, main: taken.main, mainName: taken.mainName, tower: taken.tower } } : {}
10743
10932
  };
10744
10933
  this.o.store.putInbox(item);
10934
+ this.o.store.log({ event: "tower-in", kind: "tower-reply", team: run3.team, from: header2.from, id: item.id, ask: message.inReplyTo });
10745
10935
  this.thread(header2.from, { dir: "in", at: item.received, id: item.id, kind: "tower-reply", inReplyTo: message.inReplyTo, text: message.text });
10746
10936
  }
10747
10937
  /** The election's record no longer names this session: nothing is acted on from here, and every stream ends. */
@@ -10895,7 +11085,7 @@ var LinkHolder = class {
10895
11085
  store.log({ event: "tower-open", project, from });
10896
11086
  const attempt = (async () => {
10897
11087
  try {
10898
- await openTower2(project, toWorkspace);
11088
+ await openTower2(project, toWorkspace, from);
10899
11089
  store.log({ event: "tower-opened", project });
10900
11090
  } catch (error) {
10901
11091
  store.log({ event: "tower-open-failed", project, error: error.message });
@@ -11078,6 +11268,17 @@ ${target.id}`, { text: req.text, at: this.now() });
11078
11268
  }
11079
11269
  return items.sort((a, b) => a.received - b.received || (a.id < b.id ? -1 : 1));
11080
11270
  }
11271
+ /** Whether this holder can tell who is in `team` right now: it runs, the team runs and has not died, and its roster reads. A device is taken as gone only while this is true (the revocation of a controller's mains). */
11272
+ rosterKnown(team) {
11273
+ const run3 = this.runs.get(team);
11274
+ if (!this.running || !run3 || run3.dead) return false;
11275
+ try {
11276
+ this.loadTeam(team);
11277
+ return true;
11278
+ } catch {
11279
+ return false;
11280
+ }
11281
+ }
11081
11282
  /** Whether `device` is a member of `team` on this computer's roster (a run whose sender left is ended). */
11082
11283
  isMember(team, device) {
11083
11284
  try {
@@ -11187,6 +11388,14 @@ ${target.id}`, { text: req.text, at: this.now() });
11187
11388
  dialogControllers() {
11188
11389
  return this.controllerRead().list;
11189
11390
  }
11391
+ /** Whether `device` is an admin of `team`'s roster now (an admin is told every question and notice of the box). False for a team that is not running or a roster that cannot be read. */
11392
+ isAdmin(team, device) {
11393
+ try {
11394
+ return this.loadTeam(team).state.members.get(device)?.role === "admin";
11395
+ } catch {
11396
+ return false;
11397
+ }
11398
+ }
11190
11399
  /** False while the controller list cannot be trusted to be complete (the holder is not running, or a team's roster could not be read): a caller does not withdraw anything then. */
11191
11400
  controllersKnown() {
11192
11401
  return this.controllerRead().known;
@@ -11259,6 +11468,7 @@ ${Buffer5.from(header2.id).toString("hex")}`).digest("hex").slice(0, 16);
11259
11468
  const sender = roster.members.get(header2.from);
11260
11469
  if (!(this.o.attended?.() ?? true) || !sender || sender.attended) return void this.o.store.log({ event: "drop", team: run3.team, reason: "dialog-open-unexpected", from: header2.from, kind: "dialog-open" });
11261
11470
  const fresh = this.dialogViews.put({ team: run3.team, device: header2.from, dialogId: p.dialogId, digest: p.digest, owner: p.owner, dialog: p.dialog, at: this.now() });
11471
+ this.o.store.log({ event: "dialog-in", team: run3.team, from: header2.from, dialog: p.dialogId, fresh });
11262
11472
  if (fresh) this.o.notify(`Straw Link: ${sender.label} asks a question (${restrictLabel(p.owner)})`, "warning");
11263
11473
  }
11264
11474
  takeDialogClose(header2, p) {
@@ -12288,6 +12498,13 @@ function createLinkService({ kit, stateDir, version, started, pid = process.pid,
12288
12498
  return store;
12289
12499
  },
12290
12500
  attended,
12501
+ /** How the policy file reads now: a refused one means the box cannot tell who may do what (g346-F003). */
12502
+ policyMode: () => policy().mode,
12503
+ /** The policy mode and the effective config from one read of the policy file, so the two cannot disagree. */
12504
+ policyLook() {
12505
+ const p = policy();
12506
+ return { mode: p.mode, config: readEffectiveConfig(p, stateDir, { home, agentDir }) };
12507
+ },
12291
12508
  /** The teams and projects of config.json, read fresh. */
12292
12509
  config,
12293
12510
  /** The level this computer gives a device (the policy file's, else `link.trust`, else `reply`): an ally run is claimed at it. */
@@ -13595,11 +13812,11 @@ ${c.device}`, units = mine.map((u) => ({ ...u, report: cutBytes(this.d.report(u.
13595
13812
  }
13596
13813
  }
13597
13814
  }
13598
- /** Tells every controller, once per `key`; nothing is remembered when there was nobody to tell, so the next trigger with the same key still goes out. */
13599
- async notify(key2, notice) {
13600
- if (this.told.has(key2)) return;
13601
- const to = this.d.controllers();
13602
- if (!to.length) return;
13815
+ /** Tells the controllers `scope` covers (the dispatcher and the admins; `EVERYONE` for all), once per `key`; nothing is remembered when there was nobody to tell, so the next trigger with the same key still goes out. Resolves whether anybody was addressed. */
13816
+ async notify(key2, notice, scope) {
13817
+ if (this.told.has(key2)) return false;
13818
+ const to = this.d.controllers().filter((c) => reaches(scope, c, this.d.admin ?? (() => false)));
13819
+ if (!to.length) return false;
13603
13820
  this.told.add(key2);
13604
13821
  while (this.told.size > TOLD_MAX) this.told.delete(this.told.values().next().value);
13605
13822
  this.d.log({ event: "notice", key: key2, kind: notice.kind, unit: notice.unit });
@@ -13610,14 +13827,17 @@ ${c.device}`, units = mine.map((u) => ({ ...u, report: cutBytes(this.d.report(u.
13610
13827
  this.d.log({ event: "notice-post-failed", device: c.device, kind: notice.kind, error: String(error.message).slice(0, 200) });
13611
13828
  }
13612
13829
  }
13830
+ return true;
13613
13831
  }
13614
13832
  };
13615
13833
  function runNotice(s2) {
13834
+ const scope = s2.device !== void 0 ? { device: s2.device, ...s2.team !== void 0 ? { team: s2.team } : {} } : {};
13616
13835
  const who = s2.ally ? "An ally run" : "A run";
13617
- if (s2.kind === "refused") return { key: `refused/${s2.workspace}/${createHash12("sha256").update(s2.text).digest("hex").slice(0, 16)}`, notice: { kind: "run-refused", unit: allyLabel(s2.label), text: s2.text } };
13836
+ if (s2.kind === "revoked") return { key: `revoked/${s2.main}/${s2.ended ? "ended" : s2.manual ? "manual" : "pending"}`, notice: { kind: s2.ended ? "main-revoked" : "main-revoke-pending", unit: allyLabel(s2.label), text: s2.text }, scope };
13837
+ if (s2.kind === "refused") return { key: `refused/${s2.workspace}/${createHash12("sha256").update(s2.text).digest("hex").slice(0, 16)}`, notice: { kind: "run-refused", unit: allyLabel(s2.label), text: s2.text }, scope };
13618
13838
  const trust = s2.detail?.startsWith("No trust decision");
13619
13839
  const kind = trust ? "trust-unanswered" : s2.outcome === "quit" ? "run-quit" : s2.outcome === "timeout" ? "run-timeout" : "run-crashed";
13620
- return { key: `run/${s2.run}`, notice: { kind, unit: allyLabel(s2.label), text: `${who} ${s2.outcome === "quit" ? "was quit" : s2.outcome === "timeout" ? "timed out" : "crashed"}${s2.detail ? `: ${s2.detail}` : ""}` } };
13840
+ return { key: `run/${s2.run}`, notice: { kind, unit: allyLabel(s2.label), text: `${who} ${s2.outcome === "quit" ? "was quit" : s2.outcome === "timeout" ? "timed out" : "crashed"}${s2.detail ? `: ${s2.detail}` : ""}` }, scope };
13621
13841
  }
13622
13842
 
13623
13843
  // src/server/towerrunstore.ts
@@ -15461,6 +15681,62 @@ function removeAnswer(slot, workspace, id, now) {
15461
15681
  if (was.some((a) => a.id === id)) write(slot, workspace, ANSWERS, was.filter((a) => a.id !== id));
15462
15682
  }
15463
15683
 
15684
+ // src/server/revoke.ts
15685
+ var GONE = /No live session is named|is not in this workspace|is not a main agent|was not opened by a tower/;
15686
+ function createRevoker(d) {
15687
+ const told = /* @__PURE__ */ new Set();
15688
+ let low = /* @__PURE__ */ new Set();
15689
+ let goneSeen = /* @__PURE__ */ new Map();
15690
+ let running;
15691
+ const pass = async () => {
15692
+ const now = /* @__PURE__ */ new Set(), gone = /* @__PURE__ */ new Map();
15693
+ for (const row2 of d.opened()) {
15694
+ if (row2.level !== void 0 && row2.level !== "dispatch") continue;
15695
+ if (!d.known(row2.team)) continue;
15696
+ const standing = d.standing(row2.device, row2.team);
15697
+ if (standing?.level === "dispatch") continue;
15698
+ now.add(row2.main);
15699
+ if (!low.has(row2.main)) continue;
15700
+ const why = standing ? "the device lost dispatch" : "the device left the team", who = `The main "${row2.name}" opened for ${row2.deviceLabel}`;
15701
+ try {
15702
+ await d.close(row2.workspace, row2.main);
15703
+ } catch (cause) {
15704
+ const reason = cause.message;
15705
+ if (GONE.test(reason)) {
15706
+ const seen = (goneSeen.get(row2.main) ?? 0) + 1;
15707
+ gone.set(row2.main, seen);
15708
+ if (seen < 2) continue;
15709
+ if (await d.paneGone(row2.workspace, row2.pane)) {
15710
+ d.log({ event: "revoke-gone", workspace: row2.workspace, main: row2.main });
15711
+ d.forget(row2.workspace, row2.main);
15712
+ gone.delete(row2.main);
15713
+ continue;
15714
+ }
15715
+ }
15716
+ const manual = /was not opened by a tower/.test(reason), key2 = `${row2.main}/${manual ? "manual" : "pending"}`;
15717
+ d.log({ event: "revoke-waiting", workspace: row2.workspace, main: row2.main, why });
15718
+ if (!told.has(key2)) {
15719
+ const sent = await d.signal({ kind: "revoked", workspace: row2.workspace, label: row2.label, ally: true, device: row2.device, ...row2.team ? { team: row2.team } : {}, main: row2.main, ended: false, ...manual ? { manual: true } : {}, text: manual ? `${who} still runs and cannot be ended by the box (${reason}). Close it by hand.` : `${who} could not be ended yet (${reason}). The box asks again until it quits.` });
15720
+ if (sent !== false) {
15721
+ told.add(key2);
15722
+ while (told.size > 256) told.delete(told.values().next().value);
15723
+ }
15724
+ }
15725
+ continue;
15726
+ }
15727
+ d.log({ event: "revoke-ended", workspace: row2.workspace, main: row2.main, why });
15728
+ d.forget(row2.workspace, row2.main);
15729
+ d.signal({ kind: "revoked", workspace: row2.workspace, label: row2.label, ally: true, device: row2.device, ...row2.team ? { team: row2.team } : {}, main: row2.main, ended: true, text: `${who} was ended: ${why}.` });
15730
+ }
15731
+ low = now;
15732
+ goneSeen = gone;
15733
+ };
15734
+ return {
15735
+ /** One look at every opened main; looks that overlap share the one in flight. A failure is logged and the next sweep tries again. */
15736
+ sweep: () => running ??= pass().catch((cause) => d.log({ event: "revoke-failed", error: cause.message.slice(0, 200) })).finally(() => running = void 0)
15737
+ };
15738
+ }
15739
+
15464
15740
  // src/server/runs.ts
15465
15741
  import { randomBytes as randomBytes17 } from "node:crypto";
15466
15742
  import { lstatSync as lstatSync7, realpathSync as realpathSync10 } from "node:fs";
@@ -15991,7 +16267,7 @@ function createRuns2(o) {
15991
16267
  release();
15992
16268
  svc.refusedAt = now();
15993
16269
  noticeOf(svc, { id: "settings", level: "error", kind: "settings", text: `A run could not start: ${cause.message}` });
15994
- o.signal?.({ kind: "refused", workspace: svc.ref.workspace, label: svc.ref.record().label, ally: true, text: `A run could not start: ${cause.message}` });
16270
+ o.signal?.({ kind: "refused", workspace: svc.ref.workspace, label: svc.ref.record().label, ally: true, text: `A run could not start: ${cause.message}`, device, team: first2.team });
15995
16271
  log.line("run-refused", { workspace: svc.ref.workspace, why: "settings", kind: "ally" });
15996
16272
  return;
15997
16273
  }
@@ -16202,7 +16478,7 @@ function createRuns2(o) {
16202
16478
  a.rec.outcome = outcome;
16203
16479
  a.rec.endedAt = iso2(now());
16204
16480
  if (detail) a.rec.detail = phrase4(detail, 200);
16205
- if (outcome === "crashed" || outcome === "timeout" || outcome === "quit") o.signal?.({ kind: "ended", run: a.rec.id, workspace: ws, label: svc.ref.record().label, ally: a.rec.kind === "ally", outcome, ...detail ? { detail: phrase4(detail, 200) } : {} });
16481
+ if (outcome === "crashed" || outcome === "timeout" || outcome === "quit") o.signal?.({ kind: "ended", run: a.rec.id, workspace: ws, label: svc.ref.record().label, ally: a.rec.kind === "ally", outcome, ...detail ? { detail: phrase4(detail, 200) } : {}, ...a.rec.kind === "ally" && a.rec.device ? { device: a.rec.device, ...a.rec.team ? { team: a.rec.team } : {} } : {} });
16206
16482
  try {
16207
16483
  writeRun(slot, a.rec);
16208
16484
  pruneRuns(slot, ws);
@@ -16324,7 +16600,7 @@ function createRuns2(o) {
16324
16600
  const orphan = a.rec.hand === true && !a.polled && a.pending === void 0 && a.closing === void 0;
16325
16601
  const outcome = a.pending ?? (a.closing !== void 0 ? "done" : orphan ? "quit" : "crashed");
16326
16602
  if (outcome === "crashed" && diedWithHerdr(a)) continue;
16327
- await finish(a, outcome, outcome === "crashed" ? "pi exited without tower_done" : orphan ? "the pi left before its first poll" : void 0, a.left === true);
16603
+ await finish(a, outcome, outcome === "crashed" ? "pi exited without tower_done" : orphan ? "the pi left before its first poll" : a.why, a.left === true);
16328
16604
  continue;
16329
16605
  }
16330
16606
  if (a.recovering !== void 0) {
@@ -16353,9 +16629,18 @@ function createRuns2(o) {
16353
16629
  if (!a.token) continue;
16354
16630
  if (a.rec.kind === "ally" && !standingOf(a)) {
16355
16631
  log.line("ally-ended", { workspace: a.rec.workspace, run: a.rec.id, why: "the sender left the roster" });
16632
+ a.why = "the sender left the team";
16356
16633
  terminate(a, "quit");
16357
16634
  continue;
16358
16635
  }
16636
+ const low = a.rec.kind === "ally" && a.rec.level === "dispatch" && o.ally?.known?.(a.rec.team ?? "") !== false && standingOf(a)?.level !== "dispatch";
16637
+ if (low && a.lowSeen) {
16638
+ log.line("ally-ended", { workspace: a.rec.workspace, run: a.rec.id, why: "the sender lost dispatch" });
16639
+ a.why = "the sender lost dispatch";
16640
+ terminate(a, "quit");
16641
+ continue;
16642
+ }
16643
+ a.lowSeen = low;
16359
16644
  if (now() - a.lastPoll > t.lost) {
16360
16645
  log.line("run-lost", { run: a.rec.id });
16361
16646
  terminate(a, "crashed");
@@ -16526,6 +16811,7 @@ function createRuns2(o) {
16526
16811
  a.offered.delete(event.id);
16527
16812
  a.rec.events.push(event.id);
16528
16813
  writeRun(slot, a.rec);
16814
+ if (a.rec.kind === "ally") o.ally?.delivered?.(event.id, a.rec.id);
16529
16815
  if (event.kind === "boss") bossTurn(a);
16530
16816
  return { ok: true };
16531
16817
  }
@@ -16790,7 +17076,7 @@ function createRuns2(o) {
16790
17076
  const a = of(token);
16791
17077
  if (a && !a.ended && a.rec.kind === "ally") {
16792
17078
  const standing = standingOf(a);
16793
- if (!standing) terminate(a, "quit");
17079
+ if (!standing) a.why ??= "the sender left the team", terminate(a, "quit");
16794
17080
  return { id: a.rec.id, workspace: a.rec.workspace, kind: "ally", ...standing ? { level: standing.level } : { gone: true }, ...a.rec.device ? { device: a.rec.device } : {}, ...a.rec.deviceLabel ? { deviceLabel: a.rec.deviceLabel } : {}, ...a.rec.deviceShort ? { deviceShort: a.rec.deviceShort } : {}, ...a.rec.team ? { team: a.rec.team } : {}, ...a.rec.teamName ? { teamName: a.rec.teamName } : {}, pane: a.rec.pane, asks: itemsOf(a, true).flatMap((i) => i.awaitsReply ? [i.awaitsReply] : []) };
16795
17081
  }
16796
17082
  return a && !a.ended ? { id: a.rec.id, workspace: a.rec.workspace, kind: headRun(a) ? "head" : a.rec.kind, ...a.rec.level ? { level: a.rec.level } : {}, ...a.rec.device ? { device: a.rec.device } : {}, ...a.rec.deviceLabel ? { deviceLabel: a.rec.deviceLabel } : {}, ...a.rec.team ? { team: a.rec.team } : {}, pane: a.rec.pane, asks: itemsOf(a, true).flatMap((i) => i.awaitsReply ? [i.awaitsReply] : []) } : void 0;
@@ -16911,6 +17197,8 @@ function createRuns2(o) {
16911
17197
  parked: readQueue(slot, workspace, true).map((e) => ({ id: e.id, kind: e.kind, ...e.from?.name ?? e.from?.id ? { from: e.from?.name ?? e.from?.id } : {}, first: phrase4(e.text.split("\n").find((l) => l.trim()) ?? "", 120) }))
16912
17198
  };
16913
17199
  },
17200
+ /** The panes of the live ally runs, with the device each serves and its team: the units a controller dispatched (a question asked in one is that controller's). */
17201
+ allyPanes: () => [...services.values()].flatMap((svc) => [...svc.allies.values()].filter((a) => !a.ended && a.rec.pane && a.rec.device).map((a) => ({ pane: a.rec.pane, device: a.rec.device, ...a.rec.team ? { team: a.rec.team } : {} }))),
16914
17202
  /** The waiting events of a workspace for the GUI's queue rows. */
16915
17203
  queue: (workspace) => {
16916
17204
  const s2 = find2(workspace);
@@ -17087,6 +17375,7 @@ var UNKNOWN = "(unknown)";
17087
17375
  var RUN_SEND_CAP_MS = 2e4;
17088
17376
  var RETRY_MS3 = [1e3, 2e3, 5e3, 1e4, 3e4];
17089
17377
  var REPUBLISH_MS = 300;
17378
+ var REVOKE_MS = 15e3;
17090
17379
  var REFRESH_MS2 = 4 * 6e4;
17091
17380
  var RECOVER_MS = 6e4;
17092
17381
  var RECOVER_POLL_MS = 2e3;
@@ -17216,10 +17505,14 @@ function createTowers(o) {
17216
17505
  standing: (device, team) => o.ally.standing(device, team),
17217
17506
  project: (team) => o.ally.project(team),
17218
17507
  thread: (device) => o.ally.thread(device),
17508
+ known: (team) => o.ally.known?.(team) ?? true,
17219
17509
  waiting: (identity2) => {
17220
17510
  const ws = workspaceOf(identity2), answers2 = ws ? readAnswers(slot, ws, now()).map((a) => ({ id: `${LOCAL}${a.id}`, device: a.device, label: a.label, short: a.short, team: a.team, teamName: a.teamName, fromWorkspace: "", replyExpected: false, text: a.text, at: a.at, local: a.from, ...a.ask ? { awaitsReply: a.ask } : {} })) : [];
17221
17511
  return [...o.ally.waiting(identity2), ...answers2].sort((a, b) => a.at - b.at);
17222
17512
  },
17513
+ delivered: (id, run4) => {
17514
+ if (!id.startsWith(LOCAL)) o.ally.delivered?.(id, run4);
17515
+ },
17223
17516
  taken: async (id, identity2) => {
17224
17517
  const ws = workspaceOf(identity2);
17225
17518
  if (!id.startsWith(LOCAL)) return o.ally.taken(id, identity2);
@@ -17691,6 +17984,7 @@ function createTowers(o) {
17691
17984
  error("purge", cause.message);
17692
17985
  }
17693
17986
  }, o.refreshMs ?? REFRESH_MS2));
17987
+ timers2.push(setInterval(() => void revoker.sweep(), o.revokeMs ?? REVOKE_MS));
17694
17988
  const poll = o.recoverPollMs ?? RECOVER_POLL_MS;
17695
17989
  timers2.push(setInterval(() => {
17696
17990
  if (now() < recoverUntil + poll) look2();
@@ -17856,6 +18150,25 @@ function createTowers(o) {
17856
18150
  }
17857
18151
  return closed;
17858
18152
  };
18153
+ const revoker = createRevoker({
18154
+ opened: () => o.ally ? [...services.values()].filter((l) => !l.stopped && l.state === "live" && l.control && !isHeadL(l)).flatMap((l) => readRoutes(slot, l.record.workspace, now()).filter((r) => r.kind === "opened").map((r) => ({ workspace: l.record.workspace, label: l.record.label, main: r.to, name: r.name, device: r.device, deviceLabel: r.label, team: r.team, ...r.level ? { level: r.level } : {}, ...r.pane ? { pane: r.pane } : {} }))) : [],
18155
+ known: (team) => o.ally?.known?.(team) ?? true,
18156
+ standing: (device, team) => o.ally?.standing(device, team),
18157
+ close: async (workspace, main) => void await need(workspace).control.closeMain(main),
18158
+ paneGone: async (workspace, pane) => {
18159
+ if (!pane) return false;
18160
+ try {
18161
+ const net = await need(workspace).reach.read();
18162
+ return !net.ps.some((p) => p.pane_id === pane);
18163
+ } catch {
18164
+ return false;
18165
+ }
18166
+ },
18167
+ // Herdr's listing is the second witness that a main is gone; no pane or a failed read confirms nothing
18168
+ forget: (workspace, main) => void forgetClosed(workspace, { main }),
18169
+ signal: (s2) => o.runs?.signal?.(s2),
18170
+ log: ({ event, ...rest }) => log.line(String(event), rest)
18171
+ });
17859
18172
  const answered = answeredAsks(now);
17860
18173
  const runToolFor = async (request) => {
17861
18174
  const who = runs2.runOf(request.token);
@@ -17865,7 +18178,7 @@ function createTowers(o) {
17865
18178
  const peer = who.kind === "ally" && who.device && who.team ? { device: who.device, label: who.deviceLabel ?? who.device.slice(0, 6), short: who.deviceShort ?? who.device.slice(0, 6), team: who.team, teamName: who.teamName ?? who.team.slice(0, 6) } : void 0;
17866
18179
  const route = (to, name3, kind, ask, live, panes3, pane) => {
17867
18180
  try {
17868
- addRoute(slot, ws, { ...peer, to, name: name3, kind, at: now(), ...ask ? { ask } : {}, ...pane ? { pane } : {} }, now(), live, panes3);
18181
+ addRoute(slot, ws, { ...peer, to, name: name3, kind, at: now(), ...ask ? { ask } : {}, ...pane ? { pane } : {}, ...kind === "opened" && who.level ? { level: who.level } : {} }, now(), live, panes3);
17869
18182
  } catch (cause) {
17870
18183
  error("ally-route", cause.message);
17871
18184
  }
@@ -18627,9 +18940,9 @@ async function runServer(o) {
18627
18940
  ...o.towers,
18628
18941
  // A seam that watches the runs (tests) sees every signal too, and does not take the place of the notice to the controllers.
18629
18942
  runs: { ...o.towers?.runs, signal: (sig) => {
18630
- const r = runNotice(sig);
18631
- void reporter?.notify(r.key, r.notice);
18943
+ const r = runNotice(sig), sent = reporter?.notify(r.key, r.notice, r.scope);
18632
18944
  o.towers?.runs?.signal?.(sig);
18945
+ return sent;
18633
18946
  } }
18634
18947
  });
18635
18948
  stops.push(cfg.watch(() => {
@@ -18648,21 +18961,26 @@ async function runServer(o) {
18648
18961
  }
18649
18962
  }), o.herdrPollMs ?? HERDR_POLL_MS));
18650
18963
  const dialogsOut = () => forwarder ??= new DialogForwarder({
18651
- ...guiHost.dialogs(),
18964
+ hub: guiHost.dialogs().hub,
18965
+ read: guiHost.dialogs().read,
18966
+ dispatcher: dispatcherLookup({ paneOf: (key2) => guiHost.dialogs().lookup(key2)?.pane, list: () => panes(cfg.run), allyPanes: () => towers?.runs.allyPanes() ?? [], now }),
18967
+ admin: (team, device) => link?.holder?.isAdmin(team, device) ?? false,
18652
18968
  controllers: () => link?.holder?.dialogControllers() ?? [],
18653
18969
  online: (team, device) => link?.holder?.deviceOnline(team, device) ?? false,
18654
18970
  controllersKnown: () => link?.holder?.controllersKnown() ?? false,
18971
+ unit: (key2) => unitLabel(guiHost.dialogs().lookup(key2)),
18655
18972
  post: async (team, device, kind, payload) => {
18656
18973
  if (!link?.holder) throw new Error("The link holder is not running");
18657
18974
  await link.holder.sendDialog(team, device, kind, payload);
18658
18975
  },
18659
18976
  log: ({ event, ...rest }) => log.line(String(event), Object.fromEntries(Object.entries(rest).map(([k, v]) => [k, typeof v === "number" || typeof v === "boolean" ? v : phrase6(v, 200)]))),
18660
18977
  now,
18661
- notice: (key2, notice) => unitsOut().notify(key2, notice)
18978
+ notice: (key2, notice, scope) => unitsOut().notify(key2, notice, scope)
18662
18979
  });
18663
18980
  const unitWorkspaces = /* @__PURE__ */ new Map();
18664
18981
  const unitsOut = () => reporter ??= new UnitReporter({
18665
18982
  controllers: () => link?.holder?.dialogControllers() ?? [],
18983
+ admin: (team, device) => link?.holder?.isAdmin(team, device) ?? false,
18666
18984
  // Per team: the units whose workspace has a pane inside that team's project, when its policy shares status. Labels and monitor commands leave this computer, so they pass the same scrub as a board's.
18667
18985
  units: async (team) => {
18668
18986
  const policy = link?.holder?.policyOf(team), rows = towers?.status() ?? [];
@@ -18714,11 +19032,11 @@ ${unit}`), note = ws ? readNotes(slot, ws).at(-1) : void 0, sent = ws ? link?.ho
18714
19032
  asks: { take: (q) => asks.take(q) },
18715
19033
  served: (identity2) => towers?.allyWaiting(identity2),
18716
19034
  confirmed: (to, dialogId, allow) => towers?.runs.confirmed(dialogId, allow) ?? false,
18717
- openTower: async (project, toWorkspace) => {
19035
+ openTower: async (project, toWorkspace, from) => {
18718
19036
  try {
18719
19037
  await openTower({ run: cfg.run, towers: () => towers, now, home: o.link?.home ?? homedir12(), agentDir: env.PI_CODING_AGENT_DIR ?? join44(o.link?.home ?? homedir12(), ".pi", "agent") }, project, toWorkspace);
18720
19038
  } catch (error) {
18721
- void unitsOut().notify(`tower-open/${project}/${Math.floor(now() / 6e5)}`, { kind: "tower-open-failed", unit: allyLabel(project), text: `Could not open a tower for ${project}: ${error.message}` });
19039
+ void unitsOut().notify(`tower-open/${from}/${project}/${Math.floor(now() / 6e5)}`, { kind: "tower-open-failed", unit: allyLabel(project), text: `Could not open a tower for ${project}: ${error.message}` }, { device: from });
18722
19040
  throw error;
18723
19041
  }
18724
19042
  }
@@ -18770,7 +19088,7 @@ ${unit}`), note = ws ? readNotes(slot, ws).at(-1) : void 0, sent = ws ? link?.ho
18770
19088
  if (slot.primary && !leaving && phase === "starting") {
18771
19089
  dialogsOut().start();
18772
19090
  timers2.push(setInterval(() => void Promise.all([dialogsOut().tick(), unitsOut().tick()]), o.unitsMs ?? 15e3));
18773
- }
19091
+ } else log.line("dialog-forwarder", { state: "skipped", primary: slot.primary, leaving: Boolean(leaving), phase });
18774
19092
  if (phase === "starting") {
18775
19093
  phase = "ready";
18776
19094
  const stale = own.lastError !== void 0 && own.lastError === prior?.lastError;