@cotal-ai/manager 0.50.1 → 0.52.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/manager.js CHANGED
@@ -2,8 +2,8 @@ import { execFile } from "node:child_process";
2
2
  import { createHash, randomUUID, randomBytes } from "node:crypto";
3
3
  import { hostname } from "node:os";
4
4
  import { credsAuthenticator } from "@nats-io/transport-node";
5
- import { existsSync, lstatSync, readFileSync, rmSync } from "node:fs";
6
- import { join, dirname, resolve } from "node:path";
5
+ import { existsSync, lstatSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
6
+ import { isAbsolute, join, dirname, resolve } from "node:path";
7
7
  import { CotalEndpoint, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, divergentSecretStoreNotice, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, firstFreeName, spawnNameError, idFromCreds, inspectCredHealth, composeWirePersona, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, mintCreds, mintGeneration, mintAcceptedToken, openIssuedStore, acceptedBucket, issuedBucket, mintLifecycleUid, managedRetirementOpId, mkSecretDir, newIdentity, actionContext, parsePrincipalKey, parseShareSelection, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, epRequestSubject, epCallerReplyFilter, parseEpSubject, controlServiceSubject, eventChannelPrincipal, } from "@cotal-ai/core";
8
8
  import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, connectorInstallHint, DEFAULT_CONNECTOR, defaultAgentType, DELIVERY_CREDS_KIND, extensionConnectors, findCotalRoot, getSpaceAuth, hasUserAuthState, loadExtensionsManifest, loadManagerInstanceIdentity, loadMeshes, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KIND, mergeLaunchOptions, remintDaemonCreds, resolveOnPath, createManagerInstanceIdentity, spaceMaterialKey, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
9
9
  import { createRuntime, isCustodialRuntime, requireRuntimeAdopt, requireRuntimeReap, RuntimeReapUnproven, } from "./runtime/index.js";
@@ -147,9 +147,10 @@ function sameStrings(a, b) {
147
147
  return JSON.stringify([...(a ?? [])].sort()) === JSON.stringify([...(b ?? [])].sort());
148
148
  }
149
149
  /** Operator-facing phrasing per cause. Kept beside the union so adding a member without a sentence
150
- * is a type error rather than a blank in the log. */
150
+ * is a type error rather than a blank in the log. The stop family's sentences carry the principal
151
+ * from the cause, so they render through {@link STOP_CAUSE_TEXT} under the same exhaustive-Record
152
+ * discipline: a new stop kind without a renderer is a type error too. */
151
153
  const FREE_SLOT_CAUSE_TEXT = {
152
- "stopped": "this manager stopped it (despawn or shutdown)",
153
154
  "process-exit": "its own process exited and this manager did not stop it",
154
155
  "pi-crash-loop": "this manager retired it after a Pi crash loop",
155
156
  "pi-recovery-failed": "this manager retired it after Pi session recovery failed",
@@ -158,6 +159,21 @@ const FREE_SLOT_CAUSE_TEXT = {
158
159
  "session-bind-failed": "this manager stopped it: its host session could not be bound at launch",
159
160
  "resume-session-rebind-failed": "this manager stopped it: its host session could not be rebound on resume",
160
161
  };
162
+ /** The stop family's sentences — the same job {@link FREE_SLOT_CAUSE_TEXT} does for the string
163
+ * causes, beside it so both stay the single place a cause becomes a sentence. A requested stop's
164
+ * sentence embeds the authenticated principal and a recursive reap's embeds the parent that
165
+ * left, so they cannot sit in a static Record keyed by kind; this exhaustive switch is their
166
+ * single home, and the `satisfies never` default keeps the discipline: a new stop kind without a
167
+ * sentence is a type error rather than a blank in the log. */
168
+ function stopCauseText(cause) {
169
+ switch (cause.kind) {
170
+ case "stopped-requested": return `this manager stopped it at ${cause.requester}'s request`;
171
+ case "stopped-self": return "it stopped itself (self-stop)";
172
+ case "stopped-reap": return `this manager stopped it because its parent ${cause.parent} left (recursive reap)`;
173
+ case "stopped-shutdown": return "this manager stopped it on manager shutdown";
174
+ default: return cause;
175
+ }
176
+ }
161
177
  function parseTurnNote(raw) {
162
178
  let o;
163
179
  try {
@@ -284,6 +300,7 @@ export class Manager {
284
300
  wsPort;
285
301
  name;
286
302
  workspaceRoot;
303
+ eventsRequired;
287
304
  /** P2 item 6: the operator-set global live-session ceiling (see {@link ManagerOptions.maxSessions}). */
288
305
  maxSessions;
289
306
  /** The ONE secret store for every kind this manager touches (daemon-cred remint + agent kinds).
@@ -535,6 +552,7 @@ export class Manager {
535
552
  this.servers = opts.servers;
536
553
  this.name = opts.name ?? "manager";
537
554
  this.workspaceRoot = opts.workspaceRoot ?? findCotalRoot();
555
+ this.eventsRequired = opts.eventsRequired === true;
538
556
  this.maxSessions = opts.maxSessions;
539
557
  this.remoteAuthority = opts.remoteAuthority;
540
558
  if (opts.remoteAuthority)
@@ -927,7 +945,7 @@ export class Manager {
927
945
  }
928
946
  /** One class-2 renewal pass (D5 slice 5): re-sign `.cotal/delivery.creds` + `.cotal/membership-rw.creds`
929
947
  * for their existing nkeys, then request the delivery daemon's EXPLICIT `reloadCreds` adoption on the
930
- * delivery-admin rail and persist the audit record (`.cotal/renewal.json`) that `cotal doctor auth`
948
+ * delivery-admin rail and persist the audit record (`.cotal/renewal.<spaceKey>.json`) that `cotal doctor auth`
931
949
  * renders — so "file re-signed" and "daemon adopted" are distinguishable states. A missing daemon
932
950
  * (no responder) is recorded honestly: each daemon's own 75% renewal timer remains the adoption backstop.
933
951
  * Never throws — renewal failure must be LOUD (log + record), not fatal to the supervisor. */
@@ -985,7 +1003,7 @@ export class Manager {
985
1003
  // A non-owner (#1634) re-signed nothing, so it writes no record: an empty one renders in
986
1004
  // `doctor auth` as a pass that never ran.
987
1005
  if (this.daemonRenewalOwner)
988
- writeRenewalRecord(this.workspaceRoot, { ts: new Date().toISOString(), owner: "manager", results, adoption });
1006
+ writeRenewalRecord(this.workspaceRoot, this.space, { ts: new Date().toISOString(), owner: "manager", results, adoption });
989
1007
  this.warnOnSystemCredExpiry();
990
1008
  // F5(b) (Unit B): the MANAGER is the renewal owner for its managed-static agent creds —
991
1009
  // supervisor-side PUSH remint for recorded LIVE slots (the child JWT is never proof of
@@ -1480,6 +1498,9 @@ export class Manager {
1480
1498
  shareTools: a.launch.shareTools,
1481
1499
  forkSource: a.launch.forkSource,
1482
1500
  sessionId: a.restart?.armed ? this.readManagedSession(a) : a.launch.sessionId,
1501
+ // Additive and only when the connector supplied one. A seat with no pointer records no
1502
+ // field, which is what keeps an older inventory and a fresh one the same document shape.
1503
+ ...(a.restart?.sessionStatePath ? { sessionStatePath: a.restart.sessionStatePath } : {}),
1483
1504
  unresolvedLaunchOptionKeys: a.launch.unresolvedLaunchOptionKeys,
1484
1505
  },
1485
1506
  dependencies,
@@ -1504,6 +1525,11 @@ export class Manager {
1504
1525
  this.agents.delete(a.name);
1505
1526
  this.stopHandle(a, false);
1506
1527
  }
1528
+ // The slot delete above bypasses freeSlot, which is where the reap line lives — so say it here,
1529
+ // once per seat, with the one stop cause that names this path: the shutdown itself. Without
1530
+ // this a `--with-agents` stop left every seat unaccounted for in the log (see #1423).
1531
+ for (const a of managed)
1532
+ this.logSeatReaped(a, { kind: "stopped-shutdown" });
1507
1533
  // A runtime stop request is not exit proof. Do not release the manager lease or registration
1508
1534
  // while any seat may still hold this instance's broker rails: that creates the exact orphan
1509
1535
  // window where a successor sees a dead manager but live predecessor authority. Every shipped
@@ -2070,6 +2096,34 @@ export class Manager {
2070
2096
  return a.name;
2071
2097
  return caller;
2072
2098
  }
2099
+ /** A baseline seat may answer only the pause whose relay is pending on that exact incarnation.
2100
+ * Explicit `run` holders and unmanaged operator instruments keep the run capability's existing
2101
+ * answer reach. The relay payload is manager-held state written by the runtime, never request
2102
+ * input, and its token is checked against the open checkpoint before any answer is filed. */
2103
+ authorizeRunAnswer(ctx, args, open) {
2104
+ const caller = principalKey(ctx.subject.caller.owner, ctx.subject.caller.actor).key;
2105
+ const agent = [...this.agents.values()].find((a) => this.managedPrincipal(a) === caller && a.lifecycleUid === ctx.subject.caller.uid);
2106
+ if (agent === undefined || agent.launch.capabilities?.includes("run"))
2107
+ return;
2108
+ const pending = [...this.pendingTurns.values()].find((p) => p.seat.owner === ctx.subject.caller.owner
2109
+ && p.seat.actor === ctx.subject.caller.actor
2110
+ && p.seat.uid === ctx.subject.caller.uid);
2111
+ if (pending === undefined)
2112
+ throw new EpEnvelopeError("permission-denied", `run-answer is allowed to this baseline seat only for a pending ask or escalation addressed to its own incarnation; none is pending for ${agent.name}`);
2113
+ let payload;
2114
+ try {
2115
+ payload = JSON.parse(pending.payload);
2116
+ }
2117
+ catch {
2118
+ throw new EpEnvelopeError("permission-denied", `turn "${pending.goalId}" carries no readable ask or escalation authorization; run-answer is refused`);
2119
+ }
2120
+ const p = payload;
2121
+ const addressedToken = typeof p.ask?.token === "string" ? p.ask.token
2122
+ : p.checkpoint?.escalatedTo === agent.name && typeof p.checkpoint?.token === "string" ? p.checkpoint.token
2123
+ : undefined;
2124
+ if (p.run !== args.runId || p.step !== args.stepKey || addressedToken !== open.token)
2125
+ throw new EpEnvelopeError("permission-denied", `run-answer is allowed to ${agent.name} only for the open pause named by its pending ask or escalation relay; ${args.runId} ${args.stepKey} is not that pause`);
2126
+ }
2073
2127
  managerServiceDefs() {
2074
2128
  const args = (ctx) => (ctx.request.args ?? {});
2075
2129
  const callerOf = (ctx) => principalKey(ctx.subject.caller.owner, ctx.subject.caller.actor).key;
@@ -2139,15 +2193,16 @@ export class Manager {
2139
2193
  const data = unwrap(await this.opModels(args(ctx)));
2140
2194
  return { catalogs: Array.isArray(data) ? data : [data] };
2141
2195
  }),
2196
+ resolveCwd: (ctx) => this.serveGated(ctx, () => this.resolveSpawnCwd(args(ctx).cwd)),
2142
2197
  // P2 item 2: `spawn` is an ACTION - accept a goal + reply the acceptance floor payload, drive
2143
2198
  // progress + terminal off-handler (no ~30s block). The blocking reply path is gone (pin 8).
2144
- spawn: (ctx) => this.serveGated(ctx, () => this.serveSpawnGoal(ctx, (h) => this.opStart(args(ctx), callerOf(ctx), h))),
2199
+ spawn: (ctx) => this.serveGated(ctx, () => this.serveSpawnGoal(ctx, (h) => this.opStart(args(ctx), callerOf(ctx), h, ctx.subject.route))),
2145
2200
  despawn: (ctx) => this.serveGated(ctx, async () => {
2146
2201
  const a = targetAgent(ctx);
2147
2202
  const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
2148
2203
  if (denied)
2149
2204
  throw new EpEnvelopeError("permission-denied", denied);
2150
- return unwrap(this.despawnAuthorized(a, args(ctx).graceful !== false, true));
2205
+ return unwrap(this.despawnAuthorized(a, args(ctx).graceful !== false, true, callerOf(ctx)));
2151
2206
  }),
2152
2207
  attach: (ctx) => this.serveGated(ctx, async () => {
2153
2208
  const a = targetAgent(ctx);
@@ -2206,7 +2261,7 @@ export class Manager {
2206
2261
  // P2 item 2 (ruling 3): manifest `launch` is an ACTION through the SAME chokepoint as spawn -
2207
2262
  // the manifest resolve + owner-equality authz run in opLaunch's accept path, then the goal
2208
2263
  // drives progress + terminal. The acceptance floor is the allocated identity + goal coords.
2209
- launch: (ctx) => this.serveGated(ctx, () => this.serveSpawnGoal(ctx, (h) => this.opLaunch(args(ctx), callerOf(ctx), false, h))),
2264
+ launch: (ctx) => this.serveGated(ctx, () => this.serveSpawnGoal(ctx, (h) => this.opLaunch(args(ctx), callerOf(ctx), false, h, ctx.subject.route))),
2210
2265
  resumePreserved: (ctx) => adminGated(ctx, async () => unwrap(await this.opResumePreserved(args(ctx)))),
2211
2266
  commitResume: (ctx) => adminGated(ctx, async () => unwrap(await this.opCommitResume(args(ctx)))),
2212
2267
  finalizeResume: (ctx) => adminGated(ctx, async () => unwrap(await this.opFinalizeResume(args(ctx)))),
@@ -2214,7 +2269,10 @@ export class Manager {
2214
2269
  // (`run` capability / privileged instrument rows); the serve gate is the maintenance fence.
2215
2270
  runStart: (ctx) => this.serveGated(ctx, () => this.runHost().start(ctx, args(ctx))),
2216
2271
  runResume: (ctx) => this.serveGated(ctx, () => this.runHost().resume(args(ctx))),
2217
- runAnswer: (ctx) => this.serveGated(ctx, () => this.runHost().answer(args(ctx), this.runAnswerer(ctx))),
2272
+ runAnswer: (ctx) => this.serveGated(ctx, () => {
2273
+ const input = args(ctx);
2274
+ return this.runHost().answer(input, this.runAnswerer(ctx), (open) => this.authorizeRunAnswer(ctx, input, open));
2275
+ }),
2218
2276
  runStatus: (ctx) => this.serveGated(ctx, () => this.runHost().status(args(ctx))),
2219
2277
  runPs: (ctx) => this.serveGated(ctx, () => this.runHost().list(args(ctx))),
2220
2278
  preparePreservation: (ctx) => adminGated(ctx, async () => unwrap(await this.opPreservationCtl("preparePreservation", args(ctx)))),
@@ -2358,7 +2416,7 @@ export class Manager {
2358
2416
  return { ok: false, error: `self-stop: caller ${callerId} is not a managed agent` };
2359
2417
  const graceful = args.graceful !== false;
2360
2418
  this.stopHandle(target, graceful);
2361
- this.trackStoppedHandle(target, true);
2419
+ this.trackStoppedHandle(target, true, { kind: "stopped-self" });
2362
2420
  return { ok: true, data: { name: target.name, stopped: true, graceful } };
2363
2421
  }
2364
2422
  // Plane-3 durable join/leave/list ops moved OFF the manager onto the server-side delivery daemon's
@@ -2392,7 +2450,9 @@ export class Manager {
2392
2450
  }
2393
2451
  }
2394
2452
  /** Keep an accepted stop inside the lifecycle drain until the runtime proves the child is gone,
2395
- * so a maintenance prepare can never fence ahead of a child that is still dying.
2453
+ * so a maintenance prepare can never fence ahead of a child that is still dying. The `cause` is
2454
+ * the stop-family cause the door already knows (which door, and who asked); it rides every
2455
+ * freeSlot below so the reap line can say it.
2396
2456
  *
2397
2457
  * An operator-accepted stop frees its slot at once: `stop` replying ✓ means `ps` no longer lists
2398
2458
  * the agent. That cannot omit a still-live child from a cut, because runPreparation drains the
@@ -2400,7 +2460,7 @@ export class Manager {
2400
2460
  * slot lingering. A recursive reap (`requireAuthoritativeExit`) instead keeps the slot until the
2401
2461
  * wait proves exit: nobody asked for those children to be gone, so they stay managed until the
2402
2462
  * runtime says otherwise, and a runtime that cannot prove exit records an unverified stop. */
2403
- trackStoppedHandle(a, floor, requireAuthoritativeExit = false) {
2463
+ trackStoppedHandle(a, floor, cause, requireAuthoritativeExit = false) {
2404
2464
  if (!a.handle.waitForExit) {
2405
2465
  // Preserve ordinary external-runtime stop behavior, but retain enough evidence for a later
2406
2466
  // maintenance prepare to fail if that runtime still cannot prove the surface disappeared.
@@ -2415,14 +2475,14 @@ export class Manager {
2415
2475
  });
2416
2476
  if (requireAuthoritativeExit)
2417
2477
  return;
2418
- this.freeSlot(a, floor, "stopped", true);
2478
+ this.freeSlot(a, floor, cause, true);
2419
2479
  return;
2420
2480
  }
2421
2481
  if (!requireAuthoritativeExit)
2422
- this.freeSlot(a, floor, "stopped", true);
2482
+ this.freeSlot(a, floor, cause, true);
2423
2483
  this.lifecycleInFlight++;
2424
2484
  void this.awaitHandleExit(a.handle)
2425
- .then(() => this.freeSlot(a, floor, "stopped", true)) // no-op once an accepted stop already freed it
2485
+ .then(() => this.freeSlot(a, floor, cause, true)) // no-op once an accepted stop already freed it
2426
2486
  .catch((e) => {
2427
2487
  this.unverifiedStops.push({
2428
2488
  name: a.name,
@@ -2559,6 +2619,7 @@ export class Manager {
2559
2619
  * that could not provide it.
2560
2620
  */
2561
2621
  logSeatReaped(a, cause) {
2622
+ const causeText = typeof cause === "string" ? FREE_SLOT_CAUSE_TEXT[cause] : stopCauseText(cause);
2562
2623
  let detail;
2563
2624
  try {
2564
2625
  const info = a.handle.exitInfo?.();
@@ -2571,7 +2632,7 @@ export class Manager {
2571
2632
  // log line (or the free path it sits on) down with it.
2572
2633
  detail = `exit detail unreadable from runtime "${a.handle.kind}": ${e.message}`;
2573
2634
  }
2574
- console.error(`seat reaped: ${a.name} (${a.id}, uid ${a.lifecycleUid}${a.handle.pid !== undefined ? `, pid ${a.handle.pid}` : ""}) - ${FREE_SLOT_CAUSE_TEXT[cause]}; ${detail}`);
2635
+ console.error(`seat reaped: ${a.name} (${a.id}, uid ${a.lifecycleUid}${a.handle.pid !== undefined ? `, pid ${a.handle.pid}` : ""}) - ${causeText}; ${detail}`);
2575
2636
  }
2576
2637
  freeSlot(a, floor, cause, acceptedBeforeFence = false) {
2577
2638
  if (this.agents.get(a.name) !== a)
@@ -2934,7 +2995,7 @@ export class Manager {
2934
2995
  continue;
2935
2996
  this.reapChildrenOf(this.managedPrincipal(child));
2936
2997
  this.stopHandle(child, false);
2937
- this.trackStoppedHandle(child, true, true);
2998
+ this.trackStoppedHandle(child, true, { kind: "stopped-reap", parent: parentId }, true);
2938
2999
  }
2939
3000
  }
2940
3001
  readSessionStatePath(path) {
@@ -3227,7 +3288,7 @@ export class Manager {
3227
3288
  return false;
3228
3289
  }
3229
3290
  /** Parse an untyped control-plane `start` request into {@link StartAgentOpts}. */
3230
- opStart(args, caller, hooks) {
3291
+ opStart(args, caller, hooks, route = "inst") {
3231
3292
  // `resume`, when present, must be a non-empty session id. An empty/whitespace value is a
3232
3293
  // malformed request, not an implicit "spawn fresh" (no fallbacks). The CLI surfaces reject it,
3233
3294
  // but a raw control message could otherwise slip an empty value through and silently start fresh.
@@ -3293,8 +3354,36 @@ export class Manager {
3293
3354
  allowPublish,
3294
3355
  shareTools: args.shareTools !== undefined ? String(args.shareTools) : undefined,
3295
3356
  ...(supervise !== undefined ? { supervise } : {}),
3357
+ route,
3296
3358
  }, caller, hooks);
3297
3359
  }
3360
+ /** Resolve a placed spawn directory on this manager's host. The manager serves any existing
3361
+ * absolute directory it can resolve. It creates nothing and never substitutes the workspace root. */
3362
+ resolveSpawnCwd(value) {
3363
+ const asked = typeof value === "string" ? value : String(value ?? "");
3364
+ const refuse = (reason) => {
3365
+ throw new EpEnvelopeError("failed-precondition", `cwd does not resolve on this host: ${JSON.stringify(asked)} (${reason})`);
3366
+ };
3367
+ if (!isAbsolute(asked))
3368
+ refuse("expected an absolute path");
3369
+ let cwd = asked;
3370
+ try {
3371
+ cwd = realpathSync(asked);
3372
+ }
3373
+ catch (error) {
3374
+ refuse(`path cannot be resolved: ${error.message}`);
3375
+ }
3376
+ try {
3377
+ if (!statSync(cwd).isDirectory())
3378
+ refuse("path is not a directory");
3379
+ }
3380
+ catch (error) {
3381
+ if (error instanceof EpEnvelopeError)
3382
+ throw error;
3383
+ refuse(error.message);
3384
+ }
3385
+ return { cwd, host: hostname() };
3386
+ }
3298
3387
  /** Resolve a connector by agent type. Library composition (installedExtensions off) → a registry
3299
3388
  * hit, exactly as before (the composition root imported what it wants). The published binary gates
3300
3389
  * on MANIFEST membership FIRST — so a live `cotal ext remove` is honored even though the registry
@@ -3328,6 +3417,7 @@ export class Manager {
3328
3417
  const reason = error.message;
3329
3418
  console.error(`! manager boot: connector inventory unavailable - ${reason}`);
3330
3419
  this.connectorStatuses = [{ agent: "(inventory)", state: "unavailable", binaries: {}, reason }];
3420
+ console.error("! manager boot: no connector available - spawn and launch stay on this instance rail only so a sibling that can launch them can take the class queue");
3331
3421
  return;
3332
3422
  }
3333
3423
  }
@@ -3355,6 +3445,27 @@ export class Manager {
3355
3445
  }
3356
3446
  }
3357
3447
  this.connectorStatuses = rows.sort((a, b) => a.agent.localeCompare(b.agent));
3448
+ if (!this.classSpawnEnabled())
3449
+ console.error("! manager boot: no connector available - spawn and launch stay on this instance rail only so a sibling that can launch them can take the class queue");
3450
+ }
3451
+ /** True when at least one declared connector is available. Empty inventory (or a failed
3452
+ * inventory load) skips the class `one` subscribe for spawn/launch so this member cannot
3453
+ * consume an unpinned request a sibling could serve. */
3454
+ classSpawnEnabled() {
3455
+ return this.connectorStatuses.some((row) => row.state === "available");
3456
+ }
3457
+ omitClassCommands() {
3458
+ return this.classSpawnEnabled() ? undefined : ["spawn", "launch"];
3459
+ }
3460
+ /** Named harness-unavailable refusal. On the class rail a partial inventory must not look
3461
+ * like the space cannot launch: name `--on` so the caller can pin a sibling. The standing
3462
+ * serve credential cannot enumerate sibling `svc.*.status` inventories, so the residual
3463
+ * is the flag, not a list of capable instance ids. Instance-pinned calls keep the local
3464
+ * reason only. */
3465
+ harnessUnavailableError(reason, route) {
3466
+ if (route !== "one")
3467
+ return reason;
3468
+ return `${reason}. This manager cannot launch that harness. The space may have other managers; pin one with --on <instance> (the whole id, as ps prints it)`;
3358
3469
  }
3359
3470
  /** Return connector-provided model catalogs for selector UIs. Optional by connector: a host with no
3360
3471
  * local model-list API reports `supported:false` rather than blocking the manager. A connector that
@@ -3444,7 +3555,7 @@ export class Manager {
3444
3555
  * (collision-numbered) name + nkey id creds are filed under, plus the manifest `requested` name,
3445
3556
  * `runId`, and resolved `hash`. USER mesh: a privileged-tier launch is owner-equality-authorized
3446
3557
  * (spec owner === caller owner) before any side effect; the admin tier keeps operator behavior. */
3447
- async opLaunch(args, caller, admin, hooks) {
3558
+ async opLaunch(args, caller, admin, hooks, route = "inst") {
3448
3559
  const runId = String(args.runId ?? "").trim();
3449
3560
  const name = String(args.name ?? "").trim();
3450
3561
  if (!runId || !name)
@@ -3496,7 +3607,7 @@ export class Manager {
3496
3607
  catch (e) {
3497
3608
  return { ok: false, error: e.message };
3498
3609
  }
3499
- const reply = await this.startAgent(launchAgentToStartOpts(la, configPath, spec.owner, runId), caller, hooks);
3610
+ const reply = await this.startAgent({ ...launchAgentToStartOpts(la, configPath, spec.owner, runId), route }, caller, hooks);
3500
3611
  if (reply.ok)
3501
3612
  // `data.name` stays the spawned (numbered) identity — what creds are filed under and the ledger
3502
3613
  // keys on; `requested`/`runId`/`hash` give the CLI the manifest name + drift hash for the ledger.
@@ -3599,14 +3710,15 @@ export class Manager {
3599
3710
  // fails here with a clear name, not obscurely at process spawn. No fallback. All synchronous, so
3600
3711
  // the reserve gate stays atomic. (The connector itself was resolved up top, before the capacity gate.)
3601
3712
  const bootStatus = this.connectorStatuses.find((row) => row.agent === agent);
3713
+ const route = opts.route ?? "inst";
3602
3714
  if (bootStatus?.state === "unavailable")
3603
- return { ok: false, error: bootStatus.reason };
3715
+ return { ok: false, error: this.harnessUnavailableError(bootStatus.reason ?? `${agent} harness is unavailable`, route) };
3604
3716
  // A connector registered after boot has no inventory row. Keep the existing pre-mint backstop
3605
3717
  // for that dynamic library-composition case; ordinary installed connectors were checked at boot.
3606
3718
  if (!bootStatus) {
3607
3719
  const missing = (connector.requires ?? []).filter((bin) => !resolveOnPath(bin));
3608
3720
  if (missing.length)
3609
- return { ok: false, error: `${agent} harness needs ${missing.join(", ")} on PATH - not found` };
3721
+ return { ok: false, error: this.harnessUnavailableError(`${agent} harness needs ${missing.join(", ")} on PATH - not found`, route) };
3610
3722
  }
3611
3723
  // Resume is a connector capability: reject an unsupported resume HERE, before the reserve/mint, so
3612
3724
  // it can never provision creds + durables and then throw at buildLaunch (mint-then-orphan). Same
@@ -3759,19 +3871,25 @@ export class Manager {
3759
3871
  name = this.uniqueName(identityName);
3760
3872
  }
3761
3873
  this.reserved.add(name);
3762
- // The AG-UI event plane (opt-in: `--events` / COTAL_EVENTS_DEFAULT=1). Refused HERE, before
3874
+ // The AG-UI event plane is on unless the launch explicitly opted out. Refused HERE, before
3763
3875
  // anything is minted: a connector that cannot
3764
3876
  // emit must fail before provisioning rather than after, exactly as an unsupported `resume` does.
3765
3877
  // The GRANT itself cannot be derived yet. It is keyed on the agent's PRINCIPAL, and in user mode
3766
3878
  // the principal's owner is resolved further down, so deriving it from anything in scope here
3767
3879
  // would mean guessing at the identity the child will actually connect as. It is added at the
3768
3880
  // accept seam below, where the allocated triple exists.
3769
- const events = opts.events ?? process.env.COTAL_EVENTS_DEFAULT === "1";
3881
+ if (this.eventsRequired && opts.events === false) {
3882
+ this.reserved.delete(name);
3883
+ return { ok: false, error: `space "${this.space}" requires the event plane by registration policy; --no-events (events: false on the start op) is not allowed` };
3884
+ }
3885
+ const events = this.eventsRequired || opts.events !== false;
3770
3886
  if (events && !connector.eventChannel) {
3771
3887
  // Release the just-reserved name on this fail-fast path. A leaked reserve is silent: it costs
3772
3888
  // the next spawn of this persona its un-suffixed name and nothing reports why.
3773
3889
  this.reserved.delete(name);
3774
- return { ok: false, error: `connector "${connector.name}" does not publish an AG-UI event plane, but events was requested` };
3890
+ return { ok: false, error: this.eventsRequired
3891
+ ? `space "${this.space}" requires the event plane by registration policy, but connector "${connector.name}" does not publish one`
3892
+ : `connector "${connector.name}" does not publish an AG-UI event plane; pass --no-events (events: false on the start op) to launch it without one` };
3775
3893
  }
3776
3894
  // F2 (Unit B): a STATIC managed spawn REFUSES endpoint capabilities, fail-closed IN CODE (not
3777
3895
  // a doc note): the static terminal has no obligation-drain/frontier steps yet, so an accepted-
@@ -3995,6 +4113,7 @@ export class Manager {
3995
4113
  allowPublish,
3996
4114
  capabilities,
3997
4115
  events,
4116
+ eventsRequired: this.eventsRequired,
3998
4117
  mcpServers,
3999
4118
  envAllow,
4000
4119
  resolvedBinaries: bootStatus?.binaries,
@@ -4899,13 +5018,15 @@ export class Manager {
4899
5018
  const denied = await this.authorizeNamed(a, caller, admin);
4900
5019
  if (denied)
4901
5020
  return { ok: false, error: denied };
4902
- return this.despawnAuthorized(a, graceful, !admin);
5021
+ return this.despawnAuthorized(a, graceful, !admin, caller);
4903
5022
  }
4904
5023
  /** The post-authorization terminal effect (both doors). `trackNonAdmin` mirrors the ctl door's
4905
- * `trackStoppedHandle(a, !admin)` disposition. */
4906
- despawnAuthorized(a, graceful, trackNonAdmin) {
5024
+ * `trackStoppedHandle(a, !admin)` disposition. The reap line names `caller` — the AUTHENTICATED
5025
+ * wire principal the door already resolved — so an operator reading the log can tell one peer's
5026
+ * despawn from another's. */
5027
+ despawnAuthorized(a, graceful, trackNonAdmin, caller) {
4907
5028
  this.stopHandle(a, graceful);
4908
- this.trackStoppedHandle(a, trackNonAdmin);
5029
+ this.trackStoppedHandle(a, trackNonAdmin, { kind: "stopped-requested", requester: caller });
4909
5030
  void this.cancelAgentGoal(a.name, graceful ? "graceful" : "terminate"); // M4: cancel a live spawn goal
4910
5031
  return { ok: true, data: { name: a.name, stopped: true, graceful } };
4911
5032
  }
@@ -5036,6 +5157,7 @@ export class Manager {
5036
5157
  agentCount: this.agents.size,
5037
5158
  uptimeMs: Date.now() - this.startedAtMs,
5038
5159
  connectors: this.connectorStatuses.map((row) => ({ ...row, binaries: { ...row.binaries } })),
5160
+ classSpawn: this.classSpawnEnabled(),
5039
5161
  staticReconciliation: this.staticReconciliationStatus(),
5040
5162
  };
5041
5163
  }
@@ -5145,6 +5267,7 @@ export class Manager {
5145
5267
  return { lifecycleUid: a.lifecycleUid, mappingRevision: 0 };
5146
5268
  return undefined;
5147
5269
  },
5270
+ ...(this.omitClassCommands() ? { omitClassCommands: this.omitClassCommands() } : {}),
5148
5271
  });
5149
5272
  }
5150
5273
  catch (e) {
@@ -5384,6 +5507,7 @@ export class Manager {
5384
5507
  return { lifecycleUid: a.lifecycleUid, mappingRevision: 0 };
5385
5508
  return undefined;
5386
5509
  },
5510
+ ...(this.omitClassCommands() ? { omitClassCommands: this.omitClassCommands() } : {}),
5387
5511
  });
5388
5512
  }
5389
5513
  catch (e) {