@cotal-ai/manager 0.90.0 → 0.91.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 (48) hide show
  1. package/dist/chat-session.d.ts +165 -0
  2. package/dist/chat-session.d.ts.map +1 -0
  3. package/dist/chat-session.js +281 -0
  4. package/dist/chat-session.js.map +1 -0
  5. package/dist/commands.d.ts +3 -0
  6. package/dist/commands.d.ts.map +1 -1
  7. package/dist/commands.js +374 -163
  8. package/dist/commands.js.map +1 -1
  9. package/dist/endpoint-evict.d.ts +2 -0
  10. package/dist/endpoint-evict.d.ts.map +1 -1
  11. package/dist/endpoint-evict.js +1 -0
  12. package/dist/endpoint-evict.js.map +1 -1
  13. package/dist/holder-liveness.d.ts +1 -0
  14. package/dist/holder-liveness.d.ts.map +1 -1
  15. package/dist/holder-liveness.js.map +1 -1
  16. package/dist/index.d.ts +4 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/manager-service-contract.d.ts +52 -1
  21. package/dist/manager-service-contract.d.ts.map +1 -1
  22. package/dist/manager-service-contract.js +138 -4
  23. package/dist/manager-service-contract.js.map +1 -1
  24. package/dist/manager.d.ts +105 -2
  25. package/dist/manager.d.ts.map +1 -1
  26. package/dist/manager.js +515 -49
  27. package/dist/manager.js.map +1 -1
  28. package/dist/operator-deregister.d.ts +4 -0
  29. package/dist/operator-deregister.d.ts.map +1 -0
  30. package/dist/operator-deregister.js +75 -0
  31. package/dist/operator-deregister.js.map +1 -0
  32. package/dist/service-adapter.d.ts +12 -0
  33. package/dist/service-adapter.d.ts.map +1 -0
  34. package/dist/service-adapter.js +174 -0
  35. package/dist/service-adapter.js.map +1 -0
  36. package/dist/service-child.d.ts +37 -0
  37. package/dist/service-child.d.ts.map +1 -0
  38. package/dist/service-child.js +195 -0
  39. package/dist/service-child.js.map +1 -0
  40. package/dist/service-observation.d.ts +19 -0
  41. package/dist/service-observation.d.ts.map +1 -0
  42. package/dist/service-observation.js +97 -0
  43. package/dist/service-observation.js.map +1 -0
  44. package/dist/static-lifecycle.d.ts +8 -6
  45. package/dist/static-lifecycle.d.ts.map +1 -1
  46. package/dist/static-lifecycle.js +19 -8
  47. package/dist/static-lifecycle.js.map +1 -1
  48. package/package.json +13 -12
package/dist/manager.js CHANGED
@@ -5,6 +5,7 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, s
5
5
  import { isAbsolute, join, dirname, resolve } from "node:path";
6
6
  import Headless from "@xterm/headless";
7
7
  import { CotalEndpoint, canonicalJson, MANAGED_HANDOFF_KIND, accountFromCreds, credsClaims, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, MANAGER_LEASE_TTL_MS, awaitLeaseExpiry, leaseRemainingMs, STANDING_RENEWABLE_TTL_SEC, assertLifecycleToken, discardLaunchArtifacts, divergentSecretStoreNotice, injectedSecretStoreIdentity, parseDaemonStoreAnswer, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, DeprovisionError, isConcreteChannel, deprovisionTargetPrincipal, firstFreeName, spawnNameError, idFromCreds, identityFromCreds, 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, runAgentBearer, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, controlServiceSubject, eventChannelPrincipal, resolveService, invokeCommand, digestIdToken, } from "@cotal-ai/core";
8
+ import { loadLaunchPersonaFile, PersonaSourceError } from "@cotal-ai/workspace";
8
9
  import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, claimIdentityRecord, claimManagerSiblingIdentities, connectorInstallHint, DELIVERY_CREDS_KIND, extensionConnectors, findCotalRoot, getSpaceAuth, hasUserAuthState, loadExtensionsManifest, loadManagerInstanceIdentity, loadMeshes, localTrustOfSpace, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KIND, mergeLaunchOptions, probeLiveness, remintDaemonCreds, resolveAgentType, resolveOnPath, createManagerInstanceIdentity, spaceKey, spaceMaterialKey, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
9
10
  import { createRuntime, exitProof, isCustodialRuntime, pollProvedExit, requireRuntimeAdopt, requireRuntimeReap, RuntimeReapUnproven, watchProvedExit, } from "./runtime/index.js";
10
11
  import { AttachEndpoint } from "./attach-endpoint.js";
@@ -33,6 +34,7 @@ import { ManagerSessionPlane, openSessionLedgerKv } from "./session/index.js";
33
34
  import { provisionEndpointGateOpen, endpointRegistrationBarrier, serveIssuanceGateKv, commitSiblingIssuance, markLedgerRowRevoked, epcredRowKey, epgateKey, parseEndpointGate, readEndpointGateGeneration, registerServingInstance, deregisterServiceInstance, serveEndpoint, bindGoal, createGoal, transitionGoal, commitGoalResult, requestGoalCancel, goalAlreadyTerminal, 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
35
  import { MANAGER_ENDPOINT, managerClusterArtifacts, managerCommandDefs, managerContractArtifactValues } from "./manager-service-contract.js";
35
36
  import { staticLifecycleTransport, activateStaticLifecycle, runStaticTerminal, readStaticSlot, casStaticSlot, recordSlotCredential, appendStaticCredentialRow, planStaticSlotResume, ownedBySibling, observeStaticSlot, listStaticSlotObservations, walkStaticSlots, renderStaticSlotObservation, StaticSlotReadError, STATIC_SLOT_READ_FAILED_DETAIL, } from "./static-lifecycle.js";
37
+ import { CHAT_ALIAS_PREFIX, CHAT_EXPIRY_CONCURRENCY, ChatSessionExpiry, chatOpenLists, chatRetireRefusal, drawChatAlias, isChatAlias, readChatOpenInput, readChatRetireInput, sealChatAdmission, } from "./chat-session.js";
36
38
  // Capture the loaded package version before an installation can replace its files.
37
39
  const MANAGER_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
38
40
  /** Minimum slot lifetime for rate-flooring (P4c). A slot freed (by despawn OR natural exit/reap)
@@ -415,6 +417,7 @@ export class Manager {
415
417
  * {@link provisionEndpointGateOpen} idempotent (no core barrier change) and gives verified
416
418
  * eviction a stable target (the predecessor's connections under this principal). Set in start(). */
417
419
  managerServeIdentity;
420
+ onServiceIdentity;
418
421
  /** The manager's v0.4 service-endpoint serve state — the serve handle +
419
422
  * its dedicated connection, the STABLE serve identity (renewals re-mint the same nkey), the
420
423
  * branded serve grant, and the CURRENT credential (the connection's authenticator reads it on
@@ -525,6 +528,15 @@ export class Manager {
525
528
  /** Static aliases whose startup exact-op terminal is still in flight. Scoped per alias: control
526
529
  * serves during the sweep, but no caller can reuse or attach an alias the sweep still owns. */
527
530
  reconcilingAliases = new Set();
531
+ /** In-flight static chat-session terminals, by lifecycle UID, so every caller that retires one
532
+ * session joins one terminal instead of racing its CAS sequence. */
533
+ chatRetirements = new Map();
534
+ /** The expiry side of every chat session this process admitted and nothing retired sooner. */
535
+ chatExpiry = new ChatSessionExpiry((session) => this.expireStaticChatSession(session), {
536
+ retryDelaysMs: STATIC_RECONCILE_RETRY_DELAYS_MS,
537
+ concurrency: CHAT_EXPIRY_CONCURRENCY,
538
+ log: (line) => console.error(line),
539
+ });
528
540
  /** Current failed/recovered static reconciliation coordinates. This is an operator snapshot, not
529
541
  * lifecycle authority: every re-drive re-reads broker KV and re-runs planStaticSlotResume. */
530
542
  staticReconcileItems = new Map();
@@ -641,11 +653,16 @@ export class Manager {
641
653
  return dialerFor(servers)({ ...opts, servers });
642
654
  }
643
655
  constructor(opts) {
656
+ if (opts.onServiceIdentity !== undefined && typeof opts.onServiceIdentity !== "function")
657
+ throw new Error("manager onServiceIdentity must be a trusted embedding callback");
644
658
  if (opts.execution !== undefined && opts.execution !== "runtime" && opts.execution !== "none")
645
659
  throw new Error(`unknown manager execution capability ${JSON.stringify(opts.execution)}`);
646
- if (opts.execution === "none" && (!opts.pooled || !opts.remoteAuthority || opts.runtime !== undefined))
647
- throw new Error("execution: none requires pooled signerless remote authority and no runtime option");
648
- if (opts.execution === "none" && ["renewExecutor", "renewStandingBundle", "mintSessionServing", "mintRetirementRequester", "prepareAgentRetirement", "validateRetainedAgent", "scanGoalIndex", "authorizeAdmin"].some((key) => typeof opts.remoteAuthority?.[key] !== "function"))
660
+ if (opts.execution === "none" && ((opts.pooled !== undefined && typeof opts.pooled !== "boolean") ||
661
+ (opts.remoteAuthority !== undefined && (!opts.remoteAuthority || typeof opts.remoteAuthority !== "object" || Array.isArray(opts.remoteAuthority)))))
662
+ throw new Error("execution: none requires boolean pooled and a non-null remote authority object when supplied");
663
+ if (opts.execution === "none" && (opts.runtime !== undefined || (!!opts.pooled !== !!opts.remoteAuthority)))
664
+ throw new Error("execution: none requires pooled signerless remote authority for remote composition, or genuine local authority, and no runtime option");
665
+ if (opts.execution === "none" && opts.remoteAuthority && ["renewExecutor", "renewStandingBundle", "mintSessionServing", "mintRetirementRequester", "prepareAgentRetirement", "validateRetainedAgent", "scanGoalIndex", "authorizeAdmin"].some((key) => typeof opts.remoteAuthority?.[key] !== "function"))
649
666
  throw new Error("execution: none requires complete closed remote authority callbacks before construction");
650
667
  if (opts.pooled && (!opts.remoteAuthority || (opts.execution !== "none" && (opts.runtime === undefined || opts.runtime === "auto" || opts.runtime === "pty"))))
651
668
  throw new Error("pooled control requires signerless remote authority and an explicit non-PTY runtime; local custodial execution is forbidden");
@@ -655,6 +672,7 @@ export class Manager {
655
672
  this.servers = opts.servers;
656
673
  this.name = opts.name ?? "manager";
657
674
  this.workspaceRoot = opts.workspaceRoot ?? findCotalRoot();
675
+ this.onServiceIdentity = opts.onServiceIdentity;
658
676
  this.eventsRequired = opts.eventsRequired === true;
659
677
  this.maxSessions = opts.maxSessions;
660
678
  this.remoteAuthority = opts.remoteAuthority;
@@ -670,6 +688,11 @@ export class Manager {
670
688
  this.secretStoreIdentity = injectedSecretStoreIdentity(opts.secretStore);
671
689
  this.installedExtensions = opts.installedExtensions ?? false;
672
690
  this.executionState = opts.execution === "none" ? { kind: "none" } : { kind: "runtime" };
691
+ if (!this.remoteAuthority) {
692
+ const local = loadManagerInstanceIdentity(this.workspaceRoot, this.space);
693
+ if (local && existsSync(this.executionRecordPath(local.instanceId)))
694
+ this.claimExecutionRecord(local.instanceId);
695
+ }
673
696
  if (this.executionState.kind === "runtime")
674
697
  this.runtime = createRuntime(opts.runtime ?? "auto", `cotal-${this.space}`);
675
698
  if (opts.pooled && this.executionState.kind === "runtime" && isCustodialRuntime(this.requiredRuntime))
@@ -707,7 +730,7 @@ export class Manager {
707
730
  return this.executionState.kind;
708
731
  }
709
732
  executionRefusal() {
710
- return "execution: none forbids seat launch and workflow hosting on this control instance; select the participant Manager with an instance-pinned manager-caller view";
733
+ return "execution: none forbids seat launch, workflow hosting, model inspection and caller-driven writes on this control instance; select the participant Manager with an instance-pinned manager-caller view";
711
734
  }
712
735
  /** A defensive last barrier, not a substitute Runtime. Entry admission runs before effects. */
713
736
  get requiredRuntime() {
@@ -730,6 +753,37 @@ export class Manager {
730
753
  throw new EpEnvelopeError("unimplemented", this.executionRefusal());
731
754
  return requireRuntimeAdopt(this.requiredRuntime, reference);
732
755
  }
756
+ /** Snapshot this embedding's own registration and stop policy. It is not a fresh serving
757
+ * response, registration-leader read or authorization for another process to act. Wire callers
758
+ * still use status through its fresh owner/admin visibility gate. No credential or URL leaves. */
759
+ localServiceObservation() {
760
+ const state = this.serviceServe;
761
+ if (!state)
762
+ return undefined;
763
+ const identity = this.serviceIdentityObservation();
764
+ if (state.grant.endpoint !== identity.endpoint || state.grant.instanceId !== identity.instanceId || state.grant.owner !== identity.principal.owner)
765
+ throw new Error("the manager's local service observation does not match its owned serve incarnation");
766
+ const stopImpact = this.managerStatusData(undefined).stopImpact;
767
+ if (!stopImpact)
768
+ throw new Error("the manager did not expose its own native stop policy");
769
+ // The local receipt keeps its revision-32 shape: an older controller's closed reader refuses an
770
+ // extra key. The preserved-exit check reads the binding through `localPreservation` instead.
771
+ const { preservation: _preservation, ...ordinary } = stopImpact;
772
+ return { registration: { ...identity, epoch: state.grant.epoch }, stopImpact: ordinary };
773
+ }
774
+ /** The attempt and inventory digest this embedding holds while maintenance is not `active`, for
775
+ * its own native child only. Absent when active or before an inventory exists. */
776
+ localPreservation() {
777
+ return this.managerStatusData(undefined).stopImpact?.preservation;
778
+ }
779
+ serviceIdentityObservation() {
780
+ if (!this.managerInstanceId || !this.managerServeIdentity)
781
+ throw new Error("manager native service identity has not been selected");
782
+ const principal = this.remoteAuthority
783
+ ? { owner: this.remoteAuthority.owner, actor: this.remoteAuthority.actors.serve, uid: this.managerLifecycleUid }
784
+ : { owner: DEV_OWNER, actor: this.managerServeIdentity.id, uid: this.managerLifecycleUid };
785
+ return Object.freeze({ endpoint: MANAGER_ENDPOINT, instanceId: this.managerInstanceId, principal: Object.freeze(principal) });
786
+ }
733
787
  /** The console page URL (manager-hosted, loopback). */
734
788
  get consoleUrl() {
735
789
  return this.attach.consoleUrl();
@@ -831,12 +885,64 @@ export class Manager {
831
885
  this.startTask = wrapped;
832
886
  return wrapped;
833
887
  }
888
+ executionRecordPath(instanceId) {
889
+ return join(this.workspaceRoot, ".cotal", `manager-execution.${spaceKey(this.space)}.${instanceId}.json`);
890
+ }
891
+ claimExecutionRecord(instanceId) {
892
+ const record = claimIdentityRecord(this.executionRecordPath(instanceId), "manager execution capability", (raw) => {
893
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
894
+ return undefined;
895
+ const r = raw;
896
+ return Object.keys(r).sort().join(",") === "execution,instanceId,space,v" && r.v === 1 && r.space === this.space &&
897
+ r.instanceId === instanceId && (r.execution === "none" || r.execution === "runtime") ? r : undefined;
898
+ }, () => ({ v: 1, space: this.space, instanceId, execution: this.execution }));
899
+ if (record.execution !== this.execution)
900
+ throw new Error("manager execution capability differs from its retained instance record; execution cannot change across restart");
901
+ }
834
902
  async admitExecutionState() {
903
+ if (!this.remoteAuthority) {
904
+ let local = loadManagerInstanceIdentity(this.workspaceRoot, this.space);
905
+ if (this.execution === "none") {
906
+ const recorded = loadMeshes().find((m) => m.space === this.space);
907
+ const auth = hasUserAuthState(this.workspaceRoot, this.space) && recorded?.mode === "user"
908
+ ? await getSpaceAuth(this.secrets, this.space) : undefined;
909
+ if (!auth)
910
+ throw new Error("execution: none requires a genuine hosting user-auth root and its user-mode mesh record");
911
+ if (local && !existsSync(this.executionRecordPath(local.instanceId)))
912
+ throw new Error("execution: none refuses an existing unclassified manager instance; keep its runtime and retained state, or register a fresh control instance");
913
+ if (!local) {
914
+ const proposed = { instanceId: mintLifecycleUid(), serveIdentity: newIdentity() };
915
+ local = createManagerInstanceIdentity(this.workspaceRoot, this.space, proposed);
916
+ if (local.instanceId !== proposed.instanceId)
917
+ throw new Error("execution: none refuses a concurrently claimed unclassified manager identity");
918
+ }
919
+ const localInstanceId = local.instanceId;
920
+ this.claimExecutionRecord(localInstanceId);
921
+ const nc = await this.dial({ ...standaloneConnectOpts({ creds: await mintCreds(auth, newIdentity(), "provisioner"), tls: false }), maxReconnectAttempts: 0 });
922
+ try {
923
+ const kvm = new Kvm(nc);
924
+ await ensureAuthorityStores(await jetstreamManager(nc), kvm, this.space);
925
+ const entries = await listGoalIndex(await kvm.open(recordsBucket(this.space)), MANAGER_ENDPOINT);
926
+ if (entries.some((entry) => entry.iid === localInstanceId && (entry.allocated !== undefined || entry.note !== undefined)))
927
+ throw new Error("execution: none refuses retained seat or turn inventory before recovery; keep the execution-owning Manager");
928
+ }
929
+ finally {
930
+ await nc.drain().catch(() => nc.close());
931
+ }
932
+ }
933
+ else {
934
+ local ??= createManagerInstanceIdentity(this.workspaceRoot, this.space, { instanceId: mintLifecycleUid(), serveIdentity: newIdentity() });
935
+ this.claimExecutionRecord(local.instanceId);
936
+ }
937
+ return;
938
+ }
835
939
  if (this.remoteAuthority) {
940
+ if (this.execution === "none" && (await localTrustOfSpace(this.secrets, this.space)).present)
941
+ throw new Error("remote manager-service authority cannot be combined with local space signing trust - choose one authority path, never a fallback");
836
942
  // Execution is fixed for a registered instance. An older instance with no capability
837
943
  // record might own retained seats/runs, so it cannot be converted to none by omission.
838
944
  const remote = this.remoteAuthority;
839
- const path = join(this.workspaceRoot, ".cotal", `manager-execution.${spaceKey(this.space)}.${remote.instanceId}.json`);
945
+ const path = this.executionRecordPath(remote.instanceId);
840
946
  if (this.execution === "none" && !existsSync(path) && remote.serveGrant.epoch !== 0)
841
947
  throw new Error("execution: none refuses an existing unclassified manager instance; keep its runtime and retained state, or register a fresh control instance");
842
948
  if (this.execution === "none") {
@@ -844,17 +950,8 @@ export class Manager {
844
950
  if (entries.some((entry) => entry.iid === remote.instanceId && (entry.allocated !== undefined || entry.note !== undefined)))
845
951
  throw new Error("execution: none refuses retained seat or turn inventory before recovery; keep the execution-owning Manager");
846
952
  }
847
- if (this.execution === "none" || existsSync(path)) {
848
- const record = claimIdentityRecord(path, "manager execution capability", (raw) => {
849
- if (!raw || typeof raw !== "object" || Array.isArray(raw))
850
- return undefined;
851
- const r = raw;
852
- return Object.keys(r).sort().join(",") === "execution,instanceId,space,v" && r.v === 1 && r.space === this.space &&
853
- r.instanceId === remote.instanceId && (r.execution === "none" || r.execution === "runtime") ? r : undefined;
854
- }, () => ({ v: 1, space: this.space, instanceId: remote.instanceId, execution: this.execution }));
855
- if (record.execution !== this.execution)
856
- throw new Error("manager execution capability differs from its retained instance record; execution cannot change across restart");
857
- }
953
+ if (this.execution === "none" || existsSync(path))
954
+ this.claimExecutionRecord(remote.instanceId);
858
955
  }
859
956
  }
860
957
  async runStart() {
@@ -931,6 +1028,7 @@ export class Manager {
931
1028
  }
932
1029
  }
933
1030
  }
1031
+ await this.onServiceIdentity?.(this.serviceIdentityObservation());
934
1032
  let creds;
935
1033
  let id;
936
1034
  if (this.remoteAuthority) {
@@ -1164,7 +1262,7 @@ export class Manager {
1164
1262
  }
1165
1263
  // A carried resume (#1499) needs this instance's transfer reader: minted from the space's signing
1166
1264
  // seed, issued by the host to a remote manager, or none on an open mesh.
1167
- if (!this.remoteAuthority || this.remoteAuthority.mintTransferReader) {
1265
+ if (this.execution !== "none" && (!this.remoteAuthority || this.remoteAuthority.mintTransferReader)) {
1168
1266
  this.transcripts = new TranscriptReceiver({
1169
1267
  space: this.space,
1170
1268
  instanceId: this.managerInstanceId,
@@ -1754,6 +1852,8 @@ export class Manager {
1754
1852
  /** Fence and build the inventory without stopping a child. The coordinator must durably persist
1755
1853
  * this exact plan before calling commitPreservation with the same attempt id. */
1756
1854
  preparePreservation(attemptId) {
1855
+ if (this.execution === "none")
1856
+ return Promise.reject(new EpEnvelopeError("unimplemented", this.executionRefusal()));
1757
1857
  if (!attemptId.trim())
1758
1858
  return Promise.reject(new Error("preservation attemptId is required"));
1759
1859
  if (this.preservationAttemptId && this.preservationAttemptId !== attemptId)
@@ -1857,6 +1957,8 @@ export class Manager {
1857
1957
  /** Stop children only after the coordinator has persisted the prepared inventory. Same-attempt
1858
1958
  * retries are idempotent; a different attempt is refused. */
1859
1959
  commitPreservation(attemptId) {
1960
+ if (this.execution === "none")
1961
+ return Promise.reject(new EpEnvelopeError("unimplemented", this.executionRefusal()));
1860
1962
  if (!this.preservationAttemptId || this.preservationAttemptId !== attemptId)
1861
1963
  return Promise.reject(new Error(`preservation attempt ${attemptId} was not prepared by this manager`));
1862
1964
  if (!this.preservationInventory)
@@ -1882,6 +1984,8 @@ export class Manager {
1882
1984
  /** Recover an abandoned prepare before any child stop. Once commit begins, preservation is
1883
1985
  * irreversible and remains fenced until the coordinator records failure/recourse. */
1884
1986
  abortPreservation(attemptId) {
1987
+ if (this.execution === "none")
1988
+ throw new EpEnvelopeError("unimplemented", this.executionRefusal());
1885
1989
  if (this.preservationAttemptId !== attemptId)
1886
1990
  throw new Error(`preservation attempt ${attemptId} is not the active manager attempt`);
1887
1991
  if (this.preparationTask || this.lifecycleInFlight > 0)
@@ -2144,11 +2248,14 @@ export class Manager {
2144
2248
  if (failures.length)
2145
2249
  throw new Error(`manager shutdown could not prove every seat exited: ${failures.join("; ")}`);
2146
2250
  }
2251
+ isProcessBoundSeat(agent) {
2252
+ return agent.handle.kind === "pty" && typeof agent.handle.release !== "function";
2253
+ }
2147
2254
  /** Drop this manager's local custody of every seat that can outlive it. A pty seat whose handle
2148
2255
  * cannot be released lives in this process and closes with it, so it is left for {@link stop} to
2149
2256
  * stop instead. */
2150
2257
  releaseManagedAgents() {
2151
- const managed = [...this.agents.values()].filter((a) => !(a.handle.kind === "pty" && typeof a.handle.release !== "function"));
2258
+ const managed = [...this.agents.values()].filter((a) => !this.isProcessBoundSeat(a));
2152
2259
  const failures = [];
2153
2260
  for (const a of managed) {
2154
2261
  try {
@@ -2248,6 +2355,7 @@ export class Manager {
2248
2355
  this.leaseStopping = true;
2249
2356
  this.transcripts?.stop();
2250
2357
  this.staticReconcileStopping = true;
2358
+ this.chatExpiry.stop();
2251
2359
  const starting = this.startTask;
2252
2360
  for (const item of this.staticReconcileItems.values()) {
2253
2361
  if (item.timer)
@@ -2286,7 +2394,7 @@ export class Manager {
2286
2394
  else {
2287
2395
  // An in-process seat cannot be detached from the process that is exiting, so the default
2288
2396
  // stop stops and deprovisions it as `withAgents` does instead of orphaning its footprint.
2289
- const inProcess = [...this.agents.values()].filter((a) => a.handle.kind === "pty" && typeof a.handle.release !== "function");
2397
+ const inProcess = [...this.agents.values()].filter((a) => this.isProcessBoundSeat(a));
2290
2398
  releaseFailures = this.releaseManagedAgents();
2291
2399
  await this.teardownManagedAgents(inProcess);
2292
2400
  }
@@ -2310,6 +2418,13 @@ export class Manager {
2310
2418
  failures.push(`${name}: ${rejectionText(e)}`);
2311
2419
  }
2312
2420
  };
2421
+ // The expiry that would end a chat session stops with this process, and nothing can adopt one,
2422
+ // so an active stop retires every session this process admitted, under either agent policy, as
2423
+ // it does an in-process seat. A preserving stop leaves them to the next start's sweep.
2424
+ if (this.maintenanceState === "active" && !this.resumeRequired)
2425
+ await step("retire chat sessions", () => this.retireChatSessionsOnStop());
2426
+ else
2427
+ this.reportChatSessionsLeftAtStop(this.resumeRequired ? "this manager is waiting for a resume" : "this manager is preserving its state");
2313
2428
  // A seat that may still be alive keeps this instance's leases and registration (see
2314
2429
  // teardownManagedAgents): they lapse on their TTL instead of being handed to a successor.
2315
2430
  if (seatFailure === undefined) {
@@ -2555,6 +2670,8 @@ export class Manager {
2555
2670
  return { kind: "held", revision: current.revision };
2556
2671
  }
2557
2672
  async opFinalizeResume(rawArgs) {
2673
+ if (this.execution === "none")
2674
+ throw new EpEnvelopeError("unimplemented", this.executionRefusal());
2558
2675
  {
2559
2676
  let args;
2560
2677
  try {
@@ -2605,6 +2722,8 @@ export class Manager {
2605
2722
  }
2606
2723
  }
2607
2724
  async opCommitResume(rawArgs) {
2725
+ if (this.execution === "none")
2726
+ throw new EpEnvelopeError("unimplemented", this.executionRefusal());
2608
2727
  {
2609
2728
  let attemptId;
2610
2729
  try {
@@ -2638,11 +2757,11 @@ export class Manager {
2638
2757
  }
2639
2758
  }
2640
2759
  async opResumePreserved(rawArgs) {
2760
+ if (this.execution === "none")
2761
+ throw new EpEnvelopeError("unimplemented", this.executionRefusal());
2641
2762
  {
2642
2763
  try {
2643
2764
  const args = parseResumeControlArgs(rawArgs);
2644
- if (this.execution === "none" && args.inventory.agents.length !== 0)
2645
- return { ok: false, error: this.executionRefusal() };
2646
2765
  const inventoryDigest = createHash("sha256").update(JSON.stringify(args.inventory)).digest("hex");
2647
2766
  if (!this.resumeAttemptId)
2648
2767
  return { ok: false, error: "resumePreserved requires a manager started with --resume-attempt" };
@@ -2675,6 +2794,8 @@ export class Manager {
2675
2794
  }
2676
2795
  }
2677
2796
  async opPreservationCtl(op, rawArgs) {
2797
+ if (this.execution === "none")
2798
+ throw new EpEnvelopeError("unimplemented", this.executionRefusal());
2678
2799
  if (this.resumeRequired)
2679
2800
  return { ok: false, error: this.maintenanceError() };
2680
2801
  const attemptId = String(rawArgs?.attemptId ?? "").trim();
@@ -2726,8 +2847,9 @@ export class Manager {
2726
2847
  async serveGated(ctx, fn) {
2727
2848
  const caller = principalKey(ctx.subject.caller.owner, ctx.subject.caller.actor).key;
2728
2849
  const admission = this.admitControl(caller);
2850
+ // Both fences refuse before the handler runs, so nothing ran (SPEC 13.3).
2729
2851
  if (admission.refusal !== undefined)
2730
- throw new EpEnvelopeError(admission.fence === "membership" ? "permission-denied" : "unavailable", admission.refusal);
2852
+ throw new EpEnvelopeError(admission.fence === "membership" ? "permission-denied" : "unavailable", admission.refusal, undefined, "not-executed");
2731
2853
  try {
2732
2854
  return await fn();
2733
2855
  }
@@ -2735,6 +2857,29 @@ export class Manager {
2735
2857
  admission.release();
2736
2858
  }
2737
2859
  }
2860
+ /** `status` only: {@link serveGated}, except that a fully preserved manager, and an active one
2861
+ * launched for a resume that has not committed it yet, still answer it. A preserved-exit stop's
2862
+ * caller-attributed preview needs the preserved manager's stop impact, and a resumed service's
2863
+ * readiness reads the same status; the maintenance fence refuses every other command in both
2864
+ * states. The read is counted in `lifecycleInFlight` like any admitted operation and released in
2865
+ * `finally`, so a drain waits at most for one status snapshot; the membership gate still
2866
+ * applies. `preserving` and a stopping manager stay fenced. Status writes nothing. */
2867
+ async serveObservation(ctx, fn) {
2868
+ const preserved = this.maintenanceState === "preserved" && !this.resumeRequired;
2869
+ const resumePending = this.maintenanceState === "active" && this.resumeRequired;
2870
+ if (this.leaseStopping || !preserved && !resumePending)
2871
+ return this.serveGated(ctx, fn);
2872
+ const membership = this.lifecycleMembershipRefusal(principalKey(ctx.subject.caller.owner, ctx.subject.caller.actor).key);
2873
+ if (membership)
2874
+ throw new EpEnvelopeError("permission-denied", membership, undefined, "not-executed");
2875
+ this.lifecycleInFlight++;
2876
+ try {
2877
+ return await fn();
2878
+ }
2879
+ finally {
2880
+ this.releaseLifecycle();
2881
+ }
2882
+ }
2738
2883
  /** The ep door's ADMIN flag for a caller. Static mesh: `true` — the
2739
2884
  * admin-grade rows (any-mode despawn/attach, the `manager.admin` family, `launch`) are minted
2740
2885
  * only into operator instruments (§13.2: `any` is operator-policy-mintable; the agent/spawn
@@ -2898,20 +3043,22 @@ export class Manager {
2898
3043
  // serveGated BYPASS (those ops must run while the maintenance fence holds) but not the gate.
2899
3044
  const adminGated = async (ctx, fn) => {
2900
3045
  if (!(await this.epAdminReach(ctx.subject.caller)))
2901
- throw new EpEnvelopeError("permission-denied", `${ctx.subject.command} is operator reach; the caller's current ledger grant does not carry "admin" (SPEC 13.2)`);
3046
+ throw new EpEnvelopeError("permission-denied", `${ctx.subject.command} is operator reach; the caller's current ledger grant does not carry "admin" (SPEC 13.2)`, undefined, "not-executed");
2902
3047
  return fn();
2903
3048
  };
2904
3049
  const targetAgent = (ctx) => {
3050
+ if (this.execution === "none")
3051
+ throw new EpEnvelopeError("unimplemented", this.executionRefusal());
2905
3052
  const t = ctx.request.target; // targeted commands only: the serve boundary enforced body-target presence + fresh currency
2906
3053
  const a = this.findManagedByTarget(t);
2907
3054
  if (!a)
2908
- throw new EpEnvelopeError("expired", `target ${t.owner}.${t.actor} (lifecycle ${t.lifecycleUid}) is not a live managed agent of this manager`);
3055
+ throw new EpEnvelopeError("expired", `target ${t.owner}.${t.actor} (lifecycle ${t.lifecycleUid}) is not a live managed agent of this manager`, undefined, "not-executed");
2909
3056
  return a;
2910
3057
  };
2911
3058
  return managerCommandDefs({
2912
3059
  // A remote Manager files every seat under its own owner, so only a user mesh's shared local
2913
3060
  // Manager narrows the count, and status never waits on a remote admin check.
2914
- status: (ctx) => this.serveGated(ctx, async () => this.managerStatusData(this.remoteAuthority ? undefined : await this.psOwnerFilter(ctx.subject.caller, false))),
3061
+ status: (ctx) => this.serveObservation(ctx, async () => this.managerStatusData(this.remoteAuthority ? undefined : await this.psOwnerFilter(ctx.subject.caller, false))),
2915
3062
  ps: (ctx) => this.serveGated(ctx, async () => this.list(await this.psOwnerFilter(ctx.subject.caller, false))),
2916
3063
  inspect: (ctx) => this.serveGated(ctx, async () => {
2917
3064
  const name = String(args(ctx).name ?? "").trim();
@@ -2966,7 +3113,11 @@ export class Manager {
2966
3113
  ]);
2967
3114
  let observed;
2968
3115
  try {
2969
- observed = await listStaticSlotObservations(recordsKv, DEV_OWNER, this.managerInstanceId);
3116
+ const query = args(ctx);
3117
+ observed = await listStaticSlotObservations(recordsKv, DEV_OWNER, this.managerInstanceId, {
3118
+ ...(query.names === undefined ? {} : { names: query.names }),
3119
+ ...(query.includeRetired === true ? { includeRetired: true } : {}),
3120
+ });
2970
3121
  }
2971
3122
  catch (error) {
2972
3123
  throw new EpEnvelopeError("unavailable", `the durable static slot state could not be scanned: ${rejectionText(error)}`, [
@@ -2995,9 +3146,11 @@ export class Manager {
2995
3146
  }),
2996
3147
  resolveCwd: (ctx) => this.serveGated(ctx, () => this.resolveSpawnCwd(args(ctx).cwd)),
2997
3148
  resolveAgent: (ctx) => this.serveGated(ctx, async () => {
3149
+ if (this.execution === "none")
3150
+ throw new EpEnvelopeError("unimplemented", this.executionRefusal());
2998
3151
  const persona = await this.spawnPersona(args(ctx), callerOf(ctx), ctx.subject.caller);
2999
3152
  if ("error" in persona)
3000
- throw new EpEnvelopeError(persona.error.startsWith("no persona") ? "not-found" : "failed-precondition", persona.error);
3153
+ throw new EpEnvelopeError(persona.code ?? (persona.error.startsWith("no persona") ? "not-found" : "failed-precondition"), persona.error);
3001
3154
  return { agent: persona.agent };
3002
3155
  }),
3003
3156
  // `spawn` is an ACTION - accept a goal + reply the acceptance floor payload, drive
@@ -3011,14 +3164,14 @@ export class Manager {
3011
3164
  const a = targetAgent(ctx);
3012
3165
  const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
3013
3166
  if (denied)
3014
- throw new EpEnvelopeError("permission-denied", denied);
3167
+ throw new EpEnvelopeError("permission-denied", denied, undefined, "not-executed");
3015
3168
  return unwrap(this.despawnAuthorized(a, args(ctx).graceful !== false, true, callerOf(ctx)));
3016
3169
  }),
3017
3170
  attach: (ctx) => this.serveGated(ctx, async () => {
3018
3171
  const a = targetAgent(ctx);
3019
3172
  const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
3020
3173
  if (denied)
3021
- throw new EpEnvelopeError("permission-denied", denied);
3174
+ throw new EpEnvelopeError("permission-denied", denied, undefined, "not-executed");
3022
3175
  return unwrap(await this.attachAuthorized(a, ctx.subject.caller));
3023
3176
  }),
3024
3177
  // C3 `input`: the SAME authorization as `attach`, deliberately written out rather than
@@ -3028,7 +3181,7 @@ export class Manager {
3028
3181
  const a = targetAgent(ctx);
3029
3182
  const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
3030
3183
  if (denied)
3031
- throw new EpEnvelopeError("permission-denied", denied);
3184
+ throw new EpEnvelopeError("permission-denied", denied, undefined, "not-executed");
3032
3185
  return this.inputAuthorized(a, args(ctx));
3033
3186
  }),
3034
3187
  // The turn relay (§8 durable actions): `turn` shares despawn/input's reach policy — the
@@ -3038,7 +3191,7 @@ export class Manager {
3038
3191
  const a = targetAgent(ctx);
3039
3192
  const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
3040
3193
  if (denied)
3041
- throw new EpEnvelopeError("permission-denied", denied);
3194
+ throw new EpEnvelopeError("permission-denied", denied, undefined, "not-executed");
3042
3195
  return this.serveTurnGoal(ctx, a);
3043
3196
  }),
3044
3197
  // The seat's own half of the relay (`manager.self`, stop-self's tier): a seat pulls the
@@ -3050,6 +3203,10 @@ export class Manager {
3050
3203
  cancel: (ctx) => this.serveGated(ctx, () => this.serveTurnCancel(ctx)),
3051
3204
  stopSelf: (ctx) => this.serveGated(ctx, () => unwrap(this.opStopSelf(callerOf(ctx), args(ctx)))),
3052
3205
  definePersona: (ctx) => this.serveGated(ctx, () => unwrap(this.opDefinePersona(args(ctx), callerOf(ctx), false))),
3206
+ // Reach is the broker's: only operator instruments carry these rows. The serve gate is the
3207
+ // maintenance fence, so neither runs while a shutdown or resume holds control.
3208
+ chatSessionOpen: (ctx) => this.serveGated(ctx, () => this.openStaticChatSession(args(ctx))),
3209
+ chatSessionRetire: (ctx) => this.serveGated(ctx, () => this.retireStaticChatSession(args(ctx))),
3053
3210
  listPersonas: (ctx) => this.serveGated(ctx, () => unwrap(this.opListPersonas(callerOf(ctx), false))),
3054
3211
  showPersona: (ctx) => this.serveGated(ctx, () => {
3055
3212
  const r = this.opShowPersona(args(ctx), callerOf(ctx), false);
@@ -3094,6 +3251,8 @@ export class Manager {
3094
3251
  commitPreservation: (ctx) => adminGated(ctx, async () => unwrap(await this.opPreservationCtl("commitPreservation", args(ctx)))),
3095
3252
  abortPreservation: (ctx) => adminGated(ctx, async () => unwrap(await this.opPreservationCtl("abortPreservation", args(ctx)))),
3096
3253
  transcriptReceive: (ctx) => this.serveGated(ctx, () => adminGated(ctx, () => {
3254
+ if (this.execution === "none")
3255
+ throw new EpEnvelopeError("unimplemented", this.executionRefusal());
3097
3256
  if (!this.transcripts)
3098
3257
  throw new EpEnvelopeError("failed-precondition", "transcript-receive: this manager does not receive carried transcripts (its remote authority issues no transfer reader)");
3099
3258
  return this.transcripts.receive(args(ctx));
@@ -3231,6 +3390,8 @@ export class Manager {
3231
3390
  * hitting another agent. Non-managed callers (human CLI, the manager itself, observers) find
3232
3391
  * no match and get a loud error, not a silent no-op. */
3233
3392
  opStopSelf(callerId, args) {
3393
+ if (this.execution === "none")
3394
+ throw new EpEnvelopeError("unimplemented", this.executionRefusal());
3234
3395
  const target = [...this.agents.values()].find((a) => this.managedPrincipal(a) === callerId);
3235
3396
  if (!target)
3236
3397
  return { ok: false, error: `self-stop: caller ${callerId} is not a managed agent` };
@@ -3390,6 +3551,9 @@ export class Manager {
3390
3551
  parent: spawnerPr ? opts.spawner : undefined,
3391
3552
  label: opts.label,
3392
3553
  lifecycleUid: opts.lifecycleUid,
3554
+ // This manager attests its own persisted logical instance id: the same id a static slot
3555
+ // records as its owner. Recovery binds a managed row to a manager only through this.
3556
+ custody: { kind: "local-manager", instanceId: this.managerInstanceId },
3393
3557
  });
3394
3558
  // Durables + ACL row, LIFECYCLE-keyed (SPEC 13.1) — the same onboarding as static agents minus
3395
3559
  // the mint (a user agent's credential is its bearer, minted by the callout per connect from the
@@ -4631,6 +4795,8 @@ export class Manager {
4631
4795
  /** Resolve a placed spawn directory on this manager's host. Delegated execution refuses before
4632
4796
  * filesystem lookup, since its seats cannot use host cwd. Creates nothing and never substitutes the workspace root. */
4633
4797
  resolveSpawnCwd(value) {
4798
+ if (this.execution === "none")
4799
+ throw new EpEnvelopeError("unimplemented", this.executionRefusal());
4634
4800
  if (this.execution === "runtime" && this.requiredRuntime.spawnDelegated)
4635
4801
  throw new EpEnvelopeError("failed-precondition", `runtime "${this.requiredRuntime.kind}" starts seats outside this host, so it cannot resolve a directory on this host's filesystem (cwd); the seat runs in its runtime resource's own directory`);
4636
4802
  const asked = typeof value === "string" ? value : String(value ?? "");
@@ -4786,6 +4952,8 @@ export class Manager {
4786
4952
  * local model-list API reports `supported:false` rather than blocking the manager. A connector that
4787
4953
  * fails to import shows an `error:` row (from manifest enumeration) and never blocks the others. */
4788
4954
  async opModels(args) {
4955
+ if (this.execution === "none")
4956
+ throw new EpEnvelopeError("unimplemented", this.executionRefusal());
4789
4957
  const requested = String(args.agent ?? "").trim();
4790
4958
  const refresh = args.refresh === true;
4791
4959
  const one = async (connector) => {
@@ -4796,7 +4964,7 @@ export class Manager {
4796
4964
  return { agent: connector.name, supported: true, models: [], error: harness.refusal };
4797
4965
  try {
4798
4966
  const catalog = await connector.listModels({ refresh });
4799
- return { agent: connector.name, supported: true, ...catalog };
4967
+ return { agent: connector.name, supported: catalog.unavailable === undefined, ...catalog };
4800
4968
  }
4801
4969
  catch (e) {
4802
4970
  return { agent: connector.name, supported: true, models: [], error: rejectionText(e) };
@@ -4883,6 +5051,7 @@ export class Manager {
4883
5051
  spec = parseLaunchSpec(args.spec, `inline launch spec (run ${runId})`);
4884
5052
  if (spec.runId !== runId)
4885
5053
  return { ok: false, error: `inline launch spec runId "${spec.runId}" does not match requested "${runId}"` };
5054
+ hooks?.onEffect?.();
4886
5055
  persistLaunchSpec(this.workspaceRoot, spec);
4887
5056
  }
4888
5057
  catch (e) {
@@ -4914,6 +5083,7 @@ export class Manager {
4914
5083
  }
4915
5084
  let configPath;
4916
5085
  try {
5086
+ hooks?.onEffect?.();
4917
5087
  configPath = materializePersona(this.workspaceRoot, runId, la);
4918
5088
  }
4919
5089
  catch (e) {
@@ -4995,10 +5165,10 @@ export class Manager {
4995
5165
  let def;
4996
5166
  if (!opts.resolved) {
4997
5167
  try {
4998
- def = loadAgentFile(configPath);
5168
+ def = loadLaunchPersonaFile(configPath);
4999
5169
  }
5000
5170
  catch (e) {
5001
- return { error: rejectionText(e) };
5171
+ return { error: rejectionText(e), ...(e instanceof PersonaSourceError ? { code: "bad-request" } : {}) };
5002
5172
  }
5003
5173
  // Shared operator files remain launchable. Owned content follows the catalog's visibility
5004
5174
  // decision before connector loading or any allocation. A hosted run acts for its admission;
@@ -5032,7 +5202,7 @@ export class Manager {
5032
5202
  }
5033
5203
  const persona = await this.spawnPersona(opts, spawner, authenticatedCaller);
5034
5204
  if ("error" in persona)
5035
- return { ok: false, error: persona.error };
5205
+ return { ok: false, error: persona.error, ...(persona.code ? { code: persona.code } : {}) };
5036
5206
  const { ref, configPath, def, agent } = persona;
5037
5207
  // Materialize the requested connector up front — the ONE async step in the spawn path (a lazy
5038
5208
  // `cotal ext` manifest import on the published binary). It runs BEFORE the capacity/reserve span
@@ -5044,7 +5214,12 @@ export class Manager {
5044
5214
  connector = await this.resolveConnector(agent);
5045
5215
  }
5046
5216
  catch (e) {
5047
- return { ok: false, error: rejectionText(e) };
5217
+ const error = rejectionText(e);
5218
+ // An action refusal here precedes capacity, allocation and onAccepted. Preserve that proof
5219
+ // through serveSpawnGoal without changing the direct startAgent ControlReply contract.
5220
+ if (hooks?.onAccepted)
5221
+ throw new EpEnvelopeError("failed-precondition", error, undefined, "not-executed");
5222
+ return { ok: false, error };
5048
5223
  }
5049
5224
  const readinessTimeoutMs = connector.readinessTimeoutMs ?? this.readinessTimeoutMs;
5050
5225
  if (!Number.isSafeInteger(readinessTimeoutMs) || readinessTimeoutMs <= 0)
@@ -5265,6 +5440,7 @@ export class Manager {
5265
5440
  return { ok: false, error: `the name "${identityName}" is still reconciling at manager startup; its prior lifecycle terminal owns this alias until it completes. Retry shortly.` };
5266
5441
  const held = this.retiring.get(identityName);
5267
5442
  if (held !== undefined) {
5443
+ hooks?.onEffect?.();
5268
5444
  void this.deprovision(held.target).catch(() => { });
5269
5445
  const err = lifecycleBlocked("failed-precondition", `the name "${identityName}" is reserved pending retirement: its previous agent's despawn started that lifecycle's teardown (footprint + standing-authority revoke + auth-side retirement), and the name frees only when all of it completes${held.lastError !== undefined ? ` (last attempt: ${held.lastError})` : ""}. NEXT: wait a moment and retry this spawn (retrying re-drives the whole teardown), or pick another name.`, { blockedOp: "retirement", opId: held.opId, remedy: "retry" });
5270
5446
  return { ok: false, error: renderLifecycleBlocked(err.message, err), details: err.details };
@@ -5328,6 +5504,11 @@ export class Manager {
5328
5504
  else {
5329
5505
  name = this.uniqueName(identityName);
5330
5506
  }
5507
+ // The `chat_` prefix belongs to static chat sessions, whose aliases only `chat-session-open`
5508
+ // draws. Checked once the name is fixed by either branch above, so a persona name, a numbered
5509
+ // collision, a manifest name and a `--name` override all meet it, and before the reservation.
5510
+ if (name.startsWith(CHAT_ALIAS_PREFIX))
5511
+ return { ok: false, error: `the name "${name}" starts with "${CHAT_ALIAS_PREFIX}", which is reserved for chat sessions; pick another name` };
5331
5512
  // Set once the agent's footprint (durables + creds, or the user-mode grant + secret files)
5332
5513
  // exists; cleared when a live slot takes ownership. If it survives to `finally`, the spawn threw
5333
5514
  // AFTER provisioning (buildLaunch / runtime.spawn) — the orphan-rollback tears it down. Carries
@@ -5343,6 +5524,7 @@ export class Manager {
5343
5524
  let seatHome;
5344
5525
  // Taken right before the try so its finally is the only release; a refusal belongs above the
5345
5526
  // allocation, where there is no name to give back.
5527
+ hooks?.onEffect?.();
5346
5528
  this.reserved.add(name);
5347
5529
  try {
5348
5530
  // A stable nkey identity assigned at spawn: the public key is the agent's card.id (threaded via
@@ -5824,7 +6006,7 @@ export class Manager {
5824
6006
  * principal without provisioning. A later runtime launch failure is reported per-agent, but malformed
5825
6007
  * or missing inventory material can never produce a partially resumed set. */
5826
6008
  async resumePreserved(inventory) {
5827
- if (this.execution === "none" && (!Array.isArray(inventory.agents) || inventory.agents.length !== 0))
6009
+ if (this.execution === "none")
5828
6010
  return { ok: false, agents: [], error: this.executionRefusal() };
5829
6011
  const release = this.beginLifecycle(true);
5830
6012
  if (!release)
@@ -6957,6 +7139,7 @@ export class Manager {
6957
7139
  managerStatusData(ownerFilter) {
6958
7140
  return {
6959
7141
  instanceId: this.managerInstanceId,
7142
+ lifecycleUid: this.managerLifecycleUid,
6960
7143
  host: hostname(),
6961
7144
  version: MANAGER_VERSION,
6962
7145
  execution: this.execution,
@@ -6969,6 +7152,34 @@ export class Manager {
6969
7152
  connectors: this.connectorStatuses.map((row) => ({ ...row, binaries: { ...row.binaries } })),
6970
7153
  classSpawn: this.classSpawnEnabled(),
6971
7154
  staticReconciliation: this.staticReconciliationStatus(),
7155
+ stopImpact: {
7156
+ agentScope: ownerFilter === undefined ? "manager" : "owner-filtered",
7157
+ observedAt: new Date().toISOString(),
7158
+ maintenance: this.maintenanceState,
7159
+ resumeRequired: this.resumeRequired,
7160
+ stopping: this.leaseStopping,
7161
+ lifecycleInFlight: this.lifecycleInFlight,
7162
+ reservedLaunches: this.reserved.size,
7163
+ retirementsPending: this.retiring.size,
7164
+ deprovisioningInFlight: this.deprovisioningFlight.size,
7165
+ staticReconciliationInFlight: this.staticReconcileSweepsInFlight,
7166
+ agents: [...this.agents.values()]
7167
+ .filter((a) => ownerFilter === undefined || a.userOwner === ownerFilter)
7168
+ .map((a) => ({
7169
+ name: a.name,
7170
+ id: a.id,
7171
+ lifecycleUid: a.lifecycleUid,
7172
+ mode: a.handle.kind,
7173
+ defaultAction: this.maintenanceState !== "active" || this.resumeRequired
7174
+ ? "stop-retaining-state" : this.isProcessBoundSeat(a) ? "stop" : "release-custody",
7175
+ withAgentsAction: this.maintenanceState !== "active" || this.resumeRequired ? "stop-retaining-state" : "stop",
7176
+ })),
7177
+ // The same digest form `opResumePreserved` binds, so a preserved-exit stop and the later
7178
+ // resume name one inventory.
7179
+ ...(this.maintenanceState !== "active" && this.preservationAttemptId && this.preservationInventory
7180
+ ? { preservation: { attemptId: this.preservationAttemptId, inventorySha256: createHash("sha256").update(JSON.stringify(this.preservationInventory)).digest("hex") } }
7181
+ : {}),
7182
+ },
6972
7183
  };
6973
7184
  }
6974
7185
  /** Render the current operator snapshot. Recovered rows live until the next sweep starts, then
@@ -7973,15 +8184,16 @@ export class Manager {
7973
8184
  * Returns the acceptance floor payload {name, owner, actor, uid, goalId, fingerprint, executor}
7974
8185
  * (the ALLOCATED identity). goalId = the request id (env.id). */
7975
8186
  async serveSpawnGoal(ctx, run) {
8187
+ // These four refuse before anything is accepted or allocated, so they state it (SPEC 13.3).
7976
8188
  if (this.execution === "none")
7977
- throw new EpEnvelopeError("unimplemented", this.executionRefusal());
8189
+ throw new EpEnvelopeError("unimplemented", this.executionRefusal(), undefined, "not-executed");
7978
8190
  const gw = this.goalWriter;
7979
8191
  if (!gw)
7980
- throw new EpEnvelopeError("unavailable", "the manager goal-writer connection is not standing; spawn-as-action cannot accept (SPEC 13.6)");
8192
+ throw new EpEnvelopeError("unavailable", "the manager goal-writer connection is not standing; spawn-as-action cannot accept (SPEC 13.6)", undefined, "not-executed");
7981
8193
  // Refuse to accept until the boot reconcile of inherited goals completes, so a fresh
7982
8194
  // acceptance never races the sweep (settling a live goal mid-flight would steal its real terminal).
7983
8195
  if (!this.goalReconcileDone)
7984
- throw new EpEnvelopeError("unavailable", "the manager is still reconciling accepted goals at boot; retry shortly (SPEC 13.6)");
8196
+ throw new EpEnvelopeError("unavailable", "the manager is still reconciling accepted goals at boot; retry shortly (SPEC 13.6)", undefined, "not-executed");
7985
8197
  const goalId = ctx.request.id;
7986
8198
  const { fingerprint } = submissionFingerprint(ctx.request, ctx.subject);
7987
8199
  const ref = goalRefOf(ctx.subject, goalId);
@@ -8002,7 +8214,7 @@ export class Manager {
8002
8214
  const accepted = prior ?? ended;
8003
8215
  if (accepted !== undefined) {
8004
8216
  if (accepted.fingerprint !== fingerprint)
8005
- throw new EpEnvelopeError("failed-precondition", `goal "${goalId}" was accepted under a different submission; one goalId never carries two specs (SPEC 13.6)`);
8217
+ throw new EpEnvelopeError("failed-precondition", `goal "${goalId}" was accepted under a different submission; one goalId never carries two specs (SPEC 13.6)`, undefined, "not-executed");
8006
8218
  return prior ?? this.cachedSpawnAcceptance(ref, goalId, fingerprint, executor);
8007
8219
  }
8008
8220
  let resolveAccept;
@@ -8029,6 +8241,8 @@ export class Manager {
8029
8241
  // (#357): it had entered no terminal, so the fallback wrote one for it, using its own abort
8030
8242
  // message as the caller-visible outcome.
8031
8243
  let ownsGoal = false;
8244
+ // Set by onEffect. A refusal that arrives with it still false changed nothing.
8245
+ let effectStarted = false;
8032
8246
  // The terminal commits OFF-handler on the goal-writer connection (manager-only authority; a
8033
8247
  // caller cannot publish it). TWO COMPOSED FENCES (defense in depth): (a) reads THIS
8034
8248
  // incarnation's OWN gate epoch and REFUSES a superseded commit (the currency belt), and (b)
@@ -8148,12 +8362,15 @@ export class Manager {
8148
8362
  // it and commits `cancel`. This only stops the non-ok reply below from manufacturing a
8149
8363
  // `failed` that would race that `cancel` (first-terminal-fact-wins).
8150
8364
  onTerminalDeferred: () => { terminalEntered = true; },
8365
+ onEffect: () => { effectStarted = true; },
8151
8366
  });
8152
8367
  bg.then((reply) => {
8153
8368
  // Refused BEFORE onAccepted (hard-pin collision, capacity, persona-not-found) — no goal bound.
8369
+ // Only a refusal before the first effect states `not-executed`; a later one, such as the
8370
+ // alias gate that re-drives a teardown, leaves the outcome unknown.
8154
8371
  if (acceptance === undefined) {
8155
8372
  const details = reply.details;
8156
- rejectAccept(new EpEnvelopeError("failed-precondition", reply.error ?? "spawn refused at accept", details));
8373
+ rejectAccept(new EpEnvelopeError("failed-precondition", reply.error ?? "spawn refused at accept", details, effectStarted ? undefined : "not-executed"));
8157
8374
  return;
8158
8375
  }
8159
8376
  // H1: `run` CATCHES its own body (a throw in buildLaunch/runtime.spawn) and RESOLVES `{ok:false}`
@@ -8415,6 +8632,8 @@ export class Manager {
8415
8632
  * `succeeded` carrying the TurnResult. A yield AFTER the deadline drives the deadline terminal
8416
8633
  * instead and refuses — the expiry outcome stands, never a late success over it. */
8417
8634
  async serveTurnYield(c, raw) {
8635
+ if (this.execution === "none")
8636
+ throw new EpEnvelopeError("unimplemented", this.executionRefusal());
8418
8637
  // Same boot window as the pull, and checked first because it is the earlier boot phase: before
8419
8638
  // the index is rebuilt a predecessor's accepted turn is not in `pendingTurns`, and `not-found`
8420
8639
  // would tell the seat to drop work it is holding.
@@ -8503,6 +8722,8 @@ export class Manager {
8503
8722
  * whose outcome cannot be read back stays out and converges through the goal index at the
8504
8723
  * next boot, as a failed deadline commit does. */
8505
8724
  async serveTurnCancel(ctx) {
8725
+ if (this.execution === "none")
8726
+ throw new EpEnvelopeError("unimplemented", this.executionRefusal());
8506
8727
  if (!this.goalReconcileDone)
8507
8728
  throw new EpEnvelopeError("unavailable", "the manager is still reconciling accepted goals at boot; the pending-turn index is not rebuilt yet, retry (SPEC 13.6)");
8508
8729
  const gw = this.goalWriter;
@@ -9141,6 +9362,229 @@ export class Manager {
9141
9362
  throw e;
9142
9363
  }
9143
9364
  }
9365
+ /** `chat-session-open`: admit one static chat session. The manager draws the `chat_` alias and a
9366
+ * fresh nkey identity, then runs the same native steps a static spawn does: the slot intent and
9367
+ * the shared activation saga, the provisioner's bind-only DM and DELIVER durables plus the ACL
9368
+ * row, and the agent-profile mint. The leaf carries the spawn's three lists under the spawn's
9369
+ * rules (`resolveReadAcl`, a default-deny post list, the foreign event-channel refusal) and
9370
+ * nothing else: no role (so no TASK queue), no capabilities, no endpoint rows, no issuance. It
9371
+ * expires at `ttl` with no renewal, because no managed row exists to renew it.
9372
+ *
9373
+ * The session is registered with the tracker before the first durable write and scheduled for
9374
+ * its expiry before the reply is sent, or after a rollback that did not complete, so this
9375
+ * process retires it at the expiry even when the reply is lost or the client never retires.
9376
+ *
9377
+ * The credential is ledgered before it leaves this process (slot credentialId, then the `cred.`
9378
+ * row), so the static terminal marks the row revoked and verify-evicts the session's principal;
9379
+ * the broker keeps accepting the leaf until its expiry. The direct ledger write is deliberate:
9380
+ * the leaf is not an issuance, so the issuance seam, which writes the same row inside its
9381
+ * finalization, never runs for it, and without the row the terminal's eviction set would be
9382
+ * empty. The credential leaves only sealed to `sealTo`, bound to the space, this instance and
9383
+ * the reply's fields, so a reply printed anywhere carries no usable credential. A failure at
9384
+ * any step drives the terminal before the refusal is returned, and the refusal says whether it
9385
+ * completed. */
9386
+ async openStaticChatSession(raw) {
9387
+ if (!this.auth || this.userMode)
9388
+ throw new EpEnvelopeError("unimplemented", "chat-session-open admits static chat sessions only, and this manager is not a static-auth manager", undefined, "not-executed");
9389
+ const input = readChatOpenInput(raw);
9390
+ if ("error" in input)
9391
+ throw new EpEnvelopeError("bad-request", input.error, undefined, "not-executed");
9392
+ const lists = chatOpenLists(input);
9393
+ if ("error" in lists)
9394
+ throw new EpEnvelopeError("bad-request", lists.error, undefined, "not-executed");
9395
+ const alias = drawChatAlias();
9396
+ const identity = newIdentity();
9397
+ // The spawn's rule, against the drawn actor: a concrete event channel of any other principal is
9398
+ // refused, and a wildcard is an ordinary grant.
9399
+ const foreign = foreignEventChannels([...lists.allowSubscribe, ...lists.allowPublish], DEV_OWNER, identity.id);
9400
+ if (foreign.length)
9401
+ throw new EpEnvelopeError("bad-request", `chat-session-open asks for another agent's event channel: ${foreign.join(", ")}. A session may be granted its OWN event plane and no other, because that plane carries the session's tool inputs and outputs. Grant a reader out of band rather than through a chat session: ${this.readerRemedy(DEV_OWNER, foreign[0])}`, undefined, "not-executed");
9402
+ const lifecycleUid = mintLifecycleUid();
9403
+ const exp = Math.floor(Date.now() / 1000) + input.ttl;
9404
+ const pin = { owner: DEV_OWNER, actor: identity.id, lifecycleUid, alias };
9405
+ const session = { alias, actor: identity.id, lifecycleUid, channels: lists.allowSubscribe, expiresAt: exp * 1000 };
9406
+ // Registered before the first durable write, so the startup sweep never mistakes this opening's
9407
+ // provisioning row for a crashed spawn, and the stop knows it.
9408
+ try {
9409
+ this.chatExpiry.register(session);
9410
+ }
9411
+ catch (e) {
9412
+ throw new EpEnvelopeError("unavailable", rejectionText(e), undefined, "not-executed");
9413
+ }
9414
+ try {
9415
+ await this.withLifecycleExecutor(pin, (t) => activateStaticLifecycle(t, { owner: DEV_OWNER, alias, actor: identity.id, lifecycleUid, managerInstance: this.managerLifecycleUid, ownerInstanceId: this.managerInstanceId }));
9416
+ const creds = await this.withProvisioner((prov) => provisionAgent(prov, this.auth, identity, { subscribe: lists.subscribe, allowSubscribe: lists.allowSubscribe, allowPublish: lists.allowPublish, lifecycleUid, expiresAt: exp }));
9417
+ // The same credential id the issuance path derives, so a re-sent identical credential maps to
9418
+ // the same row.
9419
+ const credentialId = rawDigest(creds).replace("sha256:", "sha256-");
9420
+ await this.withLifecycleExecutor(pin, async (t) => {
9421
+ await recordSlotCredential(t, DEV_OWNER, alias, lifecycleUid, credentialId);
9422
+ await appendStaticCredentialRow(t, { lifecycleUid, credentialId, holderPrincipal: principalKey(DEV_OWNER, identity.id).key, exp });
9423
+ const slot = await readStaticSlot(t, DEV_OWNER, alias);
9424
+ if (slot === undefined || slot.row.lifecycleUid !== lifecycleUid || slot.row.phase !== "provisioning")
9425
+ throw new Error(`the static slot for "${alias}" is ${slot === undefined ? "absent" : `${slot.row.phase} at uid ${slot.row.lifecycleUid}`}, not this opening's provisioning intent`);
9426
+ await casStaticSlot(t, { ...slot.row, phase: "active" }, slot.revision);
9427
+ });
9428
+ const { sealer, sealed } = sealChatAdmission({
9429
+ space: this.space, managerInstanceId: this.managerInstanceId, alias, actor: identity.id, lifecycleUid, expiresAt: exp * 1000, sealTo: input.sealTo, creds,
9430
+ });
9431
+ this.chatExpiry.arm(lifecycleUid);
9432
+ console.error(`chat session ${alias} admitted as ${identity.id} (uid ${lifecycleUid}), expires ${new Date(exp * 1000).toISOString()}`);
9433
+ return { alias, actor: identity.id, lifecycleUid, expiresAt: exp * 1000, sealer, sealed };
9434
+ }
9435
+ catch (e) {
9436
+ // A confirmed rollback leaves a known state, a retired slot. An unconfirmed one does not, so
9437
+ // the session stays registered and its expiry, or the stop, retries the same terminal.
9438
+ let rollback;
9439
+ try {
9440
+ await this.chatTerminal(session);
9441
+ }
9442
+ catch (r) {
9443
+ rollback = rejectionText(r);
9444
+ try {
9445
+ this.chatExpiry.arm(lifecycleUid);
9446
+ }
9447
+ catch (t) {
9448
+ rollback += `; ${rejectionText(t)}`;
9449
+ }
9450
+ }
9451
+ throw new EpEnvelopeError(e instanceof EpEnvelopeError ? e.code : "unavailable", `chat-session-open did not complete for ${alias}: ${rejectionText(e)}; ${rollback === undefined ? "its rollback retired the slot" : `its rollback was not confirmed (${rollback}), so the slot stays held until a retire, its expiry here, this manager's stop, or the next start of this manager instance`}`, undefined, rollback === undefined ? "executed" : "unknown");
9452
+ }
9453
+ }
9454
+ /** `chat-session-retire`: end one static chat session through the native static terminal (ledger
9455
+ * row marked revoked, verified eviction of its principal's live connections, footprint cleanup,
9456
+ * gate and head retirement). It refuses before any effect unless {@link admitChatRetirement}
9457
+ * admits the request. A terminal that cannot complete answers `failed` with the reason, never
9458
+ * success; the slot stays held. The broker keeps accepting the leaf until its expiry either way. */
9459
+ async retireStaticChatSession(raw) {
9460
+ if (!this.auth || this.userMode)
9461
+ throw new EpEnvelopeError("unimplemented", "chat-session-retire retires static chat sessions only, and this manager is not a static-auth manager", undefined, "not-executed");
9462
+ const input = readChatRetireInput(raw);
9463
+ if ("error" in input)
9464
+ throw new EpEnvelopeError("bad-request", input.error, undefined, "not-executed");
9465
+ if (await this.admitChatRetirement(input) === "alreadyRetired")
9466
+ return { outcome: "alreadyRetired" };
9467
+ try {
9468
+ await this.chatTerminal({ ...input, channels: this.chatExpiry.channels(input.lifecycleUid) });
9469
+ }
9470
+ catch (e) {
9471
+ return { outcome: "failed", reason: `retirement pending: ${rejectionText(e)}` };
9472
+ }
9473
+ return { outcome: "retired" };
9474
+ }
9475
+ /** The expiry cleanup's retirement of one session this process admitted. It is lifecycle work, so
9476
+ * it is admitted like a control call and a stop drains it. A throw is a failure the tracker
9477
+ * retries; a slot that is no longer this instance's to retire ends the tracking instead. */
9478
+ async expireStaticChatSession(session) {
9479
+ const release = this.beginLifecycle();
9480
+ if (!release)
9481
+ throw new Error(this.maintenanceError());
9482
+ try {
9483
+ console.error(`chat session ${session.alias} (uid ${session.lifecycleUid}) reached its expiry; retiring it`);
9484
+ let admitted;
9485
+ try {
9486
+ admitted = await this.admitChatRetirement(session);
9487
+ }
9488
+ catch (e) {
9489
+ if (e instanceof EpEnvelopeError && (e.code === "not-found" || e.code === "failed-precondition")) {
9490
+ console.error(`! chat session ${session.alias} expired, but its slot is not this instance's to retire (${e.message}); expiry tracking ends`);
9491
+ return;
9492
+ }
9493
+ throw e;
9494
+ }
9495
+ if (admitted === "admitted")
9496
+ await this.chatTerminal(session);
9497
+ }
9498
+ finally {
9499
+ release();
9500
+ }
9501
+ }
9502
+ /** The ownership admission shared by `chat-session-retire`, the expiry and the stop. It throws a
9503
+ * not-executed refusal unless the alias is a chat alias whose slot this logical instance owns
9504
+ * at the requested UID and actor. An already retired slot ends its expiry tracking. */
9505
+ async admitChatRetirement(s) {
9506
+ if (!isChatAlias(s.alias))
9507
+ throw new EpEnvelopeError("permission-denied", `"${s.alias}" is not a chat session alias`, undefined, "not-executed");
9508
+ if (this.reconcilingAliases.has(s.alias))
9509
+ throw new EpEnvelopeError("unavailable", `the chat session "${s.alias}" is still reconciling at manager startup; that terminal owns it`, undefined, "not-executed");
9510
+ const recordsKv = this.goalWriter?.ctx.kv;
9511
+ if (!recordsKv)
9512
+ throw new EpEnvelopeError("unavailable", `the durable static slot store is not ready while retiring "${s.alias}"`, undefined, "not-executed");
9513
+ let slot;
9514
+ try {
9515
+ slot = await readStaticSlot(staticLifecycleTransport(recordsKv, recordsKv), DEV_OWNER, s.alias);
9516
+ }
9517
+ catch (e) {
9518
+ throw new EpEnvelopeError("unavailable", `the durable slot for "${s.alias}" could not be read: ${rejectionText(e)}`, undefined, "not-executed");
9519
+ }
9520
+ const refused = chatRetireRefusal(slot?.row, s, this.managerInstanceId);
9521
+ if (refused)
9522
+ throw new EpEnvelopeError(refused.code, refused.message, undefined, "not-executed");
9523
+ if (slot.row.phase !== "retired")
9524
+ return "admitted";
9525
+ this.chatExpiry.forget(s.lifecycleUid);
9526
+ return "alreadyRetired";
9527
+ }
9528
+ /** One static terminal per chat session UID, however many callers ask: the explicit retire, the
9529
+ * expiry cleanup and the open's rollback join it instead of racing its CAS sequence. A completed
9530
+ * terminal ends the expiry tracking. `channels` names the membership rows to delete by name;
9531
+ * without it the delivery inventory alone finds them. */
9532
+ chatTerminal(s) {
9533
+ let flight = this.chatRetirements.get(s.lifecycleUid);
9534
+ if (!flight) {
9535
+ flight = this.driveStaticRetirement({ id: s.actor, name: s.alias, lifecycleUid: s.lifecycleUid, secretPaths: {}, ...(s.channels ? { launch: { allowSubscribe: s.channels } } : {}) }, true)
9536
+ .then(() => this.chatExpiry.forget(s.lifecycleUid))
9537
+ .finally(() => this.chatRetirements.delete(s.lifecycleUid));
9538
+ this.chatRetirements.set(s.lifecycleUid, flight);
9539
+ }
9540
+ return flight;
9541
+ }
9542
+ /** Whether `row` is a chat slot this process registered, at its exact alias, actor and UID,
9543
+ * under this logical instance and this process's incarnation. */
9544
+ holdsLiveChatSlot(row) {
9545
+ return row.ownerInstanceId === this.managerInstanceId && row.managerInstance === this.managerLifecycleUid && this.chatExpiry.holdsExact(row);
9546
+ }
9547
+ /** The stop's retirement of every chat session this process still tracks, through the same
9548
+ * ownership admission and terminal as `chat-session-retire`, at most
9549
+ * {@link CHAT_EXPIRY_CONCURRENCY} at a time. It runs after the lifecycle fence closed and every
9550
+ * admitted operation drained, as the seat teardown does, and mints nothing the terminal does not
9551
+ * already mint. Every outcome is logged. It throws, naming each one, when any session was not
9552
+ * retired; that session stays held until the next start of this manager instance. */
9553
+ async retireChatSessionsOnStop() {
9554
+ const queue = this.chatExpiry.remaining();
9555
+ const failed = [];
9556
+ const retireOne = async (session) => {
9557
+ const at = `chat session ${session.alias} (uid ${session.lifecycleUid})`;
9558
+ try {
9559
+ if (await this.admitChatRetirement(session) === "alreadyRetired") {
9560
+ console.error(`${at} was already retired at stop`);
9561
+ return;
9562
+ }
9563
+ await this.chatTerminal(session);
9564
+ console.error(`${at} retired at stop`);
9565
+ }
9566
+ catch (e) {
9567
+ if (e instanceof EpEnvelopeError && (e.code === "not-found" || e.code === "failed-precondition")) {
9568
+ this.chatExpiry.forget(session.lifecycleUid);
9569
+ console.error(`! ${at} was not retired at stop: its slot is not this instance's to retire (${e.message})`);
9570
+ return;
9571
+ }
9572
+ failed.push(`${session.alias} (uid ${session.lifecycleUid}): ${rejectionText(e)}`);
9573
+ console.error(`! ${at} was not retired at stop (${rejectionText(e)}); it stays held until the next start of this manager instance`);
9574
+ }
9575
+ };
9576
+ await Promise.all(Array.from({ length: Math.min(CHAT_EXPIRY_CONCURRENCY, queue.length) }, async () => {
9577
+ for (let next = queue.shift(); next !== undefined; next = queue.shift())
9578
+ await retireOne(next);
9579
+ }));
9580
+ if (failed.length)
9581
+ throw new Error(`${failed.length} chat session(s) were not retired and stay held until the next start of this manager instance: ${failed.join("; ")}`);
9582
+ }
9583
+ /** Name, at a stop that does not retire them, each chat session this process still tracks. */
9584
+ reportChatSessionsLeftAtStop(why) {
9585
+ for (const session of this.chatExpiry.remaining())
9586
+ console.error(`! chat session ${session.alias} (uid ${session.lifecycleUid}) was not retired because ${why}; the next start of this manager instance retires it`);
9587
+ }
9144
9588
  /** F5(b) push renewal of ONE live managed-static credential (Unit B): re-mint the SAME nkey
9145
9589
  * identity with the SAME scope (recorded on the managed row at spawn) and a fresh bounded
9146
9590
  * exp, ledger the new credentialId (slot record first, then the row, then the file — a
@@ -9505,6 +9949,15 @@ export class Manager {
9505
9949
  preservedForeign++;
9506
9950
  continue;
9507
9951
  }
9952
+ // A chat session this process is opening or holds is live membership, as an adopted seat
9953
+ // is: its own open, expiry or stop runs its terminal, which this sweep must not race. The
9954
+ // match is the exact registered alias, actor and UID under this logical instance and this
9955
+ // process's incarnation, so a foreign, legacy or unrecorded row, or any UID this process
9956
+ // did not register, falls through to the ordinary plan below.
9957
+ if (this.holdsLiveChatSlot(row)) {
9958
+ preservedAdopted++;
9959
+ continue;
9960
+ }
9508
9961
  // ADOPTION is genuine membership: a slot backed by a live managed agent THIS process owns
9509
9962
  // at the SAME uid is never an orphan (empty at boot; exactly the adopted set at the
9510
9963
  // post-adoption sweep — the fix for the F3 resume hole).
@@ -9721,6 +10174,8 @@ export class Manager {
9721
10174
  * supervisor); regular agents are denied STREAM.PURGE under auth. Cleanup only: leaves live agents and
9722
10175
  * the TASK queue alone. */
9723
10176
  async opPurge(args, _caller) {
10177
+ if (this.execution === "none")
10178
+ throw new EpEnvelopeError("unimplemented", this.executionRefusal());
9724
10179
  const includeDms = args.includeDms === true;
9725
10180
  try {
9726
10181
  const creds = this.auth ? await mintCreds(this.auth, newIdentity(), "purger") : undefined;
@@ -9748,6 +10203,8 @@ export class Manager {
9748
10203
  * only if `file.owner == caller`, else admin is required. Fail-closed: an ownerless file
9749
10204
  * (legacy / operator-written) is admin-only. */
9750
10205
  opDefinePersona(args, caller, admin) {
10206
+ if (this.execution === "none")
10207
+ throw new EpEnvelopeError("unimplemented", this.executionRefusal());
9751
10208
  const name = String(args.name ?? "").trim();
9752
10209
  if (!name)
9753
10210
  return { ok: false, error: "name required" };
@@ -9898,13 +10355,16 @@ export class Manager {
9898
10355
  * `enter` defaults to true: a harness command typed but not submitted has not been delivered.
9899
10356
  * Nothing is echoed back; the caller reads the resulting turns from the event plane. */
9900
10357
  async inputAuthorized(a, args) {
10358
+ // These three refuse before the first write, so nothing was typed (SPEC 13.3). A write the
10359
+ // runtime rejects below states no outcome: the runtime contract does not say a rejected write
10360
+ // delivered nothing, and an earlier slice may already have reached the seat.
9901
10361
  if (this.agents.get(a.name) !== a)
9902
- throw new EpEnvelopeError("failed-precondition", `agent "${a.name}" was replaced during authorization - retry`);
10362
+ throw new EpEnvelopeError("failed-precondition", `agent "${a.name}" was replaced during authorization - retry`, undefined, "not-executed");
9903
10363
  if (a.handle.status() !== "running")
9904
- throw new EpEnvelopeError("failed-precondition", `agent "${a.name}" is not running (${a.handle.status()}); nothing to type into`);
10364
+ throw new EpEnvelopeError("failed-precondition", `agent "${a.name}" is not running (${a.handle.status()}); nothing to type into`, undefined, "not-executed");
9905
10365
  const write = a.handle.write?.bind(a.handle);
9906
10366
  if (!write)
9907
- throw new EpEnvelopeError("unimplemented", `input is not supported by runtime ${a.handle.kind}`);
10367
+ throw new EpEnvelopeError("unimplemented", `input is not supported by runtime ${a.handle.kind}`, undefined, "not-executed");
9908
10368
  // The contract validated `text` (non-empty, <= 64KiB) and `enter` (boolean) before this ran, so
9909
10369
  // the only decision left is the carriage return. `!== false` and not `?? true`: an ABSENT enter
9910
10370
  // and an explicit `true` must behave identically, and only `false` may suppress the return.
@@ -9978,18 +10438,19 @@ export class Manager {
9978
10438
  * redeems it over the mesh with a per-session rails-only cred it mints itself. Only streamable
9979
10439
  * backends (pty/host) attach; an external runtime's attach() throws with per-runtime guidance. */
9980
10440
  async attachAuthorized(a, caller) {
10441
+ // Every refusal before establishAttach below has opened no session, so it states that (SPEC 13.3).
9981
10442
  if (!this.sessionPlane)
9982
- return { ok: false, error: "the manager session plane is not available (the manager is not fully started)" };
10443
+ throw new EpEnvelopeError("failed-precondition", "the manager session plane is not available (the manager is not fully started)", undefined, "not-executed");
9983
10444
  // A name is a reusable slot and the authorization above can await (user mode reads the ledger).
9984
10445
  // The ep target is incarnation-pinned, so `a` cannot BE a successor — but the slot it names can
9985
10446
  // have been stopped and refilled while we waited, and everything below must act on the
9986
10447
  // incarnation this caller was actually authorized for, never on whoever holds the name now.
9987
10448
  if (this.agents.get(a.name) !== a)
9988
- return { ok: false, error: `agent "${a.name}" was replaced during authorization - retry` };
10449
+ throw new EpEnvelopeError("failed-precondition", `agent "${a.name}" was replaced during authorization - retry`, undefined, "not-executed");
9989
10450
  // Never establish a session over a dead agent — a doomed session (the caller would get an
9990
10451
  // immediate process-exit at best, a confusing empty terminal at worst). Refuse honestly.
9991
10452
  if (a.handle.status() !== "running")
9992
- return { ok: false, error: `agent "${a.name}" is not running (${a.handle.status()}); nothing to attach` };
10453
+ throw new EpEnvelopeError("failed-precondition", `agent "${a.name}" is not running (${a.handle.status()}); nothing to attach`, undefined, "not-executed");
9993
10454
  // CLAIM the session slot BEFORE attaching the target's PTY, matching the console door: this door
9994
10455
  // used to attach first and check capacity inside establishAttach, so a `resource-exhausted`
9995
10456
  // refusal landed AFTER the attach. The claim is carried INTO establishAttach, so one reservation
@@ -10007,7 +10468,8 @@ export class Manager {
10007
10468
  }
10008
10469
  catch (e) {
10009
10470
  slot.release();
10010
- return { ok: false, error: rejectionText(e) };
10471
+ // The external runtimes refuse attach() before acquiring anything (see the residual above).
10472
+ throw new EpEnvelopeError("failed-precondition", rejectionText(e), undefined, "not-executed");
10011
10473
  }
10012
10474
  // establishAttach releases the claim it was handed on every exit; nothing to unwind here.
10013
10475
  const { grant } = await this.sessionPlane.establishAttach(caller, { name: a.name, lifecycleUid: a.lifecycleUid }, session, slot);
@@ -10066,6 +10528,7 @@ export class Manager {
10066
10528
  * owner-domain bound); undefined = unbounded. {@link NO_OWNER_MATCHES} matches nothing. */
10067
10529
  list(ownerFilter) {
10068
10530
  const roster = new Map(this.ep.getRoster().map((p) => [p.card.name, p]));
10531
+ const observedAt = Date.now();
10069
10532
  // The roster is only evidence while THIS observer's presence watch is fresh. A stale view
10070
10533
  // (whole-bucket silence past TTL) or an unpopulated one (snapshot not yet replayed) cannot
10071
10534
  // support "offline" or "absent" for anyone: a live deployment on 2026-09-09 rendered every live seat as
@@ -10085,6 +10548,7 @@ export class Manager {
10085
10548
  // The connector-reported provider from presence (#785): the live roster card's `meta.provider`,
10086
10549
  // when it is a non-empty string. Absent when the connector reported none — never fabricated.
10087
10550
  const seen = roster.get(a.name);
10551
+ const presenceIdentityVerified = this.isOwnPresence(a, seen);
10088
10552
  const providerMeta = seen?.card.meta?.provider;
10089
10553
  const provider = typeof providerMeta === "string" && providerMeta ? providerMeta : undefined;
10090
10554
  // The harness-reported condition beside the mesh status (#618): a turn that died upstream reads
@@ -10129,6 +10593,8 @@ export class Manager {
10129
10593
  // `current` is the only state in which `mesh` is a verdict; the other two are the
10130
10594
  // observer's own condition and travel on the row (older CLIs ignore the field).
10131
10595
  meshView: view.state,
10596
+ presenceIdentityVerified,
10597
+ ...(view.state === "current" && presenceIdentityVerified ? { presenceObservedAt: observedAt } : {}),
10132
10598
  ...(offlineSince !== undefined ? { offlineSince } : {}),
10133
10599
  // The incarnation coordinate (SPEC 13.1) — with `id`, exactly what a v0.4 caller needs to
10134
10600
  // build a targeted (`despawn`/`attach`) request against THIS incarnation.