@cotal-ai/connector-core 0.35.0 → 0.37.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 +1 -1
- package/dist/agent.d.ts +73 -17
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +266 -54
- package/dist/agent.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/docs-bundle.generated.js +21 -21
- package/dist/docs-bundle.generated.js.map +1 -1
- package/dist/orientation.d.ts +1 -0
- package/dist/orientation.d.ts.map +1 -1
- package/dist/orientation.js +8 -4
- package/dist/orientation.js.map +1 -1
- package/dist/tool-specs.d.ts.map +1 -1
- package/dist/tool-specs.js +116 -11
- package/dist/tool-specs.js.map +1 -1
- package/package.json +3 -2
package/dist/agent.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
3
|
import { hostname } from "node:os";
|
|
4
|
-
import { normalizeMentions, subjectMatches, isConcreteChannel, assertValidChannel, channelInAllow, resolvePeer as resolvePeerInRoster, CotalEndpoint, BASELINE_LIFECYCLE_ENDPOINT, EpEnvelopeError, isPublishPermissionDenied, unansweredRequest, partsToText, } from "@cotal-ai/core";
|
|
5
|
-
/** Client-side
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* relation by test. */
|
|
4
|
+
import { normalizeMentions, subjectMatches, isConcreteChannel, assertValidChannel, channelInAllow, resolvePeer as resolvePeerInRoster, CotalEndpoint, BASELINE_LIFECYCLE_ENDPOINT, EpEnvelopeError, isPublishPermissionDenied, unansweredRequest, renderLifecycleBlocked, partsToText, } from "@cotal-ai/core";
|
|
5
|
+
/** Client-side floor for a spawn action's submit + follow. The manager acceptance carries the exact
|
|
6
|
+
* connector-selected readiness budget, and core extends the follow through that budget plus
|
|
7
|
+
* delivery margin. This floor still outlives the manager's generic 30s default and protects
|
|
8
|
+
* older responders whose acceptance predates that field. */
|
|
10
9
|
export const SPAWN_TIMEOUT_MS = 40_000;
|
|
11
10
|
/** Grace for a mesh op issued while the link is still coming up. `start()` deliberately returns
|
|
12
11
|
* immediately so the connector's MCP surface boots while the broker is absent — which means a
|
|
@@ -96,22 +95,6 @@ function sleep(ms) {
|
|
|
96
95
|
function ingestDedupKey(id) {
|
|
97
96
|
return id === "" ? undefined : id;
|
|
98
97
|
}
|
|
99
|
-
/**
|
|
100
|
-
* A thin, mesh-native agent: a {@link CotalEndpoint} plus a buffered inbox and
|
|
101
|
-
* name-based peer resolution. This is the shared core behind the MCP server
|
|
102
|
-
* (and, later, the lifecycle hooks) — it owns the NATS connection and presence.
|
|
103
|
-
*
|
|
104
|
-
* Connecting is resilient: {@link start} kicks off a background retry loop so the
|
|
105
|
-
* MCP server is responsive immediately even if the mesh isn't up yet.
|
|
106
|
-
*
|
|
107
|
-
* Emits `"incoming"` (InboxItem) when a message is buffered or an unacked durable copy
|
|
108
|
-
* redelivers, so a push layer can apply its normal delivery policy again; `"mention-wake"`
|
|
109
|
-
* (InboxItem) when a `focus`-mode agent is @-mentioned on a channel — the body was
|
|
110
|
-
* acked-and-dropped (not buffered), so this
|
|
111
|
-
* only asks the push layer to *wake* the agent to pull it; `"wake"` (no payload) to ask that
|
|
112
|
-
* layer to wake the session now (the Stop→idle flush of held messages); `"error"` (Error) for
|
|
113
|
-
* endpoint faults.
|
|
114
|
-
*/
|
|
115
98
|
export class MeshAgent extends EventEmitter {
|
|
116
99
|
ep;
|
|
117
100
|
config;
|
|
@@ -143,10 +126,16 @@ export class MeshAgent extends EventEmitter {
|
|
|
143
126
|
protectedDropIds = new Set();
|
|
144
127
|
dropUnsafe = false;
|
|
145
128
|
_connected = false;
|
|
129
|
+
/** Raw NATS transport liveness, separate from `_connected` (the full Cotal bind/readiness). */
|
|
130
|
+
_transportConnected = false;
|
|
131
|
+
/** Wall-clock time of the latest inbox drain that actually committed at least one delivery.
|
|
132
|
+
* This is measured only after the backing acknowledgements succeed, never inferred from a read
|
|
133
|
+
* attempt or from an empty inbox. */
|
|
134
|
+
_lastInboxDrainedAt;
|
|
146
135
|
/** Latest connection failure, retained until the endpoint binds so a bounded readiness gate can
|
|
147
136
|
* explain why an otherwise healthy host never joined the mesh. */
|
|
148
137
|
lastConnectionError;
|
|
149
|
-
|
|
138
|
+
endpointNoticeLog = new Map();
|
|
150
139
|
_status = "idle";
|
|
151
140
|
_attention = "open"; // F3: fail-open default; reset to open on SessionStart
|
|
152
141
|
_recallCursor = { ts: 0, id: "" };
|
|
@@ -174,7 +163,7 @@ export class MeshAgent extends EventEmitter {
|
|
|
174
163
|
recvKeySeq = 0;
|
|
175
164
|
focusExcludedIds = new Map();
|
|
176
165
|
focusRecallUnsafeChannels = new Set();
|
|
177
|
-
|
|
166
|
+
_stopping = false;
|
|
178
167
|
constructor(config) {
|
|
179
168
|
super();
|
|
180
169
|
this.config = config;
|
|
@@ -218,14 +207,34 @@ export class MeshAgent extends EventEmitter {
|
|
|
218
207
|
});
|
|
219
208
|
this.ep.on("message", (m, d, meta) => this.ingest(m, d, meta));
|
|
220
209
|
this.ep.on("error", (e) => this.handleEndpointError(e));
|
|
210
|
+
// A warning is a condition the endpoint is already surviving. Log it through the same bounded
|
|
211
|
+
// operator sink, but never turn it into connectionIssue: readiness is still true.
|
|
212
|
+
this.ep.on("warning", (e) => this.handleEndpointWarning(e));
|
|
213
|
+
// Two guards, and the comments sit out here so neither anchors a mutation on prose. An
|
|
214
|
+
// in-flight initial bind or rebuild can finish after stop() cleared local state, and shutdown is
|
|
215
|
+
// terminal for this MeshAgent, so a late endpoint edge must not resurrect transport. Separately,
|
|
216
|
+
// nats.js and clean shutdown can both confirm the same edge; a duplicate carries no state change
|
|
217
|
+
// and must not wake consumers or let an old confirmation look like a new outage.
|
|
218
|
+
this.ep.on("transport", (e) => {
|
|
219
|
+
if (this._stopping)
|
|
220
|
+
return;
|
|
221
|
+
if (this._transportConnected === e.connected)
|
|
222
|
+
return;
|
|
223
|
+
this._transportConnected = e.connected;
|
|
224
|
+
this.emit("transport", e);
|
|
225
|
+
});
|
|
221
226
|
// The endpoint's (re)binds are the single source of truth for connectedness: this fires on
|
|
222
227
|
// initial start, manual reconnect, AND the background self-heal — so a recovery the endpoint
|
|
223
228
|
// did on its own can't leave us thinking we're offline (which would skip stop() → leak).
|
|
229
|
+
// Same stop race as the transport handler above: a late connectAndBind completion is not a new
|
|
230
|
+
// session. Kept out of the block so the guard can be anchored on code alone.
|
|
224
231
|
this.ep.on("connection", (e) => {
|
|
232
|
+
if (this._stopping)
|
|
233
|
+
return;
|
|
225
234
|
this._connected = e.connected;
|
|
226
235
|
if (e.connected) {
|
|
227
236
|
this.lastConnectionError = undefined;
|
|
228
|
-
this.
|
|
237
|
+
this.endpointNoticeLog.clear();
|
|
229
238
|
}
|
|
230
239
|
this.emit("connection", e);
|
|
231
240
|
});
|
|
@@ -236,10 +245,34 @@ export class MeshAgent extends EventEmitter {
|
|
|
236
245
|
get connected() {
|
|
237
246
|
return this._connected;
|
|
238
247
|
}
|
|
239
|
-
/**
|
|
248
|
+
/** Whether this session's current NATS transport is live, independent of full endpoint readiness. */
|
|
249
|
+
get transportConnected() {
|
|
250
|
+
return this._transportConnected;
|
|
251
|
+
}
|
|
252
|
+
/** Latest pre-bind failure. A successful bind clears it; stop preserves it for post-mortem diagnosis. */
|
|
240
253
|
get connectionIssue() {
|
|
241
254
|
return this.lastConnectionError;
|
|
242
255
|
}
|
|
256
|
+
/** The latest successful, non-empty inbox drain in this session. */
|
|
257
|
+
get lastInboxDrainedAt() {
|
|
258
|
+
return this._lastInboxDrainedAt;
|
|
259
|
+
}
|
|
260
|
+
/** Whether {@link stop} has been called. Terminal, and never cleared: a stopped session does not
|
|
261
|
+
* serve again. This is the ONLY way to tell a deliberate shutdown from a lost connection, because
|
|
262
|
+
* `stop()` clears readiness and transport together, so those two read identically in both cases. */
|
|
263
|
+
get stopping() {
|
|
264
|
+
return this._stopping;
|
|
265
|
+
}
|
|
266
|
+
/** The three liveness facts combined, in one place. Every combination maps, so a caller never has
|
|
267
|
+
* to guess what an unlisted pair means, and a caller that disagrees with this reading can still
|
|
268
|
+
* read {@link connected}, {@link transportConnected} and {@link stopping} directly. */
|
|
269
|
+
get connectionState() {
|
|
270
|
+
if (this._stopping)
|
|
271
|
+
return "stopped";
|
|
272
|
+
if (this._connected)
|
|
273
|
+
return this._transportConnected ? "ready" : "degraded";
|
|
274
|
+
return this._transportConnected ? "connecting" : "disconnected";
|
|
275
|
+
}
|
|
243
276
|
/** Wait for the endpoint's real post-bind connection signal. `start()` deliberately stays
|
|
244
277
|
* background for connectors whose MCP surface must boot while the broker is absent; a host that
|
|
245
278
|
* advertises mesh readiness uses this bounded gate before making that claim. */
|
|
@@ -285,7 +318,7 @@ export class MeshAgent extends EventEmitter {
|
|
|
285
318
|
return this.connectLoop(retryMs);
|
|
286
319
|
}
|
|
287
320
|
async connectLoop(retryMs) {
|
|
288
|
-
while (!this.
|
|
321
|
+
while (!this._stopping && !this._connected) {
|
|
289
322
|
try {
|
|
290
323
|
await this.ep.start();
|
|
291
324
|
// _connected is set by the endpoint's "connection" event (fired inside start()), not here.
|
|
@@ -293,6 +326,11 @@ export class MeshAgent extends EventEmitter {
|
|
|
293
326
|
}
|
|
294
327
|
catch (e) {
|
|
295
328
|
const error = e instanceof Error ? e : new Error(String(e));
|
|
329
|
+
// stop() can win while the initial endpoint start is still pending. A rejection arriving
|
|
330
|
+
// after that terminal decision is teardown noise, not a new connectionIssue for the stopped
|
|
331
|
+
// session, and there is no next retry to explain or sleep toward.
|
|
332
|
+
if (this._stopping)
|
|
333
|
+
return;
|
|
296
334
|
this.lastConnectionError = error.message;
|
|
297
335
|
this.log(`mesh unreachable (${error.message}); retrying in ${retryMs}ms`);
|
|
298
336
|
await sleep(retryMs);
|
|
@@ -300,7 +338,14 @@ export class MeshAgent extends EventEmitter {
|
|
|
300
338
|
}
|
|
301
339
|
}
|
|
302
340
|
async stop() {
|
|
303
|
-
this.
|
|
341
|
+
this._stopping = true;
|
|
342
|
+
// stop() is a local terminal fact. Do not wait for an endpoint event that intentionally ignores
|
|
343
|
+
// its own stopped close, or leave a cleanly stopped session reporting either state as live.
|
|
344
|
+
this._connected = false;
|
|
345
|
+
if (this._transportConnected) {
|
|
346
|
+
this._transportConnected = false;
|
|
347
|
+
this.emit("transport", { connected: false });
|
|
348
|
+
}
|
|
304
349
|
// Unconditional: a background self-heal can flip _connected without us, so a `_connected`
|
|
305
350
|
// guard could skip the stop and leak the live connection/heartbeat/supervisor. ep.stop() is
|
|
306
351
|
// idempotent (early-returns once stopped), so calling it when already-down is a noop.
|
|
@@ -312,7 +357,7 @@ export class MeshAgent extends EventEmitter {
|
|
|
312
357
|
* interruptible. Returns a one-line status for the caller to surface (e.g. the
|
|
313
358
|
* cotal_reconnect tool → TUI); on failure the endpoint keeps retrying in the background. */
|
|
314
359
|
async reconnect() {
|
|
315
|
-
if (this.
|
|
360
|
+
if (this._stopping) {
|
|
316
361
|
return {
|
|
317
362
|
ok: false,
|
|
318
363
|
message: "This session is shutting down, so its Cotal mesh connection cannot be reconnected. Start a new session instead.",
|
|
@@ -379,8 +424,10 @@ export class MeshAgent extends EventEmitter {
|
|
|
379
424
|
// - `quiet` → buffer ambient as pull-only; an @mention remains automatic. Overrides global
|
|
380
425
|
// `focus` so "retain this channel, but only surface ambient on explicit pull" stays expressible.
|
|
381
426
|
// Focus (global, only when NOT overridden): channel ambient AND @mentions are acked-and-dropped —
|
|
382
|
-
// they stay recallable via cotal_inbox (recallAmbient)
|
|
383
|
-
//
|
|
427
|
+
// they stay recallable via cotal_inbox (recallAmbient), or if recall itself cannot vouch for the
|
|
428
|
+
// channel (replay=off, or a wildcard join — #977), that is reported rather than left silent; an
|
|
429
|
+
// @mention still *wakes* (mention-wake), body pulled (F4=B), never auto-injected (the mention
|
|
430
|
+
// tag is payload-forgeable).
|
|
384
431
|
if (item.kind === "channel") {
|
|
385
432
|
const cm = this.channelModes.get(item.channel ?? "");
|
|
386
433
|
// chatFrontier() is asynchronous. Channel traffic retained while entering focus must not also
|
|
@@ -428,7 +475,7 @@ export class MeshAgent extends EventEmitter {
|
|
|
428
475
|
this.buffer(item, delivery.ack, false);
|
|
429
476
|
}
|
|
430
477
|
buffer(item, ack, pullOnly) {
|
|
431
|
-
this.inbox.push({ item, ack, pullOnly });
|
|
478
|
+
this.inbox.push({ item, ack, pullOnly, receivedAt: Date.now() });
|
|
432
479
|
if (this.inbox.length > MAX_INBOX) {
|
|
433
480
|
// Prefer sacrificing pull-only backlog so it cannot crowd out DMs/mentions. Overflow remains
|
|
434
481
|
// bounded local loss: evicted items are acked without being marked handled.
|
|
@@ -642,7 +689,10 @@ export class MeshAgent extends EventEmitter {
|
|
|
642
689
|
// acking only the selected — silent loss by selection. Identity removes exactly what was taken.
|
|
643
690
|
const taken = new Set(selected);
|
|
644
691
|
this.inbox = this.inbox.filter((p) => !taken.has(p));
|
|
645
|
-
|
|
692
|
+
const items = this.commitPending(selected);
|
|
693
|
+
if (items.length)
|
|
694
|
+
this._lastInboxDrainedAt = Date.now();
|
|
695
|
+
return items;
|
|
646
696
|
}
|
|
647
697
|
/** Ack exact surfaced deliveries without assuming they still form the physical inbox prefix.
|
|
648
698
|
* Takes RECEIVE keys ({@link InboxItem.recvKey}): the wire id for real messages, a minted key
|
|
@@ -664,6 +714,8 @@ export class MeshAgent extends EventEmitter {
|
|
|
664
714
|
}
|
|
665
715
|
this.inbox = this.inbox.filter((p) => !present.has(p.item.recvKey));
|
|
666
716
|
const items = this.commitPending(selected);
|
|
717
|
+
if (items.length)
|
|
718
|
+
this._lastInboxDrainedAt = Date.now();
|
|
667
719
|
for (const id of requested) {
|
|
668
720
|
// A MINTED key (an id-less delivery) is never handled-authority: its wire id is "", which
|
|
669
721
|
// markHandled already refuses, so skipping it here is the same at-least-once stance rather
|
|
@@ -708,6 +760,17 @@ export class MeshAgent extends EventEmitter {
|
|
|
708
760
|
inboxCount(scope = "all") {
|
|
709
761
|
return scope === "all" ? this.inbox.length : this.inbox.filter((p) => this.inScope(p, scope)).length;
|
|
710
762
|
}
|
|
763
|
+
/** Local receive time of the oldest still-buffered automatic delivery, if any. */
|
|
764
|
+
oldestAutomaticReceivedAt() {
|
|
765
|
+
let oldest;
|
|
766
|
+
for (const pending of this.inbox) {
|
|
767
|
+
if (pending.pullOnly)
|
|
768
|
+
continue;
|
|
769
|
+
if (oldest === undefined || pending.receivedAt < oldest)
|
|
770
|
+
oldest = pending.receivedAt;
|
|
771
|
+
}
|
|
772
|
+
return oldest;
|
|
773
|
+
}
|
|
711
774
|
/**
|
|
712
775
|
* How far this session has read the focus-mode channel recall.
|
|
713
776
|
*
|
|
@@ -913,19 +976,24 @@ export class MeshAgent extends EventEmitter {
|
|
|
913
976
|
}
|
|
914
977
|
/** Focus recall: the channel ambient + @mentions ack-dropped since this agent entered focus,
|
|
915
978
|
* read back from the chat stream on demand and **replay-gated per channel** (a `replay=off`
|
|
916
|
-
* channel yields nothing — recall must not become a history
|
|
917
|
-
*
|
|
918
|
-
*
|
|
919
|
-
*
|
|
920
|
-
*
|
|
979
|
+
* channel yields nothing, and is named in `droppedChannels` — recall must not become a history
|
|
980
|
+
* bypass, and it must not claim a suppressed channel's window was empty and complete either).
|
|
981
|
+
* Items are marked `historical` (catch-up framing). `droppedChannels` also names channels whose
|
|
982
|
+
* earliest retained message postdates the focus-watermark — older ambient may have aged out of
|
|
983
|
+
* the per-channel window — and wildcard subscriptions (`team.>`), which recall cannot read back
|
|
984
|
+
* per concrete sub-channel (#977: a wildcard join is not itself a channel ingest can consult a
|
|
985
|
+
* replay policy for) and so cannot vouch for either (never-silent throughout). Empty unless in
|
|
986
|
+
* focus. */
|
|
921
987
|
async recallAmbient() {
|
|
922
988
|
if (this._attention !== "focus" || this.focusSince === undefined)
|
|
923
989
|
return { items: [], droppedChannels: [] };
|
|
924
990
|
const items = [];
|
|
925
991
|
const droppedChannels = [];
|
|
926
992
|
for (const channel of this.ep.joinedChannels()) {
|
|
927
|
-
if (!isConcreteChannel(channel))
|
|
993
|
+
if (!isConcreteChannel(channel)) {
|
|
994
|
+
droppedChannels.push(channel);
|
|
928
995
|
continue;
|
|
996
|
+
}
|
|
929
997
|
if (this.focusRecallUnsafeChannels.has(channel)) {
|
|
930
998
|
droppedChannels.push(channel);
|
|
931
999
|
continue;
|
|
@@ -949,6 +1017,30 @@ export class MeshAgent extends EventEmitter {
|
|
|
949
1017
|
this.assertKnownMentions(clean);
|
|
950
1018
|
return this.ep.multicast(text, { channel, mentions: clean, contextId: this._contextId });
|
|
951
1019
|
}
|
|
1020
|
+
/**
|
|
1021
|
+
* What a caller can TELL about a send target BEFORE the publish: whether the name
|
|
1022
|
+
* already existed (joined, registry, or prior traffic) and close matches when it
|
|
1023
|
+
* did not. Does not refuse create. Graded before multicast so the new message cannot
|
|
1024
|
+
* make the name look pre-existing.
|
|
1025
|
+
*/
|
|
1026
|
+
async describeSendChannel(channel) {
|
|
1027
|
+
if (this.ep.getChannelConfig(channel))
|
|
1028
|
+
return "existing channel";
|
|
1029
|
+
const known = new Set(this.ep.joinedChannels().filter(isConcreteChannel));
|
|
1030
|
+
try {
|
|
1031
|
+
for (const row of await this.ep.listChannels())
|
|
1032
|
+
known.add(row.channel);
|
|
1033
|
+
}
|
|
1034
|
+
catch {
|
|
1035
|
+
/* no stream: joined + registry cache still distinguish a typo of a channel we are on */
|
|
1036
|
+
}
|
|
1037
|
+
if (known.has(channel))
|
|
1038
|
+
return "existing channel";
|
|
1039
|
+
const hints = closeChannelNames(channel, [...known]);
|
|
1040
|
+
if (hints.length)
|
|
1041
|
+
return `new channel - no registry entry and no prior traffic; did you mean ${hints.map((h) => "#" + h).join(", ")}?`;
|
|
1042
|
+
return "new channel - no registry entry and no prior traffic";
|
|
1043
|
+
}
|
|
952
1044
|
/** Throw if any name isn't a peer we've observed. Validates against the FULL roster
|
|
953
1045
|
* (incl. self — your own name is a valid participant; resolvePeer's self-filter would
|
|
954
1046
|
* wrongly reject it), case-insensitively. Send is all-or-nothing: one unknown @name aborts
|
|
@@ -980,10 +1072,9 @@ export class MeshAgent extends EventEmitter {
|
|
|
980
1072
|
return { msg, peer };
|
|
981
1073
|
}
|
|
982
1074
|
// ---- supervision ---------------------------------------------------------
|
|
983
|
-
/** Ask the manager to spawn a new teammate into this space (its `
|
|
984
|
-
*
|
|
985
|
-
*
|
|
986
|
-
* not the 5s op default.
|
|
1075
|
+
/** Ask the manager to spawn a new teammate into this space (its `spawn` action).
|
|
1076
|
+
* The request uses {@link SPAWN_TIMEOUT_MS} as its floor; after acceptance, core follows through
|
|
1077
|
+
* the exact connector-selected readiness budget carried by the acceptance.
|
|
987
1078
|
* How it lands — a detached PTY, a tmux window, a cmux tab — is the manager's
|
|
988
1079
|
* runtime; from here it just joins the mesh as a lateral peer. `opts.agent` picks
|
|
989
1080
|
* the harness (default the manager's `COTAL_DEFAULT_AGENT`, else `cotal`/Claude), `opts.model` /
|
|
@@ -995,10 +1086,63 @@ export class MeshAgent extends EventEmitter {
|
|
|
995
1086
|
* operator-local intent, kept off the peer-facing spawn door — see #159.) */
|
|
996
1087
|
async spawn(name, role, opts) {
|
|
997
1088
|
await this.requireConnected();
|
|
998
|
-
const
|
|
1089
|
+
const raw = opts?.model;
|
|
1090
|
+
if (raw !== undefined && !raw.trim())
|
|
1091
|
+
return { ok: false, error: "model: must not be empty" };
|
|
1092
|
+
const requested = raw?.trim();
|
|
1093
|
+
const args = { name, role, agent: opts?.agent, model: requested || undefined, variant: opts?.variant, launchOptions: opts?.launchOptions, cwd: opts?.cwd, prompt: opts?.prompt };
|
|
999
1094
|
// P2 item 2 (2b): spawn is an ACTION — follow the acceptance to the terminal so cotal_spawn
|
|
1000
1095
|
// stays synchronous (the MCP reply carries the live outcome, not the pre-launch acceptance).
|
|
1001
|
-
|
|
1096
|
+
const reply = await this.managerInvoke("spawn", args, { deadlineMs: SPAWN_TIMEOUT_MS, follow: true });
|
|
1097
|
+
if (!requested)
|
|
1098
|
+
return reply;
|
|
1099
|
+
// A requested pin that the manager did not record is the silent-drop failure (#972): the spawn
|
|
1100
|
+
// looks successful, the seat comes up on the harness default, and nothing in the spawn result
|
|
1101
|
+
// names the mismatch. Inspect is the manager's recorded pin (persona file or this override).
|
|
1102
|
+
// A wait-timeout is still a timeout, not evidence the pin landed — annotate it, never upgrade it.
|
|
1103
|
+
const actual = reply.data;
|
|
1104
|
+
const seat = actual?.name ?? name;
|
|
1105
|
+
const recorded = await this.inspectModel(seat);
|
|
1106
|
+
const recordedLabel = !recorded.ok
|
|
1107
|
+
? `could not inspect the recorded pin: ${recorded.error}`
|
|
1108
|
+
: recorded.model === undefined
|
|
1109
|
+
? "the manager recorded no model pin"
|
|
1110
|
+
: `the manager recorded ${JSON.stringify(recorded.model)}`;
|
|
1111
|
+
if (recorded.ok && recorded.model !== requested) {
|
|
1112
|
+
return {
|
|
1113
|
+
ok: false,
|
|
1114
|
+
error: `requested model ${JSON.stringify(requested)} but ${recordedLabel} for "${seat}" — refusing a spawn whose pin did not land. The seat may already be live; inspect it before retrying, because a retry duplicates the spawn`,
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
if (!reply.ok) {
|
|
1118
|
+
return {
|
|
1119
|
+
ok: false,
|
|
1120
|
+
error: `${reply.error ?? "manager refused"} (requested model ${JSON.stringify(requested)}; ${recordedLabel} for "${seat}")`,
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
if (!recorded.ok) {
|
|
1124
|
+
return {
|
|
1125
|
+
ok: false,
|
|
1126
|
+
error: `requested model ${JSON.stringify(requested)} for "${seat}" but ${recordedLabel} — refusing to report a pin that cannot be audited`,
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
return {
|
|
1130
|
+
ok: true,
|
|
1131
|
+
data: { ...reply.data, model: recorded.model },
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
/** The manager's recorded model pin for a managed seat (`inspect.model`). Absence is a real
|
|
1135
|
+
* state: a launch may pin none. Distinct from a failed inspect, which cannot attest. */
|
|
1136
|
+
async inspectModel(name) {
|
|
1137
|
+
const info = await this.managerInvoke("inspect", { name });
|
|
1138
|
+
if (!info.ok)
|
|
1139
|
+
return { ok: false, error: info.error ?? "inspect refused" };
|
|
1140
|
+
const model = info.data?.model;
|
|
1141
|
+
if (model === undefined)
|
|
1142
|
+
return { ok: true };
|
|
1143
|
+
if (typeof model !== "string")
|
|
1144
|
+
return { ok: false, error: `inspect returned a non-string model for "${name}"` };
|
|
1145
|
+
return { ok: true, model };
|
|
1002
1146
|
}
|
|
1003
1147
|
/** One v0.4 manager-endpoint invoke (P2 item 1, 1c.2b): the generic {@link CotalEndpoint.invokeService}
|
|
1004
1148
|
* path (describe → §13.7 store fetch → digest-verified recompile → typed command), adapted back to
|
|
@@ -1029,12 +1173,19 @@ export class MeshAgent extends EventEmitter {
|
|
|
1029
1173
|
ok: false,
|
|
1030
1174
|
error: unansweredRequest(e)
|
|
1031
1175
|
? `${e.message} (no responder answered - a manager may be down, or this credential holds no "${command}" capability and the broker denied the request)`
|
|
1032
|
-
: `${e.code}: ${e.message}`,
|
|
1176
|
+
: renderLifecycleBlocked(`${e.code}: ${e.message}`, e),
|
|
1177
|
+
...(e.details ? { details: e.details } : {}),
|
|
1033
1178
|
};
|
|
1034
1179
|
return { ok: false, error: e.message };
|
|
1035
1180
|
}
|
|
1036
|
-
if (r.reply.ok !== true)
|
|
1037
|
-
|
|
1181
|
+
if (r.reply.ok !== true) {
|
|
1182
|
+
const raw = r.reply.error?.message ?? r.reply.error?.code ?? "error";
|
|
1183
|
+
return {
|
|
1184
|
+
ok: false,
|
|
1185
|
+
error: renderLifecycleBlocked(raw, r.reply.error),
|
|
1186
|
+
...(r.reply.error?.details ? { details: r.reply.error.details } : {}),
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1038
1189
|
return { ok: true, ...(r.reply.data !== undefined ? { data: r.reply.data } : {}) };
|
|
1039
1190
|
}
|
|
1040
1191
|
/** Resolve a managed agent's CURRENT principal triple (owner-mode targets are (owner, actor,
|
|
@@ -1158,6 +1309,17 @@ export class MeshAgent extends EventEmitter {
|
|
|
1158
1309
|
}
|
|
1159
1310
|
return reply;
|
|
1160
1311
|
}
|
|
1312
|
+
/** Mesh-side persona catalog list: name, role, model, description, scoped by the same
|
|
1313
|
+
* ownership rule as `definePersona`. */
|
|
1314
|
+
async listPersonas() {
|
|
1315
|
+
await this.requireConnected();
|
|
1316
|
+
return this.managerInvoke("list-personas", undefined);
|
|
1317
|
+
}
|
|
1318
|
+
/** Mesh-side persona catalog show of one card. Unauthorized / missing names are not-found. */
|
|
1319
|
+
async showPersona(name) {
|
|
1320
|
+
await this.requireConnected();
|
|
1321
|
+
return this.managerInvoke("show-persona", { name });
|
|
1322
|
+
}
|
|
1161
1323
|
// ---- presence ------------------------------------------------------------
|
|
1162
1324
|
/** The full roster, including ourselves. */
|
|
1163
1325
|
roster() {
|
|
@@ -1207,6 +1369,7 @@ export class MeshAgent extends EventEmitter {
|
|
|
1207
1369
|
description: cfg?.description,
|
|
1208
1370
|
instructions: cfg?.instructions,
|
|
1209
1371
|
replay: this.ep.channelReplay(channel),
|
|
1372
|
+
registered: cfg !== undefined,
|
|
1210
1373
|
};
|
|
1211
1374
|
}
|
|
1212
1375
|
/** Channels we're currently subscribed to (live — reflects join/leave). */
|
|
@@ -1348,23 +1511,72 @@ export class MeshAgent extends EventEmitter {
|
|
|
1348
1511
|
* attached Codex TUI. Consumer names are generated per reset, so normalize them before
|
|
1349
1512
|
* deduplicating; otherwise every `_71`, `_72`, ... would look like a new fault. */
|
|
1350
1513
|
handleEndpointError(error) {
|
|
1514
|
+
// connectionIssue is the bounded readiness diagnostic documented above: retain failures only
|
|
1515
|
+
// while the endpoint has not bound. Post-bind consumer/permission faults are still logged, but
|
|
1516
|
+
// presenting one as the current connection failure after readiness succeeded is stale and false.
|
|
1517
|
+
if (!this._connected && !this._stopping)
|
|
1518
|
+
this.lastConnectionError = error.message;
|
|
1519
|
+
this.logEndpointNotice("error", error);
|
|
1520
|
+
}
|
|
1521
|
+
handleEndpointWarning(error) {
|
|
1522
|
+
this.logEndpointNotice("warning", error);
|
|
1523
|
+
}
|
|
1524
|
+
logEndpointNotice(kind, error) {
|
|
1351
1525
|
const now = Date.now();
|
|
1352
|
-
this.lastConnectionError = error.message;
|
|
1353
1526
|
const fingerprint = error.message.replace(/oc_[A-Za-z0-9]+_\d+/g, "oc_*");
|
|
1354
|
-
const prior = this.
|
|
1527
|
+
const prior = this.endpointNoticeLog.get(fingerprint);
|
|
1355
1528
|
if (prior && now - prior.lastLoggedAt < ENDPOINT_ERROR_LOG_WINDOW_MS) {
|
|
1356
1529
|
prior.suppressed++;
|
|
1357
1530
|
return;
|
|
1358
1531
|
}
|
|
1359
1532
|
const suffix = prior?.suppressed ? ` (${prior.suppressed} repeats suppressed)` : "";
|
|
1360
|
-
this.
|
|
1533
|
+
this.endpointNoticeLog.set(fingerprint, { lastLoggedAt: now, suppressed: 0 });
|
|
1361
1534
|
// Bound the map even for a server producing novel error text on every request.
|
|
1362
|
-
if (this.
|
|
1363
|
-
this.
|
|
1364
|
-
this.log(`endpoint
|
|
1535
|
+
if (this.endpointNoticeLog.size > 16)
|
|
1536
|
+
this.endpointNoticeLog.delete(this.endpointNoticeLog.keys().next().value);
|
|
1537
|
+
this.log(`endpoint ${kind}: ${error.message}${suffix}`);
|
|
1365
1538
|
}
|
|
1366
1539
|
log(msg) {
|
|
1367
1540
|
process.stderr.write(`[cotal-connector] ${msg}\n`);
|
|
1368
1541
|
}
|
|
1369
1542
|
}
|
|
1543
|
+
/** Names already known that differ from `channel` by one insertion, deletion, or substitution. */
|
|
1544
|
+
export function closeChannelNames(channel, known) {
|
|
1545
|
+
const out = [];
|
|
1546
|
+
for (const name of known) {
|
|
1547
|
+
if (name !== channel && editDistanceAtMostOne(channel, name))
|
|
1548
|
+
out.push(name);
|
|
1549
|
+
}
|
|
1550
|
+
out.sort((a, b) => a.localeCompare(b));
|
|
1551
|
+
return out.slice(0, 3);
|
|
1552
|
+
}
|
|
1553
|
+
function editDistanceAtMostOne(a, b) {
|
|
1554
|
+
if (a === b)
|
|
1555
|
+
return true;
|
|
1556
|
+
const da = a.length - b.length;
|
|
1557
|
+
if (da > 1 || da < -1)
|
|
1558
|
+
return false;
|
|
1559
|
+
let i = 0;
|
|
1560
|
+
let j = 0;
|
|
1561
|
+
let skipped = false;
|
|
1562
|
+
while (i < a.length && j < b.length) {
|
|
1563
|
+
if (a[i] === b[j]) {
|
|
1564
|
+
i++;
|
|
1565
|
+
j++;
|
|
1566
|
+
continue;
|
|
1567
|
+
}
|
|
1568
|
+
if (skipped)
|
|
1569
|
+
return false;
|
|
1570
|
+
skipped = true;
|
|
1571
|
+
if (a.length > b.length)
|
|
1572
|
+
i++;
|
|
1573
|
+
else if (b.length > a.length)
|
|
1574
|
+
j++;
|
|
1575
|
+
else {
|
|
1576
|
+
i++;
|
|
1577
|
+
j++;
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
return true;
|
|
1581
|
+
}
|
|
1370
1582
|
//# sourceMappingURL=agent.js.map
|