@cotal-ai/manager 0.68.0 → 0.69.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.
Files changed (38) hide show
  1. package/dist/commands.js +11 -41
  2. package/dist/commands.js.map +1 -1
  3. package/dist/endpoint-evict.d.ts +10 -1
  4. package/dist/endpoint-evict.d.ts.map +1 -1
  5. package/dist/endpoint-evict.js +31 -51
  6. package/dist/endpoint-evict.js.map +1 -1
  7. package/dist/holder-liveness.d.ts.map +1 -1
  8. package/dist/holder-liveness.js +11 -48
  9. package/dist/holder-liveness.js.map +1 -1
  10. package/dist/manager-service-contract.d.ts +5 -1
  11. package/dist/manager-service-contract.d.ts.map +1 -1
  12. package/dist/manager-service-contract.js +7 -3
  13. package/dist/manager-service-contract.js.map +1 -1
  14. package/dist/manager.d.ts +12 -11
  15. package/dist/manager.d.ts.map +1 -1
  16. package/dist/manager.js +89 -86
  17. package/dist/manager.js.map +1 -1
  18. package/dist/remote-authority.d.ts +14 -1
  19. package/dist/remote-authority.d.ts.map +1 -1
  20. package/dist/remote-authority.js +50 -12
  21. package/dist/remote-authority.js.map +1 -1
  22. package/dist/remote-register.d.ts +1 -1
  23. package/dist/remote-register.d.ts.map +1 -1
  24. package/dist/remote-register.js +1 -9
  25. package/dist/remote-register.js.map +1 -1
  26. package/dist/resume.d.ts.map +1 -1
  27. package/dist/resume.js +24 -7
  28. package/dist/resume.js.map +1 -1
  29. package/dist/roster.d.ts.map +1 -1
  30. package/dist/roster.js +3 -9
  31. package/dist/roster.js.map +1 -1
  32. package/dist/run-hosting.js +1 -1
  33. package/dist/run-hosting.js.map +1 -1
  34. package/dist/static-lifecycle.d.ts +9 -5
  35. package/dist/static-lifecycle.d.ts.map +1 -1
  36. package/dist/static-lifecycle.js +14 -9
  37. package/dist/static-lifecycle.js.map +1 -1
  38. package/package.json +12 -12
package/dist/manager.js CHANGED
@@ -4,8 +4,8 @@ import { hostname } from "node:os";
4
4
  import { credsAuthenticator } from "@nats-io/transport-node";
5
5
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
6
6
  import { isAbsolute, join, dirname, resolve } from "node:path";
7
- import { CotalEndpoint, MANAGED_HANDOFF_KIND, accountFromCreds, credsClaims, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, assertLifecycleToken, discardLaunchArtifacts, divergentSecretStoreNotice, parseDaemonStoreAnswer, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, DeprovisionError, isConcreteChannel, deprovisionTargetPrincipal, firstFreeName, spawnNameError, idFromCreds, inspectCredHealth, credsRenewalDelayMs, composeWirePersona, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, modelPolicyRefusal, mintCreds, mintGeneration, mintAcceptedToken, openIssuedStore, acceptedBucket, issuedBucket, mintLifecycleUid, managedRetirementOpId, mkSecretDir, newIdentity, actionContext, parsePrincipalKey, parseShareSelection, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, bearerCommandFailure, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, controlServiceSubject, eventChannelPrincipal, resolveService, invokeCommand, } from "@cotal-ai/core";
8
- import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, claimManagerSiblingIdentities, connectorInstallHint, DEFAULT_CONNECTOR, defaultAgentType, DELIVERY_CREDS_KIND, extensionConnectors, findCotalRoot, getSpaceAuth, hasUserAuthState, loadExtensionsManifest, loadManagerInstanceIdentity, loadMeshes, localTrustOfSpace, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KIND, mergeLaunchOptions, remintDaemonCreds, resolveOnPath, createManagerInstanceIdentity, spaceMaterialKey, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
7
+ import { CotalEndpoint, MANAGED_HANDOFF_KIND, accountFromCreds, credsClaims, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, assertLifecycleToken, discardLaunchArtifacts, divergentSecretStoreNotice, parseDaemonStoreAnswer, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, DeprovisionError, isConcreteChannel, deprovisionTargetPrincipal, firstFreeName, spawnNameError, idFromCreds, inspectCredHealth, credsRenewalDelayMs, composeWirePersona, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, modelPolicyRefusal, mintCreds, mintGeneration, mintAcceptedToken, openIssuedStore, acceptedBucket, issuedBucket, mintLifecycleUid, managedRetirementOpId, mkSecretDir, newIdentity, actionContext, parsePrincipalKey, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, resolveReadAcl, bearerCommandFailure, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, controlServiceSubject, eventChannelPrincipal, resolveService, invokeCommand, } from "@cotal-ai/core";
8
+ import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, claimManagerSiblingIdentities, connectorInstallHint, DELIVERY_CREDS_KIND, extensionConnectors, findCotalRoot, getSpaceAuth, hasUserAuthState, loadExtensionsManifest, loadManagerInstanceIdentity, loadMeshes, localTrustOfSpace, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KIND, mergeLaunchOptions, remintDaemonCreds, resolveAgentType, 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";
10
10
  import { AttachEndpoint } from "./attach-endpoint.js";
11
11
  import { DELIVERY_ADMIN_BOOT_WAIT_MS, isUnansweredDeliveryAdmin, makeManagerEndpointEvictionEvidence, makeManagerEndpointHolderEvictor, untilDeliveryAdminAnswers } from "./endpoint-evict.js";
@@ -32,7 +32,7 @@ import { ManagerSessionPlane, openSessionLedgerKv } from "./session/index.js";
32
32
  // the register/authorize/serve seams, all driven over a scoped one-shot executor connection.
33
33
  import { provisionEndpointGateOpen, endpointRegistrationBarrier, serveIssuanceGateKv, commitSiblingIssuance, markLedgerRowRevoked, epcredRowKey, epgateKey, parseEndpointGate, readEndpointGateGeneration, registerServingInstance, deregisterServiceInstance, serveEndpoint, bindGoal, createGoal, transitionGoal, commitGoalResult, settleGoalUncertain, readGoalResult, readGoalStatus, readGoalSpec, recordGoalIndex, readGoalIndex, clearGoalIndex, listGoalIndex, mintCheckpoint, resumeCheckpoint, readCheckpointSettle, readCheckpointSpec, expireCheckpoint, GOAL_TERMINAL_STATES, goalRefOf, goalProgressTopic, epeSubject, submissionFingerprint, EpEnvelopeError, lifecycleBlocked, lifecycleBlockedFrom, renderLifecycleBlocked, } from "@cotal-ai/core";
34
34
  import { MANAGER_ENDPOINT, managerClusterArtifacts, managerCommandDefs, managerContractArtifactValues } from "./manager-service-contract.js";
35
- import { staticLifecycleTransport, activateStaticLifecycle, runStaticTerminal, readStaticSlot, casStaticSlot, recordSlotCredential, appendStaticCredentialRow, planStaticSlotResume, observeStaticSlot, listStaticSlotObservations, renderStaticSlotObservation, StaticSlotReadError, STATIC_SLOT_READ_FAILED_DETAIL, } from "./static-lifecycle.js";
35
+ import { staticLifecycleTransport, activateStaticLifecycle, runStaticTerminal, readStaticSlot, casStaticSlot, recordSlotCredential, appendStaticCredentialRow, planStaticSlotResume, ownedBySibling, observeStaticSlot, listStaticSlotObservations, renderStaticSlotObservation, StaticSlotReadError, STATIC_SLOT_READ_FAILED_DETAIL, } from "./static-lifecycle.js";
36
36
  /** Minimum slot lifetime for rate-flooring (P4c). A slot freed (by despawn OR natural exit/reap)
37
37
  * before living this long leaves a cooling stamp that still counts toward the ceiling until it
38
38
  * expires — so churn (spawn↔despawn or spawn↔fast-exit) can't outrun the concurrency bound. */
@@ -293,7 +293,8 @@ export class Manager {
293
293
  /** The ONE secret store for every kind this manager touches (daemon-cred remint + agent kinds).
294
294
  * See {@link ManagerOptions.secretStore}. */
295
295
  secrets;
296
- /** Named identity of {@link secrets}: the same coordinate the delivery daemon must reload from. */
296
+ /** Named identity of {@link secrets}: the same coordinate the delivery daemon must reload from.
297
+ * Set at construction for an injected store; the workspace store is named on first challenge. */
297
298
  secretStoreIdentity;
298
299
  /** See {@link ManagerOptions.installedExtensions}. */
299
300
  installedExtensions;
@@ -604,9 +605,8 @@ export class Manager {
604
605
  if (opts.remoteAuthority)
605
606
  this.managerLifecycleUid = opts.remoteAuthority.lifecycleUid;
606
607
  this.secrets = opts.secretStore ?? workspaceSecretStore(this.workspaceRoot);
607
- this.secretStoreIdentity = opts.secretStore
608
- ? injectedManagerStoreIdentity(opts.secretStore)
609
- : { kind: "fs", root: resolve(this.workspaceRoot) };
608
+ if (opts.secretStore)
609
+ this.secretStoreIdentity = injectedManagerStoreIdentity(opts.secretStore);
610
610
  this.installedExtensions = opts.installedExtensions ?? false;
611
611
  this.runtime = createRuntime(opts.runtime ?? "auto", `cotal-${this.space}`);
612
612
  if (opts.pooled && isCustodialRuntime(this.runtime))
@@ -1140,6 +1140,9 @@ export class Manager {
1140
1140
  `this space's delivery lease (${holder}), so its SecretStore is not the store the daemon ` +
1141
1141
  `reloads from - nothing reminted`);
1142
1142
  const daemon = answer.identity;
1143
+ // The workspace store records its id the first time it is named, so it is named here, where
1144
+ // only an auth manager reaches, rather than in a constructor every open-mode manager runs.
1145
+ this.secretStoreIdentity ??= parseSecretStoreIdentity(this.secrets.identity);
1143
1146
  if (!sameSecretStoreIdentity(this.secretStoreIdentity, daemon)) {
1144
1147
  console.error(`! ${divergentSecretStoreNotice(this.secretStoreIdentity, daemon)}`);
1145
1148
  return "divergent";
@@ -1687,7 +1690,7 @@ export class Manager {
1687
1690
  // actually saw.
1688
1691
  const backfillFloor = await this.chatFrontierForPreservation();
1689
1692
  const inventory = this.preservationInventory ?? {
1690
- version: "cotal-manager-resume/v1",
1693
+ version: "cotal-manager-resume/v2",
1691
1694
  space: this.space,
1692
1695
  createdAt: new Date().toISOString(),
1693
1696
  agents: [...this.agents.values()].map((a) => this.resumeEntry(a, backfillFloor)),
@@ -2150,17 +2153,17 @@ export class Manager {
2150
2153
  await step("release renewal lease", () => this.ep.releaseDaemonRenewalLease());
2151
2154
  }
2152
2155
  // Capture BEFORE the serve loop is torn down: `stopServiceServe` clears the state, and what is
2153
- // being asked here is "did this process register a service instance", which only the state
2156
+ // being asked here is "which registration did this process write", which only the state
2154
2157
  // before teardown can answer. Deregistration runs AFTER the serve loop has drained, so no
2155
2158
  // in-flight command can write a status back onto the record it just removed.
2156
- const registered = this.serviceServe !== undefined;
2159
+ const registrationRevision = this.serviceServe?.grant.registrationRevision;
2157
2160
  await step("stop service serve", () => this.stopServiceServe());
2158
2161
  // The drives AFTER the serve loop (no new `run-start` can land) and BEFORE deregistration: a
2159
2162
  // released run's status write is the last thing this incarnation says about it.
2160
2163
  await step("stop run hosting", () => this.runHosting?.stop());
2161
2164
  this.runHosting = undefined;
2162
- if (registered && !seatFailure)
2163
- await step("deregister service", () => this.deregisterServiceOnStop());
2165
+ if (registrationRevision !== undefined && !seatFailure)
2166
+ await step("deregister service", () => this.deregisterServiceOnStop(registrationRevision));
2164
2167
  await step("stop goal writer", () => this.stopGoalWriter());
2165
2168
  await step("stop session plane", () => this.stopSessionPlane());
2166
2169
  await step("stop endpoint", () => this.ep.stop());
@@ -2206,8 +2209,8 @@ export class Manager {
2206
2209
  * deadline — on every `cotal ps`, `stop` and `attach` in that space, for good. A manager that is
2207
2210
  * shutting down is the one participant that KNOWS it is going away, so it says so.
2208
2211
  *
2209
- * The delete is revision-pinned inside {@link deregisterServiceInstance}, so a successor that
2210
- * persists the SAME instanceId and has already re-registered loses nothing to a slow stop here.
2212
+ * The delete is pinned to the `registrationRevision` this process registered at, so a successor
2213
+ * that persists the SAME instanceId and has already re-registered loses nothing to a slow stop here.
2211
2214
  *
2212
2215
  * Best-effort and LOUD, matching every other teardown step: a broker that is already gone must not
2213
2216
  * turn a stop into a failure, but a registration that survives a stop is the exact defect this
@@ -2215,11 +2218,12 @@ export class Manager {
2215
2218
  * rather than swallowed. It is not fatal because a crash leaves the same state and the operator
2216
2219
  * verb handles both.
2217
2220
  */
2218
- async deregisterServiceOnStop() {
2221
+ async deregisterServiceOnStop(registrationRevision) {
2219
2222
  const iid = this.managerInstanceId;
2220
2223
  const dereg = ({ recordsKv, authKv }) => deregisterServiceInstance(recordsKv, {
2221
2224
  endpoint: MANAGER_ENDPOINT,
2222
2225
  instanceId: iid,
2226
+ registrationRevision,
2223
2227
  observeGeneration: async () => {
2224
2228
  const key = epgateKey(MANAGER_ENDPOINT, iid);
2225
2229
  const entry = await authKv.get(key);
@@ -2235,7 +2239,7 @@ export class Manager {
2235
2239
  if (outcome.removed)
2236
2240
  console.error(`✓ deregistered manager instance ${iid} from the ${MANAGER_ENDPOINT} service registry (spec revision ${outcome.specRevision})`);
2237
2241
  else if (outcome.reason === "superseded")
2238
- console.error(`! manager instance ${iid} was not deregistered: its registration moved while this stop ran, so another incarnation owns it now - leaving it alone`);
2242
+ console.error(`! manager instance ${iid} was not deregistered: another incarnation has registered it since this process did - leaving it alone`);
2239
2243
  else if (outcome.reason === "registration-in-flight")
2240
2244
  console.error(`! manager instance ${iid} was not deregistered: a registration is still in flight (governance slot held at the live gate generation) - leaving it alone`);
2241
2245
  // `absent` is silent: there was nothing registered to remove, which is not news at shutdown.
@@ -4275,7 +4279,7 @@ export class Manager {
4275
4279
  nameInUse(name, live = this.liveRosterNames()) {
4276
4280
  return this.agents.has(name) || this.reserved.has(name) || this.retiring.has(name) || live.has(name);
4277
4281
  }
4278
- /** First free name in the series `base`, `base-2`, `base-3`, … — checked against live slots,
4282
+ /** First free name in the series `base`, `base_2`, `base_3`, … — checked against live slots,
4279
4283
  * in-flight (reserved) slots, names held pending retirement, AND the live mesh roster. The
4280
4284
  * roster check covers occupants this manager does not manage (a foreground `cotal spawn`, a
4281
4285
  * connector session, another manager's agent): allocating their name would mint a sibling the
@@ -4356,11 +4360,12 @@ export class Manager {
4356
4360
  throw new Error(`${flag}: expected an array of strings`);
4357
4361
  return v;
4358
4362
  };
4359
- let subscribe, allowSubscribe, allowPublish;
4363
+ let subscribe, allowSubscribe, allowPublish, shareTools;
4360
4364
  try {
4361
4365
  subscribe = strList(args.subscribe, "subscribe");
4362
4366
  allowSubscribe = strList(args.allowSubscribe, "allowSubscribe");
4363
4367
  allowPublish = strList(args.allowPublish, "allowPublish");
4368
+ shareTools = strList(args.shareTools, "shareTools");
4364
4369
  }
4365
4370
  catch (e) {
4366
4371
  return Promise.resolve({ ok: false, error: e.message });
@@ -4410,7 +4415,7 @@ export class Manager {
4410
4415
  subscribe,
4411
4416
  allowSubscribe,
4412
4417
  allowPublish,
4413
- shareTools: args.shareTools !== undefined ? String(args.shareTools) : undefined,
4418
+ shareTools,
4414
4419
  ...(supervise !== undefined ? { supervise } : {}),
4415
4420
  route,
4416
4421
  }, caller, hooks);
@@ -4722,6 +4727,9 @@ export class Manager {
4722
4727
  }
4723
4728
  }
4724
4729
  async startAgentActive(opts, spawner, hooks) {
4730
+ // The caller keeps its array, so the selection is copied before the first await: a later write
4731
+ // to it cannot change what this launch shares or what preservation retains.
4732
+ const shareTools = opts.shareTools && [...opts.shareTools];
4725
4733
  if (opts.delegatedIntent) {
4726
4734
  if (!this.remoteAuthority?.executeDelegatedUserIntent)
4727
4735
  return { ok: false, error: `"${opts.name}" names a delegated user intent, and this manager has no host execution for one` };
@@ -4778,10 +4786,7 @@ export class Manager {
4778
4786
  return { ok: false, error: e.message };
4779
4787
  }
4780
4788
  }
4781
- // Harness precedence (#869): --agent flag > persona file `agent:` > detached caller default >
4782
- // this manager's COTAL_DEFAULT_AGENT > DEFAULT_CONNECTOR. Both environment values are defaults,
4783
- // never overrides: neither may beat a deliberate per-persona pin.
4784
- const agent = opts.agent ?? def?.agent ?? opts.defaultAgent ?? defaultAgentType(DEFAULT_CONNECTOR);
4789
+ const agent = resolveAgentType({ flag: opts.agent, pin: def?.agent, callerDefault: opts.defaultAgent });
4785
4790
  // Materialize the requested connector up front — the ONE async step in the spawn path (a lazy
4786
4791
  // `cotal ext` manifest import on the published binary). It runs BEFORE the capacity/reserve span
4787
4792
  // below so that span stays fully SYNCHRONOUS and atomic. On the manifest binary this also honors a
@@ -4862,7 +4867,7 @@ export class Manager {
4862
4867
  return { ok: false, error: `${delegatedBy}, so it cannot reopen a session held on this host (continuity: exact)` };
4863
4868
  if (typeof opts.cwd === "string" && opts.cwd !== "")
4864
4869
  return { ok: false, error: `${delegatedBy}, so it cannot run in a directory on this host's filesystem (cwd); the seat runs in its runtime resource's own directory` };
4865
- const shared = Object.keys(connectorServers(loadCotalConfig(this.workspaceRoot), agent, parseShareSelection(opts.shareTools)));
4870
+ const shared = Object.keys(connectorServers(loadCotalConfig(this.workspaceRoot), agent, shareTools));
4866
4871
  if (shared.length)
4867
4872
  return { ok: false, error: `${delegatedBy}, so it cannot share MCP servers that run on this host (${shared.join(", ")}); pass --share-tools none` };
4868
4873
  }
@@ -4883,7 +4888,7 @@ export class Manager {
4883
4888
  // Resolve the launch profile: IDENTITY (free-form `name:`) + role + read/post ACL + capabilities
4884
4889
  // + model/variant. Either from a fully-resolved manifest launch object (`opts.resolved`, whose `config`
4885
4890
  // is a materialized transient persona — the file is NOT the access authority), or from the
4886
- // persona file. The number rides the IDENTITY (socrates → socrates-2), not the file ref — a
4891
+ // persona file. The number rides the IDENTITY (socrates → socrates_2), not the file ref — a
4887
4892
  // redelivered identical spawn yields a fresh numbered agent (MAX_AGENTS bounds the blast radius).
4888
4893
  let identityName;
4889
4894
  let role;
@@ -4898,13 +4903,13 @@ export class Manager {
4898
4903
  if (opts.resolved) {
4899
4904
  // A manifest launch is the access + identity authority: imperative overrides arriving
4900
4905
  // alongside `resolved` are a caller contract error, not something to merge (no fallbacks).
4901
- if (opts.subscribe || opts.allowSubscribe || opts.allowPublish || opts.prompt || opts.shareTools || opts.identity)
4906
+ if (opts.subscribe || opts.allowSubscribe || opts.allowPublish || opts.prompt || shareTools || opts.identity)
4902
4907
  return { ok: false, error: "a manifest launch (resolved) rejects imperative overrides (identity/subscribe/allow*/prompt/shareTools)" };
4903
4908
  const r = opts.resolved;
4904
4909
  identityName = r.name;
4905
4910
  role = opts.role ?? r.role;
4906
4911
  subscribe = r.subscribe;
4907
- allowSubscribe = r.allowSubscribe?.length ? r.allowSubscribe : r.subscribe;
4912
+ allowSubscribe = r.allowSubscribe;
4908
4913
  allowPublish = r.allowPublish;
4909
4914
  capabilities = r.capabilities;
4910
4915
  model = opts.model ?? r.model;
@@ -4926,9 +4931,7 @@ export class Manager {
4926
4931
  // paths of the merged grammar can't diverge. One source feeds BOTH the minted creds and the
4927
4932
  // connector env below.
4928
4933
  subscribe = opts.subscribe ?? def.subscribe;
4929
- // Defaulted the same way the loader/provisioner do — minted into the creds (the broker
4930
- // boundary); runtime durable joins are re-authorized against the committed ACL by the daemon.
4931
- allowSubscribe = opts.allowSubscribe ?? def.allowSubscribe ?? subscribe ?? [];
4934
+ allowSubscribe = opts.allowSubscribe ?? def.allowSubscribe ?? [];
4932
4935
  allowPublish = opts.allowPublish ?? def.allowPublish;
4933
4936
  capabilities = def.capabilities;
4934
4937
  // #651: fold the persona's model into the launch record, mirroring the variant line below
@@ -4939,6 +4942,14 @@ export class Manager {
4939
4942
  variant = opts.variant ?? def.variant;
4940
4943
  launchOptions = mergeLaunchOptions(def.launchOptions, opts.launchOptions);
4941
4944
  }
4945
+ // Resolved once for both branches, because this one list is granted, minted, recorded on the
4946
+ // launch and forwarded to the session, and each of those must read the list the provisioner does.
4947
+ try {
4948
+ allowSubscribe = resolveReadAcl(subscribe ?? [], allowSubscribe);
4949
+ }
4950
+ catch (e) {
4951
+ return { ok: false, error: opts.resolved ? `launch agent: ${e.message}` : `persona ${configPath}: ${e.message}` };
4952
+ }
4942
4953
  // #651: an empty or whitespace-only model string is not a pin. Coerce it to undefined here, at
4943
4954
  // the single point every path (persona, manifest, imperative) has resolved `model`, so it
4944
4955
  // serializes ABSENT rather than present-but-empty (`"model": ""`), which a key-presence consumer
@@ -5026,6 +5037,30 @@ export class Manager {
5026
5037
  const unportable = this.runtime.spawnDelegated ? Object.keys(launchOptions ?? {}).filter((k) => typeof launchOptions[k] !== "string") : [];
5027
5038
  if (unportable.length)
5028
5039
  return { ok: false, error: `runtime "${this.runtime.kind}" starts seats outside this host, so it cannot pass non-string launch options (${unportable.join(", ")})` };
5040
+ // The AG-UI event plane is on unless the launch explicitly opted out. Refused HERE, before
5041
+ // anything is minted: a connector that cannot
5042
+ // emit must fail before provisioning rather than after, exactly as an unsupported `resume` does.
5043
+ // The GRANT itself cannot be derived yet. It is keyed on the agent's PRINCIPAL, and in user mode
5044
+ // the principal's owner is resolved further down, so deriving it from anything in scope here
5045
+ // would mean guessing at the identity the child will actually connect as. It is added at the
5046
+ // accept seam below, where the allocated triple exists.
5047
+ if (this.eventsRequired && opts.events === false)
5048
+ 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` };
5049
+ const events = this.eventsRequired || opts.events !== false;
5050
+ if (events && !connector.eventChannel)
5051
+ return { ok: false, error: this.eventsRequired
5052
+ ? `space "${this.space}" requires the event plane by registration policy, but connector "${connector.name}" does not publish one`
5053
+ : `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` };
5054
+ // F2 (Unit B): a STATIC managed spawn REFUSES endpoint capabilities, fail-closed IN CODE (not
5055
+ // a doc note): the static terminal has no obligation-drain/frontier steps yet, so an accepted-
5056
+ // but-uncompleted endpoint obligation could execute AFTER its uid is declared retired. The
5057
+ // refusal sits at spawn-accept, before any provisioning, over the same records a persona or
5058
+ // manifest self-claim would ride in on — capabilities cannot slip past it into the grant path.
5059
+ if (this.auth && !this.userMode) {
5060
+ const claims = [opts, (opts.resolved ?? {})];
5061
+ if (claims.some((c) => c.endpointCapabilities !== undefined))
5062
+ return { ok: false, error: "a static managed spawn refuses endpointCapabilities (Unit B F2): the static lifecycle terminal carries no obligation-drain/frontier steps, so endpoint-rail grants are not containable in static mode" };
5063
+ }
5029
5064
  // #4 A4 (panel): the roster the allocation consults must reflect the initial presence snapshot,
5030
5065
  // or a spawn immediately after manager boot races an already-live unmanaged peer and re-opens the
5031
5066
  // very collision black-hole this closes. Await the snapshot (bounded internally, fail-safe on an
@@ -5035,10 +5070,10 @@ export class Manager {
5035
5070
  await this.ep.waitForPresenceSnapshot();
5036
5071
  // M6 (P2 item 2 spawn-as-action): a HARD-PINNED name — an imperative `--name`/identity override
5037
5072
  // or a manifest-declared name (opts.resolved) — that collides with a LIVE/provisioning/reserved
5038
- // incarnation REFUSES loud at accept, BEFORE any reserve/mint/bind (pin 1), never a silent `-2`
5073
+ // incarnation REFUSES loud at accept, BEFORE any reserve/mint/bind (pin 1), never a silent `_2`
5039
5074
  // suffix (so an address-by-triple caller's pinned name can't be re-pointed). A PERSONA-DERIVED
5040
5075
  // base name (no pin) keeps uniqueName's collision numbering, so multi-peer `spawn reviewer` twice
5041
- // still yields reviewer + reviewer-2. The retiring-hold refuse (~2472) is orthogonal and already fired.
5076
+ // still yields reviewer + reviewer_2. The retiring-hold refuse (~2472) is orthogonal and already fired.
5042
5077
  const hardPinned = opts.identity !== undefined || opts.resolved !== undefined;
5043
5078
  let name;
5044
5079
  if (hardPinned) {
@@ -5054,42 +5089,6 @@ export class Manager {
5054
5089
  else {
5055
5090
  name = this.uniqueName(identityName);
5056
5091
  }
5057
- this.reserved.add(name);
5058
- // The AG-UI event plane is on unless the launch explicitly opted out. Refused HERE, before
5059
- // anything is minted: a connector that cannot
5060
- // emit must fail before provisioning rather than after, exactly as an unsupported `resume` does.
5061
- // The GRANT itself cannot be derived yet. It is keyed on the agent's PRINCIPAL, and in user mode
5062
- // the principal's owner is resolved further down, so deriving it from anything in scope here
5063
- // would mean guessing at the identity the child will actually connect as. It is added at the
5064
- // accept seam below, where the allocated triple exists.
5065
- if (this.eventsRequired && opts.events === false) {
5066
- this.reserved.delete(name);
5067
- 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` };
5068
- }
5069
- const events = this.eventsRequired || opts.events !== false;
5070
- if (events && !connector.eventChannel) {
5071
- // Release the just-reserved name on this fail-fast path. A leaked reserve is silent: it costs
5072
- // the next spawn of this persona its un-suffixed name and nothing reports why.
5073
- this.reserved.delete(name);
5074
- return { ok: false, error: this.eventsRequired
5075
- ? `space "${this.space}" requires the event plane by registration policy, but connector "${connector.name}" does not publish one`
5076
- : `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` };
5077
- }
5078
- // F2 (Unit B): a STATIC managed spawn REFUSES endpoint capabilities, fail-closed IN CODE (not
5079
- // a doc note): the static terminal has no obligation-drain/frontier steps yet, so an accepted-
5080
- // but-uncompleted endpoint obligation could execute AFTER its uid is declared retired. The
5081
- // refusal sits at spawn-accept, before any provisioning, over the same records a persona or
5082
- // manifest self-claim would ride in on — capabilities cannot slip past it into the grant path.
5083
- if (this.auth && !this.userMode) {
5084
- const claims = [opts, (opts.resolved ?? {})];
5085
- if (claims.some((c) => c.endpointCapabilities !== undefined)) {
5086
- this.reserved.delete(name);
5087
- return { ok: false, error: "a static managed spawn refuses endpointCapabilities (Unit B F2): the static lifecycle terminal carries no obligation-drain/frontier steps, so endpoint-rail grants are not containable in static mode" };
5088
- }
5089
- }
5090
- // Set once the agent's creds + durables are minted; cleared the moment a live slot takes ownership
5091
- // (`agents.set`, after which freeSlot deprovisions on exit). If it survives to `finally`, the spawn
5092
- // threw AFTER minting (buildLaunch / runtime.spawn) — tear the orphan down so no footprint leaks (#159 B).
5093
5092
  // Set once the agent's footprint (durables + creds, or the user-mode grant + secret files)
5094
5093
  // exists; cleared when a live slot takes ownership. If it survives to `finally`, the spawn threw
5095
5094
  // AFTER provisioning (buildLaunch / runtime.spawn) — the orphan-rollback tears it down. Carries
@@ -5103,6 +5102,9 @@ export class Manager {
5103
5102
  let provisioned;
5104
5103
  // A carried resume's seat-private home, removed by the finally unless a live seat took it.
5105
5104
  let seatHome;
5105
+ // Taken right before the try so its finally is the only release; a refusal belongs above the
5106
+ // allocation, where there is no name to give back.
5107
+ this.reserved.add(name);
5106
5108
  try {
5107
5109
  // A stable nkey identity assigned at spawn: the public key is the agent's card.id (threaded via
5108
5110
  // COTAL_ID); the seed is retained to mint matching creds later.
@@ -5208,10 +5210,8 @@ export class Manager {
5208
5210
  }
5209
5211
  : undefined,
5210
5212
  });
5211
- if ("error" in prep) {
5212
- this.reserved.delete(name);
5213
+ if ("error" in prep)
5213
5214
  return { ok: false, error: prep.error };
5214
- }
5215
5215
  // THE HOST'S UID WINS (#1972). On the local arm this is the value passed in, unchanged. On
5216
5216
  // the hosted enrollment arm the host selected it, and from here every lifecycle-keyed thing
5217
5217
  // this spawn records — the slot, the launch, the managed row, the teardown credential — must
@@ -5273,7 +5273,7 @@ export class Manager {
5273
5273
  // (cotal config; default none → isolated, the memory-safe default this guards), narrowed by
5274
5274
  // an optional --share-tools selection (absent → all declared, the pre-merge behavior).
5275
5275
  const cotalConfig = loadCotalConfig(this.workspaceRoot);
5276
- const mcpServers = connectorServers(cotalConfig, agent, parseShareSelection(opts.shareTools));
5276
+ const mcpServers = connectorServers(cotalConfig, agent, shareTools);
5277
5277
  // The operator's spawn-env policy travels the same route: absent means no extras (the OS
5278
5278
  // allow-list + operator knobs + connector-declared inputs), present means those names too.
5279
5279
  // A connector never reads the config itself.
@@ -5400,7 +5400,7 @@ export class Manager {
5400
5400
  allowPublish,
5401
5401
  capabilities,
5402
5402
  events,
5403
- shareTools: opts.shareTools,
5403
+ shareTools,
5404
5404
  forkSource: opts.resume,
5405
5405
  ...(opts.resume !== undefined && spec?.resumeRecordPath ? { resumeRecordPath: spec.resumeRecordPath } : {}),
5406
5406
  ...(carried ? { carried: { transcriptSha256: carried.sha256, host: carried.sourceHost, transferredAt: carried.stagedAt } } : {}),
@@ -5573,19 +5573,22 @@ export class Manager {
5573
5573
  const batchReservations = [];
5574
5574
  const prepared = new Map();
5575
5575
  try {
5576
- if (inventory.version !== "cotal-manager-resume/v1")
5576
+ if (inventory.version !== "cotal-manager-resume/v2")
5577
5577
  return { ok: false, agents: [], error: `unsupported manager resume inventory version ${String(inventory.version)}` };
5578
5578
  if (inventory.space !== this.space)
5579
5579
  return { ok: false, agents: [], error: `resume inventory belongs to space "${inventory.space}", not "${this.space}"` };
5580
+ // The caller keeps its inventory, so each selection is copied before the first await: a later
5581
+ // write to it cannot change what a resumed seat shares or what the next preservation retains.
5582
+ const entries = inventory.agents.map((entry) => ({ ...entry, launch: { ...entry.launch, shareTools: entry.launch.shareTools && [...entry.launch.shareTools] } }));
5580
5583
  const seen = new Set();
5581
5584
  const principals = new Set();
5582
5585
  const orphans = [];
5583
5586
  await this.ep.waitForPresenceSnapshot();
5584
5587
  const liveRoster = this.ep.getRoster().filter((presence) => presence.status !== "offline");
5585
5588
  const occupancy = this.occupancy();
5586
- if (occupancy.used + inventory.agents.length > MAX_AGENTS)
5587
- return { ok: false, agents: [], error: `resume inventory of ${inventory.agents.length} would exceed manager capacity (${this.occupancyText(occupancy)})` };
5588
- for (const entry of inventory.agents) {
5589
+ if (occupancy.used + entries.length > MAX_AGENTS)
5590
+ return { ok: false, agents: [], error: `resume inventory of ${entries.length} would exceed manager capacity (${this.occupancyText(occupancy)})` };
5591
+ for (const entry of entries) {
5589
5592
  if (seen.has(entry.name))
5590
5593
  return { ok: false, agents: [], error: `resume inventory contains duplicate agent name "${entry.name}"` };
5591
5594
  seen.add(entry.name);
@@ -5642,12 +5645,12 @@ export class Manager {
5642
5645
  if (this.agents.has(entry.name) || this.reserved.has(entry.name))
5643
5646
  return { ok: false, agents: [], error: `retained agent "${entry.name}" is already managed or reserved` };
5644
5647
  }
5645
- for (const entry of inventory.agents) {
5648
+ for (const entry of entries) {
5646
5649
  this.reserved.add(entry.name);
5647
5650
  batchReservations.push(entry.name);
5648
5651
  }
5649
5652
  const preflight = [];
5650
- for (const entry of inventory.agents) {
5653
+ for (const entry of entries) {
5651
5654
  const reply = await this.resumePreservedAgent(entry, true, true, prepared);
5652
5655
  preflight.push({ name: entry.name, reply });
5653
5656
  }
@@ -5666,12 +5669,12 @@ export class Manager {
5666
5669
  return { ok: false, agents: [], error: refusal };
5667
5670
  }
5668
5671
  const agents = [];
5669
- for (let i = 0; i < inventory.agents.length; i++) {
5670
- const entry = inventory.agents[i];
5672
+ for (let i = 0; i < entries.length; i++) {
5673
+ const entry = entries[i];
5671
5674
  const reply = await this.resumePreservedAgent(entry, false, true, prepared);
5672
5675
  agents.push({ name: entry.name, reply });
5673
5676
  if (!reply.ok) {
5674
- for (const skipped of inventory.agents.slice(i + 1))
5677
+ for (const skipped of entries.slice(i + 1))
5675
5678
  agents.push({ name: skipped.name, reply: { ok: false, error: `not launched because ${entry.name} failed` } });
5676
5679
  return { ok: false, agents, error: reply.error };
5677
5680
  }
@@ -5881,7 +5884,7 @@ export class Manager {
5881
5884
  const slot = await this.readStaticReconcileSlot(entry.name);
5882
5885
  if (slot?.phase !== "active" || slot.actor !== entry.identity.id || slot.lifecycleUid !== entry.identity.lifecycleUid)
5883
5886
  return undefined;
5884
- if (slot.ownerInstanceId !== undefined && slot.ownerInstanceId !== this.managerInstanceId)
5887
+ if (ownedBySibling(slot, this.managerInstanceId))
5885
5888
  return undefined;
5886
5889
  return slot.runtime?.kind === this.runtime.kind ? slot.runtime : undefined;
5887
5890
  }
@@ -6013,7 +6016,7 @@ export class Manager {
6013
6016
  }
6014
6017
  try {
6015
6018
  const resumeConfig = loadCotalConfig(this.workspaceRoot);
6016
- const mcpServers = connectorServers(resumeConfig, entry.launch.connector, parseShareSelection(entry.launch.shareTools));
6019
+ const mcpServers = connectorServers(resumeConfig, entry.launch.connector, entry.launch.shareTools);
6017
6020
  const envAllow = spawnEnvAllow(resumeConfig);
6018
6021
  const launchOpts = {
6019
6022
  space: this.space,
@@ -8705,7 +8708,7 @@ export class Manager {
8705
8708
  console.error(`✓ static reconcile recovered alias=${item.alias} phase=retired uid=${item.lifecycleUid} attempts=${item.attempts}/${item.maxAttempts}`);
8706
8709
  return;
8707
8710
  }
8708
- if (row.ownerInstanceId !== undefined && row.ownerInstanceId !== this.managerInstanceId) {
8711
+ if (ownedBySibling(row, this.managerInstanceId)) {
8709
8712
  item.disposition = "refused";
8710
8713
  item.remedy = "the owning manager must reconcile or an operator must perform an explicit CAS takeover";
8711
8714
  item.lastError = `the durable slot belongs to manager instance ${row.ownerInstanceId}, not ${this.managerInstanceId}`;
@@ -8898,7 +8901,7 @@ export class Manager {
8898
8901
  // hazard, now cross-instance). A legacy row (pre-3b-2, no owner recorded) predates multi-manager,
8899
8902
  // so this manager is its legitimate single-manager-past successor and reconciles it. An orphaned
8900
8903
  // sibling row is reclaimed only by an explicit operator CAS takeover (ruling 1), never here.
8901
- if (row.ownerInstanceId !== undefined && row.ownerInstanceId !== this.managerInstanceId) {
8904
+ if (ownedBySibling(row, this.managerInstanceId)) {
8902
8905
  preservedForeign++;
8903
8906
  continue;
8904
8907
  }