primitive 1.72.2 → 1.73.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,11 +1,11 @@
1
- import { S as __exportAll } from "./repeats-BL7ZC-_6-CKMKBlx7.js";
2
- import { B as parseConnectedAgentProfile, F as agentProfileName, G as PrimitiveApiClient, H as profileAlreadyConnectedMessage, I as agentProfilesDirectory, L as connectedAgentIdentity, M as AGENT_PROFILE_ENV, N as AgentConnectionSetupError, P as agentProfileDirectory, R as connectedApiBaseUrl, V as parseOwnerMemberAddress, W as saveConnectedAgentProfile, x as loadCliCredentials, z as loadConnectedAgentProfile } from "./cli-config--YTRdJ3t.js";
3
- import { $t as refreshStoredCliCredentials, Bn as listAgentContacts, Dt as defaultConnectionName, Gn as listDomains, Hn as listContacts, Tn as getContact, Tt as cliInvocation, cr as putContact, er as listSentEmails, fn as deleteAgentContact, hr as searchEmails, kn as getEmail, or as putAgentContact, pn as deleteContact, sr as putAgentContactPolicy, vr as sendEmail, wn as getAgentContactPolicy } from "./api-command-BrwPbyPP.js";
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-Byt5sPWW.js";
6
- import { a as scopedChatSenderTrust } from "./scoped-chat-DTFkP360.js";
7
- import { l as stopBackgroundListen, r as backgroundListenStatus } from "./listen-background--0peztKE.js";
8
- import { t as readSharedMailOwner } from "./shared-mail-watch-DtKpsgg8.js";
1
+ import { $t as listDomains, At as getAgentContactPolicy, Dn as sendEmail, Ft as getEmail, Jt as listAgentContacts, Kn as __exportAll, Kt as inviteAgentConnection, Xt as listContacts, cn as listSentEmails, gn as putContact, hn as putAgentContactPolicy, jt as getContact, mn as putAgentContact, qt as listAgentConnections, vt as deleteAgentContact, wn as searchEmails, yt as deleteContact } from "./repeats-BL7ZC-_6-B_ESsWTH.js";
2
+ import { B as parseConnectedAgentProfile, F as agentProfileName, G as PrimitiveApiClient, H as profileAlreadyConnectedMessage, I as agentProfilesDirectory, L as connectedAgentIdentity, M as AGENT_PROFILE_ENV, N as AgentConnectionSetupError, P as agentProfileDirectory, R as connectedApiBaseUrl, V as parseOwnerMemberAddress, W as saveConnectedAgentProfile, x as loadCliCredentials, z as loadConnectedAgentProfile } from "./cli-config-C0Y_Ppol.js";
3
+ import { _ as acquireListenLock, b as listenLockHeld, c as mailTime, d as readMailJson, f as removeMailFile, g as ListenStateError, h as writeMailJson, i as mailId, l as privateMailDirectory, o as mailObject, p as syncMailDirectory, r as mailAddress, s as mailString } from "./shared-mail-files-CZfm7E2k.js";
4
+ import { gt as defaultConnectionName, jt as refreshStoredCliCredentials, mt as cliInvocation } from "./api-command-fKP3NYa7.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-DJAb9SGm.js";
6
+ import { o as scopedChatSenderTrust } from "./scoped-chat-DAiOb622.js";
7
+ import { l as stopBackgroundListen, r as backgroundListenStatus } from "./listen-background-cD6n8VYO.js";
8
+ import { t as readSharedMailOwner } from "./shared-mail-watch-DyQB0MK0.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";
10
10
  import { createHash, randomUUID } from "node:crypto";
11
11
  import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path";
@@ -146,6 +146,146 @@ async function withOwnerMemberAddress(result, params) {
146
146
  };
147
147
  }
148
148
  //#endregion
149
+ //#region src/oclif/claude-session-receivers.ts
150
+ const FILE = /^([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})\.json$/;
151
+ const LOCK_WAIT$2 = new Int32Array(new SharedArrayBuffer(4));
152
+ function sessionReceiversDirectory(configDir) {
153
+ return join(agentProfilesDirectory(resolve(configDir)), "claude-sessions");
154
+ }
155
+ function sessionId(value) {
156
+ const id = value.toLowerCase();
157
+ if (!SESSION_UUID.test(id)) throw new Error("A session must be a UUID.");
158
+ return id;
159
+ }
160
+ function sessionReceiversPath(configDir, session) {
161
+ return join(sessionReceiversDirectory(configDir), `${sessionId(session)}.json`);
162
+ }
163
+ function receiver(value) {
164
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
165
+ const row = value;
166
+ try {
167
+ return {
168
+ profile: agentProfileName(String(row.profile ?? "")),
169
+ address: mailAddress(String(row.address ?? ""))
170
+ };
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+ /** The profiles one session receives as; empty when it has none. */
176
+ function readSessionReceivers(configDir, session) {
177
+ let value;
178
+ try {
179
+ value = readMailJson(sessionReceiversPath(configDir, session));
180
+ } catch {
181
+ return [];
182
+ }
183
+ const row = value && typeof value === "object" && !Array.isArray(value) ? value : null;
184
+ if (row?.version !== 1 || row.session_id !== sessionId(session) || !Array.isArray(row.receivers)) return [];
185
+ return row.receivers.flatMap((item) => {
186
+ const parsed = receiver(item);
187
+ return parsed ? [parsed] : [];
188
+ });
189
+ }
190
+ /** Every session with an entry, and the profiles it receives as. */
191
+ function listSessionReceivers(configDir) {
192
+ const sessions = /* @__PURE__ */ new Map();
193
+ let names;
194
+ try {
195
+ names = readdirSync(sessionReceiversDirectory(configDir));
196
+ } catch {
197
+ return sessions;
198
+ }
199
+ for (const name of names.sort()) {
200
+ const session = FILE.exec(name)?.[1];
201
+ if (!session) continue;
202
+ const receivers = readSessionReceivers(configDir, session);
203
+ if (receivers.length) sessions.set(session, receivers);
204
+ }
205
+ return sessions;
206
+ }
207
+ function withLock(configDir, action) {
208
+ const directory = sessionReceiversDirectory(configDir);
209
+ let release;
210
+ for (let attempt = 0; attempt < 80 && !release; attempt++) try {
211
+ release = acquireListenLock(directory, "claude-session-receivers");
212
+ } catch {
213
+ Atomics.wait(LOCK_WAIT$2, 0, 0, 25);
214
+ }
215
+ if (!release) throw new Error("Another Primitive command is editing session receivers.");
216
+ try {
217
+ return action();
218
+ } finally {
219
+ release();
220
+ }
221
+ }
222
+ function write(configDir, session, receivers) {
223
+ const path = sessionReceiversPath(configDir, session);
224
+ if (receivers.length === 0) {
225
+ try {
226
+ unlinkSync(path);
227
+ syncMailDirectory(sessionReceiversDirectory(configDir));
228
+ } catch (error) {
229
+ if (error.code !== "ENOENT") throw error;
230
+ }
231
+ return;
232
+ }
233
+ writeMailJson(path, {
234
+ version: 1,
235
+ session_id: sessionId(session),
236
+ receivers
237
+ });
238
+ }
239
+ /**
240
+ * Add or update one profile for a session (its address follows the
241
+ * profile). Returns whether the entry changed.
242
+ */
243
+ function addSessionReceiver(configDir, session, value) {
244
+ const next = receiver(value);
245
+ if (!next) throw new Error("A session receiver needs a profile and address.");
246
+ return withLock(configDir, () => {
247
+ const current = readSessionReceivers(configDir, session);
248
+ if (current.find((row) => row.profile === next.profile)?.address === next.address) return false;
249
+ write(configDir, session, [...current.filter((row) => row.profile !== next.profile), next]);
250
+ return true;
251
+ });
252
+ }
253
+ /** Remove one profile from a session. Returns whether it was there. */
254
+ function removeSessionReceiver(configDir, session, profile) {
255
+ return withLock(configDir, () => {
256
+ const current = readSessionReceivers(configDir, session);
257
+ const next = current.filter((row) => row.profile !== profile);
258
+ if (next.length === current.length) return false;
259
+ write(configDir, session, next);
260
+ return true;
261
+ });
262
+ }
263
+ /**
264
+ * Drop entries for profiles that are no longer saved as connected, or whose
265
+ * saved address changed to another. Returns how many were dropped.
266
+ */
267
+ function pruneSessionReceivers(configDir) {
268
+ const live = (row) => {
269
+ try {
270
+ const profile = loadConnectedAgentProfile(resolve(configDir), row.profile);
271
+ return profile !== null && profile.agent_address === row.address;
272
+ } catch {
273
+ return true;
274
+ }
275
+ };
276
+ let dropped = 0;
277
+ for (const [session, receivers] of listSessionReceivers(configDir)) {
278
+ if (receivers.every(live)) continue;
279
+ withLock(configDir, () => {
280
+ const current = readSessionReceivers(configDir, session);
281
+ const next = current.filter(live);
282
+ dropped += current.length - next.length;
283
+ if (next.length !== current.length) write(configDir, session, next);
284
+ });
285
+ }
286
+ return dropped;
287
+ }
288
+ //#endregion
149
289
  //#region src/oclif/hook-launcher.ts
150
290
  /**
151
291
  * Claude Code hooks run Primitive through a small POSIX launcher instead of a
@@ -730,7 +870,8 @@ const STATE_PHRASES = {
730
870
  stale: "session disconnected or removed",
731
871
  duplicate: "duplicate",
732
872
  outdated: "old CLI path",
733
- old_address: "old agent address"
873
+ old_address: "old agent address",
874
+ per_session: "per-session hook, now served by the shared receive hooks"
734
875
  };
735
876
  /** "profile my-agent, session 1234...": who a session hook serves. */
736
877
  function describeSessionHookOwner(item) {
@@ -799,7 +940,15 @@ function globalHookCurrent(hook, cli) {
799
940
  return nodeCommandCurrent(hook.command, cli.node) && samePath(args[0], cli.entry);
800
941
  }
801
942
  const WAKE_MARKER = "primitive-agent-wake-v1";
802
- const PENDING_MARKER$1 = "primitive-pending-mail-v1";
943
+ const PENDING_MARKER = "primitive-pending-mail-v1";
944
+ /**
945
+ * The shared receive hooks: one set per config directory serves every Claude
946
+ * session. Each run reads its session id from the hook input and looks up
947
+ * the profiles it receives as (claude-session-receivers.ts), exiting at once
948
+ * for a session with none.
949
+ */
950
+ const SHARED_WAKE_MARKER = "primitive-agent-wake-v2";
951
+ const SHARED_PENDING_MARKER = "primitive-pending-mail-v2";
803
952
  const SESSION_EVENTS = [
804
953
  "Stop",
805
954
  "SessionStart",
@@ -818,6 +967,7 @@ const SESSION_HOOK_KIND = {
818
967
  * both use it, so they never disagree about a hook being present.
819
968
  */
820
969
  function hasSessionReceiveHook(hooks, event, target, node) {
970
+ if (hasSharedHook(hooks, event, target.configDir, node) && readSessionReceivers(target.configDir, target.session).some((row) => row.profile === target.profile && row.address === target.address)) return true;
821
971
  const entries = hooks[event];
822
972
  if (!Array.isArray(entries)) return false;
823
973
  const pending = SESSION_HOOK_KIND[event] === "pending";
@@ -826,9 +976,64 @@ function hasSessionReceiveHook(hooks, event, target, node) {
826
976
  return entries.some((entry) => record$2(entry) && Array.isArray(entry.hooks) && entry.hooks.some((hook) => {
827
977
  if (!record$2(hook) || hook.type !== "command") return false;
828
978
  const args = hook.args;
829
- return Array.isArray(args) && args.length === 7 && args.every((value) => typeof value === "string") && basename(args[0]) === (pending ? "claude-pending-mail.mjs" : "claude-wake.mjs") && resolve(args[2]) === configDir && args[3] === target.profile && args[4] === target.address && args[5].toLowerCase() === session && args[6] === (pending ? PENDING_MARKER$1 : WAKE_MARKER) && (node === null || samePath(hook.command, node));
979
+ return Array.isArray(args) && args.length === 7 && args.every((value) => typeof value === "string") && basename(args[0]) === (pending ? "claude-pending-mail.mjs" : "claude-wake.mjs") && resolve(args[2]) === configDir && args[3] === target.profile && args[4] === target.address && args[5].toLowerCase() === session && args[6] === (pending ? PENDING_MARKER : WAKE_MARKER) && (node === null || samePath(hook.command, node));
980
+ }));
981
+ }
982
+ /** Whether an event carries the shared receive hook for a config directory. */
983
+ function hasSharedHook(hooks, event, configDir, node) {
984
+ const entries = hooks[event];
985
+ if (!Array.isArray(entries)) return false;
986
+ const pending = SESSION_HOOK_KIND[event] === "pending";
987
+ return entries.some((entry) => record$2(entry) && (event !== "SessionStart" || entry.matcher === "resume") && Array.isArray(entry.hooks) && entry.hooks.some((hook) => {
988
+ const parsed = parseSessionHook(hook);
989
+ return parsed?.shared === true && parsed.kind === (pending ? "pending" : "wake") && resolve(parsed.configDir) === resolve(configDir) && (node === null || record$2(hook) && samePath(hook.command, node));
830
990
  }));
831
991
  }
992
+ /** The shared hook entry for one event, as installers write it. */
993
+ function sharedHookEntry(event, cli, configDir, node = cli.node) {
994
+ const pending = SESSION_HOOK_KIND[event] === "pending";
995
+ const hook = {
996
+ type: "command",
997
+ command: node,
998
+ args: [
999
+ pending ? cli.pending : cli.wake,
1000
+ cli.entry,
1001
+ resolve(configDir),
1002
+ pending ? SHARED_PENDING_MARKER : SHARED_WAKE_MARKER
1003
+ ],
1004
+ ...pending ? { timeout: 10 } : {
1005
+ asyncRewake: true,
1006
+ timeout: 604800
1007
+ }
1008
+ };
1009
+ return event === "SessionStart" ? {
1010
+ matcher: "resume",
1011
+ hooks: [hook]
1012
+ } : { hooks: [hook] };
1013
+ }
1014
+ /**
1015
+ * The receiver an exact-session hook from an earlier version stands for:
1016
+ * its profile, with the address the profile is connected as now, and its
1017
+ * session. Null when the profile is no longer connected.
1018
+ */
1019
+ function foldedReceiver(parsed) {
1020
+ if (!parsed.profile || !parsed.session || !SESSION_UUID.test(parsed.session)) return null;
1021
+ try {
1022
+ const profile = loadConnectedAgentProfile(resolve(parsed.configDir), parsed.profile);
1023
+ return profile ? {
1024
+ configDir: resolve(parsed.configDir),
1025
+ profile: parsed.profile,
1026
+ address: profile.agent_address,
1027
+ session: parsed.session.toLowerCase()
1028
+ } : null;
1029
+ } catch {
1030
+ return null;
1031
+ }
1032
+ }
1033
+ /** A per-session receive hook (any version), shared or not. Exported for installers. */
1034
+ function parseReceiveHook(hook) {
1035
+ return parseSessionHook(hook);
1036
+ }
832
1037
  /** A per-session receive hook written by `agent connect` or `agent enroll`, any version. */
833
1038
  function parseSessionHook(hook) {
834
1039
  if (!record$2(hook) || hook.type !== "command" || !Array.isArray(hook.args)) return null;
@@ -836,7 +1041,15 @@ function parseSessionHook(hook) {
836
1041
  if (!args.every((value) => typeof value === "string")) return null;
837
1042
  const strings = args;
838
1043
  const script = basename(strings[0] ?? "");
839
- if (script === "claude-pending-mail.mjs") return strings.length === 7 && strings[6] === PENDING_MARKER$1 ? {
1044
+ if (strings.length === 4 && (script === "claude-wake.mjs" && strings[3] === "primitive-agent-wake-v2" || script === "claude-pending-mail.mjs" && strings[3] === "primitive-pending-mail-v2")) return {
1045
+ kind: script === "claude-wake.mjs" ? "wake" : "pending",
1046
+ configDir: strings[2] ?? "",
1047
+ profile: null,
1048
+ address: null,
1049
+ session: null,
1050
+ shared: true
1051
+ };
1052
+ if (script === "claude-pending-mail.mjs") return strings.length === 7 && strings[6] === PENDING_MARKER ? {
840
1053
  kind: "pending",
841
1054
  configDir: strings[2] ?? "",
842
1055
  profile: strings[3] ?? null,
@@ -892,10 +1105,14 @@ function judgeSessionHooks(hooks, cli, bound = []) {
892
1105
  const parsed = parseSessionHook(hook);
893
1106
  if (!parsed || !record$2(hook)) continue;
894
1107
  const pathsExist = existsSync(String(hook.command)) && existsSync(String(hook.args[0])) && existsSync(String(hook.args[1]));
895
- if (!(parsed.profile ? existsSync(parsed.configDir) && profileStillConnected(parsed.configDir, parsed.profile) : existsSync(parsed.configDir) && pathsExist)) {
1108
+ if (!(parsed.shared ? existsSync(parsed.configDir) : parsed.profile ? existsSync(parsed.configDir) && profileStillConnected(parsed.configDir, parsed.profile) : existsSync(parsed.configDir) && pathsExist)) {
896
1109
  verdicts.set(hook, "stale");
897
1110
  continue;
898
1111
  }
1112
+ if (parsed.profile && parsed.session) {
1113
+ verdicts.set(hook, "per_session");
1114
+ continue;
1115
+ }
899
1116
  const current = bound.find((item) => item.configDir === resolve(parsed.configDir) && item.profile === parsed.profile && item.session === parsed.session?.toLowerCase());
900
1117
  if (current && parsed.address !== current.address) {
901
1118
  verdicts.set(hook, "old_address");
@@ -905,7 +1122,7 @@ function judgeSessionHooks(hooks, cli, bound = []) {
905
1122
  event,
906
1123
  parsed.kind,
907
1124
  resolve(parsed.configDir),
908
- parsed.profile ?? "",
1125
+ parsed.shared ? "shared" : parsed.profile ?? "",
909
1126
  parsed.session?.toLowerCase() ?? ""
910
1127
  ].join("\0");
911
1128
  if (seen.has(key)) {
@@ -974,8 +1191,28 @@ function boundKey(item) {
974
1191
  * disconnected or removed profile is stale (machine doctor removes it), so
975
1192
  * the session does not receive through it.
976
1193
  */
977
- function receivingProfilesBySession(hooks, always) {
1194
+ function receivingProfilesBySession(hooks, always, configDirs = []) {
978
1195
  const receiving = /* @__PURE__ */ new Map();
1196
+ const shared = new Set(configDirs.map((dir) => resolve(dir)));
1197
+ for (const event of SESSION_EVENTS) {
1198
+ const entries = hooks[event];
1199
+ if (!Array.isArray(entries)) continue;
1200
+ for (const entry of entries) if (record$2(entry) && Array.isArray(entry.hooks)) for (const hook of entry.hooks) {
1201
+ const parsed = parseSessionHook(hook);
1202
+ if (parsed?.shared) shared.add(resolve(parsed.configDir));
1203
+ }
1204
+ }
1205
+ for (const configDir of shared) for (const [session, receivers] of listSessionReceivers(configDir)) for (const row of receivers) {
1206
+ const key = [
1207
+ configDir,
1208
+ row.profile,
1209
+ session
1210
+ ].join("\0");
1211
+ if (key !== always && !profileStillConnected(configDir, row.profile)) continue;
1212
+ const profiles = receiving.get(session) ?? /* @__PURE__ */ new Set();
1213
+ profiles.add(key);
1214
+ receiving.set(session, profiles);
1215
+ }
979
1216
  for (const event of SESSION_EVENTS) {
980
1217
  const entries = hooks[event];
981
1218
  if (!Array.isArray(entries)) continue;
@@ -1014,7 +1251,7 @@ function otherSessionReceivers(hooks, target) {
1014
1251
  target.profile,
1015
1252
  session
1016
1253
  ].join("\0");
1017
- const present = receivingProfilesBySession(hooks, own).get(session) ?? /* @__PURE__ */ new Set();
1254
+ const present = receivingProfilesBySession(hooks, own, [target.configDir]).get(session) ?? /* @__PURE__ */ new Set();
1018
1255
  if (present.has(own)) return [];
1019
1256
  return [...present].map((other) => other.split("\0")[1] ?? "").sort();
1020
1257
  }
@@ -1073,31 +1310,6 @@ function sessionHookItems(hooks, verdicts, missing) {
1073
1310
  }
1074
1311
  return items;
1075
1312
  }
1076
- /** The exact entry `agent connect` and `session-register` install. */
1077
- function sessionHookEntry(event, item, cli) {
1078
- const pending = SESSION_HOOK_KIND[event] === "pending";
1079
- const hook = {
1080
- type: "command",
1081
- command: cli.node,
1082
- args: [
1083
- pending ? cli.pending : cli.wake,
1084
- cli.entry,
1085
- item.configDir,
1086
- item.profile,
1087
- item.address,
1088
- item.session,
1089
- pending ? PENDING_MARKER$1 : WAKE_MARKER
1090
- ],
1091
- ...pending ? { timeout: 10 } : {
1092
- asyncRewake: true,
1093
- timeout: 604800
1094
- }
1095
- };
1096
- return event === "SessionStart" ? {
1097
- matcher: "resume",
1098
- hooks: [hook]
1099
- } : { hooks: [hook] };
1100
- }
1101
1313
  /** Inspect every Primitive-owned Claude hook without changing anything. */
1102
1314
  function inspectClaudeHooks(settings, context) {
1103
1315
  const hooks = record$2(settings.hooks) ? settings.hooks : {};
@@ -1154,7 +1366,8 @@ function inspectClaudeHooks(settings, context) {
1154
1366
  stale: 0,
1155
1367
  duplicate: 0,
1156
1368
  outdated: 0,
1157
- old_address: 0
1369
+ old_address: 0,
1370
+ per_session: 0
1158
1371
  };
1159
1372
  const verdicts = judgeSessionHooks(hooks, context.cli, bound);
1160
1373
  for (const verdict of verdicts.values()) counts[verdict]++;
@@ -1166,16 +1379,17 @@ function inspectClaudeHooks(settings, context) {
1166
1379
  counts.old_address ? `${counts.old_address} naming an old agent address` : "",
1167
1380
  counts.stale ? `${counts.stale} for disconnected or removed sessions` : "",
1168
1381
  counts.duplicate ? `${counts.duplicate} duplicates` : "",
1169
- counts.outdated ? `${counts.outdated} pointing at an old CLI path` : ""
1382
+ counts.outdated ? `${counts.outdated} pointing at an old CLI path` : "",
1383
+ counts.per_session ? `${counts.per_session} per-session hooks to fold into the shared receive hooks` : ""
1170
1384
  ].filter(Boolean);
1171
1385
  findings["claude.hook.stop"] = problems.length ? {
1172
1386
  status: "fail",
1173
- detail: `Per-session receive hooks need cleanup: ${problems.join(", ")}. Affected: ${summarizeOwners(items)}.${blockedNote}${heldNote}`,
1174
- fixable: canFix || counts.outdated === 0 && missing === 0 && context.blocked === null,
1387
+ detail: `Receive hooks need cleanup: ${problems.join(", ")}. Affected: ${summarizeOwners(items)}.${blockedNote}${heldNote}`,
1388
+ fixable: canFix || counts.outdated === 0 && counts.per_session === 0 && missing === 0 && context.blocked === null,
1175
1389
  items
1176
1390
  } : {
1177
1391
  status: "ok",
1178
- detail: `${counts.keep ? `${counts.keep} per-session receive hooks, each present once and current.` : "No per-session receive hooks; each registered session adds its own exact-session hooks."}${heldNote}`,
1392
+ detail: `${counts.keep ? `${counts.keep} receive hooks, each present once and current; the shared hooks serve every registered session.` : "No receive hooks; registering a session adds the shared receive hooks."}${heldNote}`,
1179
1393
  fixable: false
1180
1394
  };
1181
1395
  return findings;
@@ -1210,6 +1424,7 @@ function rewriteEntries(entries, transform) {
1210
1424
  function repairClaudeHooks(settings, context, checks, options = {}) {
1211
1425
  const changed = /* @__PURE__ */ new Set();
1212
1426
  const changes = [];
1427
+ const receivers = [];
1213
1428
  const selected = (profile) => !options.profiles || profile !== null && options.profiles.has(profile);
1214
1429
  const hooks = record$2(settings.hooks) ? { ...settings.hooks } : {};
1215
1430
  const cli = context.blocked === null ? context.cli : null;
@@ -1246,6 +1461,13 @@ function repairClaudeHooks(settings, context, checks, options = {}) {
1246
1461
  const held = heldSessionProfiles(hooks, context.bound ?? [], context.restoreProfiles);
1247
1462
  const bound = (context.bound ?? []).filter((item) => !held.has(boundKey(item)));
1248
1463
  const verdicts = judgeSessionHooks(hooks, context.cli, bound);
1464
+ const missingBefore = missingSessionHooks(hooks, bound);
1465
+ const planned = /* @__PURE__ */ new Set();
1466
+ const plan = (item) => {
1467
+ if (planned.has(boundKey(item))) return;
1468
+ planned.add(boundKey(item));
1469
+ receivers.push(item);
1470
+ };
1249
1471
  for (const event of SESSION_EVENTS) {
1250
1472
  const entries = eventEntries(hooks, event);
1251
1473
  if (entries === "invalid" || entries.length === 0) continue;
@@ -1253,13 +1475,23 @@ function repairClaudeHooks(settings, context, checks, options = {}) {
1253
1475
  const verdict = verdicts.get(hook);
1254
1476
  const parsed = parseSessionHook(hook);
1255
1477
  if (!verdict || verdict === "keep" || !parsed) return hook;
1256
- if (!selected(parsed.profile)) return hook;
1478
+ if (!parsed.shared && !selected(parsed.profile)) return hook;
1257
1479
  const item = {
1258
1480
  profile: parsed.profile,
1259
1481
  session: parsed.session?.toLowerCase() ?? null,
1260
1482
  hook: event,
1261
1483
  state: verdict
1262
1484
  };
1485
+ if (verdict === "per_session") {
1486
+ if (!cli) return hook;
1487
+ const current = foldedReceiver(parsed);
1488
+ if (current) plan(current);
1489
+ changes.push({
1490
+ ...item,
1491
+ action: "removed"
1492
+ });
1493
+ return;
1494
+ }
1263
1495
  if (verdict === "stale" || verdict === "duplicate" || verdict === "old_address") {
1264
1496
  changes.push({
1265
1497
  ...item,
@@ -1286,26 +1518,33 @@ function repairClaudeHooks(settings, context, checks, options = {}) {
1286
1518
  changed.add("claude.hook.stop");
1287
1519
  }
1288
1520
  }
1289
- if (cli) for (const missing of missingSessionHooks(hooks, bound)) {
1290
- if (!selected(missing.bound.profile)) continue;
1291
- const entries = eventEntries(hooks, missing.event);
1292
- if (entries === "invalid") continue;
1293
- changes.push({
1294
- profile: missing.bound.profile,
1295
- session: missing.bound.session,
1296
- hook: missing.event,
1297
- state: "missing",
1298
- action: "added"
1299
- });
1300
- hooks[missing.event] = [...entries, sessionHookEntry(missing.event, missing.bound, cli)];
1301
- changed.add("claude.hook.stop");
1521
+ if (cli) {
1522
+ for (const missing of missingBefore) {
1523
+ if (!selected(missing.bound.profile)) continue;
1524
+ changes.push({
1525
+ profile: missing.bound.profile,
1526
+ session: missing.bound.session,
1527
+ hook: missing.event,
1528
+ state: "missing",
1529
+ action: "added"
1530
+ });
1531
+ plan(missing.bound);
1532
+ }
1533
+ for (const configDir of new Set(receivers.map((item) => resolve(item.configDir)))) for (const event of SESSION_EVENTS) {
1534
+ const entries = eventEntries(hooks, event);
1535
+ if (entries === "invalid" || hasSharedHook(hooks, event, configDir, null)) continue;
1536
+ hooks[event] = [...entries, sharedHookEntry(event, cli, configDir)];
1537
+ changed.add("claude.hook.stop");
1538
+ }
1539
+ if (receivers.length) changed.add("claude.hook.stop");
1302
1540
  }
1303
1541
  }
1304
1542
  }
1305
1543
  if (!changed.size) return {
1306
1544
  settings,
1307
1545
  changed,
1308
- changes
1546
+ changes,
1547
+ receivers
1309
1548
  };
1310
1549
  return {
1311
1550
  settings: {
@@ -1313,15 +1552,17 @@ function repairClaudeHooks(settings, context, checks, options = {}) {
1313
1552
  hooks
1314
1553
  },
1315
1554
  changed,
1316
- changes
1555
+ changes,
1556
+ receivers
1317
1557
  };
1318
1558
  }
1319
1559
  //#endregion
1320
1560
  //#region src/oclif/claude-wake-install.ts
1321
- const HOOK_MARKER = "primitive-agent-wake-v1";
1322
- const WAKE_EVENTS = ["Stop", "SessionStart"];
1323
- const PENDING_MARKER = "primitive-pending-mail-v1";
1324
- const PENDING_EVENT = "PostToolUse";
1561
+ const RECEIVE_EVENTS = [
1562
+ "Stop",
1563
+ "SessionStart",
1564
+ "PostToolUse"
1565
+ ];
1325
1566
  const LOCK_WAIT = new Int32Array(new SharedArrayBuffer(4));
1326
1567
  function record$1(value) {
1327
1568
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -1445,7 +1686,14 @@ function editClaudeSettings(claudeDir, edit) {
1445
1686
  release();
1446
1687
  }
1447
1688
  }
1448
- /** Add exact-session fail-open receiving hooks while preserving unrelated settings. */
1689
+ /**
1690
+ * Record this profile as a receiver for the session and make sure the one
1691
+ * shared set of receive hooks is installed, preserving unrelated settings.
1692
+ * Per-session hooks written by earlier versions for this config directory
1693
+ * are folded in: a still-bound profile becomes a recorded receiver and its
1694
+ * hooks are removed; hooks for disconnected profiles or removed config
1695
+ * directories are pruned. Hooks of other tools are never touched.
1696
+ */
1449
1697
  function installClaudeWakeHook(options) {
1450
1698
  try {
1451
1699
  const configDir = resolve(options.configDir);
@@ -1455,9 +1703,6 @@ function installClaudeWakeHook(options) {
1455
1703
  platform: options.platform
1456
1704
  });
1457
1705
  if (!cli) return "unavailable";
1458
- const cliPath = cli.entry;
1459
- const wrapperPath = cli.wake;
1460
- const pendingPath = cli.pending;
1461
1706
  const profileName = agentProfileName(options.profileName);
1462
1707
  const agentAddress = mailAddress(options.agentAddress);
1463
1708
  if (!SESSION_UUID.test(options.sessionId)) return "unavailable";
@@ -1469,52 +1714,19 @@ function installClaudeWakeHook(options) {
1469
1714
  } catch {
1470
1715
  node = cli.runtimeNode;
1471
1716
  }
1717
+ pruneSessionReceivers(configDir);
1472
1718
  let held = false;
1473
1719
  if (!editClaudeSettings(claudeDir, (settings) => {
1474
1720
  const hooks = settings.hooks ?? {};
1475
1721
  if (!record$1(hooks)) throw new Error("Invalid hook settings");
1476
- for (const event of WAKE_EVENTS) if (!Array.isArray(hooks[event] ?? [])) throw new Error(`Invalid ${event} hooks`);
1477
- if (!Array.isArray(hooks[PENDING_EVENT] ?? [])) throw new Error(`Invalid ${PENDING_EVENT} hooks`);
1722
+ for (const event of RECEIVE_EVENTS) if (!Array.isArray(hooks[event] ?? [])) throw new Error(`Invalid ${event} hooks`);
1478
1723
  held = options.yieldToOtherProfiles === true && otherSessionReceivers(hooks, {
1479
1724
  configDir,
1480
1725
  profile: profileName,
1481
1726
  session: sessionId
1482
1727
  }).length > 0;
1483
- const hook = {
1484
- type: "command",
1485
- command: node,
1486
- args: [
1487
- wrapperPath,
1488
- cliPath,
1489
- configDir,
1490
- profileName,
1491
- agentAddress,
1492
- sessionId,
1493
- HOOK_MARKER
1494
- ],
1495
- asyncRewake: true,
1496
- timeout: 604800
1497
- };
1498
- const pendingHook = {
1499
- type: "command",
1500
- command: node,
1501
- args: [
1502
- pendingPath,
1503
- cliPath,
1504
- configDir,
1505
- profileName,
1506
- agentAddress,
1507
- sessionId,
1508
- PENDING_MARKER
1509
- ],
1510
- timeout: 10
1511
- };
1512
- const isOwnPendingHook = (candidate) => record$1(candidate) && candidate.type === "command" && Array.isArray(candidate.args) && candidate.args.length === 7 && typeof candidate.args[0] === "string" && basename(candidate.args[0]) === "claude-pending-mail.mjs" && candidate.args[2] === configDir && candidate.args[3] === profileName && candidate.args[5] === sessionId && candidate.args[6] === PENDING_MARKER;
1513
- const isOwnHook = (candidate) => {
1514
- if (!record$1(candidate) || !Array.isArray(candidate.args)) return false;
1515
- if (typeof candidate.args[0] !== "string" || basename(candidate.args[0]) !== "claude-wake.mjs" || candidate.args[2] !== configDir || candidate.args[3] !== profileName) return false;
1516
- if (candidate.args.length === 7 && candidate.args[5] === sessionId && candidate.args[6] === HOOK_MARKER) return true;
1517
- if (candidate.args.length !== 6 || candidate.args[5] !== HOOK_MARKER) return false;
1728
+ const isOwnLegacyHook = (parsed) => {
1729
+ if (!parsed || parsed.shared || parsed.session !== null || parsed.profile !== profileName || resolve(parsed.configDir) !== configDir) return false;
1518
1730
  try {
1519
1731
  const state = readMailJson(join(agentProfileDirectory(configDir, profileName), "setup.json"));
1520
1732
  return record$1(state) && state.session === sessionId;
@@ -1522,39 +1734,50 @@ function installClaudeWakeHook(options) {
1522
1734
  return false;
1523
1735
  }
1524
1736
  };
1737
+ const perSession = (parsed) => parsed !== null && !parsed.shared && parsed.profile !== null && parsed.session !== null;
1738
+ if (!held) addSessionReceiver(configDir, sessionId, {
1739
+ profile: profileName,
1740
+ address: agentAddress
1741
+ });
1525
1742
  const nextHooks = { ...hooks };
1526
- for (const event of WAKE_EVENTS) {
1527
- const entries = hooks[event];
1528
- const retained = (entries ?? []).flatMap((entry) => {
1743
+ for (const event of RECEIVE_EVENTS) {
1744
+ const entries = hooks[event] ?? [];
1745
+ const ownShared = (candidate) => {
1746
+ const parsed = parseReceiveHook(candidate);
1747
+ return parsed?.shared === true && resolve(parsed.configDir) === configDir;
1748
+ };
1749
+ const wanted = sharedHookEntry(event, cli, configDir, node);
1750
+ const [wantedHook] = wanted.hooks;
1751
+ const existing = entries.flatMap((entry) => record$1(entry) && Array.isArray(entry.hooks) ? entry.hooks.filter(ownShared) : []);
1752
+ const reuse = held || existing.length === 1 && JSON.stringify(existing[0]) === JSON.stringify(wantedHook) && hasSharedHook(hooks, event, configDir, node);
1753
+ const keep = (candidate) => {
1754
+ const parsed = parseReceiveHook(candidate);
1755
+ if (!parsed) return true;
1756
+ if (isOwnLegacyHook(parsed)) return false;
1757
+ if (!existsSync(parsed.configDir)) return false;
1758
+ if (parsed.shared) return resolve(parsed.configDir) !== configDir || reuse;
1759
+ if (!perSession(parsed)) return true;
1760
+ if (held && !(parsed.profile === profileName && parsed.session?.toLowerCase() === sessionId && resolve(parsed.configDir) === configDir)) return true;
1761
+ if (resolve(parsed.configDir) !== configDir) return profileStillConnected(parsed.configDir, parsed.profile ?? "");
1762
+ const current = foldedReceiver(parsed);
1763
+ if (current && !held) addSessionReceiver(configDir, current.session, {
1764
+ profile: current.profile,
1765
+ address: current.address
1766
+ });
1767
+ return false;
1768
+ };
1769
+ const retained = entries.flatMap((entry) => {
1529
1770
  if (!record$1(entry) || !Array.isArray(entry.hooks)) return [entry];
1530
- const siblings = entry.hooks.filter((candidate) => !isOwnHook(candidate));
1771
+ const siblings = entry.hooks.filter(keep);
1531
1772
  if (siblings.length === entry.hooks.length) return [entry];
1532
1773
  return siblings.length ? [{
1533
1774
  ...entry,
1534
1775
  hooks: siblings
1535
1776
  }] : [];
1536
1777
  });
1537
- if (held) {
1538
- if (entries) nextHooks[event] = retained;
1539
- } else nextHooks[event] = [...retained, event === "SessionStart" ? {
1540
- matcher: "resume",
1541
- hooks: [hook]
1542
- } : { hooks: [hook] }];
1778
+ if (hooks[event] !== void 0 || !reuse) nextHooks[event] = reuse ? retained : [...retained, wanted];
1543
1779
  }
1544
- const pendingEntries = hooks[PENDING_EVENT];
1545
- const pendingRetained = (pendingEntries ?? []).flatMap((entry) => {
1546
- if (!record$1(entry) || !Array.isArray(entry.hooks)) return [entry];
1547
- const siblings = entry.hooks.filter((candidate) => !isOwnPendingHook(candidate));
1548
- if (siblings.length === entry.hooks.length) return [entry];
1549
- return siblings.length ? [{
1550
- ...entry,
1551
- hooks: siblings
1552
- }] : [];
1553
- });
1554
- if (held) {
1555
- if (pendingEntries) nextHooks[PENDING_EVENT] = pendingRetained;
1556
- } else nextHooks[PENDING_EVENT] = [...pendingRetained, { hooks: [pendingHook] }];
1557
- if (held && JSON.stringify(nextHooks) === JSON.stringify(hooks)) return null;
1780
+ if (JSON.stringify(nextHooks) === JSON.stringify(hooks)) return null;
1558
1781
  return {
1559
1782
  ...settings,
1560
1783
  hooks: nextHooks
@@ -1565,7 +1788,12 @@ function installClaudeWakeHook(options) {
1565
1788
  return "unavailable";
1566
1789
  }
1567
1790
  }
1568
- /** Remove only this profile's exact-session hooks after disconnection. */
1791
+ /**
1792
+ * Stop receiving as this profile in the session after disconnection: its
1793
+ * receiver record goes, with any per-session hooks an earlier version wrote
1794
+ * for it. The shared hooks stay; for a session with no receivers they exit
1795
+ * at once.
1796
+ */
1569
1797
  function uninstallClaudeWakeHook(options) {
1570
1798
  try {
1571
1799
  const configDir = resolve(options.configDir);
@@ -1575,12 +1803,12 @@ function uninstallClaudeWakeHook(options) {
1575
1803
  const sessionId = options.sessionId.toLowerCase();
1576
1804
  const env = options.env ?? process.env;
1577
1805
  const claudeDir = resolve(env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"));
1806
+ removeSessionReceiver(configDir, sessionId, profileName);
1578
1807
  if (!existsSync(join(claudeDir, "settings.json"))) return true;
1579
1808
  return editClaudeSettings(claudeDir, (settings) => {
1580
1809
  const hooks = settings.hooks ?? {};
1581
1810
  if (!record$1(hooks)) throw new Error("Invalid hook settings");
1582
- for (const event of WAKE_EVENTS) if (!Array.isArray(hooks[event] ?? [])) throw new Error(`Invalid ${event} hooks`);
1583
- if (!Array.isArray(hooks[PENDING_EVENT] ?? [])) throw new Error(`Invalid ${PENDING_EVENT} hooks`);
1811
+ for (const event of RECEIVE_EVENTS) if (!Array.isArray(hooks[event] ?? [])) throw new Error(`Invalid ${event} hooks`);
1584
1812
  const legacySessionMatches = () => {
1585
1813
  try {
1586
1814
  const setup = readMailJson(join(agentProfileDirectory(configDir, profileName), "setup.json"));
@@ -1590,38 +1818,25 @@ function uninstallClaudeWakeHook(options) {
1590
1818
  }
1591
1819
  };
1592
1820
  const isOwnHook = (candidate) => {
1593
- if (!record$1(candidate) || !Array.isArray(candidate.args)) return false;
1594
- const args = candidate.args;
1595
- return candidate.type === "command" && typeof candidate.command === "string" && candidate.asyncRewake === true && typeof args[0] === "string" && basename(args[0]) === "claude-wake.mjs" && typeof args[1] === "string" && args[2] === configDir && args[3] === profileName && args[4] === agentAddress && (args.length === 7 && args[5] === sessionId && args[6] === HOOK_MARKER || args.length === 6 && args[5] === HOOK_MARKER && legacySessionMatches());
1596
- };
1597
- const isOwnPendingHook = (candidate) => {
1598
- if (!record$1(candidate) || !Array.isArray(candidate.args)) return false;
1599
- const args = candidate.args;
1600
- return candidate.type === "command" && typeof candidate.command === "string" && typeof args[0] === "string" && basename(args[0]) === "claude-pending-mail.mjs" && args[2] === configDir && args[3] === profileName && args[4] === agentAddress && args[5] === sessionId && args[6] === PENDING_MARKER && args.length === 7;
1821
+ const parsed = parseReceiveHook(candidate);
1822
+ return parsed !== null && !parsed.shared && resolve(parsed.configDir) === configDir && parsed.profile === profileName && parsed.address === agentAddress && (parsed.session === null ? legacySessionMatches() : parsed.session.toLowerCase() === sessionId);
1601
1823
  };
1602
1824
  let removed = false;
1603
1825
  const nextHooks = { ...hooks };
1604
- for (const event of WAKE_EVENTS) nextHooks[event] = (hooks[event] ?? []).flatMap((entry) => {
1605
- if (!record$1(entry) || !Array.isArray(entry.hooks)) return [entry];
1606
- const siblings = entry.hooks.filter((candidate) => !isOwnHook(candidate));
1607
- if (siblings.length === entry.hooks.length) return [entry];
1608
- removed = true;
1609
- return siblings.length ? [{
1610
- ...entry,
1611
- hooks: siblings
1612
- }] : [];
1613
- });
1614
- const pendingEntries = hooks[PENDING_EVENT];
1615
- nextHooks[PENDING_EVENT] = (pendingEntries ?? []).flatMap((entry) => {
1616
- if (!record$1(entry) || !Array.isArray(entry.hooks)) return [entry];
1617
- const siblings = entry.hooks.filter((candidate) => !isOwnPendingHook(candidate));
1618
- if (siblings.length === entry.hooks.length) return [entry];
1619
- removed = true;
1620
- return siblings.length ? [{
1621
- ...entry,
1622
- hooks: siblings
1623
- }] : [];
1624
- });
1826
+ for (const event of RECEIVE_EVENTS) {
1827
+ const entries = hooks[event];
1828
+ if (!entries) continue;
1829
+ nextHooks[event] = entries.flatMap((entry) => {
1830
+ if (!record$1(entry) || !Array.isArray(entry.hooks)) return [entry];
1831
+ const siblings = entry.hooks.filter((candidate) => !isOwnHook(candidate));
1832
+ if (siblings.length === entry.hooks.length) return [entry];
1833
+ removed = true;
1834
+ return siblings.length ? [{
1835
+ ...entry,
1836
+ hooks: siblings
1837
+ }] : [];
1838
+ });
1839
+ }
1625
1840
  return removed ? {
1626
1841
  ...settings,
1627
1842
  hooks: nextHooks
@@ -1821,6 +2036,12 @@ var AgentInvitationRejectedError = class extends AgentConnectionSetupError {
1821
2036
  this.reason = reason;
1822
2037
  }
1823
2038
  };
2039
+ /**
2040
+ * The claim was never sent: another setup on this machine held the claim
2041
+ * lock for the whole wait. Unlike an uncertain claim, the invitation is
2042
+ * untouched, so a caller may discard what it created for this run.
2043
+ */
2044
+ var ClaimNotSubmittedError = class extends AgentConnectionSetupError {};
1824
2045
  const NOTHING_CHANGED = "Nothing was changed on this machine.";
1825
2046
  /**
1826
2047
  * Restates a refusal that followed a replacement: the agent being replaced
@@ -2122,9 +2343,16 @@ async function connectAgent(params) {
2122
2343
  let release;
2123
2344
  try {
2124
2345
  privateMailDirectory(directory, true);
2125
- release = params.claimLockHeld ? () => {} : acquireListenLock(directory, "agent-connection-setup");
2126
2346
  } catch {
2127
- throw new AgentConnectionSetupError("Agent setup is already running, or its private directory is unavailable. No invitation was submitted.");
2347
+ throw new ClaimNotSubmittedError("This machine's private agent directory is unavailable. No invitation was submitted.");
2348
+ }
2349
+ if (params.claimLockHeld) release = () => {};
2350
+ const deadline = Date.now() + Math.max(0, params.claimLockWaitMs ?? 0);
2351
+ while (!release) try {
2352
+ release = acquireListenLock(directory, "agent-connection-setup");
2353
+ } catch {
2354
+ if (Date.now() >= deadline) throw new ClaimNotSubmittedError("Agent setup is already running on this machine, so this invitation was not submitted; rerun the same command when it finishes.");
2355
+ await setTimeout$1(Math.min(250, Math.max(1, deadline - Date.now())));
2128
2356
  }
2129
2357
  try {
2130
2358
  const existing = loadConnectedAgentProfile(params.configDir, profileName);
@@ -3411,6 +3639,140 @@ function createNotificationContactPolicy(options) {
3411
3639
  };
3412
3640
  }
3413
3641
  //#endregion
3642
+ //#region src/oclif/registration-log.ts
3643
+ /**
3644
+ * A short private record of this profile's registration attempts, so a
3645
+ * stuck registration can be explained by `agent connect --status` and
3646
+ * `whoami` without anyone opening profile files. It holds phase changes,
3647
+ * error codes, the organization id and the agent address only: never a
3648
+ * token, key, claim URL or response body.
3649
+ */
3650
+ const LOG_FILE = "registration-log.json";
3651
+ const MAX_DETAIL = 400;
3652
+ const MAX_BYTES = 65536;
3653
+ /**
3654
+ * Removes anything that could carry a credential: setup URLs and their
3655
+ * fragments, token parameters and Primitive key or token prefixes. The
3656
+ * messages written here are the CLI's own refusals, so this is a second
3657
+ * line of defence, not the only one.
3658
+ */
3659
+ function scrubRegistrationDetail(text) {
3660
+ return text.replace(/https?:\/\/\S*agent-connections\/setup\S*/gi, "[setup URL]").replace(/#?token=[^\s"'&]+/gi, "token=[redacted]").replace(/\b(?:prim|pconn|invite|invitation)_[A-Za-z0-9_-]+/g, "[redacted]").replace(/\bBearer\s+\S+/gi, "Bearer [redacted]").slice(0, MAX_DETAIL);
3661
+ }
3662
+ function logPath(configDir, profileName) {
3663
+ return join(agentProfileDirectory(configDir, profileName), LOG_FILE);
3664
+ }
3665
+ function entryFrom(value) {
3666
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
3667
+ const row = value;
3668
+ const text = (key) => typeof row[key] === "string" ? row[key] : null;
3669
+ const at = text("at");
3670
+ const event = text("event");
3671
+ if (!at || !event || !Number.isFinite(Date.parse(at))) return null;
3672
+ return {
3673
+ at,
3674
+ event,
3675
+ phase: text("phase"),
3676
+ code: text("code"),
3677
+ orgId: text("orgId"),
3678
+ address: text("address"),
3679
+ detail: text("detail")
3680
+ };
3681
+ }
3682
+ /** Every saved entry, oldest first. Empty when there is no readable log. */
3683
+ function readRegistrationLog(configDir, profileName) {
3684
+ try {
3685
+ const saved = readMailJson(logPath(configDir, profileName), MAX_BYTES);
3686
+ if (saved?.version !== 1 || !Array.isArray(saved.entries)) return [];
3687
+ return saved.entries.map(entryFrom).filter((row) => row !== null);
3688
+ } catch {
3689
+ return [];
3690
+ }
3691
+ }
3692
+ /** Appends one entry, keeping the most recent ones. Best effort. */
3693
+ function appendRegistrationLog(configDir, profileName, entry) {
3694
+ try {
3695
+ const row = {
3696
+ at: entry.at ?? (/* @__PURE__ */ new Date()).toISOString(),
3697
+ event: entry.event,
3698
+ phase: entry.phase,
3699
+ code: entry.code,
3700
+ orgId: entry.orgId,
3701
+ address: entry.address,
3702
+ detail: entry.detail === void 0 || entry.detail === null ? null : scrubRegistrationDetail(entry.detail)
3703
+ };
3704
+ const entries = [...readRegistrationLog(configDir, profileName), row].slice(-30);
3705
+ writeMailJson(logPath(configDir, profileName), {
3706
+ version: 1,
3707
+ entries
3708
+ });
3709
+ } catch {}
3710
+ }
3711
+ /** What the last registration attempt did, or null when none was recorded. */
3712
+ function registrationSummary(configDir, profileName) {
3713
+ const entries = readRegistrationLog(configDir, profileName);
3714
+ const last = entries.at(-1);
3715
+ if (!last) return null;
3716
+ const address = [...entries].reverse().find((row) => row.address !== null)?.address ?? null;
3717
+ const orgId = [...entries].reverse().find((row) => row.orgId !== null)?.orgId ?? null;
3718
+ return {
3719
+ lastAttemptAt: last.at,
3720
+ lastEvent: last.event,
3721
+ address,
3722
+ orgId,
3723
+ lastFailure: last.event === "failed" ? {
3724
+ at: last.at,
3725
+ code: last.code,
3726
+ detail: last.detail
3727
+ } : null
3728
+ };
3729
+ }
3730
+ /** The lock scope an enrollment holds on its profile's enrollment directory. */
3731
+ const ENROLLMENT_LOCK_SCOPE = "agent-enrollment";
3732
+ /** Profiles whose registration is running right now on this machine. */
3733
+ function registrationsInFlight(configDir) {
3734
+ let names;
3735
+ try {
3736
+ names = readdirSync(join(agentProfilesDirectory(configDir), "profiles"));
3737
+ } catch {
3738
+ return [];
3739
+ }
3740
+ return names.sort().filter((name) => listenLockHeld(join(agentProfilesDirectory(configDir), "profiles", name, "enrollment"), ENROLLMENT_LOCK_SCOPE));
3741
+ }
3742
+ /**
3743
+ * Waits up to `budgetMs` for running registrations to finish, so a login
3744
+ * switch does not change the member login underneath one. Returns the
3745
+ * profiles still registering when the budget ran out.
3746
+ */
3747
+ async function waitForRegistrations(configDir, budgetMs, options = {}) {
3748
+ const deadline = Date.now() + budgetMs;
3749
+ let running = registrationsInFlight(configDir);
3750
+ if (running.length) options.onWait?.(running);
3751
+ while (running.length && Date.now() < deadline) {
3752
+ await setTimeout$1(Math.min(options.pollMs ?? 250, deadline - Date.now()));
3753
+ running = registrationsInFlight(configDir);
3754
+ }
3755
+ return running;
3756
+ }
3757
+ /** One line for people: what the last registration attempt did. */
3758
+ function registrationSummaryLine(summary) {
3759
+ const failure = summary.lastFailure;
3760
+ return failure ? `The last registration attempt for this session${summary.address ? ` (${summary.address})` : ""} failed at ${failure.at}${failure.code ? ` (${failure.code})` : ""}: ${failure.detail ?? "no detail was recorded."}` : `The last registration attempt for this session${summary.address ? ` (${summary.address})` : ""} reached "${summary.lastEvent}" at ${summary.lastAttemptAt}.`;
3761
+ }
3762
+ /**
3763
+ * The failed registration of this session's own profile, when the session
3764
+ * has no connected credential there. Null when it is connected, never
3765
+ * registered, or its last attempt did not fail. Offline and read-only.
3766
+ */
3767
+ function failedSessionRegistration(configDir, session) {
3768
+ const profile = `session-${session.toLowerCase()}`;
3769
+ try {
3770
+ if (loadConnectedAgentProfile(configDir, profile)) return null;
3771
+ } catch {}
3772
+ const summary = registrationSummary(configDir, profile);
3773
+ return summary?.lastFailure ? summary : null;
3774
+ }
3775
+ //#endregion
3414
3776
  //#region src/oclif/agent-setup.ts
3415
3777
  const SUBJECT = "Connect your agent to Primitive";
3416
3778
  const MARKER = /\bprimitive-connection:([a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}):([1-9]\d*)\b/g;
@@ -3786,6 +4148,44 @@ function refuseSetupConflicts(state, params) {
3786
4148
  ];
3787
4149
  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."}`);
3788
4150
  }
4151
+ /** How long a claim waits for another claim on this machine to finish. */
4152
+ const CLAIM_LOCK_WAIT_MS = 3e4;
4153
+ /**
4154
+ * Moves a file aside as `replaced-<stamp>-<name>`, keeping it for recovery.
4155
+ * Returns where it went, or null when there was no file.
4156
+ */
4157
+ function moveAside(path, now) {
4158
+ const stamp = now.toISOString().replace(/[^0-9]/g, "");
4159
+ const target = join(dirname(path), `replaced-${stamp}-${basename(path)}`);
4160
+ try {
4161
+ renameSync(path, target);
4162
+ return target;
4163
+ } catch (error) {
4164
+ if (error.code !== "ENOENT") throw error;
4165
+ return null;
4166
+ }
4167
+ }
4168
+ /** The address an unfinished enrollment in this profile reserved, if any. */
4169
+ function pendingEnrollmentAddress(directory) {
4170
+ try {
4171
+ const saved = readMailJson(join(directory, "enrollment", "state.json"));
4172
+ return typeof saved?.address === "string" ? saved.address : null;
4173
+ } catch {
4174
+ return null;
4175
+ }
4176
+ }
4177
+ /** A connected credential is saved in this profile. Unreadable counts as one. */
4178
+ function holdsCredential(configDir, profileName) {
4179
+ try {
4180
+ return loadConnectedAgentProfile(configDir, profileName) !== null;
4181
+ } catch {
4182
+ return true;
4183
+ }
4184
+ }
4185
+ /** The one next step when a profile has setup progress but no credential. */
4186
+ function unfinishedSetupStep(address) {
4187
+ return address ? `This session's agent ${address} is created but not connected: in the Primitive app open it and choose Copy again, then paste that setup instruction here; \`agent connect\` finishes the same address.` : "This profile's earlier setup never finished and holds no credential: ask the owner to copy a fresh setup instruction from the Primitive app and paste it here; `agent connect` claims it into this same profile.";
4188
+ }
3789
4189
  function invitationHashOrNull(invitation) {
3790
4190
  if (invitation === void 0) return null;
3791
4191
  try {
@@ -3815,7 +4215,8 @@ async function setupAgent(params) {
3815
4215
  } catch {}
3816
4216
  const newHash = params.resume ? null : invitationHashOrNull(params.invitation);
3817
4217
  const replacesRevoked = (state) => newHash !== null && state.invitationHash !== newHash && savedSetupRevoked(params.configDir, profileName, state.invitationHash);
3818
- if (saved && !replacesRevoked(saved)) refuseSetupConflicts(saved, params);
4218
+ const replacesUnfinished = (state) => newHash !== null && state.invitationHash !== newHash && state.phase === "waiting" && !holdsCredential(params.configDir, profileName);
4219
+ if (saved && !replacesRevoked(saved) && !replacesUnfinished(saved)) refuseSetupConflicts(saved, params);
3819
4220
  const receiverMode = params.receiverMode ?? (params.resume ? saved?.receiverMode : void 0);
3820
4221
  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.");
3821
4222
  if ((receiverMode ?? "native") === "native" && params.session) try {
@@ -3826,13 +4227,31 @@ async function setupAgent(params) {
3826
4227
  privateMailDirectory(directory, true);
3827
4228
  const release = acquireListenLock(directory, "setup");
3828
4229
  let releaseClaim;
4230
+ let releaseEnrollment;
4231
+ let replacedUnfinished;
4232
+ const moved = [];
4233
+ let retireEnrollment = false;
4234
+ const setAside = (from, at) => {
4235
+ const to = moveAside(from, at);
4236
+ if (to) moved.push({
4237
+ from,
4238
+ to
4239
+ });
4240
+ };
3829
4241
  let discardStub = false;
3830
4242
  let claimedName;
4243
+ const takeEnrollment = () => {
4244
+ try {
4245
+ releaseEnrollment = acquireListenLock(join(directory, "enrollment"), ENROLLMENT_LOCK_SCOPE);
4246
+ } catch {
4247
+ throw fail("This session's background registration is still running, so this invitation was not claimed; wait a few seconds and rerun the same command.");
4248
+ }
4249
+ };
3831
4250
  try {
3832
4251
  const path = join(directory, "setup.json");
3833
4252
  const value = readMailJson(path);
3834
4253
  let state = value === null ? null : parseState(value);
3835
- if (state && replacesRevoked(state)) {
4254
+ if (state && (replacesRevoked(state) || replacesUnfinished(state))) {
3836
4255
  try {
3837
4256
  releaseClaim = acquireListenLock(agentProfilesDirectory(params.configDir), "agent-connection-setup");
3838
4257
  } catch {
@@ -3843,8 +4262,19 @@ async function setupAgent(params) {
3843
4262
  if (state && replacesRevoked(state)) {
3844
4263
  archiveRevokedSetup(params.configDir, profileName, new Date(dependencies.now()));
3845
4264
  state = null;
4265
+ } else if (state && replacesUnfinished(state)) {
4266
+ if (!params.enrollmentOwned) takeEnrollment();
4267
+ replacedUnfinished = { address: pendingEnrollmentAddress(directory) };
4268
+ setAside(path, new Date(dependencies.now()));
4269
+ retireEnrollment = !params.enrollmentOwned;
4270
+ state = null;
3846
4271
  }
3847
4272
  }
4273
+ if (!params.resume && !state && !params.enrollmentOwned && !releaseEnrollment && !holdsCredential(params.configDir, profileName) && existsSync(join(directory, "enrollment", "state.json"))) {
4274
+ takeEnrollment();
4275
+ replacedUnfinished = { address: pendingEnrollmentAddress(directory) };
4276
+ retireEnrollment = true;
4277
+ }
3848
4278
  if (state) refuseSetupConflicts(state, params);
3849
4279
  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.");
3850
4280
  if (params.resume && !state) throw fail("No resumable setup exists for this profile. Do not adopt another session's identity.");
@@ -3874,16 +4304,29 @@ async function setupAgent(params) {
3874
4304
  invitation: params.invitation ?? "",
3875
4305
  fetch: params.fetch,
3876
4306
  presence: true,
3877
- claimLockHeld: releaseClaim !== void 0
4307
+ claimLockHeld: releaseClaim !== void 0,
4308
+ claimLockWaitMs: params.claimLockWaitMs ?? CLAIM_LOCK_WAIT_MS
3878
4309
  })).name;
4310
+ if (retireEnrollment) moveAside(join(directory, "enrollment", "state.json"), new Date(dependencies.now()));
4311
+ if (replacedUnfinished?.address) appendRegistrationLog(params.configDir, profileName, {
4312
+ event: "replaced_unfinished",
4313
+ phase: null,
4314
+ code: null,
4315
+ orgId: null,
4316
+ address: replacedUnfinished.address,
4317
+ detail: "A pasted setup instruction finished this session's unfinished registration."
4318
+ });
3879
4319
  } catch (error) {
3880
- if (error instanceof AgentInvitationRejectedError && created) {
4320
+ if ((error instanceof AgentInvitationRejectedError || error instanceof ClaimNotSubmittedError) && created) {
3881
4321
  discardStub = true;
3882
4322
  try {
3883
4323
  removeMailFile(path);
3884
4324
  } catch {
3885
4325
  discardStub = false;
3886
4326
  }
4327
+ if (discardStub) for (const { from, to } of moved.splice(0).reverse()) try {
4328
+ renameSync(to, from);
4329
+ } catch {}
3887
4330
  }
3888
4331
  throw error;
3889
4332
  } finally {
@@ -3892,7 +4335,8 @@ async function setupAgent(params) {
3892
4335
  }
3893
4336
  }
3894
4337
  const profile = loadConnectedAgentProfile(params.configDir, profileName);
3895
- if (!profile || !state || profile.invitation_hash !== state.invitationHash) throw fail("The claim has no matching saved credential. Preserve private state and request a fresh invitation; do not replay this one.");
4338
+ if (!profile) throw fail(unfinishedSetupStep(pendingEnrollmentAddress(directory)));
4339
+ if (!state || profile.invitation_hash !== state.invitationHash) throw fail("The claim has no matching saved credential. Preserve private state and request a fresh invitation; do not replay this one.");
3896
4340
  const setupBudgetMs = Math.max(9e4, timeoutMs + 3e4);
3897
4341
  const readBudget = {
3898
4342
  deadline: dependencies.now() + setupBudgetMs,
@@ -3923,6 +4367,10 @@ async function setupAgent(params) {
3923
4367
  claimedName ??= savedConnectionName(params.configDir, profileName);
3924
4368
  const result = (receiving, ownerNotifications) => ({
3925
4369
  identity: context.identity,
4370
+ ...replacedUnfinished ? { replacedUnfinished: {
4371
+ address: replacedUnfinished.address,
4372
+ sameAddress: replacedUnfinished.address === null ? null : replacedUnfinished.address.toLowerCase() === context.identity.agentAddress.toLowerCase()
4373
+ } } : {},
3926
4374
  ...claimedName === void 0 ? {} : { connectionName: claimedName },
3927
4375
  sessionId: params.session ?? state?.session ?? null,
3928
4376
  verification: {
@@ -3979,6 +4427,7 @@ async function setupAgent(params) {
3979
4427
  return result(state.receiverMode === "external" ? EXTERNAL_HOOKS_PENDING : state.receiverMode === "poll" ? "poll" : healthy ? "healthy" : "not_ready", ownerNotifications);
3980
4428
  } finally {
3981
4429
  releaseClaim?.();
4430
+ releaseEnrollment?.();
3982
4431
  release();
3983
4432
  if (discardStub) removeEmptyDirectory(directory);
3984
4433
  }
@@ -4209,6 +4658,11 @@ async function seedAgentInfoNote(configDir, profileName, value, fetchImpl) {
4209
4658
  return "failed";
4210
4659
  }
4211
4660
  }
4661
+ /** What a claim that replaced an unfinished setup did to its agent. */
4662
+ function unfinishedNote(replaced) {
4663
+ if (!replaced?.address) return "";
4664
+ return replaced.sameAddress ? `This finished the session's unfinished agent ${replaced.address}. ` : `This invitation was for a different address, so the session's earlier unfinished agent ${replaced.address} stays unconnected in the Primitive app; tell the owner so they can remove it there if it is not needed. `;
4665
+ }
4212
4666
  /**
4213
4667
  * One call: self check, install the matching skill, claim and verify, start
4214
4668
  * receiving, seed AGENT_INFO, and report everything as one result. Every step
@@ -4419,13 +4873,14 @@ async function runAgentConnect(options) {
4419
4873
  verification: result.verification,
4420
4874
  receiving,
4421
4875
  ...result.ownerNotifications ? { ownerNotifications: result.ownerNotifications } : {},
4876
+ ...result.replacedUnfinished ? { replacedUnfinished: result.replacedUnfinished } : {},
4422
4877
  agentInfo: agentInfoState,
4423
4878
  resumeNote,
4424
4879
  skipped,
4425
4880
  warnings,
4426
4881
  selectProfile: `PRIMITIVE_AGENT_PROFILE=${result.identity.profileName}`,
4427
4882
  resumeCommand,
4428
- guidance: `${result.guidance} ${ownerReportGuidance(identity)}`,
4883
+ guidance: `${unfinishedNote(result.replacedUnfinished)}${result.guidance} ${ownerReportGuidance(identity)}`,
4429
4884
  identity,
4430
4885
  externalHook
4431
4886
  };
@@ -4437,7 +4892,23 @@ const CONNECTION_LIST_LIMIT = 131072;
4437
4892
  const CONFIRMATION_BUDGET_MS = 12e4;
4438
4893
  const CONFIRMATION_MAX_ATTEMPTS = 40;
4439
4894
  const CONFIRMATION_PAGE_LIMIT = 20;
4895
+ /** How long a member-login refresh waits out another credential operation. */
4896
+ const CREDENTIAL_LOCK_WAIT_MS = 15e3;
4897
+ /** Fresh invitations a stuck enrollment may issue for itself, in total. */
4898
+ const HEAL_MAX_ATTEMPTS = 3;
4440
4899
  const refuse = (message) => new AgentConnectionSetupError(message);
4900
+ /**
4901
+ * The one next step for a session whose agent exists but never connected.
4902
+ * Copying the setup instruction again in the app issues a fresh invitation
4903
+ * for the same address, and `agent connect` claims it into this session.
4904
+ */
4905
+ function stuckEnrollmentStep(address) {
4906
+ return unfinishedSetupStep(address);
4907
+ }
4908
+ /** The one next step for a session whose agent was disconnected or taken over. */
4909
+ function reconnectStep(address, why) {
4910
+ return `This session's agent ${address} ${why}, so this machine did not change it: in the Primitive app open it and choose Reconnect, then paste that setup instruction here; \`agent connect\` finishes the same address.`;
4911
+ }
4441
4912
  function plainRecord(value) {
4442
4913
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
4443
4914
  }
@@ -4632,15 +5103,14 @@ async function enableAgentContactRequests(options) {
4632
5103
  }
4633
5104
  async function readOwnerConnection(enrollment, params, deadline, listed) {
4634
5105
  const saved = loadCliCredentials(params.configDir);
4635
- if (!saved || saved.org_id !== enrollment.orgId || saved.oauth_grant_id !== enrollment.grantId || saved.api_base_url !== enrollment.apiBaseUrl) throw refuse("The saved member login changed before connection confirmation. Preserve this session's profile and resume with the original member login.");
4636
- const credentials = await refreshStoredCliCredentials({
4637
- apiBaseUrl: enrollment.apiBaseUrl,
4638
- configDir: params.configDir,
4639
- credentials: saved,
4640
- fetch: params.fetch,
4641
- now: params.now
4642
- });
4643
- if (credentials.org_id !== enrollment.orgId || credentials.oauth_grant_id !== enrollment.grantId || credentials.api_base_url !== enrollment.apiBaseUrl) throw refuse("The saved member login changed before connection confirmation. Preserve this session's profile and resume with the original member login.");
5106
+ if (!saved || saved.org_id !== enrollment.orgId || saved.oauth_grant_id !== enrollment.grantId || saved.api_base_url !== enrollment.apiBaseUrl) return "unavailable";
5107
+ let credentials;
5108
+ try {
5109
+ credentials = await refreshMemberLogin(enrollment.apiBaseUrl, saved, params);
5110
+ } catch {
5111
+ return "unavailable";
5112
+ }
5113
+ if (credentials.org_id !== enrollment.orgId || credentials.oauth_grant_id !== enrollment.grantId || credentials.api_base_url !== enrollment.apiBaseUrl) return "unavailable";
4644
5114
  let cursor;
4645
5115
  for (let page = 0; page < CONFIRMATION_PAGE_LIMIT; page++) {
4646
5116
  const remaining = deadline - Date.now();
@@ -4672,7 +5142,7 @@ async function readOwnerConnection(enrollment, params, deadline, listed) {
4672
5142
  for (const candidate of envelope.data) {
4673
5143
  const connection = plainRecord(candidate);
4674
5144
  if (connection?.address !== enrollment.address) continue;
4675
- if (connection.owner_address !== enrollment.ownerAddress) throw refuse("The owner connection list did not match this session's saved identity. Preserve the profile and inspect the connection in the app.");
5145
+ if (connection.owner_address !== enrollment.ownerAddress) throw refuse(`The owner connection list names a different control address for ${enrollment.address}, so this machine changed nothing: in the Primitive app open it and choose Reconnect, then paste that setup instruction here.`);
4676
5146
  if (typeof connection.name === "string") listed.name = connection.name;
4677
5147
  if (connection.owner_active === false) return "owner_inactive";
4678
5148
  if (connection.status === "revoked") return "revoked";
@@ -4716,6 +5186,171 @@ function enrollmentResumes(configDir, session) {
4716
5186
  return false;
4717
5187
  }
4718
5188
  }
5189
+ function credentialLockBusy(error) {
5190
+ return error instanceof Error && error.message.includes("credential operation is already in progress");
5191
+ }
5192
+ /**
5193
+ * Refreshes the saved member login, waiting out another credential
5194
+ * operation (a login or logout in another terminal) instead of failing on
5195
+ * its lock. The caller compares the returned login with the one it expects.
5196
+ */
5197
+ async function refreshMemberLogin(apiBaseUrl, saved, params) {
5198
+ const deadline = Date.now() + (params.credentialLockWaitMs ?? CREDENTIAL_LOCK_WAIT_MS);
5199
+ for (;;) try {
5200
+ return await refreshStoredCliCredentials({
5201
+ apiBaseUrl,
5202
+ configDir: params.configDir,
5203
+ credentials: saved,
5204
+ fetch: params.fetch,
5205
+ now: params.now
5206
+ });
5207
+ } catch (error) {
5208
+ if (!credentialLockBusy(error) || Date.now() >= deadline) throw error;
5209
+ await setTimeout$1(Math.min(500, Math.max(1, deadline - Date.now())));
5210
+ }
5211
+ }
5212
+ /**
5213
+ * One connection the signed-in member personally owns, read page by page
5214
+ * from their own list (`owner=self`), so a login that does not own the
5215
+ * agent never finds it.
5216
+ */
5217
+ async function findOwnedConnection(client, address) {
5218
+ let cursor;
5219
+ for (let page = 0; page < CONFIRMATION_PAGE_LIMIT; page++) {
5220
+ let value;
5221
+ try {
5222
+ const response = await listAgentConnections({
5223
+ client: client.client,
5224
+ query: {
5225
+ limit: 50,
5226
+ owner: "self",
5227
+ ...cursor ? { cursor } : {}
5228
+ },
5229
+ signal: AbortSignal.timeout(1e4),
5230
+ responseStyle: "fields"
5231
+ });
5232
+ if (response.error) return "unavailable";
5233
+ value = response.data;
5234
+ } catch {
5235
+ return "unavailable";
5236
+ }
5237
+ const envelope = plainRecord(value);
5238
+ if (envelope?.success !== true || !Array.isArray(envelope.data)) return "unavailable";
5239
+ for (const candidate of envelope.data) {
5240
+ const row = plainRecord(candidate);
5241
+ if (row?.address !== address) continue;
5242
+ return {
5243
+ status: String(row.status),
5244
+ ownerAddress: row.owner_address
5245
+ };
5246
+ }
5247
+ const next = plainRecord(envelope.meta)?.cursor;
5248
+ if (next === null) return "absent";
5249
+ if (typeof next !== "string" || !SESSION_UUID.test(next) || next === cursor) return "unavailable";
5250
+ cursor = next;
5251
+ }
5252
+ return "unavailable";
5253
+ }
5254
+ function healAttempts(path) {
5255
+ try {
5256
+ const saved = readMailJson(path);
5257
+ return saved?.version === 1 && Number.isSafeInteger(saved.attempts) ? Number(saved.attempts) : 0;
5258
+ } catch {
5259
+ return HEAL_MAX_ATTEMPTS;
5260
+ }
5261
+ }
5262
+ /**
5263
+ * A stuck enrollment: its address exists and its claim never produced a
5264
+ * saved credential (the claim was never sent, was refused, or its response
5265
+ * was lost). With the owner's member login for the same organization, it
5266
+ * asks Primitive for a fresh invitation for the same address and returns it
5267
+ * for this session to claim. It never creates another address and only
5268
+ * heals a pending connection, with a pending-only invitation. Every refusal
5269
+ * names the one next step.
5270
+ */
5271
+ async function healStuckEnrollment(state, params, saved, directory) {
5272
+ const stuck = stuckEnrollmentStep(state.address);
5273
+ if (saved.org_id !== state.orgId || connectedApiBaseUrl(saved.api_base_url) !== state.apiBaseUrl) throw refuse(stuck);
5274
+ const healPath = join(directory, "heal.json");
5275
+ const attempts = healAttempts(healPath);
5276
+ if (attempts >= HEAL_MAX_ATTEMPTS) throw refuse(stuck);
5277
+ let credentials;
5278
+ try {
5279
+ credentials = await refreshMemberLogin(state.apiBaseUrl, saved, params);
5280
+ } catch (error) {
5281
+ if (credentialLockBusy(error)) throw refuse(`Another Primitive login is still in progress, so this session's agent ${state.address} was not finished yet; rerun this command once that login completes.`);
5282
+ throw refuse(stuck);
5283
+ }
5284
+ if (credentials.org_id !== state.orgId || credentials.api_base_url !== state.apiBaseUrl) throw refuse(stuck);
5285
+ const client = new PrimitiveApiClient({
5286
+ apiKey: credentials.access_token,
5287
+ apiBaseUrl: state.apiBaseUrl,
5288
+ fetch: params.fetch
5289
+ });
5290
+ const row = await findOwnedConnection(client, state.address);
5291
+ if (row === "unavailable") throw refuse(`Primitive could not be reached to finish this session's agent ${state.address}; rerun this command (or start the session again) to retry.`);
5292
+ if (row === "absent" || row.ownerAddress !== state.ownerAddress) throw refuse(stuck);
5293
+ if (row.status === "revoked") throw refuse(reconnectStep(state.address, "was disconnected in the Primitive app"));
5294
+ if (row.status !== "pending") throw refuse(reconnectStep(state.address, row.status === "connected" ? "is connected from somewhere else" : "was claimed by another setup"));
5295
+ const record = (count) => writeMailJson(healPath, {
5296
+ version: 1,
5297
+ attempts: count,
5298
+ lastAttemptAt: new Date((params.now ?? Date.now)()).toISOString()
5299
+ });
5300
+ record(attempts + 1);
5301
+ let claimedElsewhere = false;
5302
+ try {
5303
+ const response = await inviteAgentConnection({
5304
+ client: client.client,
5305
+ path: { address: state.address },
5306
+ body: { pending_only: true },
5307
+ signal: AbortSignal.timeout(25e3),
5308
+ responseStyle: "fields"
5309
+ });
5310
+ if (response.error) {
5311
+ claimedElsewhere = plainRecord(plainRecord(response.error)?.error)?.code === "connection_already_claimed";
5312
+ throw new Error();
5313
+ }
5314
+ const invitation = invitationFrom(response.data, state, (params.now ?? Date.now)());
5315
+ if (invitation.ownerAddress !== state.ownerAddress) throw new Error();
5316
+ return {
5317
+ ...invitation,
5318
+ grantId: credentials.oauth_grant_id
5319
+ };
5320
+ } catch {
5321
+ if (claimedElsewhere) throw refuse(reconnectStep(state.address, "was claimed by another setup"));
5322
+ try {
5323
+ record(HEAL_MAX_ATTEMPTS);
5324
+ } catch {}
5325
+ throw refuse(stuck);
5326
+ }
5327
+ }
5328
+ /** A short code for the registration log, from the refusal it explains. */
5329
+ function failureCode(error) {
5330
+ if (!(error instanceof AgentConnectionSetupError)) return "unexpected_error";
5331
+ const text = error.message;
5332
+ if (text.includes("is created but not connected")) return "setup_unfinished";
5333
+ if (text.includes("choose Reconnect")) return "reconnect_needed";
5334
+ if (text.includes("could not be reached")) return "api_unavailable";
5335
+ if (text.includes("login is still in progress")) return "login_in_progress";
5336
+ if (text.includes("Sign in") || text.includes("sign in")) return "login_required";
5337
+ return "refused";
5338
+ }
5339
+ /**
5340
+ * The address this session's enrollment reserved when it has no saved
5341
+ * credential yet, so a refusal can name the step that finishes it. Null when
5342
+ * there is no such enrollment. Offline and read-only.
5343
+ */
5344
+ function unfinishedEnrollmentAddress(configDir, session) {
5345
+ const profile = `session-${session}`;
5346
+ try {
5347
+ if (loadConnectedAgentProfile(configDir, profile)) return null;
5348
+ const raw = readMailJson(join(agentProfileDirectory(configDir, profile), "enrollment", "state.json"));
5349
+ return raw === null ? null : savedEnrollment(raw).address;
5350
+ } catch {
5351
+ return null;
5352
+ }
5353
+ }
4719
5354
  /** Trusted-machine enrollment using only saved member OAuth and the public API. */
4720
5355
  async function enrollAgent(params) {
4721
5356
  if (!SESSION_UUID.test(params.session)) throw refuse("Enrollment requires the exact current session UUID.");
@@ -4726,24 +5361,58 @@ async function enrollAgent(params) {
4726
5361
  const env = params.env ?? process.env;
4727
5362
  if (env["PRIMITIVE_AGENT_PROFILE"]?.trim() || env.PRIMITIVE_API_KEY?.trim() || env.PRIMITIVE_KEY?.trim()) throw refuse("Enrollment uses only the saved member login; rerun it under `env -u PRIMITIVE_AGENT_PROFILE -u PRIMITIVE_API_KEY -u PRIMITIVE_KEY` so no agent profile or API key override applies.");
4728
5363
  const saved = loadCliCredentials(params.configDir);
4729
- if (!saved) throw refuse("Sign in with `primitive signin` as a member of this organization first.");
5364
+ if (!saved) {
5365
+ const pending = unfinishedEnrollmentAddress(params.configDir, params.session);
5366
+ throw refuse(pending ? stuckEnrollmentStep(pending) : "Sign in with `primitive signin` as a member of this organization first.");
5367
+ }
4730
5368
  const apiBaseUrl = connectedApiBaseUrl(saved.api_base_url);
4731
5369
  const profile = `session-${params.session}`;
4732
5370
  const directory = join(agentProfileDirectory(params.configDir, profile), "enrollment");
4733
5371
  privateMailDirectory(directory, true);
4734
- const release = acquireListenLock(directory, "agent-enrollment");
5372
+ const release = acquireListenLock(directory, ENROLLMENT_LOCK_SCOPE);
5373
+ const progress = {
5374
+ phase: null,
5375
+ orgId: saved.org_id,
5376
+ address: null
5377
+ };
5378
+ const log = (event, extra = {}) => appendRegistrationLog(params.configDir, profile, {
5379
+ event,
5380
+ phase: progress.phase,
5381
+ code: extra.code ?? null,
5382
+ orgId: progress.orgId,
5383
+ address: progress.address,
5384
+ detail: extra.detail ?? null
5385
+ });
5386
+ const track = (current) => {
5387
+ if (!current) return;
5388
+ progress.phase = current.phase;
5389
+ progress.orgId = current.orgId;
5390
+ progress.address = current.address;
5391
+ };
4735
5392
  try {
4736
5393
  await (params.preflight ?? ((session, receiver) => preflight(session, receiver, env)))(params.session, receiverMode);
4737
5394
  await params.beforeCreate?.();
4738
5395
  const path = join(directory, "state.json");
4739
5396
  const old = readMailJson(path);
4740
5397
  let state = old === null ? null : savedEnrollment(old);
5398
+ track(state);
5399
+ log("started");
4741
5400
  if (state && params.name === void 0) name = state.name;
4742
- if (state && (state.session !== params.session || state.profile !== profile || state.name !== name || state.orgId !== saved.org_id || state.grantId !== saved.oauth_grant_id || state.apiBaseUrl !== apiBaseUrl || state.receiverMode !== receiverMode || state.contactRequests !== contactRequests)) throw refuse("This session's enrollment belongs to another owner, address, or setup choice. Nothing was changed.");
4743
- if (state && Date.parse(state.startedAt) > (params.now ?? Date.now)() + 6e4) throw refuse("Saved enrollment time is inconsistent. Preserve its private files.");
5401
+ if (state && (state.session !== params.session || state.profile !== profile || state.name !== name || state.receiverMode !== receiverMode || state.contactRequests !== contactRequests)) throw refuse("This session's enrollment was started with different setup choices (name, receiver or contact requests); rerun it with the original options. Nothing was changed.");
4744
5402
  const existing = loadConnectedAgentProfile(params.configDir, profile);
5403
+ const resumesClaim = Boolean(existing && state?.phase === "setup_attempted");
5404
+ const sameOrganization = state?.orgId === saved.org_id && state.apiBaseUrl === apiBaseUrl;
5405
+ if (state && !sameOrganization && !resumesClaim) throw refuse(state.address && !existing ? stuckEnrollmentStep(state.address) : `This session's enrollment belongs to organization ${state.orgId}, and the saved member login is now for another organization: sign in to ${state.orgId} with \`primitive login\` and rerun this command. Nothing was changed.`);
5406
+ if (state && sameOrganization && state.grantId !== saved.oauth_grant_id) {
5407
+ state = {
5408
+ ...state,
5409
+ grantId: saved.oauth_grant_id
5410
+ };
5411
+ writeMailJson(path, state);
5412
+ }
5413
+ if (state && Date.parse(state.startedAt) > (params.now ?? Date.now)() + 6e4) throw refuse("Saved enrollment time is inconsistent. Preserve its private files.");
4745
5414
  if (existing && (!state || existing.agent_address !== state.address || existing.org_id !== state.orgId || existing.api_base_url !== state.apiBaseUrl || state.phase === "setup_attempted" && (existing.invitation_hash !== state.invitationHash || existing.owner_address !== state.ownerAddress))) throw refuse("This session profile already belongs to another connection. Nothing was changed.");
4746
- const finish = async (enrollment, resume, invitation) => {
5415
+ const finishSetup = async (enrollment, resume, invitation) => {
4747
5416
  const setup = await (params.setup ?? setupAgent)({
4748
5417
  configDir: params.configDir,
4749
5418
  profileName: profile,
@@ -4751,6 +5420,7 @@ async function enrollAgent(params) {
4751
5420
  receiverMode,
4752
5421
  ...resume ? { resume: true } : { invitation },
4753
5422
  contactRequests,
5423
+ enrollmentOwned: true,
4754
5424
  fetch: params.fetch
4755
5425
  });
4756
5426
  const ownerMemberAddress = await refreshOwnerMemberAddress({
@@ -4796,23 +5466,33 @@ async function enrollAgent(params) {
4796
5466
  name: current.name ?? name
4797
5467
  };
4798
5468
  };
5469
+ const finish = async (enrollment, resume, invitation) => {
5470
+ const value = await finishSetup(enrollment, resume, invitation);
5471
+ log("finished", { code: value.connection.status });
5472
+ return value;
5473
+ };
4799
5474
  if (state?.phase === "setup_attempted") {
4800
- if (!existing) throw refuse("The invitation claim may have been consumed. Keep this pending agent and inspect it in the app; do not create or claim another address.");
4801
- return finish(resolvedEnrollment(state), true);
5475
+ if (existing) return await finish(resolvedEnrollment(state), true);
5476
+ const resolved = resolvedEnrollment(state);
5477
+ const healed = await healStuckEnrollment(resolved, params, saved, directory);
5478
+ state = {
5479
+ ...resolved,
5480
+ grantId: healed.grantId,
5481
+ invitationHash: healed.hash,
5482
+ ownerAddress: healed.ownerAddress
5483
+ };
5484
+ writeMailJson(path, state);
5485
+ track(state);
5486
+ log("heal_invited", { code: "pending_only" });
5487
+ return await finish(resolvedEnrollment(state), false, healed.url);
4802
5488
  }
4803
5489
  if (existing) throw refuse("This profile is already connected without a matching enrollment. Nothing was changed.");
4804
- if (state?.version === 1) throw refuse("Connection creation may already have happened. Inspect this session's pending address in the app and issue a fresh invitation there if needed. Use `primitive agent connect` with that invitation and this session's profile; do not rerun enrollment or create another address.");
4805
- const credentials = await refreshStoredCliCredentials({
4806
- apiBaseUrl,
4807
- configDir: params.configDir,
4808
- credentials: saved,
4809
- fetch: params.fetch,
4810
- now: params.now
4811
- });
5490
+ if (state?.version === 1) throw refuse(stuckEnrollmentStep(resolvedEnrollment(state).address));
5491
+ const credentials = await refreshMemberLogin(apiBaseUrl, saved, params);
4812
5492
  if (credentials.org_id !== saved.org_id || credentials.oauth_grant_id !== saved.oauth_grant_id || credentials.api_base_url !== apiBaseUrl) throw refuse("The saved member login changed during enrollment. No address was created. Sign in to the intended organization and retry this exact session.");
4813
5493
  const fetchImpl = params.fetch ?? fetch;
4814
5494
  const firstAttempt = state === null;
4815
- if (state?.continueAttempted) throw refuse("Continue setup already ran and its result may be uncertain. Inspect the saved agent in the app; do not issue another invitation automatically.");
5495
+ if (state?.continueAttempted) throw refuse(stuckEnrollmentStep(resolvedEnrollment(state).address));
4816
5496
  if (!state) {
4817
5497
  if (params.continueSetup) throw refuse("There is no saved enrollment to continue. Start enrollment normally first.");
4818
5498
  if (!(await managedDomains(new PrimitiveApiClient({
@@ -4894,9 +5574,11 @@ async function enrollAgent(params) {
4894
5574
  address: connection.address
4895
5575
  };
4896
5576
  writeMailJson(path, state);
5577
+ track(state);
5578
+ log("created");
4897
5579
  if (data?.recovered === true) {
4898
5580
  if (data.invitation !== null) throw refuse("Recovered setup unexpectedly included an invitation. Preserve the saved identity; no invitation was claimed.");
4899
- if (connection.status !== "pending") throw refuse("The saved agent is already claimed, connected or disconnected. Its credential was not changed. Inspect it in the app; do not automatically reconnect it.");
5581
+ if (connection.status !== "pending") throw refuse(reconnectStep(connection.address, connection.status === "revoked" ? "was disconnected in the Primitive app and its credential was not changed" : "was already claimed and its credential was not changed"));
4900
5582
  if (!params.continueSetup) throw refuse(`Recovered ${state.address}. Run this exact command with --continue-setup to explicitly obtain one pending-only invitation.`);
4901
5583
  state = {
4902
5584
  ...state,
@@ -4916,13 +5598,13 @@ async function enrollAgent(params) {
4916
5598
  body: JSON.stringify({ pending_only: true })
4917
5599
  });
4918
5600
  if (response.status !== 200) {
4919
- if (plainRecord(plainRecord(await boundedJson(response, "pending setup continuation"))?.error)?.code === "connection_already_claimed") throw refuse("This agent was claimed before Continue setup. Its credential was not changed. Inspect it in the app.");
5601
+ if (plainRecord(plainRecord(await boundedJson(response, "pending setup continuation"))?.error)?.code === "connection_already_claimed") throw refuse(reconnectStep(connection.address, "was claimed before Continue setup and its credential was not changed"));
4920
5602
  throw new Error();
4921
5603
  }
4922
5604
  payload = await boundedJson(response);
4923
5605
  } catch (error) {
4924
5606
  if (error instanceof AgentConnectionSetupError) throw error;
4925
- throw refuse("Continue setup has an uncertain outcome. Inspect the saved agent in the app; do not repeat it and invalidate another invitation.");
5607
+ throw refuse(`Continue setup has an uncertain outcome, so it is not repeated automatically. ${stuckEnrollmentStep(connection.address)}`);
4926
5608
  }
4927
5609
  }
4928
5610
  const invitation = invitationFrom(payload, state, (params.now ?? Date.now)());
@@ -4933,7 +5615,21 @@ async function enrollAgent(params) {
4933
5615
  ownerAddress: invitation.ownerAddress
4934
5616
  };
4935
5617
  writeMailJson(path, state);
4936
- return finish(resolvedEnrollment(state), false, invitation.url);
5618
+ track(state);
5619
+ let current = null;
5620
+ try {
5621
+ current = loadCliCredentials(params.configDir);
5622
+ } catch {
5623
+ current = null;
5624
+ }
5625
+ log("setup_attempted", { code: current?.org_id === state.orgId && current.oauth_grant_id === state.grantId ? null : "login_changed" });
5626
+ return await finish(resolvedEnrollment(state), false, invitation.url);
5627
+ } catch (error) {
5628
+ log("failed", {
5629
+ code: failureCode(error),
5630
+ detail: error instanceof AgentConnectionSetupError ? error.message : "Registration stopped on an unexpected error."
5631
+ });
5632
+ throw error;
4937
5633
  } finally {
4938
5634
  release();
4939
5635
  }
@@ -5604,11 +6300,15 @@ async function registerSession(options) {
5604
6300
  } catch {
5605
6301
  credentials = null;
5606
6302
  }
5607
- if (!credentials) return {
5608
- ...known,
5609
- status: "not_logged_in",
5610
- detail: "No saved member login on this machine. Run `primitive signin`, then start the session again."
5611
- };
6303
+ if (!credentials) {
6304
+ const pending = unfinishedEnrollmentAddress(options.configDir, sessionId);
6305
+ return {
6306
+ ...known,
6307
+ ...pending ? { address: pending } : {},
6308
+ status: "not_logged_in",
6309
+ detail: pending ? stuckEnrollmentStep(pending) : "No saved member login on this machine. Run `primitive signin`, then start the session again."
6310
+ };
6311
+ }
5612
6312
  record = await updateSessionRecord(options.configDir, sessionId, (current) => current ?? newRecord("session-register", null));
5613
6313
  if (!record) throw new Error("session record unavailable");
5614
6314
  if (record.endedAt) return await ended(record);
@@ -6072,4 +6772,4 @@ async function runDetached(params) {
6072
6772
  }
6073
6773
  }
6074
6774
  //#endregion
6075
- 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 };
6775
+ export { saveConnectionName as $, ContactPolicyReadRetryError as A, AddressNotesApiError as B, RECEIVER_MODES as C, upsertManagedBlock as Ct, registrationSummary as D, refreshOwnerMemberAddressPeriodically as Dt, failedSessionRegistration as E, addSessionReceiver as Et, canonicalContactSelector as F, contactAgentAddress as G, runAddressNotesRequest as H, AgentDisconnectError as I, agentInvitationHash as J, runContactRequest as K, claimOverRevokedSetup as L, NetworkAdmissionPendingError as M, createNotificationContactPolicy as N, registrationSummaryLine as O, withOwnerMemberAddress as Ot, evaluateContactPolicy as P, refusalAfterReplacement as Q, clearRevokedAddresses as R, runAgentConnect as S, readManagedFile as St, verificationReplySubmitted as T, readHookLauncherWarning as Tt, ContactsApiError as U, addressNoteTarget as V, contactAddress as W, readAgentInvitation as X, connectAgent as Y, receiverStatusCommand as Z, enrollmentResumes as _, samePath as _t, endSession as a, boundSessionProfiles as at, invitationProfileName as b, inspectManagedBlock as bt, pendingSessionDisconnects as c, describeSessionHookChange as ct, registerSession as d, ephemeralCliReason as dt, savedReceiver as et, retryPendingDisconnects as f, findOnPath as ft, enrollAgent as g, repairClaudeHooks as gt, staleSessionAgents as h, readClaudeSettings as ht, connectedProfilesForSession as i, recordHookMailCheck as it, NETWORK_ADMISSION_PENDING_RETRY_MS as j, waitForRegistrations as k, psProcessInfo as l, describeSessionHookItem as lt, runDetached as m, inspectClaudeHooks as mt, OMP_MAIN as n, releaseRegistrationsForProfile as nt, headlessClaudeRun as o, claudeConfigDir as ot, revokeStaleSessionAgents as p, hasMachineSessionHooks as pt, agentConnectionStatus as q, agentConnectionState as r, installClaudeWakeHook as rt, machine_session_exports as s, currentCliLocation as st, HOOK_END_GRACE_MS as t, MACHINE_RUNTIMES as tt, readClaudeHookInput as u, ensureHookLauncher as ut, connectWarnings as v, writeClaudeSettings as vt, externalReceivingState as w, writeManagedFile as wt, pollCheckCommand as x, jsonIndentation as xt, defaultAgentProfileName as y, MachineFileError as yt, disconnectAgent as z };