@cotal-ai/connector-opencode 0.3.1 → 0.4.0

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.
@@ -14756,7 +14756,7 @@ function subjectMatches(pattern, subject) {
14756
14756
  const s = subject.split(".");
14757
14757
  for (let i = 0; i < p.length; i++) {
14758
14758
  if (p[i] === ">")
14759
- return true;
14759
+ return i < s.length;
14760
14760
  if (i >= s.length)
14761
14761
  return false;
14762
14762
  if (p[i] === "*")
@@ -14766,6 +14766,26 @@ function subjectMatches(pattern, subject) {
14766
14766
  }
14767
14767
  return p.length === s.length;
14768
14768
  }
14769
+ function assertValidChannel(channel) {
14770
+ const segs = channel.split(".");
14771
+ if (!channel.length || segs.some((s) => s.length === 0))
14772
+ throw new Error(`invalid channel "${channel}": empty segment (no leading/trailing/double dots)`);
14773
+ segs.forEach((s, i) => {
14774
+ if (s === ">") {
14775
+ if (i !== segs.length - 1)
14776
+ throw new Error(`invalid channel "${channel}": '>' is only valid as the last segment`);
14777
+ return;
14778
+ }
14779
+ if (s === "*")
14780
+ return;
14781
+ if (!/^[A-Za-z0-9_-]+$/.test(s))
14782
+ throw new Error(`invalid channel "${channel}": segment "${s}" must be a NATS-safe token ([A-Za-z0-9_-]), '*', or '>' \u2014 policy channel names can't contain characters the wire layer would rewrite`);
14783
+ });
14784
+ return channel;
14785
+ }
14786
+ function channelInAllow(allow, channel) {
14787
+ return allow.some((a) => subjectMatches(a, channel));
14788
+ }
14769
14789
  function collapseFilterSubjects(subjects) {
14770
14790
  const uniq = [...new Set(subjects)];
14771
14791
  return uniq.filter((x) => !uniq.some((y) => y !== x && subjectMatches(y, x)));
@@ -14779,6 +14799,8 @@ function anycastSubject(space, service, sender) {
14779
14799
  function controlServiceSubject(space, service, sender) {
14780
14800
  return `${spacePrefix(space)}.ctl.${routeToken(service)}.${routeToken(sender)}`;
14781
14801
  }
14802
+ var CONTROL_PRIVILEGED = "manager";
14803
+ var CONTROL_SELF_SERVICE = "self";
14782
14804
  function spaceWildcard(space) {
14783
14805
  return `${spacePrefix(space)}.>`;
14784
14806
  }
@@ -14818,6 +14840,9 @@ function taskStream(space) {
14818
14840
  function chatDurable(instance) {
14819
14841
  return `chat_${token(instance)}`;
14820
14842
  }
14843
+ function chatHistDurable(instance) {
14844
+ return `chathist_${token(instance)}`;
14845
+ }
14821
14846
  function dmDurable(instance) {
14822
14847
  return `dm_${token(instance)}`;
14823
14848
  }
@@ -14825,6 +14850,46 @@ function taskDurable(service) {
14825
14850
  return `svc_${token(service)}`;
14826
14851
  }
14827
14852
 
14853
+ // ../../packages/core/dist/resolve.js
14854
+ var AmbiguousPeerError = class extends Error {
14855
+ target;
14856
+ candidates;
14857
+ constructor(target, candidates) {
14858
+ super(`"${target}" is ambiguous \u2014 ${candidates.length} peers share that name: ` + candidates.map((c) => `${c.name} (${c.id}, ${c.status})`).join("; ") + `. Re-send to the exact instance id.`);
14859
+ this.target = target;
14860
+ this.candidates = candidates;
14861
+ this.name = "AmbiguousPeerError";
14862
+ }
14863
+ };
14864
+ function candidate(p) {
14865
+ return { id: p.card.id, name: p.card.name, role: p.card.role, status: p.status, ts: p.ts };
14866
+ }
14867
+ function resolvePeer(roster, target, opts = {}) {
14868
+ const peers = opts.selfId ? roster.filter((p) => p.card.id !== opts.selfId) : roster;
14869
+ const byId = peers.find((p) => p.card.id === target);
14870
+ if (byId)
14871
+ return byId;
14872
+ const want = target.trim().toLowerCase();
14873
+ if (!want)
14874
+ return void 0;
14875
+ const matches = peers.filter((p) => p.card.name.toLowerCase() === want);
14876
+ if (matches.length === 0)
14877
+ return void 0;
14878
+ const live = matches.filter((p) => p.status !== "offline");
14879
+ const pool = live.length > 0 ? live : matches;
14880
+ if (pool.length === 1)
14881
+ return pool[0];
14882
+ throw new AmbiguousPeerError(target, pool.map(candidate));
14883
+ }
14884
+ function assertValidName(name) {
14885
+ if (name.length === 0 || name !== name.trim())
14886
+ throw new Error(`invalid name ${JSON.stringify(name)}: must be non-empty with no surrounding whitespace`);
14887
+ if (/[\r\n]/.test(name))
14888
+ throw new Error(`invalid name ${JSON.stringify(name)}: must be a single line`);
14889
+ if (name.includes("/"))
14890
+ throw new Error(`invalid name ${JSON.stringify(name)}: "/" is reserved (the owner/name separator)`);
14891
+ }
14892
+
14828
14893
  // ../../packages/core/dist/link.js
14829
14894
  function parseJoinLink(link) {
14830
14895
  const tls = link.startsWith("cotals://");
@@ -16504,9 +16569,10 @@ async function createSpaceStreams(jsm, space) {
16504
16569
  max_msgs_per_subject: MAX_MSGS_PER_SUBJECT,
16505
16570
  // capped per-channel backlog (buffer + history)
16506
16571
  discard: import_jetstream.DiscardPolicy.Old,
16507
- // Enable the read-only Direct Get API for per-channel history backfill on join (a pure
16508
- // read verb, no consumer create). CHAT ONLY never DM/TASK: direct-get bypasses the
16509
- // consumer-create deny that is DM's confidentiality boundary.
16572
+ // Direct Get API stays enabled on CHAT (harmless: agents hold no DIRECT.GET grant). Per-channel
16573
+ // history reads no longer use itthey go through contained single-filter ephemeral consumers
16574
+ // (endpoint `collectHistory`) so the read ACL bounds them. NEVER set on DM/TASK: direct-get
16575
+ // would bypass the consumer-create deny that is DM's confidentiality boundary.
16510
16576
  allow_direct: true
16511
16577
  });
16512
16578
  await jsm.streams.add({
@@ -16534,6 +16600,18 @@ function dmDurableConfig(space, id, opts = {}) {
16534
16600
  cfg.inactive_threshold = (0, import_transport_node.nanos)(opts.inactiveThresholdMs);
16535
16601
  return cfg;
16536
16602
  }
16603
+ function chatDurableConfig(space, id, channels, opts = {}) {
16604
+ const cfg = {
16605
+ durable_name: chatDurable(id),
16606
+ filter_subjects: collapseFilterSubjects(channels.map((ch) => chatSubject(space, "*", ch))),
16607
+ ack_policy: import_jetstream.AckPolicy.Explicit,
16608
+ ack_wait: (0, import_transport_node.nanos)(opts.ackWaitMs ?? 6e4),
16609
+ deliver_policy: import_jetstream.DeliverPolicy.New
16610
+ };
16611
+ if (opts.inactiveThresholdMs)
16612
+ cfg.inactive_threshold = (0, import_transport_node.nanos)(opts.inactiveThresholdMs);
16613
+ return cfg;
16614
+ }
16537
16615
  function taskDurableConfig(space, role, opts = {}) {
16538
16616
  return {
16539
16617
  durable_name: taskDurable(role),
@@ -16632,10 +16710,28 @@ function loadAgentFile(path) {
16632
16710
  const name = str("name");
16633
16711
  if (!name)
16634
16712
  throw new Error(`agent file ${path}: "name" is required`);
16713
+ assertValidName(name);
16635
16714
  const kind = str("kind");
16636
16715
  if (kind && kind !== "agent" && kind !== "endpoint")
16637
16716
  throw new Error(`agent file ${path}: "kind" must be "agent" or "endpoint"`);
16638
- const known = /* @__PURE__ */ new Set(["name", "role", "kind", "description", "tags", "channels", "publish", "model"]);
16717
+ for (const old of ["channels", "publish"])
16718
+ if (old in fm)
16719
+ throw new Error(`agent file ${path}: "${old}" was renamed \u2014 use "subscribe"/"allowSubscribe" (read) and "allowPublish" (post)`);
16720
+ const subscribe = list("subscribe");
16721
+ const allowSubscribe = list("allowSubscribe");
16722
+ const allowPublish = list("allowPublish");
16723
+ for (const ch of [...subscribe ?? [], ...allowSubscribe ?? [], ...allowPublish ?? []])
16724
+ try {
16725
+ assertValidChannel(ch);
16726
+ } catch (e) {
16727
+ throw new Error(`agent file ${path}: ${e.message}`);
16728
+ }
16729
+ const effSubscribe = subscribe?.length ? subscribe : ["general"];
16730
+ const effAllow = allowSubscribe?.length ? allowSubscribe : effSubscribe;
16731
+ for (const ch of effSubscribe)
16732
+ if (!channelInAllow(effAllow, ch))
16733
+ throw new Error(`agent file ${path}: subscribe channel "${ch}" is not within allowSubscribe [${effAllow.join(", ")}]`);
16734
+ const known = /* @__PURE__ */ new Set(["name", "role", "kind", "description", "tags", "subscribe", "allowSubscribe", "allowPublish", "model", "capabilities", "owner"]);
16639
16735
  const meta3 = {};
16640
16736
  for (const [k, v] of Object.entries(fm))
16641
16737
  if (!known.has(k) && typeof v === "string")
@@ -16646,9 +16742,12 @@ function loadAgentFile(path) {
16646
16742
  kind,
16647
16743
  description: str("description"),
16648
16744
  tags: list("tags"),
16649
- channels: list("channels"),
16650
- publish: list("publish"),
16745
+ subscribe,
16746
+ allowSubscribe,
16747
+ allowPublish,
16651
16748
  model: str("model"),
16749
+ capabilities: list("capabilities"),
16750
+ owner: str("owner"),
16652
16751
  meta: Object.keys(meta3).length ? meta3 : void 0,
16653
16752
  persona: persona || void 0
16654
16753
  };
@@ -16691,6 +16790,9 @@ var CotalEndpoint = class extends EventEmitter {
16691
16790
  * a lagging joiner + dedups the backfill overlap). Keyed by the subscription pattern (may be
16692
16791
  * wildcard), so the drop matches every concrete channel the pattern subsumes. */
16693
16792
  joinSeq = /* @__PURE__ */ new Map();
16793
+ /** Serializes history reads ({@link collectHistory}): they share the fixed per-instance
16794
+ * `chathist_<id>` consumer, so overlapping reads would delete/recreate it under one another. */
16795
+ histLock = Promise.resolve();
16694
16796
  subs = [];
16695
16797
  streamMsgs = [];
16696
16798
  heartbeatTimer;
@@ -16699,9 +16801,24 @@ var CotalEndpoint = class extends EventEmitter {
16699
16801
  status = "idle";
16700
16802
  activity;
16701
16803
  stopped = false;
16804
+ /** In-flight rebuild (drain+rebind) — serializes manual reconnect, the supervisor's
16805
+ * closed(), and reestablishLoop so only ONE rebuild runs at a time (a second trigger
16806
+ * coalesces onto the shared promise, never starts a parallel connectAndBind). */
16807
+ rebuildPromise;
16808
+ /** True only during the null window of a rebuild (this.nc unset) — user-facing ops then
16809
+ * throw a "reconnecting" message instead of the misleading "endpoint not started". */
16810
+ reconnecting = false;
16811
+ /** One reestablishLoop at a time; concurrent triggers coalesce via rebuild(). */
16812
+ reestablishing = false;
16813
+ /** Interruptible backoff for reestablishLoop — reconnect()/stop() resolves this to retry
16814
+ * now instead of awaiting the full retryMs. */
16815
+ backoffResolve;
16816
+ backoffTimer;
16817
+ retryMs = 3e3;
16702
16818
  constructor(opts) {
16703
16819
  super();
16704
16820
  this.space = opts.space;
16821
+ assertValidName(opts.card.name);
16705
16822
  const credId = opts.creds ? idFromCreds(opts.creds) : void 0;
16706
16823
  if (opts.card.id && credId && opts.card.id !== credId)
16707
16824
  throw new Error(`card.id ${opts.card.id} != creds identity ${credId} \u2014 they must be the same nkey`);
@@ -16726,6 +16843,15 @@ var CotalEndpoint = class extends EventEmitter {
16726
16843
  return { id: this.card.id, name: this.card.name, role: this.card.role };
16727
16844
  }
16728
16845
  async start() {
16846
+ await this.connectAndBind();
16847
+ this.superviseConnection();
16848
+ }
16849
+ /** Open the connection and bind everything that hangs off it: status watch, presence
16850
+ * watch + heartbeat, channel registry, and the durable consumers. Re-runnable — a
16851
+ * reconnect calls it again after {@link clearConnectionScoped}; every binding is
16852
+ * idempotent (durables bind by name, JetStream dedups by msgID, KV opens are idempotent). */
16853
+ async connectAndBind() {
16854
+ this.clearConnectionScoped();
16729
16855
  this.nc = await (0, import_transport_node3.connect)({
16730
16856
  servers: this.servers,
16731
16857
  name: `cotal:${this.card.name}`,
@@ -16764,11 +16890,167 @@ var CotalEndpoint = class extends EventEmitter {
16764
16890
  await this.ensureStreams();
16765
16891
  await this.startConsumers();
16766
16892
  }
16893
+ this.emit("connection", { connected: true });
16894
+ }
16895
+ /** Tear down everything {@link connectAndBind} (re)creates, so a rebind can't leak a
16896
+ * second heartbeat, double-pump a consumer, or keep stale roster ghosts. Caller-owned
16897
+ * subs (tap/serve) are left alone — they aren't rebuilt here. */
16898
+ clearConnectionScoped() {
16899
+ if (this.heartbeatTimer) {
16900
+ clearInterval(this.heartbeatTimer);
16901
+ this.heartbeatTimer = void 0;
16902
+ }
16903
+ if (this.sweepTimer) {
16904
+ clearInterval(this.sweepTimer);
16905
+ this.sweepTimer = void 0;
16906
+ }
16907
+ for (const msgs of this.streamMsgs) {
16908
+ try {
16909
+ msgs.stop();
16910
+ } catch {
16911
+ }
16912
+ }
16913
+ this.streamMsgs.length = 0;
16914
+ this.roster.clear();
16915
+ this.joinSeq.clear();
16916
+ this.channelConfigs.clear();
16917
+ this.channelDefaults = {};
16918
+ }
16919
+ /** If stop() ran during a rebuild's `await connectAndBind`, the just-bound connection +
16920
+ * heartbeat + supervisor would be left live on a stopped endpoint. Tear that fresh
16921
+ * connection back down and report it. Reads `this.nc` in its own scope (a bare `this.nc`
16922
+ * in doRebuild narrows to `never` via TS inlining connectAndBind's assignment). Returns
16923
+ * true iff it tore something down (caller bails out of the rebuild). */
16924
+ async tearDownIfStopped() {
16925
+ if (!this.stopped)
16926
+ return false;
16927
+ const nc = this.nc;
16928
+ this.clearConnectionScoped();
16929
+ try {
16930
+ await nc?.drain();
16931
+ } catch {
16932
+ }
16933
+ this.nc = void 0;
16934
+ return true;
16935
+ }
16936
+ /** Watch for a terminal close (nats.js has exhausted its own reconnect) and rebuild.
16937
+ * Our own stop()/drain also resolves closed(), so the `stopped` guard keeps a clean
16938
+ * shutdown from re-establishing. The identity guard (`this.nc !== nc`) no-ops a STALE
16939
+ * supervisor — one whose connection reconnect()/rebuild already replaced — so only a
16940
+ * close of the CURRENT connection triggers a rebuild. The rebuild itself is serialized
16941
+ * with the manual path via {@link rebuild}. */
16942
+ superviseConnection() {
16943
+ const nc = this.nc;
16944
+ if (!nc)
16945
+ return;
16946
+ void nc.closed().then((err2) => {
16947
+ if (this.stopped)
16948
+ return;
16949
+ if (this.nc !== nc)
16950
+ return;
16951
+ this.emit("connection", { connected: false });
16952
+ this.emit("error", new Error(`mesh connection closed${err2 ? `: ${err2.message}` : ""} \u2014 re-establishing`));
16953
+ void this.reestablishLoop();
16954
+ });
16955
+ }
16956
+ /** Single serialized rebuild: drain the old connection and rebind via {@link connectAndBind},
16957
+ * guarded so concurrent triggers (manual {@link reconnect}, the supervisor's closed(), the
16958
+ * retry loop) coalesce onto ONE in-flight rebuild instead of racing two connectAndBinds and
16959
+ * leaking a connection. Returns the shared promise; a second caller gets the in-flight one. */
16960
+ rebuild() {
16961
+ if (this.rebuildPromise)
16962
+ return this.rebuildPromise;
16963
+ const p = this.doRebuild().finally(() => {
16964
+ if (this.rebuildPromise === p)
16965
+ this.rebuildPromise = void 0;
16966
+ });
16967
+ this.rebuildPromise = p;
16968
+ return p;
16969
+ }
16970
+ /** The transition: stop the connection-scoped timers FIRST (so nothing live touches
16971
+ * this.nc during the null window), drop the connection refs, drain the old nc, then
16972
+ * rebind + re-arm the supervisor on the fresh connection. clearConnectionScoped is
16973
+ * idempotent, so connectAndBind's own call here is a noop. */
16974
+ async doRebuild() {
16975
+ const oldNc = this.nc;
16976
+ this.reconnecting = true;
16977
+ try {
16978
+ this.clearConnectionScoped();
16979
+ this.nc = void 0;
16980
+ this.js = void 0;
16981
+ this.jsm = void 0;
16982
+ this.kv = void 0;
16983
+ this.channelKv = void 0;
16984
+ this.emit("connection", { connected: false });
16985
+ try {
16986
+ await oldNc?.drain();
16987
+ } catch {
16988
+ }
16989
+ await this.connectAndBind();
16990
+ if (await this.tearDownIfStopped())
16991
+ return;
16992
+ this.superviseConnection();
16993
+ } finally {
16994
+ this.reconnecting = false;
16995
+ }
16996
+ }
16997
+ /** Rebuild with backoff until it sticks or we're stopped. Interruptible: a manual
16998
+ * {@link reconnect} kicks the backoff so the next attempt runs immediately instead of
16999
+ * awaiting the full retryMs. One loop at a time ({@link reestablishing}); concurrent
17000
+ * triggers coalesce via {@link rebuild}. */
17001
+ async reestablishLoop() {
17002
+ if (this.reestablishing)
17003
+ return;
17004
+ this.reestablishing = true;
17005
+ try {
17006
+ while (!this.stopped) {
17007
+ try {
17008
+ await this.rebuild();
17009
+ return;
17010
+ } catch (e) {
17011
+ if (!this.stopped)
17012
+ this.emit("error", e);
17013
+ await new Promise((resolve) => {
17014
+ this.backoffResolve = resolve;
17015
+ this.backoffTimer = setTimeout(resolve, this.retryMs);
17016
+ });
17017
+ }
17018
+ }
17019
+ } finally {
17020
+ this.reestablishing = false;
17021
+ }
17022
+ }
17023
+ /** Cut an in-flight reestablish backoff short so the next attempt runs immediately, and
17024
+ * clear its timer so it can't fire later on a stopped/restarted loop. */
17025
+ kickBackoff() {
17026
+ this.backoffResolve?.();
17027
+ if (this.backoffTimer) {
17028
+ clearTimeout(this.backoffTimer);
17029
+ this.backoffTimer = void 0;
17030
+ }
17031
+ }
17032
+ /** Manual reconnect: tear down the current connection and rebuild, WITHOUT the permanent
17033
+ * stop (stopped/stopping stay false). Serialized with the self-heal supervisor via
17034
+ * {@link rebuild}, and interruptible — if a backoff is in flight, kick it so the attempt
17035
+ * is now, not in retryMs. Throws if stopped. On failure, leaves {@link reestablishLoop}
17036
+ * running in the background so the endpoint never stays dead, and rethrows so the caller
17037
+ * can report it. */
17038
+ async reconnect() {
17039
+ if (this.stopped)
17040
+ throw new Error("endpoint stopped \u2014 cannot reconnect");
17041
+ this.kickBackoff();
17042
+ try {
17043
+ await this.rebuild();
17044
+ } catch (e) {
17045
+ void this.reestablishLoop();
17046
+ throw e;
17047
+ }
16767
17048
  }
16768
17049
  async stop() {
16769
17050
  if (this.stopped)
16770
17051
  return;
16771
17052
  this.stopped = true;
17053
+ this.kickBackoff();
16772
17054
  if (this.heartbeatTimer)
16773
17055
  clearInterval(this.heartbeatTimer);
16774
17056
  if (this.sweepTimer)
@@ -16897,7 +17179,7 @@ var CotalEndpoint = class extends EventEmitter {
16897
17179
  /** Send a control request to a service and await its reply (client side). */
16898
17180
  async requestControl(service, req, timeoutMs = 5e3) {
16899
17181
  if (!this.nc)
16900
- throw new Error("endpoint not started");
17182
+ throw new Error(this.notLiveMsg());
16901
17183
  const body = { ...req, from: req.from ?? this.ref() };
16902
17184
  const m = await this.nc.request(controlServiceSubject(this.space, service, this.card.id), JSON.stringify(body), { timeout: timeoutMs });
16903
17185
  return m.json();
@@ -16938,14 +17220,16 @@ var CotalEndpoint = class extends EventEmitter {
16938
17220
  */
16939
17221
  async joinChannel(channel) {
16940
17222
  if (!this.jsm)
16941
- throw new Error("endpoint not started");
17223
+ throw new Error(this.notLiveMsg());
16942
17224
  if (this.channels.includes(channel))
16943
17225
  return { joined: false, backfilled: 0 };
16944
- const next = collapseFilterSubjects([...this.channels, channel].map((ch) => chatSubject(this.space, "*", ch)));
16945
17226
  const armed = await this.armJoin([channel]);
16946
- await this.jsm.consumers.update(chatStream(this.space), chatDurable(this.card.id), {
16947
- filter_subjects: next
16948
- });
17227
+ try {
17228
+ await this.setChatFilter([...this.channels, channel]);
17229
+ } catch (e) {
17230
+ this.joinSeq.delete(channel);
17231
+ throw e;
17232
+ }
16949
17233
  this.channels.push(channel);
16950
17234
  const backfilled = await this.backfillArmed(armed);
16951
17235
  return { joined: true, backfilled };
@@ -16955,26 +17239,51 @@ var CotalEndpoint = class extends EventEmitter {
16955
17239
  * leaving). Returns whether anything changed. */
16956
17240
  async leaveChannel(channel) {
16957
17241
  if (!this.jsm)
16958
- throw new Error("endpoint not started");
17242
+ throw new Error(this.notLiveMsg());
16959
17243
  const i = this.channels.indexOf(channel);
16960
17244
  if (i < 0)
16961
17245
  return { left: false };
16962
17246
  if (this.channels.length === 1)
16963
17247
  throw new Error(`cannot leave "${channel}" \u2014 it is your only channel (an empty filter would subscribe to all)`);
16964
17248
  const remaining = this.channels.filter((c) => c !== channel);
16965
- await this.jsm.consumers.update(chatStream(this.space), chatDurable(this.card.id), {
16966
- filter_subjects: collapseFilterSubjects(remaining.map((ch) => chatSubject(this.space, "*", ch)))
16967
- });
17249
+ await this.setChatFilter(remaining);
16968
17250
  this.channels.splice(i, 1);
16969
17251
  this.joinSeq.delete(channel);
16970
17252
  return { left: true };
16971
17253
  }
17254
+ /** Move the chat live-tail durable to a new channel set. OPEN mode self-serves the
17255
+ * `consumers.update` (the agent owns its durable). AUTH mode is bind-only — the agent has no
17256
+ * UPDATE grant — so it sends a mediated control request to the manager, which validates the set
17257
+ * ⊆ its `allowSubscribe` before moving the filter. Throws clearly when no privileged responder is
17258
+ * present: a manager-less standalone auth session is fixed to its boot subscribe set — a
17259
+ * documented limitation, not a silent degrade. */
17260
+ async setChatFilter(channels) {
17261
+ if (!this.jsm)
17262
+ throw new Error(this.notLiveMsg());
17263
+ if (!this.creds) {
17264
+ await this.jsm.consumers.update(chatStream(this.space), chatDurable(this.card.id), {
17265
+ filter_subjects: collapseFilterSubjects(channels.map((ch) => chatSubject(this.space, "*", ch)))
17266
+ });
17267
+ return;
17268
+ }
17269
+ let reply;
17270
+ try {
17271
+ reply = await this.requestControl(CONTROL_SELF_SERVICE, { op: "setChannels", args: { channels } });
17272
+ } catch (e) {
17273
+ const msg = e.message;
17274
+ if (/no responders/i.test(msg))
17275
+ throw new Error("cannot change channels at runtime: no privileged provisioner (manager) is serving the mesh \u2014 this session is fixed to its boot subscribe set");
17276
+ throw e;
17277
+ }
17278
+ if (!reply.ok)
17279
+ throw new Error(reply.error ?? "channel change rejected");
17280
+ }
16972
17281
  /** One coherent channel model for dashboards: every channel that has messages OR a registry
16973
17282
  * entry (configured-but-empty), each tagged with its {@link ChannelConfig}. Works even on
16974
17283
  * observer endpoints (no consumers needed). */
16975
17284
  async listChannels() {
16976
17285
  if (!this.nc)
16977
- throw new Error("endpoint not started");
17286
+ throw new Error(this.notLiveMsg());
16978
17287
  const mgr = await (0, import_jetstream2.jetstreamManager)(this.nc);
16979
17288
  const counts = /* @__PURE__ */ new Map();
16980
17289
  try {
@@ -17097,9 +17406,16 @@ var CotalEndpoint = class extends EventEmitter {
17097
17406
  this.emit("error", e);
17098
17407
  });
17099
17408
  }
17409
+ /** The error message for a guard that finds the endpoint unbound: "reconnecting" during a
17410
+ * rebuild's null window OR an inter-retry backoff (so a concurrent op reports the real
17411
+ * reason, not "not started" — `reestablishing` spans the whole retry loop incl. backoff),
17412
+ * else "endpoint not started" (genuine pre-start). */
17413
+ notLiveMsg() {
17414
+ return this.reconnecting || this.reestablishing ? "reconnecting \u2014 try again shortly" : "endpoint not started";
17415
+ }
17100
17416
  async publishMsg(subject, msg) {
17101
17417
  if (!this.js)
17102
- throw new Error("endpoint not started");
17418
+ throw new Error(this.notLiveMsg());
17103
17419
  await this.js.publish(subject, JSON.stringify(msg), { msgID: msg.id });
17104
17420
  }
17105
17421
  /** Create the three backing streams for this space (idempotent). Open-mode lazy create;
@@ -17109,6 +17425,29 @@ var CotalEndpoint = class extends EventEmitter {
17109
17425
  throw new Error("endpoint not started");
17110
17426
  await createSpaceStreams(this.jsm, this.space);
17111
17427
  }
17428
+ /**
17429
+ * Privileged: pre-create an agent's bind-only chat live-tail durable (auth mode), filtered to its
17430
+ * `subscribe` set, so the agent can BIND it without holding CONSUMER.CREATE/UPDATE on CHAT — its
17431
+ * live read can't be self-widened past `allowSubscribe`. The creator sets the filter; the agent
17432
+ * never does (mirrors {@link provisionDmInbox}). Idempotent. The caller must be permissive on CHAT.
17433
+ */
17434
+ async provisionChatDurable(targetId, subscribe) {
17435
+ const jsm = await this.manager();
17436
+ await jsm.consumers.add(chatStream(this.space), chatDurableConfig(this.space, targetId, subscribe));
17437
+ }
17438
+ /**
17439
+ * Privileged: move an agent's bind-only chat durable to a new channel set — the write half of the
17440
+ * mediated join/leave. The manager calls this AFTER validating the set ⊆ the agent's
17441
+ * `allowSubscribe`; the agent itself has no UPDATE grant, so this trusted path is the only way its
17442
+ * live filter moves. The filter is rebuilt from channel names here (not from agent-supplied
17443
+ * subjects) so a caller can't smuggle a hand-built filter.
17444
+ */
17445
+ async setChatFilterFor(targetId, channels) {
17446
+ const jsm = await this.manager();
17447
+ await jsm.consumers.update(chatStream(this.space), chatDurable(targetId), {
17448
+ filter_subjects: collapseFilterSubjects(channels.map((ch) => chatSubject(this.space, "*", ch)))
17449
+ });
17450
+ }
17112
17451
  /**
17113
17452
  * Privileged: pre-create an agent's DM inbox durable (auth mode), so the agent can BIND
17114
17453
  * it without holding CONSUMER.CREATE on DM_<space>. The creator sets the filter to
@@ -17143,8 +17482,6 @@ var CotalEndpoint = class extends EventEmitter {
17143
17482
  if (!this.jsm)
17144
17483
  throw new Error("endpoint not started");
17145
17484
  const id = this.card.id;
17146
- const ack_wait = (0, import_transport_node3.nanos)(this.ackWaitMs);
17147
- const inactive_threshold = (0, import_transport_node3.nanos)(this.inactiveThresholdMs);
17148
17485
  if (!this.creds) {
17149
17486
  await this.jsm.consumers.add(dmStream(this.space), dmDurableConfig(this.space, id, {
17150
17487
  ackWaitMs: this.ackWaitMs,
@@ -17157,14 +17494,15 @@ var CotalEndpoint = class extends EventEmitter {
17157
17494
  const want = collapseFilterSubjects(this.channels.map((ch) => chatSubject(this.space, "*", ch)));
17158
17495
  const info = await this.consumerInfo(chatStream(this.space), durable);
17159
17496
  if (!info) {
17160
- await this.jsm.consumers.add(chatStream(this.space), {
17161
- durable_name: durable,
17162
- filter_subjects: want,
17163
- ack_policy: import_jetstream2.AckPolicy.Explicit,
17164
- ack_wait,
17165
- deliver_policy: import_jetstream2.DeliverPolicy.New,
17166
- inactive_threshold
17167
- });
17497
+ if (this.creds)
17498
+ throw new Error(`chat durable ${durable} not pre-created \u2014 a launcher must call provisionChatDurable (auth mode binds the durable, it never self-creates)`);
17499
+ await this.jsm.consumers.add(chatStream(this.space), chatDurableConfig(this.space, id, this.channels, {
17500
+ ackWaitMs: this.ackWaitMs,
17501
+ inactiveThresholdMs: this.inactiveThresholdMs
17502
+ }));
17503
+ }
17504
+ const consumed = (info?.delivered?.consumer_seq ?? 0) > 0;
17505
+ if (!consumed) {
17168
17506
  const armed = await this.armJoin(this.channels);
17169
17507
  await this.pump(chatStream(this.space), durable);
17170
17508
  await this.backfillArmed(armed);
@@ -17173,7 +17511,7 @@ var CotalEndpoint = class extends EventEmitter {
17173
17511
  const haveFilters = info.config.filter_subjects ?? (info.config.filter_subject ? [info.config.filter_subject] : []);
17174
17512
  const gained = this.channels.filter((c) => !haveFilters.some((f) => subjectMatches(f, chatSubject(this.space, "*", c))));
17175
17513
  const armed = gained.length ? await this.armJoin(gained) : void 0;
17176
- if (!sameSet(haveFilters, want))
17514
+ if (!this.creds && !sameSet(haveFilters, want))
17177
17515
  await this.jsm.consumers.update(chatStream(this.space), durable, { filter_subjects: want });
17178
17516
  if (armed)
17179
17517
  await this.backfillArmed(armed);
@@ -17290,63 +17628,107 @@ var CotalEndpoint = class extends EventEmitter {
17290
17628
  if (!this.channelKv)
17291
17629
  return { replay: effectiveReplay(void 0, void 0) };
17292
17630
  const [cfg, defaults] = await Promise.all([
17293
- readChannelConfig(this.channelKv, channel),
17631
+ isConcreteChannel(channel) ? readChannelConfig(this.channelKv, channel) : Promise.resolve(void 0),
17294
17632
  readChannelDefaults(this.channelKv)
17295
17633
  ]);
17296
17634
  return { replay: effectiveReplay(cfg, defaults), windowMs: effectiveReplayWindowMs(cfg, defaults) };
17297
17635
  }
17298
- /** Read a channel's retained history up to `upToSeq` via JetStream **Direct Get** (a read
17299
- * verb no consumer create, so it rides a read-only grant) and emit each message as a
17300
- * `historical` "message" event. `sinceMs` bounds how far back via a native Direct-Get
17301
- * `start_time` (now window); unset ⇒ the full retained window. New messages (`seq > upToSeq`)
17302
- * are skipped the live tail owns them. Pages the batch API; the ack handle is a no-op. */
17303
- async backfillChannel(channel, upToSeq, sinceMs) {
17304
- if (!this.jsm)
17636
+ /**
17637
+ * Read retained chat history on ONE channel subject through a name-scoped, single-filter
17638
+ * EPHEMERAL pull consumer the broker-contained replacement for the removed Direct Get. The
17639
+ * create rides `$JS.API.CONSUMER.CREATE.<CHAT>.<chathist_id>.<subject>`, whose trailing filter
17640
+ * token nats-server pins to the request body (JSConsumerCreateFilterSubjectMismatchErr, code
17641
+ * 10131) — so an agent can only ever replay a channel its `allowSubscribe` grants. Single filter
17642
+ * only (plural isn't ACL-constrainable); `AckPolicy.None` + `mem_storage` so it leaves no durable
17643
+ * state, and it is deleted right after. Returns raw messages in stream order from `start`,
17644
+ * stopping once past `untilSeq` (exclusive of it) or after `limit`. The per-instance name means
17645
+ * calls must be serial — every reader here awaits to completion, so they are.
17646
+ */
17647
+ async collectHistory(subject, start, opts = {}) {
17648
+ const run = this.histLock.then(() => this.collectHistoryInner(subject, start, opts));
17649
+ this.histLock = run.catch(() => {
17650
+ });
17651
+ return run;
17652
+ }
17653
+ async collectHistoryInner(subject, start, opts = {}) {
17654
+ if (!this.jsm || !this.js)
17305
17655
  throw new Error("endpoint not started");
17306
- const subject = chatSubject(this.space, "*", channel);
17307
- const collected = [];
17308
- const startTime = sinceMs === void 0 ? void 0 : new Date(Date.now() - sinceMs);
17309
- let startSeq = 1;
17310
- let first = true;
17311
- pages: for (; ; ) {
17312
- let last = 0;
17313
- let got = 0;
17314
- try {
17315
- const query = first && startTime !== void 0 ? { start_time: startTime, next_by_subj: subject, batch: 256 } : { seq: startSeq, next_by_subj: subject, batch: 256 };
17316
- first = false;
17317
- const iter = await this.jsm.direct.getBatch(chatStream(this.space), query);
17318
- for await (const sm of iter) {
17656
+ const stream = chatStream(this.space);
17657
+ const name = chatHistDurable(this.card.id);
17658
+ const out = [];
17659
+ try {
17660
+ await this.jsm.consumers.delete(stream, name);
17661
+ } catch {
17662
+ }
17663
+ await this.jsm.consumers.add(stream, {
17664
+ name,
17665
+ filter_subject: subject,
17666
+ ack_policy: import_jetstream2.AckPolicy.None,
17667
+ mem_storage: true,
17668
+ inactive_threshold: (0, import_transport_node3.nanos)(3e4),
17669
+ ..."time" in start ? { deliver_policy: import_jetstream2.DeliverPolicy.StartTime, opt_start_time: start.time.toISOString() } : { deliver_policy: import_jetstream2.DeliverPolicy.StartSequence, opt_start_seq: start.seq }
17670
+ });
17671
+ try {
17672
+ const consumer = await this.js.consumers.get(stream, name);
17673
+ let pending = (await consumer.info()).num_pending;
17674
+ while (pending > 0) {
17675
+ const want = Math.min(pending, 256);
17676
+ const iter = await consumer.fetch({ max_messages: want, expires: 5e3 });
17677
+ let got = 0;
17678
+ for await (const m of iter) {
17319
17679
  got++;
17320
- if (sm.seq > upToSeq)
17321
- break pages;
17322
- last = sm.seq;
17323
- let msg;
17324
- try {
17325
- msg = sm.json();
17326
- } catch {
17327
- continue;
17328
- }
17329
- const parsed = parseSubject(sm.subject);
17330
- if (!parsed || msg.from?.id !== parsed.sender || msg.from.id === this.card.id)
17680
+ if (opts.untilSeq !== void 0 && m.seq > opts.untilSeq)
17681
+ return out;
17682
+ if (!subjectMatches(subject, m.subject))
17331
17683
  continue;
17332
- collected.push({ msg, seq: sm.seq });
17684
+ out.push(m);
17685
+ if (opts.limit !== void 0 && out.length >= opts.limit)
17686
+ return out;
17333
17687
  }
17334
- } catch (e) {
17335
- if (e.code === 404)
17688
+ if (got < want)
17336
17689
  break;
17337
- this.emit("error", e);
17338
- break;
17690
+ pending -= got;
17691
+ }
17692
+ } finally {
17693
+ try {
17694
+ await this.jsm.consumers.delete(stream, name);
17695
+ } catch {
17339
17696
  }
17340
- if (got === 0 || last === 0)
17341
- break;
17342
- startSeq = last + 1;
17697
+ }
17698
+ return out;
17699
+ }
17700
+ /** Read a channel's retained history up to `upToSeq` (the join frontier) and emit each message
17701
+ * as a `historical` "message" event. `sinceMs` bounds how far back via a native consumer
17702
+ * `start_time` (now − window); unset ⇒ the full retained window. New messages (`seq > upToSeq`)
17703
+ * are skipped — the live tail owns them. Reads through the contained {@link collectHistory}. */
17704
+ async backfillChannel(channel, upToSeq, sinceMs) {
17705
+ const subject = chatSubject(this.space, "*", channel);
17706
+ const start = sinceMs === void 0 ? { seq: 1 } : { time: new Date(Date.now() - sinceMs) };
17707
+ let msgs;
17708
+ try {
17709
+ msgs = await this.collectHistory(subject, start, { untilSeq: upToSeq });
17710
+ } catch (e) {
17711
+ this.emit("error", e);
17712
+ return 0;
17343
17713
  }
17344
17714
  const noop = { ack: () => {
17345
17715
  }, nak: () => {
17346
17716
  } };
17347
- for (const { msg } of collected)
17717
+ let n = 0;
17718
+ for (const sm of msgs) {
17719
+ let msg;
17720
+ try {
17721
+ msg = sm.json();
17722
+ } catch {
17723
+ continue;
17724
+ }
17725
+ const parsed = parseSubject(sm.subject);
17726
+ if (!parsed || msg.from?.id !== parsed.sender || msg.from.id === this.card.id)
17727
+ continue;
17348
17728
  this.emit("message", msg, noop, { historical: true, kind: "channel" });
17349
- return collected.length;
17729
+ n++;
17730
+ }
17731
+ return n;
17350
17732
  }
17351
17733
  /**
17352
17734
  * Replay-gated pull of a channel's retained ambient from `sinceSeq` (exclusive) forward — the
@@ -17357,52 +17739,37 @@ var CotalEndpoint = class extends EventEmitter {
17357
17739
  *
17358
17740
  * Honors the **same** per-channel replay gate as join-backfill ({@link joinPolicyFresh}): a
17359
17741
  * `replay=off` channel returns nothing, so `focus` can't become a history bypass for a channel
17360
- * that denies replay to everyone else (chat is `allow_direct` with no broker-level ACL, so this
17361
- * app gate is the entire boundary).
17742
+ * that denies replay to everyone else (the read ACL bounds *which* channels recall can touch; this
17743
+ * app gate bounds *whether* a permitted channel replays).
17362
17744
  */
17363
17745
  async recallChannel(channel, sinceSeq) {
17364
17746
  if (!this.jsm)
17365
- throw new Error("endpoint not started");
17747
+ throw new Error(this.notLiveMsg());
17366
17748
  if (!isConcreteChannel(channel))
17367
17749
  return { messages: [], dropped: false };
17368
17750
  const policy = await this.joinPolicyFresh(channel);
17369
17751
  if (!policy.replay)
17370
17752
  return { messages: [], dropped: false };
17371
17753
  const subject = chatSubject(this.space, "*", channel);
17754
+ let raw;
17755
+ try {
17756
+ raw = await this.collectHistory(subject, { seq: sinceSeq + 1 });
17757
+ } catch (e) {
17758
+ this.emit("error", e);
17759
+ raw = [];
17760
+ }
17372
17761
  const collected = [];
17373
- let startSeq = sinceSeq + 1;
17374
- pages: for (; ; ) {
17375
- let last = 0;
17376
- let got = 0;
17762
+ for (const sm of raw) {
17763
+ let msg;
17377
17764
  try {
17378
- const iter = await this.jsm.direct.getBatch(chatStream(this.space), {
17379
- seq: startSeq,
17380
- next_by_subj: subject,
17381
- batch: 256
17382
- });
17383
- for await (const sm of iter) {
17384
- got++;
17385
- last = sm.seq;
17386
- let msg;
17387
- try {
17388
- msg = sm.json();
17389
- } catch {
17390
- continue;
17391
- }
17392
- const parsed = parseSubject(sm.subject);
17393
- if (!parsed || msg.from?.id !== parsed.sender || msg.from.id === this.card.id)
17394
- continue;
17395
- collected.push(msg);
17396
- }
17397
- } catch (e) {
17398
- if (e.code === 404)
17399
- break;
17400
- this.emit("error", e);
17401
- break;
17765
+ msg = sm.json();
17766
+ } catch {
17767
+ continue;
17402
17768
  }
17403
- if (got === 0 || last === 0)
17404
- break;
17405
- startSeq = last + 1;
17769
+ const parsed = parseSubject(sm.subject);
17770
+ if (!parsed || msg.from?.id !== parsed.sender || msg.from.id === this.card.id)
17771
+ continue;
17772
+ collected.push(msg);
17406
17773
  }
17407
17774
  const dropped = await this.channelDropped(subject, sinceSeq);
17408
17775
  return { messages: collected, dropped };
@@ -17433,22 +17800,16 @@ var CotalEndpoint = class extends EventEmitter {
17433
17800
  return oldest !== void 0 && oldest > sinceSeq + 1;
17434
17801
  }
17435
17802
  /** Sequence of the earliest message still retained on a channel subject (any sender), or
17436
- * undefined if nothing is retained. One 1-message Direct Get — used for the recall drop marker. */
17803
+ * undefined if nothing is retained. One message through the contained {@link collectHistory}
17804
+ * used for the recall drop marker. */
17437
17805
  async channelOldestSeq(subject) {
17438
17806
  if (!this.jsm)
17439
17807
  return void 0;
17440
17808
  try {
17441
- const iter = await this.jsm.direct.getBatch(chatStream(this.space), {
17442
- seq: 1,
17443
- next_by_subj: subject,
17444
- batch: 1
17445
- });
17446
- for await (const sm of iter)
17447
- return sm.seq;
17448
- return void 0;
17809
+ const [first] = await this.collectHistory(subject, { seq: 1 }, { limit: 1 });
17810
+ return first?.seq;
17449
17811
  } catch (e) {
17450
- if (e.code !== 404)
17451
- this.emit("error", e);
17812
+ this.emit("error", e);
17452
17813
  return void 0;
17453
17814
  }
17454
17815
  }
@@ -17597,6 +17958,13 @@ function describeStatusError(err2) {
17597
17958
  }
17598
17959
  return err2;
17599
17960
  }
17961
+ function isPermissionDenied(e) {
17962
+ if (e instanceof import_transport_node3.PermissionViolationError)
17963
+ return true;
17964
+ if (e?.cause instanceof import_transport_node3.PermissionViolationError)
17965
+ return true;
17966
+ return /permissions?\s+violation/i.test(String(e?.message ?? ""));
17967
+ }
17600
17968
 
17601
17969
  // ../../packages/core/dist/spaces.js
17602
17970
  var import_transport_node4 = __toESM(require_transport_node(), 1);
@@ -17648,9 +18016,17 @@ function configFromEnv(env = process.env) {
17648
18016
  const name = env.COTAL_NAME?.trim() || def?.name || (link ? userInfo().username : void 0);
17649
18017
  if (!name)
17650
18018
  throw new Error("COTAL_NAME, COTAL_AGENT_FILE or COTAL_LINK is required \u2014 a Cotal session needs an explicit identity from its launcher");
17651
- const channels = splitList(env.COTAL_CHANNELS);
17652
- const resolvedChannels = channels.length ? channels : def?.channels ?? link?.channels ?? ["general"];
17653
- const publish = splitList(env.COTAL_PUBLISH);
18019
+ const subscribe = splitList(env.COTAL_SUBSCRIBE);
18020
+ const resolvedSubscribe = subscribe.length ? subscribe : def?.subscribe ?? link?.channels ?? ["general"];
18021
+ const allowSub = splitList(env.COTAL_ALLOW_SUBSCRIBE);
18022
+ const resolvedAllowSub = allowSub.length ? allowSub : def?.allowSubscribe ?? resolvedSubscribe;
18023
+ for (const ch of resolvedSubscribe)
18024
+ if (!channelInAllow(resolvedAllowSub, ch))
18025
+ throw new Error(`COTAL config: subscribe channel "${ch}" is not within allowSubscribe [${resolvedAllowSub.join(", ")}]`);
18026
+ const allowPub = splitList(env.COTAL_ALLOW_PUBLISH);
18027
+ const resolvedAllowPub = allowPub.length ? allowPub : def?.allowPublish ?? [];
18028
+ for (const ch of [...resolvedSubscribe, ...resolvedAllowSub, ...resolvedAllowPub])
18029
+ assertValidChannel(ch);
17654
18030
  const credsPath = env.COTAL_CREDS?.trim();
17655
18031
  return {
17656
18032
  space: env.COTAL_SPACE?.trim() || link?.space || "demo",
@@ -17661,8 +18037,11 @@ function configFromEnv(env = process.env) {
17661
18037
  description: def?.description,
17662
18038
  tags: def?.tags,
17663
18039
  servers: env.COTAL_SERVERS?.trim() || link?.servers || DEFAULT_SERVER,
17664
- channels: resolvedChannels,
17665
- publish: publish.length ? publish : def?.publish ?? resolvedChannels,
18040
+ subscribe: resolvedSubscribe,
18041
+ allowSubscribe: resolvedAllowSub,
18042
+ // Post ACL is default-DENY: only what's explicitly declared (env > agent-file). The broker
18043
+ // enforces it under auth; in open mode posting is unrestricted regardless (see laneLine).
18044
+ allowPublish: resolvedAllowPub,
17666
18045
  kind: env.COTAL_KIND?.trim() || def?.kind || "agent",
17667
18046
  token: env.COTAL_TOKEN?.trim() || link?.token,
17668
18047
  user: link?.user,
@@ -17687,6 +18066,7 @@ var MeshAgent = class extends EventEmitter2 {
17687
18066
  _status = "idle";
17688
18067
  _attention = "open";
17689
18068
  // F3: fail-open default; reset to open on SessionStart
18069
+ _contextId;
17690
18070
  /** Chat-stream frontier captured when this agent entered `focus` — recall surfaces ambient
17691
18071
  * published after it ("since you entered focus"). Undefined unless in focus. */
17692
18072
  focusSince;
@@ -17702,7 +18082,8 @@ var MeshAgent = class extends EventEmitter2 {
17702
18082
  pass: config2.pass,
17703
18083
  creds: config2.creds,
17704
18084
  tls: config2.tls,
17705
- channels: config2.channels,
18085
+ channels: config2.subscribe,
18086
+ // the endpoint's live filter = the active read set
17706
18087
  card: {
17707
18088
  id: config2.id,
17708
18089
  name: config2.name,
@@ -17714,6 +18095,9 @@ var MeshAgent = class extends EventEmitter2 {
17714
18095
  });
17715
18096
  this.ep.on("message", (m, d, meta3) => this.ingest(m, d, meta3));
17716
18097
  this.ep.on("error", (e) => this.log(`endpoint error: ${e.message}`));
18098
+ this.ep.on("connection", (e) => {
18099
+ this._connected = e.connected;
18100
+ });
17717
18101
  }
17718
18102
  get id() {
17719
18103
  return this.ep.card.id;
@@ -17721,6 +18105,11 @@ var MeshAgent = class extends EventEmitter2 {
17721
18105
  get connected() {
17722
18106
  return this._connected;
17723
18107
  }
18108
+ /** Correlates outgoing messages to the host agent's current context/window. */
18109
+ setContextId(contextId) {
18110
+ const clean = contextId?.trim();
18111
+ this._contextId = clean ? clean : void 0;
18112
+ }
17724
18113
  /** Begin connecting (with background retry). Returns immediately. */
17725
18114
  start(retryMs = 3e3) {
17726
18115
  void this.connectLoop(retryMs);
@@ -17729,8 +18118,7 @@ var MeshAgent = class extends EventEmitter2 {
17729
18118
  while (!this.stopping && !this._connected) {
17730
18119
  try {
17731
18120
  await this.ep.start();
17732
- this._connected = true;
17733
- this.log(`connected to ${this.config.servers} as ${this.who()} in space "${this.config.space}" on #${this.config.channels.join(", #")}`);
18121
+ this.log(`connected to ${this.config.servers} as ${this.who()} in space "${this.config.space}" on #${this.config.subscribe.join(", #")}`);
17734
18122
  } catch (e) {
17735
18123
  this.log(`mesh unreachable (${e.message}); retrying in ${retryMs}ms`);
17736
18124
  await sleep(retryMs);
@@ -17739,8 +18127,26 @@ var MeshAgent = class extends EventEmitter2 {
17739
18127
  }
17740
18128
  async stop() {
17741
18129
  this.stopping = true;
17742
- if (this._connected)
17743
- await this.ep.stop();
18130
+ await this.ep.stop();
18131
+ }
18132
+ /** Manual reconnect: tear down the mesh connection and rebuild it in-process, WITHOUT
18133
+ * stopping the agent (the recovery path, so it does NOT assert connected). Delegates to
18134
+ * {@link CotalEndpoint.reconnect}, which is serialized with the self-heal supervisor and
18135
+ * interruptible. Returns a one-line status for the caller to surface (e.g. the
18136
+ * cotal_reconnect tool → TUI); on failure the endpoint keeps retrying in the background. */
18137
+ async reconnect() {
18138
+ if (this.stopping) {
18139
+ return {
18140
+ ok: false,
18141
+ message: "This session is shutting down, so its Cotal mesh connection cannot be reconnected. Start a new session instead."
18142
+ };
18143
+ }
18144
+ try {
18145
+ await this.ep.reconnect();
18146
+ return { ok: true, message: `Reconnected \u2713 (${this.config.name}@${this.config.space})` };
18147
+ } catch (e) {
18148
+ return { ok: false, message: `Reconnect failed: ${e.message}. Still retrying automatically \u2014 or run /reconnect to retry now.` };
18149
+ }
17744
18150
  }
17745
18151
  // ---- inbox ---------------------------------------------------------------
17746
18152
  ingest(m, delivery, meta3) {
@@ -17868,7 +18274,7 @@ var MeshAgent = class extends EventEmitter2 {
17868
18274
  const clean = normalizeMentions(mentions);
17869
18275
  if (clean)
17870
18276
  this.assertKnownMentions(clean);
17871
- return this.ep.multicast(text, { channel, mentions: clean });
18277
+ return this.ep.multicast(text, { channel, mentions: clean, contextId: this._contextId });
17872
18278
  }
17873
18279
  /** Throw if any name isn't a peer we've observed. Validates against the FULL roster
17874
18280
  * (incl. self — your own name is a valid participant; resolvePeer's self-filter would
@@ -17884,24 +18290,20 @@ var MeshAgent = class extends EventEmitter2 {
17884
18290
  }
17885
18291
  async anycast(role, text) {
17886
18292
  this.assertConnected();
17887
- return this.ep.anycast(role, text);
18293
+ return this.ep.anycast(role, text, { contextId: this._contextId });
17888
18294
  }
17889
- /** Resolve a peer by instance id (exact) or display name (case-insensitive, prefer present). */
18295
+ /** Resolve a peer by instance id (exact) or display name. Deterministic and fail-loud: returns
18296
+ * one peer, `undefined` if none match, or throws `AmbiguousPeerError` on a same-name collision —
18297
+ * it never silently picks. See `resolvePeer` in @cotal-ai/core. */
17890
18298
  resolvePeer(target) {
17891
- const roster = this.ep.getRoster().filter((p) => p.card.id !== this.id);
17892
- const byId = roster.find((p) => p.card.id === target);
17893
- if (byId)
17894
- return byId;
17895
- const t = target.toLowerCase();
17896
- const present = roster.filter((p) => p.status !== "offline");
17897
- return present.find((p) => p.card.name.toLowerCase() === t) ?? roster.find((p) => p.card.name.toLowerCase() === t);
18299
+ return resolvePeer(this.ep.getRoster(), target, { selfId: this.id });
17898
18300
  }
17899
18301
  async dm(target, text) {
17900
18302
  this.assertConnected();
17901
18303
  const peer = this.resolvePeer(target);
17902
18304
  if (!peer)
17903
18305
  throw new Error(`no peer "${target}" in space "${this.config.space}"`);
17904
- const msg = await this.ep.unicast(peer.card.id, text);
18306
+ const msg = await this.ep.unicast(peer.card.id, text, { contextId: this._contextId });
17905
18307
  return { msg, peer };
17906
18308
  }
17907
18309
  // ---- supervision ---------------------------------------------------------
@@ -17910,23 +18312,32 @@ var MeshAgent = class extends EventEmitter2 {
17910
18312
  * runtime; from here it just joins the mesh as a lateral peer. */
17911
18313
  async spawn(name, role) {
17912
18314
  this.assertConnected();
17913
- return this.ep.requestControl("manager", { op: "start", args: { name, role } });
18315
+ return this.ep.requestControl(CONTROL_PRIVILEGED, { op: "start", args: { name, role } });
17914
18316
  }
17915
18317
  /** Ask the manager to tear a teammate down (its `stop` op). Graceful by default —
17916
18318
  * the session is told to exit cleanly (so it leaves the mesh) before the
17917
- * process/tab is closed; `graceful:false` is a hard, immediate kill. */
18319
+ * process/tab is closed; `graceful:false` is a hard, immediate kill.
18320
+ *
18321
+ * No `name` ⇒ self-despawn: rides the self-service control subject and the manager
18322
+ * resolves the target as the managed agent whose id == this caller — so it can only
18323
+ * ever stop itself, never a peer. A `name` ⇒ rides the privileged control subject
18324
+ * (transport-gated to spawn-capable/admin); the manager refines own-child vs admin. */
17918
18325
  async despawn(name, opts) {
17919
18326
  this.assertConnected();
17920
- return this.ep.requestControl("manager", {
18327
+ const graceful = opts?.graceful ?? true;
18328
+ if (!name) {
18329
+ return this.ep.requestControl(CONTROL_SELF_SERVICE, { op: "stop", args: { graceful } });
18330
+ }
18331
+ return this.ep.requestControl(CONTROL_PRIVILEGED, {
17921
18332
  op: "stop",
17922
- args: { name, graceful: opts?.graceful ?? true }
18333
+ args: { name, graceful }
17923
18334
  });
17924
18335
  }
17925
18336
  /** Ask the manager to purge the space's retained chat backlog (its `purge` op). Cleanup only —
17926
18337
  * it doesn't touch live agents or the anycast work queue. `includeDms` also clears DM history. */
17927
18338
  async purgeHistory(opts) {
17928
18339
  this.assertConnected();
17929
- return this.ep.requestControl("manager", {
18340
+ return this.ep.requestControl(CONTROL_PRIVILEGED, {
17930
18341
  op: "purge",
17931
18342
  args: { includeDms: opts?.includeDms ?? false }
17932
18343
  });
@@ -17936,9 +18347,10 @@ var MeshAgent = class extends EventEmitter2 {
17936
18347
  * half — so peers see the new persona; `spawn(name)` then launches an agent wearing it. */
17937
18348
  async definePersona(def) {
17938
18349
  this.assertConnected();
17939
- const reply = await this.ep.requestControl("manager", {
18350
+ const reply = await this.ep.requestControl(CONTROL_PRIVILEGED, {
17940
18351
  op: "definePersona",
17941
- args: { name: def.name, role: def.role, model: def.model, persona: def.prompt }
18352
+ // role is policy set at spawn, never via definePersona; the manager ignores it regardless.
18353
+ args: { name: def.name, model: def.model, persona: def.prompt }
17942
18354
  });
17943
18355
  if (reply.ok)
17944
18356
  await this.send(`persona \`${def.name}\` is now available \u2014 spawn it to bring it online`);
@@ -32545,6 +32957,13 @@ config(en_default());
32545
32957
  // ../connector-core/dist/tool-specs.js
32546
32958
  var ok = (text) => ({ text });
32547
32959
  var err = (text) => ({ text, isError: true });
32960
+ function controlFailure(action, e) {
32961
+ const detail = e?.message ?? String(e);
32962
+ if (isPermissionDenied(e)) {
32963
+ return err(`${action}: this session isn't allowed to \u2014 its persona needs \`capabilities: [spawn]\` (which grants the privileged manager control subject). Add it and respawn so its creds re-mint. [${detail}]`);
32964
+ }
32965
+ return err(`${action}: no manager reachable (${detail}). Is the manager running?`);
32966
+ }
32548
32967
  function statusGlyph(s) {
32549
32968
  return s === "working" ? "\u25CF" : s === "waiting" ? "\u25D0" : s === "idle" ? "\u25CB" : "\xB7";
32550
32969
  }
@@ -32603,10 +33022,16 @@ function cotalToolSpecs(config2, source = "connector") {
32603
33022
  const roster = agent.roster();
32604
33023
  if (!roster.length)
32605
33024
  return ok(`No one is present in "${config2.space}" yet.`);
33025
+ const counts = /* @__PURE__ */ new Map();
33026
+ for (const p of roster) {
33027
+ const n = p.card.name.toLowerCase();
33028
+ counts.set(n, (counts.get(n) ?? 0) + 1);
33029
+ }
32606
33030
  const lines = roster.map((p) => {
32607
33031
  const who2 = p.card.role ? `${p.card.name}/${p.card.role}` : p.card.name;
32608
33032
  const me = p.card.id === agent.id ? ` (you${agent.attention !== "open" ? `, ${agent.attention}` : ""})` : "";
32609
- return `${statusGlyph(p.status)} ${who2} \u2014 ${p.status}${p.activity ? `: ${p.activity}` : ""}${me}`;
33033
+ const id = (counts.get(p.card.name.toLowerCase()) ?? 0) > 1 ? ` \u2014 id: ${p.card.id}` : "";
33034
+ return `${statusGlyph(p.status)} ${who2} \u2014 ${p.status}${p.activity ? `: ${p.activity}` : ""}${me}${id}`;
32610
33035
  });
32611
33036
  return ok(`Present in "${config2.space}" (${roster.length}):
32612
33037
  ${lines.join("\n")}`);
@@ -32649,7 +33074,7 @@ ${all.map(fmtItem).join("\n")}`);
32649
33074
  description: "Broadcast a message to everyone on a channel in your space.",
32650
33075
  schema: {
32651
33076
  text: external_exports.string().describe("The message to broadcast."),
32652
- channel: external_exports.string().optional().describe(`Channel to send on (default: ${config2.channels.find(isConcreteChannel) ?? "general"}). Concrete only \u2014 not a wildcard like team.>; reply on the channel you received a message on.`),
33077
+ channel: external_exports.string().optional().describe(`Channel to send on (default: ${config2.subscribe.find(isConcreteChannel) ?? "general"}). Concrete only \u2014 not a wildcard like team.>; reply on the channel you received a message on.`),
32653
33078
  mentions: external_exports.array(external_exports.string()).optional().describe("Names of peers to call out (e.g. ['bob']). Everyone on the channel still receives the message, but a mentioned peer gets high-priority delivery (eg @bob) \u2014 woken now if idle, instead of waiting for its next idle moment. Use sparingly: a mention WAKES that peer, so only call someone out when you need THAT specific peer to act now \u2014 never in an acknowledgement, thanks, or sign-off, or mentions ping-pong between peers and wake the channel in a loop.")
32654
33079
  },
32655
33080
  async run(agent, _config, { text: msg, channel, mentions }) {
@@ -32674,6 +33099,11 @@ ${all.map(fmtItem).join("\n")}`);
32674
33099
  const { peer } = await agent.dm(to, stripFaceTags(msg));
32675
33100
  return ok(`DM sent to ${peer.card.name}.`);
32676
33101
  } catch (e) {
33102
+ if (e instanceof AmbiguousPeerError) {
33103
+ const who2 = e.candidates.map((c) => ` \u2022 ${c.name}${c.role ? `/${c.role}` : ""} (${c.status}) \u2014 id: ${c.id}`).join("\n");
33104
+ return err(`"${e.target}" is ambiguous \u2014 ${e.candidates.length} peers share that name. Re-send cotal_dm with the exact instance id as "to":
33105
+ ${who2}`);
33106
+ }
32677
33107
  return err(`Couldn't DM: ${e.message}`);
32678
33108
  }
32679
33109
  }
@@ -32759,11 +33189,13 @@ ${lines.join("\n")}`);
32759
33189
  {
32760
33190
  name: "cotal_join",
32761
33191
  title: "Cotal: join a channel",
32762
- description: "Subscribe to a channel mid-session. Returns its registry info; if the channel replays, recent history is delivered to your inbox marked as catch-up (it pre-dates your join \u2014 don't treat it as live). Idempotent.",
33192
+ description: "Subscribe to a channel mid-session. Returns its registry info; if the channel replays, recent history is delivered to your inbox marked as catch-up (it pre-dates your join \u2014 don't treat it as live). Idempotent. Bounded by your read ACL: a channel outside it is refused.",
32763
33193
  schema: {
32764
33194
  channel: external_exports.string().describe("The channel to join (e.g. incident).")
32765
33195
  },
32766
33196
  async run(agent, _config, { channel }) {
33197
+ if (!channelInAllow(config2.allowSubscribe, channel))
33198
+ return err(`Can't join #${channel}: it's outside your read ACL (allowSubscribe: ${config2.allowSubscribe.map((c) => `#${c}`).join(", ")}).`);
32767
33199
  try {
32768
33200
  const r = await agent.joinChannel(channel);
32769
33201
  if (!r.joined)
@@ -32799,7 +33231,7 @@ ${info}${caught}`);
32799
33231
  title: "Cotal: spawn a new teammate",
32800
33232
  description: "Ask the manager to start a new peer endpoint in your space. It joins the mesh as a lateral peer (and, when the manager runs the cmux runtime, appears in its own tab). Use when the team needs another agent.",
32801
33233
  schema: {
32802
- name: external_exports.string().describe("Unique name for the new peer."),
33234
+ name: external_exports.string().describe("Name for the new peer; auto-numbered (e.g. reviewer-2) if taken."),
32803
33235
  role: external_exports.string().optional().describe("Optional role for the new peer (e.g. worker, reviewer).")
32804
33236
  },
32805
33237
  async run(agent, _config, { name, role }) {
@@ -32807,10 +33239,14 @@ ${info}${caught}`);
32807
33239
  const reply = await agent.spawn(name, role);
32808
33240
  if (!reply.ok)
32809
33241
  return err(`Couldn't spawn ${name}: ${reply.error ?? "manager refused"}`);
32810
- const mode = reply.data?.mode;
32811
- return ok(`Spawning ${role ? `${name}/${role}` : name}${mode ? ` (${mode})` : ""} \u2014 it will appear in the roster shortly.`);
33242
+ const d = reply.data;
33243
+ const actual = d?.name ?? name;
33244
+ const mode = d?.mode;
33245
+ const who2 = role ? `${actual}/${role}` : actual;
33246
+ const lead = actual !== name ? `"${name}" was taken \u2014 spawning ${who2} instead` : `Spawning ${who2}`;
33247
+ return ok(`${lead}${mode ? ` (${mode})` : ""} \u2014 it will appear in the roster shortly.`);
32812
33248
  } catch (e) {
32813
- return err(`Couldn't spawn ${name}: no manager reachable (${e.message}). Is the manager running?`);
33249
+ return controlFailure(`Couldn't spawn ${name}`, e);
32814
33250
  }
32815
33251
  }
32816
33252
  },
@@ -32866,63 +33302,52 @@ ${info}${caught}`);
32866
33302
  {
32867
33303
  name: "cotal_despawn",
32868
33304
  title: "Cotal: stop a teammate",
32869
- description: "Ask the manager to tear a teammate down \u2014 it leaves the mesh and its process/tab is closed. Graceful by default (the session exits cleanly first); pass graceful:false for a hard, immediate kill. The inverse of cotal_spawn.",
33305
+ description: "Ask the manager to tear a teammate down \u2014 it leaves the mesh and its process/tab is closed. Graceful by default (the session exits cleanly first); pass graceful:false for a hard, immediate kill. The inverse of cotal_spawn. Omit `name` to stop yourself (self-despawn): the manager resolves the target as your own managed entry, so it can only ever stop you, never a peer.",
32870
33306
  schema: {
32871
- name: external_exports.string().describe("Name of the peer to stop."),
33307
+ name: external_exports.string().optional().describe("Name of the peer to stop. Omit to stop yourself (self-despawn)."),
32872
33308
  graceful: external_exports.boolean().optional().describe("Default true: let the session exit cleanly. false = hard kill.")
32873
33309
  },
32874
33310
  async run(agent, _config, { name, graceful }) {
32875
33311
  try {
32876
33312
  const reply = await agent.despawn(name, { graceful });
32877
- if (!reply.ok)
32878
- return err(`Couldn't despawn ${name}: ${reply.error ?? "manager refused"}`);
32879
- return ok(`Stopping ${name}${graceful === false ? " (hard)" : ""} \u2014 it will leave the roster shortly.`);
32880
- } catch (e) {
32881
- return err(`Couldn't despawn ${name}: no manager reachable (${e.message}). Is the manager running?`);
32882
- }
32883
- }
32884
- },
32885
- {
32886
- name: "cotal_purge",
32887
- title: "Cotal: clear chat history",
32888
- description: "Ask the manager to purge this space's retained chat backlog (channel history). Set includeDms to also clear direct-message history. Cleanup only \u2014 it does not affect live agents or the anycast work queue. Irreversible.",
32889
- schema: {
32890
- includeDms: external_exports.boolean().optional().describe("Default false: channel history only. true = also purge DM history.")
32891
- },
32892
- async run(agent, _config, { includeDms }) {
32893
- try {
32894
- const reply = await agent.purgeHistory({ includeDms });
32895
- if (!reply.ok)
32896
- return err(`Couldn't purge history: ${reply.error ?? "manager refused"}`);
32897
- const d = reply.data;
32898
- const chat = d?.chat ?? 0;
32899
- const dm = d?.dm;
32900
- return ok(`Cleared ${chat} channel message${chat === 1 ? "" : "s"}${dm === void 0 ? "" : ` and ${dm} DM${dm === 1 ? "" : "s"}`} from "${_config.space}".`);
33313
+ if (!reply.ok) {
33314
+ return err(`Couldn't despawn ${name ?? "self"}: ${reply.error ?? "manager refused"}`);
33315
+ }
33316
+ const who2 = name ?? "self";
33317
+ return ok(`Stopping ${who2}${graceful === false ? " (hard)" : ""} \u2014 it will leave the roster shortly.`);
32901
33318
  } catch (e) {
32902
- return err(`Couldn't purge history: no manager reachable (${e.message}). Is the manager running?`);
33319
+ return controlFailure(`Couldn't despawn ${name ?? "self"}`, e);
32903
33320
  }
32904
33321
  }
32905
33322
  },
32906
33323
  {
32907
33324
  name: "cotal_persona",
32908
33325
  title: "Cotal: define a persona",
32909
- description: "Define a new persona and save it as config (the manager writes .cotal/agents/<name>.md), then announce it on the mesh. Afterwards cotal_spawn(name) launches a real agent wearing this persona/model. Use to grow the team with a custom role you describe on the fly.",
33326
+ description: "Define a new persona and save it as config (the manager writes .cotal/agents/<name>.md), then announce it on the mesh. Afterwards cotal_spawn(name) launches a real agent wearing this persona/model. Use to grow the team with a custom persona you describe on the fly; set its role at spawn (cotal_spawn takes a role).",
32910
33327
  schema: {
32911
33328
  name: external_exports.string().regex(/^[A-Za-z0-9_-]+$/, "letters, digits, _ or - only").describe("Unique name for the persona (also the spawn name): letters, digits, _ or -."),
32912
33329
  prompt: external_exports.string().max(1e4).describe("The persona \u2014 an appended system prompt describing who this agent is."),
32913
- role: external_exports.string().max(120).optional().describe("Optional role label (e.g. reviewer, scout)."),
32914
33330
  model: external_exports.string().max(120).optional().describe("Optional model override (e.g. opus, sonnet).")
32915
33331
  },
32916
- async run(agent, _config, { name, prompt, role, model }) {
33332
+ async run(agent, _config, { name, prompt, model }) {
32917
33333
  try {
32918
- const reply = await agent.definePersona({ name, prompt, role, model });
33334
+ const reply = await agent.definePersona({ name, prompt, model });
32919
33335
  if (!reply.ok)
32920
33336
  return err(`Couldn't define ${name}: ${reply.error ?? "manager refused"}`);
32921
33337
  return ok(`Persona \`${name}\` saved \u2014 spawn it with cotal_spawn(name="${name}") to bring it online.`);
32922
33338
  } catch (e) {
32923
- return err(`Couldn't define ${name}: no manager reachable (${e.message}). Is the manager running?`);
33339
+ return controlFailure(`Couldn't define ${name}`, e);
32924
33340
  }
32925
33341
  }
33342
+ },
33343
+ {
33344
+ name: "cotal_reconnect",
33345
+ title: "Cotal: reconnect to the mesh",
33346
+ description: "Tear down and rebuild this session's mesh connection in-process \u2014 the manual recovery path when the connection has wedged (the counterpart to Claude Code's /mcp reconnect, and a complement to the automatic self-heal). Zero-argument; local only \u2014 it does not ride the mesh link. Returns a one-line status (Reconnected \u2713 / Reconnect failed \u2014 still retrying automatically, or this session is shutting down).",
33347
+ async run(agent) {
33348
+ const r = await agent.reconnect();
33349
+ return r.ok ? ok(r.message) : err(r.message);
33350
+ }
32926
33351
  }
32927
33352
  ];
32928
33353
  }
@@ -33010,13 +33435,34 @@ var cotal = async ({ client }) => {
33010
33435
  } catch {
33011
33436
  }
33012
33437
  };
33438
+ function pendingForWake() {
33439
+ return agent.attention === "open" ? agent.inboxCount() : agent.directedPendingCount();
33440
+ }
33441
+ function adoptSession(id, reason) {
33442
+ if (sessionID === id) return;
33443
+ const previous = sessionID;
33444
+ sessionID = id;
33445
+ agent.setContextId(id);
33446
+ busy = false;
33447
+ driving = false;
33448
+ primed = false;
33449
+ briefed = false;
33450
+ surfaced = [];
33451
+ awaitingTurnEnd = false;
33452
+ if (previous) {
33453
+ log(`adopted opencode session ${id} after ${reason}; mesh identity unchanged`);
33454
+ if (pendingForWake() > 0) void drive();
33455
+ }
33456
+ }
33013
33457
  const sessionReady = (async () => {
33014
33458
  try {
33015
33459
  const res = await client.session.create({ body: { title: `cotal:${config2.space}:${config2.name}` } });
33016
- sessionID = res.data?.id;
33017
- if (sessionID) process.stderr.write(`[cotal-session] ${sessionID}
33460
+ const id = res.data?.id;
33461
+ if (id) {
33462
+ adoptSession(id, "boot");
33463
+ process.stderr.write(`[cotal-session] ${id}
33018
33464
  `);
33019
- else log("session.create returned no id");
33465
+ } else log("session.create returned no id");
33020
33466
  } catch (e) {
33021
33467
  log(`session.create failed: ${e.message}`);
33022
33468
  }
@@ -33074,12 +33520,13 @@ var cotal = async ({ client }) => {
33074
33520
  surfaced = [];
33075
33521
  }
33076
33522
  function completeTurn() {
33077
- if (!awaitingTurnEnd) return;
33078
- awaitingTurnEnd = false;
33523
+ if (!busy && !awaitingTurnEnd) return;
33079
33524
  busy = false;
33080
- ackSurfaced();
33081
- const pending = agent.attention === "open" ? agent.inboxCount() : agent.directedPendingCount();
33082
- if (pending > 0) void drive();
33525
+ if (awaitingTurnEnd) {
33526
+ awaitingTurnEnd = false;
33527
+ ackSurfaced();
33528
+ }
33529
+ if (pendingForWake() > 0) void drive();
33083
33530
  }
33084
33531
  agent.on("incoming", (item) => {
33085
33532
  if (busy) return;
@@ -33094,7 +33541,7 @@ var cotal = async ({ client }) => {
33094
33541
  });
33095
33542
  const ours = (id) => {
33096
33543
  if (!id) return !sessionID;
33097
- if (!sessionID) sessionID = id;
33544
+ if (!sessionID) adoptSession(id, "first event");
33098
33545
  return id === sessionID;
33099
33546
  };
33100
33547
  const hooks = {
@@ -33107,7 +33554,7 @@ var cotal = async ({ client }) => {
33107
33554
  }
33108
33555
  switch (event.type) {
33109
33556
  case "session.created":
33110
- if (!event.properties.info.parentID) ours(event.properties.info.id);
33557
+ if (!event.properties.info.parentID) adoptSession(event.properties.info.id, "top-level session create");
33111
33558
  break;
33112
33559
  case "session.idle":
33113
33560
  if (!ours(event.properties.sessionID)) return;
@@ -33129,12 +33576,14 @@ var cotal = async ({ client }) => {
33129
33576
  }
33130
33577
  case "session.error":
33131
33578
  if (event.properties.sessionID && !ours(event.properties.sessionID)) return;
33132
- if (!awaitingTurnEnd) return;
33133
- awaitingTurnEnd = false;
33579
+ if (!busy && !awaitingTurnEnd) return;
33134
33580
  busy = false;
33135
- ackSurfaced();
33581
+ if (awaitingTurnEnd) {
33582
+ awaitingTurnEnd = false;
33583
+ ackSurfaced();
33584
+ }
33136
33585
  await safeStatus("idle");
33137
- void drive();
33586
+ if (pendingForWake() > 0) void drive();
33138
33587
  break;
33139
33588
  case "session.deleted":
33140
33589
  if (!ours(event.properties.info.id)) return;