primcli 1.67.0 → 1.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  import { S as __exportAll } from "./repeats-BL7ZC-_6-BZ-MphTV.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-Dh4hs7kh.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-C58Oh38I.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-CeKAcPIX.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-CVZtEboh.js";
6
6
  import { a as scopedChatSenderTrust } from "./scoped-chat-Dsark9hm.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";
@@ -1674,8 +1674,9 @@ function readSessionRecord(configDir, session) {
1674
1674
  agentInfo: row.agentInfo === "created" || row.agentInfo === "already_present" ? row.agentInfo : null,
1675
1675
  registeredAt: typeof row.registeredAt === "string" ? row.registeredAt : (/* @__PURE__ */ new Date(0)).toISOString(),
1676
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,
1677
+ disconnect: row.disconnect === "done" || row.disconnect === "pending" || row.disconnect === "pending_enrollment" || row.disconnect === "deferred" || row.disconnect === "kept" ? row.disconnect : typeof row.endedAt === "string" ? "done" : null,
1678
1678
  deferredUntil: typeof row.deferredUntil === "string" ? row.deferredUntil : null,
1679
+ revokeOnEnd: row.revokeOnEnd === true,
1679
1680
  endReason: typeof row.endReason === "string" && REASON.test(row.endReason) ? row.endReason : null,
1680
1681
  release: parseRelease(row.release)
1681
1682
  };
@@ -1741,6 +1742,7 @@ function releasedRecord(record, reason, at, current) {
1741
1742
  endedAt: null,
1742
1743
  disconnect: null,
1743
1744
  deferredUntil: null,
1745
+ revokeOnEnd: false,
1744
1746
  endReason: null,
1745
1747
  release: {
1746
1748
  at: at.toISOString(),
@@ -2505,7 +2507,7 @@ async function runAddressNotesRequest(client, request) {
2505
2507
  if (request.ifAbsent) condition = { if_absent: true };
2506
2508
  else if (ifVersion) condition = { if_version: ifVersion };
2507
2509
  else try {
2508
- condition = { if_version: (await getNote(client, address, name)).version };
2510
+ condition = { if_version: (await getNote(client, address, name, request.signal)).version };
2509
2511
  } catch (error) {
2510
2512
  if (!(error instanceof AddressNotesApiError) || error.status !== 404) throw error;
2511
2513
  condition = { if_absent: true };
@@ -2523,10 +2525,11 @@ async function runAddressNotesRequest(client, request) {
2523
2525
  ...request.visibility ? { visibility: request.visibility } : {}
2524
2526
  },
2525
2527
  headers: { "Content-Type": "application/json" },
2526
- responseStyle: "fields"
2528
+ responseStyle: "fields",
2529
+ ...request.signal ? { signal: request.signal } : {}
2527
2530
  })), address, name);
2528
2531
  }
2529
- const version = ifVersion ?? (await getNote(client, address, name)).version;
2532
+ const version = ifVersion ?? (await getNote(client, address, name, request.signal)).version;
2530
2533
  const data = await result(client.delete({
2531
2534
  security,
2532
2535
  url: "/address-notes/{address}/{name}",
@@ -2535,12 +2538,127 @@ async function runAddressNotesRequest(client, request) {
2535
2538
  name
2536
2539
  },
2537
2540
  query: { if_version: version },
2538
- responseStyle: "fields"
2541
+ responseStyle: "fields",
2542
+ ...request.signal ? { signal: request.signal } : {}
2539
2543
  }));
2540
2544
  if (!data || typeof data !== "object" || Array.isArray(data) || typeof data.deleted !== "boolean") throw new Error("The address notes API returned an invalid deletion response.");
2541
2545
  return data;
2542
2546
  }
2543
2547
  //#endregion
2548
+ //#region src/oclif/agent-resume-note.ts
2549
+ /**
2550
+ * The address note holding the command that resumes the coding session an
2551
+ * agent is bound to, so an app can offer to reopen that exact session.
2552
+ */
2553
+ const RESUME_NOTE_NAME = "AGENT_RESUME";
2554
+ /**
2555
+ * Each note request gets this long. The note is best effort and runs inside
2556
+ * registration, connect, disconnect and their hooks, so a stalled request
2557
+ * must never hold them up.
2558
+ */
2559
+ const NOTE_REQUEST_TIMEOUT_MS = 1e4;
2560
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2561
+ /**
2562
+ * The AGENT_RESUME value for a session: `claude --resume <uuid>` for Claude
2563
+ * Code or `codex resume <uuid>` for Codex, with the ID lowercased. Any other
2564
+ * runtime, or an ID that is not a UUID, has no value.
2565
+ */
2566
+ function resumeNoteValue(runtime, sessionId) {
2567
+ const id = sessionId?.trim().toLowerCase() ?? "";
2568
+ if (!UUID.test(id)) return null;
2569
+ if (runtime === "claude") return `claude --resume ${id}`;
2570
+ if (runtime === "codex") return `codex resume ${id}`;
2571
+ return null;
2572
+ }
2573
+ function client(profile, fetchImpl) {
2574
+ return new PrimitiveApiClient({
2575
+ apiKey: profile.api_key,
2576
+ apiBaseUrl: profile.api_base_url,
2577
+ ...fetchImpl ? { fetch: fetchImpl } : {}
2578
+ }).client;
2579
+ }
2580
+ /** The current note, or null when the address has none. */
2581
+ async function currentNote(api, address, timeoutMs) {
2582
+ try {
2583
+ return await runAddressNotesRequest(api, {
2584
+ action: "get",
2585
+ address,
2586
+ name: RESUME_NOTE_NAME,
2587
+ signal: AbortSignal.timeout(timeoutMs)
2588
+ });
2589
+ } catch (error) {
2590
+ if (error instanceof AddressNotesApiError && error.status === 404) return null;
2591
+ throw error;
2592
+ }
2593
+ }
2594
+ /**
2595
+ * Write the AGENT_RESUME note for a connected profile's address. Skips the
2596
+ * write when the note already holds the same value, so a resume hook that
2597
+ * runs on every start does not rewrite it. `rewrite` writes it anyway, so a
2598
+ * new claim leaves the note no older than the connection it belongs to.
2599
+ * Never throws.
2600
+ */
2601
+ async function publishResumeNote(options) {
2602
+ const timeoutMs = options.timeoutMs ?? 1e4;
2603
+ const value = resumeNoteValue(options.runtime, options.sessionId);
2604
+ if (value === null) return "skipped";
2605
+ try {
2606
+ const profile = loadConnectedAgentProfile(options.configDir, options.profileName);
2607
+ if (!profile) return "skipped";
2608
+ const api = client(profile, options.fetch);
2609
+ const address = profile.agent_address;
2610
+ const current = await currentNote(api, address, timeoutMs);
2611
+ if (current?.value === value && !options.rewrite) return "unchanged";
2612
+ await runAddressNotesRequest(api, {
2613
+ action: "set",
2614
+ address,
2615
+ name: RESUME_NOTE_NAME,
2616
+ value,
2617
+ visibility: "private",
2618
+ signal: AbortSignal.timeout(timeoutMs),
2619
+ ...current?.version ? { ifVersion: current.version } : { ifAbsent: true }
2620
+ });
2621
+ return "written";
2622
+ } catch {
2623
+ return "failed";
2624
+ }
2625
+ }
2626
+ /**
2627
+ * Delete the AGENT_RESUME note with this credential, before it is revoked.
2628
+ * Never throws.
2629
+ */
2630
+ async function clearResumeNote(profile, fetchImpl, timeoutMs = NOTE_REQUEST_TIMEOUT_MS) {
2631
+ try {
2632
+ const api = client(profile, fetchImpl);
2633
+ const current = await currentNote(api, profile.agent_address, timeoutMs);
2634
+ if (!current) return "absent";
2635
+ await runAddressNotesRequest(api, {
2636
+ action: "delete",
2637
+ address: profile.agent_address,
2638
+ name: RESUME_NOTE_NAME,
2639
+ signal: AbortSignal.timeout(timeoutMs),
2640
+ ...current.version ? { ifVersion: current.version } : {}
2641
+ });
2642
+ return "cleared";
2643
+ } catch {
2644
+ return "failed";
2645
+ }
2646
+ }
2647
+ /**
2648
+ * Delete the AGENT_RESUME note of a connected profile's address, for a
2649
+ * connection that binds no resumable session, so a command left by an older
2650
+ * session never points at the wrong conversation. Never throws.
2651
+ */
2652
+ async function clearProfileResumeNote(options) {
2653
+ try {
2654
+ const profile = loadConnectedAgentProfile(options.configDir, options.profileName);
2655
+ if (!profile) return "skipped";
2656
+ return await clearResumeNote(profile, options.fetch);
2657
+ } catch {
2658
+ return "failed";
2659
+ }
2660
+ }
2661
+ //#endregion
2544
2662
  //#region src/oclif/agent-disconnect.ts
2545
2663
  var AgentDisconnectError = class extends Error {};
2546
2664
  /** The profile no longer holds the credential the caller expected to revoke. */
@@ -2674,6 +2792,12 @@ async function disconnectAgent(params) {
2674
2792
  }) : null;
2675
2793
  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
2794
  const previous = confirmedLocally(directory, profile);
2795
+ let resumeNote = "skipped";
2796
+ if (!previous) try {
2797
+ resumeNote = await (params.clearResumeNote ?? clearResumeNote)(profile, params.fetch);
2798
+ } catch {
2799
+ resumeNote = "failed";
2800
+ }
2677
2801
  const revocation = previous ? "previously_confirmed" : await revoke(profile, params.fetch);
2678
2802
  if (!previous) try {
2679
2803
  writeMailJson(confirmationPath(directory, profile), {
@@ -2706,7 +2830,8 @@ async function disconnectAgent(params) {
2706
2830
  identity: connectedAgentIdentity(profileName, profile),
2707
2831
  receiver,
2708
2832
  revocation,
2709
- externalHook
2833
+ externalHook,
2834
+ resumeNote
2710
2835
  };
2711
2836
  } catch (error) {
2712
2837
  if (error instanceof AgentDisconnectError) throw error;
@@ -2816,6 +2941,7 @@ async function clearRevokedAddresses(params) {
2816
2941
  stopReceiver: params.stopReceiver,
2817
2942
  now: params.now,
2818
2943
  env: params.env,
2944
+ clearResumeNote: async () => "skipped",
2819
2945
  expected: {
2820
2946
  address: checked.agent_address,
2821
2947
  invitationHash: checked.invitation_hash
@@ -4026,6 +4152,17 @@ function defaults(options) {
4026
4152
  });
4027
4153
  },
4028
4154
  seedAgentInfo: (profileName, value) => seedAgentInfoNote(options.configDir, profileName, value),
4155
+ publishResumeNote: (profileName, runtime, session) => publishResumeNote({
4156
+ configDir: options.configDir,
4157
+ profileName,
4158
+ runtime,
4159
+ sessionId: session,
4160
+ rewrite: true
4161
+ }),
4162
+ clearResumeNote: (profileName) => clearProfileResumeNote({
4163
+ configDir: options.configDir,
4164
+ profileName
4165
+ }),
4029
4166
  refreshOwnerMemberAddress: (profileName) => refreshOwnerMemberAddress({
4030
4167
  configDir: options.configDir,
4031
4168
  profileName
@@ -4222,6 +4359,12 @@ async function runAgentConnect(options) {
4222
4359
  reason: "already_present"
4223
4360
  });
4224
4361
  }
4362
+ let resumeNote = "skipped";
4363
+ if (verified) try {
4364
+ resumeNote = session && runtime ? await dependencies.publishResumeNote(result.identity.profileName, runtime, session) : await dependencies.clearResumeNote(result.identity.profileName);
4365
+ } catch {
4366
+ resumeNote = "failed";
4367
+ }
4225
4368
  const receiving = receiver === "external" ? {
4226
4369
  ...result.receiving,
4227
4370
  state: externalReceivingState(externalHook),
@@ -4277,6 +4420,7 @@ async function runAgentConnect(options) {
4277
4420
  receiving,
4278
4421
  ...result.ownerNotifications ? { ownerNotifications: result.ownerNotifications } : {},
4279
4422
  agentInfo: agentInfoState,
4423
+ resumeNote,
4280
4424
  skipped,
4281
4425
  warnings,
4282
4426
  selectProfile: `PRIMITIVE_AGENT_PROFILE=${result.identity.profileName}`,
@@ -4799,6 +4943,8 @@ async function enrollAgent(params) {
4799
4943
  var machine_session_exports = /* @__PURE__ */ __exportAll({
4800
4944
  HOOK_END_GRACE_MS: () => HOOK_END_GRACE_MS,
4801
4945
  OMP_MAIN: () => OMP_MAIN,
4946
+ RECONNECT_HINT: () => RECONNECT_HINT,
4947
+ STALE_SESSION_MS: () => STALE_SESSION_MS,
4802
4948
  agentConnectionState: () => agentConnectionState,
4803
4949
  agentInfoForSession: () => agentInfoForSession,
4804
4950
  connectedProfilesForSession: () => connectedProfilesForSession,
@@ -4811,9 +4957,11 @@ var machine_session_exports = /* @__PURE__ */ __exportAll({
4811
4957
  readClaudeHookInput: () => readClaudeHookInput,
4812
4958
  registerSession: () => registerSession,
4813
4959
  retryPendingDisconnects: () => retryPendingDisconnects,
4960
+ revokeStaleSessionAgents: () => revokeStaleSessionAgents,
4814
4961
  revokeWithMemberLogin: () => revokeWithMemberLogin,
4815
4962
  runDetached: () => runDetached,
4816
4963
  runtimeSessionId: () => runtimeSessionId,
4964
+ staleSessionAgents: () => staleSessionAgents,
4817
4965
  workspaceName: () => workspaceName
4818
4966
  });
4819
4967
  /** The repository (or directory) a session works in, for its default name. */
@@ -5158,7 +5306,7 @@ async function cleanUpReplacedRegistration(configDir, record, current, deps, env
5158
5306
  await deps.revokeByAddress(configDir, address, fetchImpl);
5159
5307
  } catch {}
5160
5308
  }
5161
- async function disconnectSessionAgent(configDir, record, deps, env, fetchImpl, enrollmentSettled = false) {
5309
+ async function disconnectSessionAgent(configDir, record, deps, env, fetchImpl, enrollmentSettled = false, report = {}) {
5162
5310
  try {
5163
5311
  const current = loadConnectedAgentProfile(configDir, record.profile);
5164
5312
  if (current) {
@@ -5168,7 +5316,7 @@ async function disconnectSessionAgent(configDir, record, deps, env, fetchImpl, e
5168
5316
  };
5169
5317
  if (!holdsRegisteredCredential(configDir, record, current)) return await release(current);
5170
5318
  try {
5171
- await deps.disconnect({
5319
+ const disconnected = await deps.disconnect({
5172
5320
  configDir,
5173
5321
  profileName: record.profile,
5174
5322
  fetch: fetchImpl,
@@ -5178,6 +5326,7 @@ async function disconnectSessionAgent(configDir, record, deps, env, fetchImpl, e
5178
5326
  invitationHash: current.invitation_hash
5179
5327
  }
5180
5328
  });
5329
+ if (disconnected?.resumeNote) report.resumeNote = disconnected.resumeNote;
5181
5330
  } catch (error) {
5182
5331
  if (!(error instanceof AgentCredentialChangedError)) throw error;
5183
5332
  const holder = loadConnectedAgentProfile(configDir, record.profile);
@@ -5197,8 +5346,8 @@ async function disconnectSessionAgent(configDir, record, deps, env, fetchImpl, e
5197
5346
  return "pending";
5198
5347
  }
5199
5348
  }
5200
- async function finishSessionDisconnect(configDir, record, deps, env, fetchImpl, enrollmentSettled = false) {
5201
- const outcome = await disconnectSessionAgent(configDir, record, deps, env, fetchImpl, enrollmentSettled);
5349
+ async function finishSessionDisconnect(configDir, record, deps, env, fetchImpl, enrollmentSettled = false, report = {}) {
5350
+ const outcome = await disconnectSessionAgent(configDir, record, deps, env, fetchImpl, enrollmentSettled, report);
5202
5351
  const saved = await updateSessionRecord(configDir, record.session, (current) => {
5203
5352
  if (current?.createdBy !== "session-register" || current.disconnect === "done") return current;
5204
5353
  if (outcome === "released") return releasedRecord(current, "credential_changed", deps.now());
@@ -5221,8 +5370,15 @@ function withDefined(defaults, overrides) {
5221
5370
  ...Object.fromEntries(Object.entries(overrides ?? {}).filter(([, value]) => value !== void 0))
5222
5371
  };
5223
5372
  }
5373
+ /**
5374
+ * What to do about an agent that was disconnected in Primitive. The owner's
5375
+ * Reconnect re-invites the same address, and pasting that instruction into
5376
+ * the session claims it into the session's profile again.
5377
+ */
5378
+ const RECONNECT_HINT = "To get the same address back, the owner can use Reconnect on this agent in the Primitive app and paste the instruction it gives into this session.";
5224
5379
  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.";
5225
- const ENDED_DETAIL = "This session ended, so its agent was disconnected. A new address is never created for the same session.";
5380
+ const ENDED_DETAIL = `This session ended and its agent was disconnected. A new address is never created for the same session. ${RECONNECT_HINT}`;
5381
+ const ENDED_KEPT_DETAIL = "This session ended while it was registering. Its agent is kept, and resuming the session reconnects it at the same address.";
5226
5382
  const ENDED_DEFERRED_DETAIL = "This session ended moments ago. Its agent is disconnected after a short wait unless the session starts again first.";
5227
5383
  const RELEASED_DETAIL = "This session's profile now holds an agent connected some other way, so it was left connected.";
5228
5384
  const ENDED_PENDING_DETAIL = "This session ended, but disconnecting its agent has not been confirmed yet; `primitive machine doctor --fix` retries it.";
@@ -5239,6 +5395,13 @@ async function registerSession(options) {
5239
5395
  installClaudeHook: installClaudeWakeHook,
5240
5396
  seedAgentInfo: (configDir, profileName, value) => seedAgentInfoNote(configDir, profileName, value, options.fetch),
5241
5397
  connectionState: (profile) => agentConnectionState(profile, options.fetch),
5398
+ publishResumeNote: (configDir, profileName, runtime, sessionId) => publishResumeNote({
5399
+ configDir,
5400
+ profileName,
5401
+ runtime,
5402
+ sessionId,
5403
+ fetch: options.fetch
5404
+ }),
5242
5405
  ompSession: ompProcessSession,
5243
5406
  ...DISCONNECT_DEFAULTS
5244
5407
  }, options.dependencies);
@@ -5313,6 +5476,12 @@ async function registerSession(options) {
5313
5476
  status: "ended",
5314
5477
  detail: ENDED_DEFERRED_DETAIL
5315
5478
  };
5479
+ if (current.disconnect === "kept") return {
5480
+ ...known,
5481
+ address: current.address,
5482
+ status: "ended",
5483
+ detail: ENDED_KEPT_DETAIL
5484
+ };
5316
5485
  const outcome = current.disconnect === "done" && !enrolledNow ? "done" : await finishSessionDisconnect(options.configDir, current, deps, env, options.fetch, enrolledNow);
5317
5486
  if (outcome === "released") return {
5318
5487
  ...known,
@@ -5340,15 +5509,17 @@ async function registerSession(options) {
5340
5509
  endedAt: null,
5341
5510
  disconnect: null,
5342
5511
  deferredUntil: null,
5512
+ revokeOnEnd: false,
5343
5513
  endReason: null,
5344
5514
  release: null
5345
5515
  });
5346
5516
  try {
5347
- let record = await updateSessionRecord(options.configDir, sessionId, (current) => current?.endedAt && current.disconnect === "deferred" ? {
5517
+ let record = await updateSessionRecord(options.configDir, sessionId, (current) => current?.endedAt && (current.disconnect === "deferred" || current.disconnect === "kept") ? {
5348
5518
  ...current,
5349
5519
  endedAt: null,
5350
5520
  disconnect: null,
5351
5521
  deferredUntil: null,
5522
+ revokeOnEnd: false,
5352
5523
  endReason: null
5353
5524
  } : current);
5354
5525
  if (record?.endedAt) return await ended(record);
@@ -5361,6 +5532,14 @@ async function registerSession(options) {
5361
5532
  agentInfo: seeded
5362
5533
  } : current);
5363
5534
  };
5535
+ const finishResumeNote = async () => {
5536
+ if (runtime === "omp") return {};
5537
+ try {
5538
+ return { resumeNote: await deps.publishResumeNote(options.configDir, profileName, runtime, sessionId) };
5539
+ } catch {
5540
+ return { resumeNote: "failed" };
5541
+ }
5542
+ };
5364
5543
  const claudeHook = (agentAddress) => {
5365
5544
  const outcome = deps.installClaudeHook({
5366
5545
  cliPath: options.cliPath,
@@ -5387,7 +5566,7 @@ async function registerSession(options) {
5387
5566
  if (state === "revoked" || state === "rejected") return {
5388
5567
  ...withAddress,
5389
5568
  status: "revoked",
5390
- detail: "This session's agent was disconnected in Primitive. No new address was created. `primitive machine doctor --fix` removes the stale local profile."
5569
+ detail: `This session's agent was disconnected in Primitive, so it cannot receive mail. No new address was created. ${RECONNECT_HINT}`
5391
5570
  };
5392
5571
  if (state === "unavailable") return {
5393
5572
  ...withAddress,
@@ -5403,10 +5582,12 @@ async function registerSession(options) {
5403
5582
  if (state === "connected") {
5404
5583
  const hooked = installHook(existing);
5405
5584
  await finishAgentInfo();
5585
+ const resumeNote = await finishResumeNote();
5406
5586
  return {
5407
5587
  ...withAddress,
5408
5588
  receiving: RECEIVING[runtime],
5409
5589
  status: "already_registered",
5590
+ ...resumeNote,
5410
5591
  detail: hooked === "installed" ? "This session is already connected." : hooked === "held" ? HELD_DETAIL : "This session is already connected, but its receive hook could not be installed. Run `primitive machine doctor`."
5411
5592
  };
5412
5593
  }
@@ -5414,7 +5595,7 @@ async function registerSession(options) {
5414
5595
  ...known,
5415
5596
  address: record.address,
5416
5597
  status: "removed",
5417
- detail: "This session's local profile was removed. No new address was created for the same session."
5598
+ detail: `This session's local profile was removed. No new address was created for the same session. ${RECONNECT_HINT}`
5418
5599
  };
5419
5600
  let credentials;
5420
5601
  try {
@@ -5474,7 +5655,11 @@ async function registerSession(options) {
5474
5655
  const status = result.connection.status;
5475
5656
  let hooked = "installed";
5476
5657
  if (runtime === "claude" && submitted && status !== "owner_inactive") hooked = claudeHook(address);
5477
- if (status === "connected") await finishAgentInfo();
5658
+ let resumeNote = {};
5659
+ if (status === "connected") {
5660
+ await finishAgentInfo();
5661
+ resumeNote = await finishResumeNote();
5662
+ }
5478
5663
  const withAddress = {
5479
5664
  ...known,
5480
5665
  address,
@@ -5486,6 +5671,7 @@ async function registerSession(options) {
5486
5671
  status: "registered",
5487
5672
  name,
5488
5673
  nameIsDefault: name === record.name,
5674
+ ...resumeNote,
5489
5675
  detail: hooked === "installed" ? `Connected as ${address}.` : hooked === "held" ? `Connected as ${address}. ${HELD_DETAIL}` : `Connected as ${address}, but the receive hook could not be installed. Run \`primitive machine doctor\`.`
5490
5676
  };
5491
5677
  if (status === "revoked" || status === "owner_inactive") return {
@@ -5512,24 +5698,62 @@ async function registerSession(options) {
5512
5698
  }
5513
5699
  }
5514
5700
  /**
5515
- * How long an end from a runtime hook waits before disconnecting. A runtime
5701
+ * How long an end from a runtime hook waits before it takes effect. A runtime
5516
5702
  * reports the same end for a restart, a switch to another session and a real
5517
5703
  * 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.
5704
+ * within this window cancels the end.
5519
5705
  */
5520
5706
  const HOOK_END_GRACE_MS = 3e5;
5707
+ /**
5708
+ * How long an ended session keeps the agent `session-register` created for
5709
+ * it. A session that has not started again for this long has its agent
5710
+ * disconnected by `machine doctor --fix` or the next registration on this
5711
+ * machine, so closed sessions do not keep live credentials forever.
5712
+ */
5713
+ const STALE_SESSION_MS = 2592e6;
5521
5714
  /** The end of this record is a hook's, still waiting. */
5522
5715
  function deferredEnd(record) {
5523
5716
  return Boolean(record?.endedAt) && record?.disconnect === "deferred";
5524
5717
  }
5718
+ /** Removes an ended session's own exact-session receive hooks. Best effort. */
5719
+ function removeSessionHooks(configDir, record, deps, env) {
5720
+ if (record.runtime !== "claude" || !record.address) return;
5721
+ try {
5722
+ deps.uninstallHook({
5723
+ configDir,
5724
+ profileName: record.profile,
5725
+ agentAddress: record.address,
5726
+ sessionId: record.session,
5727
+ env
5728
+ });
5729
+ } catch {}
5730
+ }
5525
5731
  /**
5526
- * Disconnect the agent a session got from `session-register`. Agents
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.
5732
+ * The record of an end that keeps the agent. Its exact-session receive hooks
5733
+ * are removed in the same step, under the record's lock, so a start of the
5734
+ * same session that clears the end afterwards reinstalls them.
5735
+ */
5736
+ function keptEnd(configDir, current, deps, env, at) {
5737
+ removeSessionHooks(configDir, current, deps, env);
5738
+ return {
5739
+ ...current,
5740
+ endedAt: current.endedAt ?? at.toISOString(),
5741
+ disconnect: "kept",
5742
+ deferredUntil: null,
5743
+ revokeOnEnd: false
5744
+ };
5745
+ }
5746
+ const KEPT_DETAIL = "This session is marked ended. Its agent stays connected, so resuming the session keeps the same address; it is disconnected only if the session does not start again for 30 days. Pass --revoke to disconnect it now.";
5747
+ /**
5748
+ * Record that a session ended. By default the agent `session-register`
5749
+ * created for it stays connected, with its credential and profile, so a
5750
+ * resume of the same session keeps its address; only its exact-session
5751
+ * receive hooks are removed. With `revoke` the agent is disconnected instead.
5752
+ * Agents connected any other way are left alone either way, including a
5753
+ * credential connected later into the same profile. The end is recorded
5754
+ * first, under the record's lock, so a registration still enrolling sees it.
5755
+ * With `graceMs` (an end reported by a runtime hook) the end waits that
5756
+ * long, and a start of the same session meanwhile cancels it. Never throws.
5533
5757
  */
5534
5758
  async function endSession(options) {
5535
5759
  const env = options.env ?? process.env;
@@ -5561,6 +5785,7 @@ async function endSession(options) {
5561
5785
  };
5562
5786
  const sessionId = session.toLowerCase();
5563
5787
  const graceMs = Math.max(0, options.graceMs ?? 0);
5788
+ const revoke = options.revoke === true;
5564
5789
  const reason = options.reason && /^[a-z_]{1,40}$/.test(options.reason) ? options.reason : null;
5565
5790
  try {
5566
5791
  let managed = false;
@@ -5574,21 +5799,29 @@ async function endSession(options) {
5574
5799
  return current;
5575
5800
  }
5576
5801
  const now = deps.now();
5577
- if (graceMs > 0 && (!current.endedAt || deferredEnd(current))) {
5802
+ const revoking = revoke || Boolean(current.endedAt) && (current.disconnect === "pending" || current.disconnect === "pending_enrollment" || current.disconnect === "deferred" && current.revokeOnEnd);
5803
+ if (!revoking && current.endedAt && current.disconnect === "kept") return current;
5804
+ if (graceMs > 0 && (!current.endedAt || deferredEnd(current) || current.disconnect === "kept")) {
5578
5805
  deferred = true;
5579
5806
  return {
5580
5807
  ...current,
5581
5808
  endedAt: now.toISOString(),
5582
5809
  disconnect: "deferred",
5583
5810
  deferredUntil: new Date(now.getTime() + graceMs).toISOString(),
5811
+ revokeOnEnd: revoking,
5584
5812
  endReason: reason
5585
5813
  };
5586
5814
  }
5815
+ if (!revoking) return {
5816
+ ...keptEnd(options.configDir, current, deps, env, now),
5817
+ endReason: current.endReason ?? reason
5818
+ };
5587
5819
  return {
5588
5820
  ...current,
5589
5821
  endedAt: current.endedAt ?? now.toISOString(),
5590
5822
  disconnect: current.disconnect === "pending_enrollment" ? "pending_enrollment" : "pending",
5591
5823
  deferredUntil: null,
5824
+ revokeOnEnd: true,
5592
5825
  endReason: current.endReason ?? reason
5593
5826
  };
5594
5827
  });
@@ -5616,19 +5849,25 @@ async function endSession(options) {
5616
5849
  resumed = true;
5617
5850
  return current;
5618
5851
  }
5619
- return {
5852
+ return current.revokeOnEnd ? {
5620
5853
  ...current,
5621
5854
  disconnect: "pending",
5622
5855
  deferredUntil: null
5623
- };
5856
+ } : keptEnd(options.configDir, current, deps, env, deps.now());
5624
5857
  });
5625
5858
  if (resumed || !record) return {
5626
5859
  ...known,
5627
5860
  status: "resumed",
5628
- detail: "The session started again (or was ended again) before its end took effect, so this end left its agent connected."
5861
+ detail: "The session started again (or was ended again) before its end took effect, so this end changed nothing."
5629
5862
  };
5630
5863
  }
5631
- const outcome = await finishSessionDisconnect(options.configDir, record, deps, env, options.fetch);
5864
+ if (record.disconnect === "kept") return {
5865
+ ...known,
5866
+ status: "kept",
5867
+ detail: KEPT_DETAIL
5868
+ };
5869
+ const report = {};
5870
+ const outcome = await finishSessionDisconnect(options.configDir, record, deps, env, options.fetch, false, report);
5632
5871
  if (outcome === "released") return {
5633
5872
  ...known,
5634
5873
  status: "left_connected",
@@ -5637,6 +5876,7 @@ async function endSession(options) {
5637
5876
  return outcome === "done" ? {
5638
5877
  ...known,
5639
5878
  status: "disconnected",
5879
+ ...report.resumeNote ? { resumeNote: report.resumeNote } : {},
5640
5880
  detail: "This session's agent was disconnected."
5641
5881
  } : {
5642
5882
  ...known,
@@ -5648,17 +5888,99 @@ async function endSession(options) {
5648
5888
  ...base,
5649
5889
  session: sessionId,
5650
5890
  status: "failed",
5651
- detail: error instanceof AgentDisconnectError ? error.message : "Disconnect did not finish. The agent was left as it was."
5891
+ detail: error instanceof AgentDisconnectError ? error.message : "The end did not finish. The agent was left as it was."
5652
5892
  };
5653
5893
  }
5654
5894
  }
5655
5895
  /**
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).
5896
+ * Ended sessions whose agent disconnect was asked for and still needs
5897
+ * confirming, including a `--revoke` end from a hook whose grace period is
5898
+ * over but whose own process never finished it (for example because the
5899
+ * machine shut down). Ends that keep their agent are never listed here.
5659
5900
  */
5660
5901
  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())));
5902
+ return listSessionRecords(configDir).filter((record) => record.createdBy === "session-register" && record.endedAt && (record.disconnect === "pending" || record.disconnect === "pending_enrollment" || record.disconnect === "deferred" && record.revokeOnEnd && !(Date.parse(record.deferredUntil ?? "") > now.getTime())));
5903
+ }
5904
+ /**
5905
+ * Ended sessions that kept their agent and have not started again for
5906
+ * STALE_SESSION_MS, whose profile still holds exactly the credential
5907
+ * `session-register` created. Only these are disconnected for being stale.
5908
+ */
5909
+ function staleSessionAgents(configDir, now = /* @__PURE__ */ new Date()) {
5910
+ return listSessionRecords(configDir).filter((record) => {
5911
+ if (record.createdBy !== "session-register" || !record.endedAt || !(record.disconnect === "kept" || record.disconnect === "deferred" && !record.revokeOnEnd) || !(now.getTime() - Date.parse(record.endedAt) >= 2592e6)) return false;
5912
+ try {
5913
+ const current = loadConnectedAgentProfile(configDir, record.profile);
5914
+ return Boolean(current && holdsRegisteredCredential(configDir, record, current));
5915
+ } catch {
5916
+ return false;
5917
+ }
5918
+ });
5919
+ }
5920
+ /**
5921
+ * Disconnect the agent of every stale ended session (see
5922
+ * staleSessionAgents). Each disconnect names the exact credential it
5923
+ * checked, under the locks `agent connect` also takes, so a credential
5924
+ * connected into the profile meanwhile is never revoked, and nothing is
5925
+ * ever revoked by address here. A start of the session meanwhile turns it
5926
+ * into an ordinary pending disconnect, which that start finishes. Returns
5927
+ * how many agents were disconnected.
5928
+ */
5929
+ async function revokeStaleSessionAgents(configDir, options = {}) {
5930
+ const deps = withDefined(DISCONNECT_DEFAULTS, options.dependencies);
5931
+ const env = options.env ?? process.env;
5932
+ let revoked = 0;
5933
+ for (const listed of staleSessionAgents(configDir, deps.now())) {
5934
+ if (listed.session === options.exclude?.toLowerCase()) continue;
5935
+ try {
5936
+ const current = loadConnectedAgentProfile(configDir, listed.profile);
5937
+ if (!current || !holdsRegisteredCredential(configDir, listed, current)) continue;
5938
+ let claimed = false;
5939
+ await updateSessionRecord(configDir, listed.session, (row) => {
5940
+ if (row?.createdBy !== "session-register" || row.endedAt !== listed.endedAt || row.disconnect !== listed.disconnect) return row;
5941
+ claimed = true;
5942
+ return {
5943
+ ...row,
5944
+ disconnect: "pending",
5945
+ deferredUntil: null,
5946
+ revokeOnEnd: true
5947
+ };
5948
+ });
5949
+ if (!claimed) continue;
5950
+ let outcome;
5951
+ try {
5952
+ await deps.disconnect({
5953
+ configDir,
5954
+ profileName: listed.profile,
5955
+ fetch: options.fetch,
5956
+ env,
5957
+ expected: {
5958
+ address: current.agent_address,
5959
+ invitationHash: current.invitation_hash
5960
+ }
5961
+ });
5962
+ outcome = "done";
5963
+ } catch (error) {
5964
+ outcome = error instanceof AgentCredentialChangedError ? "released" : "failed";
5965
+ }
5966
+ await updateSessionRecord(configDir, listed.session, (row) => {
5967
+ if (row?.createdBy !== "session-register" || row.disconnect !== "pending") return row;
5968
+ if (outcome === "done") return {
5969
+ ...row,
5970
+ disconnect: "done"
5971
+ };
5972
+ if (outcome === "released") return releasedRecord(row, "credential_changed", deps.now());
5973
+ return {
5974
+ ...row,
5975
+ disconnect: listed.disconnect,
5976
+ deferredUntil: listed.deferredUntil,
5977
+ revokeOnEnd: false
5978
+ };
5979
+ });
5980
+ if (outcome === "done") revoked++;
5981
+ } catch {}
5982
+ }
5983
+ return revoked;
5662
5984
  }
5663
5985
  /**
5664
5986
  * Retry every pending disconnect. Each goes through the same credential check
@@ -5748,4 +6070,4 @@ async function runDetached(params) {
5748
6070
  }
5749
6071
  }
5750
6072
  //#endregion
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 };
6073
+ export { installClaudeWakeHook as $, evaluateContactPolicy as A, contactAddress as B, RECEIVER_MODES as C, withOwnerMemberAddress as Ct, NETWORK_ADMISSION_PENDING_RETRY_MS as D, ContactPolicyReadRetryError as E, disconnectAgent as F, connectAgent as G, runContactRequest as H, AddressNotesApiError as I, refusalAfterReplacement as J, readAgentInvitation as K, addressNoteTarget as L, AgentDisconnectError as M, claimOverRevokedSetup as N, NetworkAdmissionPendingError as O, clearRevokedAddresses as P, releaseRegistrationsForProfile as Q, runAddressNotesRequest as R, runAgentConnect as S, refreshOwnerMemberAddressPeriodically as St, verificationReplySubmitted as T, agentConnectionStatus as U, contactAgentAddress as V, agentInvitationHash as W, savedReceiver as X, saveConnectionName as Y, MACHINE_RUNTIMES as Z, enrollmentResumes as _, jsonIndentation as _t, endSession as a, describeSessionHookItem as at, invitationProfileName as b, writeManagedFile as bt, pendingSessionDisconnects as c, findOnPath as ct, registerSession as d, readClaudeSettings as dt, recordHookMailCheck as et, retryPendingDisconnects as f, repairClaudeHooks as ft, enrollAgent as g, inspectManagedBlock as gt, staleSessionAgents as h, MachineFileError as ht, connectedProfilesForSession as i, describeSessionHookChange as it, canonicalContactSelector as j, createNotificationContactPolicy as k, psProcessInfo as l, hasMachineSessionHooks as lt, runDetached as m, writeClaudeSettings as mt, OMP_MAIN as n, claudeConfigDir as nt, headlessClaudeRun as o, ensureHookLauncher as ot, revokeStaleSessionAgents as p, samePath as pt, receiverStatusCommand as q, agentConnectionState as r, currentCliLocation as rt, machine_session_exports as s, ephemeralCliReason as st, HOOK_END_GRACE_MS as t, boundSessionProfiles as tt, readClaudeHookInput as u, inspectClaudeHooks as ut, connectWarnings as v, readManagedFile as vt, externalReceivingState as w, pollCheckCommand as x, readHookLauncherWarning as xt, defaultAgentProfileName as y, upsertManagedBlock as yt, ContactsApiError as z };