primcli 1.62.1 → 1.64.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.
@@ -1,9 +1,9 @@
1
- import { b as __exportAll } from "./repeats-x4QM2yoG-De3MV3st.js";
1
+ import { S as __exportAll } from "./repeats-BL7ZC-_6-Dzangtkf.js";
2
2
  import { B as parseOwnerMemberAddress, F as agentProfilesDirectory, I as connectedAgentIdentity, L as connectedApiBaseUrl, M as AgentConnectionSetupError, N as agentProfileDirectory, P as agentProfileName, R as loadConnectedAgentProfile, U as saveConnectedAgentProfile, V as profileAlreadyConnectedMessage, W as PrimitiveApiClient, j as AGENT_PROFILE_ENV, x as loadCliCredentials, z as parseConnectedAgentProfile } from "./cli-config-DMszFbGn.js";
3
- import { Ct as cliInvocation, Jn as putAgentContactPolicy, Jt as refreshStoredCliCredentials, Pn as listDomains, Tt as defaultConnectionName, Vn as listSentEmails, Yn as putContact, an as deleteAgentContact, ar as sendEmail, jn as listContacts, kn as listAgentContacts, mn as getContact, nr as searchEmails, on as deleteContact, pn as getAgentContactPolicy, qn as putAgentContact, vn as getEmail } from "./api-command-Bj8WyqXK.js";
3
+ import { Ct as cliInvocation, Jn as putAgentContactPolicy, Jt as refreshStoredCliCredentials, Pn as listDomains, Tt as defaultConnectionName, Vn as listSentEmails, Yn as putContact, an as deleteAgentContact, ar as sendEmail, jn as listContacts, kn as listAgentContacts, mn as getContact, nr as searchEmails, on as deleteContact, pn as getAgentContactPolicy, qn as putAgentContact, vn as getEmail } from "./api-command-C3XihKH7.js";
4
4
  import { _ as acquireListenLock, c as mailTime, d as readMailJson, f as removeMailFile, g as ListenStateError, h as writeMailJson, i as mailId, l as privateMailDirectory, o as mailObject, r as mailAddress, s as mailString } from "./shared-mail-files-MwlVE90c.js";
5
- import { _ as detectAgentRuntime, a as NotificationRetryError, d as defaultSessionSocket, f as NativeSessionDisconnectedError, l as SESSION_UUID, t as notificationScope, u as connectNativeSession, v as installConnectSkill, x as readBundledConnectSkill } from "./notify-session-anckNIzA.js";
6
- import { a as scopedChatSenderTrust } from "./scoped-chat-C7cpIxVS.js";
5
+ import { _ as detectAgentRuntime, a as NotificationRetryError, d as defaultSessionSocket, f as NativeSessionDisconnectedError, l as SESSION_UUID, t as notificationScope, u as connectNativeSession, v as installConnectSkill, x as readBundledConnectSkill } from "./notify-session-Bzcs3VBe.js";
6
+ import { a as scopedChatSenderTrust } from "./scoped-chat-BW041zEY.js";
7
7
  import { l as stopBackgroundListen, r as backgroundListenStatus } from "./listen-background--0peztKE.js";
8
8
  import { t as readSharedMailOwner } from "./shared-mail-watch-DtKpsgg8.js";
9
9
  import { accessSync, chmodSync, closeSync, constants, copyFileSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
@@ -1632,6 +1632,174 @@ function uninstallClaudeWakeHook(options) {
1632
1632
  }
1633
1633
  }
1634
1634
  //#endregion
1635
+ //#region src/oclif/session-records.ts
1636
+ const MACHINE_RUNTIMES = [
1637
+ "claude",
1638
+ "codex",
1639
+ "omp"
1640
+ ];
1641
+ const HASH = /^[a-f0-9]{64}$/;
1642
+ const REASON = /^[a-z_]{1,40}$/;
1643
+ function sessionRecordsDirectory(configDir) {
1644
+ return join(configDir, "machine", "sessions");
1645
+ }
1646
+ function sessionRecordPath(configDir, session) {
1647
+ return join(sessionRecordsDirectory(configDir), `${session}.json`);
1648
+ }
1649
+ function parseRelease(value) {
1650
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
1651
+ const row = value;
1652
+ if (typeof row.at !== "string" || row.reason !== "replaced_by_connect" && row.reason !== "credential_changed") return null;
1653
+ return {
1654
+ at: row.at,
1655
+ reason: row.reason,
1656
+ registeredAddress: typeof row.registeredAddress === "string" ? row.registeredAddress : null
1657
+ };
1658
+ }
1659
+ function readSessionRecord(configDir, session) {
1660
+ try {
1661
+ const value = readMailJson(sessionRecordPath(configDir, session));
1662
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
1663
+ const row = value;
1664
+ if (row.version !== 1 || row.session !== session || typeof row.profile !== "string" || typeof row.name !== "string" || !MACHINE_RUNTIMES.includes(row.runtime) || row.createdBy !== "session-register" && row.createdBy !== "existing") return null;
1665
+ return {
1666
+ version: 1,
1667
+ runtime: row.runtime,
1668
+ session,
1669
+ profile: row.profile,
1670
+ name: row.name,
1671
+ createdBy: row.createdBy,
1672
+ address: typeof row.address === "string" ? row.address : null,
1673
+ invitationHash: typeof row.invitationHash === "string" && HASH.test(row.invitationHash) ? row.invitationHash : null,
1674
+ agentInfo: row.agentInfo === "created" || row.agentInfo === "already_present" ? row.agentInfo : null,
1675
+ registeredAt: typeof row.registeredAt === "string" ? row.registeredAt : (/* @__PURE__ */ new Date(0)).toISOString(),
1676
+ endedAt: typeof row.endedAt === "string" ? row.endedAt : null,
1677
+ disconnect: row.disconnect === "done" || row.disconnect === "pending" || row.disconnect === "pending_enrollment" || row.disconnect === "deferred" ? row.disconnect : typeof row.endedAt === "string" ? "done" : null,
1678
+ deferredUntil: typeof row.deferredUntil === "string" ? row.deferredUntil : null,
1679
+ endReason: typeof row.endReason === "string" && REASON.test(row.endReason) ? row.endReason : null,
1680
+ release: parseRelease(row.release)
1681
+ };
1682
+ } catch {
1683
+ return null;
1684
+ }
1685
+ }
1686
+ function saveSessionRecord(configDir, row) {
1687
+ writeMailJson(sessionRecordPath(configDir, row.session), row);
1688
+ }
1689
+ /** Serialize every read-modify-write of one session's record. */
1690
+ async function withRecordLock(configDir, session, action) {
1691
+ const directory = sessionRecordsDirectory(configDir);
1692
+ privateMailDirectory(directory, true);
1693
+ let release;
1694
+ for (let attempt = 0; attempt < 200 && !release; attempt++) try {
1695
+ release = acquireListenLock(directory, `session-record-${session}`);
1696
+ } catch {
1697
+ await setTimeout$1(50);
1698
+ }
1699
+ if (!release) throw new ListenStateError("The session record is locked by another run.");
1700
+ try {
1701
+ return await action();
1702
+ } finally {
1703
+ release();
1704
+ }
1705
+ }
1706
+ /** Read, change and save one record under its lock. Returns the saved record. */
1707
+ function updateSessionRecord(configDir, session, change) {
1708
+ return withRecordLock(configDir, session, () => {
1709
+ const current = readSessionRecord(configDir, session);
1710
+ const next = change(current);
1711
+ if (next && next !== current) saveSessionRecord(configDir, next);
1712
+ return next ?? current;
1713
+ });
1714
+ }
1715
+ /** Every readable session record on this machine. */
1716
+ function listSessionRecords(configDir) {
1717
+ let names;
1718
+ try {
1719
+ names = readdirSync(sessionRecordsDirectory(configDir));
1720
+ } catch {
1721
+ return [];
1722
+ }
1723
+ return names.flatMap((name) => {
1724
+ const match = /^([0-9a-f-]{36})\.json$/.exec(name);
1725
+ if (!match?.[1]) return [];
1726
+ const record = readSessionRecord(configDir, match[1]);
1727
+ return record ? [record] : [];
1728
+ });
1729
+ }
1730
+ /**
1731
+ * The record no longer owns the credential in its profile: it becomes a
1732
+ * record of an agent connected some other way, which a session end leaves
1733
+ * connected. Any end that was waiting is dropped with it.
1734
+ */
1735
+ function releasedRecord(record, reason, at, current) {
1736
+ return {
1737
+ ...record,
1738
+ createdBy: "existing",
1739
+ address: current ? current.address : record.address,
1740
+ invitationHash: current ? current.invitationHash : null,
1741
+ endedAt: null,
1742
+ disconnect: null,
1743
+ deferredUntil: null,
1744
+ endReason: null,
1745
+ release: {
1746
+ at: at.toISOString(),
1747
+ reason,
1748
+ registeredAddress: record.address
1749
+ }
1750
+ };
1751
+ }
1752
+ /** The invitation hash an enrollment in this profile saved, if any. */
1753
+ function enrollmentInvitationHash(configDir, profileName) {
1754
+ try {
1755
+ const state = readMailJson(join(agentProfileDirectory(configDir, profileName), "enrollment", "state.json"), 65536);
1756
+ return typeof state?.invitationHash === "string" && HASH.test(state.invitationHash) ? state.invitationHash : null;
1757
+ } catch {
1758
+ return null;
1759
+ }
1760
+ }
1761
+ /**
1762
+ * A credential was just claimed into `profileName`. Every session record that
1763
+ * says `session-register` created the agent in that profile is released,
1764
+ * unless this claim is that registration's own enrollment finishing, so a
1765
+ * later session end never disconnects the newly connected agent. Best effort:
1766
+ * a failure here leaves the end-time credential check as the safeguard.
1767
+ */
1768
+ async function releaseRegistrationsForClaim(params) {
1769
+ const own = enrollmentInvitationHash(params.configDir, params.profileName);
1770
+ for (const listed of listSessionRecords(params.configDir)) {
1771
+ if (listed.profile !== params.profileName) continue;
1772
+ try {
1773
+ await updateSessionRecord(params.configDir, listed.session, (current) => {
1774
+ if (!current || current.profile !== params.profileName) return current;
1775
+ if (current.createdBy === "existing") return current.release && (current.address !== params.address || current.invitationHash !== params.invitationHash) ? {
1776
+ ...current,
1777
+ address: params.address,
1778
+ invitationHash: params.invitationHash
1779
+ } : current;
1780
+ if (own === params.invitationHash && (current.address === null || current.address.toLowerCase() === params.address.toLowerCase())) return current;
1781
+ return releasedRecord(current, "replaced_by_connect", (params.now ?? (() => /* @__PURE__ */ new Date()))(), {
1782
+ address: params.address,
1783
+ invitationHash: params.invitationHash
1784
+ });
1785
+ });
1786
+ } catch {}
1787
+ }
1788
+ }
1789
+ /**
1790
+ * The profile's credential is being replaced on purpose (for example by
1791
+ * `--replace-existing`): release every registration record for it, keeping
1792
+ * the registered address for reference.
1793
+ */
1794
+ async function releaseRegistrationsForProfile(params) {
1795
+ for (const listed of listSessionRecords(params.configDir)) {
1796
+ if (listed.profile !== params.profileName || listed.createdBy !== "session-register") continue;
1797
+ try {
1798
+ await updateSessionRecord(params.configDir, listed.session, (current) => current && current.profile === params.profileName && current.createdBy === "session-register" ? releasedRecord(current, "replaced_by_connect", (params.now ?? (() => /* @__PURE__ */ new Date()))()) : current);
1799
+ } catch {}
1800
+ }
1801
+ }
1802
+ //#endregion
1635
1803
  //#region src/oclif/agent-connect.ts
1636
1804
  const MAX_INVITATION_BYTES = 4096;
1637
1805
  const MAX_RESPONSE_BYTES = 32768;
@@ -1952,7 +2120,7 @@ async function connectAgent(params) {
1952
2120
  let release;
1953
2121
  try {
1954
2122
  privateMailDirectory(directory, true);
1955
- release = acquireListenLock(directory, "agent-connection-setup");
2123
+ release = params.claimLockHeld ? () => {} : acquireListenLock(directory, "agent-connection-setup");
1956
2124
  } catch {
1957
2125
  throw new AgentConnectionSetupError("Agent setup is already running, or its private directory is unavailable. No invitation was submitted.");
1958
2126
  }
@@ -2012,6 +2180,14 @@ async function connectAgent(params) {
2012
2180
  const profile = profileFromClaim(body, invitation, invitationHash, params.now ?? Date.now);
2013
2181
  const name = claimedConnectionName(body);
2014
2182
  saveConnectedAgentProfile(params.configDir, profileName, profile);
2183
+ try {
2184
+ await releaseRegistrationsForClaim({
2185
+ configDir: params.configDir,
2186
+ profileName,
2187
+ address: profile.agent_address,
2188
+ invitationHash: profile.invitation_hash
2189
+ });
2190
+ } catch {}
2015
2191
  try {
2016
2192
  writeMailJson(journal, {
2017
2193
  version: 1,
@@ -2365,6 +2541,297 @@ async function runAddressNotesRequest(client, request) {
2365
2541
  return data;
2366
2542
  }
2367
2543
  //#endregion
2544
+ //#region src/oclif/agent-disconnect.ts
2545
+ var AgentDisconnectError = class extends Error {};
2546
+ /** The profile no longer holds the credential the caller expected to revoke. */
2547
+ var AgentCredentialChangedError = class extends AgentDisconnectError {};
2548
+ function credentialDigest(profile) {
2549
+ return createHash("sha256").update("primitive-agent-disconnect-v1\0").update(profile.api_key).digest("hex");
2550
+ }
2551
+ function boundSession(directory, profile) {
2552
+ const raw = readMailJson(join(directory, "setup.json"));
2553
+ if (raw === null) return null;
2554
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new AgentDisconnectError("Saved session setup is invalid. The connection was not changed.");
2555
+ const row = raw;
2556
+ if (row.receiverMode === "poll" && row.session === null && row.invitationHash === profile.invitation_hash) return null;
2557
+ if (typeof row.session !== "string" || !SESSION_UUID.test(row.session) || row.invitationHash !== profile.invitation_hash) throw new AgentDisconnectError("Saved session binding does not match this profile. The connection was not changed.");
2558
+ return row.session.toLowerCase();
2559
+ }
2560
+ function receiverStopped(status) {
2561
+ return status.phase === null || status.phase === "stopped" || status.phase === "failed" || status.reason === "exited";
2562
+ }
2563
+ function confirmationPath(directory, profile) {
2564
+ return join(directory, `disconnected-${profile.invitation_hash}.json`);
2565
+ }
2566
+ /** The saved setup's disconnect marker, when it is a well-formed record. */
2567
+ function markerRecord(directory, invitationHash) {
2568
+ const raw = readMailJson(join(directory, `disconnected-${invitationHash}.json`));
2569
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
2570
+ const row = raw;
2571
+ return row.version === 1 && typeof row.revoked_at === "string" && Number.isFinite(Date.parse(row.revoked_at)) ? row : null;
2572
+ }
2573
+ function confirmedLocally(directory, profile) {
2574
+ const raw = readMailJson(confirmationPath(directory, profile));
2575
+ if (raw === null) return false;
2576
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new AgentDisconnectError("Saved revocation confirmation is invalid. The credential was preserved.");
2577
+ const row = raw;
2578
+ if (row.version !== 1 || row.address !== profile.agent_address || row.api_base_url !== profile.api_base_url || row.credential_digest !== credentialDigest(profile) || typeof row.revoked_at !== "string" || !Number.isFinite(Date.parse(row.revoked_at))) throw new AgentDisconnectError("Saved revocation confirmation does not match this credential. The credential was preserved.");
2579
+ return true;
2580
+ }
2581
+ /** The API error code from an error envelope, when it has one. */
2582
+ function errorCode(error) {
2583
+ if (!error || typeof error !== "object") return void 0;
2584
+ const inner = error.error;
2585
+ const code = inner && typeof inner === "object" ? inner.code : error.code;
2586
+ return typeof code === "string" && /^[a-z0-9_]{1,64}$/.test(code) ? code : void 0;
2587
+ }
2588
+ /**
2589
+ * Whether Primitive confirms, with this same credential, that it no longer
2590
+ * authenticates: a definite 401 from the connection's own record, or that
2591
+ * record reporting the connection revoked. A server error, a timeout, a
2592
+ * network failure or any other answer is not a confirmation.
2593
+ */
2594
+ async function credentialRevokedByServer(profile, fetcher = fetch) {
2595
+ try {
2596
+ const response = await fetcher(`${profile.api_base_url}/agent-connections/me`, {
2597
+ method: "GET",
2598
+ redirect: "error",
2599
+ signal: AbortSignal.timeout(1e4),
2600
+ headers: {
2601
+ authorization: `Bearer ${profile.api_key}`,
2602
+ accept: "application/json"
2603
+ }
2604
+ });
2605
+ if (response.status !== 200) {
2606
+ await response.body?.cancel().catch(() => void 0);
2607
+ return response.status === 401;
2608
+ }
2609
+ const text = await response.text();
2610
+ if (text.length > 65536) return false;
2611
+ const body = JSON.parse(text);
2612
+ const connection = body?.data?.connection;
2613
+ return body?.success === true && typeof connection?.address === "string" && connection.address.toLowerCase() === profile.agent_address.toLowerCase() && connection.status === "revoked";
2614
+ } catch {
2615
+ return false;
2616
+ }
2617
+ }
2618
+ async function revoke(profile, fetcher) {
2619
+ const client = new PrimitiveApiClient({
2620
+ apiKey: profile.api_key,
2621
+ apiBaseUrl: profile.api_base_url,
2622
+ ...fetcher ? { fetch: fetcher } : {}
2623
+ });
2624
+ let result;
2625
+ try {
2626
+ result = await client.client.delete({
2627
+ security: [{
2628
+ scheme: "bearer",
2629
+ type: "http"
2630
+ }],
2631
+ url: "/agent-connections/{address}",
2632
+ path: { address: profile.agent_address },
2633
+ redirect: "error",
2634
+ signal: AbortSignal.timeout(25e3),
2635
+ responseStyle: "fields"
2636
+ });
2637
+ } catch {
2638
+ throw new AgentDisconnectError("Revocation outcome is unknown after a transport error. The profile and credential were preserved. Check the agent in the app before retrying.");
2639
+ }
2640
+ if (result.error !== void 0) {
2641
+ const status = result.response?.status;
2642
+ if (status === void 0) throw new AgentDisconnectError("Revocation outcome is unknown: no response was received from Primitive. The profile and credential were preserved. Check your connection, then retry or check the agent in the app.");
2643
+ if (status === 401 && await credentialRevokedByServer(profile, fetcher)) return "already_revoked";
2644
+ const code = errorCode(result.error);
2645
+ const label = `HTTP ${status}${code ? `, ${code}` : ""}`;
2646
+ throw new AgentDisconnectError(status === 401 ? `Revocation was not confirmed (${label}). The credential was preserved; it may already be invalid. Check the agent in the app before retrying.` : status >= 500 ? `Primitive returned a server error while revoking this agent (${label}), so revocation is unconfirmed. The profile was preserved. Retry the same command; a 401 on retry means the credential was already revoked.` : `Revocation was refused by Primitive (${label}). The credential was preserved. Check the agent in the app before retrying.`);
2647
+ }
2648
+ const envelope = result.data;
2649
+ if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) throw new AgentDisconnectError("Revocation response was incomplete. The credential was preserved. Check the agent in the app before retrying.");
2650
+ const row = envelope;
2651
+ const data = row.data;
2652
+ const connection = data && typeof data === "object" && !Array.isArray(data) ? data.connection : void 0;
2653
+ if (row.success !== true || !connection || typeof connection !== "object" || Array.isArray(connection) || connection.address !== profile.agent_address || connection.owner_address !== profile.owner_address || connection.status !== "revoked") throw new AgentDisconnectError("Revocation response was incomplete. The credential was preserved. Check the agent in the app before retrying.");
2654
+ return "confirmed";
2655
+ }
2656
+ /** Revoke one selected connected credential; never alter OAuth login or mail evidence. */
2657
+ async function disconnectAgent(params) {
2658
+ const profileName = agentProfileName(params.profileName);
2659
+ if (!loadConnectedAgentProfile(params.configDir, profileName)) throw new AgentDisconnectError("This agent profile is not configured. Nothing was changed.");
2660
+ const directory = agentProfileDirectory(params.configDir, profileName);
2661
+ let releaseSetup;
2662
+ let releaseClaim;
2663
+ try {
2664
+ releaseSetup = acquireListenLock(directory, "setup");
2665
+ releaseClaim = acquireListenLock(agentProfilesDirectory(params.configDir), "agent-connection-setup");
2666
+ const profile = loadConnectedAgentProfile(params.configDir, profileName);
2667
+ if (!profile) throw new AgentDisconnectError("The selected profile changed before disconnect. Nothing was changed.");
2668
+ if (params.expected && (profile.agent_address.toLowerCase() !== params.expected.address.toLowerCase() || profile.invitation_hash !== params.expected.invitationHash)) throw new AgentCredentialChangedError("The profile holds a different credential than expected. Nothing was changed.");
2669
+ const session = boundSession(directory, profile);
2670
+ const receiver = session ? await (params.stopReceiver ?? stopBackgroundListen)({
2671
+ configDir: params.configDir,
2672
+ scope: notificationScope(profile.api_base_url, profile.api_key),
2673
+ threadId: session
2674
+ }) : null;
2675
+ if (receiver && !receiverStopped(receiver)) throw new AgentDisconnectError("The bound receiver has not confirmed stopping. The connection and credential were preserved. Check listener status before retrying.");
2676
+ const previous = confirmedLocally(directory, profile);
2677
+ const revocation = previous ? "previously_confirmed" : await revoke(profile, params.fetch);
2678
+ if (!previous) try {
2679
+ writeMailJson(confirmationPath(directory, profile), {
2680
+ version: 1,
2681
+ address: profile.agent_address,
2682
+ api_base_url: profile.api_base_url,
2683
+ credential_digest: credentialDigest(profile),
2684
+ revoked_at: (params.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
2685
+ ...revocation === "already_revoked" ? { reason: "server_revoked" } : {}
2686
+ });
2687
+ } catch {
2688
+ throw new AgentDisconnectError("Primitive confirmed revocation, but its local confirmation could not be saved. The credential was preserved. Keep this profile for recovery.");
2689
+ }
2690
+ const current = loadConnectedAgentProfile(params.configDir, profileName);
2691
+ if (!current || current.api_key !== profile.api_key) throw new AgentDisconnectError("The selected profile changed after revocation. Its credential was preserved for inspection.");
2692
+ try {
2693
+ removeMailFile(join(directory, "connection.json"));
2694
+ } catch {
2695
+ throw new AgentDisconnectError("Primitive confirmed revocation, but the local credential could not be removed. Confirmation is saved; retry this command to finish local cleanup.");
2696
+ }
2697
+ const externalHook = session ? uninstallClaudeWakeHook({
2698
+ configDir: params.configDir,
2699
+ profileName,
2700
+ agentAddress: profile.agent_address,
2701
+ sessionId: session,
2702
+ env: params.env
2703
+ }) ? "removed" : "unavailable" : null;
2704
+ return {
2705
+ status: "disconnected",
2706
+ identity: connectedAgentIdentity(profileName, profile),
2707
+ receiver,
2708
+ revocation,
2709
+ externalHook
2710
+ };
2711
+ } catch (error) {
2712
+ if (error instanceof AgentDisconnectError) throw error;
2713
+ throw new AgentDisconnectError("Disconnect could not safely complete. The credential and local evidence were preserved. Check this profile and listener status before retrying.");
2714
+ } finally {
2715
+ releaseClaim?.();
2716
+ releaseSetup?.();
2717
+ }
2718
+ }
2719
+ /**
2720
+ * Whether the saved setup bound to `invitationHash` belongs to a credential
2721
+ * whose revocation is confirmed locally: its disconnect marker exists and,
2722
+ * when the credential file is still present, matches that credential. Such
2723
+ * a setup is evidence only; it no longer binds this profile to an address.
2724
+ */
2725
+ function savedSetupRevoked(configDir, profileName, invitationHash) {
2726
+ if (!/^[a-f0-9]{64}$/.test(invitationHash)) return false;
2727
+ try {
2728
+ const directory = agentProfileDirectory(configDir, profileName);
2729
+ if (!markerRecord(directory, invitationHash)) return false;
2730
+ const profile = loadConnectedAgentProfile(configDir, profileName);
2731
+ if (!profile) return true;
2732
+ return profile.invitation_hash === invitationHash && confirmedLocally(directory, profile);
2733
+ } catch {
2734
+ return false;
2735
+ }
2736
+ }
2737
+ /**
2738
+ * Move a revoked setup aside, the same way a replacement does, so a new
2739
+ * invitation can be claimed into the same profile. Files are renamed, never
2740
+ * deleted, and kept for recovery.
2741
+ */
2742
+ function archiveRevokedSetup(configDir, profileName, now) {
2743
+ const directory = agentProfileDirectory(configDir, profileName);
2744
+ const stamp = now.toISOString().replace(/[^0-9]/g, "");
2745
+ for (const path of [
2746
+ join(directory, "setup.json"),
2747
+ join(directory, "enrollment", "state.json"),
2748
+ join(directory, "connection.json")
2749
+ ]) try {
2750
+ renameSync(path, join(dirname(path), `replaced-${stamp}-${basename(path)}`));
2751
+ } catch (error) {
2752
+ if (error.code !== "ENOENT") throw error;
2753
+ }
2754
+ }
2755
+ /**
2756
+ * Run a claim into `profileName`, first moving aside a saved setup whose
2757
+ * credential is confirmed revoked, so the new credential is never saved
2758
+ * beside a setup naming the old invitation. The profile's setup lock and the
2759
+ * machine-wide claim lock are taken (in the order setup and disconnect use)
2760
+ * before anything is checked or moved, and held through the claim, which is
2761
+ * told the claim lock is already held. A profile with no saved setup is
2762
+ * claimed as before, without these locks.
2763
+ */
2764
+ async function claimOverRevokedSetup(params, claim) {
2765
+ const directory = agentProfileDirectory(params.configDir, params.profileName);
2766
+ const path = join(directory, "setup.json");
2767
+ if (!existsSync(path)) return claim(false);
2768
+ let releaseSetup;
2769
+ let releaseClaim;
2770
+ try {
2771
+ try {
2772
+ releaseSetup = acquireListenLock(directory, "setup");
2773
+ releaseClaim = acquireListenLock(agentProfilesDirectory(params.configDir), "agent-connection-setup");
2774
+ } catch {
2775
+ throw new AgentConnectionSetupError("Another agent setup is running on this machine. No invitation was claimed and nothing was changed; retry when it finishes.");
2776
+ }
2777
+ let saved = null;
2778
+ try {
2779
+ saved = readMailJson(path);
2780
+ } catch {}
2781
+ const savedHash = saved && typeof saved === "object" && !Array.isArray(saved) ? saved.invitationHash : void 0;
2782
+ if (typeof savedHash === "string" && savedHash !== params.invitationHash && savedSetupRevoked(params.configDir, params.profileName, savedHash)) archiveRevokedSetup(params.configDir, params.profileName, (params.now ?? (() => /* @__PURE__ */ new Date()))());
2783
+ return await claim(true);
2784
+ } finally {
2785
+ releaseClaim?.();
2786
+ releaseSetup?.();
2787
+ }
2788
+ }
2789
+ /**
2790
+ * Of the addresses bound to a session, clear the ones Primitive confirms are
2791
+ * already revoked: each is disconnected locally (receiver stopped, hooks
2792
+ * removed, credential removed, marker written) so it no longer counts as the
2793
+ * session's address. A live credential, or one whose state cannot be
2794
+ * confirmed, is kept and still counts.
2795
+ */
2796
+ async function clearRevokedAddresses(params) {
2797
+ const remaining = [];
2798
+ const cleared = [];
2799
+ for (const row of params.rows) {
2800
+ let checked = null;
2801
+ try {
2802
+ const profile = loadConnectedAgentProfile(params.configDir, row.profile);
2803
+ if (profile && await credentialRevokedByServer(profile, params.fetch)) checked = profile;
2804
+ } catch {
2805
+ checked = null;
2806
+ }
2807
+ if (!checked) {
2808
+ remaining.push(row);
2809
+ continue;
2810
+ }
2811
+ try {
2812
+ await (params.disconnect ?? disconnectAgent)({
2813
+ configDir: params.configDir,
2814
+ profileName: row.profile,
2815
+ fetch: params.fetch,
2816
+ stopReceiver: params.stopReceiver,
2817
+ now: params.now,
2818
+ env: params.env,
2819
+ expected: {
2820
+ address: checked.agent_address,
2821
+ invitationHash: checked.invitation_hash
2822
+ }
2823
+ });
2824
+ cleared.push(row);
2825
+ } catch {
2826
+ remaining.push(row);
2827
+ }
2828
+ }
2829
+ return {
2830
+ remaining,
2831
+ cleared
2832
+ };
2833
+ }
2834
+ //#endregion
2368
2835
  //#region src/oclif/agent-setup-read.ts
2369
2836
  function retryDelay(response, attempt, now) {
2370
2837
  const raw = response?.headers.get("retry-after")?.trim();
@@ -3193,6 +3660,14 @@ function refuseSetupConflicts(state, params) {
3193
3660
  ];
3194
3661
  if (conflicts.length) throw fail(`This profile's saved setup conflicts with ${conflicts.join(" and ")}. Omit the option to reuse the saved setup. Its session and notification preferences were not changed.${params.resume ? "" : " Do not create a separate profile on your own: if this session should get a different address, ask the user whether to keep the existing one or disconnect it first."}`);
3195
3662
  }
3663
+ function invitationHashOrNull(invitation) {
3664
+ if (invitation === void 0) return null;
3665
+ try {
3666
+ return agentInvitationHash(invitation);
3667
+ } catch {
3668
+ return null;
3669
+ }
3670
+ }
3196
3671
  /** One resumable operation owns setup plumbing; it never stores or replays the invitation. */
3197
3672
  async function setupAgent(params) {
3198
3673
  const profileName = agentProfileName(params.profileName);
@@ -3212,7 +3687,9 @@ async function setupAgent(params) {
3212
3687
  const value = readMailJson(join(directory, "setup.json"));
3213
3688
  saved = value === null ? null : parseState(value);
3214
3689
  } catch {}
3215
- if (saved) refuseSetupConflicts(saved, params);
3690
+ const newHash = params.resume ? null : invitationHashOrNull(params.invitation);
3691
+ const replacesRevoked = (state) => newHash !== null && state.invitationHash !== newHash && savedSetupRevoked(params.configDir, profileName, state.invitationHash);
3692
+ if (saved && !replacesRevoked(saved)) refuseSetupConflicts(saved, params);
3216
3693
  const receiverMode = params.receiverMode ?? (params.resume ? saved?.receiverMode : void 0);
3217
3694
  if (params.session === void 0 && (receiverMode ?? "native") !== "poll") throw fail("--session requires the exact loaded session UUID. Without one, use --receiver poll. No invitation was claimed.");
3218
3695
  if ((receiverMode ?? "native") === "native" && params.session) try {
@@ -3222,12 +3699,26 @@ async function setupAgent(params) {
3222
3699
  }
3223
3700
  privateMailDirectory(directory, true);
3224
3701
  const release = acquireListenLock(directory, "setup");
3702
+ let releaseClaim;
3225
3703
  let discardStub = false;
3226
3704
  let claimedName;
3227
3705
  try {
3228
3706
  const path = join(directory, "setup.json");
3229
3707
  const value = readMailJson(path);
3230
3708
  let state = value === null ? null : parseState(value);
3709
+ if (state && replacesRevoked(state)) {
3710
+ try {
3711
+ releaseClaim = acquireListenLock(agentProfilesDirectory(params.configDir), "agent-connection-setup");
3712
+ } catch {
3713
+ throw fail("Another agent setup is running on this machine. No invitation was claimed and nothing was changed; retry when it finishes.");
3714
+ }
3715
+ const current = readMailJson(path);
3716
+ state = current === null ? null : parseState(current);
3717
+ if (state && replacesRevoked(state)) {
3718
+ archiveRevokedSetup(params.configDir, profileName, new Date(dependencies.now()));
3719
+ state = null;
3720
+ }
3721
+ }
3231
3722
  if (state) refuseSetupConflicts(state, params);
3232
3723
  if (!state && loadConnectedAgentProfile(params.configDir, profileName)) throw fail("This profile was configured without a setup binding. Use its existing session setup; do not adopt it into another session.");
3233
3724
  if (params.resume && !state) throw fail("No resumable setup exists for this profile. Do not adopt another session's identity.");
@@ -3256,7 +3747,8 @@ async function setupAgent(params) {
3256
3747
  profileName,
3257
3748
  invitation: params.invitation ?? "",
3258
3749
  fetch: params.fetch,
3259
- presence: true
3750
+ presence: true,
3751
+ claimLockHeld: releaseClaim !== void 0
3260
3752
  })).name;
3261
3753
  } catch (error) {
3262
3754
  if (error instanceof AgentInvitationRejectedError && created) {
@@ -3268,6 +3760,9 @@ async function setupAgent(params) {
3268
3760
  }
3269
3761
  }
3270
3762
  throw error;
3763
+ } finally {
3764
+ releaseClaim?.();
3765
+ releaseClaim = void 0;
3271
3766
  }
3272
3767
  }
3273
3768
  const profile = loadConnectedAgentProfile(params.configDir, profileName);
@@ -3357,6 +3852,7 @@ async function setupAgent(params) {
3357
3852
  }
3358
3853
  return result(state.receiverMode === "external" ? EXTERNAL_HOOKS_PENDING : state.receiverMode === "poll" ? "poll" : healthy ? "healthy" : "not_ready", ownerNotifications);
3359
3854
  } finally {
3855
+ releaseClaim?.();
3360
3856
  release();
3361
3857
  if (discardStub) removeEmptyDirectory(directory);
3362
3858
  }
@@ -3699,229 +4195,96 @@ async function runAgentConnect(options) {
3699
4195
  });
3700
4196
  }
3701
4197
  const pendingPath = join(agentProfileDirectory(options.configDir, profileName), PENDING_AGENT_INFO_FILE);
3702
- const noteValue = agentInfo ?? readPendingAgentInfo(pendingPath);
3703
- let agentInfoState = "not_requested";
3704
- if (noteValue === null) skipped.push({
3705
- step: "agent_info",
3706
- reason: "not_requested"
3707
- });
3708
- else if (!verified) {
3709
- agentInfoState = "pending_verification";
3710
- savePendingAgentInfo(pendingPath, noteValue);
3711
- skipped.push({
3712
- step: "agent_info",
3713
- reason: "pending_verification"
3714
- });
3715
- } else {
3716
- agentInfoState = await dependencies.seedAgentInfo(profileName, noteValue);
3717
- if (agentInfoState === "failed") {
3718
- savePendingAgentInfo(pendingPath, noteValue);
3719
- skipped.push({
3720
- step: "agent_info",
3721
- reason: "failed"
3722
- });
3723
- } else clearPendingAgentInfo(pendingPath);
3724
- if (agentInfoState === "already_present") skipped.push({
3725
- step: "agent_info",
3726
- reason: "already_present"
3727
- });
3728
- }
3729
- const receiving = receiver === "external" ? {
3730
- ...result.receiving,
3731
- state: externalReceivingState(externalHook),
3732
- mode: "external",
3733
- hook: externalHook
3734
- } : receiver === "poll" ? {
3735
- ...result.receiving,
3736
- mode: "poll",
3737
- checkCommand: pollCheckCommand(invocation, result.identity.profileName),
3738
- ...fallback ? {
3739
- fallbackReason: fallback.reason,
3740
- fallbackDetail: fallback.detail,
3741
- codexVersion: fallback.codexVersion
3742
- } : {}
3743
- } : {
3744
- ...result.receiving,
3745
- mode: "native",
3746
- mailCheck: mailCheck?.state ?? "not_started",
3747
- lastSuccessfulMailCheckAt: mailCheck?.lastSuccessfulMailCheckAt ?? null
3748
- };
3749
- const receiverReady = receiver === "external" ? externalHook === "installed_unverified" : receiver === "poll" || result.receiving.state === "healthy";
3750
- const resumeCommand = [
3751
- invocation,
3752
- "agent connect",
3753
- `--profile ${result.identity.profileName}`,
3754
- ...session ? [`--session ${session}`] : [],
3755
- `--receiver ${receiver}`,
3756
- "--resume",
3757
- ...options.contactRequests ?? savedSetup(options.configDir, profileName)?.contactRequests ? ["--contact-requests"] : [],
3758
- ...options.skill === false ? ["--no-skill"] : [],
3759
- ...options.project ? ["--project"] : [],
3760
- "--json"
3761
- ].join(" ");
3762
- return {
3763
- status: verified && receiverReady ? "connected" : "pending",
3764
- address: result.identity.agentAddress,
3765
- orgId: result.identity.orgId,
3766
- ownerAddress: result.identity.ownerAddress,
3767
- ownerMemberAddress,
3768
- profile: result.identity.profileName,
3769
- ...result.connectionName === void 0 ? {} : {
3770
- name: result.connectionName,
3771
- nameIsDefault: defaultConnectionName(result.connectionName, result.identity.agentAddress)
3772
- },
3773
- sessionId: session ?? null,
3774
- runtime,
3775
- cli: {
3776
- version: options.cliVersion,
3777
- capabilities: [...AGENT_CONNECT_CAPABILITIES]
3778
- },
3779
- skill,
3780
- verification: result.verification,
3781
- receiving,
3782
- ...result.ownerNotifications ? { ownerNotifications: result.ownerNotifications } : {},
3783
- agentInfo: agentInfoState,
3784
- skipped,
3785
- warnings,
3786
- selectProfile: `PRIMITIVE_AGENT_PROFILE=${result.identity.profileName}`,
3787
- resumeCommand,
3788
- guidance: `${result.guidance} ${ownerReportGuidance(identity)}`,
3789
- identity,
3790
- externalHook
3791
- };
3792
- }
3793
- //#endregion
3794
- //#region src/oclif/agent-disconnect.ts
3795
- var AgentDisconnectError = class extends Error {};
3796
- function credentialDigest(profile) {
3797
- return createHash("sha256").update("primitive-agent-disconnect-v1\0").update(profile.api_key).digest("hex");
3798
- }
3799
- function boundSession(directory, profile) {
3800
- const raw = readMailJson(join(directory, "setup.json"));
3801
- if (raw === null) return null;
3802
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new AgentDisconnectError("Saved session setup is invalid. The connection was not changed.");
3803
- const row = raw;
3804
- if (row.receiverMode === "poll" && row.session === null && row.invitationHash === profile.invitation_hash) return null;
3805
- if (typeof row.session !== "string" || !SESSION_UUID.test(row.session) || row.invitationHash !== profile.invitation_hash) throw new AgentDisconnectError("Saved session binding does not match this profile. The connection was not changed.");
3806
- return row.session.toLowerCase();
3807
- }
3808
- function receiverStopped(status) {
3809
- return status.phase === null || status.phase === "stopped" || status.phase === "failed" || status.reason === "exited";
3810
- }
3811
- function confirmationPath(directory, profile) {
3812
- return join(directory, `disconnected-${profile.invitation_hash}.json`);
3813
- }
3814
- function confirmedLocally(directory, profile) {
3815
- const raw = readMailJson(confirmationPath(directory, profile));
3816
- if (raw === null) return false;
3817
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new AgentDisconnectError("Saved revocation confirmation is invalid. The credential was preserved.");
3818
- const row = raw;
3819
- if (row.version !== 1 || row.address !== profile.agent_address || row.api_base_url !== profile.api_base_url || row.credential_digest !== credentialDigest(profile) || typeof row.revoked_at !== "string" || !Number.isFinite(Date.parse(row.revoked_at))) throw new AgentDisconnectError("Saved revocation confirmation does not match this credential. The credential was preserved.");
3820
- return true;
3821
- }
3822
- /** The API error code from an error envelope, when it has one. */
3823
- function errorCode(error) {
3824
- if (!error || typeof error !== "object") return void 0;
3825
- const inner = error.error;
3826
- const code = inner && typeof inner === "object" ? inner.code : error.code;
3827
- return typeof code === "string" && /^[a-z0-9_]{1,64}$/.test(code) ? code : void 0;
3828
- }
3829
- async function revoke(profile, fetcher) {
3830
- const client = new PrimitiveApiClient({
3831
- apiKey: profile.api_key,
3832
- apiBaseUrl: profile.api_base_url,
3833
- ...fetcher ? { fetch: fetcher } : {}
3834
- });
3835
- let result;
3836
- try {
3837
- result = await client.client.delete({
3838
- security: [{
3839
- scheme: "bearer",
3840
- type: "http"
3841
- }],
3842
- url: "/agent-connections/{address}",
3843
- path: { address: profile.agent_address },
3844
- redirect: "error",
3845
- signal: AbortSignal.timeout(25e3),
3846
- responseStyle: "fields"
3847
- });
3848
- } catch {
3849
- throw new AgentDisconnectError("Revocation outcome is unknown after a transport error. The profile and credential were preserved. Check the agent in the app before retrying.");
3850
- }
3851
- if (result.error !== void 0) {
3852
- const status = result.response?.status;
3853
- if (status === void 0) throw new AgentDisconnectError("Revocation outcome is unknown: no response was received from Primitive. The profile and credential were preserved. Check your connection, then retry or check the agent in the app.");
3854
- const code = errorCode(result.error);
3855
- const label = `HTTP ${status}${code ? `, ${code}` : ""}`;
3856
- throw new AgentDisconnectError(status === 401 ? `Revocation was not confirmed (${label}). The credential was preserved; it may already be invalid. Check the agent in the app before retrying.` : status >= 500 ? `Primitive returned a server error while revoking this agent (${label}), so revocation is unconfirmed. The profile was preserved. Retry the same command; a 401 on retry means the credential was already revoked.` : `Revocation was refused by Primitive (${label}). The credential was preserved. Check the agent in the app before retrying.`);
3857
- }
3858
- const envelope = result.data;
3859
- if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) throw new AgentDisconnectError("Revocation response was incomplete. The credential was preserved. Check the agent in the app before retrying.");
3860
- const row = envelope;
3861
- const data = row.data;
3862
- const connection = data && typeof data === "object" && !Array.isArray(data) ? data.connection : void 0;
3863
- if (row.success !== true || !connection || typeof connection !== "object" || Array.isArray(connection) || connection.address !== profile.agent_address || connection.owner_address !== profile.owner_address || connection.status !== "revoked") throw new AgentDisconnectError("Revocation response was incomplete. The credential was preserved. Check the agent in the app before retrying.");
3864
- }
3865
- /** Revoke one selected connected credential; never alter OAuth login or mail evidence. */
3866
- async function disconnectAgent(params) {
3867
- const profileName = agentProfileName(params.profileName);
3868
- if (!loadConnectedAgentProfile(params.configDir, profileName)) throw new AgentDisconnectError("This agent profile is not configured. Nothing was changed.");
3869
- const directory = agentProfileDirectory(params.configDir, profileName);
3870
- let releaseSetup;
3871
- let releaseClaim;
3872
- try {
3873
- releaseSetup = acquireListenLock(directory, "setup");
3874
- releaseClaim = acquireListenLock(agentProfilesDirectory(params.configDir), "agent-connection-setup");
3875
- const profile = loadConnectedAgentProfile(params.configDir, profileName);
3876
- if (!profile) throw new AgentDisconnectError("The selected profile changed before disconnect. Nothing was changed.");
3877
- const session = boundSession(directory, profile);
3878
- const receiver = session ? await (params.stopReceiver ?? stopBackgroundListen)({
3879
- configDir: params.configDir,
3880
- scope: notificationScope(profile.api_base_url, profile.api_key),
3881
- threadId: session
3882
- }) : null;
3883
- if (receiver && !receiverStopped(receiver)) throw new AgentDisconnectError("The bound receiver has not confirmed stopping. The connection and credential were preserved. Check listener status before retrying.");
3884
- const previous = confirmedLocally(directory, profile);
3885
- if (!previous) await revoke(profile, params.fetch);
3886
- if (!previous) try {
3887
- writeMailJson(confirmationPath(directory, profile), {
3888
- version: 1,
3889
- address: profile.agent_address,
3890
- api_base_url: profile.api_base_url,
3891
- credential_digest: credentialDigest(profile),
3892
- revoked_at: (params.now ?? (() => /* @__PURE__ */ new Date()))().toISOString()
4198
+ const noteValue = agentInfo ?? readPendingAgentInfo(pendingPath);
4199
+ let agentInfoState = "not_requested";
4200
+ if (noteValue === null) skipped.push({
4201
+ step: "agent_info",
4202
+ reason: "not_requested"
4203
+ });
4204
+ else if (!verified) {
4205
+ agentInfoState = "pending_verification";
4206
+ savePendingAgentInfo(pendingPath, noteValue);
4207
+ skipped.push({
4208
+ step: "agent_info",
4209
+ reason: "pending_verification"
4210
+ });
4211
+ } else {
4212
+ agentInfoState = await dependencies.seedAgentInfo(profileName, noteValue);
4213
+ if (agentInfoState === "failed") {
4214
+ savePendingAgentInfo(pendingPath, noteValue);
4215
+ skipped.push({
4216
+ step: "agent_info",
4217
+ reason: "failed"
3893
4218
  });
3894
- } catch {
3895
- throw new AgentDisconnectError("Primitive confirmed revocation, but its local confirmation could not be saved. The credential was preserved. Keep this profile for recovery.");
3896
- }
3897
- const current = loadConnectedAgentProfile(params.configDir, profileName);
3898
- if (!current || current.api_key !== profile.api_key) throw new AgentDisconnectError("The selected profile changed after revocation. Its credential was preserved for inspection.");
3899
- try {
3900
- removeMailFile(join(directory, "connection.json"));
3901
- } catch {
3902
- throw new AgentDisconnectError("Primitive confirmed revocation, but the local credential could not be removed. Confirmation is saved; retry this command to finish local cleanup.");
3903
- }
3904
- const externalHook = session ? uninstallClaudeWakeHook({
3905
- configDir: params.configDir,
3906
- profileName,
3907
- agentAddress: profile.agent_address,
3908
- sessionId: session,
3909
- env: params.env
3910
- }) ? "removed" : "unavailable" : null;
3911
- return {
3912
- status: "disconnected",
3913
- identity: connectedAgentIdentity(profileName, profile),
3914
- receiver,
3915
- revocation: previous ? "previously_confirmed" : "confirmed",
3916
- externalHook
3917
- };
3918
- } catch (error) {
3919
- if (error instanceof AgentDisconnectError) throw error;
3920
- throw new AgentDisconnectError("Disconnect could not safely complete. The credential and local evidence were preserved. Check this profile and listener status before retrying.");
3921
- } finally {
3922
- releaseClaim?.();
3923
- releaseSetup?.();
4219
+ } else clearPendingAgentInfo(pendingPath);
4220
+ if (agentInfoState === "already_present") skipped.push({
4221
+ step: "agent_info",
4222
+ reason: "already_present"
4223
+ });
3924
4224
  }
4225
+ const receiving = receiver === "external" ? {
4226
+ ...result.receiving,
4227
+ state: externalReceivingState(externalHook),
4228
+ mode: "external",
4229
+ hook: externalHook
4230
+ } : receiver === "poll" ? {
4231
+ ...result.receiving,
4232
+ mode: "poll",
4233
+ checkCommand: pollCheckCommand(invocation, result.identity.profileName),
4234
+ ...fallback ? {
4235
+ fallbackReason: fallback.reason,
4236
+ fallbackDetail: fallback.detail,
4237
+ codexVersion: fallback.codexVersion
4238
+ } : {}
4239
+ } : {
4240
+ ...result.receiving,
4241
+ mode: "native",
4242
+ mailCheck: mailCheck?.state ?? "not_started",
4243
+ lastSuccessfulMailCheckAt: mailCheck?.lastSuccessfulMailCheckAt ?? null
4244
+ };
4245
+ const receiverReady = receiver === "external" ? externalHook === "installed_unverified" : receiver === "poll" || result.receiving.state === "healthy";
4246
+ const resumeCommand = [
4247
+ invocation,
4248
+ "agent connect",
4249
+ `--profile ${result.identity.profileName}`,
4250
+ ...session ? [`--session ${session}`] : [],
4251
+ `--receiver ${receiver}`,
4252
+ "--resume",
4253
+ ...options.contactRequests ?? savedSetup(options.configDir, profileName)?.contactRequests ? ["--contact-requests"] : [],
4254
+ ...options.skill === false ? ["--no-skill"] : [],
4255
+ ...options.project ? ["--project"] : [],
4256
+ "--json"
4257
+ ].join(" ");
4258
+ return {
4259
+ status: verified && receiverReady ? "connected" : "pending",
4260
+ address: result.identity.agentAddress,
4261
+ orgId: result.identity.orgId,
4262
+ ownerAddress: result.identity.ownerAddress,
4263
+ ownerMemberAddress,
4264
+ profile: result.identity.profileName,
4265
+ ...result.connectionName === void 0 ? {} : {
4266
+ name: result.connectionName,
4267
+ nameIsDefault: defaultConnectionName(result.connectionName, result.identity.agentAddress)
4268
+ },
4269
+ sessionId: session ?? null,
4270
+ runtime,
4271
+ cli: {
4272
+ version: options.cliVersion,
4273
+ capabilities: [...AGENT_CONNECT_CAPABILITIES]
4274
+ },
4275
+ skill,
4276
+ verification: result.verification,
4277
+ receiving,
4278
+ ...result.ownerNotifications ? { ownerNotifications: result.ownerNotifications } : {},
4279
+ agentInfo: agentInfoState,
4280
+ skipped,
4281
+ warnings,
4282
+ selectProfile: `PRIMITIVE_AGENT_PROFILE=${result.identity.profileName}`,
4283
+ resumeCommand,
4284
+ guidance: `${result.guidance} ${ownerReportGuidance(identity)}`,
4285
+ identity,
4286
+ externalHook
4287
+ };
3925
4288
  }
3926
4289
  //#endregion
3927
4290
  //#region src/oclif/agent-enroll.ts
@@ -4434,7 +4797,7 @@ async function enrollAgent(params) {
4434
4797
  //#endregion
4435
4798
  //#region src/oclif/machine-session.ts
4436
4799
  var machine_session_exports = /* @__PURE__ */ __exportAll({
4437
- MACHINE_RUNTIMES: () => MACHINE_RUNTIMES,
4800
+ HOOK_END_GRACE_MS: () => HOOK_END_GRACE_MS,
4438
4801
  OMP_MAIN: () => OMP_MAIN,
4439
4802
  agentConnectionState: () => agentConnectionState,
4440
4803
  agentInfoForSession: () => agentInfoForSession,
@@ -4453,40 +4816,6 @@ var machine_session_exports = /* @__PURE__ */ __exportAll({
4453
4816
  runtimeSessionId: () => runtimeSessionId,
4454
4817
  workspaceName: () => workspaceName
4455
4818
  });
4456
- const MACHINE_RUNTIMES = [
4457
- "claude",
4458
- "codex",
4459
- "omp"
4460
- ];
4461
- function sessionRecordPath(configDir, session) {
4462
- return join(configDir, "machine", "sessions", `${session}.json`);
4463
- }
4464
- function readSessionRecord(configDir, session) {
4465
- try {
4466
- const value = readMailJson(sessionRecordPath(configDir, session));
4467
- if (!value || typeof value !== "object" || Array.isArray(value)) return null;
4468
- const row = value;
4469
- if (row.version !== 1 || row.session !== session || typeof row.profile !== "string" || typeof row.name !== "string" || !MACHINE_RUNTIMES.includes(row.runtime) || row.createdBy !== "session-register" && row.createdBy !== "existing") return null;
4470
- return {
4471
- version: 1,
4472
- runtime: row.runtime,
4473
- session,
4474
- profile: row.profile,
4475
- name: row.name,
4476
- createdBy: row.createdBy,
4477
- address: typeof row.address === "string" ? row.address : null,
4478
- agentInfo: row.agentInfo === "created" || row.agentInfo === "already_present" ? row.agentInfo : null,
4479
- registeredAt: typeof row.registeredAt === "string" ? row.registeredAt : (/* @__PURE__ */ new Date(0)).toISOString(),
4480
- endedAt: typeof row.endedAt === "string" ? row.endedAt : null,
4481
- disconnect: row.disconnect === "done" || row.disconnect === "pending" || row.disconnect === "pending_enrollment" ? row.disconnect : typeof row.endedAt === "string" ? "done" : null
4482
- };
4483
- } catch {
4484
- return null;
4485
- }
4486
- }
4487
- function saveSessionRecord(configDir, row) {
4488
- writeMailJson(sessionRecordPath(configDir, row.session), row);
4489
- }
4490
4819
  /** The repository (or directory) a session works in, for its default name. */
4491
4820
  function workspaceName(cwd) {
4492
4821
  let current = resolve(cwd);
@@ -4644,32 +4973,6 @@ function receiverMode(setupDirectory) {
4644
4973
  return null;
4645
4974
  }
4646
4975
  }
4647
- /** Serialize every read-modify-write of one session's record. */
4648
- async function withRecordLock(configDir, session, action) {
4649
- const directory = join(configDir, "machine", "sessions");
4650
- privateMailDirectory(directory, true);
4651
- let release;
4652
- for (let attempt = 0; attempt < 200 && !release; attempt++) try {
4653
- release = acquireListenLock(directory, `session-record-${session}`);
4654
- } catch {
4655
- await setTimeout$1(50);
4656
- }
4657
- if (!release) throw new ListenStateError("The session record is locked by another run.");
4658
- try {
4659
- return await action();
4660
- } finally {
4661
- release();
4662
- }
4663
- }
4664
- /** Read, change and save one record under its lock. Returns the saved record. */
4665
- function updateSessionRecord(configDir, session, change) {
4666
- return withRecordLock(configDir, session, () => {
4667
- const current = readSessionRecord(configDir, session);
4668
- const next = change(current);
4669
- if (next && next !== current) saveSessionRecord(configDir, next);
4670
- return next ?? current;
4671
- });
4672
- }
4673
4976
  /** The address an interrupted enrollment saved, when there is one. */
4674
4977
  function enrollmentAddress(configDir, profileName) {
4675
4978
  try {
@@ -4791,17 +5094,95 @@ async function revokeWithMemberLogin(configDir, address, fetchImpl = fetch) {
4791
5094
  return false;
4792
5095
  }
4793
5096
  }
5097
+ const DISCONNECT_DEFAULTS = {
5098
+ disconnect: disconnectAgent,
5099
+ revokeByAddress: revokeWithMemberLogin,
5100
+ uninstallHook: (options) => uninstallClaudeWakeHook(options),
5101
+ now: () => /* @__PURE__ */ new Date()
5102
+ };
4794
5103
  /** Enrollment saves its state before any request, so a quiet record this old created nothing. */
4795
5104
  const ENROLLMENT_SETTLED_MS = 6e5;
5105
+ function sameAddress(a, b) {
5106
+ return a !== null && b !== null && a.toLowerCase() === b.toLowerCase();
5107
+ }
5108
+ /**
5109
+ * Whether the profile's current credential is the one this registration
5110
+ * created. Another credential can sit in the same profile after `agent
5111
+ * connect` or a replacement, and that one must never be disconnected here.
5112
+ */
5113
+ function holdsRegisteredCredential(configDir, record, current) {
5114
+ if (record.invitationHash) return record.invitationHash === current.invitation_hash && (record.address === null || sameAddress(record.address, current.agent_address));
5115
+ if (record.address) return sameAddress(record.address, current.agent_address);
5116
+ return sameAddress(enrollmentAddress(configDir, record.profile).address, current.agent_address);
5117
+ }
5118
+ /** A saved revocation confirmation in the profile names this address. */
5119
+ function addressRevokedLocally(configDir, profileName, address) {
5120
+ try {
5121
+ const directory = agentProfileDirectory(configDir, profileName);
5122
+ return readdirSync(directory).some((name) => {
5123
+ if (!/^disconnected-[a-f0-9]{64}\.json$/.test(name)) return false;
5124
+ try {
5125
+ const row = readMailJson(join(directory, name));
5126
+ return typeof row?.address === "string" && sameAddress(row.address, address);
5127
+ } catch {
5128
+ return false;
5129
+ }
5130
+ });
5131
+ } catch {
5132
+ return false;
5133
+ }
5134
+ }
5135
+ /**
5136
+ * The profile holds another credential now. Leave it connected; remove only
5137
+ * the registered agent's own exact-session hooks (they are keyed by its
5138
+ * address, so the current agent's hooks are untouched), and revoke the
5139
+ * registered agent by address if nothing confirms it is already revoked.
5140
+ * When the current credential has the registered address (the same address
5141
+ * reconnected with a new credential), hooks and a revocation by address would
5142
+ * both hit the current agent, so nothing is touched.
5143
+ */
5144
+ async function cleanUpReplacedRegistration(configDir, record, current, deps, env, fetchImpl) {
5145
+ const address = record.address;
5146
+ if (!address || sameAddress(address, current.agent_address)) return;
5147
+ if (record.runtime === "claude") try {
5148
+ deps.uninstallHook({
5149
+ configDir,
5150
+ profileName: record.profile,
5151
+ agentAddress: address,
5152
+ sessionId: record.session,
5153
+ env
5154
+ });
5155
+ } catch {}
5156
+ if (addressRevokedLocally(configDir, record.profile, address)) return;
5157
+ try {
5158
+ await deps.revokeByAddress(configDir, address, fetchImpl);
5159
+ } catch {}
5160
+ }
4796
5161
  async function disconnectSessionAgent(configDir, record, deps, env, fetchImpl, enrollmentSettled = false) {
4797
5162
  try {
4798
- if (loadConnectedAgentProfile(configDir, record.profile)) {
4799
- await deps.disconnect({
4800
- configDir,
4801
- profileName: record.profile,
4802
- fetch: fetchImpl,
4803
- env
4804
- });
5163
+ const current = loadConnectedAgentProfile(configDir, record.profile);
5164
+ if (current) {
5165
+ const release = async (holder) => {
5166
+ await cleanUpReplacedRegistration(configDir, record, holder, deps, env, fetchImpl);
5167
+ return "released";
5168
+ };
5169
+ if (!holdsRegisteredCredential(configDir, record, current)) return await release(current);
5170
+ try {
5171
+ await deps.disconnect({
5172
+ configDir,
5173
+ profileName: record.profile,
5174
+ fetch: fetchImpl,
5175
+ env,
5176
+ expected: {
5177
+ address: current.agent_address,
5178
+ invitationHash: current.invitation_hash
5179
+ }
5180
+ });
5181
+ } catch (error) {
5182
+ if (!(error instanceof AgentCredentialChangedError)) throw error;
5183
+ const holder = loadConnectedAgentProfile(configDir, record.profile);
5184
+ return holder ? await release(holder) : "pending";
5185
+ }
4805
5186
  return "done";
4806
5187
  }
4807
5188
  if (disconnectConfirmedLocally(configDir, record.profile)) return "done";
@@ -4818,10 +5199,16 @@ async function disconnectSessionAgent(configDir, record, deps, env, fetchImpl, e
4818
5199
  }
4819
5200
  async function finishSessionDisconnect(configDir, record, deps, env, fetchImpl, enrollmentSettled = false) {
4820
5201
  const outcome = await disconnectSessionAgent(configDir, record, deps, env, fetchImpl, enrollmentSettled);
4821
- return (await updateSessionRecord(configDir, record.session, (current) => !current || current.disconnect === "done" || current.disconnect === outcome ? current : {
4822
- ...current,
4823
- disconnect: outcome
4824
- }))?.disconnect === "done" ? "done" : outcome;
5202
+ const saved = await updateSessionRecord(configDir, record.session, (current) => {
5203
+ if (current?.createdBy !== "session-register" || current.disconnect === "done") return current;
5204
+ if (outcome === "released") return releasedRecord(current, "credential_changed", deps.now());
5205
+ return current.disconnect === outcome ? current : {
5206
+ ...current,
5207
+ disconnect: outcome
5208
+ };
5209
+ });
5210
+ if (saved?.createdBy === "existing" && outcome === "released") return "released";
5211
+ return saved?.disconnect === "done" ? "done" : outcome;
4825
5212
  }
4826
5213
  const RECEIVING = {
4827
5214
  claude: "external_hook",
@@ -4836,6 +5223,8 @@ function withDefined(defaults, overrides) {
4836
5223
  }
4837
5224
  const HELD_DETAIL = "This session already receives as another connected profile, so this profile's receive hooks were not added. Run `primitive machine doctor --fix --profile <profile>` to bind it deliberately.";
4838
5225
  const ENDED_DETAIL = "This session ended, so its agent was disconnected. A new address is never created for the same session.";
5226
+ const ENDED_DEFERRED_DETAIL = "This session ended moments ago. Its agent is disconnected after a short wait unless the session starts again first.";
5227
+ const RELEASED_DETAIL = "This session's profile now holds an agent connected some other way, so it was left connected.";
4839
5228
  const ENDED_PENDING_DETAIL = "This session ended, but disconnecting its agent has not been confirmed yet; `primitive machine doctor --fix` retries it.";
4840
5229
  /**
4841
5230
  * Give this session an address once. Safe to run on every start and resume:
@@ -4851,9 +5240,7 @@ async function registerSession(options) {
4851
5240
  seedAgentInfo: (configDir, profileName, value) => seedAgentInfoNote(configDir, profileName, value, options.fetch),
4852
5241
  connectionState: (profile) => agentConnectionState(profile, options.fetch),
4853
5242
  ompSession: ompProcessSession,
4854
- now: () => /* @__PURE__ */ new Date(),
4855
- disconnect: disconnectAgent,
4856
- revokeByAddress: revokeWithMemberLogin
5243
+ ...DISCONNECT_DEFAULTS
4857
5244
  }, options.dependencies);
4858
5245
  const runtime = options.runtime;
4859
5246
  const base = {
@@ -4920,7 +5307,18 @@ async function registerSession(options) {
4920
5307
  };
4921
5308
  const cwd = resolve(options.cwd ?? process.cwd());
4922
5309
  const ended = async (current, enrolledNow = false) => {
5310
+ if (current.disconnect === "deferred") return {
5311
+ ...known,
5312
+ address: current.address,
5313
+ status: "ended",
5314
+ detail: ENDED_DEFERRED_DETAIL
5315
+ };
4923
5316
  const outcome = current.disconnect === "done" && !enrolledNow ? "done" : await finishSessionDisconnect(options.configDir, current, deps, env, options.fetch, enrolledNow);
5317
+ if (outcome === "released") return {
5318
+ ...known,
5319
+ status: "already_registered",
5320
+ detail: RELEASED_DETAIL
5321
+ };
4924
5322
  return {
4925
5323
  ...known,
4926
5324
  address: current.address,
@@ -4928,7 +5326,7 @@ async function registerSession(options) {
4928
5326
  detail: outcome === "done" ? ENDED_DETAIL : ENDED_PENDING_DETAIL
4929
5327
  };
4930
5328
  };
4931
- const newRecord = (createdBy, address) => ({
5329
+ const newRecord = (createdBy, address, invitationHash = null) => ({
4932
5330
  version: 1,
4933
5331
  runtime,
4934
5332
  session: sessionId,
@@ -4936,13 +5334,23 @@ async function registerSession(options) {
4936
5334
  name: defaultSessionName(runtime, cwd),
4937
5335
  createdBy,
4938
5336
  address,
5337
+ invitationHash,
4939
5338
  agentInfo: null,
4940
5339
  registeredAt: deps.now().toISOString(),
4941
5340
  endedAt: null,
4942
- disconnect: null
5341
+ disconnect: null,
5342
+ deferredUntil: null,
5343
+ endReason: null,
5344
+ release: null
4943
5345
  });
4944
5346
  try {
4945
- let record = readSessionRecord(options.configDir, sessionId);
5347
+ let record = await updateSessionRecord(options.configDir, sessionId, (current) => current?.endedAt && current.disconnect === "deferred" ? {
5348
+ ...current,
5349
+ endedAt: null,
5350
+ disconnect: null,
5351
+ deferredUntil: null,
5352
+ endReason: null
5353
+ } : current);
4946
5354
  if (record?.endedAt) return await ended(record);
4947
5355
  const finishAgentInfo = async () => {
4948
5356
  if (reused || record?.agentInfo) return;
@@ -4968,7 +5376,7 @@ async function registerSession(options) {
4968
5376
  const installHook = (profile) => runtime !== "claude" || receiverMode(agentProfileDirectory(options.configDir, profileName)) !== "external" ? "installed" : claudeHook(profile.agent_address);
4969
5377
  const existing = loadConnectedAgentProfile(options.configDir, profileName);
4970
5378
  if (existing) {
4971
- record = await updateSessionRecord(options.configDir, sessionId, (current) => current ?? newRecord("existing", existing.agent_address));
5379
+ record = await updateSessionRecord(options.configDir, sessionId, (current) => current ?? newRecord("existing", existing.agent_address, existing.invitation_hash));
4972
5380
  if (!record) throw new Error("session record unavailable");
4973
5381
  if (record.endedAt) return await ended(record);
4974
5382
  const state = await deps.connectionState(existing);
@@ -5045,14 +5453,18 @@ async function registerSession(options) {
5045
5453
  enrollError = error;
5046
5454
  }
5047
5455
  let created = result?.identity.agentAddress ?? null;
5048
- if (!created) try {
5049
- created = loadConnectedAgentProfile(options.configDir, profileName)?.agent_address ?? enrollmentAddress(options.configDir, profileName).address;
5456
+ let saved = null;
5457
+ try {
5458
+ saved = loadConnectedAgentProfile(options.configDir, profileName);
5050
5459
  } catch {
5051
- created = enrollmentAddress(options.configDir, profileName).address;
5460
+ saved = null;
5052
5461
  }
5053
- record = await updateSessionRecord(options.configDir, sessionId, (current) => current && created && current.address !== created ? {
5462
+ created ??= saved?.agent_address ?? enrollmentAddress(options.configDir, profileName).address;
5463
+ const createdHash = saved && created && sameAddress(saved.agent_address, created) ? saved.invitation_hash : null;
5464
+ record = await updateSessionRecord(options.configDir, sessionId, (current) => current && current.createdBy === "session-register" && created && (current.address !== created || createdHash !== null && current.invitationHash !== createdHash) ? {
5054
5465
  ...current,
5055
- address: created
5466
+ address: created,
5467
+ invitationHash: createdHash ?? current.invitationHash
5056
5468
  } : current);
5057
5469
  if (!record) throw new Error("session record unavailable");
5058
5470
  if (record.endedAt) return await ended(record, true);
@@ -5100,17 +5512,30 @@ async function registerSession(options) {
5100
5512
  }
5101
5513
  }
5102
5514
  /**
5515
+ * How long an end from a runtime hook waits before disconnecting. A runtime
5516
+ * reports the same end for a restart, a switch to another session and a real
5517
+ * exit, so the hook cannot tell them apart; a start of the same session
5518
+ * within this window cancels the end and keeps its agent.
5519
+ */
5520
+ const HOOK_END_GRACE_MS = 3e5;
5521
+ /** The end of this record is a hook's, still waiting. */
5522
+ function deferredEnd(record) {
5523
+ return Boolean(record?.endedAt) && record?.disconnect === "deferred";
5524
+ }
5525
+ /**
5103
5526
  * Disconnect the agent a session got from `session-register`. Agents
5104
- * connected any other way are left alone. The end is recorded first, under
5105
- * the record's lock, so a registration still enrolling sees it and
5106
- * disconnects the agent it creates. Never throws.
5527
+ * connected any other way are left alone, including a credential connected
5528
+ * later into the same profile. The end is recorded first, under the record's
5529
+ * lock, so a registration still enrolling sees it and disconnects the agent
5530
+ * it creates. With `graceMs` (an end reported by a runtime hook) the
5531
+ * disconnect waits that long, and a start of the same session meanwhile
5532
+ * cancels it. Never throws.
5107
5533
  */
5108
5534
  async function endSession(options) {
5109
5535
  const env = options.env ?? process.env;
5110
5536
  const deps = withDefined({
5111
- disconnect: disconnectAgent,
5112
- revokeByAddress: revokeWithMemberLogin,
5113
- now: () => /* @__PURE__ */ new Date()
5537
+ ...DISCONNECT_DEFAULTS,
5538
+ sleep: (milliseconds) => setTimeout$1(milliseconds)
5114
5539
  }, options.dependencies);
5115
5540
  const runtime = options.runtime;
5116
5541
  const base = {
@@ -5135,20 +5560,36 @@ async function endSession(options) {
5135
5560
  detail: "No session ID was given or found. Nothing was changed."
5136
5561
  };
5137
5562
  const sessionId = session.toLowerCase();
5563
+ const graceMs = Math.max(0, options.graceMs ?? 0);
5564
+ const reason = options.reason && /^[a-z_]{1,40}$/.test(options.reason) ? options.reason : null;
5138
5565
  try {
5139
5566
  let managed = false;
5140
5567
  let alreadyDone = false;
5141
- const record = await updateSessionRecord(options.configDir, sessionId, (current) => {
5568
+ let deferred = false;
5569
+ let record = await updateSessionRecord(options.configDir, sessionId, (current) => {
5142
5570
  if (current?.createdBy !== "session-register") return current;
5143
5571
  managed = true;
5144
5572
  if (current.endedAt && current.disconnect === "done") {
5145
5573
  alreadyDone = true;
5146
5574
  return current;
5147
5575
  }
5576
+ const now = deps.now();
5577
+ if (graceMs > 0 && (!current.endedAt || deferredEnd(current))) {
5578
+ deferred = true;
5579
+ return {
5580
+ ...current,
5581
+ endedAt: now.toISOString(),
5582
+ disconnect: "deferred",
5583
+ deferredUntil: new Date(now.getTime() + graceMs).toISOString(),
5584
+ endReason: reason
5585
+ };
5586
+ }
5148
5587
  return {
5149
5588
  ...current,
5150
- endedAt: current.endedAt ?? deps.now().toISOString(),
5151
- disconnect: "pending"
5589
+ endedAt: current.endedAt ?? now.toISOString(),
5590
+ disconnect: current.disconnect === "pending_enrollment" ? "pending_enrollment" : "pending",
5591
+ deferredUntil: null,
5592
+ endReason: current.endReason ?? reason
5152
5593
  };
5153
5594
  });
5154
5595
  const known = {
@@ -5166,7 +5607,34 @@ async function endSession(options) {
5166
5607
  status: "already_ended",
5167
5608
  detail: "This session's agent was already disconnected."
5168
5609
  };
5169
- return await finishSessionDisconnect(options.configDir, record, deps, env, options.fetch) === "done" ? {
5610
+ if (deferred) {
5611
+ const marked = record.endedAt;
5612
+ await deps.sleep(graceMs);
5613
+ let resumed = false;
5614
+ record = await updateSessionRecord(options.configDir, sessionId, (current) => {
5615
+ if (current?.createdBy !== "session-register" || current.endedAt !== marked || current.disconnect !== "deferred") {
5616
+ resumed = true;
5617
+ return current;
5618
+ }
5619
+ return {
5620
+ ...current,
5621
+ disconnect: "pending",
5622
+ deferredUntil: null
5623
+ };
5624
+ });
5625
+ if (resumed || !record) return {
5626
+ ...known,
5627
+ status: "resumed",
5628
+ detail: "The session started again (or was ended again) before its end took effect, so this end left its agent connected."
5629
+ };
5630
+ }
5631
+ const outcome = await finishSessionDisconnect(options.configDir, record, deps, env, options.fetch);
5632
+ if (outcome === "released") return {
5633
+ ...known,
5634
+ status: "left_connected",
5635
+ detail: "This session's profile now holds an agent that `agent session-register` did not create, so it was left connected. Only the registered agent's own receive hooks were removed."
5636
+ };
5637
+ return outcome === "done" ? {
5170
5638
  ...known,
5171
5639
  status: "disconnected",
5172
5640
  detail: "This session's agent was disconnected."
@@ -5184,30 +5652,42 @@ async function endSession(options) {
5184
5652
  };
5185
5653
  }
5186
5654
  }
5187
- /** Ended sessions whose agent disconnect still needs confirming. */
5188
- function pendingSessionDisconnects(configDir) {
5189
- const directory = join(configDir, "machine", "sessions");
5190
- let names;
5191
- try {
5192
- names = readdirSync(directory);
5193
- } catch {
5194
- return [];
5195
- }
5196
- return names.flatMap((name) => {
5197
- const match = /^([0-9a-f-]{36})\.json$/.exec(name);
5198
- if (!match?.[1]) return [];
5199
- const record = readSessionRecord(configDir, match[1]);
5200
- return record?.endedAt && (record.disconnect === "pending" || record.disconnect === "pending_enrollment") ? [record] : [];
5201
- });
5655
+ /**
5656
+ * Ended sessions whose agent disconnect still needs confirming, including a
5657
+ * hook's end whose grace period is over but whose own process never finished
5658
+ * it (for example because the machine shut down).
5659
+ */
5660
+ function pendingSessionDisconnects(configDir, now = /* @__PURE__ */ new Date()) {
5661
+ return listSessionRecords(configDir).filter((record) => record.createdBy === "session-register" && record.endedAt && (record.disconnect === "pending" || record.disconnect === "pending_enrollment" || record.disconnect === "deferred" && !(Date.parse(record.deferredUntil ?? "") > now.getTime())));
5202
5662
  }
5203
- /** Retry every pending disconnect. Returns how many are now confirmed. */
5663
+ /**
5664
+ * Retry every pending disconnect. Each goes through the same credential check
5665
+ * as a session end, so a profile reconnected some other way is left connected
5666
+ * (and counts as resolved). Returns how many are now resolved.
5667
+ */
5204
5668
  async function retryPendingDisconnects(configDir, options = {}) {
5205
- const deps = withDefined({
5206
- disconnect: disconnectAgent,
5207
- revokeByAddress: revokeWithMemberLogin
5208
- }, options.dependencies);
5669
+ const deps = withDefined(DISCONNECT_DEFAULTS, options.dependencies);
5209
5670
  let done = 0;
5210
- for (const record of pendingSessionDisconnects(configDir)) if (await finishSessionDisconnect(configDir, record, deps, options.env ?? process.env, options.fetch) === "done") done++;
5671
+ for (const listed of pendingSessionDisconnects(configDir, deps.now())) {
5672
+ let record = listed;
5673
+ if (listed.disconnect === "deferred") {
5674
+ let changed = false;
5675
+ record = await updateSessionRecord(configDir, listed.session, (current) => {
5676
+ if (!current || current.endedAt !== listed.endedAt || current.disconnect !== "deferred") {
5677
+ changed = true;
5678
+ return current;
5679
+ }
5680
+ return {
5681
+ ...current,
5682
+ disconnect: "pending",
5683
+ deferredUntil: null
5684
+ };
5685
+ });
5686
+ if (changed || !record) continue;
5687
+ }
5688
+ const outcome = await finishSessionDisconnect(configDir, record, deps, options.env ?? process.env, options.fetch);
5689
+ if (outcome === "done" || outcome === "released") done++;
5690
+ }
5211
5691
  return done;
5212
5692
  }
5213
5693
  /** Read Claude's hook JSON from stdin within a short budget. */
@@ -5232,7 +5712,8 @@ async function readClaudeHookInput(stdin, timeoutMs = 2e3) {
5232
5712
  if (typeof value?.session_id !== "string" || !SESSION_UUID.test(value.session_id)) return null;
5233
5713
  return {
5234
5714
  sessionId: value.session_id.toLowerCase(),
5235
- cwd: typeof value.cwd === "string" && value.cwd ? value.cwd : null
5715
+ cwd: typeof value.cwd === "string" && value.cwd ? value.cwd : null,
5716
+ reason: typeof value.reason === "string" && /^[a-z_]{1,40}$/.test(value.reason) ? value.reason : null
5236
5717
  };
5237
5718
  } catch {
5238
5719
  return null;
@@ -5267,4 +5748,4 @@ async function runDetached(params) {
5267
5748
  }
5268
5749
  }
5269
5750
  //#endregion
5270
- export { describeSessionHookItem as $, evaluateContactPolicy as A, agentInvitationHash as B, RECEIVER_MODES as C, NETWORK_ADMISSION_PENDING_RETRY_MS as D, ContactPolicyReadRetryError as E, ContactsApiError as F, saveConnectionName as G, readAgentInvitation as H, contactAddress as I, recordHookMailCheck as J, savedReceiver as K, contactAgentAddress as L, AddressNotesApiError as M, addressNoteTarget as N, NetworkAdmissionPendingError as O, runAddressNotesRequest as P, describeSessionHookChange as Q, runContactRequest as R, runAgentConnect as S, verificationReplySubmitted as T, receiverStatusCommand as U, connectAgent as V, refusalAfterReplacement as W, claudeConfigDir as X, boundSessionProfiles as Y, currentCliLocation as Z, disconnectAgent as _, withOwnerMemberAddress as _t, endSession as a, readClaudeSettings as at, invitationProfileName as b, pendingSessionDisconnects as c, writeClaudeSettings as ct, registerSession as d, jsonIndentation as dt, ensureHookLauncher as et, retryPendingDisconnects as f, readManagedFile as ft, AgentDisconnectError as g, refreshOwnerMemberAddressPeriodically as gt, enrollmentResumes as h, readHookLauncherWarning as ht, connectedProfilesForSession as i, inspectClaudeHooks as it, canonicalContactSelector as j, createNotificationContactPolicy as k, psProcessInfo as l, MachineFileError as lt, enrollAgent as m, writeManagedFile as mt, OMP_MAIN as n, findOnPath as nt, headlessClaudeRun as o, repairClaudeHooks as ot, runDetached as p, upsertManagedBlock as pt, installClaudeWakeHook as q, agentConnectionState as r, hasMachineSessionHooks as rt, machine_session_exports as s, samePath as st, MACHINE_RUNTIMES as t, ephemeralCliReason as tt, readClaudeHookInput as u, inspectManagedBlock as ut, connectWarnings as v, externalReceivingState as w, pollCheckCommand as x, defaultAgentProfileName as y, agentConnectionStatus as z };
5751
+ export { boundSessionProfiles as $, AgentDisconnectError as A, runContactRequest as B, verificationReplySubmitted as C, createNotificationContactPolicy as D, NetworkAdmissionPendingError as E, addressNoteTarget as F, receiverStatusCommand as G, agentInvitationHash as H, runAddressNotesRequest as I, savedReceiver as J, refusalAfterReplacement as K, ContactsApiError as L, clearRevokedAddresses as M, disconnectAgent as N, evaluateContactPolicy as O, AddressNotesApiError as P, recordHookMailCheck as Q, contactAddress as R, externalReceivingState as S, NETWORK_ADMISSION_PENDING_RETRY_MS as T, connectAgent as U, agentConnectionStatus as V, readAgentInvitation as W, releaseRegistrationsForProfile as X, MACHINE_RUNTIMES as Y, installClaudeWakeHook as Z, defaultAgentProfileName as _, upsertManagedBlock as _t, endSession as a, ephemeralCliReason as at, runAgentConnect as b, refreshOwnerMemberAddressPeriodically as bt, pendingSessionDisconnects as c, inspectClaudeHooks as ct, registerSession as d, samePath as dt, claudeConfigDir as et, retryPendingDisconnects as f, writeClaudeSettings as ft, connectWarnings as g, readManagedFile as gt, enrollmentResumes as h, jsonIndentation as ht, connectedProfilesForSession as i, ensureHookLauncher as it, claimOverRevokedSetup as j, canonicalContactSelector as k, psProcessInfo as l, readClaudeSettings as lt, enrollAgent as m, inspectManagedBlock as mt, OMP_MAIN as n, describeSessionHookChange as nt, headlessClaudeRun as o, findOnPath as ot, runDetached as p, MachineFileError as pt, saveConnectionName as q, agentConnectionState as r, describeSessionHookItem as rt, machine_session_exports as s, hasMachineSessionHooks as st, HOOK_END_GRACE_MS as t, currentCliLocation as tt, readClaudeHookInput as u, repairClaudeHooks as ut, invitationProfileName as v, writeManagedFile as vt, ContactPolicyReadRetryError as w, RECEIVER_MODES as x, withOwnerMemberAddress as xt, pollCheckCommand as y, readHookLauncherWarning as yt, contactAgentAddress as z };