@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 +11 -0
- package/dist/extension.d.ts.map +1 -1
- package/dist/extension.js +9 -7
- package/dist/extension.js.map +1 -1
- package/package.json +4 -3
- package/plugin/cotal/_sidecar/standalone.cjs +609 -184
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.
|
package/dist/extension.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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
|
};
|
package/dist/extension.js.map
CHANGED
|
@@ -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;
|
|
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
|
-
"
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
16510
|
-
//
|
|
16511
|
-
//
|
|
16574
|
+
// Direct Get API stays enabled on CHAT (harmless: agents hold no DIRECT.GET grant). Per-channel
|
|
16575
|
+
// history reads no longer use it — they 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
|
|
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
|
-
|
|
16652
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
16949
|
-
|
|
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(
|
|
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.
|
|
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(
|
|
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(
|
|
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
|
-
|
|
17163
|
-
|
|
17164
|
-
|
|
17165
|
-
|
|
17166
|
-
|
|
17167
|
-
|
|
17168
|
-
|
|
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
|
-
/**
|
|
17301
|
-
*
|
|
17302
|
-
*
|
|
17303
|
-
*
|
|
17304
|
-
*
|
|
17305
|
-
|
|
17306
|
-
|
|
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
|
|
17309
|
-
const
|
|
17310
|
-
const
|
|
17311
|
-
|
|
17312
|
-
|
|
17313
|
-
|
|
17314
|
-
|
|
17315
|
-
|
|
17316
|
-
|
|
17317
|
-
|
|
17318
|
-
|
|
17319
|
-
|
|
17320
|
-
|
|
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 (
|
|
17323
|
-
|
|
17324
|
-
|
|
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
|
-
|
|
17332
|
-
|
|
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
|
-
|
|
17337
|
-
if (e.code === 404)
|
|
17690
|
+
if (got < want)
|
|
17338
17691
|
break;
|
|
17339
|
-
|
|
17340
|
-
|
|
17692
|
+
pending -= got;
|
|
17693
|
+
}
|
|
17694
|
+
} finally {
|
|
17695
|
+
try {
|
|
17696
|
+
await this.jsm.consumers.delete(stream, name);
|
|
17697
|
+
} catch {
|
|
17341
17698
|
}
|
|
17342
|
-
|
|
17343
|
-
|
|
17344
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
17363
|
-
* app gate
|
|
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(
|
|
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
|
-
|
|
17376
|
-
|
|
17377
|
-
let last = 0;
|
|
17378
|
-
let got = 0;
|
|
17764
|
+
for (const sm of raw) {
|
|
17765
|
+
let msg;
|
|
17379
17766
|
try {
|
|
17380
|
-
|
|
17381
|
-
|
|
17382
|
-
|
|
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
|
-
|
|
17406
|
-
|
|
17407
|
-
|
|
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
|
|
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
|
|
17444
|
-
|
|
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
|
-
|
|
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
|
|
17649
|
-
const
|
|
17650
|
-
const
|
|
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
|
-
|
|
17662
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
17740
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
18347
|
+
const reply = await this.ep.requestControl(CONTROL_PRIVILEGED, {
|
|
17937
18348
|
op: "definePersona",
|
|
17938
|
-
|
|
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
|
-
|
|
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.
|
|
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("
|
|
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
|
|
32808
|
-
|
|
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
|
|
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
|
-
|
|
32877
|
-
|
|
32878
|
-
return
|
|
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
|
|
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
|
|
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,
|
|
33329
|
+
async run(agent, _config, { name, prompt, model }) {
|
|
32914
33330
|
try {
|
|
32915
|
-
const reply = await agent.definePersona({ name, prompt,
|
|
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
|
|
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
|
}
|