cotal-ai 0.96.0 → 0.96.1

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.
@@ -44,6 +44,24 @@ export async function registerViaDaemon(opts) {
44
44
  sock.destroy();
45
45
  }
46
46
  }
47
+ /** Ask the daemon to connect, disconnect or report its own service connection to `space`. */
48
+ export async function serviceViaDaemon(opts) {
49
+ const sock = await openVerified({ ...opts, timeoutMs: 5000 });
50
+ try {
51
+ sock.write(encode({ v: DAEMON_PROTOCOL_VERSION, id: randomUUID().replaceAll("-", ""), op: opts.op, space: opts.space }));
52
+ // A connect runs the register replay, the first renewal, the broker connection and `ready`, each
53
+ // bounded by its stock client (30 s); allow for all of them.
54
+ const reply = JSON.parse(await readLine(sock, 130_000));
55
+ if (!reply.ok)
56
+ return { ok: false, code: reply.error.code, message: reply.error.message };
57
+ if ("event" in reply || !("state" in reply.result) || !("space" in reply.result))
58
+ return { ok: false, code: "BAD_REPLY", message: "the daemon answered the service request with something else" };
59
+ return { ok: true, result: reply.result };
60
+ }
61
+ finally {
62
+ sock.destroy();
63
+ }
64
+ }
47
65
  function connectTo(path, timeoutMs) {
48
66
  return new Promise((resolve, reject) => {
49
67
  const s = netConnect(path);
@@ -11,7 +11,9 @@
11
11
  * directory where `cotal login` keeps it, and no token ever crosses the local endpoint. The
12
12
  * connection check (`connect.ts`) reads the space's public account identity and keeps the daemon's
13
13
  * own per-space identity, never a Manager's. The registration request (`register.ts`) sends one stock
14
- * `register` for that identity and reports the space's answer; approval stays with the space owner. It is a
14
+ * `register` for that identity and reports the space's answer; approval stays with the space owner. The
15
+ * service connection (`service.ts`) connects that approved identity as a stock service through the stock
16
+ * renewal source and reports it connected only once the broker and the space's ready both accept it. It is a
15
17
  * foreground foundation, not a resilient machine service: a daemon killed without cleanup leaves
16
18
  * its socket behind, and the next start refuses until the operator removes it (see the
17
19
  * ALREADY_RUNNING remedy). `state: "ready"` means the local control listener is bound and
@@ -26,7 +28,7 @@ import { peerCredentials } from "@cotal-ai/seat";
26
28
  import { normalizeIdpUrl } from "@cotal-ai/auth";
27
29
  import { EndpointRefusal, daemonPaths } from "./endpoint.js";
28
30
  import { serveDaemon } from "./server.js";
29
- import { connectCheckViaDaemon, loginViaDaemon, queryDaemon, registerViaDaemon } from "./client.js";
31
+ import { connectCheckViaDaemon, loginViaDaemon, queryDaemon, registerViaDaemon, serviceViaDaemon } from "./client.js";
30
32
  export const daemonFlags = [
31
33
  jsonFlag,
32
34
  plainFlag,
@@ -230,6 +232,43 @@ async function registerCommand(args) {
230
232
  ] },
231
233
  ] }));
232
234
  }
235
+ /** Connect, disconnect or read the daemon's own service connection to a space. Only `connected` (and
236
+ * `disconnected` for a disconnect) exits 0; every other state is a refusal naming why. */
237
+ async function serviceCommand(args, op) {
238
+ const presentation = presentationOf(args.values);
239
+ const space = args.values.space;
240
+ if (typeof space !== "string" || !space)
241
+ throw new CommandFailure("refused", "USAGE_INVALID", `cotal daemon ${op} needs --space <s>`);
242
+ let reply;
243
+ try {
244
+ reply = await serviceViaDaemon({ paths: daemonPaths(), peerUid, space, op });
245
+ }
246
+ catch (e) {
247
+ throw asFailure(e);
248
+ }
249
+ if (!reply.ok)
250
+ throw new CommandFailure("refused", "USAGE_INVALID", `the daemon refused the service request: ${reply.code} ${reply.message}`, { native: { code: reply.code } });
251
+ const r = reply.result;
252
+ const native = { native: { code: (r.state === "ended" && r.reason ? r.reason : r.state).toUpperCase().replaceAll("-", "_") } };
253
+ const ok = r.state === "connected" || (op === "service-disconnect" && r.state === "disconnected");
254
+ if (!ok) {
255
+ const code = r.state === "not-requested" || r.state === "not-registered" ? "TARGET_NOT_FOUND"
256
+ : r.state === "unsupported" ? "UNSUPPORTED"
257
+ : r.state === "not-signed-in" ? "AUTH_REQUIRED"
258
+ : r.state === "refused" || r.state === "ended" ? "PERMISSION_DENIED"
259
+ : r.state === "conflict" ? "CONFLICT"
260
+ : r.state === "approval-pending" || r.state === "connecting" ? "LIFECYCLE_BLOCKED"
261
+ : r.state === "disconnected" ? "TARGET_UNAVAILABLE"
262
+ : "DEPENDENCY_UNAVAILABLE";
263
+ throw new CommandFailure("refused", code, r.message, native);
264
+ }
265
+ writeResult(presentation, { command: `daemon ${op}`, target: { space }, result: r }, () => ({ sections: [
266
+ { title: r.state === "connected" ? "Connected" : "Disconnected", blocks: [
267
+ { kind: "fields", rows: [["Space", r.space], ...(r.instanceId ? [["Instance", r.instanceId]] : [])] },
268
+ { kind: "lines", lines: [{ text: r.message, tone: "hint" }] },
269
+ ] },
270
+ ] }));
271
+ }
233
272
  async function serve() {
234
273
  let running;
235
274
  try {
@@ -259,14 +298,16 @@ async function run(args) {
259
298
  return connectCheckCommand(args);
260
299
  if (mode === "register")
261
300
  return registerCommand(args);
262
- throw new CommandFailure("refused", "USAGE_INVALID", mode === undefined ? "cotal daemon needs a mode: status, serve, login, connect-check or register" : `unknown daemon mode ${JSON.stringify(mode)}`, { remedy: "cotal daemon status" });
301
+ if (mode === "service-connect" || mode === "service-disconnect" || mode === "service-status")
302
+ return serviceCommand(args, mode);
303
+ throw new CommandFailure("refused", "USAGE_INVALID", mode === undefined ? "cotal daemon needs a mode: status, serve, login, connect-check, register, service-connect, service-disconnect or service-status" : `unknown daemon mode ${JSON.stringify(mode)}`, { remedy: "cotal daemon status" });
263
304
  }
264
305
  const daemonCommand = {
265
306
  kind: "command",
266
307
  name: "daemon",
267
308
  group: "Manager",
268
- summary: "the local machine daemon (Linux): serve its control endpoint, read its status, sign it in, check and request its own registration with a space",
269
- positionals: "<status|serve|login|connect-check|register>",
309
+ summary: "the local machine daemon (Linux): serve its control endpoint, read its status, sign it in, check and request its own registration with a space, and connect it as its own service",
310
+ positionals: "<status|serve|login|connect-check|register|service-connect|service-disconnect|service-status>",
270
311
  flags: daemonFlags,
271
312
  subcommands: [
272
313
  { name: "status", summary: "ask the running daemon for its status over the local endpoint", flagNames: ["json", "plain"], outputs: ["json", "plain"], reads: true, prepareMeshTarget: false },
@@ -274,9 +315,12 @@ const daemonCommand = {
274
315
  { name: "login", summary: "sign the running daemon in to a provider with device approval; the daemon keeps the session", flagNames: ["idp", "client-id", "json", "plain"], outputs: ["json", "plain"], prepareMeshTarget: false },
275
316
  { name: "connect-check", summary: "ask the running daemon whether it could register with a space as its own service; registers nothing", flagNames: ["space", "json", "plain"], outputs: ["json", "plain"], reads: true, prepareMeshTarget: false },
276
317
  { name: "register", summary: "ask the running daemon to request its own service registration with a space; the space owner must approve it, and it does not connect", flagNames: ["space", "json", "plain"], outputs: ["json", "plain"], prepareMeshTarget: false },
318
+ { name: "service-connect", summary: "connect the running daemon to a space as its own approved service; succeeds only when the broker accepts the connection and the space accepts it as ready", flagNames: ["space", "json", "plain"], outputs: ["json", "plain"], prepareMeshTarget: false },
319
+ { name: "service-disconnect", summary: "close the running daemon's service connection to a space", flagNames: ["space", "json", "plain"], outputs: ["json", "plain"], prepareMeshTarget: false },
320
+ { name: "service-status", summary: "read the running daemon's service connection to a space, without contacting it", flagNames: ["space", "json", "plain"], outputs: ["json", "plain"], reads: true, prepareMeshTarget: false },
277
321
  ],
278
322
  prepareMeshTarget: false,
279
- examples: ["cotal daemon serve", "cotal daemon status", "cotal daemon status --json", "cotal daemon login --idp https://example.org/api/auth", "cotal daemon connect-check --space acme", "cotal daemon register --space acme"],
323
+ examples: ["cotal daemon serve", "cotal daemon status", "cotal daemon status --json", "cotal daemon login --idp https://example.org/api/auth", "cotal daemon connect-check --space acme", "cotal daemon register --space acme", "cotal daemon service-connect --space acme", "cotal daemon service-status --space acme"],
280
324
  run,
281
325
  };
282
326
  registry.register(daemonCommand);
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { normalizeIdpUrl } from "@cotal-ai/auth";
15
15
  import { DAEMON_PROTOCOL_VERSION, MAX_LINE_BYTES } from "./endpoint.js";
16
- export const DAEMON_OPS = ["status", "login-start", "login-cancel", "connect-check", "register"];
16
+ export const DAEMON_OPS = ["status", "login-start", "login-cancel", "connect-check", "register", "service-connect", "service-disconnect", "service-status"];
17
17
  /** The fields each op takes besides `v`, `id` and `op`. Required ones must be present. */
18
18
  const OP_FIELDS = {
19
19
  status: { required: [], optional: [] },
@@ -21,6 +21,9 @@ const OP_FIELDS = {
21
21
  "login-cancel": { required: ["attempt"], optional: [] },
22
22
  "connect-check": { required: ["space"], optional: [] },
23
23
  register: { required: ["space"], optional: [] },
24
+ "service-connect": { required: ["space"], optional: [] },
25
+ "service-disconnect": { required: ["space"], optional: [] },
26
+ "service-status": { required: ["space"], optional: [] },
24
27
  };
25
28
  const ENVELOPE_KEYS = new Set(["v", "id", "op"]);
26
29
  const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
@@ -65,7 +68,7 @@ export function parseRequest(line) {
65
68
  return bad(id, "attempt must be the 22-character attempt id from login-prompt");
66
69
  return { ok: true, request: { v, id, op, attempt: obj.attempt } };
67
70
  }
68
- if (op === "connect-check" || op === "register") {
71
+ if (op === "connect-check" || op === "register" || op === "service-connect" || op === "service-disconnect" || op === "service-status") {
69
72
  if (typeof obj.space !== "string" || !SPACE_PATTERN.test(obj.space))
70
73
  return bad(id, "space must be 1-64 characters of [A-Za-z0-9_-]");
71
74
  return { ok: true, request: { v, id, op, space: obj.space } };
@@ -70,6 +70,21 @@ const NOT_SIGNED_IN = "this machine's sign-in was not accepted by the space's pr
70
70
  const NOT_SENT = "the daemon could not send the registration request; nothing was sent and nothing is connected";
71
71
  const UNUSABLE = "the space answered the registration with something the daemon does not use; run the same command again";
72
72
  export async function registerDaemon(space, home = homeCotalDir()) {
73
+ const sent = await sendRegister(space, home);
74
+ return "outcome" in sent ? sent : registerAnswer(sent.result, sent.ids);
75
+ }
76
+ /** The stock witness this daemon's own registration holds in `space`, read by replaying its persisted
77
+ * register request unchanged: the same requestId and body, so no new key or registration. Anything
78
+ * but `registered` is returned as the daemon's register outcome. */
79
+ export async function daemonWitness(space, home = homeCotalDir()) {
80
+ const sent = await sendRegister(space, home);
81
+ if ("outcome" in sent)
82
+ return sent;
83
+ if (sent.result.state === "registered")
84
+ return { witness: sent.result.witness };
85
+ return registerAnswer(sent.result, sent.ids);
86
+ }
87
+ async function sendRegister(space, home) {
73
88
  const ready = await connectCheck(space, home);
74
89
  if (ready.outcome !== "ready-to-register")
75
90
  return ready;
@@ -109,7 +124,7 @@ export async function registerDaemon(space, home = homeCotalDir()) {
109
124
  return { outcome: "not-signed-in", space, message: NOT_SIGNED_IN };
110
125
  return { outcome: "refused", space, message: NOT_SENT };
111
126
  }
112
- return registerAnswer(result, ids);
127
+ return { result, ids };
113
128
  }
114
129
  /** The daemon's outcome for one stock register answer. */
115
130
  export function registerAnswer(result, ids) {
@@ -17,6 +17,7 @@ import { encode, parseRequest } from "./protocol.js";
17
17
  import { createLoginCoordinator } from "./login.js";
18
18
  import { connectCheck } from "./connect.js";
19
19
  import { registerDaemon } from "./register.js";
20
+ import { createServiceConnections } from "./service.js";
20
21
  import { homeCotalDir } from "@cotal-ai/workspace";
21
22
  /** Requests one connection may send. A sign-in needs two (start, cancel); status polling a few. */
22
23
  export const MAX_REQUESTS_PER_CONNECTION = 64;
@@ -42,9 +43,10 @@ export async function serveDaemon(opts) {
42
43
  const { paths } = opts;
43
44
  const dirOutcome = ensureDir(paths.dir, uid);
44
45
  refuseExisting(paths.socket);
45
- const counters = { accepted: 0, peerRejected: 0, framesDecoded: 0, requestsRefused: 0, statusAnswered: 0, loginsStarted: 0, loginsRefused: 0, cancelsAnswered: 0, connectChecksAnswered: 0, registersAnswered: 0 };
46
+ const counters = { accepted: 0, peerRejected: 0, framesDecoded: 0, requestsRefused: 0, statusAnswered: 0, loginsStarted: 0, loginsRefused: 0, cancelsAnswered: 0, connectChecksAnswered: 0, registersAnswered: 0, serviceAnswered: 0 };
46
47
  const machineHome = opts.sessionDir ?? homeCotalDir();
47
48
  const login = opts.login ?? createLoginCoordinator({ sessionDir: machineHome });
49
+ const services = createServiceConnections(machineHome);
48
50
  const startedAt = new Date();
49
51
  const status = () => ({
50
52
  state: "ready",
@@ -128,6 +130,16 @@ export async function serveDaemon(opts) {
128
130
  });
129
131
  return;
130
132
  }
133
+ if (req.op === "service-connect" || req.op === "service-disconnect" || req.op === "service-status") {
134
+ const run = req.op === "service-status" ? Promise.resolve(services.status(req.space))
135
+ : (req.op === "service-connect" ? services.connect(req.space) : services.disconnect(req.space))
136
+ .catch(() => ({ space: req.space, state: "unavailable", message: "the daemon could not complete the request; nothing is connected" }));
137
+ void run.then((result) => {
138
+ counters.serviceAnswered++;
139
+ return write({ v: DAEMON_PROTOCOL_VERSION, id: req.id, ok: true, result });
140
+ });
141
+ return;
142
+ }
131
143
  if (req.op === "login-cancel") {
132
144
  void login.cancel(req.attempt).then((result) => {
133
145
  counters.cancelsAnswered++;
@@ -231,6 +243,8 @@ export async function serveDaemon(opts) {
231
243
  closed ??= (async () => {
232
244
  // An in-flight sign-in is cancelled through the stock fence before the listener goes away.
233
245
  await login.close();
246
+ // Every service connection, its endpoint and its renewal source close before the socket does.
247
+ await services.close();
234
248
  for (const s of sockets)
235
249
  s.destroy();
236
250
  await new Promise((r) => server.close(() => r()));
@@ -0,0 +1,339 @@
1
+ /**
2
+ * The daemon's own service connection to a space (A2c): its registered `ai.cotal.machine` instance,
3
+ * connected as a stock service and accepted by the space as ready.
4
+ *
5
+ * - The witness comes from replaying the daemon's persisted register request unchanged
6
+ * (`daemonWitness`): no new key, no new registration, no changed body. Without a register request
7
+ * nothing is sent.
8
+ * - The credentials come from the stock renewal source (`AuthProvider.serviceEndpointRenewal`) over
9
+ * the daemon's private store in the machine home. Only the renewal and ready records live there;
10
+ * the source's other reads (the space's sentinel) come from the space's own workspace store, which
11
+ * the daemon never writes.
12
+ * - The connection is a real `CotalEndpoint` with the source as its bearer function, admitted by the
13
+ * space's native service callout. Its reconnect and refresh schedule is the endpoint's own.
14
+ * - `connected` means both: the endpoint reports its broker connection up, and the space answered
15
+ * stock `ready` for this witness. The broker connection is read from the endpoint's `transport`
16
+ * edge (a transient loss while nats.js reconnects) and its `connection` edge (a rebuild, teardown
17
+ * or close). Either going down withdraws `connected` at once, as `connecting`; it is claimed again
18
+ * only after the broker is back and the space answers a fresh `ready`.
19
+ *
20
+ * No liveness command is served: the registered `machine-status` command has void output and a
21
+ * service connection gets no stock serve grant (see the connect contract). No token, proof or
22
+ * provider text leaves this module; every message is fixed text.
23
+ */
24
+ import { join } from "node:path";
25
+ import { existsSync } from "node:fs";
26
+ import { randomBytes } from "node:crypto";
27
+ import { CotalEndpoint, resolveAuthProvider, ServiceAuthorityTransportError, serviceWitnessDigest, } from "@cotal-ai/core";
28
+ import { FsSecretStore, findMesh, homeCotalDir, spaceSegment, userAuthStateDir, workspaceSecretStore } from "@cotal-ai/workspace";
29
+ import { daemonRegisterFile, daemonWitness } from "./register.js";
30
+ const SENTENCE = {
31
+ "not-requested": "this daemon has not requested registration with this space; run `cotal daemon register --space <s>` first",
32
+ "not-registered": "this space is not registered on this machine; add it with `cotal meshes add`",
33
+ "not-signed-in": "this machine is not signed in to the space's provider; run `cotal daemon login --idp <url>`",
34
+ unsupported: "the space's auth service does not host service endpoints, so the daemon cannot connect to it",
35
+ "approval-pending": "the space owner has not approved this daemon yet, or the space is still registering it; nothing is connected",
36
+ refused: "the space refused this daemon's registration; nothing is connected",
37
+ conflict: "this daemon's registration does not match what the space holds; nothing is connected",
38
+ connecting: "the daemon is connecting to the space; it is not connected yet",
39
+ connected: "connected: the broker accepted this daemon's service connection and the space accepted it as ready",
40
+ unavailable: "the space or its broker could not be reached; nothing is connected, and connecting again retries the same requests",
41
+ ended: "the space ended this daemon's service authority; nothing is connected, and connecting again will not reconnect it",
42
+ disconnected: "not connected",
43
+ };
44
+ /** Codes reported by name when the space ends the connection. Any other is `unrecognized`. */
45
+ const ENDED = new Set(["expired", "approval-revoked", "root-revoked", "stale-witness", "permission-denied", "unauthenticated", "proof-mismatch",
46
+ "unsupported", "conflict", "resource-exhausted", "registration-pending", "bad-request", "registration-active", "approval-mismatch", "not-found"]);
47
+ /** The renewal source's own record keys go to the daemon's private store; every other key the source
48
+ * reads (the space's client sentinel) comes from the space's workspace store, read only. */
49
+ function daemonServiceStore(home, space, workspace) {
50
+ const own = new FsSecretStore(join(home, "daemon", spaceSegment(space), "service-store"));
51
+ const ownKey = (k) => k.startsWith(`auth/${spaceSegment(space)}/service-renewal/`) || k.startsWith("daemon-ready/");
52
+ return {
53
+ get identity() { return own.identity; },
54
+ get: (k) => (ownKey(k) ? own.get(k) : workspace.get(k)),
55
+ put: (k, v) => { if (!ownKey(k))
56
+ throw new Error("the daemon writes only its own service records"); return own.put(k, v); },
57
+ create: (k, v) => { if (!ownKey(k))
58
+ throw new Error("the daemon writes only its own service records"); return own.create(k, v); },
59
+ delete: (k) => { if (!ownKey(k))
60
+ throw new Error("the daemon writes only its own service records"); return own.delete(k); },
61
+ };
62
+ }
63
+ const HEX32 = /^[0-9a-f]{32}$/;
64
+ const readyKey = (witness) => `daemon-ready/${serviceWitnessDigest(witness).slice("sha256:".length)}.json`;
65
+ const readyId = (raw) => {
66
+ if (raw === undefined)
67
+ return undefined;
68
+ try {
69
+ const o = JSON.parse(raw);
70
+ return o.v === 1 && typeof o.requestId === "string" && HEX32.test(o.requestId) ? o.requestId : undefined;
71
+ }
72
+ catch {
73
+ return undefined;
74
+ }
75
+ };
76
+ /** The one persisted ready request id for `witness`, minted and created before its first use.
77
+ * `undefined` when the store fails or holds a record that is not one (never its text). */
78
+ async function readyRequestId(store, witness) {
79
+ const key = readyKey(witness);
80
+ try {
81
+ const stored = await store.get(key);
82
+ if (stored !== undefined)
83
+ return readyId(stored);
84
+ const minted = randomBytes(16).toString("hex");
85
+ if (await store.create(key, JSON.stringify({ v: 1, requestId: minted })))
86
+ return minted;
87
+ return readyId(await store.get(key));
88
+ }
89
+ catch {
90
+ return undefined;
91
+ }
92
+ }
93
+ export function createServiceConnections(home = homeCotalDir()) {
94
+ const live = new Map();
95
+ const queue = new Map();
96
+ let closed = false;
97
+ const result = (space) => {
98
+ const l = live.get(space);
99
+ const state = l?.state ?? "disconnected";
100
+ return { space, state, message: SENTENCE[state], ...(l?.instanceId ? { instanceId: l.instanceId } : {}),
101
+ ...(state === "ended" && l?.reason ? { reason: l.reason } : {}) };
102
+ };
103
+ /** One operation per space at a time: concurrent connects share the outcome of the first. */
104
+ const serial = (space, op) => {
105
+ const next = (queue.get(space) ?? Promise.resolve()).then(op, op);
106
+ queue.set(space, next.catch(() => { }));
107
+ return next;
108
+ };
109
+ /** State changes from endpoint callbacks between awaits; read it fresh. */
110
+ const stateOf = (l) => l.state;
111
+ async function teardown(l) {
112
+ l.closing ??= (async () => {
113
+ const ep = l.endpoint, src = l.source;
114
+ l.endpoint = undefined;
115
+ l.source = undefined;
116
+ l.brokerUp = false;
117
+ await ep?.stop().catch(() => { });
118
+ await src?.close().catch(() => { });
119
+ })();
120
+ await l.closing;
121
+ l.closing = undefined;
122
+ }
123
+ const fromRegister = (r) => {
124
+ switch (r.outcome) {
125
+ case "approval-required":
126
+ case "pending": return "approval-pending";
127
+ case "refused": return "reason" in r ? "refused" : "unavailable";
128
+ case "not-signed-in": return "not-signed-in";
129
+ case "not-registered": return "not-registered";
130
+ case "not-user-auth":
131
+ case "unsupported-by-space": return "unsupported";
132
+ case "account-mismatch":
133
+ case "conflict": return "conflict";
134
+ default: return "unavailable";
135
+ }
136
+ };
137
+ /** Ask the space to accept this witness as ready, on a fresh access bearer from the source, under
138
+ * one persisted ready request per witness. Sets `connected` only if the broker is still up. */
139
+ async function ready(l, space, witness, store, dir) {
140
+ const provider = resolveAuthProvider();
141
+ const requestId = await readyRequestId(store, witness);
142
+ if (requestId === undefined) {
143
+ l.state = "unavailable";
144
+ return;
145
+ }
146
+ let token;
147
+ try {
148
+ token = await l.source.bearer();
149
+ }
150
+ catch {
151
+ return sourceFailed(l);
152
+ }
153
+ let answer;
154
+ try {
155
+ answer = (await provider.serviceEndpointAuthority({ store, dir, request: { v: 1, kind: "service-endpoint-authority", operation: "ready", requestId,
156
+ space, accountPublicKey: witness.accountPublicKey, endpoint: witness.endpoint, instanceId: witness.instanceId, witness },
157
+ proof: { kind: "service-access", token } })).result;
158
+ }
159
+ catch (e) {
160
+ if (!(e instanceof ServiceAuthorityTransportError)) { /* a local failure: no claim */ }
161
+ l.state = "unavailable";
162
+ return;
163
+ }
164
+ if (answer.state === "ready") {
165
+ l.state = l.brokerUp && l.endpoint ? "connected" : "connecting";
166
+ return;
167
+ }
168
+ if (answer.state === "denied" && answer.reason === "rate-limited") {
169
+ l.state = "unavailable";
170
+ return;
171
+ }
172
+ l.state = "ended";
173
+ l.reason = answer.state === "denied" && ENDED.has(answer.reason) ? answer.reason : "unrecognized";
174
+ await teardown(l);
175
+ }
176
+ /** Start a `ready` for the current broker connection, at most one at a time. Never rejects: a
177
+ * failure leaves the state not connected. */
178
+ function readyOnce(l, space, witness, store, dir) {
179
+ l.readying ??= ready(l, space, witness, store, dir)
180
+ .catch(() => { if (l.state !== "ended")
181
+ l.state = "unavailable"; })
182
+ .finally(() => { l.readying = undefined; });
183
+ return l.readying;
184
+ }
185
+ function sourceFailed(l) {
186
+ const s = l.source?.state();
187
+ if (s?.state === "ended") {
188
+ l.state = "ended";
189
+ l.reason = ENDED.has(s.reason) ? s.reason : "unrecognized";
190
+ }
191
+ else
192
+ l.state = "unavailable";
193
+ }
194
+ /** Any throw inside a connect leaves nothing running: the endpoint and source are torn down and
195
+ * the answer is a fixed `unavailable`. */
196
+ async function connectOnce(space) {
197
+ try {
198
+ return await connectInner(space);
199
+ }
200
+ catch {
201
+ const l = live.get(space);
202
+ if (l) {
203
+ await teardown(l).catch(() => { });
204
+ if (l.state !== "ended")
205
+ l.state = "unavailable";
206
+ }
207
+ return result(space);
208
+ }
209
+ }
210
+ async function connectInner(space) {
211
+ if (closed)
212
+ return { space, state: "disconnected", message: SENTENCE.disconnected };
213
+ const current = live.get(space);
214
+ if (current?.state === "ended")
215
+ return result(space);
216
+ if (current?.endpoint && current.state === "connected")
217
+ return result(space);
218
+ if (current)
219
+ await teardown(current);
220
+ const l = current ?? { state: "connecting", brokerUp: false };
221
+ l.state = "connecting";
222
+ l.reason = undefined;
223
+ live.set(space, l);
224
+ if (!existsSync(daemonRegisterFile(space, home))) {
225
+ l.state = "not-requested";
226
+ return result(space);
227
+ }
228
+ const entry = findMesh(space);
229
+ if (!entry || entry.mode !== "user" || !entry.userAuth) {
230
+ l.state = entry ? "unsupported" : "not-registered";
231
+ return result(space);
232
+ }
233
+ const w = await daemonWitness(space, home);
234
+ if ("outcome" in w) {
235
+ l.state = fromRegister(w);
236
+ return result(space);
237
+ }
238
+ const witness = w.witness;
239
+ l.instanceId = witness.instanceId;
240
+ const provider = resolveAuthProvider();
241
+ if (!provider.serviceEndpointRenewal || !provider.serviceEndpointAuthority) {
242
+ l.state = "unsupported";
243
+ return result(space);
244
+ }
245
+ const store = daemonServiceStore(home, space, workspaceSecretStore(entry.root));
246
+ const dir = userAuthStateDir(entry.root, space, { readOnly: true });
247
+ const source = provider.serviceEndpointRenewal({ store, dir, witness });
248
+ l.source = source;
249
+ try {
250
+ await source.bearer();
251
+ }
252
+ catch {
253
+ sourceFailed(l);
254
+ await teardown(l);
255
+ return result(space);
256
+ }
257
+ const onEnd = () => {
258
+ // The source ends inside the endpoint's own refresh: tear down after that call returns.
259
+ if (source.state().state === "ended" && l.source === source) {
260
+ sourceFailed(l);
261
+ setImmediate(() => void teardown(l));
262
+ }
263
+ };
264
+ const endpoint = new CotalEndpoint({
265
+ space, servers: entry.server, sentinelCreds: source.sentinelCreds,
266
+ bearer: async () => { try {
267
+ return await source.bearer();
268
+ }
269
+ finally {
270
+ onEnd();
271
+ } },
272
+ card: { name: "cotal-daemon", kind: "endpoint", owner: witness.principal.owner, actor: witness.principal.actor },
273
+ channels: [], registerPresence: false, watchPresence: false, watchChannels: false, consume: false,
274
+ });
275
+ // Conditions the endpoint survives (renewal retries, reconnects) are not reported: their text can
276
+ // carry provider wording. State below is derived from the connection edge and the space's answers.
277
+ endpoint.on("warning", () => { });
278
+ endpoint.on("error", () => { });
279
+ // The broker edge while nats.js reconnects on its own: no claim while it is down.
280
+ endpoint.on("transport", (e) => {
281
+ if (l.endpoint !== endpoint)
282
+ return;
283
+ l.brokerUp = e.connected;
284
+ if (!e.connected) {
285
+ if (l.state === "connected")
286
+ l.state = "connecting";
287
+ return;
288
+ }
289
+ if (l.state !== "ended")
290
+ void readyOnce(l, space, witness, store, dir);
291
+ });
292
+ endpoint.on("connection", (e) => {
293
+ if (l.endpoint !== endpoint)
294
+ return;
295
+ l.brokerUp = e.connected;
296
+ if (!e.connected) {
297
+ if (l.state === "connected")
298
+ l.state = "connecting";
299
+ return;
300
+ }
301
+ if (l.state !== "ended")
302
+ void readyOnce(l, space, witness, store, dir);
303
+ });
304
+ l.endpoint = endpoint;
305
+ try {
306
+ await endpoint.start();
307
+ }
308
+ catch {
309
+ if (stateOf(l) !== "ended")
310
+ l.state = "unavailable";
311
+ await teardown(l);
312
+ return result(space);
313
+ }
314
+ l.brokerUp = true;
315
+ await readyOnce(l, space, witness, store, dir);
316
+ const after = stateOf(l);
317
+ if (after !== "connected" && after !== "ended") {
318
+ await teardown(l);
319
+ }
320
+ return result(space);
321
+ }
322
+ return {
323
+ connect: (space) => serial(space, () => connectOnce(space)),
324
+ disconnect: (space) => serial(space, async () => {
325
+ const l = live.get(space);
326
+ if (l) {
327
+ await teardown(l);
328
+ live.delete(space);
329
+ }
330
+ return { space, state: "disconnected", message: SENTENCE.disconnected };
331
+ }),
332
+ status: (space) => result(space),
333
+ async close() {
334
+ closed = true;
335
+ await Promise.all([...live.values()].map((l) => teardown(l)));
336
+ live.clear();
337
+ },
338
+ };
339
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cotal-ai",
3
- "version": "0.96.0",
3
+ "version": "0.96.1",
4
4
  "description": "Cotal — lateral agent coordination over NATS. Run `npx cotal-ai` for the CLI.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -33,26 +33,26 @@
33
33
  "node": ">=22"
34
34
  },
35
35
  "dependencies": {
36
- "@cotal-ai/auth": "0.96.0",
37
- "@cotal-ai/cli": "0.96.0",
38
- "@cotal-ai/connector-core": "0.96.0",
39
- "@cotal-ai/core": "0.96.0",
40
- "@cotal-ai/delivery": "0.96.0",
41
- "@cotal-ai/manager": "0.96.0",
42
- "@cotal-ai/runtime": "0.96.0",
43
- "@cotal-ai/seat": "0.96.0",
44
- "@cotal-ai/workspace": "0.96.0"
36
+ "@cotal-ai/auth": "0.96.1",
37
+ "@cotal-ai/cli": "0.96.1",
38
+ "@cotal-ai/connector-core": "0.96.1",
39
+ "@cotal-ai/core": "0.96.1",
40
+ "@cotal-ai/delivery": "0.96.1",
41
+ "@cotal-ai/manager": "0.96.1",
42
+ "@cotal-ai/runtime": "0.96.1",
43
+ "@cotal-ai/seat": "0.96.1",
44
+ "@cotal-ai/workspace": "0.96.1"
45
45
  },
46
46
  "devDependencies": {
47
- "@cotal-ai/connector-claude-code": "0.96.0",
48
- "@cotal-ai/connector-codex": "0.96.0",
49
- "@cotal-ai/connector-hermes": "0.96.0",
50
- "@cotal-ai/connector-jcode": "0.96.0",
51
- "@cotal-ai/connector-opencode": "0.96.0",
52
- "@cotal-ai/linear": "0.96.0",
53
- "@cotal-ai/pi": "0.96.0",
47
+ "@cotal-ai/connector-claude-code": "0.96.1",
48
+ "@cotal-ai/connector-codex": "0.96.1",
49
+ "@cotal-ai/connector-hermes": "0.96.1",
50
+ "@cotal-ai/connector-jcode": "0.96.1",
51
+ "@cotal-ai/connector-opencode": "0.96.1",
52
+ "@cotal-ai/linear": "0.96.1",
53
+ "@cotal-ai/pi": "0.96.1",
54
54
  "@cotal-ai/smoke-kit": "0.0.0",
55
- "@cotal-ai/web": "0.96.0",
55
+ "@cotal-ai/web": "0.96.1",
56
56
  "@nats-io/jetstream": "^3.4.0",
57
57
  "@nats-io/transport-node": "^3.4.0",
58
58
  "typescript": "^5.9.3"