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