@cotal-ai/connector-claude-code 0.22.0 → 0.23.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/hook.cjs +3 -3
- package/dist/index.js +6 -6
- package/dist/mcp.cjs +549 -218
- package/package.json +3 -3
package/dist/mcp.cjs
CHANGED
|
@@ -406,11 +406,11 @@ var require_codegen = __commonJS({
|
|
|
406
406
|
const rhs = this.rhs === void 0 ? "" : ` = ${this.rhs}`;
|
|
407
407
|
return `${varKind} ${this.name}${rhs};` + _n;
|
|
408
408
|
}
|
|
409
|
-
optimizeNames(names,
|
|
409
|
+
optimizeNames(names, constants4) {
|
|
410
410
|
if (!names[this.name.str])
|
|
411
411
|
return;
|
|
412
412
|
if (this.rhs)
|
|
413
|
-
this.rhs = optimizeExpr(this.rhs, names,
|
|
413
|
+
this.rhs = optimizeExpr(this.rhs, names, constants4);
|
|
414
414
|
return this;
|
|
415
415
|
}
|
|
416
416
|
get names() {
|
|
@@ -427,10 +427,10 @@ var require_codegen = __commonJS({
|
|
|
427
427
|
render({ _n }) {
|
|
428
428
|
return `${this.lhs} = ${this.rhs};` + _n;
|
|
429
429
|
}
|
|
430
|
-
optimizeNames(names,
|
|
430
|
+
optimizeNames(names, constants4) {
|
|
431
431
|
if (this.lhs instanceof code_1.Name && !names[this.lhs.str] && !this.sideEffects)
|
|
432
432
|
return;
|
|
433
|
-
this.rhs = optimizeExpr(this.rhs, names,
|
|
433
|
+
this.rhs = optimizeExpr(this.rhs, names, constants4);
|
|
434
434
|
return this;
|
|
435
435
|
}
|
|
436
436
|
get names() {
|
|
@@ -491,8 +491,8 @@ var require_codegen = __commonJS({
|
|
|
491
491
|
optimizeNodes() {
|
|
492
492
|
return `${this.code}` ? this : void 0;
|
|
493
493
|
}
|
|
494
|
-
optimizeNames(names,
|
|
495
|
-
this.code = optimizeExpr(this.code, names,
|
|
494
|
+
optimizeNames(names, constants4) {
|
|
495
|
+
this.code = optimizeExpr(this.code, names, constants4);
|
|
496
496
|
return this;
|
|
497
497
|
}
|
|
498
498
|
get names() {
|
|
@@ -521,12 +521,12 @@ var require_codegen = __commonJS({
|
|
|
521
521
|
}
|
|
522
522
|
return nodes.length > 0 ? this : void 0;
|
|
523
523
|
}
|
|
524
|
-
optimizeNames(names,
|
|
524
|
+
optimizeNames(names, constants4) {
|
|
525
525
|
const { nodes } = this;
|
|
526
526
|
let i = nodes.length;
|
|
527
527
|
while (i--) {
|
|
528
528
|
const n = nodes[i];
|
|
529
|
-
if (n.optimizeNames(names,
|
|
529
|
+
if (n.optimizeNames(names, constants4))
|
|
530
530
|
continue;
|
|
531
531
|
subtractNames(names, n.names);
|
|
532
532
|
nodes.splice(i, 1);
|
|
@@ -579,12 +579,12 @@ var require_codegen = __commonJS({
|
|
|
579
579
|
return void 0;
|
|
580
580
|
return this;
|
|
581
581
|
}
|
|
582
|
-
optimizeNames(names,
|
|
582
|
+
optimizeNames(names, constants4) {
|
|
583
583
|
var _a3;
|
|
584
|
-
this.else = (_a3 = this.else) === null || _a3 === void 0 ? void 0 : _a3.optimizeNames(names,
|
|
585
|
-
if (!(super.optimizeNames(names,
|
|
584
|
+
this.else = (_a3 = this.else) === null || _a3 === void 0 ? void 0 : _a3.optimizeNames(names, constants4);
|
|
585
|
+
if (!(super.optimizeNames(names, constants4) || this.else))
|
|
586
586
|
return;
|
|
587
|
-
this.condition = optimizeExpr(this.condition, names,
|
|
587
|
+
this.condition = optimizeExpr(this.condition, names, constants4);
|
|
588
588
|
return this;
|
|
589
589
|
}
|
|
590
590
|
get names() {
|
|
@@ -607,10 +607,10 @@ var require_codegen = __commonJS({
|
|
|
607
607
|
render(opts) {
|
|
608
608
|
return `for(${this.iteration})` + super.render(opts);
|
|
609
609
|
}
|
|
610
|
-
optimizeNames(names,
|
|
611
|
-
if (!super.optimizeNames(names,
|
|
610
|
+
optimizeNames(names, constants4) {
|
|
611
|
+
if (!super.optimizeNames(names, constants4))
|
|
612
612
|
return;
|
|
613
|
-
this.iteration = optimizeExpr(this.iteration, names,
|
|
613
|
+
this.iteration = optimizeExpr(this.iteration, names, constants4);
|
|
614
614
|
return this;
|
|
615
615
|
}
|
|
616
616
|
get names() {
|
|
@@ -646,10 +646,10 @@ var require_codegen = __commonJS({
|
|
|
646
646
|
render(opts) {
|
|
647
647
|
return `for(${this.varKind} ${this.name} ${this.loop} ${this.iterable})` + super.render(opts);
|
|
648
648
|
}
|
|
649
|
-
optimizeNames(names,
|
|
650
|
-
if (!super.optimizeNames(names,
|
|
649
|
+
optimizeNames(names, constants4) {
|
|
650
|
+
if (!super.optimizeNames(names, constants4))
|
|
651
651
|
return;
|
|
652
|
-
this.iterable = optimizeExpr(this.iterable, names,
|
|
652
|
+
this.iterable = optimizeExpr(this.iterable, names, constants4);
|
|
653
653
|
return this;
|
|
654
654
|
}
|
|
655
655
|
get names() {
|
|
@@ -691,11 +691,11 @@ var require_codegen = __commonJS({
|
|
|
691
691
|
(_b = this.finally) === null || _b === void 0 ? void 0 : _b.optimizeNodes();
|
|
692
692
|
return this;
|
|
693
693
|
}
|
|
694
|
-
optimizeNames(names,
|
|
694
|
+
optimizeNames(names, constants4) {
|
|
695
695
|
var _a3, _b;
|
|
696
|
-
super.optimizeNames(names,
|
|
697
|
-
(_a3 = this.catch) === null || _a3 === void 0 ? void 0 : _a3.optimizeNames(names,
|
|
698
|
-
(_b = this.finally) === null || _b === void 0 ? void 0 : _b.optimizeNames(names,
|
|
696
|
+
super.optimizeNames(names, constants4);
|
|
697
|
+
(_a3 = this.catch) === null || _a3 === void 0 ? void 0 : _a3.optimizeNames(names, constants4);
|
|
698
|
+
(_b = this.finally) === null || _b === void 0 ? void 0 : _b.optimizeNames(names, constants4);
|
|
699
699
|
return this;
|
|
700
700
|
}
|
|
701
701
|
get names() {
|
|
@@ -996,7 +996,7 @@ var require_codegen = __commonJS({
|
|
|
996
996
|
function addExprNames(names, from) {
|
|
997
997
|
return from instanceof code_1._CodeOrName ? addNames(names, from.names) : names;
|
|
998
998
|
}
|
|
999
|
-
function optimizeExpr(expr, names,
|
|
999
|
+
function optimizeExpr(expr, names, constants4) {
|
|
1000
1000
|
if (expr instanceof code_1.Name)
|
|
1001
1001
|
return replaceName(expr);
|
|
1002
1002
|
if (!canOptimize(expr))
|
|
@@ -1011,14 +1011,14 @@ var require_codegen = __commonJS({
|
|
|
1011
1011
|
return items;
|
|
1012
1012
|
}, []));
|
|
1013
1013
|
function replaceName(n) {
|
|
1014
|
-
const c =
|
|
1014
|
+
const c = constants4[n.str];
|
|
1015
1015
|
if (c === void 0 || names[n.str] !== 1)
|
|
1016
1016
|
return n;
|
|
1017
1017
|
delete names[n.str];
|
|
1018
1018
|
return c;
|
|
1019
1019
|
}
|
|
1020
1020
|
function canOptimize(e) {
|
|
1021
|
-
return e instanceof code_1._Code && e._items.some((c) => c instanceof code_1.Name && names[c.str] === 1 &&
|
|
1021
|
+
return e instanceof code_1._Code && e._items.some((c) => c instanceof code_1.Name && names[c.str] === 1 && constants4[c.str] !== void 0);
|
|
1022
1022
|
}
|
|
1023
1023
|
}
|
|
1024
1024
|
function subtractNames(names, from) {
|
|
@@ -62673,11 +62673,11 @@ var CotalEndpoint = class _CotalEndpoint extends import_node_events.EventEmitter
|
|
|
62673
62673
|
await this.manager();
|
|
62674
62674
|
const kv = await this.membersRegistry();
|
|
62675
62675
|
const existing = await readMember(kv, channel, owner, lifecycleUid);
|
|
62676
|
-
const
|
|
62677
|
-
if (
|
|
62676
|
+
const open5 = existing?.record.state === "durable-active" && existing.record.leaveCursor === void 0;
|
|
62677
|
+
if (open5 && existing.record.activated)
|
|
62678
62678
|
return { durable: true, generation: existing.record.generation };
|
|
62679
|
-
const joinCursor =
|
|
62680
|
-
const generation =
|
|
62679
|
+
const joinCursor = open5 ? existing.record.joinCursor : await this.chatFrontier();
|
|
62680
|
+
const generation = open5 ? existing.record.generation : (existing?.record.generation ?? 0) + 1;
|
|
62681
62681
|
const base = {
|
|
62682
62682
|
channel,
|
|
62683
62683
|
owner,
|
|
@@ -62689,7 +62689,7 @@ var CotalEndpoint = class _CotalEndpoint extends import_node_events.EventEmitter
|
|
|
62689
62689
|
writerIdentity: this.card.id,
|
|
62690
62690
|
updatedAt: Date.now()
|
|
62691
62691
|
};
|
|
62692
|
-
if (!
|
|
62692
|
+
if (!open5)
|
|
62693
62693
|
await commitMember(kv, base);
|
|
62694
62694
|
const fence = Math.max(await this.chatFrontier(), await this.fanoutDeliveredSeq());
|
|
62695
62695
|
const cu = await this.catchupCopy(owner, lifecycleUid, channel, joinCursor, fence, generation);
|
|
@@ -65264,6 +65264,7 @@ function eventChannelForSession(ep) {
|
|
|
65264
65264
|
|
|
65265
65265
|
// ../connector-core/dist/agui.js
|
|
65266
65266
|
var import_node_crypto8 = require("node:crypto");
|
|
65267
|
+
var import_node_path2 = require("node:path");
|
|
65267
65268
|
var COTAL_CUSTOM_EVENTS = [];
|
|
65268
65269
|
var AGUI_PROTOCOL = "ag-ui/0.0.57";
|
|
65269
65270
|
var AguiVocabularyError = class extends Error {
|
|
@@ -65652,6 +65653,9 @@ var AguiEmitter = class _AguiEmitter {
|
|
|
65652
65653
|
if (wal.principal !== live)
|
|
65653
65654
|
throw new Error(`event WAL belongs to principal ${wal.principal}, but this endpoint is ${live} \u2014 refusing to publish under one identity from another's write-ahead log`);
|
|
65654
65655
|
await endpoint.assertExpectationSemantics();
|
|
65656
|
+
if (!opts.subjectFrontier || typeof opts.subjectFrontier.advance !== "function")
|
|
65657
|
+
throw new Error(`event emitter for ${channel}: a subject frontier is required \u2014 the subject is shared by every thread of this principal, so the publish expectation cannot come from one thread's log`);
|
|
65658
|
+
await wal.bindSubjectFrontier(opts.subjectFrontier);
|
|
65655
65659
|
const em = new _AguiEmitter(endpoint, wal, opts.source, opts.map, channel, wal.threadId);
|
|
65656
65660
|
await em.recover();
|
|
65657
65661
|
return em;
|
|
@@ -65805,7 +65809,7 @@ var AguiEmitter = class _AguiEmitter {
|
|
|
65805
65809
|
this.brackets.accept(e);
|
|
65806
65810
|
const brackets = this.brackets.snapshot();
|
|
65807
65811
|
const id = (0, import_node_crypto8.randomUUID)();
|
|
65808
|
-
const E = this.wal.
|
|
65812
|
+
const E = this.wal.expectedTip;
|
|
65809
65813
|
const body = [frame];
|
|
65810
65814
|
await this.wal.beginSend({ id, E, seq: frame.seq, sourceCursor: cursor, body, brackets });
|
|
65811
65815
|
await this.attempt({ id, E, body, retry: false });
|
|
@@ -65838,7 +65842,7 @@ var AguiEmitter = class _AguiEmitter {
|
|
|
65838
65842
|
}));
|
|
65839
65843
|
} catch (e) {
|
|
65840
65844
|
if (isCasLoss(e))
|
|
65841
|
-
throw this.halt("cas-loss", `event emitter for ${this.channel}: the subject tip is no longer ${o.E} (${e.message}).
|
|
65845
|
+
throw this.halt("cas-loss", `event emitter for ${this.channel}: the subject tip is no longer ${o.E} (${e.message}). The broker ACL confines this subject to one principal, so the tip moved for one of: a CONCURRENT emitter under this same principal. The per-principal lock refuses a second one, but the lock FILE lives under a workspace root, so an emitter started against a DIFFERENT root, or by a path that never takes the lock, meets no lock at all. Another host and a stale pid do not get past it; they refuse the start instead, loudly; a subject frontier record that disagrees with the stream, which is what an interrupted upgrade or a restored backup leaves behind; a RESTORED stream; or a FILTERED PURGE, which returns the tip to 0 for every thread on the channel. One more cause is not a second writer at all: this log's OWN last ack. The shared record advances before the log records the ack, so a crash between those two writes leaves the record ahead of the frozen expectation this frame carries, and the retry publishes a sequence the subject has already passed. On disk it reads as a pending frame in state sent_unacked whose E is BEHIND the record's tip, which a restored record can also look like, so it narrows the search rather than ending it. None of these is resolvable by re-reading the tip, which agent credentials cannot read in any case. Clearing it is an explicit abandonment of epoch, seq, E, cursor and the shared subject record together, and it is VALID ONLY ONCE THE SUBJECT IS ACTUALLY EMPTY, which of the causes above is true of the FILTERED PURGE alone. On any other cause the tip is still where it is, so removing this state does not clear the halt: the next session opens virgin, expects 0, halts on the same tip, and the sibling logs a tip could have been rebuilt from are gone. Purge the channel first, or find the second writer, or match the signature above and stop looking for one. Once the subject really is back to 0, no command performs the abandonment, so by hand it means removing ${(0, import_node_path2.dirname)((0, import_node_path2.dirname)(this.wal.path))} whole, and removing less than that leaves a mixed state the next start refuses.`);
|
|
65842
65846
|
throw e;
|
|
65843
65847
|
}
|
|
65844
65848
|
if (ack.duplicate)
|
|
@@ -66020,7 +66024,7 @@ var JsonlFileSource = class _JsonlFileSource {
|
|
|
66020
66024
|
var import_node_crypto10 = require("node:crypto");
|
|
66021
66025
|
var import_node_fs5 = require("node:fs");
|
|
66022
66026
|
var import_promises2 = require("node:fs/promises");
|
|
66023
|
-
var
|
|
66027
|
+
var import_node_path3 = require("node:path");
|
|
66024
66028
|
var EVENT_WAL_VERSION = 3;
|
|
66025
66029
|
var WalCorruptError = class extends Error {
|
|
66026
66030
|
path;
|
|
@@ -66224,6 +66228,8 @@ var EventWal = class _EventWal {
|
|
|
66224
66228
|
* and guessing `E := 0` either CAS-halts forever or appends under a stale expectation. Recovery
|
|
66225
66229
|
* from that state is an explicit operator act, never a startup heuristic.
|
|
66226
66230
|
*/
|
|
66231
|
+
/** Bound by {@link bindSubjectFrontier}; absent for a WAL nothing publishes from. */
|
|
66232
|
+
subject;
|
|
66227
66233
|
static async open(path, opts) {
|
|
66228
66234
|
let raw;
|
|
66229
66235
|
let bytes;
|
|
@@ -66267,14 +66273,56 @@ var EventWal = class _EventWal {
|
|
|
66267
66273
|
brackets: { run: void 0, text: [], reasoning: [], tools: [] }
|
|
66268
66274
|
};
|
|
66269
66275
|
}
|
|
66276
|
+
/**
|
|
66277
|
+
* Bind the PRINCIPAL-scoped subject frontier this thread publishes onto.
|
|
66278
|
+
*
|
|
66279
|
+
* **THE TIP IS NOT THIS THREAD'S TO REMEMBER, AND THAT IS THE WHOLE CORRECTION.**
|
|
66280
|
+
* `frontier.lastSubjectSeq` records the last sequence THIS thread was assigned, which is a true
|
|
66281
|
+
* fact about this log and was mistaken for the subject's tip. The subject is per principal, so a
|
|
66282
|
+
* second session of the same agent opened virgin, expected an empty subject its own predecessor
|
|
66283
|
+
* had filled, and halted forever. Once bound, the bound record is authoritative for the
|
|
66284
|
+
* expectation and this document's own number is history.
|
|
66285
|
+
*
|
|
66286
|
+
* Called once, by {@link AguiEmitter.start}, which is the only thing that drives a WAL toward a
|
|
66287
|
+
* publish. An UNBOUND log still opens, replays and reports its own frontier, so a caller that
|
|
66288
|
+
* only READS one needs no record; but every step toward a publish reads the subject's tip, so
|
|
66289
|
+
* `expectedTip`, `beginSend`, `recordAck` and `abandon` all throw until this has been called.
|
|
66290
|
+
* An earlier version of this sentence said an unbound WAL behaved exactly as it did before,
|
|
66291
|
+
* which was true when it was written and stopped being true in the same change that made the
|
|
66292
|
+
* unbound expectation throw.
|
|
66293
|
+
*/
|
|
66294
|
+
async bindSubjectFrontier(frontier) {
|
|
66295
|
+
if (this.subject === frontier)
|
|
66296
|
+
return;
|
|
66297
|
+
if (this.subject)
|
|
66298
|
+
throw new Error(`event WAL ${this.path}: a DIFFERENT subject frontier is already bound`);
|
|
66299
|
+
this.subject = frontier;
|
|
66300
|
+
}
|
|
66301
|
+
/**
|
|
66302
|
+
* The sequence a publish must expect, which is the SUBJECT's tip and not this thread's.
|
|
66303
|
+
*
|
|
66304
|
+
* **UNBOUND IT THROWS, AND AN EARLIER VERSION OF THIS RETURNED THIS DOCUMENT'S OWN LAST ACK.**
|
|
66305
|
+
* That number is the defect's own shape: per session, while the subject is per principal. The
|
|
66306
|
+
* argument for returning it was that no shipped path can reach it, because
|
|
66307
|
+
* {@link AguiEmitter.start} is the only route from a log to a publish and it binds before the
|
|
66308
|
+
* emitter exists. The argument was true, and it is the same argument the released seam shipped
|
|
66309
|
+
* on: two correct components with an assumption standing where a guard belongs, recorded in
|
|
66310
|
+
* prose. So the assumption is a guard now. A caller that drives a log toward a publish without a
|
|
66311
|
+
* frontier fails here rather than republishing an expectation that was never the subject's.
|
|
66312
|
+
*/
|
|
66313
|
+
get expectedTip() {
|
|
66314
|
+
if (!this.subject)
|
|
66315
|
+
throw new Error(`event WAL ${this.path}: no subject frontier is bound, so there is no expectation to publish. The subject is shared by every thread of this principal, so this document's own last ack is not it; bind the principal's record with bindSubjectFrontier first.`);
|
|
66316
|
+
return this.subject.tip;
|
|
66317
|
+
}
|
|
66270
66318
|
/** Transition 1 — record the frame, with `id` and `E` frozen, BEFORE any publish. */
|
|
66271
66319
|
async beginSend(frame) {
|
|
66272
66320
|
return this.serialize(async () => {
|
|
66273
66321
|
if (this.doc.pending)
|
|
66274
66322
|
throw new Error(`event WAL ${this.path}: a frame is already pending; one emit unit is one pending frame`);
|
|
66275
66323
|
assertIdToken(frame.id, "event WAL pending id");
|
|
66276
|
-
if (frame.E !== this.
|
|
66277
|
-
throw new Error(`event WAL ${this.path}: E=${frame.E} is not the
|
|
66324
|
+
if (frame.E !== this.expectedTip)
|
|
66325
|
+
throw new Error(`event WAL ${this.path}: E=${frame.E} is not the subject's tip ${this.expectedTip}`);
|
|
66278
66326
|
if (frame.seq !== this.doc.frontier.seq + 1)
|
|
66279
66327
|
throw new Error(`event WAL ${this.path}: seq=${frame.seq} is not the frontier's successor ${this.doc.frontier.seq + 1}`);
|
|
66280
66328
|
if (!Array.isArray(frame.body) || frame.body.length === 0)
|
|
@@ -66293,8 +66341,13 @@ var EventWal = class _EventWal {
|
|
|
66293
66341
|
throw new Error(`event WAL ${this.path}: no sent_unacked frame to ack`);
|
|
66294
66342
|
if (!isSafeNonNegInt(ackSeq))
|
|
66295
66343
|
throw new Error(`event WAL ${this.path}: ackSeq must be a safe non-negative integer, got ${String(ackSeq)}`);
|
|
66296
|
-
if (ackSeq <= this.
|
|
66297
|
-
throw new Error(`event WAL ${this.path}: ackSeq=${ackSeq} is not ahead of the
|
|
66344
|
+
if (ackSeq <= this.expectedTip)
|
|
66345
|
+
throw new Error(`event WAL ${this.path}: ackSeq=${ackSeq} is not ahead of the subject's tip ${this.expectedTip}`);
|
|
66346
|
+
const subject = this.subject;
|
|
66347
|
+
if (!subject)
|
|
66348
|
+
throw new Error(`event WAL ${this.path}: no subject frontier is bound, so this ack has no shared record to advance`);
|
|
66349
|
+
await this.assertNotClobbering();
|
|
66350
|
+
await subject.advance(ackSeq);
|
|
66298
66351
|
await this.write({ ...this.doc, pending: { ...p, state: "acked", ackSeq } });
|
|
66299
66352
|
});
|
|
66300
66353
|
}
|
|
@@ -66337,6 +66390,10 @@ var EventWal = class _EventWal {
|
|
|
66337
66390
|
*/
|
|
66338
66391
|
async abandon() {
|
|
66339
66392
|
return this.serialize(async () => {
|
|
66393
|
+
await this.assertNotClobbering();
|
|
66394
|
+
if (!this.subject)
|
|
66395
|
+
throw new Error(`event WAL ${this.path}: no subject frontier is bound, so an abandonment here would clear this log and leave the principal's shared tip standing, which is the partial abandonment this method refuses to produce; bind the principal's record with bindSubjectFrontier first.`);
|
|
66396
|
+
await this.subject.reset();
|
|
66340
66397
|
await this.write({
|
|
66341
66398
|
...this.doc,
|
|
66342
66399
|
epoch: (0, import_node_crypto10.randomUUID)(),
|
|
@@ -66372,7 +66429,7 @@ var EventWal = class _EventWal {
|
|
|
66372
66429
|
async write(next) {
|
|
66373
66430
|
await this.assertNotClobbering();
|
|
66374
66431
|
const stamped = { ...next, gen: this.doc.gen + 1 };
|
|
66375
|
-
const tmp = (0,
|
|
66432
|
+
const tmp = (0, import_node_path3.join)((0, import_node_path3.dirname)(this.path), `.${(0, import_node_crypto10.createHash)("sha256").update(this.path).digest("hex").slice(0, 12)}.${process.pid}.${(0, import_node_crypto10.randomUUID)().slice(0, 8)}.wal.tmp`);
|
|
66376
66433
|
const body = JSON.stringify(stamped);
|
|
66377
66434
|
const fh = await openExclusiveNoFollow(tmp);
|
|
66378
66435
|
try {
|
|
@@ -66446,6 +66503,435 @@ var EventWal = class _EventWal {
|
|
|
66446
66503
|
}
|
|
66447
66504
|
};
|
|
66448
66505
|
|
|
66506
|
+
// ../connector-core/dist/subject-frontier.js
|
|
66507
|
+
var import_node_crypto12 = require("node:crypto");
|
|
66508
|
+
var import_node_fs6 = require("node:fs");
|
|
66509
|
+
var import_promises4 = require("node:fs/promises");
|
|
66510
|
+
var import_node_path5 = require("node:path");
|
|
66511
|
+
|
|
66512
|
+
// ../connector-core/dist/agui-wal-path.js
|
|
66513
|
+
var import_node_crypto11 = require("node:crypto");
|
|
66514
|
+
var import_promises3 = require("node:fs/promises");
|
|
66515
|
+
var import_node_os3 = require("node:os");
|
|
66516
|
+
var import_node_path4 = require("node:path");
|
|
66517
|
+
var EventsStateRootMissing = class extends Error {
|
|
66518
|
+
constructor(message) {
|
|
66519
|
+
super(message);
|
|
66520
|
+
this.name = "EventsStateRootMissing";
|
|
66521
|
+
}
|
|
66522
|
+
};
|
|
66523
|
+
function resolveEventsStateRoot(env) {
|
|
66524
|
+
const root = env.COTAL_WORKSPACE_ROOT;
|
|
66525
|
+
if (typeof root !== "string" || root.trim() === "")
|
|
66526
|
+
throw new EventsStateRootMissing("events are enabled for this session but COTAL_WORKSPACE_ROOT is not set, so there is nowhere to put the event write-ahead log. The launcher forwards it from LaunchOpts.workspaceRoot; a session started outside a manager has no workspace root and must not publish events. Refusing rather than defaulting to the working directory, which would put the WAL somewhere no later start looks.");
|
|
66527
|
+
return root;
|
|
66528
|
+
}
|
|
66529
|
+
function h(value) {
|
|
66530
|
+
return (0, import_node_crypto11.createHash)("sha256").update(value).digest("hex").slice(0, 16);
|
|
66531
|
+
}
|
|
66532
|
+
function eventWalLocation(opts) {
|
|
66533
|
+
const principalDir = (0, import_node_path4.join)(opts.workspaceRoot, ".cotal", "events", h(opts.space), h(opts.principal));
|
|
66534
|
+
const threadDir = (0, import_node_path4.join)(principalDir, h(opts.threadId));
|
|
66535
|
+
return {
|
|
66536
|
+
principalDir,
|
|
66537
|
+
lockPath: (0, import_node_path4.join)(principalDir, ".lock"),
|
|
66538
|
+
subjectPath: (0, import_node_path4.join)(principalDir, "subject.json"),
|
|
66539
|
+
threadDir,
|
|
66540
|
+
walPath: (0, import_node_path4.join)(threadDir, "wal.json")
|
|
66541
|
+
};
|
|
66542
|
+
}
|
|
66543
|
+
var PrincipalLockError = class extends Error {
|
|
66544
|
+
path;
|
|
66545
|
+
invariant;
|
|
66546
|
+
constructor(path, invariant, detail) {
|
|
66547
|
+
super(`event WAL principal lock at ${path} (${invariant}): ${detail}`);
|
|
66548
|
+
this.path = path;
|
|
66549
|
+
this.invariant = invariant;
|
|
66550
|
+
this.name = "PrincipalLockError";
|
|
66551
|
+
}
|
|
66552
|
+
};
|
|
66553
|
+
var held = /* @__PURE__ */ new Map();
|
|
66554
|
+
function ownerIsAlive(pid) {
|
|
66555
|
+
try {
|
|
66556
|
+
process.kill(pid, 0);
|
|
66557
|
+
return true;
|
|
66558
|
+
} catch (e) {
|
|
66559
|
+
return e.code !== "ESRCH";
|
|
66560
|
+
}
|
|
66561
|
+
}
|
|
66562
|
+
async function createLockFile(path) {
|
|
66563
|
+
try {
|
|
66564
|
+
return await openExclusiveNoFollow(path);
|
|
66565
|
+
} catch (e) {
|
|
66566
|
+
if (e.code === "EEXIST")
|
|
66567
|
+
return void 0;
|
|
66568
|
+
throw e;
|
|
66569
|
+
}
|
|
66570
|
+
}
|
|
66571
|
+
async function reclaimIfOwnerIsGone(path) {
|
|
66572
|
+
let raw;
|
|
66573
|
+
try {
|
|
66574
|
+
raw = await (0, import_promises3.readFile)(path, "utf8");
|
|
66575
|
+
} catch (e) {
|
|
66576
|
+
if (e.code === "ENOENT")
|
|
66577
|
+
return;
|
|
66578
|
+
throw e;
|
|
66579
|
+
}
|
|
66580
|
+
let record2;
|
|
66581
|
+
try {
|
|
66582
|
+
record2 = JSON.parse(raw);
|
|
66583
|
+
} catch {
|
|
66584
|
+
throw new PrincipalLockError(path, "the lock names its owner", "the file is not readable JSON, so its owner cannot be checked; refusing rather than reclaiming a lock that may be held");
|
|
66585
|
+
}
|
|
66586
|
+
const r = record2;
|
|
66587
|
+
if (!Number.isSafeInteger(r.pid) || r.pid <= 0 || typeof r.host !== "string" || r.host.length === 0)
|
|
66588
|
+
throw new PrincipalLockError(path, "the lock names its owner", `the record carries pid=${String(r.pid)} host=${String(r.host)}, which names nobody checkable`);
|
|
66589
|
+
const here = (0, import_node_os3.hostname)();
|
|
66590
|
+
if (r.host !== here)
|
|
66591
|
+
throw new PrincipalLockError(path, "the recorded owner is on THIS host", `held by pid ${r.pid} on ${r.host} while this process runs on ${here}; liveness on another machine is not observable from here`);
|
|
66592
|
+
if (ownerIsAlive(r.pid))
|
|
66593
|
+
throw new PrincipalLockError(path, "the recorded owner is gone", `pid ${r.pid} on ${here} is still running and holds this principal's emitter`);
|
|
66594
|
+
await (0, import_promises3.unlink)(path).catch((e) => {
|
|
66595
|
+
if (e.code !== "ENOENT")
|
|
66596
|
+
throw e;
|
|
66597
|
+
});
|
|
66598
|
+
}
|
|
66599
|
+
async function acquirePrincipalLock(lockPath) {
|
|
66600
|
+
const already = held.get(lockPath);
|
|
66601
|
+
if (already)
|
|
66602
|
+
return already;
|
|
66603
|
+
let fh = await createLockFile(lockPath);
|
|
66604
|
+
if (fh === void 0) {
|
|
66605
|
+
await reclaimIfOwnerIsGone(lockPath);
|
|
66606
|
+
fh = await createLockFile(lockPath);
|
|
66607
|
+
if (fh === void 0)
|
|
66608
|
+
throw new PrincipalLockError(lockPath, "the reclaimed lock is free when this process takes it", "another process created the lock between the reclaim and this open, and now holds this principal");
|
|
66609
|
+
}
|
|
66610
|
+
const record2 = JSON.stringify({ pid: process.pid, host: (0, import_node_os3.hostname)(), token: (0, import_node_crypto11.randomUUID)(), acquiredAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
66611
|
+
try {
|
|
66612
|
+
await fh.writeFile(record2, "utf8");
|
|
66613
|
+
await fh.sync();
|
|
66614
|
+
} catch (e) {
|
|
66615
|
+
await fh.close().catch(() => {
|
|
66616
|
+
});
|
|
66617
|
+
await (0, import_promises3.unlink)(lockPath).catch(() => {
|
|
66618
|
+
});
|
|
66619
|
+
throw e;
|
|
66620
|
+
}
|
|
66621
|
+
const lock = {
|
|
66622
|
+
path: lockPath,
|
|
66623
|
+
async release() {
|
|
66624
|
+
if (held.get(lockPath) !== lock)
|
|
66625
|
+
return;
|
|
66626
|
+
held.delete(lockPath);
|
|
66627
|
+
await fh.close().catch(() => {
|
|
66628
|
+
});
|
|
66629
|
+
await (0, import_promises3.unlink)(lockPath).catch((e) => {
|
|
66630
|
+
if (e.code !== "ENOENT")
|
|
66631
|
+
throw e;
|
|
66632
|
+
});
|
|
66633
|
+
}
|
|
66634
|
+
};
|
|
66635
|
+
held.set(lockPath, lock);
|
|
66636
|
+
return lock;
|
|
66637
|
+
}
|
|
66638
|
+
async function fsyncDir(dir) {
|
|
66639
|
+
let fh;
|
|
66640
|
+
try {
|
|
66641
|
+
fh = await (0, import_promises3.open)(dir, "r");
|
|
66642
|
+
} catch (e) {
|
|
66643
|
+
const code = e.code;
|
|
66644
|
+
if (code === "EPERM" || code === "EACCES")
|
|
66645
|
+
return;
|
|
66646
|
+
throw e;
|
|
66647
|
+
}
|
|
66648
|
+
try {
|
|
66649
|
+
await fh.sync();
|
|
66650
|
+
} catch (e) {
|
|
66651
|
+
const code = e.code;
|
|
66652
|
+
if (code !== "EBADF" && code !== "EINVAL" && code !== "EPERM" && code !== "EISDIR")
|
|
66653
|
+
throw e;
|
|
66654
|
+
} finally {
|
|
66655
|
+
await fh.close();
|
|
66656
|
+
}
|
|
66657
|
+
}
|
|
66658
|
+
async function ensureEventWalDir(opts) {
|
|
66659
|
+
const loc = eventWalLocation(opts);
|
|
66660
|
+
ensureDirNoSymlink(opts.workspaceRoot, ".cotal", "events", h(opts.space), h(opts.principal), h(opts.threadId));
|
|
66661
|
+
for (let dir = loc.threadDir; ; dir = (0, import_node_path4.dirname)(dir)) {
|
|
66662
|
+
await fsyncDir(dir);
|
|
66663
|
+
if (dir === opts.workspaceRoot || (0, import_node_path4.dirname)(dir) === dir)
|
|
66664
|
+
break;
|
|
66665
|
+
}
|
|
66666
|
+
const lock = await acquirePrincipalLock(loc.lockPath);
|
|
66667
|
+
return { ...loc, lock };
|
|
66668
|
+
}
|
|
66669
|
+
|
|
66670
|
+
// ../connector-core/dist/subject-frontier.js
|
|
66671
|
+
var SUBJECT_FRONTIER_VERSION = 1;
|
|
66672
|
+
var SubjectFrontierCorruptError = class extends Error {
|
|
66673
|
+
path;
|
|
66674
|
+
invariant;
|
|
66675
|
+
constructor(path, invariant, detail) {
|
|
66676
|
+
super(`subject frontier ${path}: expected ${invariant} \u2014 ${detail}`);
|
|
66677
|
+
this.path = path;
|
|
66678
|
+
this.invariant = invariant;
|
|
66679
|
+
this.name = "SubjectFrontierCorruptError";
|
|
66680
|
+
}
|
|
66681
|
+
};
|
|
66682
|
+
var SubjectFrontierMovedError = class extends Error {
|
|
66683
|
+
path;
|
|
66684
|
+
viewTip;
|
|
66685
|
+
diskTip;
|
|
66686
|
+
constructor(path, viewTip, diskTip) {
|
|
66687
|
+
super(`subject frontier ${path}: the record moved under this writer (this view holds ${viewTip}, the file holds ${diskTip === void 0 ? "no record at all" : diskTip}). The tip is shared by every thread of the principal, so writing this view's number would take the record backwards to a sequence the broker has already passed, and every later publish would expect a tip the subject no longer has.`);
|
|
66688
|
+
this.path = path;
|
|
66689
|
+
this.viewTip = viewTip;
|
|
66690
|
+
this.diskTip = diskTip;
|
|
66691
|
+
this.name = "SubjectFrontierMovedError";
|
|
66692
|
+
}
|
|
66693
|
+
};
|
|
66694
|
+
var isSafeNonNegInt2 = (n) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
|
|
66695
|
+
var FileSubjectFrontier = class _FileSubjectFrontier {
|
|
66696
|
+
path;
|
|
66697
|
+
doc;
|
|
66698
|
+
constructor(path, doc) {
|
|
66699
|
+
this.path = path;
|
|
66700
|
+
this.doc = doc;
|
|
66701
|
+
}
|
|
66702
|
+
get tip() {
|
|
66703
|
+
return this.doc.tip;
|
|
66704
|
+
}
|
|
66705
|
+
/**
|
|
66706
|
+
* Open, or create a virgin record.
|
|
66707
|
+
*
|
|
66708
|
+
* A MISSING file is virgin and legal: this principal has never published, which is the ordinary
|
|
66709
|
+
* state on a first run and after a fresh install. A ZERO-BYTE file is NOT, for the same reason
|
|
66710
|
+
* the write-ahead log refuses one: an atomic temp-and-rename never produces it, so it is a
|
|
66711
|
+
* filesystem that lost the tail, and reading it as "never published" is the guess this whole
|
|
66712
|
+
* mechanism exists to remove.
|
|
66713
|
+
*/
|
|
66714
|
+
static async open(path, opts) {
|
|
66715
|
+
let bytes;
|
|
66716
|
+
try {
|
|
66717
|
+
bytes = await (0, import_promises4.readFile)(path);
|
|
66718
|
+
} catch (e) {
|
|
66719
|
+
if (e.code !== "ENOENT")
|
|
66720
|
+
throw e;
|
|
66721
|
+
}
|
|
66722
|
+
if (bytes === void 0) {
|
|
66723
|
+
const recovered = await _FileSubjectFrontier.recoverTipFromThreadLogs((0, import_node_path5.dirname)(path), opts.principal);
|
|
66724
|
+
const fresh = new _FileSubjectFrontier(path, { v: SUBJECT_FRONTIER_VERSION, space: opts.space, principal: opts.principal, tip: 0 });
|
|
66725
|
+
if (recovered > 0)
|
|
66726
|
+
await fresh.write({ ...fresh.doc, tip: recovered });
|
|
66727
|
+
return fresh;
|
|
66728
|
+
}
|
|
66729
|
+
return new _FileSubjectFrontier(path, _FileSubjectFrontier.parse(path, bytes, opts));
|
|
66730
|
+
}
|
|
66731
|
+
/**
|
|
66732
|
+
* Bytes to a validated document, or a refusal.
|
|
66733
|
+
*
|
|
66734
|
+
* SHARED BY `open` AND BY THE RE-READ IN {@link advance} on purpose. A record that went corrupt
|
|
66735
|
+
* underneath a live writer has to meet the same wall as one that was corrupt at boot; validating
|
|
66736
|
+
* only on the way in would let a writer that opened a good file overwrite a bad one, which
|
|
66737
|
+
* destroys the evidence of whatever produced it.
|
|
66738
|
+
*/
|
|
66739
|
+
static parse(path, bytes, opts) {
|
|
66740
|
+
let raw;
|
|
66741
|
+
try {
|
|
66742
|
+
raw = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
66743
|
+
} catch {
|
|
66744
|
+
throw new SubjectFrontierCorruptError(path, "valid UTF-8", "invalid UTF-8 bytes; refusing rather than substituting U+FFFD");
|
|
66745
|
+
}
|
|
66746
|
+
if (raw.length === 0)
|
|
66747
|
+
throw new SubjectFrontierCorruptError(path, "a non-empty file", "the file is zero bytes \u2014 distinct from missing, and never treated as virgin");
|
|
66748
|
+
let parsed;
|
|
66749
|
+
try {
|
|
66750
|
+
parsed = JSON.parse(raw);
|
|
66751
|
+
} catch (e) {
|
|
66752
|
+
throw new SubjectFrontierCorruptError(path, "parseable JSON", e.message);
|
|
66753
|
+
}
|
|
66754
|
+
const d = parsed;
|
|
66755
|
+
if (d?.v !== SUBJECT_FRONTIER_VERSION)
|
|
66756
|
+
throw new SubjectFrontierCorruptError(path, `v === ${SUBJECT_FRONTIER_VERSION}`, String(d?.v));
|
|
66757
|
+
if (d.space !== opts.space)
|
|
66758
|
+
throw new SubjectFrontierCorruptError(path, "space matches", `file=${String(d.space)} caller=${opts.space}`);
|
|
66759
|
+
if (d.principal !== opts.principal)
|
|
66760
|
+
throw new SubjectFrontierCorruptError(path, "principal matches", `file=${String(d.principal)} caller=${opts.principal}`);
|
|
66761
|
+
if (!isSafeNonNegInt2(d.tip))
|
|
66762
|
+
throw new SubjectFrontierCorruptError(path, "tip is a safe non-negative integer", String(d.tip));
|
|
66763
|
+
return { v: d.v, space: d.space, principal: d.principal, tip: d.tip };
|
|
66764
|
+
}
|
|
66765
|
+
async advance(seq) {
|
|
66766
|
+
return this.serialize(async () => {
|
|
66767
|
+
if (!isSafeNonNegInt2(seq))
|
|
66768
|
+
throw new Error(`subject frontier ${this.path}: seq must be a safe non-negative integer, got ${String(seq)}`);
|
|
66769
|
+
if (seq <= this.doc.tip)
|
|
66770
|
+
throw new Error(`subject frontier ${this.path}: seq=${seq} does not advance the tip ${this.doc.tip}`);
|
|
66771
|
+
const disk = await this.readDiskTip();
|
|
66772
|
+
if (disk === void 0 ? this.doc.tip !== 0 : disk !== this.doc.tip)
|
|
66773
|
+
throw new SubjectFrontierMovedError(this.path, this.doc.tip, disk);
|
|
66774
|
+
await this.write({ ...this.doc, tip: seq });
|
|
66775
|
+
});
|
|
66776
|
+
}
|
|
66777
|
+
/**
|
|
66778
|
+
* The tip the FILE holds, or `undefined` when no record exists yet.
|
|
66779
|
+
*
|
|
66780
|
+
* Fully validated, not a bare `JSON.parse().tip`: the disagreement this feeds is decided on a
|
|
66781
|
+
* number, and a number taken from a document that failed its own shape checks is not evidence.
|
|
66782
|
+
*/
|
|
66783
|
+
async readDiskTip() {
|
|
66784
|
+
let bytes;
|
|
66785
|
+
try {
|
|
66786
|
+
bytes = await (0, import_promises4.readFile)(this.path);
|
|
66787
|
+
} catch (e) {
|
|
66788
|
+
if (e.code === "ENOENT")
|
|
66789
|
+
return void 0;
|
|
66790
|
+
throw e;
|
|
66791
|
+
}
|
|
66792
|
+
return _FileSubjectFrontier.parse(this.path, bytes, { space: this.doc.space, principal: this.doc.principal }).tip;
|
|
66793
|
+
}
|
|
66794
|
+
/**
|
|
66795
|
+
* One mutation at a time on THIS instance.
|
|
66796
|
+
*
|
|
66797
|
+
* The re-read above is a read-modify-write, so two callers that interleave between the read and
|
|
66798
|
+
* the rename would both pass a check neither still satisfies. One frontier is legitimately bound
|
|
66799
|
+
* to SEVERAL logs (the pinning runs the other way: a log may not change which record it
|
|
66800
|
+
* publishes onto), so concurrent callers on one instance are an ordinary state, not a misuse.
|
|
66801
|
+
*
|
|
66802
|
+
* It serializes this instance and nothing else. Two instances have two chains, which is the case
|
|
66803
|
+
* the re-read exists for.
|
|
66804
|
+
*/
|
|
66805
|
+
chain = Promise.resolve();
|
|
66806
|
+
serialize(op) {
|
|
66807
|
+
const next = this.chain.then(op, op);
|
|
66808
|
+
this.chain = next.catch(() => void 0);
|
|
66809
|
+
return next;
|
|
66810
|
+
}
|
|
66811
|
+
/**
|
|
66812
|
+
* Recover the tip from the THREAD LOGS beside this record, for an installation upgrading from a
|
|
66813
|
+
* release where this record did not exist.
|
|
66814
|
+
*
|
|
66815
|
+
* **THIS IS THE WHOLE UPGRADE PATH AND LEAVING IT OUT MAKES THE FIX APPLY TO NOBODY WHO ALREADY
|
|
66816
|
+
* RAN THE BROKEN VERSION.** My first attempt seeded from the log of the thread being opened, which
|
|
66817
|
+
* is empty in the case that matters: upgrading restarts the seat, so the first session after the
|
|
66818
|
+
* upgrade is a NEW thread with a virgin log, while the sequence it needs sits in the PREVIOUS
|
|
66819
|
+
* thread's log. A cell in `smoke:agui-multi-session` failed on exactly that and is the reason this
|
|
66820
|
+
* function exists rather than the reasoning that produced the first version.
|
|
66821
|
+
*
|
|
66822
|
+
* **ONLY WHEN THE RECORD IS ABSENT, NEVER WHEN IT READS ZERO.** A record holding zero is what
|
|
66823
|
+
* abandonment writes after a filtered purge, and re-seeding it from a thread log would silently
|
|
66824
|
+
* undo the abandonment and restore an expectation the subject no longer has. Missing and zero are
|
|
66825
|
+
* different states and this is the second place in this plane where conflating them is the bug.
|
|
66826
|
+
*
|
|
66827
|
+
* A sibling that cannot be read or does not parse is FATAL rather than skipped. Skipping it
|
|
66828
|
+
* under-counts the tip, which produces a permanent halt later with a message about a moved tip,
|
|
66829
|
+
* pointing at everything except the file that was quietly ignored here.
|
|
66830
|
+
*/
|
|
66831
|
+
static async recoverTipFromThreadLogs(principalDir, principal) {
|
|
66832
|
+
let entries;
|
|
66833
|
+
try {
|
|
66834
|
+
entries = await (0, import_promises4.readdir)(principalDir, { withFileTypes: true });
|
|
66835
|
+
} catch (e) {
|
|
66836
|
+
if (e.code === "ENOENT")
|
|
66837
|
+
return 0;
|
|
66838
|
+
throw e;
|
|
66839
|
+
}
|
|
66840
|
+
let best = 0;
|
|
66841
|
+
for (const ent of entries) {
|
|
66842
|
+
if (ent.isSymbolicLink())
|
|
66843
|
+
throw new SubjectFrontierCorruptError((0, import_node_path5.join)(principalDir, ent.name), "a real directory beside the record, never a symlink", "following it would carry this scan outside the principal directory, and the writer that creates these directories refuses a symlinked component for the same reason");
|
|
66844
|
+
if (!ent.isDirectory())
|
|
66845
|
+
continue;
|
|
66846
|
+
const walPath = (0, import_node_path5.join)(principalDir, ent.name, "wal.json");
|
|
66847
|
+
let st;
|
|
66848
|
+
try {
|
|
66849
|
+
st = await (0, import_promises4.lstat)(walPath);
|
|
66850
|
+
} catch (e) {
|
|
66851
|
+
const code = e.code;
|
|
66852
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
66853
|
+
continue;
|
|
66854
|
+
throw e;
|
|
66855
|
+
}
|
|
66856
|
+
if (st.isSymbolicLink())
|
|
66857
|
+
throw new SubjectFrontierCorruptError(walPath, "a real thread log, never a symlink", "following it would read a log this principal's writer never wrote");
|
|
66858
|
+
if (st.nlink > 1)
|
|
66859
|
+
throw new SubjectFrontierCorruptError(walPath, "a thread log with exactly one name", `it has ${st.nlink}, so the same file is reachable from outside this principal's directory and its tip is not this principal's to read`);
|
|
66860
|
+
let raw;
|
|
66861
|
+
try {
|
|
66862
|
+
const fh = await (0, import_promises4.open)(walPath, import_node_fs6.constants.O_RDONLY | (import_node_fs6.constants.O_NOFOLLOW ?? 0));
|
|
66863
|
+
try {
|
|
66864
|
+
raw = await fh.readFile();
|
|
66865
|
+
} finally {
|
|
66866
|
+
await fh.close();
|
|
66867
|
+
}
|
|
66868
|
+
} catch (e) {
|
|
66869
|
+
const code = e.code;
|
|
66870
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
66871
|
+
continue;
|
|
66872
|
+
if (code === "ELOOP")
|
|
66873
|
+
throw new SubjectFrontierCorruptError(walPath, "a real thread log, never a symlink", "the file became a symlink between the check and the open");
|
|
66874
|
+
throw e;
|
|
66875
|
+
}
|
|
66876
|
+
let doc;
|
|
66877
|
+
try {
|
|
66878
|
+
doc = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(raw));
|
|
66879
|
+
} catch (e) {
|
|
66880
|
+
throw new SubjectFrontierCorruptError(walPath, "a readable thread log while recovering the subject tip", e.message);
|
|
66881
|
+
}
|
|
66882
|
+
if (doc.principal !== principal)
|
|
66883
|
+
throw new SubjectFrontierCorruptError(walPath, `a thread log for principal ${principal}`, `found ${String(doc.principal)}`);
|
|
66884
|
+
const seq = doc.frontier?.lastSubjectSeq;
|
|
66885
|
+
if (!isSafeNonNegInt2(seq))
|
|
66886
|
+
throw new SubjectFrontierCorruptError(walPath, "frontier.lastSubjectSeq is a safe non-negative integer", String(seq));
|
|
66887
|
+
if (seq > best)
|
|
66888
|
+
best = seq;
|
|
66889
|
+
const pending = doc.pending;
|
|
66890
|
+
if (pending && pending.state === "acked") {
|
|
66891
|
+
const acked = pending.ackSeq;
|
|
66892
|
+
if (!isSafeNonNegInt2(acked))
|
|
66893
|
+
throw new SubjectFrontierCorruptError(walPath, "an acked pending carries a safe non-negative ackSeq", String(acked));
|
|
66894
|
+
if (!(acked > seq))
|
|
66895
|
+
throw new SubjectFrontierCorruptError(walPath, "an acked pending is ahead of the frontier it will fold into", `ackSeq=${acked} frontier.lastSubjectSeq=${seq}`);
|
|
66896
|
+
if (acked > best)
|
|
66897
|
+
best = acked;
|
|
66898
|
+
}
|
|
66899
|
+
}
|
|
66900
|
+
return best;
|
|
66901
|
+
}
|
|
66902
|
+
// `seedFromThread` used to live here, and it is GONE rather than kept for a caller that might
|
|
66903
|
+
// want it. Recovery moved into `open`, which is the only place that can see every sibling log,
|
|
66904
|
+
// and what was left behind was a public method that writes a tip into a record whose only
|
|
66905
|
+
// precondition is that the record reads 0. A record reading 0 is exactly what abandonment writes
|
|
66906
|
+
// after a channel purge, so the leftover was a supported route back into the state this file
|
|
66907
|
+
// exists to prevent, with no shipped caller to justify it.
|
|
66908
|
+
async reset() {
|
|
66909
|
+
return this.serialize(async () => {
|
|
66910
|
+
await this.write({ ...this.doc, tip: 0 });
|
|
66911
|
+
});
|
|
66912
|
+
}
|
|
66913
|
+
/** Atomic replace: sibling temp, fsync, rename, fsync the directory. */
|
|
66914
|
+
async write(next) {
|
|
66915
|
+
const tmp = `${this.path}.${(0, import_node_crypto12.randomUUID)()}.tmp`;
|
|
66916
|
+
const fh = await (0, import_promises4.open)(tmp, import_node_fs6.constants.O_WRONLY | import_node_fs6.constants.O_CREAT | import_node_fs6.constants.O_EXCL, 384);
|
|
66917
|
+
try {
|
|
66918
|
+
await fh.writeFile(JSON.stringify(next));
|
|
66919
|
+
await fh.sync();
|
|
66920
|
+
} finally {
|
|
66921
|
+
await fh.close();
|
|
66922
|
+
}
|
|
66923
|
+
try {
|
|
66924
|
+
await (0, import_promises4.rename)(tmp, this.path);
|
|
66925
|
+
} catch (e) {
|
|
66926
|
+
await (0, import_promises4.unlink)(tmp).catch(() => {
|
|
66927
|
+
});
|
|
66928
|
+
throw e;
|
|
66929
|
+
}
|
|
66930
|
+
await fsyncDir((0, import_node_path5.dirname)(this.path));
|
|
66931
|
+
this.doc = next;
|
|
66932
|
+
}
|
|
66933
|
+
};
|
|
66934
|
+
|
|
66449
66935
|
// ../connector-core/dist/agui-holder.js
|
|
66450
66936
|
var AguiEmitterHolder = class {
|
|
66451
66937
|
startEmitter;
|
|
@@ -66709,169 +67195,12 @@ function registerAguiFramePartRenderer() {
|
|
|
66709
67195
|
}
|
|
66710
67196
|
registerAguiFramePartRenderer();
|
|
66711
67197
|
|
|
66712
|
-
// ../connector-core/dist/agui-wal-path.js
|
|
66713
|
-
var import_node_crypto11 = require("node:crypto");
|
|
66714
|
-
var import_promises3 = require("node:fs/promises");
|
|
66715
|
-
var import_node_os3 = require("node:os");
|
|
66716
|
-
var import_node_path3 = require("node:path");
|
|
66717
|
-
var EventsStateRootMissing = class extends Error {
|
|
66718
|
-
constructor(message) {
|
|
66719
|
-
super(message);
|
|
66720
|
-
this.name = "EventsStateRootMissing";
|
|
66721
|
-
}
|
|
66722
|
-
};
|
|
66723
|
-
function resolveEventsStateRoot(env) {
|
|
66724
|
-
const root = env.COTAL_WORKSPACE_ROOT;
|
|
66725
|
-
if (typeof root !== "string" || root.trim() === "")
|
|
66726
|
-
throw new EventsStateRootMissing("events are enabled for this session but COTAL_WORKSPACE_ROOT is not set, so there is nowhere to put the event write-ahead log. The launcher forwards it from LaunchOpts.workspaceRoot; a session started outside a manager has no workspace root and must not publish events. Refusing rather than defaulting to the working directory, which would put the WAL somewhere no later start looks.");
|
|
66727
|
-
return root;
|
|
66728
|
-
}
|
|
66729
|
-
function h(value) {
|
|
66730
|
-
return (0, import_node_crypto11.createHash)("sha256").update(value).digest("hex").slice(0, 16);
|
|
66731
|
-
}
|
|
66732
|
-
function eventWalLocation(opts) {
|
|
66733
|
-
const principalDir = (0, import_node_path3.join)(opts.workspaceRoot, ".cotal", "events", h(opts.space), h(opts.principal));
|
|
66734
|
-
const threadDir = (0, import_node_path3.join)(principalDir, h(opts.threadId));
|
|
66735
|
-
return {
|
|
66736
|
-
principalDir,
|
|
66737
|
-
lockPath: (0, import_node_path3.join)(principalDir, ".lock"),
|
|
66738
|
-
threadDir,
|
|
66739
|
-
walPath: (0, import_node_path3.join)(threadDir, "wal.json")
|
|
66740
|
-
};
|
|
66741
|
-
}
|
|
66742
|
-
var PrincipalLockError = class extends Error {
|
|
66743
|
-
path;
|
|
66744
|
-
invariant;
|
|
66745
|
-
constructor(path, invariant, detail) {
|
|
66746
|
-
super(`event WAL principal lock at ${path} (${invariant}): ${detail}`);
|
|
66747
|
-
this.path = path;
|
|
66748
|
-
this.invariant = invariant;
|
|
66749
|
-
this.name = "PrincipalLockError";
|
|
66750
|
-
}
|
|
66751
|
-
};
|
|
66752
|
-
var held = /* @__PURE__ */ new Map();
|
|
66753
|
-
function ownerIsAlive(pid) {
|
|
66754
|
-
try {
|
|
66755
|
-
process.kill(pid, 0);
|
|
66756
|
-
return true;
|
|
66757
|
-
} catch (e) {
|
|
66758
|
-
return e.code !== "ESRCH";
|
|
66759
|
-
}
|
|
66760
|
-
}
|
|
66761
|
-
async function createLockFile(path) {
|
|
66762
|
-
try {
|
|
66763
|
-
return await openExclusiveNoFollow(path);
|
|
66764
|
-
} catch (e) {
|
|
66765
|
-
if (e.code === "EEXIST")
|
|
66766
|
-
return void 0;
|
|
66767
|
-
throw e;
|
|
66768
|
-
}
|
|
66769
|
-
}
|
|
66770
|
-
async function reclaimIfOwnerIsGone(path) {
|
|
66771
|
-
let raw;
|
|
66772
|
-
try {
|
|
66773
|
-
raw = await (0, import_promises3.readFile)(path, "utf8");
|
|
66774
|
-
} catch (e) {
|
|
66775
|
-
if (e.code === "ENOENT")
|
|
66776
|
-
return;
|
|
66777
|
-
throw e;
|
|
66778
|
-
}
|
|
66779
|
-
let record2;
|
|
66780
|
-
try {
|
|
66781
|
-
record2 = JSON.parse(raw);
|
|
66782
|
-
} catch {
|
|
66783
|
-
throw new PrincipalLockError(path, "the lock names its owner", "the file is not readable JSON, so its owner cannot be checked; refusing rather than reclaiming a lock that may be held");
|
|
66784
|
-
}
|
|
66785
|
-
const r = record2;
|
|
66786
|
-
if (!Number.isSafeInteger(r.pid) || r.pid <= 0 || typeof r.host !== "string" || r.host.length === 0)
|
|
66787
|
-
throw new PrincipalLockError(path, "the lock names its owner", `the record carries pid=${String(r.pid)} host=${String(r.host)}, which names nobody checkable`);
|
|
66788
|
-
const here = (0, import_node_os3.hostname)();
|
|
66789
|
-
if (r.host !== here)
|
|
66790
|
-
throw new PrincipalLockError(path, "the recorded owner is on THIS host", `held by pid ${r.pid} on ${r.host} while this process runs on ${here}; liveness on another machine is not observable from here`);
|
|
66791
|
-
if (ownerIsAlive(r.pid))
|
|
66792
|
-
throw new PrincipalLockError(path, "the recorded owner is gone", `pid ${r.pid} on ${here} is still running and holds this principal's emitter`);
|
|
66793
|
-
await (0, import_promises3.unlink)(path).catch((e) => {
|
|
66794
|
-
if (e.code !== "ENOENT")
|
|
66795
|
-
throw e;
|
|
66796
|
-
});
|
|
66797
|
-
}
|
|
66798
|
-
async function acquirePrincipalLock(lockPath) {
|
|
66799
|
-
const already = held.get(lockPath);
|
|
66800
|
-
if (already)
|
|
66801
|
-
return already;
|
|
66802
|
-
let fh = await createLockFile(lockPath);
|
|
66803
|
-
if (fh === void 0) {
|
|
66804
|
-
await reclaimIfOwnerIsGone(lockPath);
|
|
66805
|
-
fh = await createLockFile(lockPath);
|
|
66806
|
-
if (fh === void 0)
|
|
66807
|
-
throw new PrincipalLockError(lockPath, "the reclaimed lock is free when this process takes it", "another process created the lock between the reclaim and this open, and now holds this principal");
|
|
66808
|
-
}
|
|
66809
|
-
const record2 = JSON.stringify({ pid: process.pid, host: (0, import_node_os3.hostname)(), token: (0, import_node_crypto11.randomUUID)(), acquiredAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
66810
|
-
try {
|
|
66811
|
-
await fh.writeFile(record2, "utf8");
|
|
66812
|
-
await fh.sync();
|
|
66813
|
-
} catch (e) {
|
|
66814
|
-
await fh.close().catch(() => {
|
|
66815
|
-
});
|
|
66816
|
-
await (0, import_promises3.unlink)(lockPath).catch(() => {
|
|
66817
|
-
});
|
|
66818
|
-
throw e;
|
|
66819
|
-
}
|
|
66820
|
-
const lock = {
|
|
66821
|
-
path: lockPath,
|
|
66822
|
-
async release() {
|
|
66823
|
-
if (held.get(lockPath) !== lock)
|
|
66824
|
-
return;
|
|
66825
|
-
held.delete(lockPath);
|
|
66826
|
-
await fh.close().catch(() => {
|
|
66827
|
-
});
|
|
66828
|
-
await (0, import_promises3.unlink)(lockPath).catch((e) => {
|
|
66829
|
-
if (e.code !== "ENOENT")
|
|
66830
|
-
throw e;
|
|
66831
|
-
});
|
|
66832
|
-
}
|
|
66833
|
-
};
|
|
66834
|
-
held.set(lockPath, lock);
|
|
66835
|
-
return lock;
|
|
66836
|
-
}
|
|
66837
|
-
async function fsyncDir(dir) {
|
|
66838
|
-
let fh;
|
|
66839
|
-
try {
|
|
66840
|
-
fh = await (0, import_promises3.open)(dir, "r");
|
|
66841
|
-
} catch (e) {
|
|
66842
|
-
const code = e.code;
|
|
66843
|
-
if (code === "EPERM" || code === "EACCES")
|
|
66844
|
-
return;
|
|
66845
|
-
throw e;
|
|
66846
|
-
}
|
|
66847
|
-
try {
|
|
66848
|
-
await fh.sync();
|
|
66849
|
-
} catch (e) {
|
|
66850
|
-
const code = e.code;
|
|
66851
|
-
if (code !== "EBADF" && code !== "EINVAL" && code !== "EPERM" && code !== "EISDIR")
|
|
66852
|
-
throw e;
|
|
66853
|
-
} finally {
|
|
66854
|
-
await fh.close();
|
|
66855
|
-
}
|
|
66856
|
-
}
|
|
66857
|
-
async function ensureEventWalDir(opts) {
|
|
66858
|
-
const loc = eventWalLocation(opts);
|
|
66859
|
-
ensureDirNoSymlink(opts.workspaceRoot, ".cotal", "events", h(opts.space), h(opts.principal), h(opts.threadId));
|
|
66860
|
-
for (let dir = loc.threadDir; ; dir = (0, import_node_path3.dirname)(dir)) {
|
|
66861
|
-
await fsyncDir(dir);
|
|
66862
|
-
if (dir === opts.workspaceRoot || (0, import_node_path3.dirname)(dir) === dir)
|
|
66863
|
-
break;
|
|
66864
|
-
}
|
|
66865
|
-
const lock = await acquirePrincipalLock(loc.lockPath);
|
|
66866
|
-
return { ...loc, lock };
|
|
66867
|
-
}
|
|
66868
|
-
|
|
66869
67198
|
// ../connector-core/dist/tool-specs.js
|
|
66870
67199
|
var import_node_child_process2 = require("node:child_process");
|
|
66871
67200
|
|
|
66872
67201
|
// ../connector-core/dist/docs-bundle.generated.js
|
|
66873
67202
|
var DOCS_BUNDLE = {
|
|
66874
|
-
"version": "0.
|
|
67203
|
+
"version": "0.23.0",
|
|
66875
67204
|
"generatedFrom": "docs/*.md + SPEC.md + spec/cotal.schema.json",
|
|
66876
67205
|
"pages": [
|
|
66877
67206
|
{
|
|
@@ -66942,7 +67271,7 @@ var DOCS_BUNDLE = {
|
|
|
66942
67271
|
"title": "`cotal` CLI reference",
|
|
66943
67272
|
"kind": "Reference: describes the TypeScript reference implementation (the `cotal` CLI), not the wire contract.",
|
|
66944
67273
|
"summary": "cotal is the operator command line for the reference implementation: bring a mesh up, mint identities, launch agents, watch what they do, and tear it all down.",
|
|
66945
|
-
"body": "# `cotal` CLI reference\n\n> **Reference**: describes the TypeScript reference implementation (the `cotal` CLI), not the wire contract. \xB7 **For:** operators \xB7 **Wire contract:** [SPEC](../SPEC.md)\n\n`cotal` is the operator command line for the reference implementation: bring a mesh up, mint\nidentities, launch agents, watch what they do, and tear it all down. It is a thin client over the\nwire contract: the normative subjects and schemas live in the [SPEC](../SPEC.md); this page is\nlookup material for the commands, not a walkthrough; if you are new, start with\n[Getting started](getting-started.md).\n\n## Running it\n\n```bash\nnpm install -g cotal-ai # puts `cotal` on your PATH (needs Node 22+)\ncotal --help # every command, grouped\ncotal --version # cotal-ai version + each installed extension's (also `cotal -v`)\ncotal <command> --help # one command's flags and usage\n```\n\n`npx cotal-ai <command>` runs it without a global install; in a dev clone, `pnpm cotal <command>`\nruns it through `tsx` with no build step. Bare `cotal` prints help. Every command generates its own\n`--help`, usage, and shell completion from its declared flags.\n\nCommands come from the surfaces the binary composes: the base mesh CLI, the manager\n(`supervise`), and the delivery daemon (`deliver`), plus any operator-installed extensions.\n`cotal ext add <npm-package>` installs any registry providers a package contributes: commands,\nruntimes, and local process lifecycle descriptors. The `web` dashboard and optional manager\nruntimes ship this way.\n\n## Commands\n\n| Area | Command | Purpose |\n|---|---|---|\n| Set up & lifecycle | [`setup`](#setup) | Guided, configure-only setup (installs, seeds personas; launches nothing) |\n| Set up & lifecycle | [`update`](#update) | Reconcile first-party extensions and check or opt into a coherent CLI upgrade |\n| Set up & lifecycle | [`up`](#up) | Start a local mesh (nats-server + JetStream), or boot a whole manifest with `-f` |\n| Set up & lifecycle | [`down`](#down) | Stop the whole stack, selected registered components, or a manifest deploy |\n| Set up & lifecycle | [`backup`](#backup-and-restore) | Create an offline full-space or registry-only artifact from a preserved cut |\n| Set up & lifecycle | [`clean`](#clean) | Configurable cleanup: purge history (live), or wipe the local store / identity (stopped) |\n| Set up & lifecycle | [`meshes`](#meshes-use-status) | List the running meshes on this machine |\n| Set up & lifecycle | [`use`](#meshes-use-status) | Set the default mesh a bare `cotal spawn` joins |\n| Set up & lifecycle | [`status`](#meshes-use-status) | Read-only diagnostics for setup, processes, and the selected mesh |\n| Agents & personas | [`spawn`](#spawn) | Launch an agent from a persona (foreground, or `--detach` via the manager) |\n| Agents & personas | [`models`](#models) | List connector model catalogs and variants from the manager |\n| Agents & personas | [`ps`](#ps-stop-attach) | List managed agents and their mesh status |\n| Agents & personas | [`stop`](#ps-stop-attach) | Ask the manager to stop a managed agent |\n| Agents & personas | [`attach`](#ps-stop-attach) | Stream and drive a managed agent's terminal (pty runtime) |\n| Agents & personas | [`input`](#input) | Type one line into a managed agent's terminal without attaching |\n| Agents & personas | [`personas`](#personas) | List, show, edit, create, or remove local personas |\n| Agents & personas | [`supervise`](#supervise) | Run a manager daemon (the agent supervisor / control plane) |\n| Agents & personas | [`runtimes`](#runtimes) | List the agent runtimes the manager can spawn through and whether each is reachable |\n| Agents & personas | [`reconcile-gate`](#reconcile-gate) | Unfreeze an issuance gate left frozen by a crashed manager restart, after verifying the holder is gone |\n| Messaging & watching | [`endpoints`](#endpoints) | List every endpoint in the live presence roster, including infrastructure |\n| Messaging & watching | [`describe` / `invoke`](#describe-invoke) | Resolve a v0.4 service's command surface off the wire; invoke one command by name |\n| Messaging & watching | [`send`](#send) | Send one message, then exit: DM a peer, post a channel, or ask a role |\n| Messaging & watching | [`channels`](#channels) | Inspect or set the channel registry |\n| Messaging & watching | [`history`](#history) | Clear retained message history |\n| Messaging & watching | [`console`](#console) | Live protocol view for a space (TUI, or `--plain` line stream) |\n| Messaging & watching | [`web`](#web) | Browser dashboard (installed as the `@cotal-ai/web` extension) |\n| Auth & meshes | [`mint`](#mint) | Mint a creds file for a space (static auth mode) |\n| Auth & meshes | [`login`](#login-logout) | Sign in to a per-user-auth mesh's IdP (once per machine) |\n| Auth & meshes | [`logout`](#login-logout) | Revoke the IdP session and clear the cached login |\n| Auth & meshes | [`actor`](#actor) | Manage a user-auth space's actor ledger (grant / revoke / list) |\n| Auth & meshes | [`doctor`](#doctor) | Credential-health diagnosis and repair (`doctor auth`) |\n| Auth & meshes | [`join`](#join) | Join a space as your own presence (interactive) |\n| Manifest | [`topology`](#manifest-deploys) | Validate and view a mesh manifest's access graph (read-only) |\n| Extensions & misc | [`ext`](#ext) | Install / remove operator CLI extensions |\n| Extensions & misc | [`completion`](#completion) | Print or install shell completion |\n| Extensions & misc | [`feedback`](#feedback) | Send feedback to the Cotal developers |\n| Extensions & misc | [`deliver`](#server-daemons) | Run the server-side Plane-3 delivery daemon |\n| Extensions & misc | [`feedback-intake`](#server-daemons) | Run a self-hosted feedback intake server |\n\nThe manifest modes of `up`, `spawn`, and `down` (`-f <cotal.yaml>`) plus `topology` are covered\ntogether under [Manifest deploys](#manifest-deploys).\n\n## setup\n\n```bash\ncotal setup [--full] [--demo] [--yes]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--full` | off | Redo the full guided flow (implies `--demo`) |\n| `--demo` | off | Also seed the guided expert team (`david`, `sven`, `me`) |\n| `--yes`, `-y` | off | Non-interactive accept-all (for agents / CI) |\n\nGuided setup is **configure-only**: it checks prerequisites, installs the Claude Code plugin, and\nseeds persona files, and it launches nothing (no mesh, no web, no manager). First run gets the\nnarrated flow; later runs print a status card. By default it seeds one `default` persona; the\n`david`/`sven`/`me` team is opt-in via `--demo`. See [Getting started](getting-started.md) and, for\nmaintainers, [setup internals](setup-internals.md).\n\n## update\n\n```bash\ncotal update [--self]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--self` | off | If a newer release exists, install that exact validated `cotal-ai` version globally and reconcile through the newly installed binary |\n\nWithout `--self`, `update` keeps the installed first-party surfaces coherent with the running\nbinary: it force-reconciles the four built-in connectors, then reinstalls other `@cotal-ai/*`\noperator extensions at the binary's exact version. Each extension runs in an isolated child, so one\nfailure cannot poison later replays. It then checks npm; a newer binary is an informational notice\nwith `cotal update --self` as the next command, not an automatic install.\n\nWith `--self`, the npm check happens first. When a newer release exists, Cotal installs the exact\nversion it validated, resolves and verifies that package in npm's global root, then launches that\nbinary to reconcile connectors and first-party extensions to the new generation. An npx or dev-clone\ninvocation therefore installs and continues through a separate global copy; it never claims the\nalready-running process changed. If the binary is current, `--self` performs the normal local\nreconcile without reinstalling it.\n\nThird-party extensions are listed with their installed version and recorded spec but are not\nauto-updated in v1. Floating third-party updates require `@cotal-ai/*` peer-range validation and are\na future follow-up. A failed connector/extension install, npm metadata check, or requested global\ninstall is reported and makes the command exit nonzero. Independent extension attempts continue so\nthe output includes every failure; an unavailable npm registry does not undo a completed local\nreconcile, but the command still exits nonzero because it could not establish that the install is\ncurrent.\n\n## up\n\n```bash\ncotal up [--detach] [--open] [--space <s>] [--server <url>] [--channels <path>] [--runtime <name>]\ncotal up --tls-cert <cert.pem> --tls-key <key.pem> # serve TLS (both, or neither)\ncotal up --restore <dir> [--restore-only registry] [--accept-missing-source]\ncotal up -f <cotal.yaml> [--dry-run] [--runtime <name>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--server <url>` | auto (free local port) | Listen URL override |\n| `--host <host>` | \u2014 | Bind host override. With no `--server`, the broker URL is derived from it, so `--host <addr>` alone is enough to make a mesh reachable at that address; a `--host`/`--server` pair naming different addresses is refused. A wildcard bind (`0.0.0.0`, `::`) keeps a dialable loopback URL. Recorded on the mesh and reused by every later manager launch, so a repair or resume keeps remote [`attach`](#ps-stop-attach) working |\n| `--space <s>` | the folder's name | Space name |\n| `--store-dir <dir>` | \u2014 | JetStream store directory |\n| `--channels <path>` | `.cotal/channels.json` if present | Channel-registry seed file (JSON). An explicit path that is missing is an error |\n| `--restore <dir>` | \u2014 | Restore a completed offline backup before exposing the normal listener |\n| `--restore-only registry` | artifact selection | Restore only the registry component |\n| `--accept-missing-source` | off | Explicit disaster consent when the inode-bound preserved source is absent |\n| `--open` | off (auth) | Unauthenticated dev mesh: no JWT, no ACLs |\n| `--user-auth` | off | Per-user auth: people `cotal login`; connects are authorized against the actor ledger |\n| `--idp <url>` | \u2014 | With `--user-auth`: the IdP auth base URL to pin on first enable |\n| `--detach` | off | Run in the background (stop with `cotal down`) |\n| `--tls-cert <path>` | \u2014 | PEM certificate to serve TLS with. Must be given together with `--tls-key`. The pair is validated **before** the broker starts \u2014 readability, private-key mode, that the two match, the validity window, and that the certificate covers the host clients will dial \u2014 because `nats-server` starts happily on an expired certificate and only the client then fails. The decision is recorded, so a later bare `cotal up` after a `cotal down` keeps serving TLS rather than silently reverting to cleartext |\n| `--tls-key <path>` | \u2014 | PEM private key for `--tls-cert`. Refused if group- or other-readable (tighten to `600`) |\n| `--file <cotal.yaml>`, `-f` | \u2014 | Launch a whole mesh from a manifest |\n| `--dry-run` | off | With `-f`: print the plan, mutate nothing |\n| `--runtime <name>` | `pty` (or the manifest's, with `-f`) | Agent runtime for the mesh manager (`pty` built in; others are installed extensions, explicit-only). Resolved + probed before the broker starts; an uninstalled/unreachable runtime fails loud. With `-f`, overrides the manifest's runtime |\n| `--rotate-sys` | off | Rotate the space's system account and re-mint its two `$SYS` creds. Needs a stopped mesh; refused with `--open` |\n\n`cotal up` boots a local nats-server with JetStream and, in auth mode (the default), JWT auth and\nper-agent ACLs; `--detach` records the mesh so `cotal spawn` from any directory can find it. With no\n`--server`, it auto-selects a free port if the default address is taken; an explicit `--server`\nstays fail-loud on collision. `--detach` also brings up the control plane (delivery daemon in auth\nmode, then the manager). The `-f` form is a [manifest deploy](#manifest-deploys); see\n[Run a mesh](run-a-mesh.md).\n\n`--user-auth --idp <url>` starts the space's auth service alongside the broker (the NATS\nauth callout plus the loopback token exchange); it is torn down with `cotal down`, and a\nre-run of `cotal up` heals a dead service on a running broker. `--user-auth` and `--open`\ncontradict each other and are refused loudly; a running broker cannot change auth mode\nwithout a `cotal down` first. See [identity & auth](identity-and-auth.md).\n\n`--rotate-sys` renews the two `$SYS` credentials (`membership-observer`, `connection-evictor`).\nThey carry a 30-day expiry and nothing re-signs them in place, because the system-account seed is\nnever persisted, so they are renewed by issuing a **new system account** under the same broker\noperator and minting fresh creds against it. A plain re-`up` does **not** do this: it reuses the\nexisting trust record, and its `$SYS` creds along with it.\n\nThe rotation is safe to run on a real space, with one operational cost. The data account, the account\nsigning key, every agent credential minted from it, and the JetStream store are all untouched; what\ndies is the retired system account, and with it any out-of-band copy of the old `$SYS` creds, on every\nbroker that loads the rotated config. The cost is that **earlier full backups stop being restorable**\n(see below), so this is not a no-consequence operation. It needs the broker to restart on the rewritten\nconfig, so it runs as part of a boot:\n\n```bash\ncotal down\ncotal up --rotate-sys --detach # agents reconnect; nothing is re-provisioned\ncotal doctor auth # both $SYS creds healthy again, 30 days out\n```\n\nA rotation is a stopped, fresh boot, and anything that is not one refuses it, all for the same reason\n(the on-disk material and the broker it runs on must never end up on different generations):\n\n- a live mesh, because the running broker would keep serving the retired account;\n- an open mesh, whether that comes from `--open` or from `broker.auth: false` in a manifest, which\n has no system account at all;\n- `--restore`, because reinstating a trust root and superseding it in one command leaves no way to\n say which authority the mesh came up on;\n- an unfinished restore or resume attempt on this root, including one `cotal up` would recover on\n its own, because those paths can adopt a live listener and return without booting a broker;\n- a root that hosts more than one space, because the system account lives in the shared broker\n record and a rotation would retire every tenant's, while the root holds one `$SYS` cred pair\n pinned to one data account.\n\nTwo things to know before you run it:\n\n- **The retirement is config-load-bound.** Old `$SYS` creds are refused by any broker that loads the\n rotated config. A stale `nats-server` still running the *previous* config in memory would keep\n honouring them, so stop every broker for this root first. `--rotate-sys` refuses if this root's\n mesh is recorded as running, if anything unidentified is answering at the address it was given, or\n if the root's pid file names a live (or unreadable) process. Those are Cotal's own ownership\n records, not a scan of the process table: a `nats-server` you started by hand against this root's\n `server.conf` on some other port writes none of them and will not be seen. Do not run one.\n- **It invalidates earlier full backups.** A full artifact binds to the trust chain it was taken\n against, and that commitment covers the operator JWT and the system account. Every full backup\n taken before a rotation refuses to restore afterwards, so take a fresh `cotal backup` once the\n rotated mesh is up. `cotal up --restore` names this case when the data account still matches.\n\nThe commit is not atomic (a trust-record write plus two credential writes), so an interrupted\nrotation leaves the record ahead of the creds. That split is detected rather than silent: every\n`cotal up` on an auth mesh, and every `cotal doctor auth`, compares each `$SYS` cred's issuer against\nthe persisted record and names the retired account. `up` warns rather than refusing, because these\ncreds power the membership graph and live eviction, both of which degrade fail-soft; the mesh is not\nworth taking down over them. Re-running the rotation heals it, at the cost of one generation.\n\nWhile those creds are expired the mesh keeps delivering messages, but the\n[membership feed](delivery-daemon.md) and live connection eviction stay down; `cotal doctor auth`\nand the manager's log both name the credential and this repair.\n\n## down\n\n```bash\ncotal down\ncotal down --preserve-state [--store-dir <dir>]\ncotal down manager [delivery auth web nats ...]\ncotal down web [--space <name>]\ncotal down -f <cotal.yaml> | --run <id> [--dry-run]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--file <cotal.yaml>`, `-f` | \u2014 | Tear down this manifest's deploy |\n| `--run <id>` | \u2014 | Tear down one `spawn -f` run by id |\n| `--space <name>` | current mesh | With components: the mesh whose target-addressed components (e.g. `web`) to stop |\n| `--dry-run` | off | Print the manifest teardown or selected components, mutate nothing |\n| `--preserve-state` | off | Bare whole stack only: fence the manager, retain principals and durable state, stop and prove the stack down, then publish `ready` |\n| `--store-dir <dir>` | `.cotal/nats` | With `--preserve-state`: the actual store path (required for a custom store) |\n\nBare `cotal down` stops the whole local stack in dependency order. Positional component names stop\nonly those self-registered local processes; for example, `cotal down manager` leaves delivery and\nthe broker running, and `cotal down web` is available when the web extension is installed. A\ncomponent that starts target-resolved (the web dashboard) is stopped the same way: `cotal down web`\nresolves the mesh exactly like `cotal web` (registry current mesh first, `--space` to name one), so\nit works from any directory; the other components always stop under the folder you run it in. The\n`-f` / `--run` forms tear down a [manifest deploy](#manifest-deploys) without stopping the whole mesh\nand cannot be combined with component names. Stopping `nats` alone is refused while an unselected\nregistered daemon is still live; include those components or use bare `cotal down`.\n\nNormal `down` remains destructive at the logical identity/durable layer. `--preserve-state` is a\ndifferent maintenance transition: it suppresses leave/deprovision cleanup, persists the manager's\nsame-principal resume inventory, stops the entire stack without removing run/auth artifacts, and\npublishes a stable inode-bound cut only after every recorded process is proven stopped and the exact\nrecorded NATS endpoint is unreachable. A missing or stale broker pidfile never counts as stopped. The\nattempt is bound durably before the manager is fenced, the resume document and attempt-bound\n`cut-intent` are fsynced before manager commit, and the manager's commitment itself is journaled\n(`cut-committed`) before any process stops. A retry after a crash at any of those boundaries reuses\nthe exact recorded attempt and finishes the remaining stop and endpoint proofs idempotently, without\nneeding the (by then intentionally dead) manager. A partial cut never publishes `ready`. It cannot\nbe combined with component names, manifest teardown, or `--dry-run`.\n\n## clean\n\n```bash\ncotal clean <history|store|all> --force\ncotal clean restore-attempt --attempt <id> --force\ncotal clean restore-fallback --attempt <id> --force\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | `history`: target mesh |\n| `--dms` | off | `history`: also clear DM history |\n| `--store-dir <dir>` | `.cotal/nats` | `store`/`all`: JetStream store directory |\n| `--force` | \u2014 | Required: destructive, no prompting |\n| `--attempt <id>` | \u2014 | `restore-attempt`: exact stale pre-commit attempt; `restore-fallback`: matching healthy committed restore |\n\nOne configurable cleanup verb; every target requires `--force`.\n\n- `history` purges the retained message backlog on the **running** broker (channels, plus DMs\n with `--dms`). The same operation as [`history clear`](#history), which stays as an alias.\n- `store` deletes the **stopped** mesh's JetStream store (`.cotal/nats`): streams, durable\n consumers, and messages. This is the reset for stale on-disk broker state, e.g. durables\n minted by an older, incompatible Cotal generation surviving a `down`/`up` cycle.\n- `all` is `store` plus the space identity (`.cotal/auth`), the local creds and markers tied to\n it, any crash residue a normal `down` would have swept (stale pidfiles, `run/`), and the mesh's\n registry entry; the next `cotal up` mints a fresh identity.\n\n`history` needs the mesh up; `store` and `all` refuse while any recorded mesh process is still\nalive or any same-root recorded broker endpoint remains reachable (run `cotal down` first). They\nalso refuse outright on a root that holds accounts for several spaces: the store and the broker\ntrust record are shared by every space on the broker, so both targets would take out all of them\nand no `--space` can narrow that. `down`, `backup` and `up --restore` refuse there for the same\nreason. `cotal status` lists the tenants on such a root. Personas\n(`.cotal/agents`) and logs are never touched. A custom\nstore location is not recorded anywhere, so `--store-dir` must repeat whatever the mesh was\nlaunched with. Custom cleanup targets must contain either the Cotal store-generation marker or a\nreal `jetstream/` store directory; filesystem roots, project roots, and Cotal auth/maintenance trees\nare always refused.\n\n`store` and `all` also refuse every maintenance journal state. After a healthy committed restore,\n`restore-fallback` is the only supported way to remove the recorded unchanged old-store inode; it\nnever deletes the active target, requires both the exact attempt id and `--force`, and retires the\ncompleted restore journal so a later `down --preserve-state` can start a new backup cycle.\n\n## backup and restore\n\n```bash\ncotal down --preserve-state [--store-dir <dir>]\ncotal backup create <dir> [--only full|registry] [--store-dir <dir>]\ncotal up --restore <dir> [--restore-only registry] [--accept-missing-source]\n```\n\nBackup is offline-only. It requires the stable `ready` record from `down --preserve-state`, an exact\nstore match, no live recorded process, and an unreachable exact endpoint from the recorded cut.\nThat endpoint is probed immediately before cloning, so a live broker with a missing or stale pidfile\nis still refused. It claims the cut, reflink/copies the stopped source to a\nprivate attempt clone, and opens only that clone on a random loopback bootstrap broker with an\nindependent parent/deadline watchdog. It validates the canonical stream and pull-consumer inventory,\nwrites native snapshots with consumers excluded, and stores conservative contiguous ACK-floor\ncheckpoints separately. The original store is never opened by the backup broker, and the stack is\nnot restarted implicitly. Artifact destinations must not overlap the preserved source or maintenance\nattempt tree. Restore artifacts and targets likewise cannot nest inside or contain each other, the\npreserved source, or the maintenance attempt tree.\n\n`full` is the default and indivisible: channel registry, CHAT/DM/TASK/INBOX/DLV, ACL, MEMBERS, and\nvalidated durable checkpoints. `registry` is the sole partial artifact. Presence, derived membership\nfeed, leases, native ephemeral/history consumers, credentials, keys, tokens, owner secrets, and actor\nledger files are excluded. Artifacts are exclusively created `0700`; snapshot/checkpoint files and\nthe manifest are `0600`; `manifest.json` is written last with exact sizes and SHA-256 values. The\ndirectory is trusted operator input: hashes detect corruption, not malicious rewriting.\n\nRestore validates and stages the exact allowlisted artifact bytes before moving or creating a store.\nIt requires the same space and existing trust state. The whole pre-commit window holds a journaled\nliveness claim (coordinator, watchdogs, brokers, absolute deadline): ordinary `up` and a repeated\n`up --restore` refuse while the claim is live, and a stale attempt is recovered only after the\ndeadline has elapsed and every recorded owner is proven dead \u2014 automatically by a retried\n`up --restore`, or explicitly with `cotal clean restore-attempt --attempt <id> --force`. Nothing\never rolls back a live attempt. A registry-only artifact restores as registry-only whether or not\n`--restore-only registry` is passed; omitted infrastructure is always created and the exact\npost-restore stream inventory is asserted before commit intent. Ordinary `up` from a preserved cut\nresumes only the exact recorded source store and runtime; a contradicting `--store-dir` or\n`--runtime` fails in preflight. Authenticated restores validate the complete\nspace trust bundle before staging, including nkeys, seed matches, JWTs, signers, and space binding;\nfull restores commit to the validated operator, system-account, data-account, and active-signer root\nchain in addition to the static/user authority fingerprint. Because the system account is part of that\ncommitment, a [`cotal up --rotate-sys`](#up) makes every full artifact taken before it unrestorable\nagainst this root: take a fresh full backup after each rotation. The composed commitment is revalidated\nimmediately before store mutation and never includes secret seeds. Restore never creates fresh auth.\nSame-path restores atomically retain the old\nsource at the journaled fallback path; alternate targets retain it in place; a missing canonical\nsource needs explicit `--accept-missing-source`. Quarantine and target restores use current canonical\nconfigs on isolated random-loopback brokers, never expose native snapshot consumers, and publish a\ncommit-intent immediately before the normal listener starts. Archive bytes never instantiate the real\ntarget: after quarantine validation, every stream is re-snapshotted from the validated quarantine\nstate into attempt-owned sanitized files, and the target is restored solely from those. Before that boundary, failure rolls back\nthe attempt-owned target; after it, ambiguity preserves both stores and records forward-repair\nrecourse. The cooperative maintenance lock excludes Cotal commands, not arbitrary raw NATS processes.\n\nBootstrap brokers in every auth mode \u2014 including open \u2014 mount the store under a local account with\nrandom operation-specific logins only, each carrying the exact per-phase subject permission matrix;\nnormal static credentials and user-auth sentinel/bearer connections are rejected, and no auth\nservice or callout starts. Open mode differs only in its account label, never in authority. Inventory, each stream snapshot,\nrestore initiation, exact upload id, validation, and each checkpoint recreation use separate exact\nauthorities. Every checkpoint carries the source stream's message/first/last sequence state and must\nmatch its snapshot record before mutation; core then derives and validates the only allowed start\npolicy. TASK is not a CLI exception: the same core checkpoint API recreates its canonical `DeliverAll`\nWorkQueue durable because acknowledged tasks are absent from retention and NATS forbids a\nstart-sequence policy there. Registry-only restore creates every omitted canonical stream and transient\nbucket on the isolated target before the normal listener is exposed. It deliberately does not resume\nretained agents or recreate their DM/DLV/TASK/ACL state; their identity material stays retained and\nstopped rather than being reprovisioned into a partial restore.\n\nAfter listener readiness, the manager starts attempt-bound, validates retained credentials/tokens\nwithout granting or reprovisioning, and resumes the exact persisted principals under cleanup\nsuppression. Registry-only restore uses the same flow with an empty agent set. `commitResume` is an\nidempotent validation barrier only: success must be `awaitingFinalize` with an attempt-bound 64-hex\ncommit token and does not release suppression. Under the workspace lock, the CLI first fsyncs that\nexact evidence as `manager-committed` (restore) or `resume-committed` (ordinary resume), then calls\ntoken-bound `finalizeResume`; only an `active` response for the exact token releases suppression. The\nCLI records the same token in finalization evidence before a restore becomes `active`, or before an\nordinary resume retires and consumes the marker. Re-entry from either committed state skips the prior\nidempotent activation/commit phases, retries finalization with the durable token, and finishes the\nworkspace transition. Failure before finalization preserves the committed state and cleanup\nsuppression; it is not rewritten through a degraded transition. Re-entry between any two earlier\nboundaries reuses the same attempt and may retry the idempotent phases without deleting retained state. A missing or\nchanged per-agent dependency is a named fail-closed result; the journal becomes degraded and remains\navailable for forward repair. A retry from `resume-intent`,\n`resume-active`, or `resume-degraded` reuses the same attempt and inventory after the prior listener is\nproven stopped. Every normal restore listener has an unguessable attempt-bound NATS server name. The\nCLI fsyncs its exact name/nonce, canonical endpoint, process owner, and generation-bound target identity\nimmediately after spawn. Re-entry accepts a surviving listener only when its INFO server name, live PID\nrecord, endpoint, and target identity all match that proof; degraded restore repair then moves through\nthe guarded workspace transition only after manager commit. If an uncommitted bound owner is provably\ndead, recovery retires that exact proof under the maintenance lock and binds a fresh listener for the\nsame attempt, endpoint, and target with a new nonce and server name. A live foreign/mismatched listener\nor ambiguous owner is preserved and refused, never adopted by reachability alone. A reconstructed\ncommit/degraded attempt without either the exact bound proof or a durable dead-listener replacement\nrecord fails closed even when the recorded port is free. A later ordinary startup may pass an `active`\nrestore only when its details prove manager commit and its exact recorded listener is dead.\n\n## meshes, use, status\n\n```bash\ncotal meshes\ncotal meshes add # guided, on a terminal\ncotal meshes add <space> --server <url> [--root <dir>] [--mode auth|open] [--force]\ncotal meshes rm <space> [<space> \u2026] [--force]\ncotal use <space>\ncotal status [--space <s>] [--server <url>]\n```\n\n`meshes` lists the meshes this machine knows; a `*` marks the `current` default a bare\n`cotal spawn` joins.\n\nRun on a terminal with the space or `--server` missing, **`meshes add` is guided**: it asks for the\none thing that cannot be derived (the broker URL), probes it, and tells you what answered - open or\nrequiring credentials. It then offers the spaces your `--root` already holds credentials for, states\nthe mode as a fact about that broker rather than asking, and shows the exact record before writing\nanything. A broker that does not answer, or a space name already registered, becomes a choice rather\nthan an error. Anything you pass on the command line is taken as given and not asked again. Without\na terminal - a script, an agent, CI - nothing prompts and the flag form's errors stand\n(`COTAL_NO_PROMPT=1` forces that too).\n\n`cotal up` and `cotal down` maintain their own records. `meshes add` registers a mesh they cannot\nspeak for: one running on another machine, a shared broker, a hosted space. `--root` is the folder\nwhose `.cotal/auth` holds that mesh's credentials and whose `.cotal/agents` holds its personas\n(default: the project you run it in) \u2014 the registry stores that path, never a secret. `--mode`\ndefaults to `auth` when the root holds the space's account record and to `open` otherwise; a\nuser-auth space cannot be registered by hand, because its IdP pins are trust that only\n`cotal up --user-auth` establishes. The broker is probed before anything is recorded, so a wrong\naddress, or credentials that mesh will not accept, fails here instead of at the first `spawn`;\n`--force` records without verifying (and replaces an existing record).\n\n`meshes rm` drops records \u2014 it never stops a mesh. For a mesh running on this machine `cotal down`\nis the right verb, and `rm` says so unless you pass `--force`. A record you added by hand is only\nremoved by something that names it \u2014 `meshes rm`, or an `add --force` replacement \u2014 or by a\n`cotal up` that actually starts the broker for that same space, server and root, which becomes that\nmesh and so takes the record over (a `cotal up` for that space anywhere else refuses instead).\nNothing that merely *infers* a record is stale touches it: an\nunreachable broker is listed `offline` and stays, and `cotal down` / `cotal clean all` leave it\nalone even when it shares a root with the project they are tearing down, because nothing on this\nmachine could write it back.\n\n`use <space>` sets that default; the selection applies from every directory,\nincluding inside another mesh's project. `status` is a read-only report: machine prerequisites\n(starting with the installed `cotal-ai` version), the installed extensions and their versions, this\nfolder's `.cotal/`, the recorded meshes, and a live snapshot of the selected mesh (roster, channels,\nmembership feed). `status` takes only `--space` / `--server` to pick the mesh to inspect; it starts\nnothing.\n\n## spawn\n\n```bash\ncotal spawn [<persona>] [--detach] [--name <n>] [--agent <a>] [--model <m>] [--variant <v>] [--prompt <text>] [--cwd <dir>]\ncotal spawn -f <cotal.yaml> [--dry-run]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | resolved mesh | Target space |\n| `--server <url>` | registry entry | Broker URL override |\n| `--creds <path>` | \u2014 | Control-caller creds for an off-registry manager (`--detach` only) |\n| `--name <n>` | persona's `name:` | Presence-name override (does not choose the persona) |\n| `--config <persona-or-path>` | \u2014 | Persona catalog name or file path; wins over the positional |\n| `--agent <a>` | `COTAL_DEFAULT_AGENT`, else `claude` | Connector type (`claude`, `opencode`, `hermes`, \u2026) |\n| `--role <r>` | persona's `role:` | Role override |\n| `--model <m>` | persona's `model:` | Model override |\n| `--variant <v>` | persona's `variant:` | Model variant override (connector-defined; e.g. OpenCode reasoning tiers) |\n| `--cwd <dir>` | this cwd | Working directory to root the agent at |\n| `--prompt <text>` | \u2014 | Initial prompt auto-submitted at start |\n| `--resume <id>` | \u2014 | Fork an existing session id into the mesh (claude only) |\n| `--events` / `--no-events` | off | Publish the session's structured event plane to its own event channel |\n| `--share-tools <sel>` | none | Share named operator MCP servers with the agent |\n| `--subscribe <a,b>` | persona's | Channel read-set override |\n| `--allow-subscribe <a,b>` | = subscribe | Read-ACL override |\n| `--allow-publish <a,b>` | deny | Post-ACL override |\n| `--detach`, `-d` | off | Launch via the manager into a detached PTY (reattach with `cotal attach`) |\n| `--on <instance>` | class anycast | With `--detach` only: pin the launch to one manager instance id (the whole id, as `ps` prints it). Refused on a foreground spawn (no manager to pin), with `-f` (a manifest deploy launches through the manager class queue), and when empty |\n| `--file <cotal.yaml>`, `-f` | \u2014 | Deploy a manifest onto the running mesh |\n| `--dry-run` | off | With `-f`: print the plan, mutate nothing |\n| `--allow-stale <a,b>` | \u2014 | With `-f`: waive named stale agents (apply-only) |\n| `--runtime <name>` | manifest's | With `-f`: override the manifest's runtime |\n\n`--events` turns on the session's **event plane**: a stream of structured events describing what\nthe agent did, rather than the prose it wrote, on a channel of its own. The channel is named after\nthe agent's principal, `events.<owner>.<actor>`, never after its display name, because two live\nagents are allowed to share a display name and would then share a stream. The launch grants publish\nrights on exactly that one channel, foreground and detached alike, and a connector that does not\npublish an event plane refuses the flag rather than starting a session whose events have nowhere to\ngo.\n\nThe flag and the grant are separate on purpose. Holding publish rights on a channel is not a request\nto publish to it, so writing an event channel into an agent file's `allowPublish` does not turn the\nplane on: only the launch does.\n\nThe persona (`--config` > positional > `COTAL_DEFAULT_PERSONA` > `default`) is loaded from the\ntarget mesh's `.cotal/agents/`; the launch flags override the file. Foreground runs the agent\nattached to your terminal; `--detach` hands the launch to the running manager. Both modes get the\ndurable backstop on a mesh that runs the delivery daemon; `--live-only` skips it for a foreground\nspawn (messages posted while it is disconnected are then not replayed). A foreground exit retires\nthe agent's creds and broker footprint, like a manager despawn. A `--detach` spawn is an\n**action**: the manager accepts it and returns the allocated identity at once, then the launch\nfollows to a terminal outcome rather than blocking (see [the control surface](control-surface.md)).\nSee [Connect Claude Code](connect-claude.md) and [Agent files](agent-files.md); `-f` is a\n[manifest deploy](#manifest-deploys). (`cotal start` was merged into `cotal spawn --detach`.)\n\n## models\n\n```bash\ncotal models [--agent <connector>] [--refresh]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which manager to reach |\n| `--agent <connector>` | all registered connectors | Connector whose catalog to list |\n| `--refresh` | off | Ask the connector to refresh its provider cache |\n\nAsks the running manager for each connector's model catalog (model ids plus their variants)\nfor connectors that expose one (OpenCode today; a connector without a catalog says so). Pick a\nresult with `cotal spawn --model <provider/model> --variant <v>`.\n\n## endpoints\n\n```bash\ncotal endpoints [--space <s>] [--server <url>] [--creds <path>]\n```\n\nLists the mesh presence roster: agents, the manager, and any other protocol endpoint, with each\nendpoint's role, kind, status, and current activity. Unlike `ps`, this is a read-only presence view;\nit is not limited to child processes owned by the manager.\n\n## describe, invoke\n\n```bash\ncotal describe <endpoint> [--space <s>]\ncotal invoke <endpoint> <command> [--args '<json>'] [--space <s>]\ncotal invoke <endpoint> <command> --name <agent> [--admin] [--space <s>]\n```\n\nThe generic v0.4 service surface. `describe` resolves a registered endpoint's command set off the\nwire - the reserved `describe` command answers the registered contract digests, the schemas are\nfetched from the space's content-addressed contract store, recompiled, and verified against those\ndigests - and prints each command with its capability class and targeting shape. `invoke` calls one\ncommand by name: `--args` is a JSON object validated against the fetched input schema *before*\npublish; a targeted command takes `--name <agent>` (resolved to the agent's current principal via\n`ps`) or `--self`. `--admin` uses the admin instrument credential, whose cross-agent reach rides\nthe operator-only `any` authorization mode. Neither command has compile-time knowledge of any\nendpoint's schemas - this is the same trust chain every built-in control command now uses. Needs an\nauth mesh: the manager registers its service on both static and per-user meshes (a signed-in user\nrides their bearer; cross-agent reach needs the `admin` scope). An open mesh has no service\nregistry.\n\n## ps, stop, attach\n\n```bash\ncotal ps [--on <instance>] [--space <s>]\ncotal stop --name <n> [--on <instance>] [--space <s>]\ncotal attach --name <n> [--on <instance>] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which manager to reach |\n| `--name <n>` | \u2014 | Managed agent to stop / attach (required) |\n| `--on <instance>` | class anycast (`ps`: class scatter) | Pin to one manager instance id (multi-manager space); takes the whole id as `ps` prints it, not a prefix. An empty value (`--on \"\"`, an unset shell variable) is refused, never treated as absent |\n\nThese are operator clients over the running manager's control plane. `ps` prints two facts per\nmanaged agent, because they answer different questions: the process fact from the manager's own\nruntime handle (`running` with its uptime, or `exited` with how long it ran), and the mesh fact from\nthe roster (`idle` / `working` / `waiting` / `mesh offline`, or `not in roster` when the seat has no\npresence row at all: a seat that has not joined yet, or one that never did). A seat can be `running` and `mesh offline` at once: the process is alive and\nits presence has lapsed. On a user-auth mesh `ps` also renders each managed agent's last\ncredential-refresh outcome, fail-closed.\n\n**Mode split (chosen up front, never try-scatter-then-degrade):**\n\n- **Static / open mesh.** Bare `ps` is a **class scatter**: it freezes the live manager class from\n the records registry, merges every registered instance's agents grouped and attributed per\n instance, and a non-answering instance is shown as `registered, no answer within the deadline`\n (never silently omitted). That label is the whole claim: the instance is registered and did not\n answer. It does not say the host is down, because a dead host never deregisters itself and a\n live one can be slow; if it is gone, deregister it.\n `--on <instance>` pins the read to one exact instance id instead. A wrong pin fails loud\n rather than falling through: a well-formed id that no live manager carries is reported as\n `manager instance <id> did not answer` (nothing else is asked), and a credential without that\n instance's rail is reported as refused by the broker, not as an unresponsive manager. A manager\n that answers with a refusal is shown with its own cause; \"no manager reachable\" is said only when\n nothing answered at all. If the scatter's own registry read fails (the freeze or the reconcile),\n `ps` says the manager registry could not be read rather than pronouncing on the managers, which\n may all be up.\n\n**`stop` and `attach` route by seat locality.** A seat can only be stopped or attached by the\nmanager actually running it, and the class queue does not know which one that is. So on a\nstatic/open mesh both verbs first ask every registered instance which one hosts the named seat, then\naddress that instance directly. You do not need `--on` for this \u2014 it happens by default.\n\n`--on <instance>` remains the override, for when you already know where the seat lives or the\nlookup itself is degraded. It is also the **only** route on a **user-auth mesh**: a ledger-scoped\nbearer does not hold the registry-read rows the lookup needs, so there the verbs stay on the class\nqueue unless you pin them yourself.\n\nIf the seat is found on no reachable instance, the error says so \u2014 how many managers answered, and\nwhich ones did not \u2014 rather than reporting a bare `no agent <name>`. That distinction matters\nbecause a single manager cannot tell \"hosted elsewhere\" from \"does not exist\": it answers\n`not-found` for both.\n- **User-auth mesh.** `cotal ps` reports what **one** manager knows about your agents (an `ep.one`\n read against the manager's in-memory roster, owner-filtered). It does **not** report other\n manager instances, and it cannot tell you that one is down \u2014 an unreachable manager is absent\n from the list, not flagged. Completeness across a multi-manager user-auth space is not claimed.\n A manager that does not answer fails the command outright (exit non-zero), rather than printing\n an empty list that could be read as \"no agents\". Your ledger row needs the `admin` scope to\n reach `ps` at all; `spawn` alone is refused by the broker (the ep tier boundary).\n\n`attach` streams and drives an agent's terminal on the `pty` runtime; detach with the escape key\n(Ctrl-] by default; see [`COTAL_DETACH_KEY`](config.md)). It does so over a one-use, holder-bound\nmesh session ([SPEC](../SPEC.md) \xA713.6): the manager replies with a signed session grant (never a\n`127.0.0.1` URL), the CLI redeems it once over the broker, and the browser console (`cotal console`)\ndrives the same session. `stop` and `attach` need a running manager to talk to. On a static mesh\nthey are cross-agent admin operations. On a user-auth mesh, your own agents (any agent under your\nowner) need only the `spawn` scope; another owner's agent needs `admin` on your ledger row\n([identity & auth](identity-and-auth.md)). Launch detached agents with [`spawn --detach`](#spawn).\n\n`attach` streams over the manager's own HTTP/WS face rather than the mesh. That endpoint binds\n**loopback by default**, so nothing is exposed by accident; `cotal up --host <addr>` passes its bind\naddress down, which is what lets you attach to an agent whose manager runs on another machine. A\nbare `cotal supervise` and an embedded manager stay machine-local. Set it directly with\n`supervise --console-host <host>`.\n\nThat address is **recorded on the mesh** and carried forward, because it is a decision rather than\nsomething later commands can work out for themselves (a broker dial address is not a manager bind\naddress). Every later manager launch for the same mesh reuses it \u2014 a same-root `cotal up` repair,\nadopting a preserved or restored listener, a `spawn -f` manifest deploy \u2014 so a manager replacement\ndoes not quietly move a reachable attach face back to loopback. Passing `--host` again overrides it,\nso you can widen or narrow exposure whenever you like; a mesh that never asked stays loopback-only\nand records nothing.\n\nBecause that face carries terminal read and write for every managed agent, it is credentialed in two\ntiers. A mesh caller receives a **ticket** bound to the single agent the manager just authorized,\nsingle-use and short-lived, so one authorized attach can never be re-pointed at someone else's\nagent. The **console token** is the operator's own, reaches every agent, and is printed only to the\nmanager's output. The roster, the live feed, and the PTY stream all answer `401` without one; the\nstatic console shell is served openly, since it describes no agent.\n\n## input\n\n```bash\ncotal input --name <n> --text <text> [--no-enter] [--on <instance>] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which manager to reach |\n| `--name <n>` | | Managed agent to type into (required) |\n| `--text <text>` | | The text to type, taken verbatim (required) |\n| `--no-enter` | off | Type the text and stop there, without pressing Enter |\n| `--on <instance>` | class anycast | Pin to one manager instance id, exactly as [`attach`](#ps-stop-attach) |\n\nTypes one line into a running agent's terminal, as if you had typed it there, and returns. This is\nthe half of [`attach`](#ps-stop-attach) that a program wants: `attach` is a live stream that holds a\nsession open and expects a terminal on your side, so a script, a cron job or a web UI cannot use it\nto send a single line. `input` is one authorized call.\n\nWhat it is for is **harness commands**. A line beginning with `/` is not chat and not a message: it\nis something the agent's own harness handles, and the only way in is the keyboard.\n\n```bash\ncotal input --name reviewer --text \"/compact\" # ask the harness to compact its context\ncotal input --name reviewer --text \"/model opus\" # switch its model\ncotal input --name reviewer --text \"hold on that PR\" # ordinary typing works too\n```\n\n**Quoting.** `--text` takes a value, so a payload starting with `/` survives as written. A payload\nstarting with a dash needs the `=` form, because the shell-style `--text --foo` is ambiguous and is\nrefused rather than guessed:\n\n```bash\ncotal input --name reviewer --text=--verbose # dash-leading text: use --text=<value>\n```\n\nEnter is pressed by default, since a command typed but never submitted has not been delivered.\n`--no-enter` types the text and leaves it sitting at the prompt, which is how you stage a line and\nsend it later.\n\nNothing comes back but a delivery receipt (`\u2713 sent 9 bytes to reviewer`, counting the trailing\ncarriage return). Whatever the agent does next shows up where its output already goes: the mesh, its\ntranscript, or an `attach`.\n\n**This one is operator-only, and more narrowly than `stop` or `attach`.** Those two are granted to\nanything holding `spawn`, so an agent can stop and attach to seats under its own owner. `input` is\nnot: it is granted only to operator credentials, which on a user-auth mesh means your ledger row\nneeds the `admin` scope, the same scope [`ps`](#ps-stop-attach) already needs there. The reason is\nthat a write into a terminal is control of whatever is running in it, and on a user-auth mesh the\nown-owner rule covers every seat under you, not only the ones you launched: a `spawn`-scoped agent\ncould otherwise type into a sibling it never started. Seat locality is still resolved for you.\n\nOnly the `pty` runtime can be typed into. The external terminal runtimes (`tmux`, `cmux`, `orca`,\n`herdr`) attach to a process they do not own, so they have no input stream for it and the command\nrefuses by name rather than dropping the keystroke.\n\n## personas\n\n```bash\ncotal personas list [-v] [--running]\ncotal personas show <name>\ncotal personas edit <name>\ncotal personas new <name> (--prompt <t> | --from <f>) [--role <r>] [--model <m>]\ncotal personas rm <name> --force\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which mesh's persona catalog |\n| `--role <r>` | \u2014 | `new`: the persona's role |\n| `--model <m>` | \u2014 | `new`: the persona's model |\n| `--prompt <t>` | \u2014 | `new`: the persona's prompt text |\n| `--from <f>` | \u2014 | `new`: seed the prompt from a file |\n| `--verbose`, `-v` | off | `list`: include role / model / description |\n| `--running` | off | `list`: mark personas live on the mesh |\n| `--force` | \u2014 | `rm`: required, delete without prompting |\n\nPersonas are the local agent files under `.cotal/agents/` that `cotal spawn` launches. See\n[Agent files](agent-files.md) for the file format.\n\n## supervise\n\n```bash\ncotal supervise [--runtime <name>] [--space <s>] [--server <url>] [--spawn <names>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | this folder's auth space | Space to supervise |\n| `--server <url>` | the local mesh | Broker URL |\n| `--runtime <name>` | `pty` | Agent runtime (`pty` built in; extension runtimes are explicit-only) |\n| `--console-port <n>` | \u2014 | Protocol-console port |\n| `--console-host <host>` | loopback | Bind host for the console + attach endpoint. Loopback keeps it machine-local; `cotal up` passes the address it bound the broker to, which is what lets `cotal attach` reach this manager from another machine |\n| `--roster <file>` | \u2014 | Declarative roster to boot at startup |\n| `--launch <spec>` | \u2014 | Resolved manifest launch spec (from `up -f` / `spawn -f`) |\n| `--spawn <names>` | \u2014 | Comma-separated personas to pre-spawn at startup |\n\nThe manager is the agent supervisor and control plane: it answers `spawn --detach`, `stop`, `ps`,\n`attach`, and the `cotal_*` manager tools. `cotal up --detach` starts one for you; run `supervise`\ndirectly to recover a dead manager or drive a custom runtime. Default runtime is `pty`; install an\noptional provider first (`cotal ext add @cotal-ai/orca`, `@cotal-ai/tmux`, `@cotal-ai/cmux`, or `@cotal-ai/herdr`) and\nselect it explicitly. A missing provider or app fails loudly; there is no fallback. See [Deploy](deploy.md).\n\n## reconcile-gate\n\n```bash\ncotal reconcile-gate [--space <s>] [--server <url>] [--endpoint <e>] [--instance <id>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | this folder's auth space | Space the frozen gate lives in |\n| `--server <url>` | the local mesh | Broker URL |\n| `--endpoint <e>` | `manager` | Endpoint whose gate is frozen |\n| `--instance <id>` | this folder's persisted manager instance | Instance id |\n\n**When you need this.** A manager restart killed partway through \u2014 after it began deregistering,\nbefore the new incarnation finished \u2014 leaves the endpoint's issuance gate *frozen*, held by a\nprocess that no longer exists. The next manager start refuses to proceed, which is correct: the\nfreeze is what stops two incarnations serving at once. But nothing can lift it, so every restart\nfails the same way. `cotal doctor` shows the gate as frozen; the manager's own start logs name the\ngate it could not advance.\n\nThis command is the way out. It checks that the holder really is gone, prints what it found, and\nthen finishes the dead operation exactly as the interrupted restart would have: revoke the old\ncredentials, evict their holders with verification, and reopen the gate. Start the manager\nafterwards and its normal takeover runs end to end.\n\n**It refuses far more often than it acts, on purpose**, and always says which check stopped it:\n\n| Refusal | What it means | What to do |\n|---|---|---|\n| `holder-alive` | The freeze-holder still has a live connection \u2014 a manager *is* running | Stop that process first. Reconciling would evict a live manager's credentials |\n| `holder-unknown` | The connection sweep could not prove the holder absent | Not safe to proceed: an unprovable holder is treated as a live one. Re-run once the broker answers completely |\n| `liveness-unestablishable` | The delivery daemon could not be asked at all | Start it (`cotal up` runs it) and re-run. Silence is never read as death |\n| `not-frozen` / `no-gate` | The gate is open, or there is no gate at that coordinate | Nothing to repair \u2014 check `--endpoint` / `--instance` |\n| `wrong-op-kind` | Frozen under a takeover or retirement, not a registration | Out of scope for this command; it will not reinterpret another operation's intent |\n| `eviction-unverified` | The holder looked gone but eviction could not be verified | The gate is left frozen, unchanged. Investigate the broker before retrying |\n| `raced` | A newer manager moved the gate mid-repair | Re-run `cotal doctor` and look again |\n\nThere is no `--force`, and no path that discards gate state: the only way this reopens a gate is by\nproving the holder is gone and then completing the operation properly.\n\n## deregister-instance\n\n```bash\ncotal deregister-instance [--space <s>] [--server <url>] [--endpoint <e>] [--instance <id>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | this folder's auth space | Space the instance is registered in |\n| `--server <url>` | the local mesh | Broker URL |\n| `--endpoint <e>` | `manager` | Endpoint the instance serves |\n| `--instance <id>` | this folder's persisted manager instance | Instance id, the whole id as `cotal ps` prints it |\n\n**When you need this.** The service registry records *registration*, not liveness, and nothing in\nthe model expires a row. A manager that stops cleanly removes its own registration. One whose host\ndied without writing anything cannot, so its record goes on claiming a live instance forever: every\nclass scatter in that space freezes the dead slot in, and `cotal ps`, `stop` and `attach` each pay\ntheir whole deadline waiting for a machine that is never coming back. A laptop that was reimaged, a\ncontainer that was deleted, a box that will not be back on the network: those registrations have no\nother exit.\n\nThis command is that exit. It asks the instance first, and it removes a record only when the broker\naffirms the instance's own rail is empty: nothing subscribed there. Then it deletes the\nregistration's two records keys, each pinned to the revision it read, and prints what it removed.\n\n**Silence alone never passes.** An unanswered describe is what a dead host, a wedged process and a\nslow one all look like, and a hung process still holds its subscriptions, so the broker sees\ninterest on its rail. That instance is refused and the observation is printed. A dead process holds\nno connection and therefore no subscription, so a real corpse is still removed.\n\n**It refuses rather than guesses**, and says which check stopped it:\n\n| Refusal | What it means | What to do |\n|---|---|---|\n| `instance-answered` | The instance answered a pinned describe. It is alive | Nothing to repair. If it is wedged rather than gone, stop the process first; its own clean stop removes the record |\n| `instance-not-affirmed-gone` | It did not answer, and the broker did not report its rail empty, which is what a held subscription looks like: slow or hung, not affirmed gone | Nothing was removed. Stop the process; its record goes on its own clean stop, or re-run this once it is down |\n| `liveness-unestablishable` | The probe itself failed, so nothing was learned | Fix the probe's path (credential, broker) and re-run. A probe that could not run is never read as death |\n| `not-registered` | No registration at that coordinate | Check `--instance` and `--endpoint`. This takes the whole id, never a prefix |\n| `superseded` | The record moved between the read and the delete | Something is writing to it. Nothing was removed; re-observe before retrying |\n\nThere is no `--force` and no sweep: silence is not death, and a rule that removed rows on silence\nwould eventually remove a live instance that was merely slow. An operator names one instance, the\nbroker's verdict on its rail is what authorizes the removal, and the guard's job is to show them\nthey named a dead one. Removal is not a one way door either. The same instance re-registers over\nthe tombstone on its next start, under the same identity.\n\n## runtimes\n\n```bash\ncotal runtimes\n```\n\nLists every agent runtime the manager can spawn through: the built-in `pty`, the official providers\n(`orca`, `tmux`, `cmux`, `herdr`), and any custom provider installed via `cotal ext add`. Each installed\nprovider is probed so you can see what is actually reachable on this machine before selecting it:\n\n```\npty built in\norca installed \xB7 reachable @cotal-ai/orca\ntmux available \xB7 cotal ext add @cotal-ai/tmux\ncmux available \xB7 cotal ext add @cotal-ai/cmux\nherdr available \xB7 cotal ext add @cotal-ai/herdr\n```\n\n`installed \xB7 reachable` / `unreachable` is the provider's own `available()` probe; `available` means\nit is a known runtime you can add with the shown command. Selecting an unknown or uninstalled runtime\nvia `up`/`spawn --runtime <name>` fails loud and, for a known one, points at the exact `cotal ext add`\npackage \u2014 there is no silent fallback to `pty`.\n\n## send\n\n```bash\ncotal send dm <agent> \"<text>\" [--space <s>] [--server <url>] [--creds <path>]\ncotal send msg <channel> \"<text>\"\ncotal send ask <role> \"<text>\"\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which mesh, and (off-registry) which credential |\n\nOne-shot messaging: connect, send a single direct message (`dm`), channel post (`msg`), or role\nask/anycast (`ask`), then exit. For a running conversation, agents use the mesh tools instead\n([MCP tools](mcp-tools.md)).\n\n## channels\n\n```bash\ncotal channels list\ncotal channels set <name> [--replay | --no-replay] [--window <n>] [--desc <s>] [--instructions <s>]\ncotal channels default --replay | --no-replay\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Target mesh |\n| `--replay` / `--no-replay` | \u2014 | `set`/`default`: replay history to new joiners, or not |\n| `--window <n>` | \u2014 | `set`: replay window size |\n| `--desc <s>` | \u2014 | `set`: one-line channel description |\n| `--instructions <s>` | \u2014 | `set`: instructions shown to joiners |\n\nInspects and edits the channel registry: replay policy, description, and joiner instructions. ACL\nsemantics (who may read or post) are set at mint / provision time, not here; see\n[Channels and permissions](channels-and-permissions.md). On a user-auth mesh, `list` rides your\nown login as is; `set` and `default` edit the registry over a short-lived\nchannel-writer view, which needs ledger scope `admin` ([Identity & auth](identity-and-auth.md)).\n\n\n## history\n\n```bash\ncotal history clear --force [--dms] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Target mesh |\n| `--dms` | off | Also clear DM history |\n| `--force` | \u2014 | Required: clear without prompting |\n\nPurges retained channel history; `--dms` extends it to direct-message history. An alias of\n[`clean history`](#clean). On a user-auth mesh the purge rides a short-lived purger view over\nyour login, which needs ledger scope `admin` ([Identity & auth](identity-and-auth.md)).\n\n## console\n\n```bash\ncotal console [--plain] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Space to watch |\n| `--plain` | off | Line stream instead of the TUI |\n\nA live protocol view for a space: a lazygit-style TUI, or a plain line stream on `--plain`. On a\nuser-auth mesh it rides the read-only admin view over your login, which needs ledger scope\n`admin`. See [Watch a mesh](watch-a-mesh.md).\n\n## web\n\n```bash\ncotal web [--detach] [--port <n>] [--no-open] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Space to serve |\n| `--port <n>` | `7799` | HTTP port |\n| `--detach` | off | Run in the background; stop with `cotal down web` or bare `cotal down` |\n| `--no-open` | off | Don't open the browser |\n\nThe browser observability dashboard: presence, channels, and a live feed. It is **not** part of\n`cotal up`: it ships inside `cotal-ai` as the `@cotal-ai/web` extension, seeded automatically on first\nrun (like the built-in connectors) so it always matches your CLI version. It self-registers `cotal web`\ninto this surface and serves\n`http://cotal.localhost:7799` (loopback; `*.localhost` resolves in Chrome/Firefox/Edge; Safari may\nneed `http://127.0.0.1:7799`). On a user-auth mesh the dashboard rides the read-only admin view\nover your login, and a channel purge asks for its own channel-purger view per click; both need\nledger scope `admin`. Detached mode re-execs the current Cotal installation, writes diagnostics to\nthe mesh root's `.cotal/web.log`, and reports success only after the HTTP server answers. It requires\na recorded mesh root, but can be launched from any directory once `cotal up` has recorded the mesh.\nSee [Watch a mesh](watch-a-mesh.md).\n\n## mint\n\n```bash\ncotal mint <name> [--profile <agent|observer|admin>] [--out <path>] [--signer]\ncotal mint <name> --provision [--role <role>] [--space <s>] [--server <url>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--profile <agent\\|observer\\|admin>` | `agent` | Credential profile |\n| `--out <path>` | `.cotal/auth/creds/<name>.creds` | Output path |\n| `--signer` | off | Emit a stripped account-signing file instead |\n| `--force` | off | With `--signer`: overwrite an existing file |\n| `--allow-subscribe <a,b>` | the agent file's, else subscribe | Read-ACL override, **agent profile only**: `observer` and `admin` carry a fixed read set, and `mint` refuses this flag there rather than narrowing nothing |\n| `--allow-publish <a,b>` | the agent file's, else deny | Post-ACL override, **agent profile only** |\n| `--role <role>` | the agent file's | Agent profile: the anycast task queue the identity pulls (`svc_<role>`) |\n| `--provision` | off | Agent profile: also pre-create the identity's bind-only DM/deliver durables (and its role's task queue) on the live mesh, so the credential can consume |\n| `--space <s>`, `--server <url>` | the resolved mesh | With `--provision`: which mesh to provision on |\n\nMints a NATS creds file for a space in **static** auth mode, scoped to a profile and (optionally)\nexplicit read/post ACLs. `--signer` emits an account-signing file for delegating minting to another\nhost. A per-user-auth space refuses `mint`: agents there join under a logged-in user\n([`login`](#login-logout) + [`actor grant`](#actor)), never via a handed-out creds file. See\n[Identity and auth](identity-and-auth.md).\n\nA plain mint is creds only: the identity can publish within its post ACL at once, but on an authed\nmesh its DM inbox and task queue are provisioner-pre-created and bind-only, so a **consuming**\nconnect fails until they exist. `--provision` performs that pre-create in the same command (a\nprovisioner cred is minted from the space's trust material, used, and dropped), so a long-running\nclient you start yourself can receive DMs and role anycasts like a spawned seat. The command prints\nthe identity's principal (its wire id) and lifecycle uid; a consuming client passes that uid as its\n`lifecycleUid`. Agent profile only; an open mesh needs none of this (peers self-create there). The\nmesh it provisions on must be the one this folder's auth is for - same space and same account key -\nso `--provision` can never quietly mint under another root's trust material.\n\n## login, logout\n\n```bash\ncotal login --idp <auth base URL> [--client-id <id>]\ncotal logout --idp <auth base URL>\n```\n\nSigns you in to a per-user-auth mesh's IdP (device code flow) and caches the session; run it\nonce per machine. It prints your IdP subject, the id the operator grants against. After a\nlogin, every command on that mesh works under your identity: each connect takes a fresh IdP\nproof, exchanges it locally for a short-lived bearer, and is authorized against the actor\nledger at connect time. `logout` revokes the IdP session and clears the cache. See\n[identity & auth](identity-and-auth.md).\n\n## actor\n\n```bash\n# an upsert of the WHOLE row: a flag left off is the WIDE default below, not \"unchanged\"\ncotal actor grant <actor> --sub <IdP subject> [--scope a,b] [--allow-subscribe a,b] [--allow-publish a,b] [--role <r>] [--label <l>]\ncotal actor revoke <actor> (--sub <IdP subject> | --owner <u_\u2026>)\ncotal actor list\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | the folder's | Space whose ledger to manage |\n| `--sub <subject>` | \u2014 | The IdP subject (shown by `cotal login`) the actor belongs to |\n| `--owner <u_\u2026>` | \u2014 | The derived owner token (alternative to `--sub`) |\n| `--scope <a,b>` | `spawn,role:default` | Capability scope (`''` = none; `spawn` = may run agents, `role:<r>` = may delegate role r, `admin` = cross-agent control) |\n| `--allow-subscribe <a,b>` | `>` (all channels) | Channel read ACL; the user's envelope, their agents can never read beyond it |\n| `--allow-publish <a,b>` | `>` (all channels) | Channel post ACL; also the envelope for their agents' posting |\n| `--role <r>` | \u2014 | Role (scopes the task-queue consumer) |\n| `--label <l>` | \u2014 | Display label for `actor list` (never the IdP subject) |\n\nThe actor ledger is the single authorization source of a user-auth space: no row, no access.\nA bare `grant` is the **full** envelope (all channels, may spawn); the flags narrow it. A\nre-grant **replaces the whole row**, not the one field you name, so to add a capability spell\nevery field out: the new scope plus the row's current read set, post set, role and label\n(`cotal actor list` shows what a row holds). A field left off does not stay as it was, it\nreverts to the wide default in the table above, which is how a narrow reader becomes a reader\nof every channel. `revoke` denies the next exchange and the next connect with no restart, and\nevicts the principal's live connections. Managed-agent rows\n(written by the spawn path) live in a disjoint row space this command never touches. See\n[identity & auth](identity-and-auth.md).\n\n## doctor\n\n```bash\ncotal doctor auth [--fix]\n```\n\nCredential-health diagnosis and repair for this folder's mesh: renders every managed\ncredential as healthy / near-expiry / expired and ends in `healthy` or the exact next\ncommand; `--fix` applies the repairs it can. The one surface every stale-credential error\npoints at.\n\n## join\n\n```bash\ncotal join --space <s> --name <n> [--role <r>] [--channel <c>]\ncotal join --link <url> | --token <t>\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which mesh, and which credential |\n| `--name <n>` | \u2014 | Your presence name |\n| `--role <r>` | \u2014 | Your role |\n| `--channel <c>` | \u2014 | Channel to join |\n| `--kind <k>` | `agent` | Endpoint kind |\n| `--link <url>` | \u2014 | Join link (`cotal://\u2026`) |\n| `--token <t>` | \u2014 | Join token |\n| `--lifecycle-uid <uid>` | \u2014 | Required with `--creds`: the lifecycle UID minted alongside the credential (`COTAL_LIFECYCLE_UID` works too). A credential's durable grants name exact lifecycle-keyed resources, so `join` refuses to invent one |\n| `--tls` | off | Connect over TLS |\n\nAn interactive presence: join a space under your own name and role, without launching an agent\nharness. A `--link` or `--token` supplies the where and the auth in one value. See\n[Spaces](spaces.md) and [Identity and auth](identity-and-auth.md).\n\n## Manifest deploys\n\nA `cotal.yaml` manifest declares a whole mesh (channels, personas, roles, and ACLs) in one file.\nThree commands consume it, plus a read-only validator:\n\n```bash\ncotal up -f cotal.yaml # boot a fresh mesh from the manifest\ncotal spawn -f cotal.yaml # deploy the manifest additively onto a running mesh\ncotal down -f cotal.yaml # tear that deploy down (or --run <id> for one run)\ncotal topology view -f cotal.yaml # validate + view the access graph, change nothing\n```\n\n`up -f` and `spawn -f` differ in target: `up -f` brings up a new broker and applies the manifest;\n`spawn -f` requires an already-reachable mesh and applies additively (ownership-scoped). On a\nuser-auth mesh, `spawn -f` deploys over your own login (the deployer view, gated on ledger scope\n`spawn`): the manifest's agents land under your owner, a manifest claiming another owner is\nrefused, and seeding new channels additionally needs scope `admin`. Both take\n`--dry-run` to print the plan without mutating anything. `topology` validates the manifest and\nrenders its channel / role / ACL graph. See [Define a team](define-a-team.md) and the\n[manifest reference](manifest.md).\n\n## ext\n\n```bash\ncotal ext # same as `list`\ncotal ext add <npm-package>\ncotal ext remove <name>\ncotal ext list\ncotal ext root # print just the install prefix (scriptable)\ncotal ext seed [--repair|--reset|--force]\n```\n\nOperator-installed extensions: `add` installs an npm package into a cotal-owned prefix and records\nevery registry provider it contributes. Commands appear in help, completion, and dispatch; runtime\nproviders are lazy-loaded by commands such as `supervise`; local process providers participate in\n`status` and selective `down`. `remove` and `list` manage them. The `@cotal-ai/web` dashboard is the\ncanonical command/process example. Installed packages and their location are described in\n[config](config.md).\n\nBare `cotal ext` lists the inventory, headed by the install prefix. That prefix is a cotal-owned npm\nroot kept **separate** from npm's own global tree, so these packages never show up in `npm list -g` \u2014\n`cotal ext` (or the Extensions section of `cotal status`) is the canonical inventory. `cotal ext root`\nprints only the path, for scripts. The versions shown are the manifest pin recorded at add time.\n\nRemoving an extension that owns a running local process is refused with the mesh root and its\n`cotal down <component>` command; stop it first so uninstalling the package never strands a process\nwhose lifecycle provider is gone.\n\n### Built-in connectors are seeded extensions\n\nThe first-party agent connectors (`claude`, `opencode`, `codex`, `hermes`, `pi`) are not compiled into\nthe binary. They are seeded on first run through the **same** `ext add` path a third party uses, and\nappear in `cotal ext list` like any other extension. So you can remove one you do not want\n(`cotal ext remove @cotal-ai/connector-hermes`), and a deliberately-removed connector STAYS removed\nacross upgrades. `cotal ext add <your-package>` adds a third-party connector the same way. The web\ndashboard (`@cotal-ai/web`, providing `command:web`) is a fifth built-in seeded on the same path.\n\n`cotal ext seed` is the maintenance entry for that seeding (it runs automatically on the first real\ncommand of each boot, so you rarely call it):\n\n| Flag | Meaning |\n|---|---|\n| (none) | Reconcile: seed any never-seeded built-in, refresh a seeded one whose version the binary bumped, leave a removed one removed. A no-op once current. |\n| `--repair` | Recover after an interrupted seed or a lost authority (rebuilds the interrupted connector; restores the removed-vs-never-seeded record from its durable backup). |\n| `--reset` | Discard the record and re-seed all six built-ins (the five connectors plus the web dashboard). **Resurrects any you removed.** Rebuilds cleanly over corrupt seed state. |\n| `--force` | Re-seed the built-ins even when the version stamp is current or a downgrade. |\n\nThe default connector for a bare `cotal spawn` (no `--agent`) is `claude`; set `COTAL_DEFAULT_AGENT`\n(e.g. `opencode`) to change it. An `--agent` naming a removed connector fails loud with the exact\n`cotal ext add` to restore it. Set `COTAL_SKIP_CONNECTOR_SEED=1` to turn off the automatic first-run\nseed/refresh entirely (for a controlled or offline setup that manages connectors by hand); `cotal ext\nseed` still runs on request.\n\n## completion\n\n```bash\ncotal completion <bash|zsh|fish|powershell> # print a stub to eval / source\ncotal completion install [shell] # install it persistently\n```\n\nPrints or installs shell completion. Completion candidates come from each command's declared flags\nand, where useful, live mesh state (spaces, personas, managed agents) resolved offline.\n\n## feedback\n\n```bash\ncotal feedback \"<summary>\" [--type <t>] [--email <e>] [--details <text>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--type <t>` | \u2014 | `bug` \\| `idea` \\| `friction` \\| `praise` \\| `other` |\n| `--details <text>` | \u2014 | Longer free-form details |\n| `--severity <s>` | \u2014 | `low` \\| `medium` \\| `high` |\n| `--area <a>` | \u2014 | The part of Cotal this concerns |\n| `--email <e>` | git email | Contact email (required on the keyless public path) |\n| `--name <n>` | \u2014 | Your name (optional) |\n| `--url <url>` | keyed / public intake | Intake URL override |\n| `--key <k>` | `COTAL_FEEDBACK_KEY` | Feedback key |\n\nSends feedback to the Cotal developers. With a key (`--key` / `COTAL_FEEDBACK_KEY`) it routes to the\nkeyed beta intake; without one it goes to the public `cotal.ai` intake and requires a contact email\n(`--email` / `COTAL_FEEDBACK_EMAIL`, else your git email). Run a self-hosted intake with\n[`feedback-intake`](#server-daemons).\n\n## Server daemons\n\nTwo long-lived infra roles ship with the CLI. They are not part of everyday operation; the delivery\ndaemon comes up automatically with `cotal up --detach` in auth mode.\n\n```bash\ncotal deliver --space <s> [--server <url>] [--creds <file>]\ncotal auth-service --space <s> --server <url> [--port <n>]\ncotal feedback-intake --keys <keys.json> [--port <n>] [--creds <file>]\n```\n\n`auth-service` runs a user-auth space's identity plane (the NATS auth callout plus the\nloopback token exchange and JWKS); `cotal up --user-auth` starts and supervises it for you,\nso you run it directly only to recover one by hand.\n\n`deliver` runs the server-side Plane-3 delivery daemon: the durable backstop and membership/ACL\nauthority. It is auth-mode-only and single-instance (`--shard`/`--shards` accept only `N=1`);\n`--dev-mint` mints a scoped cred from the local signer for standalone dev. See the\n[delivery daemon](delivery-daemon.md). `feedback-intake` runs a self-hosted feedback server\n(requires `--keys` and a scoped `--creds`), announcing submissions into a space channel; flags\ninclude `--host`/`--port`, `--store`, `--space`/`--channel`, `--max-bytes`, and `--rate-limit`.\n\n## Plumbing\n\n`cotal __complete <words\u2026>` is the internal entry the shell-completion stubs call to emit candidates\nfor the current command line; you never run it directly. `cotal agent-bearer` is machine-facing\nplumbing on user-auth meshes: spawned agents exec it to print a fresh short-lived bearer from their\nspawn-time secret; you never run it directly either. (`cotal start` is a removed tombstone: it\nerrors and points you to `cotal spawn --detach`.)\n"
|
|
67274
|
+
"body": "# `cotal` CLI reference\n\n> **Reference**: describes the TypeScript reference implementation (the `cotal` CLI), not the wire contract. \xB7 **For:** operators \xB7 **Wire contract:** [SPEC](../SPEC.md)\n\n`cotal` is the operator command line for the reference implementation: bring a mesh up, mint\nidentities, launch agents, watch what they do, and tear it all down. It is a thin client over the\nwire contract: the normative subjects and schemas live in the [SPEC](../SPEC.md); this page is\nlookup material for the commands, not a walkthrough; if you are new, start with\n[Getting started](getting-started.md).\n\n## Running it\n\n```bash\nnpm install -g cotal-ai # puts `cotal` on your PATH (needs Node 22+)\ncotal --help # every command, grouped\ncotal --version # cotal-ai version + each installed extension's (also `cotal -v`)\ncotal <command> --help # one command's flags and usage\n```\n\n`npx cotal-ai <command>` runs it without a global install; in a dev clone, `pnpm cotal <command>`\nruns it through `tsx` with no build step. Bare `cotal` prints help. Every command generates its own\n`--help`, usage, and shell completion from its declared flags.\n\nCommands come from the surfaces the binary composes: the base mesh CLI, the manager\n(`supervise`), and the delivery daemon (`deliver`), plus any operator-installed extensions.\n`cotal ext add <npm-package>` installs any registry providers a package contributes: commands,\nruntimes, and local process lifecycle descriptors. The `web` dashboard and optional manager\nruntimes ship this way.\n\n## Commands\n\n| Area | Command | Purpose |\n|---|---|---|\n| Set up & lifecycle | [`setup`](#setup) | Guided, configure-only setup (installs, seeds personas; launches nothing) |\n| Set up & lifecycle | [`update`](#update) | Reconcile first-party extensions and check or opt into a coherent CLI upgrade |\n| Set up & lifecycle | [`up`](#up) | Start a local mesh (nats-server + JetStream), or boot a whole manifest with `-f` |\n| Set up & lifecycle | [`down`](#down) | Stop the whole stack, selected registered components, or a manifest deploy |\n| Set up & lifecycle | [`backup`](#backup-and-restore) | Create an offline full-space or registry-only artifact from a preserved cut |\n| Set up & lifecycle | [`clean`](#clean) | Configurable cleanup: purge history (live), or wipe the local store / identity (stopped) |\n| Set up & lifecycle | [`meshes`](#meshes-use-status) | List the running meshes on this machine |\n| Set up & lifecycle | [`use`](#meshes-use-status) | Set the default mesh a bare `cotal spawn` joins |\n| Set up & lifecycle | [`status`](#meshes-use-status) | Read-only diagnostics for setup, processes, and the selected mesh |\n| Agents & personas | [`spawn`](#spawn) | Launch an agent from a persona (foreground, or `--detach` via the manager) |\n| Agents & personas | [`models`](#models) | List connector model catalogs and variants from the manager |\n| Agents & personas | [`ps`](#ps-stop-attach) | List managed agents and their mesh status |\n| Agents & personas | [`stop`](#ps-stop-attach) | Ask the manager to stop a managed agent |\n| Agents & personas | [`attach`](#ps-stop-attach) | Stream and drive a managed agent's terminal (pty runtime) |\n| Agents & personas | [`input`](#input) | Type one line into a managed agent's terminal without attaching |\n| Agents & personas | [`personas`](#personas) | List, show, edit, create, or remove local personas |\n| Agents & personas | [`supervise`](#supervise) | Run a manager daemon (the agent supervisor / control plane) |\n| Agents & personas | [`runtimes`](#runtimes) | List the agent runtimes the manager can spawn through and whether each is reachable |\n| Agents & personas | [`reconcile-gate`](#reconcile-gate) | Unfreeze an issuance gate left frozen by a crashed manager restart, after verifying the holder is gone |\n| Messaging & watching | [`endpoints`](#endpoints) | List every endpoint in the live presence roster, including infrastructure |\n| Messaging & watching | [`describe` / `invoke`](#describe-invoke) | Resolve a v0.4 service's command surface off the wire; invoke one command by name |\n| Messaging & watching | [`send`](#send) | Send one message, then exit: DM a peer, post a channel, or ask a role |\n| Messaging & watching | [`channels`](#channels) | Inspect or set the channel registry |\n| Messaging & watching | [`history`](#history) | Clear retained message history |\n| Messaging & watching | [`console`](#console) | Live protocol view for a space (TUI, or `--plain` line stream) |\n| Messaging & watching | [`web`](#web) | Browser dashboard (installed as the `@cotal-ai/web` extension) |\n| Auth & meshes | [`mint`](#mint) | Mint a creds file for a space (static auth mode) |\n| Auth & meshes | [`login`](#login-logout) | Sign in to a per-user-auth mesh's IdP (once per machine) |\n| Auth & meshes | [`logout`](#login-logout) | Revoke the IdP session and clear the cached login |\n| Auth & meshes | [`actor`](#actor) | Manage a user-auth space's actor ledger (grant / revoke / list) |\n| Auth & meshes | [`doctor`](#doctor) | Credential-health diagnosis and repair (`doctor auth`) |\n| Auth & meshes | [`join`](#join) | Join a space as your own presence (interactive) |\n| Manifest | [`topology`](#manifest-deploys) | Validate and view a mesh manifest's access graph (read-only) |\n| Extensions & misc | [`ext`](#ext) | Install / remove operator CLI extensions |\n| Extensions & misc | [`completion`](#completion) | Print or install shell completion |\n| Extensions & misc | [`feedback`](#feedback) | Send feedback to the Cotal developers |\n| Extensions & misc | [`deliver`](#server-daemons) | Run the server-side Plane-3 delivery daemon |\n| Extensions & misc | [`feedback-intake`](#server-daemons) | Run a self-hosted feedback intake server |\n\nThe manifest modes of `up`, `spawn`, and `down` (`-f <cotal.yaml>`) plus `topology` are covered\ntogether under [Manifest deploys](#manifest-deploys).\n\n## setup\n\n```bash\ncotal setup [--full] [--demo] [--yes]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--full` | off | Redo the full guided flow (implies `--demo`) |\n| `--demo` | off | Also seed the guided expert team (`david`, `sven`, `me`) |\n| `--yes`, `-y` | off | Non-interactive accept-all (for agents / CI) |\n\nGuided setup is **configure-only**: it checks prerequisites, installs the Claude Code plugin, and\nseeds persona files, and it launches nothing (no mesh, no web, no manager). First run gets the\nnarrated flow; later runs print a status card. By default it seeds one `default` persona; the\n`david`/`sven`/`me` team is opt-in via `--demo`. See [Getting started](getting-started.md) and, for\nmaintainers, [setup internals](setup-internals.md).\n\n## update\n\n```bash\ncotal update [--self]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--self` | off | If a newer release exists, install that exact validated `cotal-ai` version globally and reconcile through the newly installed binary |\n\nWithout `--self`, `update` keeps the installed first-party surfaces coherent with the running\nbinary: it force-reconciles the four built-in connectors, then reinstalls other `@cotal-ai/*`\noperator extensions at the binary's exact version. Each extension runs in an isolated child, so one\nfailure cannot poison later replays. It then checks npm; a newer binary is an informational notice\nwith `cotal update --self` as the next command, not an automatic install.\n\nWith `--self`, the npm check happens first. When a newer release exists, Cotal installs the exact\nversion it validated, resolves and verifies that package in npm's global root, then launches that\nbinary to reconcile connectors and first-party extensions to the new generation. An npx or dev-clone\ninvocation therefore installs and continues through a separate global copy; it never claims the\nalready-running process changed. If the binary is current, `--self` performs the normal local\nreconcile without reinstalling it.\n\nThird-party extensions are listed with their installed version and recorded spec but are not\nauto-updated in v1. Floating third-party updates require `@cotal-ai/*` peer-range validation and are\na future follow-up. A failed connector/extension install, npm metadata check, or requested global\ninstall is reported and makes the command exit nonzero. Independent extension attempts continue so\nthe output includes every failure; an unavailable npm registry does not undo a completed local\nreconcile, but the command still exits nonzero because it could not establish that the install is\ncurrent.\n\n## up\n\n```bash\ncotal up [--detach] [--open] [--space <s>] [--server <url>] [--channels <path>] [--runtime <name>]\ncotal up --tls-cert <cert.pem> --tls-key <key.pem> # serve TLS (both, or neither)\ncotal up --restore <dir> [--restore-only registry] [--accept-missing-source]\ncotal up -f <cotal.yaml> [--dry-run] [--runtime <name>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--server <url>` | auto (free local port) | Listen URL override |\n| `--host <host>` | \u2014 | Bind host override. With no `--server`, the broker URL is derived from it, so `--host <addr>` alone is enough to make a mesh reachable at that address; a `--host`/`--server` pair naming different addresses is refused. A wildcard bind (`0.0.0.0`, `::`) keeps a dialable loopback URL. Recorded on the mesh and reused by every later manager launch, so a repair or resume keeps remote [`attach`](#ps-stop-attach) working |\n| `--space <s>` | the folder's name | Space name |\n| `--store-dir <dir>` | \u2014 | JetStream store directory |\n| `--channels <path>` | `.cotal/channels.json` if present | Channel-registry seed file (JSON). An explicit path that is missing is an error |\n| `--restore <dir>` | \u2014 | Restore a completed offline backup before exposing the normal listener |\n| `--restore-only registry` | artifact selection | Restore only the registry component |\n| `--accept-missing-source` | off | Explicit disaster consent when the inode-bound preserved source is absent |\n| `--open` | off (auth) | Unauthenticated dev mesh: no JWT, no ACLs |\n| `--user-auth` | off | Per-user auth: people `cotal login`; connects are authorized against the actor ledger |\n| `--idp <url>` | \u2014 | With `--user-auth`: the IdP auth base URL to pin on first enable |\n| `--detach` | off | Run in the background (stop with `cotal down`) |\n| `--tls-cert <path>` | \u2014 | PEM certificate to serve TLS with. Must be given together with `--tls-key`. The pair is validated **before** the broker starts \u2014 readability, private-key mode, that the two match, the validity window, and that the certificate covers the host clients will dial \u2014 because `nats-server` starts happily on an expired certificate and only the client then fails. The decision is recorded, so a later bare `cotal up` after a `cotal down` keeps serving TLS rather than silently reverting to cleartext |\n| `--tls-key <path>` | \u2014 | PEM private key for `--tls-cert`. Refused if group- or other-readable (tighten to `600`) |\n| `--file <cotal.yaml>`, `-f` | \u2014 | Launch a whole mesh from a manifest |\n| `--dry-run` | off | With `-f`: print the plan, mutate nothing |\n| `--runtime <name>` | `pty` (or the manifest's, with `-f`) | Agent runtime for the mesh manager (`pty` built in; others are installed extensions, explicit-only). Resolved + probed before the broker starts; an uninstalled/unreachable runtime fails loud. With `-f`, overrides the manifest's runtime |\n| `--rotate-sys` | off | Rotate the space's system account and re-mint its two `$SYS` creds. Needs a stopped mesh; refused with `--open` |\n\n`cotal up` boots a local nats-server with JetStream and, in auth mode (the default), JWT auth and\nper-agent ACLs; `--detach` records the mesh so `cotal spawn` from any directory can find it. With no\n`--server`, it auto-selects a free port if the default address is taken; an explicit `--server`\nstays fail-loud on collision. `--detach` also brings up the control plane (delivery daemon in auth\nmode, then the manager). The `-f` form is a [manifest deploy](#manifest-deploys); see\n[Run a mesh](run-a-mesh.md).\n\n`--user-auth --idp <url>` starts the space's auth service alongside the broker (the NATS\nauth callout plus the loopback token exchange); it is torn down with `cotal down`, and a\nre-run of `cotal up` heals a dead service on a running broker. `--user-auth` and `--open`\ncontradict each other and are refused loudly; a running broker cannot change auth mode\nwithout a `cotal down` first. See [identity & auth](identity-and-auth.md).\n\n`--rotate-sys` renews the two `$SYS` credentials (`membership-observer`, `connection-evictor`).\nThey carry a 30-day expiry and nothing re-signs them in place, because the system-account seed is\nnever persisted, so they are renewed by issuing a **new system account** under the same broker\noperator and minting fresh creds against it. A plain re-`up` does **not** do this: it reuses the\nexisting trust record, and its `$SYS` creds along with it.\n\nThe rotation is safe to run on a real space, with one operational cost. The data account, the account\nsigning key, every agent credential minted from it, and the JetStream store are all untouched; what\ndies is the retired system account, and with it any out-of-band copy of the old `$SYS` creds, on every\nbroker that loads the rotated config. The cost is that **earlier full backups stop being restorable**\n(see below), so this is not a no-consequence operation. It needs the broker to restart on the rewritten\nconfig, so it runs as part of a boot:\n\n```bash\ncotal down\ncotal up --rotate-sys --detach # agents reconnect; nothing is re-provisioned\ncotal doctor auth # both $SYS creds healthy again, 30 days out\n```\n\nA rotation is a stopped, fresh boot, and anything that is not one refuses it, all for the same reason\n(the on-disk material and the broker it runs on must never end up on different generations):\n\n- a live mesh, because the running broker would keep serving the retired account;\n- an open mesh, whether that comes from `--open` or from `broker.auth: false` in a manifest, which\n has no system account at all;\n- `--restore`, because reinstating a trust root and superseding it in one command leaves no way to\n say which authority the mesh came up on;\n- an unfinished restore or resume attempt on this root, including one `cotal up` would recover on\n its own, because those paths can adopt a live listener and return without booting a broker;\n- a root that hosts more than one space, because the system account lives in the shared broker\n record and a rotation would retire every tenant's, while the root holds one `$SYS` cred pair\n pinned to one data account.\n\nTwo things to know before you run it:\n\n- **The retirement is config-load-bound.** Old `$SYS` creds are refused by any broker that loads the\n rotated config. A stale `nats-server` still running the *previous* config in memory would keep\n honouring them, so stop every broker for this root first. `--rotate-sys` refuses if this root's\n mesh is recorded as running, if anything unidentified is answering at the address it was given, or\n if the root's pid file names a live (or unreadable) process. Those are Cotal's own ownership\n records, not a scan of the process table: a `nats-server` you started by hand against this root's\n `server.conf` on some other port writes none of them and will not be seen. Do not run one.\n- **It invalidates earlier full backups.** A full artifact binds to the trust chain it was taken\n against, and that commitment covers the operator JWT and the system account. Every full backup\n taken before a rotation refuses to restore afterwards, so take a fresh `cotal backup` once the\n rotated mesh is up. `cotal up --restore` names this case when the data account still matches.\n\nThe commit is not atomic (a trust-record write plus two credential writes), so an interrupted\nrotation leaves the record ahead of the creds. That split is detected rather than silent: every\n`cotal up` on an auth mesh, and every `cotal doctor auth`, compares each `$SYS` cred's issuer against\nthe persisted record and names the retired account. `up` warns rather than refusing, because these\ncreds power the membership graph and live eviction, both of which degrade fail-soft; the mesh is not\nworth taking down over them. Re-running the rotation heals it, at the cost of one generation.\n\nWhile those creds are expired the mesh keeps delivering messages, but the\n[membership feed](delivery-daemon.md) and live connection eviction stay down; `cotal doctor auth`\nand the manager's log both name the credential and this repair.\n\n## down\n\n```bash\ncotal down\ncotal down --preserve-state [--store-dir <dir>]\ncotal down manager [delivery auth web nats ...]\ncotal down web [--space <name>]\ncotal down -f <cotal.yaml> | --run <id> [--dry-run]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--file <cotal.yaml>`, `-f` | \u2014 | Tear down this manifest's deploy |\n| `--run <id>` | \u2014 | Tear down one `spawn -f` run by id |\n| `--space <name>` | current mesh | With components: the mesh whose target-addressed components (e.g. `web`) to stop |\n| `--dry-run` | off | Print the manifest teardown or selected components, mutate nothing |\n| `--preserve-state` | off | Bare whole stack only: fence the manager, retain principals and durable state, stop and prove the stack down, then publish `ready` |\n| `--store-dir <dir>` | `.cotal/nats` | With `--preserve-state`: the actual store path (required for a custom store) |\n\nBare `cotal down` stops the whole local stack in dependency order. Positional component names stop\nonly those self-registered local processes; for example, `cotal down manager` leaves delivery and\nthe broker running, and `cotal down web` is available when the web extension is installed. A\ncomponent that starts target-resolved (the web dashboard) is stopped the same way: `cotal down web`\nresolves the mesh exactly like `cotal web` (registry current mesh first, `--space` to name one), so\nit works from any directory; the other components always stop under the folder you run it in. The\n`-f` / `--run` forms tear down a [manifest deploy](#manifest-deploys) without stopping the whole mesh\nand cannot be combined with component names. Stopping `nats` alone is refused while an unselected\nregistered daemon is still live; include those components or use bare `cotal down`.\n\nNormal `down` remains destructive at the logical identity/durable layer. `--preserve-state` is a\ndifferent maintenance transition: it suppresses leave/deprovision cleanup, persists the manager's\nsame-principal resume inventory, stops the entire stack without removing run/auth artifacts, and\npublishes a stable inode-bound cut only after every recorded process is proven stopped and the exact\nrecorded NATS endpoint is unreachable. A missing or stale broker pidfile never counts as stopped. The\nattempt is bound durably before the manager is fenced, the resume document and attempt-bound\n`cut-intent` are fsynced before manager commit, and the manager's commitment itself is journaled\n(`cut-committed`) before any process stops. A retry after a crash at any of those boundaries reuses\nthe exact recorded attempt and finishes the remaining stop and endpoint proofs idempotently, without\nneeding the (by then intentionally dead) manager. A partial cut never publishes `ready`. It cannot\nbe combined with component names, manifest teardown, or `--dry-run`.\n\n## clean\n\n```bash\ncotal clean <history|store|all> --force\ncotal clean restore-attempt --attempt <id> --force\ncotal clean restore-fallback --attempt <id> --force\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | `history`: target mesh |\n| `--dms` | off | `history`: also clear DM history |\n| `--store-dir <dir>` | `.cotal/nats` | `store`/`all`: JetStream store directory |\n| `--force` | \u2014 | Required: destructive, no prompting |\n| `--attempt <id>` | \u2014 | `restore-attempt`: exact stale pre-commit attempt; `restore-fallback`: matching healthy committed restore |\n\nOne configurable cleanup verb; every target requires `--force`.\n\n- `history` purges the retained message backlog on the **running** broker (channels, plus DMs\n with `--dms`). The same operation as [`history clear`](#history), which stays as an alias.\n- `store` deletes the **stopped** mesh's JetStream store (`.cotal/nats`): streams, durable\n consumers, and messages. This is the reset for stale on-disk broker state, e.g. durables\n minted by an older, incompatible Cotal generation surviving a `down`/`up` cycle.\n- `all` is `store` plus the space identity (`.cotal/auth`), the local creds and markers tied to\n it, any crash residue a normal `down` would have swept (stale pidfiles, `run/`), and the mesh's\n registry entry; the next `cotal up` mints a fresh identity.\n\n`history` needs the mesh up; `store` and `all` refuse while any recorded mesh process is still\nalive or any same-root recorded broker endpoint remains reachable (run `cotal down` first). They\nalso refuse outright on a root that holds accounts for several spaces: the store and the broker\ntrust record are shared by every space on the broker, so both targets would take out all of them\nand no `--space` can narrow that. `down`, `backup` and `up --restore` refuse there for the same\nreason. `cotal status` lists the tenants on such a root. Personas\n(`.cotal/agents`) and logs are never touched. A custom\nstore location is not recorded anywhere, so `--store-dir` must repeat whatever the mesh was\nlaunched with. Custom cleanup targets must contain either the Cotal store-generation marker or a\nreal `jetstream/` store directory; filesystem roots, project roots, and Cotal auth/maintenance trees\nare always refused.\n\n`store` and `all` also refuse every maintenance journal state. After a healthy committed restore,\n`restore-fallback` is the only supported way to remove the recorded unchanged old-store inode; it\nnever deletes the active target, requires both the exact attempt id and `--force`, and retires the\ncompleted restore journal so a later `down --preserve-state` can start a new backup cycle.\n\n## backup and restore\n\n```bash\ncotal down --preserve-state [--store-dir <dir>]\ncotal backup create <dir> [--only full|registry] [--store-dir <dir>]\ncotal up --restore <dir> [--restore-only registry] [--accept-missing-source]\n```\n\nBackup is offline-only. It requires the stable `ready` record from `down --preserve-state`, an exact\nstore match, no live recorded process, and an unreachable exact endpoint from the recorded cut.\nThat endpoint is probed immediately before cloning, so a live broker with a missing or stale pidfile\nis still refused. It claims the cut, reflink/copies the stopped source to a\nprivate attempt clone, and opens only that clone on a random loopback bootstrap broker with an\nindependent parent/deadline watchdog. It validates the canonical stream and pull-consumer inventory,\nwrites native snapshots with consumers excluded, and stores conservative contiguous ACK-floor\ncheckpoints separately. The original store is never opened by the backup broker, and the stack is\nnot restarted implicitly. Artifact destinations must not overlap the preserved source or maintenance\nattempt tree. Restore artifacts and targets likewise cannot nest inside or contain each other, the\npreserved source, or the maintenance attempt tree.\n\n`full` is the default and indivisible: channel registry, CHAT/DM/TASK/INBOX/DLV, ACL, MEMBERS, and\nvalidated durable checkpoints. `registry` is the sole partial artifact. Presence, derived membership\nfeed, leases, native ephemeral/history consumers, credentials, keys, tokens, owner secrets, and actor\nledger files are excluded. Artifacts are exclusively created `0700`; snapshot/checkpoint files and\nthe manifest are `0600`; `manifest.json` is written last with exact sizes and SHA-256 values. The\ndirectory is trusted operator input: hashes detect corruption, not malicious rewriting.\n\nRestore validates and stages the exact allowlisted artifact bytes before moving or creating a store.\nIt requires the same space and existing trust state. The whole pre-commit window holds a journaled\nliveness claim (coordinator, watchdogs, brokers, absolute deadline): ordinary `up` and a repeated\n`up --restore` refuse while the claim is live, and a stale attempt is recovered only after the\ndeadline has elapsed and every recorded owner is proven dead \u2014 automatically by a retried\n`up --restore`, or explicitly with `cotal clean restore-attempt --attempt <id> --force`. Nothing\never rolls back a live attempt. A registry-only artifact restores as registry-only whether or not\n`--restore-only registry` is passed; omitted infrastructure is always created and the exact\npost-restore stream inventory is asserted before commit intent. Ordinary `up` from a preserved cut\nresumes only the exact recorded source store and runtime; a contradicting `--store-dir` or\n`--runtime` fails in preflight. Authenticated restores validate the complete\nspace trust bundle before staging, including nkeys, seed matches, JWTs, signers, and space binding;\nfull restores commit to the validated operator, system-account, data-account, and active-signer root\nchain in addition to the static/user authority fingerprint. Because the system account is part of that\ncommitment, a [`cotal up --rotate-sys`](#up) makes every full artifact taken before it unrestorable\nagainst this root: take a fresh full backup after each rotation. The composed commitment is revalidated\nimmediately before store mutation and never includes secret seeds. Restore never creates fresh auth.\nSame-path restores atomically retain the old\nsource at the journaled fallback path; alternate targets retain it in place; a missing canonical\nsource needs explicit `--accept-missing-source`. Quarantine and target restores use current canonical\nconfigs on isolated random-loopback brokers, never expose native snapshot consumers, and publish a\ncommit-intent immediately before the normal listener starts. Archive bytes never instantiate the real\ntarget: after quarantine validation, every stream is re-snapshotted from the validated quarantine\nstate into attempt-owned sanitized files, and the target is restored solely from those. Before that boundary, failure rolls back\nthe attempt-owned target; after it, ambiguity preserves both stores and records forward-repair\nrecourse. The cooperative maintenance lock excludes Cotal commands, not arbitrary raw NATS processes.\n\nBootstrap brokers in every auth mode \u2014 including open \u2014 mount the store under a local account with\nrandom operation-specific logins only, each carrying the exact per-phase subject permission matrix;\nnormal static credentials and user-auth sentinel/bearer connections are rejected, and no auth\nservice or callout starts. Open mode differs only in its account label, never in authority. Inventory, each stream snapshot,\nrestore initiation, exact upload id, validation, and each checkpoint recreation use separate exact\nauthorities. Every checkpoint carries the source stream's message/first/last sequence state and must\nmatch its snapshot record before mutation; core then derives and validates the only allowed start\npolicy. TASK is not a CLI exception: the same core checkpoint API recreates its canonical `DeliverAll`\nWorkQueue durable because acknowledged tasks are absent from retention and NATS forbids a\nstart-sequence policy there. Registry-only restore creates every omitted canonical stream and transient\nbucket on the isolated target before the normal listener is exposed. It deliberately does not resume\nretained agents or recreate their DM/DLV/TASK/ACL state; their identity material stays retained and\nstopped rather than being reprovisioned into a partial restore.\n\nAfter listener readiness, the manager starts attempt-bound, validates retained credentials/tokens\nwithout granting or reprovisioning, and resumes the exact persisted principals under cleanup\nsuppression. Registry-only restore uses the same flow with an empty agent set. `commitResume` is an\nidempotent validation barrier only: success must be `awaitingFinalize` with an attempt-bound 64-hex\ncommit token and does not release suppression. Under the workspace lock, the CLI first fsyncs that\nexact evidence as `manager-committed` (restore) or `resume-committed` (ordinary resume), then calls\ntoken-bound `finalizeResume`; only an `active` response for the exact token releases suppression. The\nCLI records the same token in finalization evidence before a restore becomes `active`, or before an\nordinary resume retires and consumes the marker. Re-entry from either committed state skips the prior\nidempotent activation/commit phases, retries finalization with the durable token, and finishes the\nworkspace transition. Failure before finalization preserves the committed state and cleanup\nsuppression; it is not rewritten through a degraded transition. Re-entry between any two earlier\nboundaries reuses the same attempt and may retry the idempotent phases without deleting retained state. A missing or\nchanged per-agent dependency is a named fail-closed result; the journal becomes degraded and remains\navailable for forward repair. A retry from `resume-intent`,\n`resume-active`, or `resume-degraded` reuses the same attempt and inventory after the prior listener is\nproven stopped. Every normal restore listener has an unguessable attempt-bound NATS server name. The\nCLI fsyncs its exact name/nonce, canonical endpoint, process owner, and generation-bound target identity\nimmediately after spawn. Re-entry accepts a surviving listener only when its INFO server name, live PID\nrecord, endpoint, and target identity all match that proof; degraded restore repair then moves through\nthe guarded workspace transition only after manager commit. If an uncommitted bound owner is provably\ndead, recovery retires that exact proof under the maintenance lock and binds a fresh listener for the\nsame attempt, endpoint, and target with a new nonce and server name. A live foreign/mismatched listener\nor ambiguous owner is preserved and refused, never adopted by reachability alone. A reconstructed\ncommit/degraded attempt without either the exact bound proof or a durable dead-listener replacement\nrecord fails closed even when the recorded port is free. A later ordinary startup may pass an `active`\nrestore only when its details prove manager commit and its exact recorded listener is dead.\n\n## meshes, use, status\n\n```bash\ncotal meshes\ncotal meshes add # guided, on a terminal\ncotal meshes add <space> --server <url> [--root <dir>] [--mode auth|open] [--force]\ncotal meshes rm <space> [<space> \u2026] [--force]\ncotal use <space>\ncotal status [--space <s>] [--server <url>]\n```\n\n`meshes` lists the meshes this machine knows; a `*` marks the `current` default a bare\n`cotal spawn` joins.\n\nRun on a terminal with the space or `--server` missing, **`meshes add` is guided**: it asks for the\none thing that cannot be derived (the broker URL), probes it, and tells you what answered - open or\nrequiring credentials. It then offers the spaces your `--root` already holds credentials for, states\nthe mode as a fact about that broker rather than asking, and shows the exact record before writing\nanything. A broker that does not answer, or a space name already registered, becomes a choice rather\nthan an error. Anything you pass on the command line is taken as given and not asked again. Without\na terminal - a script, an agent, CI - nothing prompts and the flag form's errors stand\n(`COTAL_NO_PROMPT=1` forces that too).\n\n`cotal up` and `cotal down` maintain their own records. `meshes add` registers a mesh they cannot\nspeak for: one running on another machine, a shared broker, a hosted space. `--root` is the folder\nwhose `.cotal/auth` holds that mesh's credentials and whose `.cotal/agents` holds its personas\n(default: the project you run it in) \u2014 the registry stores that path, never a secret. `--mode`\ndefaults to `auth` when the root holds the space's account record and to `open` otherwise; a\nuser-auth space cannot be registered by hand, because its IdP pins are trust that only\n`cotal up --user-auth` establishes. The broker is probed before anything is recorded, so a wrong\naddress, or credentials that mesh will not accept, fails here instead of at the first `spawn`;\n`--force` records without verifying (and replaces an existing record).\n\n`meshes rm` drops records \u2014 it never stops a mesh. For a mesh running on this machine `cotal down`\nis the right verb, and `rm` says so unless you pass `--force`. A record you added by hand is only\nremoved by something that names it \u2014 `meshes rm`, or an `add --force` replacement \u2014 or by a\n`cotal up` that actually starts the broker for that same space, server and root, which becomes that\nmesh and so takes the record over (a `cotal up` for that space anywhere else refuses instead).\nNothing that merely *infers* a record is stale touches it: an\nunreachable broker is listed `offline` and stays, and `cotal down` / `cotal clean all` leave it\nalone even when it shares a root with the project they are tearing down, because nothing on this\nmachine could write it back.\n\n`use <space>` sets that default; the selection applies from every directory,\nincluding inside another mesh's project. `status` is a read-only report: machine prerequisites\n(starting with the installed `cotal-ai` version), the installed extensions and their versions, this\nfolder's `.cotal/`, the recorded meshes, and a live snapshot of the selected mesh (roster, channels,\nmembership feed). `status` takes only `--space` / `--server` to pick the mesh to inspect; it starts\nnothing.\n\n## spawn\n\n```bash\ncotal spawn [<persona>] [--detach] [--name <n>] [--agent <a>] [--model <m>] [--variant <v>] [--prompt <text>] [--cwd <dir>]\ncotal spawn -f <cotal.yaml> [--dry-run]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | resolved mesh | Target space |\n| `--server <url>` | registry entry | Broker URL override |\n| `--creds <path>` | \u2014 | Control-caller creds for an off-registry manager (`--detach` only) |\n| `--name <n>` | persona's `name:` | Presence-name override (does not choose the persona) |\n| `--config <persona-or-path>` | \u2014 | Persona catalog name or file path; wins over the positional |\n| `--agent <a>` | `COTAL_DEFAULT_AGENT`, else `claude` | Connector type (`claude`, `opencode`, `hermes`, \u2026) |\n| `--role <r>` | persona's `role:` | Role override |\n| `--model <m>` | persona's `model:` | Model override |\n| `--variant <v>` | persona's `variant:` | Model variant override (connector-defined; e.g. OpenCode reasoning tiers) |\n| `--cwd <dir>` | this cwd | Working directory to root the agent at |\n| `--prompt <text>` | \u2014 | Initial prompt auto-submitted at start |\n| `--resume <id>` | \u2014 | Fork an existing session id into the mesh (claude only) |\n| `--events` / `--no-events` | off | Publish the session's structured event plane to its own event channel |\n| `--share-tools <sel>` | none | Share named operator MCP servers with the agent |\n| `--subscribe <a,b>` | persona's | Channel read-set override |\n| `--allow-subscribe <a,b>` | = subscribe | Read-ACL override |\n| `--allow-publish <a,b>` | deny | Post-ACL override |\n| `--detach`, `-d` | off | Launch via the manager into a detached PTY (reattach with `cotal attach`) |\n| `--on <instance>` | class anycast | With `--detach` only: pin the launch to one manager instance id (the whole id, as `ps` prints it). Refused on a foreground spawn (no manager to pin), with `-f` (a manifest deploy launches through the manager class queue), and when empty |\n| `--file <cotal.yaml>`, `-f` | \u2014 | Deploy a manifest onto the running mesh |\n| `--dry-run` | off | With `-f`: print the plan, mutate nothing |\n| `--allow-stale <a,b>` | \u2014 | With `-f`: waive named stale agents (apply-only) |\n| `--runtime <name>` | manifest's | With `-f`: override the manifest's runtime |\n\n`--events` turns on the session's **event plane**: a stream of structured events describing what\nthe agent did, rather than the prose it wrote, on a channel of its own. The channel is named after\nthe agent's principal, `events.<owner>.<actor>`, never after its display name, because two live\nagents are allowed to share a display name and would then share a stream. The launch grants publish\nrights on exactly that one channel, foreground and detached alike, and a connector that does not\npublish an event plane refuses the flag rather than starting a session whose events have nowhere to\ngo.\n\nThe flag and the grant are separate on purpose. Holding publish rights on a channel is not a request\nto publish to it, so writing an event channel into an agent file's `allowPublish` does not turn the\nplane on: only the launch does.\n\nThe persona (`--config` > positional > `COTAL_DEFAULT_PERSONA` > `default`) is loaded from the\ntarget mesh's `.cotal/agents/`; the launch flags override the file. Foreground runs the agent\nattached to your terminal; `--detach` hands the launch to the running manager. Both modes get the\ndurable backstop on a mesh that runs the delivery daemon; `--live-only` skips it for a foreground\nspawn (messages posted while it is disconnected are then not replayed). A foreground exit retires\nthe agent's creds and broker footprint, like a manager despawn. A `--detach` spawn is an\n**action**: the manager accepts it and returns the allocated identity at once, then the launch\nfollows to a terminal outcome rather than blocking (see [the control surface](control-surface.md)).\nSee [Connect Claude Code](connect-claude.md) and [Agent files](agent-files.md); `-f` is a\n[manifest deploy](#manifest-deploys). (`cotal start` was merged into `cotal spawn --detach`.)\n\n## models\n\n```bash\ncotal models [--agent <connector>] [--refresh]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which manager to reach |\n| `--agent <connector>` | all registered connectors | Connector whose catalog to list |\n| `--refresh` | off | Ask the connector to refresh its provider cache |\n\nAsks the running manager for each connector's model catalog (model ids plus their variants)\nfor connectors that expose one (OpenCode today; a connector without a catalog says so). Pick a\nresult with `cotal spawn --model <provider/model> --variant <v>`.\n\n## endpoints\n\n```bash\ncotal endpoints [--space <s>] [--server <url>] [--creds <path>]\n```\n\nLists the mesh presence roster: agents, the manager, and any other protocol endpoint, with each\nendpoint's role, kind, status, and current activity. Unlike `ps`, this is a read-only presence view;\nit is not limited to child processes owned by the manager.\n\n## describe, invoke\n\n```bash\ncotal describe <endpoint> [--space <s>]\ncotal invoke <endpoint> <command> [--args '<json>'] [--space <s>]\ncotal invoke <endpoint> <command> --name <agent> [--admin] [--space <s>]\n```\n\nThe generic v0.4 service surface. `describe` resolves a registered endpoint's command set off the\nwire - the reserved `describe` command answers the registered contract digests, the schemas are\nfetched from the space's content-addressed contract store, recompiled, and verified against those\ndigests - and prints each command with its capability class and targeting shape. `invoke` calls one\ncommand by name: `--args` is a JSON object validated against the fetched input schema *before*\npublish; a targeted command takes `--name <agent>` (resolved to the agent's current principal via\n`ps`) or `--self`. `--admin` uses the admin instrument credential, whose cross-agent reach rides\nthe operator-only `any` authorization mode. Neither command has compile-time knowledge of any\nendpoint's schemas - this is the same trust chain every built-in control command now uses. Needs an\nauth mesh: the manager registers its service on both static and per-user meshes (a signed-in user\nrides their bearer; cross-agent reach needs the `admin` scope). An open mesh has no service\nregistry.\n\n## ps, stop, attach\n\n```bash\ncotal ps [--on <instance>] [--space <s>]\ncotal stop --name <n> [--on <instance>] [--space <s>]\ncotal attach --name <n> [--on <instance>] [--no-reconnect] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which manager to reach |\n| `--name <n>` | \u2014 | Managed agent to stop / attach (required) |\n| `--on <instance>` | class anycast (`ps`: class scatter) | Pin to one manager instance id (multi-manager space); takes the whole id as `ps` prints it, not a prefix. An empty value (`--on \"\"`, an unset shell variable) is refused, never treated as absent |\n| `--no-reconnect` (`attach`) | off | End the attach when its session ends, instead of re-establishing it. For scripts that want one run and one exit code |\n\nThese are operator clients over the running manager's control plane. `ps` prints two facts per\nmanaged agent, because they answer different questions: the process fact from the manager's own\nruntime handle (`running` with its uptime, or `exited` with how long it ran), and the mesh fact from\nthe roster (`idle` / `working` / `waiting` / `mesh offline`, or `not in roster` when the seat has no\npresence row at all: a seat that has not joined yet, or one that never did). A seat can be `running` and `mesh offline` at once: the process is alive and\nits presence has lapsed. On a user-auth mesh `ps` also renders each managed agent's last\ncredential-refresh outcome, fail-closed.\n\n**Mode split (chosen up front, never try-scatter-then-degrade):**\n\n- **Static / open mesh.** Bare `ps` is a **class scatter**: it freezes the live manager class from\n the records registry, merges every registered instance's agents grouped and attributed per\n instance, and a non-answering instance is shown as `registered, no answer within the deadline`\n (never silently omitted). That label is the whole claim: the instance is registered and did not\n answer. It does not say the host is down, because a dead host never deregisters itself and a\n live one can be slow; if it is gone, deregister it.\n `--on <instance>` pins the read to one exact instance id instead. A wrong pin fails loud\n rather than falling through: a well-formed id that no live manager carries is reported as\n `manager instance <id> did not answer` (nothing else is asked), and a credential without that\n instance's rail is reported as refused by the broker, not as an unresponsive manager. A manager\n that answers with a refusal is shown with its own cause; \"no manager reachable\" is said only when\n nothing answered at all. If the scatter's own registry read fails (the freeze or the reconcile),\n `ps` says the manager registry could not be read rather than pronouncing on the managers, which\n may all be up.\n\n**`stop` and `attach` route by seat locality.** A seat can only be stopped or attached by the\nmanager actually running it, and the class queue does not know which one that is. So on a\nstatic/open mesh both verbs first ask every registered instance which one hosts the named seat, then\naddress that instance directly. You do not need `--on` for this \u2014 it happens by default.\n\n`--on <instance>` remains the override, for when you already know where the seat lives or the\nlookup itself is degraded. It is also the **only** route on a **user-auth mesh**: a ledger-scoped\nbearer does not hold the registry-read rows the lookup needs, so there the verbs stay on the class\nqueue unless you pin them yourself.\n\nIf the seat is found on no reachable instance, the error says so \u2014 how many managers answered, and\nwhich ones did not \u2014 rather than reporting a bare `no agent <name>`. That distinction matters\nbecause a single manager cannot tell \"hosted elsewhere\" from \"does not exist\": it answers\n`not-found` for both.\n- **User-auth mesh.** `cotal ps` reports what **one** manager knows about your agents (an `ep.one`\n read against the manager's in-memory roster, owner-filtered). It does **not** report other\n manager instances, and it cannot tell you that one is down \u2014 an unreachable manager is absent\n from the list, not flagged. Completeness across a multi-manager user-auth space is not claimed.\n A manager that does not answer fails the command outright (exit non-zero), rather than printing\n an empty list that could be read as \"no agents\". Your ledger row needs the `admin` scope to\n reach `ps` at all; `spawn` alone is refused by the broker (the ep tier boundary).\n\n`attach` streams and drives an agent's terminal on the `pty` runtime; detach with the escape key\n(Ctrl-] by default; see [`COTAL_DETACH_KEY`](config.md)). It does so over a one-use, holder-bound\nmesh session ([SPEC](../SPEC.md) \xA713.6): the manager replies with a signed session grant (never a\n`127.0.0.1` URL), the CLI redeems it once over the broker, and the browser console (`cotal console`)\ndrives the same session. `stop` and `attach` need a running manager to talk to. On a static mesh\nthey are cross-agent admin operations. On a user-auth mesh, your own agents (any agent under your\nowner) need only the `spawn` scope; another owner's agent needs `admin` on your ledger row\n([identity & auth](identity-and-auth.md)). Launch detached agents with [`spawn --detach`](#spawn).\n\n**`attach` reconnects when the link dies.** A session lives on a network link, and a laptop that\nsleeps, a VPN that drops or a wifi handover kills it. When that happens `attach` prints\n`[cotal: connection lost, reconnecting]` on stderr and starts asking the manager for a new session:\na fresh grant, a fresh per-session credential, a fresh connection, so every attempt re-runs the same\nauthorization the first attach did. On success it prints `[cotal: reconnected]`, the manager repaints\nthe seat's current screen the way it does for any attach, and you carry on in the same terminal.\nRetries wait 1s, 2s, 5s, 10s, then 30s, for as long as the seat exists. The detach key is read\nduring those waits, so a reconnect never traps you; it is not read across the round trip that\nhands the old session back and opens the new one, so a press inside that window takes effect when\nthe round trip returns, within seconds.\n\nIt stops on its own when reconnecting cannot help, and says why: a manager that refuses the attach\nexits non-zero with the manager's own message, and a reconnect that finds the seat no longer there\n(despawned, or its agent exited while the link was down) exits cleanly with `seat <name> is gone`.\nA refusal that could still pass, such as a manager at its session ceiling, is relayed in the\nmanager's own words while the loop keeps trying, once per refusal rather than once per attempt.\nPressing the detach key, or the agent's process exiting while you are attached, ends the attach as\nit always did. `--no-reconnect` turns all of this off and restores the single-session behaviour,\nwhich is what a script wants.\n\nEach reconnect also hands the abandoned session back to the manager, over the first link that can\ncarry the message, so an attach that flaps does not eat the manager's session slots one outage at a\ntime. If that message never gets a link, the attach says so when it ends.\n\n`attach` streams over the manager's own HTTP/WS face rather than the mesh. That endpoint binds\n**loopback by default**, so nothing is exposed by accident; `cotal up --host <addr>` passes its bind\naddress down, which is what lets you attach to an agent whose manager runs on another machine. A\nbare `cotal supervise` and an embedded manager stay machine-local. Set it directly with\n`supervise --console-host <host>`.\n\nThat address is **recorded on the mesh** and carried forward, because it is a decision rather than\nsomething later commands can work out for themselves (a broker dial address is not a manager bind\naddress). Every later manager launch for the same mesh reuses it \u2014 a same-root `cotal up` repair,\nadopting a preserved or restored listener, a `spawn -f` manifest deploy \u2014 so a manager replacement\ndoes not quietly move a reachable attach face back to loopback. Passing `--host` again overrides it,\nso you can widen or narrow exposure whenever you like; a mesh that never asked stays loopback-only\nand records nothing.\n\nBecause that face carries terminal read and write for every managed agent, it is credentialed in two\ntiers. A mesh caller receives a **ticket** bound to the single agent the manager just authorized,\nsingle-use and short-lived, so one authorized attach can never be re-pointed at someone else's\nagent. The **console token** is the operator's own, reaches every agent, and is printed only to the\nmanager's output. The roster, the live feed, and the PTY stream all answer `401` without one; the\nstatic console shell is served openly, since it describes no agent.\n\n## input\n\n```bash\ncotal input --name <n> --text <text> [--no-enter] [--on <instance>] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which manager to reach |\n| `--name <n>` | | Managed agent to type into (required) |\n| `--text <text>` | | The text to type, taken verbatim (required) |\n| `--no-enter` | off | Type the text and stop there, without pressing Enter |\n| `--on <instance>` | class anycast | Pin to one manager instance id, exactly as [`attach`](#ps-stop-attach) |\n\nTypes one line into a running agent's terminal, as if you had typed it there, and returns. This is\nthe half of [`attach`](#ps-stop-attach) that a program wants: `attach` is a live stream that holds a\nsession open and expects a terminal on your side, so a script, a cron job or a web UI cannot use it\nto send a single line. `input` is one authorized call.\n\nWhat it is for is **harness commands**. A line beginning with `/` is not chat and not a message: it\nis something the agent's own harness handles, and the only way in is the keyboard.\n\n```bash\ncotal input --name reviewer --text \"/compact\" # ask the harness to compact its context\ncotal input --name reviewer --text \"/model opus\" # switch its model\ncotal input --name reviewer --text \"hold on that PR\" # ordinary typing works too\n```\n\n**Quoting.** `--text` takes a value, so a payload starting with `/` survives as written. A payload\nstarting with a dash needs the `=` form, because the shell-style `--text --foo` is ambiguous and is\nrefused rather than guessed:\n\n```bash\ncotal input --name reviewer --text=--verbose # dash-leading text: use --text=<value>\n```\n\nEnter is pressed by default, since a command typed but never submitted has not been delivered.\n`--no-enter` types the text and leaves it sitting at the prompt, which is how you stage a line and\nsend it later.\n\nNothing comes back but a delivery receipt (`\u2713 sent 9 bytes to reviewer`, counting the trailing\ncarriage return). Whatever the agent does next shows up where its output already goes: the mesh, its\ntranscript, or an `attach`.\n\n**This one is operator-only, and more narrowly than `stop` or `attach`.** Those two are granted to\nanything holding `spawn`, so an agent can stop and attach to seats under its own owner. `input` is\nnot: it is granted only to operator credentials, which on a user-auth mesh means your ledger row\nneeds the `admin` scope, the same scope [`ps`](#ps-stop-attach) already needs there. The reason is\nthat a write into a terminal is control of whatever is running in it, and on a user-auth mesh the\nown-owner rule covers every seat under you, not only the ones you launched: a `spawn`-scoped agent\ncould otherwise type into a sibling it never started. Seat locality is still resolved for you.\n\nOnly the `pty` runtime can be typed into. The external terminal runtimes (`tmux`, `cmux`, `orca`,\n`herdr`) attach to a process they do not own, so they have no input stream for it and the command\nrefuses by name rather than dropping the keystroke.\n\n## personas\n\n```bash\ncotal personas list [-v] [--running]\ncotal personas show <name>\ncotal personas edit <name>\ncotal personas new <name> (--prompt <t> | --from <f>) [--role <r>] [--model <m>]\ncotal personas rm <name> --force\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which mesh's persona catalog |\n| `--role <r>` | \u2014 | `new`: the persona's role |\n| `--model <m>` | \u2014 | `new`: the persona's model |\n| `--prompt <t>` | \u2014 | `new`: the persona's prompt text |\n| `--from <f>` | \u2014 | `new`: seed the prompt from a file |\n| `--verbose`, `-v` | off | `list`: include role / model / description |\n| `--running` | off | `list`: mark personas live on the mesh |\n| `--force` | \u2014 | `rm`: required, delete without prompting |\n\nPersonas are the local agent files under `.cotal/agents/` that `cotal spawn` launches. See\n[Agent files](agent-files.md) for the file format.\n\n## supervise\n\n```bash\ncotal supervise [--runtime <name>] [--space <s>] [--server <url>] [--spawn <names>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | this folder's auth space | Space to supervise |\n| `--server <url>` | the local mesh | Broker URL |\n| `--runtime <name>` | `pty` | Agent runtime (`pty` built in; extension runtimes are explicit-only) |\n| `--console-port <n>` | \u2014 | Protocol-console port |\n| `--console-host <host>` | loopback | Bind host for the console + attach endpoint. Loopback keeps it machine-local; `cotal up` passes the address it bound the broker to, which is what lets `cotal attach` reach this manager from another machine |\n| `--roster <file>` | \u2014 | Declarative roster to boot at startup |\n| `--launch <spec>` | \u2014 | Resolved manifest launch spec (from `up -f` / `spawn -f`) |\n| `--spawn <names>` | \u2014 | Comma-separated personas to pre-spawn at startup |\n\nThe manager is the agent supervisor and control plane: it answers `spawn --detach`, `stop`, `ps`,\n`attach`, and the `cotal_*` manager tools. `cotal up --detach` starts one for you; run `supervise`\ndirectly to recover a dead manager or drive a custom runtime. Default runtime is `pty`; install an\noptional provider first (`cotal ext add @cotal-ai/orca`, `@cotal-ai/tmux`, `@cotal-ai/cmux`, or `@cotal-ai/herdr`) and\nselect it explicitly. A missing provider or app fails loudly; there is no fallback. See [Deploy](deploy.md).\n\n## reconcile-gate\n\n```bash\ncotal reconcile-gate [--space <s>] [--server <url>] [--endpoint <e>] [--instance <id>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | this folder's auth space | Space the frozen gate lives in |\n| `--server <url>` | the local mesh | Broker URL |\n| `--endpoint <e>` | `manager` | Endpoint whose gate is frozen |\n| `--instance <id>` | this folder's persisted manager instance | Instance id |\n\n**When you need this.** A manager restart killed partway through \u2014 after it began deregistering,\nbefore the new incarnation finished \u2014 leaves the endpoint's issuance gate *frozen*, held by a\nprocess that no longer exists. The next manager start refuses to proceed, which is correct: the\nfreeze is what stops two incarnations serving at once. But nothing can lift it, so every restart\nfails the same way. `cotal doctor` shows the gate as frozen; the manager's own start logs name the\ngate it could not advance.\n\nThis command is the way out. It checks that the holder really is gone, prints what it found, and\nthen finishes the dead operation exactly as the interrupted restart would have: revoke the old\ncredentials, evict their holders with verification, and reopen the gate. Start the manager\nafterwards and its normal takeover runs end to end.\n\n**It refuses far more often than it acts, on purpose**, and always says which check stopped it:\n\n| Refusal | What it means | What to do |\n|---|---|---|\n| `holder-alive` | The freeze-holder still has a live connection \u2014 a manager *is* running | Stop that process first. Reconciling would evict a live manager's credentials |\n| `holder-unknown` | The connection sweep could not prove the holder absent | Not safe to proceed: an unprovable holder is treated as a live one. Re-run once the broker answers completely |\n| `liveness-unestablishable` | The delivery daemon could not be asked at all | Start it (`cotal up` runs it) and re-run. Silence is never read as death |\n| `not-frozen` / `no-gate` | The gate is open, or there is no gate at that coordinate | Nothing to repair \u2014 check `--endpoint` / `--instance` |\n| `wrong-op-kind` | Frozen under a takeover or retirement, not a registration | Out of scope for this command; it will not reinterpret another operation's intent |\n| `eviction-unverified` | The holder looked gone but eviction could not be verified | The gate is left frozen, unchanged. Investigate the broker before retrying |\n| `raced` | A newer manager moved the gate mid-repair | Re-run `cotal doctor` and look again |\n\nThere is no `--force`, and no path that discards gate state: the only way this reopens a gate is by\nproving the holder is gone and then completing the operation properly.\n\n## deregister-instance\n\n```bash\ncotal deregister-instance [--space <s>] [--server <url>] [--endpoint <e>] [--instance <id>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | this folder's auth space | Space the instance is registered in |\n| `--server <url>` | the local mesh | Broker URL |\n| `--endpoint <e>` | `manager` | Endpoint the instance serves |\n| `--instance <id>` | this folder's persisted manager instance | Instance id, the whole id as `cotal ps` prints it |\n\n**When you need this.** The service registry records *registration*, not liveness, and nothing in\nthe model expires a row. A manager that stops cleanly removes its own registration. One whose host\ndied without writing anything cannot, so its record goes on claiming a live instance forever: every\nclass scatter in that space freezes the dead slot in, and `cotal ps`, `stop` and `attach` each pay\ntheir whole deadline waiting for a machine that is never coming back. A laptop that was reimaged, a\ncontainer that was deleted, a box that will not be back on the network: those registrations have no\nother exit.\n\nThis command is that exit. It asks the instance first, and it removes a record only when the broker\naffirms the instance's own rail is empty: nothing subscribed there. Then it deletes the\nregistration's two records keys, each pinned to the revision it read, and prints what it removed.\n\n**Silence alone never passes.** An unanswered describe is what a dead host, a wedged process and a\nslow one all look like, and a hung process still holds its subscriptions, so the broker sees\ninterest on its rail. That instance is refused and the observation is printed. A dead process holds\nno connection and therefore no subscription, so a real corpse is still removed.\n\n**It refuses rather than guesses**, and says which check stopped it:\n\n| Refusal | What it means | What to do |\n|---|---|---|\n| `instance-answered` | The instance answered a pinned describe. It is alive | Nothing to repair. If it is wedged rather than gone, stop the process first; its own clean stop removes the record |\n| `instance-not-affirmed-gone` | It did not answer, and the broker did not report its rail empty, which is what a held subscription looks like: slow or hung, not affirmed gone | Nothing was removed. Stop the process; its record goes on its own clean stop, or re-run this once it is down |\n| `liveness-unestablishable` | The probe itself failed, so nothing was learned | Fix the probe's path (credential, broker) and re-run. A probe that could not run is never read as death |\n| `not-registered` | No registration at that coordinate | Check `--instance` and `--endpoint`. This takes the whole id, never a prefix |\n| `superseded` | The record moved between the read and the delete | Something is writing to it. Nothing was removed; re-observe before retrying |\n\nThere is no `--force` and no sweep: silence is not death, and a rule that removed rows on silence\nwould eventually remove a live instance that was merely slow. An operator names one instance, the\nbroker's verdict on its rail is what authorizes the removal, and the guard's job is to show them\nthey named a dead one. Removal is not a one way door either. The same instance re-registers over\nthe tombstone on its next start, under the same identity.\n\n## runtimes\n\n```bash\ncotal runtimes\n```\n\nLists every agent runtime the manager can spawn through: the built-in `pty`, the official providers\n(`orca`, `tmux`, `cmux`, `herdr`), and any custom provider installed via `cotal ext add`. Each installed\nprovider is probed so you can see what is actually reachable on this machine before selecting it:\n\n```\npty built in\norca installed \xB7 reachable @cotal-ai/orca\ntmux available \xB7 cotal ext add @cotal-ai/tmux\ncmux available \xB7 cotal ext add @cotal-ai/cmux\nherdr available \xB7 cotal ext add @cotal-ai/herdr\n```\n\n`installed \xB7 reachable` / `unreachable` is the provider's own `available()` probe; `available` means\nit is a known runtime you can add with the shown command. Selecting an unknown or uninstalled runtime\nvia `up`/`spawn --runtime <name>` fails loud and, for a known one, points at the exact `cotal ext add`\npackage \u2014 there is no silent fallback to `pty`.\n\n## send\n\n```bash\ncotal send dm <agent> \"<text>\" [--space <s>] [--server <url>] [--creds <path>]\ncotal send msg <channel> \"<text>\"\ncotal send ask <role> \"<text>\"\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which mesh, and (off-registry) which credential |\n\nOne-shot messaging: connect, send a single direct message (`dm`), channel post (`msg`), or role\nask/anycast (`ask`), then exit. For a running conversation, agents use the mesh tools instead\n([MCP tools](mcp-tools.md)).\n\n## channels\n\n```bash\ncotal channels list\ncotal channels set <name> [--replay | --no-replay] [--window <n>] [--desc <s>] [--instructions <s>]\ncotal channels default --replay | --no-replay\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Target mesh |\n| `--replay` / `--no-replay` | \u2014 | `set`/`default`: replay history to new joiners, or not |\n| `--window <n>` | \u2014 | `set`: replay window size |\n| `--desc <s>` | \u2014 | `set`: one-line channel description |\n| `--instructions <s>` | \u2014 | `set`: instructions shown to joiners |\n\nInspects and edits the channel registry: replay policy, description, and joiner instructions. ACL\nsemantics (who may read or post) are set at mint / provision time, not here; see\n[Channels and permissions](channels-and-permissions.md). On a user-auth mesh, `list` rides your\nown login as is; `set` and `default` edit the registry over a short-lived\nchannel-writer view, which needs ledger scope `admin` ([Identity & auth](identity-and-auth.md)).\n\n\n## history\n\n```bash\ncotal history clear --force [--dms] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Target mesh |\n| `--dms` | off | Also clear DM history |\n| `--force` | \u2014 | Required: clear without prompting |\n\nPurges retained channel history; `--dms` extends it to direct-message history. An alias of\n[`clean history`](#clean). On a user-auth mesh the purge rides a short-lived purger view over\nyour login, which needs ledger scope `admin` ([Identity & auth](identity-and-auth.md)).\n\n## console\n\n```bash\ncotal console [--plain] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Space to watch |\n| `--plain` | off | Line stream instead of the TUI |\n\nA live protocol view for a space: a lazygit-style TUI, or a plain line stream on `--plain`. On a\nuser-auth mesh it rides the read-only admin view over your login, which needs ledger scope\n`admin`. See [Watch a mesh](watch-a-mesh.md).\n\n## web\n\n```bash\ncotal web [--detach] [--port <n>] [--no-open] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Space to serve |\n| `--port <n>` | `7799` | HTTP port |\n| `--detach` | off | Run in the background; stop with `cotal down web` or bare `cotal down` |\n| `--no-open` | off | Don't open the browser |\n\nThe browser observability dashboard: presence, channels, and a live feed. It is **not** part of\n`cotal up`: it ships inside `cotal-ai` as the `@cotal-ai/web` extension, seeded automatically on first\nrun (like the built-in connectors) so it always matches your CLI version. It self-registers `cotal web`\ninto this surface and serves\n`http://cotal.localhost:7799` (loopback; `*.localhost` resolves in Chrome/Firefox/Edge; Safari may\nneed `http://127.0.0.1:7799`). On a user-auth mesh the dashboard rides the read-only admin view\nover your login, and a channel purge asks for its own channel-purger view per click; both need\nledger scope `admin`. Detached mode re-execs the current Cotal installation, writes diagnostics to\nthe mesh root's `.cotal/web.log`, and reports success only after the HTTP server answers. It requires\na recorded mesh root, but can be launched from any directory once `cotal up` has recorded the mesh.\nSee [Watch a mesh](watch-a-mesh.md).\n\n## mint\n\n```bash\ncotal mint <name> [--profile <agent|observer|admin>] [--out <path>] [--signer]\ncotal mint <name> --provision [--role <role>] [--space <s>] [--server <url>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--profile <agent\\|observer\\|admin>` | `agent` | Credential profile |\n| `--out <path>` | `.cotal/auth/creds/<name>.creds` | Output path |\n| `--signer` | off | Emit a stripped account-signing file instead |\n| `--force` | off | With `--signer`: overwrite an existing file |\n| `--allow-subscribe <a,b>` | the agent file's, else subscribe | Read-ACL override, **agent profile only**: `observer` and `admin` carry a fixed read set, and `mint` refuses this flag there rather than narrowing nothing |\n| `--allow-publish <a,b>` | the agent file's, else deny | Post-ACL override, **agent profile only** |\n| `--role <role>` | the agent file's | Agent profile: the anycast task queue the identity pulls (`svc_<role>`) |\n| `--provision` | off | Agent profile: also pre-create the identity's bind-only DM/deliver durables (and its role's task queue) on the live mesh, so the credential can consume |\n| `--space <s>`, `--server <url>` | the resolved mesh | With `--provision`: which mesh to provision on |\n\nMints a NATS creds file for a space in **static** auth mode, scoped to a profile and (optionally)\nexplicit read/post ACLs. `--signer` emits an account-signing file for delegating minting to another\nhost. A per-user-auth space refuses `mint`: agents there join under a logged-in user\n([`login`](#login-logout) + [`actor grant`](#actor)), never via a handed-out creds file. See\n[Identity and auth](identity-and-auth.md).\n\nA plain mint is creds only: the identity can publish within its post ACL at once, but on an authed\nmesh its DM inbox and task queue are provisioner-pre-created and bind-only, so a **consuming**\nconnect fails until they exist. `--provision` performs that pre-create in the same command (a\nprovisioner cred is minted from the space's trust material, used, and dropped), so a long-running\nclient you start yourself can receive DMs and role anycasts like a spawned seat. The command prints\nthe identity's principal (its wire id) and lifecycle uid; a consuming client passes that uid as its\n`lifecycleUid`. Agent profile only; an open mesh needs none of this (peers self-create there). The\nmesh it provisions on must be the one this folder's auth is for - same space and same account key -\nso `--provision` can never quietly mint under another root's trust material.\n\n## login, logout\n\n```bash\ncotal login --idp <auth base URL> [--client-id <id>]\ncotal logout --idp <auth base URL>\n```\n\nSigns you in to a per-user-auth mesh's IdP (device code flow) and caches the session; run it\nonce per machine. It prints your IdP subject, the id the operator grants against. After a\nlogin, every command on that mesh works under your identity: each connect takes a fresh IdP\nproof, exchanges it locally for a short-lived bearer, and is authorized against the actor\nledger at connect time. `logout` revokes the IdP session and clears the cache. See\n[identity & auth](identity-and-auth.md).\n\n## actor\n\n```bash\n# an upsert of the WHOLE row: a flag left off is the WIDE default below, not \"unchanged\"\ncotal actor grant <actor> --sub <IdP subject> [--scope a,b] [--allow-subscribe a,b] [--allow-publish a,b] [--role <r>] [--label <l>]\ncotal actor revoke <actor> (--sub <IdP subject> | --owner <u_\u2026>)\ncotal actor list\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | the folder's | Space whose ledger to manage |\n| `--sub <subject>` | \u2014 | The IdP subject (shown by `cotal login`) the actor belongs to |\n| `--owner <u_\u2026>` | \u2014 | The derived owner token (alternative to `--sub`) |\n| `--scope <a,b>` | `spawn,role:default` | Capability scope (`''` = none; `spawn` = may run agents, `role:<r>` = may delegate role r, `admin` = cross-agent control) |\n| `--allow-subscribe <a,b>` | `>` (all channels) | Channel read ACL; the user's envelope, their agents can never read beyond it |\n| `--allow-publish <a,b>` | `>` (all channels) | Channel post ACL; also the envelope for their agents' posting |\n| `--role <r>` | \u2014 | Role (scopes the task-queue consumer) |\n| `--label <l>` | \u2014 | Display label for `actor list` (never the IdP subject) |\n\nThe actor ledger is the single authorization source of a user-auth space: no row, no access.\nA bare `grant` is the **full** envelope (all channels, may spawn); the flags narrow it. A\nre-grant **replaces the whole row**, not the one field you name, so to add a capability spell\nevery field out: the new scope plus the row's current read set, post set, role and label\n(`cotal actor list` shows what a row holds). A field left off does not stay as it was, it\nreverts to the wide default in the table above, which is how a narrow reader becomes a reader\nof every channel. `revoke` denies the next exchange and the next connect with no restart, and\nevicts the principal's live connections. Managed-agent rows\n(written by the spawn path) live in a disjoint row space this command never touches. See\n[identity & auth](identity-and-auth.md).\n\n## doctor\n\n```bash\ncotal doctor auth [--fix]\n```\n\nCredential-health diagnosis and repair for this folder's mesh: renders every managed\ncredential as healthy / near-expiry / expired and ends in `healthy` or the exact next\ncommand; `--fix` applies the repairs it can. The one surface every stale-credential error\npoints at.\n\n## join\n\n```bash\ncotal join --space <s> --name <n> [--role <r>] [--channel <c>]\ncotal join --link <url> | --token <t>\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which mesh, and which credential |\n| `--name <n>` | \u2014 | Your presence name |\n| `--role <r>` | \u2014 | Your role |\n| `--channel <c>` | \u2014 | Channel to join |\n| `--kind <k>` | `agent` | Endpoint kind |\n| `--link <url>` | \u2014 | Join link (`cotal://\u2026`) |\n| `--token <t>` | \u2014 | Join token |\n| `--lifecycle-uid <uid>` | \u2014 | Required with `--creds`: the lifecycle UID minted alongside the credential (`COTAL_LIFECYCLE_UID` works too). A credential's durable grants name exact lifecycle-keyed resources, so `join` refuses to invent one |\n| `--tls` | off | Connect over TLS |\n\nAn interactive presence: join a space under your own name and role, without launching an agent\nharness. A `--link` or `--token` supplies the where and the auth in one value. See\n[Spaces](spaces.md) and [Identity and auth](identity-and-auth.md).\n\n## Manifest deploys\n\nA `cotal.yaml` manifest declares a whole mesh (channels, personas, roles, and ACLs) in one file.\nThree commands consume it, plus a read-only validator:\n\n```bash\ncotal up -f cotal.yaml # boot a fresh mesh from the manifest\ncotal spawn -f cotal.yaml # deploy the manifest additively onto a running mesh\ncotal down -f cotal.yaml # tear that deploy down (or --run <id> for one run)\ncotal topology view -f cotal.yaml # validate + view the access graph, change nothing\n```\n\n`up -f` and `spawn -f` differ in target: `up -f` brings up a new broker and applies the manifest;\n`spawn -f` requires an already-reachable mesh and applies additively (ownership-scoped). On a\nuser-auth mesh, `spawn -f` deploys over your own login (the deployer view, gated on ledger scope\n`spawn`): the manifest's agents land under your owner, a manifest claiming another owner is\nrefused, and seeding new channels additionally needs scope `admin`. Both take\n`--dry-run` to print the plan without mutating anything. `topology` validates the manifest and\nrenders its channel / role / ACL graph. See [Define a team](define-a-team.md) and the\n[manifest reference](manifest.md).\n\n## ext\n\n```bash\ncotal ext # same as `list`\ncotal ext add <npm-package>\ncotal ext remove <name>\ncotal ext list\ncotal ext root # print just the install prefix (scriptable)\ncotal ext seed [--repair|--reset|--force]\n```\n\nOperator-installed extensions: `add` installs an npm package into a cotal-owned prefix and records\nevery registry provider it contributes. Commands appear in help, completion, and dispatch; runtime\nproviders are lazy-loaded by commands such as `supervise`; local process providers participate in\n`status` and selective `down`. `remove` and `list` manage them. The `@cotal-ai/web` dashboard is the\ncanonical command/process example. Installed packages and their location are described in\n[config](config.md).\n\nBare `cotal ext` lists the inventory, headed by the install prefix. That prefix is a cotal-owned npm\nroot kept **separate** from npm's own global tree, so these packages never show up in `npm list -g` \u2014\n`cotal ext` (or the Extensions section of `cotal status`) is the canonical inventory. `cotal ext root`\nprints only the path, for scripts. The versions shown are the manifest pin recorded at add time.\n\nRemoving an extension that owns a running local process is refused with the mesh root and its\n`cotal down <component>` command; stop it first so uninstalling the package never strands a process\nwhose lifecycle provider is gone.\n\n### Built-in connectors are seeded extensions\n\nThe first-party agent connectors (`claude`, `opencode`, `codex`, `hermes`, `pi`) are not compiled into\nthe binary. They are seeded on first run through the **same** `ext add` path a third party uses, and\nappear in `cotal ext list` like any other extension. So you can remove one you do not want\n(`cotal ext remove @cotal-ai/connector-hermes`), and a deliberately-removed connector STAYS removed\nacross upgrades. `cotal ext add <your-package>` adds a third-party connector the same way. The web\ndashboard (`@cotal-ai/web`, providing `command:web`) is a fifth built-in seeded on the same path.\n\n`cotal ext seed` is the maintenance entry for that seeding (it runs automatically on the first real\ncommand of each boot, so you rarely call it):\n\n| Flag | Meaning |\n|---|---|\n| (none) | Reconcile: seed any never-seeded built-in, refresh a seeded one whose version the binary bumped, leave a removed one removed. A no-op once current. |\n| `--repair` | Recover after an interrupted seed or a lost authority (rebuilds the interrupted connector; restores the removed-vs-never-seeded record from its durable backup). |\n| `--reset` | Discard the record and re-seed all six built-ins (the five connectors plus the web dashboard). **Resurrects any you removed.** Rebuilds cleanly over corrupt seed state. |\n| `--force` | Re-seed the built-ins even when the version stamp is current or a downgrade. |\n\nThe default connector for a bare `cotal spawn` (no `--agent`) is `claude`; set `COTAL_DEFAULT_AGENT`\n(e.g. `opencode`) to change it. An `--agent` naming a removed connector fails loud with the exact\n`cotal ext add` to restore it. Set `COTAL_SKIP_CONNECTOR_SEED=1` to turn off the automatic first-run\nseed/refresh entirely (for a controlled or offline setup that manages connectors by hand); `cotal ext\nseed` still runs on request.\n\n## completion\n\n```bash\ncotal completion <bash|zsh|fish|powershell> # print a stub to eval / source\ncotal completion install [shell] # install it persistently\n```\n\nPrints or installs shell completion. Completion candidates come from each command's declared flags\nand, where useful, live mesh state (spaces, personas, managed agents) resolved offline.\n\n## feedback\n\n```bash\ncotal feedback \"<summary>\" [--type <t>] [--email <e>] [--details <text>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--type <t>` | \u2014 | `bug` \\| `idea` \\| `friction` \\| `praise` \\| `other` |\n| `--details <text>` | \u2014 | Longer free-form details |\n| `--severity <s>` | \u2014 | `low` \\| `medium` \\| `high` |\n| `--area <a>` | \u2014 | The part of Cotal this concerns |\n| `--email <e>` | git email | Contact email (required on the keyless public path) |\n| `--name <n>` | \u2014 | Your name (optional) |\n| `--url <url>` | keyed / public intake | Intake URL override |\n| `--key <k>` | `COTAL_FEEDBACK_KEY` | Feedback key |\n\nSends feedback to the Cotal developers. With a key (`--key` / `COTAL_FEEDBACK_KEY`) it routes to the\nkeyed beta intake; without one it goes to the public `cotal.ai` intake and requires a contact email\n(`--email` / `COTAL_FEEDBACK_EMAIL`, else your git email). Run a self-hosted intake with\n[`feedback-intake`](#server-daemons).\n\n## Server daemons\n\nTwo long-lived infra roles ship with the CLI. They are not part of everyday operation; the delivery\ndaemon comes up automatically with `cotal up --detach` in auth mode.\n\n```bash\ncotal deliver --space <s> [--server <url>] [--creds <file>]\ncotal auth-service --space <s> --server <url> [--port <n>]\ncotal feedback-intake --keys <keys.json> [--port <n>] [--creds <file>]\n```\n\n`auth-service` runs a user-auth space's identity plane (the NATS auth callout plus the\nloopback token exchange and JWKS); `cotal up --user-auth` starts and supervises it for you,\nso you run it directly only to recover one by hand.\n\n`deliver` runs the server-side Plane-3 delivery daemon: the durable backstop and membership/ACL\nauthority. It is auth-mode-only and single-instance (`--shard`/`--shards` accept only `N=1`);\n`--dev-mint` mints a scoped cred from the local signer for standalone dev. See the\n[delivery daemon](delivery-daemon.md). `feedback-intake` runs a self-hosted feedback server\n(requires `--keys` and a scoped `--creds`), announcing submissions into a space channel; flags\ninclude `--host`/`--port`, `--store`, `--space`/`--channel`, `--max-bytes`, and `--rate-limit`.\n\n## Plumbing\n\n`cotal __complete <words\u2026>` is the internal entry the shell-completion stubs call to emit candidates\nfor the current command line; you never run it directly. `cotal agent-bearer` is machine-facing\nplumbing on user-auth meshes: spawned agents exec it to print a fresh short-lived bearer from their\nspawn-time secret; you never run it directly either. (`cotal start` is a removed tombstone: it\nerrors and points you to `cotal spawn --detach`.)\n"
|
|
66946
67275
|
},
|
|
66947
67276
|
{
|
|
66948
67277
|
"slug": "config",
|
|
@@ -66956,7 +67285,7 @@ var DOCS_BUNDLE = {
|
|
|
66956
67285
|
"title": "Connect Claude",
|
|
66957
67286
|
"kind": "Guide (informative)",
|
|
66958
67287
|
"summary": "The Claude Code connector turns a real claude session into a Cotal mesh peer.",
|
|
66959
|
-
"body": "# Connect Claude\n\n> **Guide** (informative) \xB7 **For:** operators \xB7 **Prereqs:** [Quickstart](getting-started.md)\n\nThe Claude Code connector turns a real `claude` session into a Cotal mesh peer. A bundled\nplugin inside the session joins NATS, maps lifecycle hooks to presence, and exposes the\nmesh tools. Nothing wraps Claude; it is an ordinary session that happens to be on the\nmesh.\n\nThe shared mesh runtime (agent, `cotal_*` tools, hook relay) lives in\n[`@cotal-ai/connector-core`](../extensions/connector-core); this connector is the thin\nClaude-specific adapter over it. Siblings: [OpenCode](connect-opencode.md) (beta),\n[Hermes](connect-hermes.md) (alpha), [pi](connect-pi.md) (alpha); the\n[Connectors](connectors.md) matrix compares them feature-by-feature.\n\n## Set up\n\n```bash\ncotal setup # one-time: installs the plugin, seeds one agent; launches nothing\ncotal up # brings up the mesh + delivery daemon + a detached manager\n```\n\n`cotal setup` installs the cotal plugin (so the repo's Claude sessions get the `cotal_*`\ntools) and seeds one `default` persona; `cotal up` brings up the local stack so\n`cotal spawn --detach` / `cotal_spawn` work right away. Re-running either is idempotent.\nThe install mechanics and the invariants behind them are in\n[setup internals](setup-internals.md).\n\n`cotal setup` also installs Cotal's authored Agent Skills (`SKILL.md`, the agentskills.io format) for\ncoordinating agent teams (today `team-topology`), from one canonical source, on two channels:\n\n- **Claude Code** gets a second, skills-only plugin, `cotal-skills`, from the same `cotal-mesh`\n marketplace, at **user scope** (machine-wide), and **independent of the mesh connector**: it carries no\n code and no core dependency, installs whenever Claude is on `PATH` (even with the connector removed),\n and uninstalls on its own with `claude plugin uninstall cotal-skills --scope user`. Its plugin version\n is stamped from the running CLI release, so an upgrade + `cotal setup` runs `claude plugin update` and\n the deployed install actually gets the new skill. `cotal setup` installs it on first run and on repeat\n runs, so upgraders are not left behind.\n- **Every other harness** (Codex, Cursor, OpenCode, Gemini CLI, Windsurf/Devin) reads the cross-vendor\n `~/.agents/skills/` directory convention, which has no remote index, so `cotal setup` **reconciles** it:\n it installs/updates each Cotal skill, backs up a copy you have edited to `SKILL.md.bak` before\n replacing it, and removes a Cotal skill that is no longer shipped. Only skills Cotal owns are touched;\n your own or third-party skills there are left alone. `cotal status` reports whether the drop is current,\n stale, missing, or has a retired skill to reconcile. This is the working cross-vendor path.\n\nCotal also generates an [Agent Skills discovery index](https://cotal.ai/.well-known/agent-skills/index.json)\non cotal.ai, but that RFC is still a draft with no harness consuming it yet, so it is a forward bet,\nnot a channel to rely on today.\n\n## Spawn a session\n\n```bash\ncotal spawn # foreground: your default agent, in this terminal\ncotal spawn dave --detach # supervised: the manager runs it in a PTY\n```\n\nA spawn resolves a persona from `.cotal/agents/<name>.md` ([agent files](agent-files.md));\n`--model`, `--variant`, `--cwd`, `--prompt`, ACL overrides, and `--share-tools` apply to\nboth forms ([run a mesh](run-a-mesh.md) has the full resolution rules). The session joins\nwith identity from its environment and auto-registers presence by the time it is\ninteractive.\n\nInside the session, the agent orients with one read-only tool, `cotal_orientation`: its\nidentity, the channels it reads and may post to, its capabilities, the tools available,\nwho's present, and unread counts. The full tool surface is the\n[MCP tool catalog](mcp-tools.md). In auth mode the team-supervision tools\n(`cotal_spawn` / `cotal_persona`) are injected **only** for personas declaring\n`capabilities: [spawn]` (the same grant that opens the privileged control subject), so an\nagent's toolset matches what it can actually invoke. Clearing retained history is\noperator-only ([run a mesh](run-a-mesh.md)), never an agent tool.\n\n## How it binds\n\nClaude Code exposes four integration surfaces, and three of them collapse into a single\ndual-purpose MCP server:\n\n| Surface | Mechanism |\n|---|---|\n| Outbound, ambient | `http` lifecycle hooks \u2192 POST to the connector (presence, activity) |\n| Outbound, deliberate | MCP tools `cotal_send` / `cotal_dm` / `cotal_anycast` (+ `cotal_feedback`) |\n| Inbound, pull | MCP tool `cotal_inbox` (same server) |\n| Inbound, push | Channel nudge + hook drain (below) |\n\nThe manager launches the *real* `claude` (no wrapper):\n\n```\nclaude --strict-mcp-config --mcp-config '{\"mcpServers\":{\"cotal\":{\u2026}}}' \\\n --dangerously-load-development-channels server:cotal\n# env: COTAL_SPACE, COTAL_NAME, COTAL_ROLE, COTAL_SERVERS, COTAL_CHANNEL=1\n```\n\n- **MCP isolation.** A spawned agent runs with **only** the cotal MCP server:\n `--strict-mcp-config` ignores every other MCP source, crucially the operator's personal\n `~/.claude.json` servers (several spawns each booting a heavy helper would starve\n memory). Share your own servers deliberately (see below).\n- **Installed, not `--plugin-dir`.** The plugin is installed once (`claude plugin install\n cotal@cotal-mesh --scope local`) because its hooks bind only to an *installed* plugin.\n In a clone the marketplace is the repo's `.claude-plugin/marketplace.json`; `cotal setup`\n (npx, no clone) materializes the same marketplace under `~/.cotal/claude-plugin/` (each plugin dir is\n rebuilt from scratch and atomically replaced, never merged, so no stale file rides in). The\n `cotal-skills` plugin installs from that same marketplace at user scope (`claude plugin install\n cotal-skills@cotal-mesh --scope user`); its assets ship inside the CLI package, not the connector, and\n its version tracks the CLI release so updates land.\n- **Identity-gated.** Connector code requires `COTAL_NAME` *or* `COTAL_LINK`. A plain\n `claude` with no `COTAL_*` env stays inert and never joins, so your own sessions in a\n repo do not appear as stray peers.\n- **Hands-free.** The dev-channels flag prints a one-time confirm prompt; the PTY runtime\n auto-clears it, so a supervised launch needs no keypress.\n\nInbound mesh messages arrive in context as\n`<channel source=\"cotal\" from=\"bob\" kind=\"dm\" \u2026>\u2026</channel>`: each meta key a tag\nattribute the agent can read for routing.\n\n## How messages reach the session\n\nDurable deliveries land in the connector's inbox from JetStream consumers\n([SPEC \xA78](../SPEC.md#8-nats--jetstream-binding)); live channel traffic can instead arrive\nthrough an at-most-once core subscription. A durable message sent while the agent is busy\nor offline waits on the stream. Two things move a message from inbox to model; one\ndelivers, the other only wakes:\n\n- **Hook drain (delivery).** `SessionStart` / `UserPromptSubmit` hooks read automatic inbox items and\n inject them as `additionalContext`. This is the single authoritative path: deterministic and works\n on any Claude Code build. Quiet ambient is excluded and stays buffered for `cotal_inbox`.\n A message is **acked only once the hook reply carrying it has cleared both legs of its journey**:\n the connector's control socket to the hook process (which gives up after 2s), and the hook\n process's own stdout to Claude Code (which it force-exits 1s after starting to write). The relay\n sends a receipt back down the control socket from that stdout write's callback, and only on a\n clean write (a runtime whose pipe has gone away fails it), and the connector treats that receipt,\n not its own socket write, as delivery. So a large injection killed mid-flush, or one written to a\n broken pipe, leaves the message un-acked and JetStream redelivers it. What this does *not* prove is\n that Claude Code read or applied the reply: a payload small enough to fit the pipe buffer is\n reported written the moment the kernel takes it. That residual is why the path errs toward\n at-least-once rather than treating a confirmed write as a confirmed read. Acking when\n the reply was merely *formatted* meant a lost reply was a lost message: it was already marked\n handled, so its own redelivery was silently acked on arrival.\n This errs toward **at-least-once**: if a reply lands but its confirmation does not, the batch is\n surfaced again and flagged as a possible repeat. A duplicate injection is noise; a buried DM stops\n the peer answering at all.\n- **Channel nudge (wake).** An arriving message fires a `notifications/claude/channel`\n event that wakes an *idle* session into a turn, so the drain runs *now* instead of at\n the next prompt. The nudge never acks anything. A nudge that the host rejects is retried with a\n bounded backoff while anything is still pending. For an idle session it is the only wake source,\n so dropping it means silence until someone types. If a nudge is lost anyway (a race in the host's\n channel startup), JetStream redelivery re-announces the unacked durable item through the same\n attention policy, so a durable message always wakes the session eventually. If the channel cannot\n run at all, delivery still waits for the next hook. Live-only traffic has no durable retry.\n\n**Two priority tiers.** A *directed* message (DM, anycast, or a channel message that\n`@mentions` us) always nudges. *Ambient* channel chatter does not nudge mid-turn; it\naccumulates, and the `Stop` \u2192 idle transition fires one batch nudge so the backlog drains\ntogether.\n\n**Constraints (accepted).** Channels are a Claude Code research preview (\u2265 v2.1.80;\npermission relay \u2265 v2.1.81): Anthropic auth only, admin-enabled on Team/Enterprise, and a\ncustom channel needs the `--dangerously-load-development-channels` launch flag. The hook\ndrain does not depend on any of that; the channel only adds \"wake me when idle.\"\n\nThe same channel also relays **tool-permission requests** onto the mesh, so a peer (a\nhuman at the CLI, a policy node) can approve or deny an agent's pending tool call through\nCotal rather than a per-terminal prompt.\n\n### Attention: how much traffic wakes you\n\nAn agent picks how aggressively peer traffic reaches it with\n`cotal_status({ attention })` (three modes, orthogonal to presence):\n\n| arrival | open (default) | dnd | focus |\n|---|---|---|---|\n| directed (dm / anycast) | wake + inject | wake + inject | wake + inject |\n| channel `@mention` | wake + inject | wake + inject | ack-drop; wake to *pull*; not injected |\n| ambient channel chatter | wake when idle; hold while working | never wakes; injects next turn | ack-drop; recall via `cotal_inbox` |\n\nPer-channel overrides refine this: **quiet** (delivered, never wakes; `@mention` still\nwakes) and **muted** (dropped on receive, mentions included; DMs/anycast unaffected), set\nwith `cotal_channel_mode` or as agent-file defaults (`quiet:` / `muted:`,\n[agent files](agent-files.md)). A per-channel override is the final word for that channel.\nQuiet ambient is pull-only: it never hitchhikes on a human prompt, DM, mention, or other\nconnector-driven turn. `cotal_inbox` explicitly surfaces and clears it. A quiet-channel\n`@mention` remains automatic and injects normally.\n\nThe local inbox is bounded. On pathological overflow it evicts pull-only items before automatic\ntraffic. If the bounded live/durable classification guard also fills, the connector fails closed:\notherwise-normal ambient becomes pull-only until restart. Muted hard-drop and normal focus recall\nstill take precedence. Focus also keeps a bounded exclusion list so mode toggles cannot recall\nquiet/muted traffic; if that safety bound fills, recall skips the affected channel and reports it\nas incomplete rather than risk resurfacing excluded content.\nIf the separate hard-drop disposition guard fills, channel traffic is dropped for the rest of the\nsession rather than risk a late copy bypassing an earlier muted/focus decision; DMs and anycast are\nunaffected.\n\nAttention is **advisory UX, not a boundary**: any peer can wake a dnd/focus agent by\nnaming it, and `muted` means \"I opted out of receiving\", not \"the channel is blocked\";\nthe broker still authorizes and delivers. Focus's real effect is shrinking the\nuntrusted-ambient injection surface (only subject-authenticated dm/anycast auto-inject).\nIt resets to **open** on `SessionStart`, so a restarted agent never stays silently deaf.\nYour attention is mirrored into presence so peers can see it.\n\n## Presence mapping\n\nThe connector wires a small subset of Claude Code hooks to presence states; presence is\ncoarse, and \"what it is doing\" rides on activity updates. Presence is **advisory**: a presence\npublish that fails (the endpoint mid-reconnect, say) is swallowed and never prevents the same hook\nfrom delivering messages or flushing held ones.\n\n| Hook | \u2192 state |\n|---|---|\n| `SessionStart` | `idle` (join; surfaces the inbox; captures the live model into `meta.model` when no pin) |\n| `UserPromptSubmit` | `working` (turn starts; surfaces the inbox) |\n| `PreToolUse` | no change; records *what* is about to run, so a permission wait can name it |\n| `Notification` (permission / elicitation) | `waiting` (blocked on a human: activity leads with the pending tool, e.g. `Bash: git push \u2026`) |\n| `Stop` / `StopFailure` | `idle` (turn done / died on an API error; flushes anything held while busy) |\n| `SessionEnd` | `offline` (graceful leave) |\n\nHooks are relayed over the connector's **authenticated** local control endpoint (per-user\nsocket + per-launch token, constant-time checked), so a local process that finds the path\nstill can't drive presence or stop the agent. The full Claude Code hook-event list lives\nwith the adapter:\n[`extensions/connector-claude-code`](../extensions/connector-claude-code/README.md).\n\n## Event plane\n\nA session launched with `cotal spawn --events` publishes a **structured** account of what it\ndid: run boundaries per turn, assistant text, reasoning, and each tool call with its arguments,\nits end, and its result. Not prose about the work, the work itself, in a vocabulary a program can\nread. Arming is `COTAL_EVENTS`, which the launcher sets for `--events` spawns; a personal session\nwith the plugin installed publishes nothing.\n\nThe channel is **`events.<owner>.<actor>`**, named after the session's principal. What the actor\nhalf is depends on the mesh, and the difference matters when you go looking for it: on a static mesh\nit is a key the manager allocated, never the display name, so two live agents sharing a display name\ndo not share a stream; on a user-auth mesh it is the agent's own name, because that is what the\nledger row is keyed on. Spelled out again with both halves below. The launch grants publish rights\non exactly that one channel. A spawn\nthat asks for a *different* agent's event channel is refused at the door rather than granted, since\nthat channel carries the session's tool inputs and outputs. The same rule runs on restart: a manager\nresume document that names another agent's event channel is refused rather than adopted, because the\nmanaged row is re-armed from that document and the credential is re-minted from the row.\n\nThe rule reads a **concrete** channel, two principal tokens and nothing else. A pattern such as\n`events.<owner>.>` is not an event channel to it and passes untouched, governed by ordinary ACL\nauthority: on a user mesh the delegation envelope, on a static mesh the spawning credential itself.\nThat is deliberate, because the pattern is the form an operator writes on purpose for an observer,\nand it is worth knowing rather than assuming the fence is total.\n\nTo let something else read a plane, grant it out of band. The refusal prints the command for the\nmesh it is running on, spelled out in full, and only that one.\n\nOn a **user-auth** mesh:\n\n```bash\ncotal actor grant <reader> --owner <owner> --scope '' --allow-subscribe 'events.<owner>.<actor>' --allow-publish ''\n```\n\nEvery field, deliberately. `actor grant` is an upsert of the whole row, and an omitted flag is not\n\"leave it alone\": it is the wide default, `>` read, `>` post, and `spawn,role:default` scope. A bare\n`cotal actor grant <reader>` therefore grants a reader of every channel in the space, which is the\nopposite of what a scoped watcher is for.\n\nOn a **static** mesh there is no actor ledger for `actor grant` to write to, and the refusal says\nso; mint the reader instead:\n\n```bash\ncotal mint watcher --profile agent --allow-subscribe 'events.<owner>.<actor>' --provision\n```\n\nThe **agent** profile, not the observer one. `mint` reads `--allow-subscribe` only for that\nprofile, and refuses it anywhere else: `--profile observer --allow-subscribe <channel>` exits\nnon-zero and writes no creds file, because the observer profile carries a fixed read set over the\nwhole chat plane, which is the opposite of what a scoped watcher is for. The agent profile also prints the lifecycle uid the\nreader needs, since an authed consuming endpoint refuses to start without one.\n\nTwo things a reader has to do that are not obvious, both on `CotalEndpoint`. It must pass the event\nchannel in `channels`, or the endpoint joins `general` by default and a scoped credential is refused\nthere. And it reads history with `readHistory(channel)`, the delivery daemon's mediated read, not\n`channelHistory(channel)`: a scoped credential is denied the ad-hoc consumer the direct read\ncreates, by design. `cotal console` and the web console already do both.\n\nThe `<owner>.<actor>` pair is the session's principal, not its display name. On a user-auth mesh\nthe actor half **is** the agent's name, so the channel is `events.<your-owner>.<agent-name>`. On a\nstatic mesh the owner half is the literal `local` and the actor is a key the manager allocated, so\nthe channel is `events.local.<key>`; the spawn reply carries that key as `id`. Note\nthat `cotal console` and the web console keep event channels out of their channel lists on purpose,\nsince a plane is a machine feed rather than a conversation; they draw the frames when you open the\nchannel by name.\n\nThe rule governs the manager's doors, which are the ones a caller other than you can reach. A\nforeground `cotal spawn` on your own machine mints from your own signing material, so it can still\ngrant any channel you name: that is the out-of-band grant, not a way around the rule.\n\nEvents are written to a per-session write-ahead log before they are published, so a hook that fires\nafter a restart resumes at the cursor it left rather than replaying or skipping, and a run that was\nopen when the session stopped is closed rather than left dangling.\n\nReading it: `cotal console` and the web console draw event frames directly. A frame carries no text\npart by design, so a surface that renders a message as flat text shows a marker instead of prose.\n\n**On a per-user-auth mesh, arming needs the spawner's grant to cover the channel.** The event\nchannel is added to the child's publish set, and delegation only narrows: an agent may hand down\na subset of what it holds and no more. So a peer-initiated `--events` spawn is refused unless the\nspawning identity's own grant already covers the child's event channel. The refusal prints the\nexact `cotal actor grant` command that widens it. An operator launch, whose chain reaches an\nadmin-scoped or roster row, is unaffected.\n\n## Resume an existing session (fork, never hijack)\n\n`--resume <session-id>` pulls an existing Claude session, its context and transcript,\ninto the mesh. It **forks**: Claude mints a *new* session id from that transcript\n(`--resume <id> --fork-session`), so the meshed agent gets its own session and the\noriginal is untouched.\n\n- `cotal spawn --resume <id>` (foreground) is the primary surface: the transcript is on\n *your* machine, and errors are Claude's own stderr, inline.\n- `--detach --resume <id>` works, with two differences: the id resolves against the\n **manager host's** `~/.claude` (you practically need `--cwd`), and the manager waits for\n a real outcome; `\u2713 started` means the agent *joined the mesh*, `\u2717 exited on launch`\n carries Claude's last output, and an uncertain launch (~30 s) is reported without\n tearing the agent down.\n- Resume is an **operator surface only**, deliberately not exposed on MCP `cotal_spawn`\n (a mesh peer naming host-local transcripts would widen `spawn` into transcript\n disclosure). Only the Claude connector supports it today; OpenCode and Hermes fail loud.\n- Needs a `claude` new enough for `--resume \u2026 --fork-session` (verified on 2.1.197).\n\n## Sharing your MCP servers\n\nIsolation is the default, but a meshed teammate sometimes genuinely needs one of your own\ntools (say, web search). The opt-in is the cotal config file\n(`~/.config/cotal/config.json`, or a space-local `.cotal/config.json` layered on top):\neach entry the familiar `.mcp.json` shape, secrets written as `${VAR}` references, never\nliterals ([full format](config.md)).\n\nAt launch the connector forwards *only* the named vars the chosen servers declare and\npasses the merged config as an owner-only temp file; `--strict-mcp-config` stays on, so\nonly cotal + the explicitly shared servers load. Scope per spawn with\n`--share-tools tavily,figma` (or `--share-tools none`).\n\nTwo caveats: sharing a server grants its credential to the agent (the var lives in the\nClaude process's environment, so share only when you're fine with that teammate holding\nthe key), and memory adds up, because a heavy server boots once per spawn, multiplied\nacross a team.\n\n## Feedback\n\n`cotal_feedback` works out of the box: without a key it posts to the public intake at\n`https://cotal.ai/v1/feedback` (needs a contact email: `COTAL_FEEDBACK_EMAIL`, then\n`git config user.email`, else the agent asks). Set `COTAL_FEEDBACK_KEY=fbk_<key>` in a\nbeta tester's environment to route to the keyed intake (`Authorization: Bearer`, identity\nderived from the key); `COTAL_FEEDBACK_URL` overrides either endpoint. The CLI can send\ntoo: `cotal feedback \"<summary>\" [--type bug]`. Each submission carries\n`origin: human | agent`, whether the tester asked, or the agent auto-reported a major\nissue.\n"
|
|
67288
|
+
"body": "# Connect Claude\n\n> **Guide** (informative) \xB7 **For:** operators \xB7 **Prereqs:** [Quickstart](getting-started.md)\n\nThe Claude Code connector turns a real `claude` session into a Cotal mesh peer. A bundled\nplugin inside the session joins NATS, maps lifecycle hooks to presence, and exposes the\nmesh tools. Nothing wraps Claude; it is an ordinary session that happens to be on the\nmesh.\n\nThe shared mesh runtime (agent, `cotal_*` tools, hook relay) lives in\n[`@cotal-ai/connector-core`](../extensions/connector-core); this connector is the thin\nClaude-specific adapter over it. Siblings: [OpenCode](connect-opencode.md) (beta),\n[Hermes](connect-hermes.md) (alpha), [pi](connect-pi.md) (alpha); the\n[Connectors](connectors.md) matrix compares them feature-by-feature.\n\n## Set up\n\n```bash\ncotal setup # one-time: installs the plugin, seeds one agent; launches nothing\ncotal up # brings up the mesh + delivery daemon + a detached manager\n```\n\n`cotal setup` installs the cotal plugin (so the repo's Claude sessions get the `cotal_*`\ntools) and seeds one `default` persona; `cotal up` brings up the local stack so\n`cotal spawn --detach` / `cotal_spawn` work right away. Re-running either is idempotent.\nThe install mechanics and the invariants behind them are in\n[setup internals](setup-internals.md).\n\n`cotal setup` also installs Cotal's authored Agent Skills (`SKILL.md`, the agentskills.io format) for\ncoordinating agent teams (today `team-topology`), from one canonical source, on two channels:\n\n- **Claude Code** gets a second, skills-only plugin, `cotal-skills`, from the same `cotal-mesh`\n marketplace, at **user scope** (machine-wide), and **independent of the mesh connector**: it carries no\n code and no core dependency, installs whenever Claude is on `PATH` (even with the connector removed),\n and uninstalls on its own with `claude plugin uninstall cotal-skills --scope user`. Its plugin version\n is stamped from the running CLI release, so an upgrade + `cotal setup` runs `claude plugin update` and\n the deployed install actually gets the new skill. `cotal setup` installs it on first run and on repeat\n runs, so upgraders are not left behind.\n- **Every other harness** (Codex, Cursor, OpenCode, Gemini CLI, Windsurf/Devin) reads the cross-vendor\n `~/.agents/skills/` directory convention, which has no remote index, so `cotal setup` **reconciles** it:\n it installs/updates each Cotal skill, backs up a copy you have edited to `SKILL.md.bak` before\n replacing it, and removes a Cotal skill that is no longer shipped. Only skills Cotal owns are touched;\n your own or third-party skills there are left alone. `cotal status` reports whether the drop is current,\n stale, missing, or has a retired skill to reconcile. This is the working cross-vendor path.\n\nCotal also generates an [Agent Skills discovery index](https://cotal.ai/.well-known/agent-skills/index.json)\non cotal.ai, but that RFC is still a draft with no harness consuming it yet, so it is a forward bet,\nnot a channel to rely on today.\n\n## Spawn a session\n\n```bash\ncotal spawn # foreground: your default agent, in this terminal\ncotal spawn dave --detach # supervised: the manager runs it in a PTY\n```\n\nA spawn resolves a persona from `.cotal/agents/<name>.md` ([agent files](agent-files.md));\n`--model`, `--variant`, `--cwd`, `--prompt`, ACL overrides, and `--share-tools` apply to\nboth forms ([run a mesh](run-a-mesh.md) has the full resolution rules). The session joins\nwith identity from its environment and auto-registers presence by the time it is\ninteractive.\n\nInside the session, the agent orients with one read-only tool, `cotal_orientation`: its\nidentity, the channels it reads and may post to, its capabilities, the tools available,\nwho's present, and unread counts. The full tool surface is the\n[MCP tool catalog](mcp-tools.md). In auth mode the team-supervision tools\n(`cotal_spawn` / `cotal_persona`) are injected **only** for personas declaring\n`capabilities: [spawn]` (the same grant that opens the privileged control subject), so an\nagent's toolset matches what it can actually invoke. Clearing retained history is\noperator-only ([run a mesh](run-a-mesh.md)), never an agent tool.\n\n## How it binds\n\nClaude Code exposes four integration surfaces, and three of them collapse into a single\ndual-purpose MCP server:\n\n| Surface | Mechanism |\n|---|---|\n| Outbound, ambient | `http` lifecycle hooks \u2192 POST to the connector (presence, activity) |\n| Outbound, deliberate | MCP tools `cotal_send` / `cotal_dm` / `cotal_anycast` (+ `cotal_feedback`) |\n| Inbound, pull | MCP tool `cotal_inbox` (same server) |\n| Inbound, push | Channel nudge + hook drain (below) |\n\nThe manager launches the *real* `claude` (no wrapper):\n\n```\nclaude --strict-mcp-config --mcp-config '{\"mcpServers\":{\"cotal\":{\u2026}}}' \\\n --dangerously-load-development-channels server:cotal\n# env: COTAL_SPACE, COTAL_NAME, COTAL_ROLE, COTAL_SERVERS, COTAL_CHANNEL=1\n```\n\n- **MCP isolation.** A spawned agent runs with **only** the cotal MCP server:\n `--strict-mcp-config` ignores every other MCP source, crucially the operator's personal\n `~/.claude.json` servers (several spawns each booting a heavy helper would starve\n memory). Share your own servers deliberately (see below).\n- **Installed, not `--plugin-dir`.** The plugin is installed once (`claude plugin install\n cotal@cotal-mesh --scope local`) because its hooks bind only to an *installed* plugin.\n In a clone the marketplace is the repo's `.claude-plugin/marketplace.json`; `cotal setup`\n (npx, no clone) materializes the same marketplace under `~/.cotal/claude-plugin/` (each plugin dir is\n rebuilt from scratch and atomically replaced, never merged, so no stale file rides in). The\n `cotal-skills` plugin installs from that same marketplace at user scope (`claude plugin install\n cotal-skills@cotal-mesh --scope user`); its assets ship inside the CLI package, not the connector, and\n its version tracks the CLI release so updates land.\n- **Identity-gated.** Connector code requires `COTAL_NAME` *or* `COTAL_LINK`. A plain\n `claude` with no `COTAL_*` env stays inert and never joins, so your own sessions in a\n repo do not appear as stray peers.\n- **Hands-free.** The dev-channels flag prints a one-time confirm prompt; the PTY runtime\n auto-clears it, so a supervised launch needs no keypress.\n\nInbound mesh messages arrive in context as\n`<channel source=\"cotal\" from=\"bob\" kind=\"dm\" \u2026>\u2026</channel>`: each meta key a tag\nattribute the agent can read for routing.\n\n## How messages reach the session\n\nDurable deliveries land in the connector's inbox from JetStream consumers\n([SPEC \xA78](../SPEC.md#8-nats--jetstream-binding)); live channel traffic can instead arrive\nthrough an at-most-once core subscription. A durable message sent while the agent is busy\nor offline waits on the stream. Two things move a message from inbox to model; one\ndelivers, the other only wakes:\n\n- **Hook drain (delivery).** `SessionStart` / `UserPromptSubmit` hooks read automatic inbox items and\n inject them as `additionalContext`. This is the single authoritative path: deterministic and works\n on any Claude Code build. Quiet ambient is excluded and stays buffered for `cotal_inbox`.\n A message is **acked only once the hook reply carrying it has cleared both legs of its journey**:\n the connector's control socket to the hook process (which gives up after 2s), and the hook\n process's own stdout to Claude Code (which it force-exits 1s after starting to write). The relay\n sends a receipt back down the control socket from that stdout write's callback, and only on a\n clean write (a runtime whose pipe has gone away fails it), and the connector treats that receipt,\n not its own socket write, as delivery. So a large injection killed mid-flush, or one written to a\n broken pipe, leaves the message un-acked and JetStream redelivers it. What this does *not* prove is\n that Claude Code read or applied the reply: a payload small enough to fit the pipe buffer is\n reported written the moment the kernel takes it. That residual is why the path errs toward\n at-least-once rather than treating a confirmed write as a confirmed read. Acking when\n the reply was merely *formatted* meant a lost reply was a lost message: it was already marked\n handled, so its own redelivery was silently acked on arrival.\n This errs toward **at-least-once**: if a reply lands but its confirmation does not, the batch is\n surfaced again and flagged as a possible repeat. A duplicate injection is noise; a buried DM stops\n the peer answering at all.\n- **Channel nudge (wake).** An arriving message fires a `notifications/claude/channel`\n event that wakes an *idle* session into a turn, so the drain runs *now* instead of at\n the next prompt. The nudge never acks anything. A nudge that the host rejects is retried with a\n bounded backoff while anything is still pending. For an idle session it is the only wake source,\n so dropping it means silence until someone types. If a nudge is lost anyway (a race in the host's\n channel startup), JetStream redelivery re-announces the unacked durable item through the same\n attention policy, so a durable message always wakes the session eventually. If the channel cannot\n run at all, delivery still waits for the next hook. Live-only traffic has no durable retry.\n\n**Two priority tiers.** A *directed* message (DM, anycast, or a channel message that\n`@mentions` us) always nudges. *Ambient* channel chatter does not nudge mid-turn; it\naccumulates, and the `Stop` \u2192 idle transition fires one batch nudge so the backlog drains\ntogether.\n\n**Constraints (accepted).** Channels are a Claude Code research preview (\u2265 v2.1.80;\npermission relay \u2265 v2.1.81): Anthropic auth only, admin-enabled on Team/Enterprise, and a\ncustom channel needs the `--dangerously-load-development-channels` launch flag. The hook\ndrain does not depend on any of that; the channel only adds \"wake me when idle.\"\n\nThe same channel also relays **tool-permission requests** onto the mesh, so a peer (a\nhuman at the CLI, a policy node) can approve or deny an agent's pending tool call through\nCotal rather than a per-terminal prompt.\n\n### Attention: how much traffic wakes you\n\nAn agent picks how aggressively peer traffic reaches it with\n`cotal_status({ attention })` (three modes, orthogonal to presence):\n\n| arrival | open (default) | dnd | focus |\n|---|---|---|---|\n| directed (dm / anycast) | wake + inject | wake + inject | wake + inject |\n| channel `@mention` | wake + inject | wake + inject | ack-drop; wake to *pull*; not injected |\n| ambient channel chatter | wake when idle; hold while working | never wakes; injects next turn | ack-drop; recall via `cotal_inbox` |\n\nPer-channel overrides refine this: **quiet** (delivered, never wakes; `@mention` still\nwakes) and **muted** (dropped on receive, mentions included; DMs/anycast unaffected), set\nwith `cotal_channel_mode` or as agent-file defaults (`quiet:` / `muted:`,\n[agent files](agent-files.md)). A per-channel override is the final word for that channel.\nQuiet ambient is pull-only: it never hitchhikes on a human prompt, DM, mention, or other\nconnector-driven turn. `cotal_inbox` explicitly surfaces and clears it. A quiet-channel\n`@mention` remains automatic and injects normally.\n\nThe local inbox is bounded. On pathological overflow it evicts pull-only items before automatic\ntraffic. If the bounded live/durable classification guard also fills, the connector fails closed:\notherwise-normal ambient becomes pull-only until restart. Muted hard-drop and normal focus recall\nstill take precedence. Focus also keeps a bounded exclusion list so mode toggles cannot recall\nquiet/muted traffic; if that safety bound fills, recall skips the affected channel and reports it\nas incomplete rather than risk resurfacing excluded content.\nIf the separate hard-drop disposition guard fills, channel traffic is dropped for the rest of the\nsession rather than risk a late copy bypassing an earlier muted/focus decision; DMs and anycast are\nunaffected.\n\nAttention is **advisory UX, not a boundary**: any peer can wake a dnd/focus agent by\nnaming it, and `muted` means \"I opted out of receiving\", not \"the channel is blocked\";\nthe broker still authorizes and delivers. Focus's real effect is shrinking the\nuntrusted-ambient injection surface (only subject-authenticated dm/anycast auto-inject).\nIt resets to **open** on `SessionStart`, so a restarted agent never stays silently deaf.\nYour attention is mirrored into presence so peers can see it.\n\n## Presence mapping\n\nThe connector wires a small subset of Claude Code hooks to presence states; presence is\ncoarse, and \"what it is doing\" rides on activity updates. Presence is **advisory**: a presence\npublish that fails (the endpoint mid-reconnect, say) is swallowed and never prevents the same hook\nfrom delivering messages or flushing held ones.\n\n| Hook | \u2192 state |\n|---|---|\n| `SessionStart` | `idle` (join; surfaces the inbox; captures the live model into `meta.model` when no pin) |\n| `UserPromptSubmit` | `working` (turn starts; surfaces the inbox) |\n| `PreToolUse` | no change; records *what* is about to run, so a permission wait can name it |\n| `Notification` (permission / elicitation) | `waiting` (blocked on a human: activity leads with the pending tool, e.g. `Bash: git push \u2026`) |\n| `Stop` / `StopFailure` | `idle` (turn done / died on an API error; flushes anything held while busy) |\n| `SessionEnd` | `offline` (graceful leave) |\n\nHooks are relayed over the connector's **authenticated** local control endpoint (per-user\nsocket + per-launch token, constant-time checked), so a local process that finds the path\nstill can't drive presence or stop the agent. The full Claude Code hook-event list lives\nwith the adapter:\n[`extensions/connector-claude-code`](../extensions/connector-claude-code/README.md).\n\n## Event plane\n\nA session launched with `cotal spawn --events` publishes a **structured** account of what it\ndid: run boundaries per turn, assistant text, reasoning, and each tool call with its arguments,\nits end, and its result. Not prose about the work, the work itself, in a vocabulary a program can\nread. Arming is `COTAL_EVENTS`, which the launcher sets for `--events` spawns; a personal session\nwith the plugin installed publishes nothing.\n\nThe channel is **`events.<owner>.<actor>`**, named after the session's principal. What the actor\nhalf is depends on the mesh, and the difference matters when you go looking for it: on a static mesh\nit is a key the manager allocated, never the display name, so two live agents sharing a display name\ndo not share a stream; on a user-auth mesh it is the agent's own name, because that is what the\nledger row is keyed on. Spelled out again with both halves below. The launch grants publish rights\non exactly that one channel. A spawn\nthat asks for a *different* agent's event channel is refused at the door rather than granted, since\nthat channel carries the session's tool inputs and outputs. The same rule runs on restart: a manager\nresume document that names another agent's event channel is refused rather than adopted, because the\nmanaged row is re-armed from that document and the credential is re-minted from the row.\n\nThe rule reads a **concrete** channel, two principal tokens and nothing else. A pattern such as\n`events.<owner>.>` is not an event channel to it and passes untouched, governed by ordinary ACL\nauthority: on a user mesh the delegation envelope, on a static mesh the spawning credential itself.\nThat is deliberate, because the pattern is the form an operator writes on purpose for an observer,\nand it is worth knowing rather than assuming the fence is total.\n\nTo let something else read a plane, grant it out of band. The refusal prints the command for the\nmesh it is running on, spelled out in full, and only that one.\n\nOn a **user-auth** mesh:\n\n```bash\ncotal actor grant <reader> --owner <owner> --scope '' --allow-subscribe 'events.<owner>.<actor>' --allow-publish ''\n```\n\nEvery field, deliberately. `actor grant` is an upsert of the whole row, and an omitted flag is not\n\"leave it alone\": it is the wide default, `>` read, `>` post, and `spawn,role:default` scope. A bare\n`cotal actor grant <reader>` therefore grants a reader of every channel in the space, which is the\nopposite of what a scoped watcher is for.\n\nOn a **static** mesh there is no actor ledger for `actor grant` to write to, and the refusal says\nso; mint the reader instead:\n\n```bash\ncotal mint watcher --profile agent --allow-subscribe 'events.<owner>.<actor>' --provision\n```\n\nThe **agent** profile, not the observer one. `mint` reads `--allow-subscribe` only for that\nprofile, and refuses it anywhere else: `--profile observer --allow-subscribe <channel>` exits\nnon-zero and writes no creds file, because the observer profile carries a fixed read set over the\nwhole chat plane, which is the opposite of what a scoped watcher is for. The agent profile also prints the lifecycle uid the\nreader needs, since an authed consuming endpoint refuses to start without one.\n\nTwo things a reader has to do that are not obvious, both on `CotalEndpoint`. It must pass the event\nchannel in `channels`, or the endpoint joins `general` by default and a scoped credential is refused\nthere. And it reads history with `readHistory(channel)`, the delivery daemon's mediated read, not\n`channelHistory(channel)`: a scoped credential is denied the ad-hoc consumer the direct read\ncreates, by design. `cotal console` and the web console already do both.\n\nThe `<owner>.<actor>` pair is the session's principal, not its display name. On a user-auth mesh\nthe actor half **is** the agent's name, so the channel is `events.<your-owner>.<agent-name>`. On a\nstatic mesh the owner half is the literal `local` and the actor is a key the manager allocated, so\nthe channel is `events.local.<key>`; the spawn reply carries that key as `id`. Note\nthat `cotal console` and the web console keep event channels out of their channel lists on purpose,\nsince a plane is a machine feed rather than a conversation; they draw the frames when you open the\nchannel by name.\n\nThe rule governs the manager's doors, which are the ones a caller other than you can reach. A\nforeground `cotal spawn` on your own machine mints from your own signing material, so it can still\ngrant any channel you name: that is the out-of-band grant, not a way around the rule.\n\nEvents are written to a per-session write-ahead log before they are published, so a hook that fires\nafter a restart resumes at the cursor it left rather than replaying or skipping, and a run that was\nopen when the session stopped is closed rather than left dangling.\n\nOne channel carries **every session of one agent**, because it is named after the principal and not\nafter the session. Alongside the per-session logs the connector keeps one small record per principal,\nholding the last sequence the broker assigned on that channel, so a new session continues the stream\nits predecessor left instead of starting again from nothing. Both live under the events state root\n(`COTAL_WORKSPACE_ROOT`), and neither is something you edit by hand.\n\nA **missing** record is not a fault: the connector rebuilds it from the session logs beside it,\nwhich is how an agent that was already running before this record existed keeps its stream. That\nrebuild stops if any one of those session logs is damaged. Unreadable, not valid JSON, and written\nfor a different principal all count, and so does a session directory or a log that is a link rather\nthan the real file the connector wrote, or a log that has more than one name. A tip taken from the\nrest would be too low, and it would stop publication later with nothing left to point at the cause.\nThe connector names the file instead, and the only way past it is the directory removal described\nbelow, under the same condition. A record that **disagrees with the broker** is a fault, and the\nconnector stops publishing and says why rather than guessing. A record that **moved while a session\nwas writing to it** is refused the same way: it means something else wrote the principal's record,\nand the connector reports which value it held and which the file holds rather than writing over the\nlater one. There is no command to clear it. The state is the principal's directory under the events\nroot, and clearing it by hand means removing that directory whole: the sequence, the cursor and the\nper-session logs only mean anything together, so removing part of it leaves a state the next start\nrefuses. Removing it is only half a remedy, and the half that comes first is the channel. The\ndirectory is where the agent's memory of the tip lives, not the tip itself, so on a channel that\nstill holds frames the next session opens expecting an empty one and stops on the same\ndisagreement, with the logs a tip could have been rebuilt from now gone. Purge the channel first,\nthen remove the directory.\n\nReading it: `cotal console` and the web console draw event frames directly. A frame carries no text\npart by design, so a surface that renders a message as flat text shows a marker instead of prose.\n\n**On a per-user-auth mesh, arming needs the spawner's grant to cover the channel.** The event\nchannel is added to the child's publish set, and delegation only narrows: an agent may hand down\na subset of what it holds and no more. So a peer-initiated `--events` spawn is refused unless the\nspawning identity's own grant already covers the child's event channel. The refusal prints the\nexact `cotal actor grant` command that widens it. An operator launch, whose chain reaches an\nadmin-scoped or roster row, is unaffected.\n\n## Resume an existing session (fork, never hijack)\n\n`--resume <session-id>` pulls an existing Claude session, its context and transcript,\ninto the mesh. It **forks**: Claude mints a *new* session id from that transcript\n(`--resume <id> --fork-session`), so the meshed agent gets its own session and the\noriginal is untouched.\n\n- `cotal spawn --resume <id>` (foreground) is the primary surface: the transcript is on\n *your* machine, and errors are Claude's own stderr, inline.\n- `--detach --resume <id>` works, with two differences: the id resolves against the\n **manager host's** `~/.claude` (you practically need `--cwd`), and the manager waits for\n a real outcome; `\u2713 started` means the agent *joined the mesh*, `\u2717 exited on launch`\n carries Claude's last output, and an uncertain launch (~30 s) is reported without\n tearing the agent down.\n- Resume is an **operator surface only**, deliberately not exposed on MCP `cotal_spawn`\n (a mesh peer naming host-local transcripts would widen `spawn` into transcript\n disclosure). Only the Claude connector supports it today; OpenCode and Hermes fail loud.\n- Needs a `claude` new enough for `--resume \u2026 --fork-session` (verified on 2.1.197).\n\n## Sharing your MCP servers\n\nIsolation is the default, but a meshed teammate sometimes genuinely needs one of your own\ntools (say, web search). The opt-in is the cotal config file\n(`~/.config/cotal/config.json`, or a space-local `.cotal/config.json` layered on top):\neach entry the familiar `.mcp.json` shape, secrets written as `${VAR}` references, never\nliterals ([full format](config.md)).\n\nAt launch the connector forwards *only* the named vars the chosen servers declare and\npasses the merged config as an owner-only temp file; `--strict-mcp-config` stays on, so\nonly cotal + the explicitly shared servers load. Scope per spawn with\n`--share-tools tavily,figma` (or `--share-tools none`).\n\nTwo caveats: sharing a server grants its credential to the agent (the var lives in the\nClaude process's environment, so share only when you're fine with that teammate holding\nthe key), and memory adds up, because a heavy server boots once per spawn, multiplied\nacross a team.\n\n## Feedback\n\n`cotal_feedback` works out of the box: without a key it posts to the public intake at\n`https://cotal.ai/v1/feedback` (needs a contact email: `COTAL_FEEDBACK_EMAIL`, then\n`git config user.email`, else the agent asks). Set `COTAL_FEEDBACK_KEY=fbk_<key>` in a\nbeta tester's environment to route to the keyed intake (`Authorization: Bearer`, identity\nderived from the key); `COTAL_FEEDBACK_URL` overrides either endpoint. The CLI can send\ntoo: `cotal feedback \"<summary>\" [--type bug]`. Each submission carries\n`origin: human | agent`, whether the tester asked, or the agent auto-reported a major\nissue.\n"
|
|
66960
67289
|
},
|
|
66961
67290
|
{
|
|
66962
67291
|
"slug": "connect-codex",
|
|
@@ -67942,8 +68271,8 @@ function registerCotalTools(server, agent, config2, source) {
|
|
|
67942
68271
|
|
|
67943
68272
|
// ../connector-core/dist/control.js
|
|
67944
68273
|
var import_node_net2 = require("node:net");
|
|
67945
|
-
var
|
|
67946
|
-
var
|
|
68274
|
+
var import_node_fs7 = require("node:fs");
|
|
68275
|
+
var import_node_crypto13 = require("node:crypto");
|
|
67947
68276
|
var HANDOFF_DEADLINE_MS = 5e3;
|
|
67948
68277
|
var LEGACY_WRITE_DEADLINE_MS = 5e3;
|
|
67949
68278
|
var MAX_FRAME_BYTES = 1 << 20;
|
|
@@ -67951,7 +68280,7 @@ var AUTH_DEADLINE_MS = 5e3;
|
|
|
67951
68280
|
function tokenMatches(presented, digest) {
|
|
67952
68281
|
if (typeof presented !== "string")
|
|
67953
68282
|
return false;
|
|
67954
|
-
return (0,
|
|
68283
|
+
return (0, import_node_crypto13.timingSafeEqual)((0, import_node_crypto13.createHash)("sha256").update(presented).digest(), digest);
|
|
67955
68284
|
}
|
|
67956
68285
|
function who(i) {
|
|
67957
68286
|
return i.fromRole ? `${i.fromName}/${i.fromRole}` : i.fromName;
|
|
@@ -68030,10 +68359,10 @@ function writeReply(sock, reply, awaitHandoff) {
|
|
|
68030
68359
|
}
|
|
68031
68360
|
function startControlServer(agent, endpoint, handle, opts = {}) {
|
|
68032
68361
|
const { path } = endpoint;
|
|
68033
|
-
const digest = (0,
|
|
68034
|
-
if (process.platform !== "win32" && (0,
|
|
68362
|
+
const digest = (0, import_node_crypto13.createHash)("sha256").update(endpoint.token).digest();
|
|
68363
|
+
if (process.platform !== "win32" && (0, import_node_fs7.existsSync)(path)) {
|
|
68035
68364
|
try {
|
|
68036
|
-
(0,
|
|
68365
|
+
(0, import_node_fs7.unlinkSync)(path);
|
|
68037
68366
|
} catch {
|
|
68038
68367
|
}
|
|
68039
68368
|
}
|
|
@@ -68099,8 +68428,8 @@ function startControlServer(agent, endpoint, handle, opts = {}) {
|
|
|
68099
68428
|
}
|
|
68100
68429
|
|
|
68101
68430
|
// src/mcp.ts
|
|
68102
|
-
var
|
|
68103
|
-
var
|
|
68431
|
+
var import_node_crypto14 = require("node:crypto");
|
|
68432
|
+
var import_node_path6 = require("node:path");
|
|
68104
68433
|
|
|
68105
68434
|
// src/hooks.ts
|
|
68106
68435
|
function toolDetail(name, input) {
|
|
@@ -68343,14 +68672,14 @@ function resultContent(raw) {
|
|
|
68343
68672
|
}
|
|
68344
68673
|
function createClaudeMapper(opts) {
|
|
68345
68674
|
const now = opts.now ?? (() => Date.now());
|
|
68346
|
-
let
|
|
68675
|
+
let open5 = null;
|
|
68347
68676
|
let runsOpened = 0;
|
|
68348
68677
|
let promptShaped = 0;
|
|
68349
68678
|
let refusedUnattributable = 0;
|
|
68350
68679
|
const closeOpenRun = (timestamp, stopReason) => {
|
|
68351
|
-
if (
|
|
68352
|
-
const runId =
|
|
68353
|
-
|
|
68680
|
+
if (open5 === null) return null;
|
|
68681
|
+
const runId = open5;
|
|
68682
|
+
open5 = null;
|
|
68354
68683
|
return {
|
|
68355
68684
|
runId,
|
|
68356
68685
|
events: [
|
|
@@ -68384,7 +68713,7 @@ function createClaudeMapper(opts) {
|
|
|
68384
68713
|
})
|
|
68385
68714
|
);
|
|
68386
68715
|
});
|
|
68387
|
-
return events2.length > 0 &&
|
|
68716
|
+
return events2.length > 0 && open5 !== null ? { runId: open5, events: events2 } : null;
|
|
68388
68717
|
}
|
|
68389
68718
|
const promptText = typeof content === "string" ? content : Array.isArray(content) ? content.filter((b) => b.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n") : null;
|
|
68390
68719
|
if (promptText === null) return null;
|
|
@@ -68397,7 +68726,7 @@ function createClaudeMapper(opts) {
|
|
|
68397
68726
|
}
|
|
68398
68727
|
const prior = closeOpenRun(ts);
|
|
68399
68728
|
const runId2 = opts.mintRunId();
|
|
68400
|
-
|
|
68729
|
+
open5 = runId2;
|
|
68401
68730
|
runsOpened += 1;
|
|
68402
68731
|
const messageId = `${uuid3}#0`;
|
|
68403
68732
|
const selfAuthored = turnSource !== "channel";
|
|
@@ -68421,7 +68750,7 @@ function createClaudeMapper(opts) {
|
|
|
68421
68750
|
};
|
|
68422
68751
|
}
|
|
68423
68752
|
if (entry.type !== "assistant" || !Array.isArray(entry.message?.content)) return null;
|
|
68424
|
-
const runId =
|
|
68753
|
+
const runId = open5;
|
|
68425
68754
|
entry.message.content.forEach((b, i) => {
|
|
68426
68755
|
const messageId = `${uuid3}#${i}`;
|
|
68427
68756
|
const meta3 = {
|
|
@@ -68470,9 +68799,9 @@ function createClaudeMapper(opts) {
|
|
|
68470
68799
|
return `agui-map: NO RUN OPENED, and it was a refusal, not an empty session \u2014 ${refusedUnattributable} of ${promptShaped} prompt-shaped record(s) carry neither an \`origin.kind\` this mapper enumerates nor \`promptSource: "sdk"\`, so none of them could be attributed and none opened a run. Every event downstream of a run is therefore absent BY DECISION. If these are real prompts, the harness has a provenance shape that has not been measured: measure it and add it to ORIGIN_RULE or ABSENT_ORIGIN_RULE deliberately.`;
|
|
68471
68800
|
};
|
|
68472
68801
|
const forgetOpenRun = (runId) => {
|
|
68473
|
-
if (
|
|
68802
|
+
if (open5 === runId) open5 = null;
|
|
68474
68803
|
};
|
|
68475
|
-
return { map: map2, closeOpenRun, openRun: () =>
|
|
68804
|
+
return { map: map2, closeOpenRun, openRun: () => open5, forgetOpenRun, diagnose };
|
|
68476
68805
|
}
|
|
68477
68806
|
|
|
68478
68807
|
// src/mcp.ts
|
|
@@ -68492,14 +68821,16 @@ async function main() {
|
|
|
68492
68821
|
events = new AguiEmitterHolder(
|
|
68493
68822
|
async (transcriptPath) => {
|
|
68494
68823
|
const workspaceRoot = resolveEventsStateRoot(process.env);
|
|
68495
|
-
const threadId = (0,
|
|
68824
|
+
const threadId = (0, import_node_path6.basename)(transcriptPath, ".jsonl");
|
|
68496
68825
|
const principal = principalKey(agent.ep.principal.owner, agent.ep.principal.actor).key;
|
|
68497
|
-
const { walPath } = await ensureEventWalDir({ workspaceRoot, space: config2.space, principal, threadId });
|
|
68826
|
+
const { walPath, subjectPath } = await ensureEventWalDir({ workspaceRoot, space: config2.space, principal, threadId });
|
|
68827
|
+
const subjectFrontier = await FileSubjectFrontier.open(subjectPath, { space: config2.space, principal });
|
|
68498
68828
|
const wal = await EventWal.open(walPath, { space: config2.space, threadId, principal, subjectMayExist: false });
|
|
68499
|
-
mapper = createClaudeMapper({ threadId, mintRunId: () => (0,
|
|
68829
|
+
mapper = createClaudeMapper({ threadId, mintRunId: () => (0, import_node_crypto14.randomUUID)() });
|
|
68500
68830
|
return AguiEmitter.start({
|
|
68501
68831
|
endpoint: agent.ep,
|
|
68502
68832
|
wal,
|
|
68833
|
+
subjectFrontier,
|
|
68503
68834
|
source: new JsonlFileSource(transcriptPath),
|
|
68504
68835
|
map: mapper.map
|
|
68505
68836
|
});
|