@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/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 request window for the manager's readiness-waiting `start` op (#159 B1): the manager
6
- * replies only on a REAL outcome presence join, process exit, or its ~30s readiness backstop —
7
- * so a spawn request must OUTLIVE that window, not the 5s op default. The tier rule forbids
8
- * importing the manager's READINESS_TIMEOUT_MS here; the launch-parity smoke enforces the
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
- endpointErrorLog = new Map();
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
- stopping = false;
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.endpointErrorLog.clear();
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
- /** The latest safe diagnostic for a connection that has not become live yet. */
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.stopping && !this._connected) {
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.stopping = true;
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.stopping) {
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); an @mention still *wakes* (mention-wake),
383
- // body pulled (F4=B), never auto-injected (the mention tag is payload-forgeable).
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
- return this.commitPending(selected);
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 bypass). Items are marked
917
- * `historical` (catch-up framing). `droppedChannels` names channels whose earliest retained
918
- * message postdates the focus-watermark older ambient may have aged out of the per-channel
919
- * window (never-silent). Empty unless in focus. Wildcard subscriptions (`team.>`) are skipped
920
- * (can't Direct-Get a wildcard). */
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 `start` op).
984
- * #159 B1: the manager replies to `start` only on a REAL outcome — presence join, process exit,
985
- * or its ~30s readiness backstop so the request must outlive that window ({@link SPAWN_TIMEOUT_MS}),
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 args = { name, role, agent: opts?.agent, model: opts?.model, variant: opts?.variant, launchOptions: opts?.launchOptions, cwd: opts?.cwd, prompt: opts?.prompt };
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
- return this.managerInvoke("spawn", args, { deadlineMs: SPAWN_TIMEOUT_MS, follow: true });
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
- return { ok: false, error: r.reply.error?.message ?? r.reply.error?.code ?? "error" };
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.endpointErrorLog.get(fingerprint);
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.endpointErrorLog.set(fingerprint, { lastLoggedAt: now, suppressed: 0 });
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.endpointErrorLog.size > 16)
1363
- this.endpointErrorLog.delete(this.endpointErrorLog.keys().next().value);
1364
- this.log(`endpoint error: ${error.message}${suffix}`);
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