@cotal-ai/connector-claude-code 0.3.2 → 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.
package/dist/mcp.cjs CHANGED
@@ -45674,7 +45674,7 @@ function subjectMatches(pattern, subject) {
45674
45674
  const s = subject.split(".");
45675
45675
  for (let i = 0; i < p.length; i++) {
45676
45676
  if (p[i] === ">")
45677
- return true;
45677
+ return i < s.length;
45678
45678
  if (i >= s.length)
45679
45679
  return false;
45680
45680
  if (p[i] === "*")
@@ -45684,6 +45684,26 @@ function subjectMatches(pattern, subject) {
45684
45684
  }
45685
45685
  return p.length === s.length;
45686
45686
  }
45687
+ function assertValidChannel(channel) {
45688
+ const segs = channel.split(".");
45689
+ if (!channel.length || segs.some((s) => s.length === 0))
45690
+ throw new Error(`invalid channel "${channel}": empty segment (no leading/trailing/double dots)`);
45691
+ segs.forEach((s, i) => {
45692
+ if (s === ">") {
45693
+ if (i !== segs.length - 1)
45694
+ throw new Error(`invalid channel "${channel}": '>' is only valid as the last segment`);
45695
+ return;
45696
+ }
45697
+ if (s === "*")
45698
+ return;
45699
+ if (!/^[A-Za-z0-9_-]+$/.test(s))
45700
+ 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`);
45701
+ });
45702
+ return channel;
45703
+ }
45704
+ function channelInAllow(allow, channel) {
45705
+ return allow.some((a) => subjectMatches(a, channel));
45706
+ }
45687
45707
  function collapseFilterSubjects(subjects) {
45688
45708
  const uniq = [...new Set(subjects)];
45689
45709
  return uniq.filter((x) => !uniq.some((y) => y !== x && subjectMatches(y, x)));
@@ -45697,6 +45717,8 @@ function anycastSubject(space, service, sender) {
45697
45717
  function controlServiceSubject(space, service, sender) {
45698
45718
  return `${spacePrefix(space)}.ctl.${routeToken(service)}.${routeToken(sender)}`;
45699
45719
  }
45720
+ var CONTROL_PRIVILEGED = "manager";
45721
+ var CONTROL_SELF_SERVICE = "self";
45700
45722
  function spaceWildcard(space) {
45701
45723
  return `${spacePrefix(space)}.>`;
45702
45724
  }
@@ -45736,6 +45758,9 @@ function taskStream(space) {
45736
45758
  function chatDurable(instance) {
45737
45759
  return `chat_${token(instance)}`;
45738
45760
  }
45761
+ function chatHistDurable(instance) {
45762
+ return `chathist_${token(instance)}`;
45763
+ }
45739
45764
  function dmDurable(instance) {
45740
45765
  return `dm_${token(instance)}`;
45741
45766
  }
@@ -45743,6 +45768,46 @@ function taskDurable(service) {
45743
45768
  return `svc_${token(service)}`;
45744
45769
  }
45745
45770
 
45771
+ // ../../packages/core/dist/resolve.js
45772
+ var AmbiguousPeerError = class extends Error {
45773
+ target;
45774
+ candidates;
45775
+ constructor(target, candidates) {
45776
+ 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.`);
45777
+ this.target = target;
45778
+ this.candidates = candidates;
45779
+ this.name = "AmbiguousPeerError";
45780
+ }
45781
+ };
45782
+ function candidate(p) {
45783
+ return { id: p.card.id, name: p.card.name, role: p.card.role, status: p.status, ts: p.ts };
45784
+ }
45785
+ function resolvePeer(roster, target, opts = {}) {
45786
+ const peers = opts.selfId ? roster.filter((p) => p.card.id !== opts.selfId) : roster;
45787
+ const byId = peers.find((p) => p.card.id === target);
45788
+ if (byId)
45789
+ return byId;
45790
+ const want = target.trim().toLowerCase();
45791
+ if (!want)
45792
+ return void 0;
45793
+ const matches = peers.filter((p) => p.card.name.toLowerCase() === want);
45794
+ if (matches.length === 0)
45795
+ return void 0;
45796
+ const live = matches.filter((p) => p.status !== "offline");
45797
+ const pool = live.length > 0 ? live : matches;
45798
+ if (pool.length === 1)
45799
+ return pool[0];
45800
+ throw new AmbiguousPeerError(target, pool.map(candidate));
45801
+ }
45802
+ function assertValidName(name) {
45803
+ if (name.length === 0 || name !== name.trim())
45804
+ throw new Error(`invalid name ${JSON.stringify(name)}: must be non-empty with no surrounding whitespace`);
45805
+ if (/[\r\n]/.test(name))
45806
+ throw new Error(`invalid name ${JSON.stringify(name)}: must be a single line`);
45807
+ if (name.includes("/"))
45808
+ throw new Error(`invalid name ${JSON.stringify(name)}: "/" is reserved (the owner/name separator)`);
45809
+ }
45810
+
45746
45811
  // ../../packages/core/dist/link.js
45747
45812
  function parseJoinLink(link) {
45748
45813
  const tls = link.startsWith("cotals://");
@@ -47422,9 +47487,10 @@ async function createSpaceStreams(jsm, space) {
47422
47487
  max_msgs_per_subject: MAX_MSGS_PER_SUBJECT,
47423
47488
  // capped per-channel backlog (buffer + history)
47424
47489
  discard: import_jetstream.DiscardPolicy.Old,
47425
- // Enable the read-only Direct Get API for per-channel history backfill on join (a pure
47426
- // read verb, no consumer create). CHAT ONLY never DM/TASK: direct-get bypasses the
47427
- // consumer-create deny that is DM's confidentiality boundary.
47490
+ // Direct Get API stays enabled on CHAT (harmless: agents hold no DIRECT.GET grant). Per-channel
47491
+ // history reads no longer use itthey go through contained single-filter ephemeral consumers
47492
+ // (endpoint `collectHistory`) so the read ACL bounds them. NEVER set on DM/TASK: direct-get
47493
+ // would bypass the consumer-create deny that is DM's confidentiality boundary.
47428
47494
  allow_direct: true
47429
47495
  });
47430
47496
  await jsm.streams.add({
@@ -47452,6 +47518,18 @@ function dmDurableConfig(space, id, opts = {}) {
47452
47518
  cfg.inactive_threshold = (0, import_transport_node.nanos)(opts.inactiveThresholdMs);
47453
47519
  return cfg;
47454
47520
  }
47521
+ function chatDurableConfig(space, id, channels, opts = {}) {
47522
+ const cfg = {
47523
+ durable_name: chatDurable(id),
47524
+ filter_subjects: collapseFilterSubjects(channels.map((ch) => chatSubject(space, "*", ch))),
47525
+ ack_policy: import_jetstream.AckPolicy.Explicit,
47526
+ ack_wait: (0, import_transport_node.nanos)(opts.ackWaitMs ?? 6e4),
47527
+ deliver_policy: import_jetstream.DeliverPolicy.New
47528
+ };
47529
+ if (opts.inactiveThresholdMs)
47530
+ cfg.inactive_threshold = (0, import_transport_node.nanos)(opts.inactiveThresholdMs);
47531
+ return cfg;
47532
+ }
47455
47533
  function taskDurableConfig(space, role, opts = {}) {
47456
47534
  return {
47457
47535
  durable_name: taskDurable(role),
@@ -47550,10 +47628,28 @@ function loadAgentFile(path) {
47550
47628
  const name = str("name");
47551
47629
  if (!name)
47552
47630
  throw new Error(`agent file ${path}: "name" is required`);
47631
+ assertValidName(name);
47553
47632
  const kind = str("kind");
47554
47633
  if (kind && kind !== "agent" && kind !== "endpoint")
47555
47634
  throw new Error(`agent file ${path}: "kind" must be "agent" or "endpoint"`);
47556
- const known = /* @__PURE__ */ new Set(["name", "role", "kind", "description", "tags", "channels", "publish", "model"]);
47635
+ for (const old of ["channels", "publish"])
47636
+ if (old in fm)
47637
+ throw new Error(`agent file ${path}: "${old}" was renamed \u2014 use "subscribe"/"allowSubscribe" (read) and "allowPublish" (post)`);
47638
+ const subscribe = list("subscribe");
47639
+ const allowSubscribe = list("allowSubscribe");
47640
+ const allowPublish = list("allowPublish");
47641
+ for (const ch of [...subscribe ?? [], ...allowSubscribe ?? [], ...allowPublish ?? []])
47642
+ try {
47643
+ assertValidChannel(ch);
47644
+ } catch (e) {
47645
+ throw new Error(`agent file ${path}: ${e.message}`);
47646
+ }
47647
+ const effSubscribe = subscribe?.length ? subscribe : ["general"];
47648
+ const effAllow = allowSubscribe?.length ? allowSubscribe : effSubscribe;
47649
+ for (const ch of effSubscribe)
47650
+ if (!channelInAllow(effAllow, ch))
47651
+ throw new Error(`agent file ${path}: subscribe channel "${ch}" is not within allowSubscribe [${effAllow.join(", ")}]`);
47652
+ const known = /* @__PURE__ */ new Set(["name", "role", "kind", "description", "tags", "subscribe", "allowSubscribe", "allowPublish", "model", "capabilities", "owner"]);
47557
47653
  const meta3 = {};
47558
47654
  for (const [k, v] of Object.entries(fm))
47559
47655
  if (!known.has(k) && typeof v === "string")
@@ -47564,9 +47660,12 @@ function loadAgentFile(path) {
47564
47660
  kind,
47565
47661
  description: str("description"),
47566
47662
  tags: list("tags"),
47567
- channels: list("channels"),
47568
- publish: list("publish"),
47663
+ subscribe,
47664
+ allowSubscribe,
47665
+ allowPublish,
47569
47666
  model: str("model"),
47667
+ capabilities: list("capabilities"),
47668
+ owner: str("owner"),
47570
47669
  meta: Object.keys(meta3).length ? meta3 : void 0,
47571
47670
  persona: persona || void 0
47572
47671
  };
@@ -47609,6 +47708,9 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
47609
47708
  * a lagging joiner + dedups the backfill overlap). Keyed by the subscription pattern (may be
47610
47709
  * wildcard), so the drop matches every concrete channel the pattern subsumes. */
47611
47710
  joinSeq = /* @__PURE__ */ new Map();
47711
+ /** Serializes history reads ({@link collectHistory}): they share the fixed per-instance
47712
+ * `chathist_<id>` consumer, so overlapping reads would delete/recreate it under one another. */
47713
+ histLock = Promise.resolve();
47612
47714
  subs = [];
47613
47715
  streamMsgs = [];
47614
47716
  heartbeatTimer;
@@ -47617,9 +47719,24 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
47617
47719
  status = "idle";
47618
47720
  activity;
47619
47721
  stopped = false;
47722
+ /** In-flight rebuild (drain+rebind) — serializes manual reconnect, the supervisor's
47723
+ * closed(), and reestablishLoop so only ONE rebuild runs at a time (a second trigger
47724
+ * coalesces onto the shared promise, never starts a parallel connectAndBind). */
47725
+ rebuildPromise;
47726
+ /** True only during the null window of a rebuild (this.nc unset) — user-facing ops then
47727
+ * throw a "reconnecting" message instead of the misleading "endpoint not started". */
47728
+ reconnecting = false;
47729
+ /** One reestablishLoop at a time; concurrent triggers coalesce via rebuild(). */
47730
+ reestablishing = false;
47731
+ /** Interruptible backoff for reestablishLoop — reconnect()/stop() resolves this to retry
47732
+ * now instead of awaiting the full retryMs. */
47733
+ backoffResolve;
47734
+ backoffTimer;
47735
+ retryMs = 3e3;
47620
47736
  constructor(opts) {
47621
47737
  super();
47622
47738
  this.space = opts.space;
47739
+ assertValidName(opts.card.name);
47623
47740
  const credId = opts.creds ? idFromCreds(opts.creds) : void 0;
47624
47741
  if (opts.card.id && credId && opts.card.id !== credId)
47625
47742
  throw new Error(`card.id ${opts.card.id} != creds identity ${credId} \u2014 they must be the same nkey`);
@@ -47644,6 +47761,15 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
47644
47761
  return { id: this.card.id, name: this.card.name, role: this.card.role };
47645
47762
  }
47646
47763
  async start() {
47764
+ await this.connectAndBind();
47765
+ this.superviseConnection();
47766
+ }
47767
+ /** Open the connection and bind everything that hangs off it: status watch, presence
47768
+ * watch + heartbeat, channel registry, and the durable consumers. Re-runnable — a
47769
+ * reconnect calls it again after {@link clearConnectionScoped}; every binding is
47770
+ * idempotent (durables bind by name, JetStream dedups by msgID, KV opens are idempotent). */
47771
+ async connectAndBind() {
47772
+ this.clearConnectionScoped();
47647
47773
  this.nc = await (0, import_transport_node3.connect)({
47648
47774
  servers: this.servers,
47649
47775
  name: `cotal:${this.card.name}`,
@@ -47682,11 +47808,167 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
47682
47808
  await this.ensureStreams();
47683
47809
  await this.startConsumers();
47684
47810
  }
47811
+ this.emit("connection", { connected: true });
47812
+ }
47813
+ /** Tear down everything {@link connectAndBind} (re)creates, so a rebind can't leak a
47814
+ * second heartbeat, double-pump a consumer, or keep stale roster ghosts. Caller-owned
47815
+ * subs (tap/serve) are left alone — they aren't rebuilt here. */
47816
+ clearConnectionScoped() {
47817
+ if (this.heartbeatTimer) {
47818
+ clearInterval(this.heartbeatTimer);
47819
+ this.heartbeatTimer = void 0;
47820
+ }
47821
+ if (this.sweepTimer) {
47822
+ clearInterval(this.sweepTimer);
47823
+ this.sweepTimer = void 0;
47824
+ }
47825
+ for (const msgs of this.streamMsgs) {
47826
+ try {
47827
+ msgs.stop();
47828
+ } catch {
47829
+ }
47830
+ }
47831
+ this.streamMsgs.length = 0;
47832
+ this.roster.clear();
47833
+ this.joinSeq.clear();
47834
+ this.channelConfigs.clear();
47835
+ this.channelDefaults = {};
47836
+ }
47837
+ /** If stop() ran during a rebuild's `await connectAndBind`, the just-bound connection +
47838
+ * heartbeat + supervisor would be left live on a stopped endpoint. Tear that fresh
47839
+ * connection back down and report it. Reads `this.nc` in its own scope (a bare `this.nc`
47840
+ * in doRebuild narrows to `never` via TS inlining connectAndBind's assignment). Returns
47841
+ * true iff it tore something down (caller bails out of the rebuild). */
47842
+ async tearDownIfStopped() {
47843
+ if (!this.stopped)
47844
+ return false;
47845
+ const nc = this.nc;
47846
+ this.clearConnectionScoped();
47847
+ try {
47848
+ await nc?.drain();
47849
+ } catch {
47850
+ }
47851
+ this.nc = void 0;
47852
+ return true;
47853
+ }
47854
+ /** Watch for a terminal close (nats.js has exhausted its own reconnect) and rebuild.
47855
+ * Our own stop()/drain also resolves closed(), so the `stopped` guard keeps a clean
47856
+ * shutdown from re-establishing. The identity guard (`this.nc !== nc`) no-ops a STALE
47857
+ * supervisor — one whose connection reconnect()/rebuild already replaced — so only a
47858
+ * close of the CURRENT connection triggers a rebuild. The rebuild itself is serialized
47859
+ * with the manual path via {@link rebuild}. */
47860
+ superviseConnection() {
47861
+ const nc = this.nc;
47862
+ if (!nc)
47863
+ return;
47864
+ void nc.closed().then((err2) => {
47865
+ if (this.stopped)
47866
+ return;
47867
+ if (this.nc !== nc)
47868
+ return;
47869
+ this.emit("connection", { connected: false });
47870
+ this.emit("error", new Error(`mesh connection closed${err2 ? `: ${err2.message}` : ""} \u2014 re-establishing`));
47871
+ void this.reestablishLoop();
47872
+ });
47873
+ }
47874
+ /** Single serialized rebuild: drain the old connection and rebind via {@link connectAndBind},
47875
+ * guarded so concurrent triggers (manual {@link reconnect}, the supervisor's closed(), the
47876
+ * retry loop) coalesce onto ONE in-flight rebuild instead of racing two connectAndBinds and
47877
+ * leaking a connection. Returns the shared promise; a second caller gets the in-flight one. */
47878
+ rebuild() {
47879
+ if (this.rebuildPromise)
47880
+ return this.rebuildPromise;
47881
+ const p = this.doRebuild().finally(() => {
47882
+ if (this.rebuildPromise === p)
47883
+ this.rebuildPromise = void 0;
47884
+ });
47885
+ this.rebuildPromise = p;
47886
+ return p;
47887
+ }
47888
+ /** The transition: stop the connection-scoped timers FIRST (so nothing live touches
47889
+ * this.nc during the null window), drop the connection refs, drain the old nc, then
47890
+ * rebind + re-arm the supervisor on the fresh connection. clearConnectionScoped is
47891
+ * idempotent, so connectAndBind's own call here is a noop. */
47892
+ async doRebuild() {
47893
+ const oldNc = this.nc;
47894
+ this.reconnecting = true;
47895
+ try {
47896
+ this.clearConnectionScoped();
47897
+ this.nc = void 0;
47898
+ this.js = void 0;
47899
+ this.jsm = void 0;
47900
+ this.kv = void 0;
47901
+ this.channelKv = void 0;
47902
+ this.emit("connection", { connected: false });
47903
+ try {
47904
+ await oldNc?.drain();
47905
+ } catch {
47906
+ }
47907
+ await this.connectAndBind();
47908
+ if (await this.tearDownIfStopped())
47909
+ return;
47910
+ this.superviseConnection();
47911
+ } finally {
47912
+ this.reconnecting = false;
47913
+ }
47914
+ }
47915
+ /** Rebuild with backoff until it sticks or we're stopped. Interruptible: a manual
47916
+ * {@link reconnect} kicks the backoff so the next attempt runs immediately instead of
47917
+ * awaiting the full retryMs. One loop at a time ({@link reestablishing}); concurrent
47918
+ * triggers coalesce via {@link rebuild}. */
47919
+ async reestablishLoop() {
47920
+ if (this.reestablishing)
47921
+ return;
47922
+ this.reestablishing = true;
47923
+ try {
47924
+ while (!this.stopped) {
47925
+ try {
47926
+ await this.rebuild();
47927
+ return;
47928
+ } catch (e) {
47929
+ if (!this.stopped)
47930
+ this.emit("error", e);
47931
+ await new Promise((resolve) => {
47932
+ this.backoffResolve = resolve;
47933
+ this.backoffTimer = setTimeout(resolve, this.retryMs);
47934
+ });
47935
+ }
47936
+ }
47937
+ } finally {
47938
+ this.reestablishing = false;
47939
+ }
47940
+ }
47941
+ /** Cut an in-flight reestablish backoff short so the next attempt runs immediately, and
47942
+ * clear its timer so it can't fire later on a stopped/restarted loop. */
47943
+ kickBackoff() {
47944
+ this.backoffResolve?.();
47945
+ if (this.backoffTimer) {
47946
+ clearTimeout(this.backoffTimer);
47947
+ this.backoffTimer = void 0;
47948
+ }
47949
+ }
47950
+ /** Manual reconnect: tear down the current connection and rebuild, WITHOUT the permanent
47951
+ * stop (stopped/stopping stay false). Serialized with the self-heal supervisor via
47952
+ * {@link rebuild}, and interruptible — if a backoff is in flight, kick it so the attempt
47953
+ * is now, not in retryMs. Throws if stopped. On failure, leaves {@link reestablishLoop}
47954
+ * running in the background so the endpoint never stays dead, and rethrows so the caller
47955
+ * can report it. */
47956
+ async reconnect() {
47957
+ if (this.stopped)
47958
+ throw new Error("endpoint stopped \u2014 cannot reconnect");
47959
+ this.kickBackoff();
47960
+ try {
47961
+ await this.rebuild();
47962
+ } catch (e) {
47963
+ void this.reestablishLoop();
47964
+ throw e;
47965
+ }
47685
47966
  }
47686
47967
  async stop() {
47687
47968
  if (this.stopped)
47688
47969
  return;
47689
47970
  this.stopped = true;
47971
+ this.kickBackoff();
47690
47972
  if (this.heartbeatTimer)
47691
47973
  clearInterval(this.heartbeatTimer);
47692
47974
  if (this.sweepTimer)
@@ -47815,7 +48097,7 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
47815
48097
  /** Send a control request to a service and await its reply (client side). */
47816
48098
  async requestControl(service, req, timeoutMs = 5e3) {
47817
48099
  if (!this.nc)
47818
- throw new Error("endpoint not started");
48100
+ throw new Error(this.notLiveMsg());
47819
48101
  const body = { ...req, from: req.from ?? this.ref() };
47820
48102
  const m = await this.nc.request(controlServiceSubject(this.space, service, this.card.id), JSON.stringify(body), { timeout: timeoutMs });
47821
48103
  return m.json();
@@ -47856,14 +48138,16 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
47856
48138
  */
47857
48139
  async joinChannel(channel) {
47858
48140
  if (!this.jsm)
47859
- throw new Error("endpoint not started");
48141
+ throw new Error(this.notLiveMsg());
47860
48142
  if (this.channels.includes(channel))
47861
48143
  return { joined: false, backfilled: 0 };
47862
- const next = collapseFilterSubjects([...this.channels, channel].map((ch) => chatSubject(this.space, "*", ch)));
47863
48144
  const armed = await this.armJoin([channel]);
47864
- await this.jsm.consumers.update(chatStream(this.space), chatDurable(this.card.id), {
47865
- filter_subjects: next
47866
- });
48145
+ try {
48146
+ await this.setChatFilter([...this.channels, channel]);
48147
+ } catch (e) {
48148
+ this.joinSeq.delete(channel);
48149
+ throw e;
48150
+ }
47867
48151
  this.channels.push(channel);
47868
48152
  const backfilled = await this.backfillArmed(armed);
47869
48153
  return { joined: true, backfilled };
@@ -47873,26 +48157,51 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
47873
48157
  * leaving). Returns whether anything changed. */
47874
48158
  async leaveChannel(channel) {
47875
48159
  if (!this.jsm)
47876
- throw new Error("endpoint not started");
48160
+ throw new Error(this.notLiveMsg());
47877
48161
  const i = this.channels.indexOf(channel);
47878
48162
  if (i < 0)
47879
48163
  return { left: false };
47880
48164
  if (this.channels.length === 1)
47881
48165
  throw new Error(`cannot leave "${channel}" \u2014 it is your only channel (an empty filter would subscribe to all)`);
47882
48166
  const remaining = this.channels.filter((c) => c !== channel);
47883
- await this.jsm.consumers.update(chatStream(this.space), chatDurable(this.card.id), {
47884
- filter_subjects: collapseFilterSubjects(remaining.map((ch) => chatSubject(this.space, "*", ch)))
47885
- });
48167
+ await this.setChatFilter(remaining);
47886
48168
  this.channels.splice(i, 1);
47887
48169
  this.joinSeq.delete(channel);
47888
48170
  return { left: true };
47889
48171
  }
48172
+ /** Move the chat live-tail durable to a new channel set. OPEN mode self-serves the
48173
+ * `consumers.update` (the agent owns its durable). AUTH mode is bind-only — the agent has no
48174
+ * UPDATE grant — so it sends a mediated control request to the manager, which validates the set
48175
+ * ⊆ its `allowSubscribe` before moving the filter. Throws clearly when no privileged responder is
48176
+ * present: a manager-less standalone auth session is fixed to its boot subscribe set — a
48177
+ * documented limitation, not a silent degrade. */
48178
+ async setChatFilter(channels) {
48179
+ if (!this.jsm)
48180
+ throw new Error(this.notLiveMsg());
48181
+ if (!this.creds) {
48182
+ await this.jsm.consumers.update(chatStream(this.space), chatDurable(this.card.id), {
48183
+ filter_subjects: collapseFilterSubjects(channels.map((ch) => chatSubject(this.space, "*", ch)))
48184
+ });
48185
+ return;
48186
+ }
48187
+ let reply;
48188
+ try {
48189
+ reply = await this.requestControl(CONTROL_SELF_SERVICE, { op: "setChannels", args: { channels } });
48190
+ } catch (e) {
48191
+ const msg = e.message;
48192
+ if (/no responders/i.test(msg))
48193
+ 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");
48194
+ throw e;
48195
+ }
48196
+ if (!reply.ok)
48197
+ throw new Error(reply.error ?? "channel change rejected");
48198
+ }
47890
48199
  /** One coherent channel model for dashboards: every channel that has messages OR a registry
47891
48200
  * entry (configured-but-empty), each tagged with its {@link ChannelConfig}. Works even on
47892
48201
  * observer endpoints (no consumers needed). */
47893
48202
  async listChannels() {
47894
48203
  if (!this.nc)
47895
- throw new Error("endpoint not started");
48204
+ throw new Error(this.notLiveMsg());
47896
48205
  const mgr = await (0, import_jetstream2.jetstreamManager)(this.nc);
47897
48206
  const counts = /* @__PURE__ */ new Map();
47898
48207
  try {
@@ -48015,9 +48324,16 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
48015
48324
  this.emit("error", e);
48016
48325
  });
48017
48326
  }
48327
+ /** The error message for a guard that finds the endpoint unbound: "reconnecting" during a
48328
+ * rebuild's null window OR an inter-retry backoff (so a concurrent op reports the real
48329
+ * reason, not "not started" — `reestablishing` spans the whole retry loop incl. backoff),
48330
+ * else "endpoint not started" (genuine pre-start). */
48331
+ notLiveMsg() {
48332
+ return this.reconnecting || this.reestablishing ? "reconnecting \u2014 try again shortly" : "endpoint not started";
48333
+ }
48018
48334
  async publishMsg(subject, msg) {
48019
48335
  if (!this.js)
48020
- throw new Error("endpoint not started");
48336
+ throw new Error(this.notLiveMsg());
48021
48337
  await this.js.publish(subject, JSON.stringify(msg), { msgID: msg.id });
48022
48338
  }
48023
48339
  /** Create the three backing streams for this space (idempotent). Open-mode lazy create;
@@ -48027,6 +48343,29 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
48027
48343
  throw new Error("endpoint not started");
48028
48344
  await createSpaceStreams(this.jsm, this.space);
48029
48345
  }
48346
+ /**
48347
+ * Privileged: pre-create an agent's bind-only chat live-tail durable (auth mode), filtered to its
48348
+ * `subscribe` set, so the agent can BIND it without holding CONSUMER.CREATE/UPDATE on CHAT — its
48349
+ * live read can't be self-widened past `allowSubscribe`. The creator sets the filter; the agent
48350
+ * never does (mirrors {@link provisionDmInbox}). Idempotent. The caller must be permissive on CHAT.
48351
+ */
48352
+ async provisionChatDurable(targetId, subscribe) {
48353
+ const jsm = await this.manager();
48354
+ await jsm.consumers.add(chatStream(this.space), chatDurableConfig(this.space, targetId, subscribe));
48355
+ }
48356
+ /**
48357
+ * Privileged: move an agent's bind-only chat durable to a new channel set — the write half of the
48358
+ * mediated join/leave. The manager calls this AFTER validating the set ⊆ the agent's
48359
+ * `allowSubscribe`; the agent itself has no UPDATE grant, so this trusted path is the only way its
48360
+ * live filter moves. The filter is rebuilt from channel names here (not from agent-supplied
48361
+ * subjects) so a caller can't smuggle a hand-built filter.
48362
+ */
48363
+ async setChatFilterFor(targetId, channels) {
48364
+ const jsm = await this.manager();
48365
+ await jsm.consumers.update(chatStream(this.space), chatDurable(targetId), {
48366
+ filter_subjects: collapseFilterSubjects(channels.map((ch) => chatSubject(this.space, "*", ch)))
48367
+ });
48368
+ }
48030
48369
  /**
48031
48370
  * Privileged: pre-create an agent's DM inbox durable (auth mode), so the agent can BIND
48032
48371
  * it without holding CONSUMER.CREATE on DM_<space>. The creator sets the filter to
@@ -48061,8 +48400,6 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
48061
48400
  if (!this.jsm)
48062
48401
  throw new Error("endpoint not started");
48063
48402
  const id = this.card.id;
48064
- const ack_wait = (0, import_transport_node3.nanos)(this.ackWaitMs);
48065
- const inactive_threshold = (0, import_transport_node3.nanos)(this.inactiveThresholdMs);
48066
48403
  if (!this.creds) {
48067
48404
  await this.jsm.consumers.add(dmStream(this.space), dmDurableConfig(this.space, id, {
48068
48405
  ackWaitMs: this.ackWaitMs,
@@ -48075,14 +48412,15 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
48075
48412
  const want = collapseFilterSubjects(this.channels.map((ch) => chatSubject(this.space, "*", ch)));
48076
48413
  const info = await this.consumerInfo(chatStream(this.space), durable);
48077
48414
  if (!info) {
48078
- await this.jsm.consumers.add(chatStream(this.space), {
48079
- durable_name: durable,
48080
- filter_subjects: want,
48081
- ack_policy: import_jetstream2.AckPolicy.Explicit,
48082
- ack_wait,
48083
- deliver_policy: import_jetstream2.DeliverPolicy.New,
48084
- inactive_threshold
48085
- });
48415
+ if (this.creds)
48416
+ throw new Error(`chat durable ${durable} not pre-created \u2014 a launcher must call provisionChatDurable (auth mode binds the durable, it never self-creates)`);
48417
+ await this.jsm.consumers.add(chatStream(this.space), chatDurableConfig(this.space, id, this.channels, {
48418
+ ackWaitMs: this.ackWaitMs,
48419
+ inactiveThresholdMs: this.inactiveThresholdMs
48420
+ }));
48421
+ }
48422
+ const consumed = (info?.delivered?.consumer_seq ?? 0) > 0;
48423
+ if (!consumed) {
48086
48424
  const armed = await this.armJoin(this.channels);
48087
48425
  await this.pump(chatStream(this.space), durable);
48088
48426
  await this.backfillArmed(armed);
@@ -48091,7 +48429,7 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
48091
48429
  const haveFilters = info.config.filter_subjects ?? (info.config.filter_subject ? [info.config.filter_subject] : []);
48092
48430
  const gained = this.channels.filter((c) => !haveFilters.some((f) => subjectMatches(f, chatSubject(this.space, "*", c))));
48093
48431
  const armed = gained.length ? await this.armJoin(gained) : void 0;
48094
- if (!sameSet(haveFilters, want))
48432
+ if (!this.creds && !sameSet(haveFilters, want))
48095
48433
  await this.jsm.consumers.update(chatStream(this.space), durable, { filter_subjects: want });
48096
48434
  if (armed)
48097
48435
  await this.backfillArmed(armed);
@@ -48208,63 +48546,107 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
48208
48546
  if (!this.channelKv)
48209
48547
  return { replay: effectiveReplay(void 0, void 0) };
48210
48548
  const [cfg, defaults] = await Promise.all([
48211
- readChannelConfig(this.channelKv, channel),
48549
+ isConcreteChannel(channel) ? readChannelConfig(this.channelKv, channel) : Promise.resolve(void 0),
48212
48550
  readChannelDefaults(this.channelKv)
48213
48551
  ]);
48214
48552
  return { replay: effectiveReplay(cfg, defaults), windowMs: effectiveReplayWindowMs(cfg, defaults) };
48215
48553
  }
48216
- /** Read a channel's retained history up to `upToSeq` via JetStream **Direct Get** (a read
48217
- * verb no consumer create, so it rides a read-only grant) and emit each message as a
48218
- * `historical` "message" event. `sinceMs` bounds how far back via a native Direct-Get
48219
- * `start_time` (now window); unset ⇒ the full retained window. New messages (`seq > upToSeq`)
48220
- * are skipped the live tail owns them. Pages the batch API; the ack handle is a no-op. */
48221
- async backfillChannel(channel, upToSeq, sinceMs) {
48222
- if (!this.jsm)
48554
+ /**
48555
+ * Read retained chat history on ONE channel subject through a name-scoped, single-filter
48556
+ * EPHEMERAL pull consumer the broker-contained replacement for the removed Direct Get. The
48557
+ * create rides `$JS.API.CONSUMER.CREATE.<CHAT>.<chathist_id>.<subject>`, whose trailing filter
48558
+ * token nats-server pins to the request body (JSConsumerCreateFilterSubjectMismatchErr, code
48559
+ * 10131) — so an agent can only ever replay a channel its `allowSubscribe` grants. Single filter
48560
+ * only (plural isn't ACL-constrainable); `AckPolicy.None` + `mem_storage` so it leaves no durable
48561
+ * state, and it is deleted right after. Returns raw messages in stream order from `start`,
48562
+ * stopping once past `untilSeq` (exclusive of it) or after `limit`. The per-instance name means
48563
+ * calls must be serial — every reader here awaits to completion, so they are.
48564
+ */
48565
+ async collectHistory(subject, start, opts = {}) {
48566
+ const run = this.histLock.then(() => this.collectHistoryInner(subject, start, opts));
48567
+ this.histLock = run.catch(() => {
48568
+ });
48569
+ return run;
48570
+ }
48571
+ async collectHistoryInner(subject, start, opts = {}) {
48572
+ if (!this.jsm || !this.js)
48223
48573
  throw new Error("endpoint not started");
48224
- const subject = chatSubject(this.space, "*", channel);
48225
- const collected = [];
48226
- const startTime = sinceMs === void 0 ? void 0 : new Date(Date.now() - sinceMs);
48227
- let startSeq = 1;
48228
- let first = true;
48229
- pages: for (; ; ) {
48230
- let last = 0;
48231
- let got = 0;
48232
- try {
48233
- const query = first && startTime !== void 0 ? { start_time: startTime, next_by_subj: subject, batch: 256 } : { seq: startSeq, next_by_subj: subject, batch: 256 };
48234
- first = false;
48235
- const iter = await this.jsm.direct.getBatch(chatStream(this.space), query);
48236
- for await (const sm of iter) {
48574
+ const stream = chatStream(this.space);
48575
+ const name = chatHistDurable(this.card.id);
48576
+ const out = [];
48577
+ try {
48578
+ await this.jsm.consumers.delete(stream, name);
48579
+ } catch {
48580
+ }
48581
+ await this.jsm.consumers.add(stream, {
48582
+ name,
48583
+ filter_subject: subject,
48584
+ ack_policy: import_jetstream2.AckPolicy.None,
48585
+ mem_storage: true,
48586
+ inactive_threshold: (0, import_transport_node3.nanos)(3e4),
48587
+ ..."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 }
48588
+ });
48589
+ try {
48590
+ const consumer = await this.js.consumers.get(stream, name);
48591
+ let pending = (await consumer.info()).num_pending;
48592
+ while (pending > 0) {
48593
+ const want = Math.min(pending, 256);
48594
+ const iter = await consumer.fetch({ max_messages: want, expires: 5e3 });
48595
+ let got = 0;
48596
+ for await (const m of iter) {
48237
48597
  got++;
48238
- if (sm.seq > upToSeq)
48239
- break pages;
48240
- last = sm.seq;
48241
- let msg;
48242
- try {
48243
- msg = sm.json();
48244
- } catch {
48598
+ if (opts.untilSeq !== void 0 && m.seq > opts.untilSeq)
48599
+ return out;
48600
+ if (!subjectMatches(subject, m.subject))
48245
48601
  continue;
48246
- }
48247
- const parsed = parseSubject(sm.subject);
48248
- if (!parsed || msg.from?.id !== parsed.sender || msg.from.id === this.card.id)
48249
- continue;
48250
- collected.push({ msg, seq: sm.seq });
48602
+ out.push(m);
48603
+ if (opts.limit !== void 0 && out.length >= opts.limit)
48604
+ return out;
48251
48605
  }
48252
- } catch (e) {
48253
- if (e.code === 404)
48606
+ if (got < want)
48254
48607
  break;
48255
- this.emit("error", e);
48256
- break;
48608
+ pending -= got;
48257
48609
  }
48258
- if (got === 0 || last === 0)
48259
- break;
48260
- startSeq = last + 1;
48610
+ } finally {
48611
+ try {
48612
+ await this.jsm.consumers.delete(stream, name);
48613
+ } catch {
48614
+ }
48615
+ }
48616
+ return out;
48617
+ }
48618
+ /** Read a channel's retained history up to `upToSeq` (the join frontier) and emit each message
48619
+ * as a `historical` "message" event. `sinceMs` bounds how far back via a native consumer
48620
+ * `start_time` (now − window); unset ⇒ the full retained window. New messages (`seq > upToSeq`)
48621
+ * are skipped — the live tail owns them. Reads through the contained {@link collectHistory}. */
48622
+ async backfillChannel(channel, upToSeq, sinceMs) {
48623
+ const subject = chatSubject(this.space, "*", channel);
48624
+ const start = sinceMs === void 0 ? { seq: 1 } : { time: new Date(Date.now() - sinceMs) };
48625
+ let msgs;
48626
+ try {
48627
+ msgs = await this.collectHistory(subject, start, { untilSeq: upToSeq });
48628
+ } catch (e) {
48629
+ this.emit("error", e);
48630
+ return 0;
48261
48631
  }
48262
48632
  const noop = { ack: () => {
48263
48633
  }, nak: () => {
48264
48634
  } };
48265
- for (const { msg } of collected)
48635
+ let n = 0;
48636
+ for (const sm of msgs) {
48637
+ let msg;
48638
+ try {
48639
+ msg = sm.json();
48640
+ } catch {
48641
+ continue;
48642
+ }
48643
+ const parsed = parseSubject(sm.subject);
48644
+ if (!parsed || msg.from?.id !== parsed.sender || msg.from.id === this.card.id)
48645
+ continue;
48266
48646
  this.emit("message", msg, noop, { historical: true, kind: "channel" });
48267
- return collected.length;
48647
+ n++;
48648
+ }
48649
+ return n;
48268
48650
  }
48269
48651
  /**
48270
48652
  * Replay-gated pull of a channel's retained ambient from `sinceSeq` (exclusive) forward — the
@@ -48275,52 +48657,37 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
48275
48657
  *
48276
48658
  * Honors the **same** per-channel replay gate as join-backfill ({@link joinPolicyFresh}): a
48277
48659
  * `replay=off` channel returns nothing, so `focus` can't become a history bypass for a channel
48278
- * that denies replay to everyone else (chat is `allow_direct` with no broker-level ACL, so this
48279
- * app gate is the entire boundary).
48660
+ * that denies replay to everyone else (the read ACL bounds *which* channels recall can touch; this
48661
+ * app gate bounds *whether* a permitted channel replays).
48280
48662
  */
48281
48663
  async recallChannel(channel, sinceSeq) {
48282
48664
  if (!this.jsm)
48283
- throw new Error("endpoint not started");
48665
+ throw new Error(this.notLiveMsg());
48284
48666
  if (!isConcreteChannel(channel))
48285
48667
  return { messages: [], dropped: false };
48286
48668
  const policy = await this.joinPolicyFresh(channel);
48287
48669
  if (!policy.replay)
48288
48670
  return { messages: [], dropped: false };
48289
48671
  const subject = chatSubject(this.space, "*", channel);
48672
+ let raw;
48673
+ try {
48674
+ raw = await this.collectHistory(subject, { seq: sinceSeq + 1 });
48675
+ } catch (e) {
48676
+ this.emit("error", e);
48677
+ raw = [];
48678
+ }
48290
48679
  const collected = [];
48291
- let startSeq = sinceSeq + 1;
48292
- pages: for (; ; ) {
48293
- let last = 0;
48294
- let got = 0;
48680
+ for (const sm of raw) {
48681
+ let msg;
48295
48682
  try {
48296
- const iter = await this.jsm.direct.getBatch(chatStream(this.space), {
48297
- seq: startSeq,
48298
- next_by_subj: subject,
48299
- batch: 256
48300
- });
48301
- for await (const sm of iter) {
48302
- got++;
48303
- last = sm.seq;
48304
- let msg;
48305
- try {
48306
- msg = sm.json();
48307
- } catch {
48308
- continue;
48309
- }
48310
- const parsed = parseSubject(sm.subject);
48311
- if (!parsed || msg.from?.id !== parsed.sender || msg.from.id === this.card.id)
48312
- continue;
48313
- collected.push(msg);
48314
- }
48315
- } catch (e) {
48316
- if (e.code === 404)
48317
- break;
48318
- this.emit("error", e);
48319
- break;
48683
+ msg = sm.json();
48684
+ } catch {
48685
+ continue;
48320
48686
  }
48321
- if (got === 0 || last === 0)
48322
- break;
48323
- startSeq = last + 1;
48687
+ const parsed = parseSubject(sm.subject);
48688
+ if (!parsed || msg.from?.id !== parsed.sender || msg.from.id === this.card.id)
48689
+ continue;
48690
+ collected.push(msg);
48324
48691
  }
48325
48692
  const dropped = await this.channelDropped(subject, sinceSeq);
48326
48693
  return { messages: collected, dropped };
@@ -48351,22 +48718,16 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
48351
48718
  return oldest !== void 0 && oldest > sinceSeq + 1;
48352
48719
  }
48353
48720
  /** Sequence of the earliest message still retained on a channel subject (any sender), or
48354
- * undefined if nothing is retained. One 1-message Direct Get — used for the recall drop marker. */
48721
+ * undefined if nothing is retained. One message through the contained {@link collectHistory}
48722
+ * used for the recall drop marker. */
48355
48723
  async channelOldestSeq(subject) {
48356
48724
  if (!this.jsm)
48357
48725
  return void 0;
48358
48726
  try {
48359
- const iter = await this.jsm.direct.getBatch(chatStream(this.space), {
48360
- seq: 1,
48361
- next_by_subj: subject,
48362
- batch: 1
48363
- });
48364
- for await (const sm of iter)
48365
- return sm.seq;
48366
- return void 0;
48727
+ const [first] = await this.collectHistory(subject, { seq: 1 }, { limit: 1 });
48728
+ return first?.seq;
48367
48729
  } catch (e) {
48368
- if (e.code !== 404)
48369
- this.emit("error", e);
48730
+ this.emit("error", e);
48370
48731
  return void 0;
48371
48732
  }
48372
48733
  }
@@ -48515,6 +48876,13 @@ function describeStatusError(err2) {
48515
48876
  }
48516
48877
  return err2;
48517
48878
  }
48879
+ function isPermissionDenied(e) {
48880
+ if (e instanceof import_transport_node3.PermissionViolationError)
48881
+ return true;
48882
+ if (e?.cause instanceof import_transport_node3.PermissionViolationError)
48883
+ return true;
48884
+ return /permissions?\s+violation/i.test(String(e?.message ?? ""));
48885
+ }
48518
48886
 
48519
48887
  // ../../packages/core/dist/spaces.js
48520
48888
  var import_transport_node4 = __toESM(require_transport_node(), 1);
@@ -48564,9 +48932,17 @@ function configFromEnv(env = process.env) {
48564
48932
  const name = env.COTAL_NAME?.trim() || def?.name || (link ? (0, import_node_os.userInfo)().username : void 0);
48565
48933
  if (!name)
48566
48934
  throw new Error("COTAL_NAME, COTAL_AGENT_FILE or COTAL_LINK is required \u2014 a Cotal session needs an explicit identity from its launcher");
48567
- const channels = splitList(env.COTAL_CHANNELS);
48568
- const resolvedChannels = channels.length ? channels : def?.channels ?? link?.channels ?? ["general"];
48569
- const publish = splitList(env.COTAL_PUBLISH);
48935
+ const subscribe = splitList(env.COTAL_SUBSCRIBE);
48936
+ const resolvedSubscribe = subscribe.length ? subscribe : def?.subscribe ?? link?.channels ?? ["general"];
48937
+ const allowSub = splitList(env.COTAL_ALLOW_SUBSCRIBE);
48938
+ const resolvedAllowSub = allowSub.length ? allowSub : def?.allowSubscribe ?? resolvedSubscribe;
48939
+ for (const ch of resolvedSubscribe)
48940
+ if (!channelInAllow(resolvedAllowSub, ch))
48941
+ throw new Error(`COTAL config: subscribe channel "${ch}" is not within allowSubscribe [${resolvedAllowSub.join(", ")}]`);
48942
+ const allowPub = splitList(env.COTAL_ALLOW_PUBLISH);
48943
+ const resolvedAllowPub = allowPub.length ? allowPub : def?.allowPublish ?? [];
48944
+ for (const ch of [...resolvedSubscribe, ...resolvedAllowSub, ...resolvedAllowPub])
48945
+ assertValidChannel(ch);
48570
48946
  const credsPath = env.COTAL_CREDS?.trim();
48571
48947
  return {
48572
48948
  space: env.COTAL_SPACE?.trim() || link?.space || "demo",
@@ -48577,8 +48953,11 @@ function configFromEnv(env = process.env) {
48577
48953
  description: def?.description,
48578
48954
  tags: def?.tags,
48579
48955
  servers: env.COTAL_SERVERS?.trim() || link?.servers || DEFAULT_SERVER,
48580
- channels: resolvedChannels,
48581
- publish: publish.length ? publish : def?.publish ?? resolvedChannels,
48956
+ subscribe: resolvedSubscribe,
48957
+ allowSubscribe: resolvedAllowSub,
48958
+ // Post ACL is default-DENY: only what's explicitly declared (env > agent-file). The broker
48959
+ // enforces it under auth; in open mode posting is unrestricted regardless (see laneLine).
48960
+ allowPublish: resolvedAllowPub,
48582
48961
  kind: env.COTAL_KIND?.trim() || def?.kind || "agent",
48583
48962
  token: env.COTAL_TOKEN?.trim() || link?.token,
48584
48963
  user: link?.user,
@@ -48590,8 +48969,12 @@ function configFromEnv(env = process.env) {
48590
48969
  }
48591
48970
  function laneLine(config2) {
48592
48971
  const fmt = (cs) => cs.map((c) => `#${c}`).join(", ");
48593
- const subs = config2.channels;
48594
- const pubs = config2.publish.length ? config2.publish : config2.channels;
48972
+ const subs = config2.subscribe;
48973
+ if (!config2.creds)
48974
+ return `You read and may post to ${fmt(subs)}. `;
48975
+ const pubs = config2.allowPublish;
48976
+ if (!pubs.length)
48977
+ return `You read ${fmt(subs)}; you may not post to any channel (no publish channels granted). `;
48595
48978
  const same = subs.length === pubs.length && subs.every((c) => pubs.includes(c));
48596
48979
  return same ? `You read and may post to ${fmt(subs)}. ` : `You read ${fmt(subs)}; you may post only to ${fmt(pubs)} (posts to other channels are rejected). `;
48597
48980
  }
@@ -48614,6 +48997,7 @@ var MeshAgent = class extends import_node_events2.EventEmitter {
48614
48997
  _status = "idle";
48615
48998
  _attention = "open";
48616
48999
  // F3: fail-open default; reset to open on SessionStart
49000
+ _contextId;
48617
49001
  /** Chat-stream frontier captured when this agent entered `focus` — recall surfaces ambient
48618
49002
  * published after it ("since you entered focus"). Undefined unless in focus. */
48619
49003
  focusSince;
@@ -48629,7 +49013,8 @@ var MeshAgent = class extends import_node_events2.EventEmitter {
48629
49013
  pass: config2.pass,
48630
49014
  creds: config2.creds,
48631
49015
  tls: config2.tls,
48632
- channels: config2.channels,
49016
+ channels: config2.subscribe,
49017
+ // the endpoint's live filter = the active read set
48633
49018
  card: {
48634
49019
  id: config2.id,
48635
49020
  name: config2.name,
@@ -48641,6 +49026,9 @@ var MeshAgent = class extends import_node_events2.EventEmitter {
48641
49026
  });
48642
49027
  this.ep.on("message", (m, d, meta3) => this.ingest(m, d, meta3));
48643
49028
  this.ep.on("error", (e) => this.log(`endpoint error: ${e.message}`));
49029
+ this.ep.on("connection", (e) => {
49030
+ this._connected = e.connected;
49031
+ });
48644
49032
  }
48645
49033
  get id() {
48646
49034
  return this.ep.card.id;
@@ -48648,6 +49036,11 @@ var MeshAgent = class extends import_node_events2.EventEmitter {
48648
49036
  get connected() {
48649
49037
  return this._connected;
48650
49038
  }
49039
+ /** Correlates outgoing messages to the host agent's current context/window. */
49040
+ setContextId(contextId) {
49041
+ const clean = contextId?.trim();
49042
+ this._contextId = clean ? clean : void 0;
49043
+ }
48651
49044
  /** Begin connecting (with background retry). Returns immediately. */
48652
49045
  start(retryMs = 3e3) {
48653
49046
  void this.connectLoop(retryMs);
@@ -48656,8 +49049,7 @@ var MeshAgent = class extends import_node_events2.EventEmitter {
48656
49049
  while (!this.stopping && !this._connected) {
48657
49050
  try {
48658
49051
  await this.ep.start();
48659
- this._connected = true;
48660
- this.log(`connected to ${this.config.servers} as ${this.who()} in space "${this.config.space}" on #${this.config.channels.join(", #")}`);
49052
+ this.log(`connected to ${this.config.servers} as ${this.who()} in space "${this.config.space}" on #${this.config.subscribe.join(", #")}`);
48661
49053
  } catch (e) {
48662
49054
  this.log(`mesh unreachable (${e.message}); retrying in ${retryMs}ms`);
48663
49055
  await sleep(retryMs);
@@ -48666,8 +49058,26 @@ var MeshAgent = class extends import_node_events2.EventEmitter {
48666
49058
  }
48667
49059
  async stop() {
48668
49060
  this.stopping = true;
48669
- if (this._connected)
48670
- await this.ep.stop();
49061
+ await this.ep.stop();
49062
+ }
49063
+ /** Manual reconnect: tear down the mesh connection and rebuild it in-process, WITHOUT
49064
+ * stopping the agent (the recovery path, so it does NOT assert connected). Delegates to
49065
+ * {@link CotalEndpoint.reconnect}, which is serialized with the self-heal supervisor and
49066
+ * interruptible. Returns a one-line status for the caller to surface (e.g. the
49067
+ * cotal_reconnect tool → TUI); on failure the endpoint keeps retrying in the background. */
49068
+ async reconnect() {
49069
+ if (this.stopping) {
49070
+ return {
49071
+ ok: false,
49072
+ message: "This session is shutting down, so its Cotal mesh connection cannot be reconnected. Start a new session instead."
49073
+ };
49074
+ }
49075
+ try {
49076
+ await this.ep.reconnect();
49077
+ return { ok: true, message: `Reconnected \u2713 (${this.config.name}@${this.config.space})` };
49078
+ } catch (e) {
49079
+ return { ok: false, message: `Reconnect failed: ${e.message}. Still retrying automatically \u2014 or run /reconnect to retry now.` };
49080
+ }
48671
49081
  }
48672
49082
  // ---- inbox ---------------------------------------------------------------
48673
49083
  ingest(m, delivery, meta3) {
@@ -48795,7 +49205,7 @@ var MeshAgent = class extends import_node_events2.EventEmitter {
48795
49205
  const clean = normalizeMentions(mentions);
48796
49206
  if (clean)
48797
49207
  this.assertKnownMentions(clean);
48798
- return this.ep.multicast(text, { channel, mentions: clean });
49208
+ return this.ep.multicast(text, { channel, mentions: clean, contextId: this._contextId });
48799
49209
  }
48800
49210
  /** Throw if any name isn't a peer we've observed. Validates against the FULL roster
48801
49211
  * (incl. self — your own name is a valid participant; resolvePeer's self-filter would
@@ -48811,24 +49221,20 @@ var MeshAgent = class extends import_node_events2.EventEmitter {
48811
49221
  }
48812
49222
  async anycast(role, text) {
48813
49223
  this.assertConnected();
48814
- return this.ep.anycast(role, text);
49224
+ return this.ep.anycast(role, text, { contextId: this._contextId });
48815
49225
  }
48816
- /** Resolve a peer by instance id (exact) or display name (case-insensitive, prefer present). */
49226
+ /** Resolve a peer by instance id (exact) or display name. Deterministic and fail-loud: returns
49227
+ * one peer, `undefined` if none match, or throws `AmbiguousPeerError` on a same-name collision —
49228
+ * it never silently picks. See `resolvePeer` in @cotal-ai/core. */
48817
49229
  resolvePeer(target) {
48818
- const roster = this.ep.getRoster().filter((p) => p.card.id !== this.id);
48819
- const byId = roster.find((p) => p.card.id === target);
48820
- if (byId)
48821
- return byId;
48822
- const t = target.toLowerCase();
48823
- const present = roster.filter((p) => p.status !== "offline");
48824
- return present.find((p) => p.card.name.toLowerCase() === t) ?? roster.find((p) => p.card.name.toLowerCase() === t);
49230
+ return resolvePeer(this.ep.getRoster(), target, { selfId: this.id });
48825
49231
  }
48826
49232
  async dm(target, text) {
48827
49233
  this.assertConnected();
48828
49234
  const peer = this.resolvePeer(target);
48829
49235
  if (!peer)
48830
49236
  throw new Error(`no peer "${target}" in space "${this.config.space}"`);
48831
- const msg = await this.ep.unicast(peer.card.id, text);
49237
+ const msg = await this.ep.unicast(peer.card.id, text, { contextId: this._contextId });
48832
49238
  return { msg, peer };
48833
49239
  }
48834
49240
  // ---- supervision ---------------------------------------------------------
@@ -48837,23 +49243,32 @@ var MeshAgent = class extends import_node_events2.EventEmitter {
48837
49243
  * runtime; from here it just joins the mesh as a lateral peer. */
48838
49244
  async spawn(name, role) {
48839
49245
  this.assertConnected();
48840
- return this.ep.requestControl("manager", { op: "start", args: { name, role } });
49246
+ return this.ep.requestControl(CONTROL_PRIVILEGED, { op: "start", args: { name, role } });
48841
49247
  }
48842
49248
  /** Ask the manager to tear a teammate down (its `stop` op). Graceful by default —
48843
49249
  * the session is told to exit cleanly (so it leaves the mesh) before the
48844
- * process/tab is closed; `graceful:false` is a hard, immediate kill. */
49250
+ * process/tab is closed; `graceful:false` is a hard, immediate kill.
49251
+ *
49252
+ * No `name` ⇒ self-despawn: rides the self-service control subject and the manager
49253
+ * resolves the target as the managed agent whose id == this caller — so it can only
49254
+ * ever stop itself, never a peer. A `name` ⇒ rides the privileged control subject
49255
+ * (transport-gated to spawn-capable/admin); the manager refines own-child vs admin. */
48845
49256
  async despawn(name, opts) {
48846
49257
  this.assertConnected();
48847
- return this.ep.requestControl("manager", {
49258
+ const graceful = opts?.graceful ?? true;
49259
+ if (!name) {
49260
+ return this.ep.requestControl(CONTROL_SELF_SERVICE, { op: "stop", args: { graceful } });
49261
+ }
49262
+ return this.ep.requestControl(CONTROL_PRIVILEGED, {
48848
49263
  op: "stop",
48849
- args: { name, graceful: opts?.graceful ?? true }
49264
+ args: { name, graceful }
48850
49265
  });
48851
49266
  }
48852
49267
  /** Ask the manager to purge the space's retained chat backlog (its `purge` op). Cleanup only —
48853
49268
  * it doesn't touch live agents or the anycast work queue. `includeDms` also clears DM history. */
48854
49269
  async purgeHistory(opts) {
48855
49270
  this.assertConnected();
48856
- return this.ep.requestControl("manager", {
49271
+ return this.ep.requestControl(CONTROL_PRIVILEGED, {
48857
49272
  op: "purge",
48858
49273
  args: { includeDms: opts?.includeDms ?? false }
48859
49274
  });
@@ -48863,9 +49278,10 @@ var MeshAgent = class extends import_node_events2.EventEmitter {
48863
49278
  * half — so peers see the new persona; `spawn(name)` then launches an agent wearing it. */
48864
49279
  async definePersona(def) {
48865
49280
  this.assertConnected();
48866
- const reply = await this.ep.requestControl("manager", {
49281
+ const reply = await this.ep.requestControl(CONTROL_PRIVILEGED, {
48867
49282
  op: "definePersona",
48868
- args: { name: def.name, role: def.role, model: def.model, persona: def.prompt }
49283
+ // role is policy set at spawn, never via definePersona; the manager ignores it regardless.
49284
+ args: { name: def.name, model: def.model, persona: def.prompt }
48869
49285
  });
48870
49286
  if (reply.ok)
48871
49287
  await this.send(`persona \`${def.name}\` is now available \u2014 spawn it to bring it online`);
@@ -48968,6 +49384,13 @@ function controlSocketPath(space, name) {
48968
49384
  var import_node_child_process = require("node:child_process");
48969
49385
  var ok = (text) => ({ text });
48970
49386
  var err = (text) => ({ text, isError: true });
49387
+ function controlFailure(action, e) {
49388
+ const detail = e?.message ?? String(e);
49389
+ if (isPermissionDenied(e)) {
49390
+ 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}]`);
49391
+ }
49392
+ return err(`${action}: no manager reachable (${detail}). Is the manager running?`);
49393
+ }
48971
49394
  function statusGlyph(s) {
48972
49395
  return s === "working" ? "\u25CF" : s === "waiting" ? "\u25D0" : s === "idle" ? "\u25CB" : "\xB7";
48973
49396
  }
@@ -49040,10 +49463,16 @@ function cotalToolSpecs(config2, source = "connector") {
49040
49463
  const roster = agent.roster();
49041
49464
  if (!roster.length)
49042
49465
  return ok(`No one is present in "${config2.space}" yet.`);
49466
+ const counts = /* @__PURE__ */ new Map();
49467
+ for (const p of roster) {
49468
+ const n = p.card.name.toLowerCase();
49469
+ counts.set(n, (counts.get(n) ?? 0) + 1);
49470
+ }
49043
49471
  const lines = roster.map((p) => {
49044
49472
  const who2 = p.card.role ? `${p.card.name}/${p.card.role}` : p.card.name;
49045
49473
  const me = p.card.id === agent.id ? ` (you${agent.attention !== "open" ? `, ${agent.attention}` : ""})` : "";
49046
- return `${statusGlyph(p.status)} ${who2} \u2014 ${p.status}${p.activity ? `: ${p.activity}` : ""}${me}`;
49474
+ const id = (counts.get(p.card.name.toLowerCase()) ?? 0) > 1 ? ` \u2014 id: ${p.card.id}` : "";
49475
+ return `${statusGlyph(p.status)} ${who2} \u2014 ${p.status}${p.activity ? `: ${p.activity}` : ""}${me}${id}`;
49047
49476
  });
49048
49477
  return ok(`Present in "${config2.space}" (${roster.length}):
49049
49478
  ${lines.join("\n")}`);
@@ -49086,7 +49515,7 @@ ${all.map(fmtItem).join("\n")}`);
49086
49515
  description: "Broadcast a message to everyone on a channel in your space.",
49087
49516
  schema: {
49088
49517
  text: external_exports.string().describe("The message to broadcast."),
49089
- 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.`),
49518
+ 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.`),
49090
49519
  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.")
49091
49520
  },
49092
49521
  async run(agent, _config, { text: msg, channel, mentions }) {
@@ -49111,6 +49540,11 @@ ${all.map(fmtItem).join("\n")}`);
49111
49540
  const { peer } = await agent.dm(to, stripFaceTags(msg));
49112
49541
  return ok(`DM sent to ${peer.card.name}.`);
49113
49542
  } catch (e) {
49543
+ if (e instanceof AmbiguousPeerError) {
49544
+ const who2 = e.candidates.map((c) => ` \u2022 ${c.name}${c.role ? `/${c.role}` : ""} (${c.status}) \u2014 id: ${c.id}`).join("\n");
49545
+ 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":
49546
+ ${who2}`);
49547
+ }
49114
49548
  return err(`Couldn't DM: ${e.message}`);
49115
49549
  }
49116
49550
  }
@@ -49196,11 +49630,13 @@ ${lines.join("\n")}`);
49196
49630
  {
49197
49631
  name: "cotal_join",
49198
49632
  title: "Cotal: join a channel",
49199
- 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.",
49633
+ 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.",
49200
49634
  schema: {
49201
49635
  channel: external_exports.string().describe("The channel to join (e.g. incident).")
49202
49636
  },
49203
49637
  async run(agent, _config, { channel }) {
49638
+ if (!channelInAllow(config2.allowSubscribe, channel))
49639
+ return err(`Can't join #${channel}: it's outside your read ACL (allowSubscribe: ${config2.allowSubscribe.map((c) => `#${c}`).join(", ")}).`);
49204
49640
  try {
49205
49641
  const r = await agent.joinChannel(channel);
49206
49642
  if (!r.joined)
@@ -49236,7 +49672,7 @@ ${info}${caught}`);
49236
49672
  title: "Cotal: spawn a new teammate",
49237
49673
  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.",
49238
49674
  schema: {
49239
- name: external_exports.string().describe("Unique name for the new peer."),
49675
+ name: external_exports.string().describe("Name for the new peer; auto-numbered (e.g. reviewer-2) if taken."),
49240
49676
  role: external_exports.string().optional().describe("Optional role for the new peer (e.g. worker, reviewer).")
49241
49677
  },
49242
49678
  async run(agent, _config, { name, role }) {
@@ -49244,10 +49680,14 @@ ${info}${caught}`);
49244
49680
  const reply = await agent.spawn(name, role);
49245
49681
  if (!reply.ok)
49246
49682
  return err(`Couldn't spawn ${name}: ${reply.error ?? "manager refused"}`);
49247
- const mode = reply.data?.mode;
49248
- return ok(`Spawning ${role ? `${name}/${role}` : name}${mode ? ` (${mode})` : ""} \u2014 it will appear in the roster shortly.`);
49683
+ const d = reply.data;
49684
+ const actual = d?.name ?? name;
49685
+ const mode = d?.mode;
49686
+ const who2 = role ? `${actual}/${role}` : actual;
49687
+ const lead = actual !== name ? `"${name}" was taken \u2014 spawning ${who2} instead` : `Spawning ${who2}`;
49688
+ return ok(`${lead}${mode ? ` (${mode})` : ""} \u2014 it will appear in the roster shortly.`);
49249
49689
  } catch (e) {
49250
- return err(`Couldn't spawn ${name}: no manager reachable (${e.message}). Is the manager running?`);
49690
+ return controlFailure(`Couldn't spawn ${name}`, e);
49251
49691
  }
49252
49692
  }
49253
49693
  },
@@ -49303,63 +49743,52 @@ ${info}${caught}`);
49303
49743
  {
49304
49744
  name: "cotal_despawn",
49305
49745
  title: "Cotal: stop a teammate",
49306
- 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.",
49746
+ 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.",
49307
49747
  schema: {
49308
- name: external_exports.string().describe("Name of the peer to stop."),
49748
+ name: external_exports.string().optional().describe("Name of the peer to stop. Omit to stop yourself (self-despawn)."),
49309
49749
  graceful: external_exports.boolean().optional().describe("Default true: let the session exit cleanly. false = hard kill.")
49310
49750
  },
49311
49751
  async run(agent, _config, { name, graceful }) {
49312
49752
  try {
49313
49753
  const reply = await agent.despawn(name, { graceful });
49314
- if (!reply.ok)
49315
- return err(`Couldn't despawn ${name}: ${reply.error ?? "manager refused"}`);
49316
- return ok(`Stopping ${name}${graceful === false ? " (hard)" : ""} \u2014 it will leave the roster shortly.`);
49317
- } catch (e) {
49318
- return err(`Couldn't despawn ${name}: no manager reachable (${e.message}). Is the manager running?`);
49319
- }
49320
- }
49321
- },
49322
- {
49323
- name: "cotal_purge",
49324
- title: "Cotal: clear chat history",
49325
- 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.",
49326
- schema: {
49327
- includeDms: external_exports.boolean().optional().describe("Default false: channel history only. true = also purge DM history.")
49328
- },
49329
- async run(agent, _config, { includeDms }) {
49330
- try {
49331
- const reply = await agent.purgeHistory({ includeDms });
49332
- if (!reply.ok)
49333
- return err(`Couldn't purge history: ${reply.error ?? "manager refused"}`);
49334
- const d = reply.data;
49335
- const chat = d?.chat ?? 0;
49336
- const dm = d?.dm;
49337
- return ok(`Cleared ${chat} channel message${chat === 1 ? "" : "s"}${dm === void 0 ? "" : ` and ${dm} DM${dm === 1 ? "" : "s"}`} from "${_config.space}".`);
49754
+ if (!reply.ok) {
49755
+ return err(`Couldn't despawn ${name ?? "self"}: ${reply.error ?? "manager refused"}`);
49756
+ }
49757
+ const who2 = name ?? "self";
49758
+ return ok(`Stopping ${who2}${graceful === false ? " (hard)" : ""} \u2014 it will leave the roster shortly.`);
49338
49759
  } catch (e) {
49339
- return err(`Couldn't purge history: no manager reachable (${e.message}). Is the manager running?`);
49760
+ return controlFailure(`Couldn't despawn ${name ?? "self"}`, e);
49340
49761
  }
49341
49762
  }
49342
49763
  },
49343
49764
  {
49344
49765
  name: "cotal_persona",
49345
49766
  title: "Cotal: define a persona",
49346
- 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.",
49767
+ 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).",
49347
49768
  schema: {
49348
49769
  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 -."),
49349
49770
  prompt: external_exports.string().max(1e4).describe("The persona \u2014 an appended system prompt describing who this agent is."),
49350
- role: external_exports.string().max(120).optional().describe("Optional role label (e.g. reviewer, scout)."),
49351
49771
  model: external_exports.string().max(120).optional().describe("Optional model override (e.g. opus, sonnet).")
49352
49772
  },
49353
- async run(agent, _config, { name, prompt, role, model }) {
49773
+ async run(agent, _config, { name, prompt, model }) {
49354
49774
  try {
49355
- const reply = await agent.definePersona({ name, prompt, role, model });
49775
+ const reply = await agent.definePersona({ name, prompt, model });
49356
49776
  if (!reply.ok)
49357
49777
  return err(`Couldn't define ${name}: ${reply.error ?? "manager refused"}`);
49358
49778
  return ok(`Persona \`${name}\` saved \u2014 spawn it with cotal_spawn(name="${name}") to bring it online.`);
49359
49779
  } catch (e) {
49360
- return err(`Couldn't define ${name}: no manager reachable (${e.message}). Is the manager running?`);
49780
+ return controlFailure(`Couldn't define ${name}`, e);
49361
49781
  }
49362
49782
  }
49783
+ },
49784
+ {
49785
+ name: "cotal_reconnect",
49786
+ title: "Cotal: reconnect to the mesh",
49787
+ 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).",
49788
+ async run(agent) {
49789
+ const r = await agent.reconnect();
49790
+ return r.ok ? ok(r.message) : err(r.message);
49791
+ }
49363
49792
  }
49364
49793
  ];
49365
49794
  }