@cotal-ai/connector-hermes 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/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # @cotal-ai/connector-hermes
2
+
3
+ The Hermes (Nous Research) adapter: connects the Hermes agent to the mesh as a lateral peer.
4
+ Ships a Python sidecar (`pyproject.toml` / `uv.lock`) alongside the TypeScript connector. A
5
+ sibling of the Claude Code and OpenCode adapters, over
6
+ [`@cotal-ai/connector-core`](../connector-core).
7
+
8
+ **Tier:** `extensions/`. Peer-depends [`@cotal-ai/core`](../../packages/core); self-registers on
9
+ import.
10
+
11
+ See the [root AGENTS.md](../../AGENTS.md) for the tier rules.
@@ -1 +1 @@
1
- {"version":3,"file":"extension.d.ts","sourceRoot":"","sources":["../src/extension.ts"],"names":[],"mappings":"AACA,OAAO,EAAY,KAAK,SAAS,EAAoC,MAAM,gBAAgB,CAAC;AAc5F;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,EAAE,SAgB7B,CAAC"}
1
+ {"version":3,"file":"extension.d.ts","sourceRoot":"","sources":["../src/extension.ts"],"names":[],"mappings":"AACA,OAAO,EAAY,KAAK,SAAS,EAAoC,MAAM,gBAAgB,CAAC;AAW5F;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,EAAE,SAsB7B,CAAC"}
package/dist/extension.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import { registry } from "@cotal-ai/core";
3
+ import { launchEnv, MODEL_PROVIDER_KEYS } from "@cotal-ai/connector-core";
3
4
  /** The launcher (run via tsx, which loads both) owns the mesh endpoint and supervises the Hermes
4
5
  * gateway as a child — see launch.ts. Resolve `.ts` when this module loads from source (dev) and
5
6
  * `.js` when it loads from the build: the package's `import` resolves to dist/, so a hardcoded
@@ -7,9 +8,6 @@ import { registry } from "@cotal-ai/core";
7
8
  const ENTRY_EXT = import.meta.url.includes("/dist/") ? "js" : "ts";
8
9
  const TSX = fileURLToPath(new URL("../node_modules/.bin/tsx", import.meta.url));
9
10
  const LAUNCH_ENTRY = fileURLToPath(new URL(`./launch.${ENTRY_EXT}`, import.meta.url));
10
- /** Provider keys forwarded to the Hermes gateway if present. Hermes is model-agnostic; any one
11
- * of these unlocks a provider. We forward, never require — the operator's own keys, untouched. */
12
- const PROVIDER_KEYS = ["OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "NOUS_API_KEY"];
13
11
  /**
14
12
  * The Hermes (Nous Research) connector. Unlike Claude Code / Codex — where the harness *is* the
15
13
  * process and an MCP server rides inside it — Hermes runs as a long-lived **gateway daemon** that
@@ -24,7 +22,14 @@ export const hermesConnector = {
24
22
  kind: "connector",
25
23
  name: "hermes",
26
24
  buildLaunch(opts) {
27
- const env = { COTAL_SPACE: opts.space, COTAL_NAME: opts.name };
25
+ // OS allow-list + the named model-provider key (Hermes is model-agnostic; any one unlocks a
26
+ // provider), forwarded BY NAME — never `...process.env` — so the operator's unrelated secrets
27
+ // don't reach the gateway child (P3).
28
+ const env = {
29
+ ...launchEnv({ providerKeys: MODEL_PROVIDER_KEYS }),
30
+ COTAL_SPACE: opts.space,
31
+ COTAL_NAME: opts.name,
32
+ };
28
33
  if (opts.role)
29
34
  env.COTAL_ROLE = opts.role;
30
35
  if (opts.id)
@@ -39,9 +44,6 @@ export const hermesConnector = {
39
44
  env.COTAL_AGENT_FILE = opts.configPath;
40
45
  if (process.env.HERMES_MODEL)
41
46
  env.HERMES_MODEL = process.env.HERMES_MODEL;
42
- for (const k of PROVIDER_KEYS)
43
- if (process.env[k])
44
- env[k] = process.env[k];
45
47
  return { command: TSX, args: [LAUNCH_ENTRY], env };
46
48
  },
47
49
  };
@@ -1 +1 @@
1
- {"version":3,"file":"extension.js","sourceRoot":"","sources":["../src/extension.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,QAAQ,EAAoD,MAAM,gBAAgB,CAAC;AAE5F;;;2DAG2D;AAC3D,MAAM,SAAS,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;AACnE,MAAM,GAAG,GAAG,aAAa,CAAC,IAAI,GAAG,CAAC,0BAA0B,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAChF,MAAM,YAAY,GAAG,aAAa,CAAC,IAAI,GAAG,CAAC,YAAY,SAAS,EAAE,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAEtF;mGACmG;AACnG,MAAM,aAAa,GAAG,CAAC,oBAAoB,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,cAAc,CAAC,CAAC;AAEpG;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,eAAe,GAAc;IACxC,IAAI,EAAE,WAAW;IACjB,IAAI,EAAE,QAAQ;IACd,WAAW,CAAC,IAAgB;QAC1B,MAAM,GAAG,GAA2B,EAAE,WAAW,EAAE,IAAI,CAAC,KAAK,EAAE,UAAU,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC;QACvF,IAAI,IAAI,CAAC,IAAI;YAAE,GAAG,CAAC,UAAU,GAAG,IAAI,CAAC,IAAI,CAAC;QAC1C,IAAI,IAAI,CAAC,EAAE;YAAE,GAAG,CAAC,QAAQ,GAAG,IAAI,CAAC,EAAE,CAAC;QACpC,IAAI,IAAI,CAAC,KAAK;YAAE,GAAG,CAAC,WAAW,GAAG,IAAI,CAAC,KAAK,CAAC;QAC7C,IAAI,IAAI,CAAC,OAAO;YAAE,GAAG,CAAC,aAAa,GAAG,IAAI,CAAC,OAAO,CAAC;QACnD,wFAAwF;QACxF,mFAAmF;QACnF,IAAI,IAAI,CAAC,UAAU;YAAE,GAAG,CAAC,gBAAgB,GAAG,IAAI,CAAC,UAAU,CAAC;QAC5D,IAAI,OAAO,CAAC,GAAG,CAAC,YAAY;YAAE,GAAG,CAAC,YAAY,GAAG,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC;QAC1E,KAAK,MAAM,CAAC,IAAI,aAAa;YAAE,IAAI,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC;gBAAE,GAAG,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,CAAE,CAAC;QAC5E,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC,YAAY,CAAC,EAAE,GAAG,EAAE,CAAC;IACrD,CAAC;CACF,CAAC;AAEF,QAAQ,CAAC,QAAQ,CAAC,eAAe,CAAC,CAAC"}
1
+ {"version":3,"file":"extension.js","sourceRoot":"","sources":["../src/extension.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,QAAQ,EAAoD,MAAM,gBAAgB,CAAC;AAC5F,OAAO,EAAE,SAAS,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAE1E;;;2DAG2D;AAC3D,MAAM,SAAS,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;AACnE,MAAM,GAAG,GAAG,aAAa,CAAC,IAAI,GAAG,CAAC,0BAA0B,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAChF,MAAM,YAAY,GAAG,aAAa,CAAC,IAAI,GAAG,CAAC,YAAY,SAAS,EAAE,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAEtF;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,eAAe,GAAc;IACxC,IAAI,EAAE,WAAW;IACjB,IAAI,EAAE,QAAQ;IACd,WAAW,CAAC,IAAgB;QAC1B,4FAA4F;QAC5F,8FAA8F;QAC9F,sCAAsC;QACtC,MAAM,GAAG,GAA2B;YAClC,GAAG,SAAS,CAAC,EAAE,YAAY,EAAE,mBAAmB,EAAE,CAAC;YACnD,WAAW,EAAE,IAAI,CAAC,KAAK;YACvB,UAAU,EAAE,IAAI,CAAC,IAAI;SACtB,CAAC;QACF,IAAI,IAAI,CAAC,IAAI;YAAE,GAAG,CAAC,UAAU,GAAG,IAAI,CAAC,IAAI,CAAC;QAC1C,IAAI,IAAI,CAAC,EAAE;YAAE,GAAG,CAAC,QAAQ,GAAG,IAAI,CAAC,EAAE,CAAC;QACpC,IAAI,IAAI,CAAC,KAAK;YAAE,GAAG,CAAC,WAAW,GAAG,IAAI,CAAC,KAAK,CAAC;QAC7C,IAAI,IAAI,CAAC,OAAO;YAAE,GAAG,CAAC,aAAa,GAAG,IAAI,CAAC,OAAO,CAAC;QACnD,wFAAwF;QACxF,mFAAmF;QACnF,IAAI,IAAI,CAAC,UAAU;YAAE,GAAG,CAAC,gBAAgB,GAAG,IAAI,CAAC,UAAU,CAAC;QAC5D,IAAI,OAAO,CAAC,GAAG,CAAC,YAAY;YAAE,GAAG,CAAC,YAAY,GAAG,OAAO,CAAC,GAAG,CAAC,YAAa,CAAC;QAC3E,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC,YAAY,CAAC,EAAE,GAAG,EAAE,CAAC;IACrD,CAAC;CACF,CAAC;AAEF,QAAQ,CAAC,QAAQ,CAAC,eAAe,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@cotal-ai/connector-hermes",
3
- "version": "0.3.2",
3
+ "description": "Cotal connector for the Hermes (Nous Research) agent.",
4
+ "version": "0.4.0",
4
5
  "license": "Apache-2.0",
5
6
  "repository": {
6
7
  "type": "git",
@@ -22,14 +23,14 @@
22
23
  "dependencies": {
23
24
  "tsx": "^4.22.4",
24
25
  "zod": "^4.4.3",
25
- "@cotal-ai/connector-core": "0.3.2"
26
+ "@cotal-ai/connector-core": "0.4.0"
26
27
  },
27
28
  "peerDependencies": {
28
29
  "@cotal-ai/core": ">=0.1.0"
29
30
  },
30
31
  "devDependencies": {
31
32
  "esbuild": "^0.28.0",
32
- "@cotal-ai/core": "0.3.2"
33
+ "@cotal-ai/core": "0.4.0"
33
34
  },
34
35
  "files": [
35
36
  "bin",
@@ -14758,7 +14758,7 @@ function subjectMatches(pattern, subject) {
14758
14758
  const s = subject.split(".");
14759
14759
  for (let i = 0; i < p.length; i++) {
14760
14760
  if (p[i] === ">")
14761
- return true;
14761
+ return i < s.length;
14762
14762
  if (i >= s.length)
14763
14763
  return false;
14764
14764
  if (p[i] === "*")
@@ -14768,6 +14768,26 @@ function subjectMatches(pattern, subject) {
14768
14768
  }
14769
14769
  return p.length === s.length;
14770
14770
  }
14771
+ function assertValidChannel(channel) {
14772
+ const segs = channel.split(".");
14773
+ if (!channel.length || segs.some((s) => s.length === 0))
14774
+ throw new Error(`invalid channel "${channel}": empty segment (no leading/trailing/double dots)`);
14775
+ segs.forEach((s, i) => {
14776
+ if (s === ">") {
14777
+ if (i !== segs.length - 1)
14778
+ throw new Error(`invalid channel "${channel}": '>' is only valid as the last segment`);
14779
+ return;
14780
+ }
14781
+ if (s === "*")
14782
+ return;
14783
+ if (!/^[A-Za-z0-9_-]+$/.test(s))
14784
+ 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`);
14785
+ });
14786
+ return channel;
14787
+ }
14788
+ function channelInAllow(allow, channel) {
14789
+ return allow.some((a) => subjectMatches(a, channel));
14790
+ }
14771
14791
  function collapseFilterSubjects(subjects) {
14772
14792
  const uniq = [...new Set(subjects)];
14773
14793
  return uniq.filter((x) => !uniq.some((y) => y !== x && subjectMatches(y, x)));
@@ -14781,6 +14801,8 @@ function anycastSubject(space, service, sender) {
14781
14801
  function controlServiceSubject(space, service, sender) {
14782
14802
  return `${spacePrefix(space)}.ctl.${routeToken(service)}.${routeToken(sender)}`;
14783
14803
  }
14804
+ var CONTROL_PRIVILEGED = "manager";
14805
+ var CONTROL_SELF_SERVICE = "self";
14784
14806
  function spaceWildcard(space) {
14785
14807
  return `${spacePrefix(space)}.>`;
14786
14808
  }
@@ -14820,6 +14842,9 @@ function taskStream(space) {
14820
14842
  function chatDurable(instance) {
14821
14843
  return `chat_${token(instance)}`;
14822
14844
  }
14845
+ function chatHistDurable(instance) {
14846
+ return `chathist_${token(instance)}`;
14847
+ }
14823
14848
  function dmDurable(instance) {
14824
14849
  return `dm_${token(instance)}`;
14825
14850
  }
@@ -14827,6 +14852,46 @@ function taskDurable(service) {
14827
14852
  return `svc_${token(service)}`;
14828
14853
  }
14829
14854
 
14855
+ // ../../packages/core/dist/resolve.js
14856
+ var AmbiguousPeerError = class extends Error {
14857
+ target;
14858
+ candidates;
14859
+ constructor(target, candidates) {
14860
+ 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.`);
14861
+ this.target = target;
14862
+ this.candidates = candidates;
14863
+ this.name = "AmbiguousPeerError";
14864
+ }
14865
+ };
14866
+ function candidate(p) {
14867
+ return { id: p.card.id, name: p.card.name, role: p.card.role, status: p.status, ts: p.ts };
14868
+ }
14869
+ function resolvePeer(roster, target, opts = {}) {
14870
+ const peers = opts.selfId ? roster.filter((p) => p.card.id !== opts.selfId) : roster;
14871
+ const byId = peers.find((p) => p.card.id === target);
14872
+ if (byId)
14873
+ return byId;
14874
+ const want = target.trim().toLowerCase();
14875
+ if (!want)
14876
+ return void 0;
14877
+ const matches = peers.filter((p) => p.card.name.toLowerCase() === want);
14878
+ if (matches.length === 0)
14879
+ return void 0;
14880
+ const live = matches.filter((p) => p.status !== "offline");
14881
+ const pool = live.length > 0 ? live : matches;
14882
+ if (pool.length === 1)
14883
+ return pool[0];
14884
+ throw new AmbiguousPeerError(target, pool.map(candidate));
14885
+ }
14886
+ function assertValidName(name) {
14887
+ if (name.length === 0 || name !== name.trim())
14888
+ throw new Error(`invalid name ${JSON.stringify(name)}: must be non-empty with no surrounding whitespace`);
14889
+ if (/[\r\n]/.test(name))
14890
+ throw new Error(`invalid name ${JSON.stringify(name)}: must be a single line`);
14891
+ if (name.includes("/"))
14892
+ throw new Error(`invalid name ${JSON.stringify(name)}: "/" is reserved (the owner/name separator)`);
14893
+ }
14894
+
14830
14895
  // ../../packages/core/dist/link.js
14831
14896
  function parseJoinLink(link) {
14832
14897
  const tls = link.startsWith("cotals://");
@@ -16506,9 +16571,10 @@ async function createSpaceStreams(jsm, space) {
16506
16571
  max_msgs_per_subject: MAX_MSGS_PER_SUBJECT,
16507
16572
  // capped per-channel backlog (buffer + history)
16508
16573
  discard: import_jetstream.DiscardPolicy.Old,
16509
- // Enable the read-only Direct Get API for per-channel history backfill on join (a pure
16510
- // read verb, no consumer create). CHAT ONLY never DM/TASK: direct-get bypasses the
16511
- // consumer-create deny that is DM's confidentiality boundary.
16574
+ // Direct Get API stays enabled on CHAT (harmless: agents hold no DIRECT.GET grant). Per-channel
16575
+ // history reads no longer use itthey go through contained single-filter ephemeral consumers
16576
+ // (endpoint `collectHistory`) so the read ACL bounds them. NEVER set on DM/TASK: direct-get
16577
+ // would bypass the consumer-create deny that is DM's confidentiality boundary.
16512
16578
  allow_direct: true
16513
16579
  });
16514
16580
  await jsm.streams.add({
@@ -16536,6 +16602,18 @@ function dmDurableConfig(space, id, opts = {}) {
16536
16602
  cfg.inactive_threshold = (0, import_transport_node.nanos)(opts.inactiveThresholdMs);
16537
16603
  return cfg;
16538
16604
  }
16605
+ function chatDurableConfig(space, id, channels, opts = {}) {
16606
+ const cfg = {
16607
+ durable_name: chatDurable(id),
16608
+ filter_subjects: collapseFilterSubjects(channels.map((ch) => chatSubject(space, "*", ch))),
16609
+ ack_policy: import_jetstream.AckPolicy.Explicit,
16610
+ ack_wait: (0, import_transport_node.nanos)(opts.ackWaitMs ?? 6e4),
16611
+ deliver_policy: import_jetstream.DeliverPolicy.New
16612
+ };
16613
+ if (opts.inactiveThresholdMs)
16614
+ cfg.inactive_threshold = (0, import_transport_node.nanos)(opts.inactiveThresholdMs);
16615
+ return cfg;
16616
+ }
16539
16617
  function taskDurableConfig(space, role, opts = {}) {
16540
16618
  return {
16541
16619
  durable_name: taskDurable(role),
@@ -16634,10 +16712,28 @@ function loadAgentFile(path) {
16634
16712
  const name = str("name");
16635
16713
  if (!name)
16636
16714
  throw new Error(`agent file ${path}: "name" is required`);
16715
+ assertValidName(name);
16637
16716
  const kind = str("kind");
16638
16717
  if (kind && kind !== "agent" && kind !== "endpoint")
16639
16718
  throw new Error(`agent file ${path}: "kind" must be "agent" or "endpoint"`);
16640
- const known = /* @__PURE__ */ new Set(["name", "role", "kind", "description", "tags", "channels", "publish", "model"]);
16719
+ for (const old of ["channels", "publish"])
16720
+ if (old in fm)
16721
+ throw new Error(`agent file ${path}: "${old}" was renamed \u2014 use "subscribe"/"allowSubscribe" (read) and "allowPublish" (post)`);
16722
+ const subscribe = list("subscribe");
16723
+ const allowSubscribe = list("allowSubscribe");
16724
+ const allowPublish = list("allowPublish");
16725
+ for (const ch of [...subscribe ?? [], ...allowSubscribe ?? [], ...allowPublish ?? []])
16726
+ try {
16727
+ assertValidChannel(ch);
16728
+ } catch (e) {
16729
+ throw new Error(`agent file ${path}: ${e.message}`);
16730
+ }
16731
+ const effSubscribe = subscribe?.length ? subscribe : ["general"];
16732
+ const effAllow = allowSubscribe?.length ? allowSubscribe : effSubscribe;
16733
+ for (const ch of effSubscribe)
16734
+ if (!channelInAllow(effAllow, ch))
16735
+ throw new Error(`agent file ${path}: subscribe channel "${ch}" is not within allowSubscribe [${effAllow.join(", ")}]`);
16736
+ const known = /* @__PURE__ */ new Set(["name", "role", "kind", "description", "tags", "subscribe", "allowSubscribe", "allowPublish", "model", "capabilities", "owner"]);
16641
16737
  const meta3 = {};
16642
16738
  for (const [k, v] of Object.entries(fm))
16643
16739
  if (!known.has(k) && typeof v === "string")
@@ -16648,9 +16744,12 @@ function loadAgentFile(path) {
16648
16744
  kind,
16649
16745
  description: str("description"),
16650
16746
  tags: list("tags"),
16651
- channels: list("channels"),
16652
- publish: list("publish"),
16747
+ subscribe,
16748
+ allowSubscribe,
16749
+ allowPublish,
16653
16750
  model: str("model"),
16751
+ capabilities: list("capabilities"),
16752
+ owner: str("owner"),
16654
16753
  meta: Object.keys(meta3).length ? meta3 : void 0,
16655
16754
  persona: persona || void 0
16656
16755
  };
@@ -16693,6 +16792,9 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
16693
16792
  * a lagging joiner + dedups the backfill overlap). Keyed by the subscription pattern (may be
16694
16793
  * wildcard), so the drop matches every concrete channel the pattern subsumes. */
16695
16794
  joinSeq = /* @__PURE__ */ new Map();
16795
+ /** Serializes history reads ({@link collectHistory}): they share the fixed per-instance
16796
+ * `chathist_<id>` consumer, so overlapping reads would delete/recreate it under one another. */
16797
+ histLock = Promise.resolve();
16696
16798
  subs = [];
16697
16799
  streamMsgs = [];
16698
16800
  heartbeatTimer;
@@ -16701,9 +16803,24 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
16701
16803
  status = "idle";
16702
16804
  activity;
16703
16805
  stopped = false;
16806
+ /** In-flight rebuild (drain+rebind) — serializes manual reconnect, the supervisor's
16807
+ * closed(), and reestablishLoop so only ONE rebuild runs at a time (a second trigger
16808
+ * coalesces onto the shared promise, never starts a parallel connectAndBind). */
16809
+ rebuildPromise;
16810
+ /** True only during the null window of a rebuild (this.nc unset) — user-facing ops then
16811
+ * throw a "reconnecting" message instead of the misleading "endpoint not started". */
16812
+ reconnecting = false;
16813
+ /** One reestablishLoop at a time; concurrent triggers coalesce via rebuild(). */
16814
+ reestablishing = false;
16815
+ /** Interruptible backoff for reestablishLoop — reconnect()/stop() resolves this to retry
16816
+ * now instead of awaiting the full retryMs. */
16817
+ backoffResolve;
16818
+ backoffTimer;
16819
+ retryMs = 3e3;
16704
16820
  constructor(opts) {
16705
16821
  super();
16706
16822
  this.space = opts.space;
16823
+ assertValidName(opts.card.name);
16707
16824
  const credId = opts.creds ? idFromCreds(opts.creds) : void 0;
16708
16825
  if (opts.card.id && credId && opts.card.id !== credId)
16709
16826
  throw new Error(`card.id ${opts.card.id} != creds identity ${credId} \u2014 they must be the same nkey`);
@@ -16728,6 +16845,15 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
16728
16845
  return { id: this.card.id, name: this.card.name, role: this.card.role };
16729
16846
  }
16730
16847
  async start() {
16848
+ await this.connectAndBind();
16849
+ this.superviseConnection();
16850
+ }
16851
+ /** Open the connection and bind everything that hangs off it: status watch, presence
16852
+ * watch + heartbeat, channel registry, and the durable consumers. Re-runnable — a
16853
+ * reconnect calls it again after {@link clearConnectionScoped}; every binding is
16854
+ * idempotent (durables bind by name, JetStream dedups by msgID, KV opens are idempotent). */
16855
+ async connectAndBind() {
16856
+ this.clearConnectionScoped();
16731
16857
  this.nc = await (0, import_transport_node3.connect)({
16732
16858
  servers: this.servers,
16733
16859
  name: `cotal:${this.card.name}`,
@@ -16766,11 +16892,167 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
16766
16892
  await this.ensureStreams();
16767
16893
  await this.startConsumers();
16768
16894
  }
16895
+ this.emit("connection", { connected: true });
16896
+ }
16897
+ /** Tear down everything {@link connectAndBind} (re)creates, so a rebind can't leak a
16898
+ * second heartbeat, double-pump a consumer, or keep stale roster ghosts. Caller-owned
16899
+ * subs (tap/serve) are left alone — they aren't rebuilt here. */
16900
+ clearConnectionScoped() {
16901
+ if (this.heartbeatTimer) {
16902
+ clearInterval(this.heartbeatTimer);
16903
+ this.heartbeatTimer = void 0;
16904
+ }
16905
+ if (this.sweepTimer) {
16906
+ clearInterval(this.sweepTimer);
16907
+ this.sweepTimer = void 0;
16908
+ }
16909
+ for (const msgs of this.streamMsgs) {
16910
+ try {
16911
+ msgs.stop();
16912
+ } catch {
16913
+ }
16914
+ }
16915
+ this.streamMsgs.length = 0;
16916
+ this.roster.clear();
16917
+ this.joinSeq.clear();
16918
+ this.channelConfigs.clear();
16919
+ this.channelDefaults = {};
16920
+ }
16921
+ /** If stop() ran during a rebuild's `await connectAndBind`, the just-bound connection +
16922
+ * heartbeat + supervisor would be left live on a stopped endpoint. Tear that fresh
16923
+ * connection back down and report it. Reads `this.nc` in its own scope (a bare `this.nc`
16924
+ * in doRebuild narrows to `never` via TS inlining connectAndBind's assignment). Returns
16925
+ * true iff it tore something down (caller bails out of the rebuild). */
16926
+ async tearDownIfStopped() {
16927
+ if (!this.stopped)
16928
+ return false;
16929
+ const nc = this.nc;
16930
+ this.clearConnectionScoped();
16931
+ try {
16932
+ await nc?.drain();
16933
+ } catch {
16934
+ }
16935
+ this.nc = void 0;
16936
+ return true;
16937
+ }
16938
+ /** Watch for a terminal close (nats.js has exhausted its own reconnect) and rebuild.
16939
+ * Our own stop()/drain also resolves closed(), so the `stopped` guard keeps a clean
16940
+ * shutdown from re-establishing. The identity guard (`this.nc !== nc`) no-ops a STALE
16941
+ * supervisor — one whose connection reconnect()/rebuild already replaced — so only a
16942
+ * close of the CURRENT connection triggers a rebuild. The rebuild itself is serialized
16943
+ * with the manual path via {@link rebuild}. */
16944
+ superviseConnection() {
16945
+ const nc = this.nc;
16946
+ if (!nc)
16947
+ return;
16948
+ void nc.closed().then((err2) => {
16949
+ if (this.stopped)
16950
+ return;
16951
+ if (this.nc !== nc)
16952
+ return;
16953
+ this.emit("connection", { connected: false });
16954
+ this.emit("error", new Error(`mesh connection closed${err2 ? `: ${err2.message}` : ""} \u2014 re-establishing`));
16955
+ void this.reestablishLoop();
16956
+ });
16957
+ }
16958
+ /** Single serialized rebuild: drain the old connection and rebind via {@link connectAndBind},
16959
+ * guarded so concurrent triggers (manual {@link reconnect}, the supervisor's closed(), the
16960
+ * retry loop) coalesce onto ONE in-flight rebuild instead of racing two connectAndBinds and
16961
+ * leaking a connection. Returns the shared promise; a second caller gets the in-flight one. */
16962
+ rebuild() {
16963
+ if (this.rebuildPromise)
16964
+ return this.rebuildPromise;
16965
+ const p = this.doRebuild().finally(() => {
16966
+ if (this.rebuildPromise === p)
16967
+ this.rebuildPromise = void 0;
16968
+ });
16969
+ this.rebuildPromise = p;
16970
+ return p;
16971
+ }
16972
+ /** The transition: stop the connection-scoped timers FIRST (so nothing live touches
16973
+ * this.nc during the null window), drop the connection refs, drain the old nc, then
16974
+ * rebind + re-arm the supervisor on the fresh connection. clearConnectionScoped is
16975
+ * idempotent, so connectAndBind's own call here is a noop. */
16976
+ async doRebuild() {
16977
+ const oldNc = this.nc;
16978
+ this.reconnecting = true;
16979
+ try {
16980
+ this.clearConnectionScoped();
16981
+ this.nc = void 0;
16982
+ this.js = void 0;
16983
+ this.jsm = void 0;
16984
+ this.kv = void 0;
16985
+ this.channelKv = void 0;
16986
+ this.emit("connection", { connected: false });
16987
+ try {
16988
+ await oldNc?.drain();
16989
+ } catch {
16990
+ }
16991
+ await this.connectAndBind();
16992
+ if (await this.tearDownIfStopped())
16993
+ return;
16994
+ this.superviseConnection();
16995
+ } finally {
16996
+ this.reconnecting = false;
16997
+ }
16998
+ }
16999
+ /** Rebuild with backoff until it sticks or we're stopped. Interruptible: a manual
17000
+ * {@link reconnect} kicks the backoff so the next attempt runs immediately instead of
17001
+ * awaiting the full retryMs. One loop at a time ({@link reestablishing}); concurrent
17002
+ * triggers coalesce via {@link rebuild}. */
17003
+ async reestablishLoop() {
17004
+ if (this.reestablishing)
17005
+ return;
17006
+ this.reestablishing = true;
17007
+ try {
17008
+ while (!this.stopped) {
17009
+ try {
17010
+ await this.rebuild();
17011
+ return;
17012
+ } catch (e) {
17013
+ if (!this.stopped)
17014
+ this.emit("error", e);
17015
+ await new Promise((resolve) => {
17016
+ this.backoffResolve = resolve;
17017
+ this.backoffTimer = setTimeout(resolve, this.retryMs);
17018
+ });
17019
+ }
17020
+ }
17021
+ } finally {
17022
+ this.reestablishing = false;
17023
+ }
17024
+ }
17025
+ /** Cut an in-flight reestablish backoff short so the next attempt runs immediately, and
17026
+ * clear its timer so it can't fire later on a stopped/restarted loop. */
17027
+ kickBackoff() {
17028
+ this.backoffResolve?.();
17029
+ if (this.backoffTimer) {
17030
+ clearTimeout(this.backoffTimer);
17031
+ this.backoffTimer = void 0;
17032
+ }
17033
+ }
17034
+ /** Manual reconnect: tear down the current connection and rebuild, WITHOUT the permanent
17035
+ * stop (stopped/stopping stay false). Serialized with the self-heal supervisor via
17036
+ * {@link rebuild}, and interruptible — if a backoff is in flight, kick it so the attempt
17037
+ * is now, not in retryMs. Throws if stopped. On failure, leaves {@link reestablishLoop}
17038
+ * running in the background so the endpoint never stays dead, and rethrows so the caller
17039
+ * can report it. */
17040
+ async reconnect() {
17041
+ if (this.stopped)
17042
+ throw new Error("endpoint stopped \u2014 cannot reconnect");
17043
+ this.kickBackoff();
17044
+ try {
17045
+ await this.rebuild();
17046
+ } catch (e) {
17047
+ void this.reestablishLoop();
17048
+ throw e;
17049
+ }
16769
17050
  }
16770
17051
  async stop() {
16771
17052
  if (this.stopped)
16772
17053
  return;
16773
17054
  this.stopped = true;
17055
+ this.kickBackoff();
16774
17056
  if (this.heartbeatTimer)
16775
17057
  clearInterval(this.heartbeatTimer);
16776
17058
  if (this.sweepTimer)
@@ -16899,7 +17181,7 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
16899
17181
  /** Send a control request to a service and await its reply (client side). */
16900
17182
  async requestControl(service, req, timeoutMs = 5e3) {
16901
17183
  if (!this.nc)
16902
- throw new Error("endpoint not started");
17184
+ throw new Error(this.notLiveMsg());
16903
17185
  const body = { ...req, from: req.from ?? this.ref() };
16904
17186
  const m = await this.nc.request(controlServiceSubject(this.space, service, this.card.id), JSON.stringify(body), { timeout: timeoutMs });
16905
17187
  return m.json();
@@ -16940,14 +17222,16 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
16940
17222
  */
16941
17223
  async joinChannel(channel) {
16942
17224
  if (!this.jsm)
16943
- throw new Error("endpoint not started");
17225
+ throw new Error(this.notLiveMsg());
16944
17226
  if (this.channels.includes(channel))
16945
17227
  return { joined: false, backfilled: 0 };
16946
- const next = collapseFilterSubjects([...this.channels, channel].map((ch) => chatSubject(this.space, "*", ch)));
16947
17228
  const armed = await this.armJoin([channel]);
16948
- await this.jsm.consumers.update(chatStream(this.space), chatDurable(this.card.id), {
16949
- filter_subjects: next
16950
- });
17229
+ try {
17230
+ await this.setChatFilter([...this.channels, channel]);
17231
+ } catch (e) {
17232
+ this.joinSeq.delete(channel);
17233
+ throw e;
17234
+ }
16951
17235
  this.channels.push(channel);
16952
17236
  const backfilled = await this.backfillArmed(armed);
16953
17237
  return { joined: true, backfilled };
@@ -16957,26 +17241,51 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
16957
17241
  * leaving). Returns whether anything changed. */
16958
17242
  async leaveChannel(channel) {
16959
17243
  if (!this.jsm)
16960
- throw new Error("endpoint not started");
17244
+ throw new Error(this.notLiveMsg());
16961
17245
  const i = this.channels.indexOf(channel);
16962
17246
  if (i < 0)
16963
17247
  return { left: false };
16964
17248
  if (this.channels.length === 1)
16965
17249
  throw new Error(`cannot leave "${channel}" \u2014 it is your only channel (an empty filter would subscribe to all)`);
16966
17250
  const remaining = this.channels.filter((c) => c !== channel);
16967
- await this.jsm.consumers.update(chatStream(this.space), chatDurable(this.card.id), {
16968
- filter_subjects: collapseFilterSubjects(remaining.map((ch) => chatSubject(this.space, "*", ch)))
16969
- });
17251
+ await this.setChatFilter(remaining);
16970
17252
  this.channels.splice(i, 1);
16971
17253
  this.joinSeq.delete(channel);
16972
17254
  return { left: true };
16973
17255
  }
17256
+ /** Move the chat live-tail durable to a new channel set. OPEN mode self-serves the
17257
+ * `consumers.update` (the agent owns its durable). AUTH mode is bind-only — the agent has no
17258
+ * UPDATE grant — so it sends a mediated control request to the manager, which validates the set
17259
+ * ⊆ its `allowSubscribe` before moving the filter. Throws clearly when no privileged responder is
17260
+ * present: a manager-less standalone auth session is fixed to its boot subscribe set — a
17261
+ * documented limitation, not a silent degrade. */
17262
+ async setChatFilter(channels) {
17263
+ if (!this.jsm)
17264
+ throw new Error(this.notLiveMsg());
17265
+ if (!this.creds) {
17266
+ await this.jsm.consumers.update(chatStream(this.space), chatDurable(this.card.id), {
17267
+ filter_subjects: collapseFilterSubjects(channels.map((ch) => chatSubject(this.space, "*", ch)))
17268
+ });
17269
+ return;
17270
+ }
17271
+ let reply;
17272
+ try {
17273
+ reply = await this.requestControl(CONTROL_SELF_SERVICE, { op: "setChannels", args: { channels } });
17274
+ } catch (e) {
17275
+ const msg = e.message;
17276
+ if (/no responders/i.test(msg))
17277
+ 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");
17278
+ throw e;
17279
+ }
17280
+ if (!reply.ok)
17281
+ throw new Error(reply.error ?? "channel change rejected");
17282
+ }
16974
17283
  /** One coherent channel model for dashboards: every channel that has messages OR a registry
16975
17284
  * entry (configured-but-empty), each tagged with its {@link ChannelConfig}. Works even on
16976
17285
  * observer endpoints (no consumers needed). */
16977
17286
  async listChannels() {
16978
17287
  if (!this.nc)
16979
- throw new Error("endpoint not started");
17288
+ throw new Error(this.notLiveMsg());
16980
17289
  const mgr = await (0, import_jetstream2.jetstreamManager)(this.nc);
16981
17290
  const counts = /* @__PURE__ */ new Map();
16982
17291
  try {
@@ -17099,9 +17408,16 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
17099
17408
  this.emit("error", e);
17100
17409
  });
17101
17410
  }
17411
+ /** The error message for a guard that finds the endpoint unbound: "reconnecting" during a
17412
+ * rebuild's null window OR an inter-retry backoff (so a concurrent op reports the real
17413
+ * reason, not "not started" — `reestablishing` spans the whole retry loop incl. backoff),
17414
+ * else "endpoint not started" (genuine pre-start). */
17415
+ notLiveMsg() {
17416
+ return this.reconnecting || this.reestablishing ? "reconnecting \u2014 try again shortly" : "endpoint not started";
17417
+ }
17102
17418
  async publishMsg(subject, msg) {
17103
17419
  if (!this.js)
17104
- throw new Error("endpoint not started");
17420
+ throw new Error(this.notLiveMsg());
17105
17421
  await this.js.publish(subject, JSON.stringify(msg), { msgID: msg.id });
17106
17422
  }
17107
17423
  /** Create the three backing streams for this space (idempotent). Open-mode lazy create;
@@ -17111,6 +17427,29 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
17111
17427
  throw new Error("endpoint not started");
17112
17428
  await createSpaceStreams(this.jsm, this.space);
17113
17429
  }
17430
+ /**
17431
+ * Privileged: pre-create an agent's bind-only chat live-tail durable (auth mode), filtered to its
17432
+ * `subscribe` set, so the agent can BIND it without holding CONSUMER.CREATE/UPDATE on CHAT — its
17433
+ * live read can't be self-widened past `allowSubscribe`. The creator sets the filter; the agent
17434
+ * never does (mirrors {@link provisionDmInbox}). Idempotent. The caller must be permissive on CHAT.
17435
+ */
17436
+ async provisionChatDurable(targetId, subscribe) {
17437
+ const jsm = await this.manager();
17438
+ await jsm.consumers.add(chatStream(this.space), chatDurableConfig(this.space, targetId, subscribe));
17439
+ }
17440
+ /**
17441
+ * Privileged: move an agent's bind-only chat durable to a new channel set — the write half of the
17442
+ * mediated join/leave. The manager calls this AFTER validating the set ⊆ the agent's
17443
+ * `allowSubscribe`; the agent itself has no UPDATE grant, so this trusted path is the only way its
17444
+ * live filter moves. The filter is rebuilt from channel names here (not from agent-supplied
17445
+ * subjects) so a caller can't smuggle a hand-built filter.
17446
+ */
17447
+ async setChatFilterFor(targetId, channels) {
17448
+ const jsm = await this.manager();
17449
+ await jsm.consumers.update(chatStream(this.space), chatDurable(targetId), {
17450
+ filter_subjects: collapseFilterSubjects(channels.map((ch) => chatSubject(this.space, "*", ch)))
17451
+ });
17452
+ }
17114
17453
  /**
17115
17454
  * Privileged: pre-create an agent's DM inbox durable (auth mode), so the agent can BIND
17116
17455
  * it without holding CONSUMER.CREATE on DM_<space>. The creator sets the filter to
@@ -17145,8 +17484,6 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
17145
17484
  if (!this.jsm)
17146
17485
  throw new Error("endpoint not started");
17147
17486
  const id = this.card.id;
17148
- const ack_wait = (0, import_transport_node3.nanos)(this.ackWaitMs);
17149
- const inactive_threshold = (0, import_transport_node3.nanos)(this.inactiveThresholdMs);
17150
17487
  if (!this.creds) {
17151
17488
  await this.jsm.consumers.add(dmStream(this.space), dmDurableConfig(this.space, id, {
17152
17489
  ackWaitMs: this.ackWaitMs,
@@ -17159,14 +17496,15 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
17159
17496
  const want = collapseFilterSubjects(this.channels.map((ch) => chatSubject(this.space, "*", ch)));
17160
17497
  const info = await this.consumerInfo(chatStream(this.space), durable);
17161
17498
  if (!info) {
17162
- await this.jsm.consumers.add(chatStream(this.space), {
17163
- durable_name: durable,
17164
- filter_subjects: want,
17165
- ack_policy: import_jetstream2.AckPolicy.Explicit,
17166
- ack_wait,
17167
- deliver_policy: import_jetstream2.DeliverPolicy.New,
17168
- inactive_threshold
17169
- });
17499
+ if (this.creds)
17500
+ throw new Error(`chat durable ${durable} not pre-created \u2014 a launcher must call provisionChatDurable (auth mode binds the durable, it never self-creates)`);
17501
+ await this.jsm.consumers.add(chatStream(this.space), chatDurableConfig(this.space, id, this.channels, {
17502
+ ackWaitMs: this.ackWaitMs,
17503
+ inactiveThresholdMs: this.inactiveThresholdMs
17504
+ }));
17505
+ }
17506
+ const consumed = (info?.delivered?.consumer_seq ?? 0) > 0;
17507
+ if (!consumed) {
17170
17508
  const armed = await this.armJoin(this.channels);
17171
17509
  await this.pump(chatStream(this.space), durable);
17172
17510
  await this.backfillArmed(armed);
@@ -17175,7 +17513,7 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
17175
17513
  const haveFilters = info.config.filter_subjects ?? (info.config.filter_subject ? [info.config.filter_subject] : []);
17176
17514
  const gained = this.channels.filter((c) => !haveFilters.some((f) => subjectMatches(f, chatSubject(this.space, "*", c))));
17177
17515
  const armed = gained.length ? await this.armJoin(gained) : void 0;
17178
- if (!sameSet(haveFilters, want))
17516
+ if (!this.creds && !sameSet(haveFilters, want))
17179
17517
  await this.jsm.consumers.update(chatStream(this.space), durable, { filter_subjects: want });
17180
17518
  if (armed)
17181
17519
  await this.backfillArmed(armed);
@@ -17292,63 +17630,107 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
17292
17630
  if (!this.channelKv)
17293
17631
  return { replay: effectiveReplay(void 0, void 0) };
17294
17632
  const [cfg, defaults] = await Promise.all([
17295
- readChannelConfig(this.channelKv, channel),
17633
+ isConcreteChannel(channel) ? readChannelConfig(this.channelKv, channel) : Promise.resolve(void 0),
17296
17634
  readChannelDefaults(this.channelKv)
17297
17635
  ]);
17298
17636
  return { replay: effectiveReplay(cfg, defaults), windowMs: effectiveReplayWindowMs(cfg, defaults) };
17299
17637
  }
17300
- /** Read a channel's retained history up to `upToSeq` via JetStream **Direct Get** (a read
17301
- * verb no consumer create, so it rides a read-only grant) and emit each message as a
17302
- * `historical` "message" event. `sinceMs` bounds how far back via a native Direct-Get
17303
- * `start_time` (now window); unset ⇒ the full retained window. New messages (`seq > upToSeq`)
17304
- * are skipped the live tail owns them. Pages the batch API; the ack handle is a no-op. */
17305
- async backfillChannel(channel, upToSeq, sinceMs) {
17306
- if (!this.jsm)
17638
+ /**
17639
+ * Read retained chat history on ONE channel subject through a name-scoped, single-filter
17640
+ * EPHEMERAL pull consumer the broker-contained replacement for the removed Direct Get. The
17641
+ * create rides `$JS.API.CONSUMER.CREATE.<CHAT>.<chathist_id>.<subject>`, whose trailing filter
17642
+ * token nats-server pins to the request body (JSConsumerCreateFilterSubjectMismatchErr, code
17643
+ * 10131) — so an agent can only ever replay a channel its `allowSubscribe` grants. Single filter
17644
+ * only (plural isn't ACL-constrainable); `AckPolicy.None` + `mem_storage` so it leaves no durable
17645
+ * state, and it is deleted right after. Returns raw messages in stream order from `start`,
17646
+ * stopping once past `untilSeq` (exclusive of it) or after `limit`. The per-instance name means
17647
+ * calls must be serial — every reader here awaits to completion, so they are.
17648
+ */
17649
+ async collectHistory(subject, start, opts = {}) {
17650
+ const run = this.histLock.then(() => this.collectHistoryInner(subject, start, opts));
17651
+ this.histLock = run.catch(() => {
17652
+ });
17653
+ return run;
17654
+ }
17655
+ async collectHistoryInner(subject, start, opts = {}) {
17656
+ if (!this.jsm || !this.js)
17307
17657
  throw new Error("endpoint not started");
17308
- const subject = chatSubject(this.space, "*", channel);
17309
- const collected = [];
17310
- const startTime = sinceMs === void 0 ? void 0 : new Date(Date.now() - sinceMs);
17311
- let startSeq = 1;
17312
- let first = true;
17313
- pages: for (; ; ) {
17314
- let last = 0;
17315
- let got = 0;
17316
- try {
17317
- const query = first && startTime !== void 0 ? { start_time: startTime, next_by_subj: subject, batch: 256 } : { seq: startSeq, next_by_subj: subject, batch: 256 };
17318
- first = false;
17319
- const iter = await this.jsm.direct.getBatch(chatStream(this.space), query);
17320
- for await (const sm of iter) {
17658
+ const stream = chatStream(this.space);
17659
+ const name = chatHistDurable(this.card.id);
17660
+ const out = [];
17661
+ try {
17662
+ await this.jsm.consumers.delete(stream, name);
17663
+ } catch {
17664
+ }
17665
+ await this.jsm.consumers.add(stream, {
17666
+ name,
17667
+ filter_subject: subject,
17668
+ ack_policy: import_jetstream2.AckPolicy.None,
17669
+ mem_storage: true,
17670
+ inactive_threshold: (0, import_transport_node3.nanos)(3e4),
17671
+ ..."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 }
17672
+ });
17673
+ try {
17674
+ const consumer = await this.js.consumers.get(stream, name);
17675
+ let pending = (await consumer.info()).num_pending;
17676
+ while (pending > 0) {
17677
+ const want = Math.min(pending, 256);
17678
+ const iter = await consumer.fetch({ max_messages: want, expires: 5e3 });
17679
+ let got = 0;
17680
+ for await (const m of iter) {
17321
17681
  got++;
17322
- if (sm.seq > upToSeq)
17323
- break pages;
17324
- last = sm.seq;
17325
- let msg;
17326
- try {
17327
- msg = sm.json();
17328
- } catch {
17682
+ if (opts.untilSeq !== void 0 && m.seq > opts.untilSeq)
17683
+ return out;
17684
+ if (!subjectMatches(subject, m.subject))
17329
17685
  continue;
17330
- }
17331
- const parsed = parseSubject(sm.subject);
17332
- if (!parsed || msg.from?.id !== parsed.sender || msg.from.id === this.card.id)
17333
- continue;
17334
- collected.push({ msg, seq: sm.seq });
17686
+ out.push(m);
17687
+ if (opts.limit !== void 0 && out.length >= opts.limit)
17688
+ return out;
17335
17689
  }
17336
- } catch (e) {
17337
- if (e.code === 404)
17690
+ if (got < want)
17338
17691
  break;
17339
- this.emit("error", e);
17340
- break;
17692
+ pending -= got;
17693
+ }
17694
+ } finally {
17695
+ try {
17696
+ await this.jsm.consumers.delete(stream, name);
17697
+ } catch {
17341
17698
  }
17342
- if (got === 0 || last === 0)
17343
- break;
17344
- startSeq = last + 1;
17699
+ }
17700
+ return out;
17701
+ }
17702
+ /** Read a channel's retained history up to `upToSeq` (the join frontier) and emit each message
17703
+ * as a `historical` "message" event. `sinceMs` bounds how far back via a native consumer
17704
+ * `start_time` (now − window); unset ⇒ the full retained window. New messages (`seq > upToSeq`)
17705
+ * are skipped — the live tail owns them. Reads through the contained {@link collectHistory}. */
17706
+ async backfillChannel(channel, upToSeq, sinceMs) {
17707
+ const subject = chatSubject(this.space, "*", channel);
17708
+ const start = sinceMs === void 0 ? { seq: 1 } : { time: new Date(Date.now() - sinceMs) };
17709
+ let msgs;
17710
+ try {
17711
+ msgs = await this.collectHistory(subject, start, { untilSeq: upToSeq });
17712
+ } catch (e) {
17713
+ this.emit("error", e);
17714
+ return 0;
17345
17715
  }
17346
17716
  const noop = { ack: () => {
17347
17717
  }, nak: () => {
17348
17718
  } };
17349
- for (const { msg } of collected)
17719
+ let n = 0;
17720
+ for (const sm of msgs) {
17721
+ let msg;
17722
+ try {
17723
+ msg = sm.json();
17724
+ } catch {
17725
+ continue;
17726
+ }
17727
+ const parsed = parseSubject(sm.subject);
17728
+ if (!parsed || msg.from?.id !== parsed.sender || msg.from.id === this.card.id)
17729
+ continue;
17350
17730
  this.emit("message", msg, noop, { historical: true, kind: "channel" });
17351
- return collected.length;
17731
+ n++;
17732
+ }
17733
+ return n;
17352
17734
  }
17353
17735
  /**
17354
17736
  * Replay-gated pull of a channel's retained ambient from `sinceSeq` (exclusive) forward — the
@@ -17359,52 +17741,37 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
17359
17741
  *
17360
17742
  * Honors the **same** per-channel replay gate as join-backfill ({@link joinPolicyFresh}): a
17361
17743
  * `replay=off` channel returns nothing, so `focus` can't become a history bypass for a channel
17362
- * that denies replay to everyone else (chat is `allow_direct` with no broker-level ACL, so this
17363
- * app gate is the entire boundary).
17744
+ * that denies replay to everyone else (the read ACL bounds *which* channels recall can touch; this
17745
+ * app gate bounds *whether* a permitted channel replays).
17364
17746
  */
17365
17747
  async recallChannel(channel, sinceSeq) {
17366
17748
  if (!this.jsm)
17367
- throw new Error("endpoint not started");
17749
+ throw new Error(this.notLiveMsg());
17368
17750
  if (!isConcreteChannel(channel))
17369
17751
  return { messages: [], dropped: false };
17370
17752
  const policy = await this.joinPolicyFresh(channel);
17371
17753
  if (!policy.replay)
17372
17754
  return { messages: [], dropped: false };
17373
17755
  const subject = chatSubject(this.space, "*", channel);
17756
+ let raw;
17757
+ try {
17758
+ raw = await this.collectHistory(subject, { seq: sinceSeq + 1 });
17759
+ } catch (e) {
17760
+ this.emit("error", e);
17761
+ raw = [];
17762
+ }
17374
17763
  const collected = [];
17375
- let startSeq = sinceSeq + 1;
17376
- pages: for (; ; ) {
17377
- let last = 0;
17378
- let got = 0;
17764
+ for (const sm of raw) {
17765
+ let msg;
17379
17766
  try {
17380
- const iter = await this.jsm.direct.getBatch(chatStream(this.space), {
17381
- seq: startSeq,
17382
- next_by_subj: subject,
17383
- batch: 256
17384
- });
17385
- for await (const sm of iter) {
17386
- got++;
17387
- last = sm.seq;
17388
- let msg;
17389
- try {
17390
- msg = sm.json();
17391
- } catch {
17392
- continue;
17393
- }
17394
- const parsed = parseSubject(sm.subject);
17395
- if (!parsed || msg.from?.id !== parsed.sender || msg.from.id === this.card.id)
17396
- continue;
17397
- collected.push(msg);
17398
- }
17399
- } catch (e) {
17400
- if (e.code === 404)
17401
- break;
17402
- this.emit("error", e);
17403
- break;
17767
+ msg = sm.json();
17768
+ } catch {
17769
+ continue;
17404
17770
  }
17405
- if (got === 0 || last === 0)
17406
- break;
17407
- startSeq = last + 1;
17771
+ const parsed = parseSubject(sm.subject);
17772
+ if (!parsed || msg.from?.id !== parsed.sender || msg.from.id === this.card.id)
17773
+ continue;
17774
+ collected.push(msg);
17408
17775
  }
17409
17776
  const dropped = await this.channelDropped(subject, sinceSeq);
17410
17777
  return { messages: collected, dropped };
@@ -17435,22 +17802,16 @@ var CotalEndpoint = class extends import_node_events.EventEmitter {
17435
17802
  return oldest !== void 0 && oldest > sinceSeq + 1;
17436
17803
  }
17437
17804
  /** Sequence of the earliest message still retained on a channel subject (any sender), or
17438
- * undefined if nothing is retained. One 1-message Direct Get — used for the recall drop marker. */
17805
+ * undefined if nothing is retained. One message through the contained {@link collectHistory}
17806
+ * used for the recall drop marker. */
17439
17807
  async channelOldestSeq(subject) {
17440
17808
  if (!this.jsm)
17441
17809
  return void 0;
17442
17810
  try {
17443
- const iter = await this.jsm.direct.getBatch(chatStream(this.space), {
17444
- seq: 1,
17445
- next_by_subj: subject,
17446
- batch: 1
17447
- });
17448
- for await (const sm of iter)
17449
- return sm.seq;
17450
- return void 0;
17811
+ const [first] = await this.collectHistory(subject, { seq: 1 }, { limit: 1 });
17812
+ return first?.seq;
17451
17813
  } catch (e) {
17452
- if (e.code !== 404)
17453
- this.emit("error", e);
17814
+ this.emit("error", e);
17454
17815
  return void 0;
17455
17816
  }
17456
17817
  }
@@ -17599,6 +17960,13 @@ function describeStatusError(err2) {
17599
17960
  }
17600
17961
  return err2;
17601
17962
  }
17963
+ function isPermissionDenied(e) {
17964
+ if (e instanceof import_transport_node3.PermissionViolationError)
17965
+ return true;
17966
+ if (e?.cause instanceof import_transport_node3.PermissionViolationError)
17967
+ return true;
17968
+ return /permissions?\s+violation/i.test(String(e?.message ?? ""));
17969
+ }
17602
17970
 
17603
17971
  // ../../packages/core/dist/spaces.js
17604
17972
  var import_transport_node4 = __toESM(require_transport_node(), 1);
@@ -17645,9 +18013,17 @@ function configFromEnv(env = process.env) {
17645
18013
  const name = env.COTAL_NAME?.trim() || def?.name || (link ? (0, import_node_os.userInfo)().username : void 0);
17646
18014
  if (!name)
17647
18015
  throw new Error("COTAL_NAME, COTAL_AGENT_FILE or COTAL_LINK is required \u2014 a Cotal session needs an explicit identity from its launcher");
17648
- const channels = splitList(env.COTAL_CHANNELS);
17649
- const resolvedChannels = channels.length ? channels : def?.channels ?? link?.channels ?? ["general"];
17650
- const publish = splitList(env.COTAL_PUBLISH);
18016
+ const subscribe = splitList(env.COTAL_SUBSCRIBE);
18017
+ const resolvedSubscribe = subscribe.length ? subscribe : def?.subscribe ?? link?.channels ?? ["general"];
18018
+ const allowSub = splitList(env.COTAL_ALLOW_SUBSCRIBE);
18019
+ const resolvedAllowSub = allowSub.length ? allowSub : def?.allowSubscribe ?? resolvedSubscribe;
18020
+ for (const ch of resolvedSubscribe)
18021
+ if (!channelInAllow(resolvedAllowSub, ch))
18022
+ throw new Error(`COTAL config: subscribe channel "${ch}" is not within allowSubscribe [${resolvedAllowSub.join(", ")}]`);
18023
+ const allowPub = splitList(env.COTAL_ALLOW_PUBLISH);
18024
+ const resolvedAllowPub = allowPub.length ? allowPub : def?.allowPublish ?? [];
18025
+ for (const ch of [...resolvedSubscribe, ...resolvedAllowSub, ...resolvedAllowPub])
18026
+ assertValidChannel(ch);
17651
18027
  const credsPath = env.COTAL_CREDS?.trim();
17652
18028
  return {
17653
18029
  space: env.COTAL_SPACE?.trim() || link?.space || "demo",
@@ -17658,8 +18034,11 @@ function configFromEnv(env = process.env) {
17658
18034
  description: def?.description,
17659
18035
  tags: def?.tags,
17660
18036
  servers: env.COTAL_SERVERS?.trim() || link?.servers || DEFAULT_SERVER,
17661
- channels: resolvedChannels,
17662
- publish: publish.length ? publish : def?.publish ?? resolvedChannels,
18037
+ subscribe: resolvedSubscribe,
18038
+ allowSubscribe: resolvedAllowSub,
18039
+ // Post ACL is default-DENY: only what's explicitly declared (env > agent-file). The broker
18040
+ // enforces it under auth; in open mode posting is unrestricted regardless (see laneLine).
18041
+ allowPublish: resolvedAllowPub,
17663
18042
  kind: env.COTAL_KIND?.trim() || def?.kind || "agent",
17664
18043
  token: env.COTAL_TOKEN?.trim() || link?.token,
17665
18044
  user: link?.user,
@@ -17684,6 +18063,7 @@ var MeshAgent = class extends import_node_events2.EventEmitter {
17684
18063
  _status = "idle";
17685
18064
  _attention = "open";
17686
18065
  // F3: fail-open default; reset to open on SessionStart
18066
+ _contextId;
17687
18067
  /** Chat-stream frontier captured when this agent entered `focus` — recall surfaces ambient
17688
18068
  * published after it ("since you entered focus"). Undefined unless in focus. */
17689
18069
  focusSince;
@@ -17699,7 +18079,8 @@ var MeshAgent = class extends import_node_events2.EventEmitter {
17699
18079
  pass: config2.pass,
17700
18080
  creds: config2.creds,
17701
18081
  tls: config2.tls,
17702
- channels: config2.channels,
18082
+ channels: config2.subscribe,
18083
+ // the endpoint's live filter = the active read set
17703
18084
  card: {
17704
18085
  id: config2.id,
17705
18086
  name: config2.name,
@@ -17711,6 +18092,9 @@ var MeshAgent = class extends import_node_events2.EventEmitter {
17711
18092
  });
17712
18093
  this.ep.on("message", (m, d, meta3) => this.ingest(m, d, meta3));
17713
18094
  this.ep.on("error", (e) => this.log(`endpoint error: ${e.message}`));
18095
+ this.ep.on("connection", (e) => {
18096
+ this._connected = e.connected;
18097
+ });
17714
18098
  }
17715
18099
  get id() {
17716
18100
  return this.ep.card.id;
@@ -17718,6 +18102,11 @@ var MeshAgent = class extends import_node_events2.EventEmitter {
17718
18102
  get connected() {
17719
18103
  return this._connected;
17720
18104
  }
18105
+ /** Correlates outgoing messages to the host agent's current context/window. */
18106
+ setContextId(contextId) {
18107
+ const clean = contextId?.trim();
18108
+ this._contextId = clean ? clean : void 0;
18109
+ }
17721
18110
  /** Begin connecting (with background retry). Returns immediately. */
17722
18111
  start(retryMs = 3e3) {
17723
18112
  void this.connectLoop(retryMs);
@@ -17726,8 +18115,7 @@ var MeshAgent = class extends import_node_events2.EventEmitter {
17726
18115
  while (!this.stopping && !this._connected) {
17727
18116
  try {
17728
18117
  await this.ep.start();
17729
- this._connected = true;
17730
- this.log(`connected to ${this.config.servers} as ${this.who()} in space "${this.config.space}" on #${this.config.channels.join(", #")}`);
18118
+ this.log(`connected to ${this.config.servers} as ${this.who()} in space "${this.config.space}" on #${this.config.subscribe.join(", #")}`);
17731
18119
  } catch (e) {
17732
18120
  this.log(`mesh unreachable (${e.message}); retrying in ${retryMs}ms`);
17733
18121
  await sleep(retryMs);
@@ -17736,8 +18124,26 @@ var MeshAgent = class extends import_node_events2.EventEmitter {
17736
18124
  }
17737
18125
  async stop() {
17738
18126
  this.stopping = true;
17739
- if (this._connected)
17740
- await this.ep.stop();
18127
+ await this.ep.stop();
18128
+ }
18129
+ /** Manual reconnect: tear down the mesh connection and rebuild it in-process, WITHOUT
18130
+ * stopping the agent (the recovery path, so it does NOT assert connected). Delegates to
18131
+ * {@link CotalEndpoint.reconnect}, which is serialized with the self-heal supervisor and
18132
+ * interruptible. Returns a one-line status for the caller to surface (e.g. the
18133
+ * cotal_reconnect tool → TUI); on failure the endpoint keeps retrying in the background. */
18134
+ async reconnect() {
18135
+ if (this.stopping) {
18136
+ return {
18137
+ ok: false,
18138
+ message: "This session is shutting down, so its Cotal mesh connection cannot be reconnected. Start a new session instead."
18139
+ };
18140
+ }
18141
+ try {
18142
+ await this.ep.reconnect();
18143
+ return { ok: true, message: `Reconnected \u2713 (${this.config.name}@${this.config.space})` };
18144
+ } catch (e) {
18145
+ return { ok: false, message: `Reconnect failed: ${e.message}. Still retrying automatically \u2014 or run /reconnect to retry now.` };
18146
+ }
17741
18147
  }
17742
18148
  // ---- inbox ---------------------------------------------------------------
17743
18149
  ingest(m, delivery, meta3) {
@@ -17865,7 +18271,7 @@ var MeshAgent = class extends import_node_events2.EventEmitter {
17865
18271
  const clean = normalizeMentions(mentions);
17866
18272
  if (clean)
17867
18273
  this.assertKnownMentions(clean);
17868
- return this.ep.multicast(text, { channel, mentions: clean });
18274
+ return this.ep.multicast(text, { channel, mentions: clean, contextId: this._contextId });
17869
18275
  }
17870
18276
  /** Throw if any name isn't a peer we've observed. Validates against the FULL roster
17871
18277
  * (incl. self — your own name is a valid participant; resolvePeer's self-filter would
@@ -17881,24 +18287,20 @@ var MeshAgent = class extends import_node_events2.EventEmitter {
17881
18287
  }
17882
18288
  async anycast(role, text) {
17883
18289
  this.assertConnected();
17884
- return this.ep.anycast(role, text);
18290
+ return this.ep.anycast(role, text, { contextId: this._contextId });
17885
18291
  }
17886
- /** Resolve a peer by instance id (exact) or display name (case-insensitive, prefer present). */
18292
+ /** Resolve a peer by instance id (exact) or display name. Deterministic and fail-loud: returns
18293
+ * one peer, `undefined` if none match, or throws `AmbiguousPeerError` on a same-name collision —
18294
+ * it never silently picks. See `resolvePeer` in @cotal-ai/core. */
17887
18295
  resolvePeer(target) {
17888
- const roster = this.ep.getRoster().filter((p) => p.card.id !== this.id);
17889
- const byId = roster.find((p) => p.card.id === target);
17890
- if (byId)
17891
- return byId;
17892
- const t = target.toLowerCase();
17893
- const present = roster.filter((p) => p.status !== "offline");
17894
- return present.find((p) => p.card.name.toLowerCase() === t) ?? roster.find((p) => p.card.name.toLowerCase() === t);
18296
+ return resolvePeer(this.ep.getRoster(), target, { selfId: this.id });
17895
18297
  }
17896
18298
  async dm(target, text) {
17897
18299
  this.assertConnected();
17898
18300
  const peer = this.resolvePeer(target);
17899
18301
  if (!peer)
17900
18302
  throw new Error(`no peer "${target}" in space "${this.config.space}"`);
17901
- const msg = await this.ep.unicast(peer.card.id, text);
18303
+ const msg = await this.ep.unicast(peer.card.id, text, { contextId: this._contextId });
17902
18304
  return { msg, peer };
17903
18305
  }
17904
18306
  // ---- supervision ---------------------------------------------------------
@@ -17907,23 +18309,32 @@ var MeshAgent = class extends import_node_events2.EventEmitter {
17907
18309
  * runtime; from here it just joins the mesh as a lateral peer. */
17908
18310
  async spawn(name, role) {
17909
18311
  this.assertConnected();
17910
- return this.ep.requestControl("manager", { op: "start", args: { name, role } });
18312
+ return this.ep.requestControl(CONTROL_PRIVILEGED, { op: "start", args: { name, role } });
17911
18313
  }
17912
18314
  /** Ask the manager to tear a teammate down (its `stop` op). Graceful by default —
17913
18315
  * the session is told to exit cleanly (so it leaves the mesh) before the
17914
- * process/tab is closed; `graceful:false` is a hard, immediate kill. */
18316
+ * process/tab is closed; `graceful:false` is a hard, immediate kill.
18317
+ *
18318
+ * No `name` ⇒ self-despawn: rides the self-service control subject and the manager
18319
+ * resolves the target as the managed agent whose id == this caller — so it can only
18320
+ * ever stop itself, never a peer. A `name` ⇒ rides the privileged control subject
18321
+ * (transport-gated to spawn-capable/admin); the manager refines own-child vs admin. */
17915
18322
  async despawn(name, opts) {
17916
18323
  this.assertConnected();
17917
- return this.ep.requestControl("manager", {
18324
+ const graceful = opts?.graceful ?? true;
18325
+ if (!name) {
18326
+ return this.ep.requestControl(CONTROL_SELF_SERVICE, { op: "stop", args: { graceful } });
18327
+ }
18328
+ return this.ep.requestControl(CONTROL_PRIVILEGED, {
17918
18329
  op: "stop",
17919
- args: { name, graceful: opts?.graceful ?? true }
18330
+ args: { name, graceful }
17920
18331
  });
17921
18332
  }
17922
18333
  /** Ask the manager to purge the space's retained chat backlog (its `purge` op). Cleanup only —
17923
18334
  * it doesn't touch live agents or the anycast work queue. `includeDms` also clears DM history. */
17924
18335
  async purgeHistory(opts) {
17925
18336
  this.assertConnected();
17926
- return this.ep.requestControl("manager", {
18337
+ return this.ep.requestControl(CONTROL_PRIVILEGED, {
17927
18338
  op: "purge",
17928
18339
  args: { includeDms: opts?.includeDms ?? false }
17929
18340
  });
@@ -17933,9 +18344,10 @@ var MeshAgent = class extends import_node_events2.EventEmitter {
17933
18344
  * half — so peers see the new persona; `spawn(name)` then launches an agent wearing it. */
17934
18345
  async definePersona(def) {
17935
18346
  this.assertConnected();
17936
- const reply = await this.ep.requestControl("manager", {
18347
+ const reply = await this.ep.requestControl(CONTROL_PRIVILEGED, {
17937
18348
  op: "definePersona",
17938
- args: { name: def.name, role: def.role, model: def.model, persona: def.prompt }
18349
+ // role is policy set at spawn, never via definePersona; the manager ignores it regardless.
18350
+ args: { name: def.name, model: def.model, persona: def.prompt }
17939
18351
  });
17940
18352
  if (reply.ok)
17941
18353
  await this.send(`persona \`${def.name}\` is now available \u2014 spawn it to bring it online`);
@@ -32542,6 +32954,13 @@ config(en_default());
32542
32954
  // ../connector-core/dist/tool-specs.js
32543
32955
  var ok = (text) => ({ text });
32544
32956
  var err = (text) => ({ text, isError: true });
32957
+ function controlFailure(action, e) {
32958
+ const detail = e?.message ?? String(e);
32959
+ if (isPermissionDenied(e)) {
32960
+ 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}]`);
32961
+ }
32962
+ return err(`${action}: no manager reachable (${detail}). Is the manager running?`);
32963
+ }
32545
32964
  function statusGlyph(s) {
32546
32965
  return s === "working" ? "\u25CF" : s === "waiting" ? "\u25D0" : s === "idle" ? "\u25CB" : "\xB7";
32547
32966
  }
@@ -32600,10 +33019,16 @@ function cotalToolSpecs(config2, source = "connector") {
32600
33019
  const roster = agent.roster();
32601
33020
  if (!roster.length)
32602
33021
  return ok(`No one is present in "${config2.space}" yet.`);
33022
+ const counts = /* @__PURE__ */ new Map();
33023
+ for (const p of roster) {
33024
+ const n = p.card.name.toLowerCase();
33025
+ counts.set(n, (counts.get(n) ?? 0) + 1);
33026
+ }
32603
33027
  const lines = roster.map((p) => {
32604
33028
  const who = p.card.role ? `${p.card.name}/${p.card.role}` : p.card.name;
32605
33029
  const me = p.card.id === agent.id ? ` (you${agent.attention !== "open" ? `, ${agent.attention}` : ""})` : "";
32606
- return `${statusGlyph(p.status)} ${who} \u2014 ${p.status}${p.activity ? `: ${p.activity}` : ""}${me}`;
33030
+ const id = (counts.get(p.card.name.toLowerCase()) ?? 0) > 1 ? ` \u2014 id: ${p.card.id}` : "";
33031
+ return `${statusGlyph(p.status)} ${who} \u2014 ${p.status}${p.activity ? `: ${p.activity}` : ""}${me}${id}`;
32607
33032
  });
32608
33033
  return ok(`Present in "${config2.space}" (${roster.length}):
32609
33034
  ${lines.join("\n")}`);
@@ -32646,7 +33071,7 @@ ${all.map(fmtItem).join("\n")}`);
32646
33071
  description: "Broadcast a message to everyone on a channel in your space.",
32647
33072
  schema: {
32648
33073
  text: external_exports.string().describe("The message to broadcast."),
32649
- 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.`),
33074
+ 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.`),
32650
33075
  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.")
32651
33076
  },
32652
33077
  async run(agent, _config, { text: msg, channel, mentions }) {
@@ -32671,6 +33096,11 @@ ${all.map(fmtItem).join("\n")}`);
32671
33096
  const { peer } = await agent.dm(to, stripFaceTags(msg));
32672
33097
  return ok(`DM sent to ${peer.card.name}.`);
32673
33098
  } catch (e) {
33099
+ if (e instanceof AmbiguousPeerError) {
33100
+ const who = e.candidates.map((c) => ` \u2022 ${c.name}${c.role ? `/${c.role}` : ""} (${c.status}) \u2014 id: ${c.id}`).join("\n");
33101
+ 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":
33102
+ ${who}`);
33103
+ }
32674
33104
  return err(`Couldn't DM: ${e.message}`);
32675
33105
  }
32676
33106
  }
@@ -32756,11 +33186,13 @@ ${lines.join("\n")}`);
32756
33186
  {
32757
33187
  name: "cotal_join",
32758
33188
  title: "Cotal: join a channel",
32759
- 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.",
33189
+ 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.",
32760
33190
  schema: {
32761
33191
  channel: external_exports.string().describe("The channel to join (e.g. incident).")
32762
33192
  },
32763
33193
  async run(agent, _config, { channel }) {
33194
+ if (!channelInAllow(config2.allowSubscribe, channel))
33195
+ return err(`Can't join #${channel}: it's outside your read ACL (allowSubscribe: ${config2.allowSubscribe.map((c) => `#${c}`).join(", ")}).`);
32764
33196
  try {
32765
33197
  const r = await agent.joinChannel(channel);
32766
33198
  if (!r.joined)
@@ -32796,7 +33228,7 @@ ${info}${caught}`);
32796
33228
  title: "Cotal: spawn a new teammate",
32797
33229
  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.",
32798
33230
  schema: {
32799
- name: external_exports.string().describe("Unique name for the new peer."),
33231
+ name: external_exports.string().describe("Name for the new peer; auto-numbered (e.g. reviewer-2) if taken."),
32800
33232
  role: external_exports.string().optional().describe("Optional role for the new peer (e.g. worker, reviewer).")
32801
33233
  },
32802
33234
  async run(agent, _config, { name, role }) {
@@ -32804,10 +33236,14 @@ ${info}${caught}`);
32804
33236
  const reply = await agent.spawn(name, role);
32805
33237
  if (!reply.ok)
32806
33238
  return err(`Couldn't spawn ${name}: ${reply.error ?? "manager refused"}`);
32807
- const mode = reply.data?.mode;
32808
- return ok(`Spawning ${role ? `${name}/${role}` : name}${mode ? ` (${mode})` : ""} \u2014 it will appear in the roster shortly.`);
33239
+ const d = reply.data;
33240
+ const actual = d?.name ?? name;
33241
+ const mode = d?.mode;
33242
+ const who = role ? `${actual}/${role}` : actual;
33243
+ const lead = actual !== name ? `"${name}" was taken \u2014 spawning ${who} instead` : `Spawning ${who}`;
33244
+ return ok(`${lead}${mode ? ` (${mode})` : ""} \u2014 it will appear in the roster shortly.`);
32809
33245
  } catch (e) {
32810
- return err(`Couldn't spawn ${name}: no manager reachable (${e.message}). Is the manager running?`);
33246
+ return controlFailure(`Couldn't spawn ${name}`, e);
32811
33247
  }
32812
33248
  }
32813
33249
  },
@@ -32863,63 +33299,52 @@ ${info}${caught}`);
32863
33299
  {
32864
33300
  name: "cotal_despawn",
32865
33301
  title: "Cotal: stop a teammate",
32866
- 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.",
33302
+ 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.",
32867
33303
  schema: {
32868
- name: external_exports.string().describe("Name of the peer to stop."),
33304
+ name: external_exports.string().optional().describe("Name of the peer to stop. Omit to stop yourself (self-despawn)."),
32869
33305
  graceful: external_exports.boolean().optional().describe("Default true: let the session exit cleanly. false = hard kill.")
32870
33306
  },
32871
33307
  async run(agent, _config, { name, graceful }) {
32872
33308
  try {
32873
33309
  const reply = await agent.despawn(name, { graceful });
32874
- if (!reply.ok)
32875
- return err(`Couldn't despawn ${name}: ${reply.error ?? "manager refused"}`);
32876
- return ok(`Stopping ${name}${graceful === false ? " (hard)" : ""} \u2014 it will leave the roster shortly.`);
32877
- } catch (e) {
32878
- return err(`Couldn't despawn ${name}: no manager reachable (${e.message}). Is the manager running?`);
32879
- }
32880
- }
32881
- },
32882
- {
32883
- name: "cotal_purge",
32884
- title: "Cotal: clear chat history",
32885
- 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.",
32886
- schema: {
32887
- includeDms: external_exports.boolean().optional().describe("Default false: channel history only. true = also purge DM history.")
32888
- },
32889
- async run(agent, _config, { includeDms }) {
32890
- try {
32891
- const reply = await agent.purgeHistory({ includeDms });
32892
- if (!reply.ok)
32893
- return err(`Couldn't purge history: ${reply.error ?? "manager refused"}`);
32894
- const d = reply.data;
32895
- const chat = d?.chat ?? 0;
32896
- const dm = d?.dm;
32897
- return ok(`Cleared ${chat} channel message${chat === 1 ? "" : "s"}${dm === void 0 ? "" : ` and ${dm} DM${dm === 1 ? "" : "s"}`} from "${_config.space}".`);
33310
+ if (!reply.ok) {
33311
+ return err(`Couldn't despawn ${name ?? "self"}: ${reply.error ?? "manager refused"}`);
33312
+ }
33313
+ const who = name ?? "self";
33314
+ return ok(`Stopping ${who}${graceful === false ? " (hard)" : ""} \u2014 it will leave the roster shortly.`);
32898
33315
  } catch (e) {
32899
- return err(`Couldn't purge history: no manager reachable (${e.message}). Is the manager running?`);
33316
+ return controlFailure(`Couldn't despawn ${name ?? "self"}`, e);
32900
33317
  }
32901
33318
  }
32902
33319
  },
32903
33320
  {
32904
33321
  name: "cotal_persona",
32905
33322
  title: "Cotal: define a persona",
32906
- 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.",
33323
+ 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).",
32907
33324
  schema: {
32908
33325
  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 -."),
32909
33326
  prompt: external_exports.string().max(1e4).describe("The persona \u2014 an appended system prompt describing who this agent is."),
32910
- role: external_exports.string().max(120).optional().describe("Optional role label (e.g. reviewer, scout)."),
32911
33327
  model: external_exports.string().max(120).optional().describe("Optional model override (e.g. opus, sonnet).")
32912
33328
  },
32913
- async run(agent, _config, { name, prompt, role, model }) {
33329
+ async run(agent, _config, { name, prompt, model }) {
32914
33330
  try {
32915
- const reply = await agent.definePersona({ name, prompt, role, model });
33331
+ const reply = await agent.definePersona({ name, prompt, model });
32916
33332
  if (!reply.ok)
32917
33333
  return err(`Couldn't define ${name}: ${reply.error ?? "manager refused"}`);
32918
33334
  return ok(`Persona \`${name}\` saved \u2014 spawn it with cotal_spawn(name="${name}") to bring it online.`);
32919
33335
  } catch (e) {
32920
- return err(`Couldn't define ${name}: no manager reachable (${e.message}). Is the manager running?`);
33336
+ return controlFailure(`Couldn't define ${name}`, e);
32921
33337
  }
32922
33338
  }
33339
+ },
33340
+ {
33341
+ name: "cotal_reconnect",
33342
+ title: "Cotal: reconnect to the mesh",
33343
+ 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).",
33344
+ async run(agent) {
33345
+ const r = await agent.reconnect();
33346
+ return r.ok ? ok(r.message) : err(r.message);
33347
+ }
32923
33348
  }
32924
33349
  ];
32925
33350
  }