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