primcli 1.45.0 → 1.47.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.
@@ -0,0 +1,4754 @@
1
+ import { n as __exportAll } from "./rolldown-runtime-CNZpIYwj.js";
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-DGrZCUfw.js";
3
+ import { $t as getAgentContactPolicy, Dn as listSentEmails, Fn as putContact, Gn as sendEmail, Gt as deleteAgentContact, Hn as searchEmails, Kt as deleteContact, Nn as putAgentContact, Pn as putAgentContactPolicy, Pt as refreshStoredCliCredentials, _n as listContacts, bn as listDomains, en as getContact, hn as listAgentContacts, in as getEmail } from "./api-command-C1JvJqV0.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-SPPcis9g.js";
5
+ import { a as readBundledConnectSkill, h as connectNativeSession, m as SESSION_UUID, n as detectAgentRuntime, o as notificationScope, r as installConnectSkill, u as NotificationRetryError } from "./connect-skill-CH2pq436.js";
6
+ import { l as stopBackgroundListen, r as backgroundListenStatus } from "./listen-background-CyLc8VBn.js";
7
+ import { t as readSharedMailOwner } from "./shared-mail-watch-BUfUaq6j.js";
8
+ import { a as scopedChatSenderTrust } from "./scoped-chat-DhxtuNXs.js";
9
+ import { accessSync, chmodSync, closeSync, constants, copyFileSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
10
+ import { createHash, randomUUID } from "node:crypto";
11
+ import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path";
12
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
13
+ import { execFile, execFileSync, spawn } from "node:child_process";
14
+ import { homedir } from "node:os";
15
+ import { promisify } from "node:util";
16
+ //#region src/oclif/machine-files.ts
17
+ /** Hand-edited files larger than this are reported, never rewritten. */
18
+ const MAX_MANAGED_FILE_BYTES = 1048576;
19
+ /** Backups kept per file; older Primitive backups of the same file are pruned. */
20
+ const BACKUPS_KEPT = 10;
21
+ const BACKUP_INFIX = ".primitive-bak-";
22
+ /** A safe, user-facing reason a file was not changed. */
23
+ var MachineFileError = class extends Error {};
24
+ /**
25
+ * Read a user-owned text file without following it anywhere surprising. A
26
+ * symlink is followed to its real file so a later write updates the file the
27
+ * user linked instead of replacing the link.
28
+ */
29
+ function readManagedFile(path) {
30
+ let info;
31
+ try {
32
+ info = lstatSync(path);
33
+ } catch (error) {
34
+ if (error.code === "ENOENT") return {
35
+ state: "absent",
36
+ path,
37
+ target: path
38
+ };
39
+ return {
40
+ state: "invalid",
41
+ path,
42
+ detail: `${path} cannot be read (${error.code ?? "error"}).`
43
+ };
44
+ }
45
+ let target = path;
46
+ if (info.isSymbolicLink()) try {
47
+ target = realpathSync(path);
48
+ info = statSync(target);
49
+ } catch {
50
+ return {
51
+ state: "invalid",
52
+ path,
53
+ detail: `${path} is a symlink to a missing file; it was left unchanged.`
54
+ };
55
+ }
56
+ if (!info.isFile()) return {
57
+ state: "invalid",
58
+ path,
59
+ detail: `${path} is not a regular file; it was left unchanged.`
60
+ };
61
+ if (info.size > MAX_MANAGED_FILE_BYTES) return {
62
+ state: "invalid",
63
+ path,
64
+ detail: `${path} is larger than 1 MiB; it was left unchanged.`
65
+ };
66
+ let raw;
67
+ try {
68
+ raw = readFileSync(target);
69
+ } catch (error) {
70
+ return {
71
+ state: "invalid",
72
+ path,
73
+ detail: `${path} cannot be read (${error.code ?? "error"}).`
74
+ };
75
+ }
76
+ const text = raw.toString("utf8");
77
+ if (text.includes("\0") || !Buffer.from(text, "utf8").equals(raw)) return {
78
+ state: "invalid",
79
+ path,
80
+ detail: `${path} is not UTF-8 text; it was left unchanged.`
81
+ };
82
+ return {
83
+ state: "present",
84
+ path,
85
+ target,
86
+ text,
87
+ mode: info.mode & 511
88
+ };
89
+ }
90
+ function backupStamp(now) {
91
+ return now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
92
+ }
93
+ function syncDirectory(directory) {
94
+ if (process.platform === "win32") return;
95
+ try {
96
+ const fd = openSync(directory, "r");
97
+ try {
98
+ fsyncSync(fd);
99
+ } finally {
100
+ closeSync(fd);
101
+ }
102
+ } catch {}
103
+ }
104
+ function pruneBackups(target) {
105
+ const directory = dirname(target);
106
+ const prefix = `${basename(target)}${BACKUP_INFIX}`;
107
+ try {
108
+ const backups = readdirSync(directory).filter((name) => name.startsWith(prefix)).sort();
109
+ for (const name of backups.slice(0, Math.max(0, backups.length - BACKUPS_KEPT))) unlinkSync(join(directory, name));
110
+ } catch {}
111
+ }
112
+ /** Copy the current file beside itself before any change. Returns the copy's path. */
113
+ function backupManagedFile(target, mode, now) {
114
+ const base = `${target}${BACKUP_INFIX}${backupStamp(now)}`;
115
+ for (let attempt = 0; attempt < 100; attempt++) {
116
+ const candidate = attempt === 0 ? base : `${base}-${attempt}`;
117
+ try {
118
+ copyFileSync(target, candidate, constants.COPYFILE_EXCL);
119
+ chmodSync(candidate, mode);
120
+ return candidate;
121
+ } catch (error) {
122
+ if (error.code !== "EEXIST") throw error;
123
+ }
124
+ }
125
+ throw new MachineFileError(`Could not create a backup of ${target}.`);
126
+ }
127
+ /**
128
+ * Replace a file atomically (temporary file plus rename in the same
129
+ * directory), after backing up the existing content. The write is refused when
130
+ * the file changed since it was read, so a concurrent edit is never lost.
131
+ */
132
+ function writeManagedFile(params) {
133
+ const { read } = params;
134
+ const target = read.target;
135
+ const expected = read.state === "present" ? read.text : null;
136
+ const directory = dirname(target);
137
+ mkdirSync(directory, {
138
+ recursive: true,
139
+ mode: 448
140
+ });
141
+ const current = () => {
142
+ try {
143
+ return readFileSync(target, "utf8");
144
+ } catch (error) {
145
+ if (error.code === "ENOENT") return null;
146
+ throw error;
147
+ }
148
+ };
149
+ if (current() !== expected) throw new MachineFileError(`${read.path} changed while it was being repaired; it was left unchanged.`);
150
+ const mode = read.state === "present" ? read.mode : params.newFileMode ?? 420;
151
+ const backup = read.state === "present" ? backupManagedFile(target, mode, (params.now ?? (() => /* @__PURE__ */ new Date()))()) : null;
152
+ const temporary = join(directory, `.${basename(target)}.${randomUUID()}.tmp`);
153
+ try {
154
+ const fd = openSync(temporary, "wx", mode);
155
+ try {
156
+ writeFileSync(fd, params.content, "utf8");
157
+ fsyncSync(fd);
158
+ } finally {
159
+ closeSync(fd);
160
+ }
161
+ chmodSync(temporary, mode);
162
+ if (current() !== expected) throw new MachineFileError(`${read.path} changed while it was being repaired; it was left unchanged.`);
163
+ renameSync(temporary, target);
164
+ syncDirectory(directory);
165
+ } finally {
166
+ if (existsSync(temporary)) unlinkSync(temporary);
167
+ }
168
+ if (backup) pruneBackups(target);
169
+ return { backup };
170
+ }
171
+ const START = /^<!-- primitive:managed-block v=(\d+) START -->\r?$/;
172
+ const END = /^<!-- primitive:managed-block END -->\r?$/;
173
+ /** Character offsets of the single managed block, or why the markers are unusable. */
174
+ function locateBlock(text) {
175
+ const starts = [];
176
+ const ends = [];
177
+ let offset = 0;
178
+ for (const line of text.split("\n")) {
179
+ const start = START.exec(line);
180
+ if (start) starts.push({
181
+ offset,
182
+ version: Number(start[1])
183
+ });
184
+ else if (END.test(line)) ends.push({
185
+ offset,
186
+ length: line.endsWith("\r") ? line.length - 1 : line.length
187
+ });
188
+ else if (line.includes("primitive:managed-block")) return { malformed: "a primitive:managed-block marker line was edited; fix or remove it by hand" };
189
+ offset += line.length + 1;
190
+ }
191
+ if (starts.length === 0 && ends.length === 0) return null;
192
+ if (starts.length !== 1 || ends.length !== 1) return { malformed: `found ${starts.length} START and ${ends.length} END markers; expected one of each` };
193
+ const [start] = starts;
194
+ const [end] = ends;
195
+ if (!start || !end || end.offset < start.offset) return { malformed: "the END marker comes before the START marker" };
196
+ return {
197
+ start: start.offset,
198
+ end: end.offset + end.length,
199
+ version: start.version
200
+ };
201
+ }
202
+ /** The exact block for a body, using the file's own line ending. */
203
+ function renderManagedBlock(body, eol = "\n") {
204
+ return [
205
+ `<!-- primitive:managed-block v=1 START -->`,
206
+ ...body.replace(/\r\n/g, "\n").replace(/\n+$/, "").split("\n"),
207
+ "<!-- primitive:managed-block END -->"
208
+ ].join(eol);
209
+ }
210
+ function lineEnding(text) {
211
+ return text.includes("\r\n") ? "\r\n" : "\n";
212
+ }
213
+ function inspectManagedBlock(text, body) {
214
+ const found = locateBlock(text);
215
+ if (found === null) return { state: "missing" };
216
+ if ("malformed" in found) return {
217
+ state: "malformed",
218
+ detail: found.malformed
219
+ };
220
+ return text.slice(found.start, found.end).replace(/\r\n/g, "\n") === renderManagedBlock(body) ? { state: "ok" } : {
221
+ state: "outdated",
222
+ version: found.version
223
+ };
224
+ }
225
+ /**
226
+ * Replace only the managed block, or append it when absent. Text outside the
227
+ * markers is preserved byte for byte. Throws for malformed markers.
228
+ */
229
+ function upsertManagedBlock(text, body) {
230
+ const found = locateBlock(text);
231
+ if (found !== null && "malformed" in found) throw new MachineFileError(found.malformed);
232
+ const eol = lineEnding(text);
233
+ const block = renderManagedBlock(body, eol);
234
+ if (found) return `${text.slice(0, found.start)}${block}${text.slice(found.end)}`;
235
+ if (text.length === 0) return `${block}${eol}`;
236
+ return `${text}${text.endsWith(`${eol}${eol}`) ? "" : text.endsWith(eol) ? eol : `${eol}${eol}`}${block}${eol}`;
237
+ }
238
+ /** Indentation the file already uses, so a rewrite keeps its style. */
239
+ function jsonIndentation(text) {
240
+ return /^[{[][^\n]*\n([ \t]+)\S/.exec(text)?.[1] ?? 2;
241
+ }
242
+ //#endregion
243
+ //#region src/oclif/claude-machine-hooks.ts
244
+ function record$2(value) {
245
+ return value !== null && typeof value === "object" && !Array.isArray(value);
246
+ }
247
+ function realpathOrNull(path) {
248
+ try {
249
+ return realpathSync(path);
250
+ } catch {
251
+ return null;
252
+ }
253
+ }
254
+ /** Two paths name the same existing file. */
255
+ function samePath(a, b) {
256
+ if (typeof a !== "string" || !a) return false;
257
+ if (a === b) return existsSync(a);
258
+ const left = realpathOrNull(a);
259
+ return left !== null && left === realpathOrNull(b);
260
+ }
261
+ /**
262
+ * A hook's Node command is current when it is the chosen Node path itself,
263
+ * or another link to the same binary. A command that pins the versioned file
264
+ * behind the stable link stops working once an upgrade replaces that file,
265
+ * so it is not current and gets rewritten to the stable path.
266
+ */
267
+ function nodeCommandCurrent(command, node) {
268
+ if (!samePath(command, node)) return false;
269
+ if (command === node) return true;
270
+ return realpathOrNull(String(command)) !== command;
271
+ }
272
+ function executable(path) {
273
+ try {
274
+ accessSync(path, constants.X_OK);
275
+ return true;
276
+ } catch {
277
+ return false;
278
+ }
279
+ }
280
+ /** The first PATH entry for a command, without running a shell. */
281
+ function findOnPath(name, env) {
282
+ for (const directory of (env.PATH ?? "").split(delimiter)) {
283
+ if (!directory) continue;
284
+ const candidate = join(directory, name);
285
+ if (existsSync(candidate) && executable(candidate)) return candidate;
286
+ }
287
+ return null;
288
+ }
289
+ /**
290
+ * Prefer a PATH link (for example a package manager's `bin/node`) over a
291
+ * versioned install directory when both are the same binary, so a hook keeps
292
+ * working after the versioned directory is replaced by an upgrade.
293
+ */
294
+ function stableNodePath(env, execPath = process.execPath) {
295
+ const onPath = findOnPath("node", env);
296
+ if (onPath && isAbsolute(onPath) && onPath !== execPath && samePath(onPath, execPath)) return onPath;
297
+ return execPath;
298
+ }
299
+ /** Locate the running CLI. Null when its files are not where a package install puts them. */
300
+ function currentCliLocation(entryArg, env, execPath = process.execPath) {
301
+ const entry = realpathOrNull(entryArg);
302
+ if (!entry) return null;
303
+ const wake = realpathOrNull(join(dirname(entry), "claude-wake.mjs"));
304
+ const pending = realpathOrNull(join(dirname(entry), "claude-pending-mail.mjs"));
305
+ if (!wake || !pending) return null;
306
+ return {
307
+ node: stableNodePath(env, execPath),
308
+ entry,
309
+ wake,
310
+ pending
311
+ };
312
+ }
313
+ /** Package-runner caches are cleaned up behind the user's back. */
314
+ function ephemeralCliReason(entry) {
315
+ if (/[\\/]_npx[\\/]/.test(entry)) return "an npx cache";
316
+ if (/[\\/]dlx[-\\/]/.test(entry)) return "a pnpm dlx cache";
317
+ if (/[\\/]bunx-/.test(entry)) return "a bunx cache";
318
+ return null;
319
+ }
320
+ function claudeConfigDir(env, home) {
321
+ return resolve(env.CLAUDE_CONFIG_DIR || join(home, ".claude"));
322
+ }
323
+ function jsonErrorDetail(error) {
324
+ const message = error instanceof Error ? error.message : "";
325
+ const position = /position (\d+)/.exec(message)?.[1];
326
+ const line = /line (\d+)/.exec(message)?.[1];
327
+ return line ? ` near line ${line}` : position ? ` near character ${position}` : "";
328
+ }
329
+ /** Parse settings.json. Anything unexpected is reported, never repaired. */
330
+ function readClaudeSettings(claudeDir) {
331
+ const path = join(claudeDir, "settings.json");
332
+ const read = readManagedFile(path);
333
+ if (read.state === "invalid") return {
334
+ ok: false,
335
+ detail: read.detail
336
+ };
337
+ if (read.state === "absent") return {
338
+ ok: true,
339
+ read,
340
+ settings: {}
341
+ };
342
+ let parsed;
343
+ try {
344
+ parsed = JSON.parse(read.text);
345
+ } catch (error) {
346
+ return {
347
+ ok: false,
348
+ detail: `${path} is not valid JSON${jsonErrorDetail(error)}. Fix it by hand; it was not modified.`
349
+ };
350
+ }
351
+ if (!record$2(parsed)) return {
352
+ ok: false,
353
+ detail: `${path} must contain a JSON object. Fix it by hand; it was not modified.`
354
+ };
355
+ if (parsed.hooks !== void 0 && !record$2(parsed.hooks)) return {
356
+ ok: false,
357
+ detail: `${path} has a "hooks" value that is not an object. Fix it by hand; it was not modified.`
358
+ };
359
+ return {
360
+ ok: true,
361
+ read,
362
+ settings: parsed
363
+ };
364
+ }
365
+ const LOCK_WAIT$1 = new Int32Array(new SharedArrayBuffer(4));
366
+ /**
367
+ * Write settings under the same lock every Primitive hook installer uses,
368
+ * keeping the file's indentation, after a timestamped backup.
369
+ */
370
+ function writeClaudeSettings(params) {
371
+ let release;
372
+ for (let attempt = 0; attempt < 80; attempt++) try {
373
+ release = acquireListenLock(params.claudeDir, "primitive-claude-settings");
374
+ break;
375
+ } catch {
376
+ Atomics.wait(LOCK_WAIT$1, 0, 0, 25);
377
+ }
378
+ if (!release) throw new MachineFileError("Another Primitive command is editing Claude settings; try again shortly.");
379
+ try {
380
+ const original = params.read.state === "present" ? params.read.text : null;
381
+ const indent = original === null ? 2 : jsonIndentation(original);
382
+ const trailing = original === null || original.endsWith("\n") ? "\n" : "";
383
+ return writeManagedFile({
384
+ read: params.read,
385
+ content: `${JSON.stringify(params.settings, null, indent)}${trailing}`,
386
+ now: params.now,
387
+ newFileMode: 384
388
+ });
389
+ } finally {
390
+ release();
391
+ }
392
+ }
393
+ const STATE_PHRASES = {
394
+ missing: "missing",
395
+ stale: "session disconnected or removed",
396
+ duplicate: "duplicate",
397
+ outdated: "old CLI path",
398
+ old_address: "old agent address"
399
+ };
400
+ /** "profile my-agent, session 1234...": who a session hook serves. */
401
+ function describeSessionHookOwner(item) {
402
+ const profile = item.profile ? `profile ${item.profile}` : "a profile an old CLI did not record";
403
+ return item.session ? `${profile}, session ${item.session}` : profile;
404
+ }
405
+ /** One short line naming a hook, its owner and its state. */
406
+ function describeSessionHookItem(item) {
407
+ return `${item.hook} hook for ${describeSessionHookOwner(item)}: ${STATE_PHRASES[item.state]}`;
408
+ }
409
+ /** One short line naming what a repair did to a hook. */
410
+ function describeSessionHookChange(change) {
411
+ return `${change.action === "added" ? "Added" : change.action === "removed" ? "Removed" : "Rewrote"} ${change.hook} hook for ${describeSessionHookOwner(change)} (${STATE_PHRASES[change.state]})`;
412
+ }
413
+ /** Group items by owner, at most `limit` owners, for a one-line detail. */
414
+ function summarizeOwners(items, limit = 3) {
415
+ const owners = [];
416
+ for (const item of items) {
417
+ const owner = describeSessionHookOwner(item);
418
+ if (!owners.includes(owner)) owners.push(owner);
419
+ }
420
+ const shown = owners.slice(0, limit).join("; ");
421
+ return owners.length > limit ? `${shown}; and ${owners.length - limit} more` : shown;
422
+ }
423
+ const GLOBAL_HOOKS = {
424
+ "claude.hook.session_start": {
425
+ event: "SessionStart",
426
+ subcommand: "session-register",
427
+ timeout: 15
428
+ },
429
+ "claude.hook.session_end": {
430
+ event: "SessionEnd",
431
+ subcommand: "session-end",
432
+ timeout: 10
433
+ }
434
+ };
435
+ /** A hook this CLI installed for every Claude session, from any CLI path or version. */
436
+ function isGlobalManaged(hook, spec) {
437
+ if (!record$2(hook) || hook.type !== "command" || !Array.isArray(hook.args)) return false;
438
+ const args = hook.args;
439
+ return args.length === 6 && typeof args[0] === "string" && args[1] === "agent" && args[2] === spec.subcommand && args[3] === "--runtime" && args[4] === "claude" && args[5] === "--hook";
440
+ }
441
+ function globalHookArgs(cli, spec) {
442
+ return [
443
+ cli.entry,
444
+ "agent",
445
+ spec.subcommand,
446
+ "--runtime",
447
+ "claude",
448
+ "--hook"
449
+ ];
450
+ }
451
+ function globalHookCurrent(hook, cli) {
452
+ const args = hook.args;
453
+ return nodeCommandCurrent(hook.command, cli.node) && samePath(args[0], cli.entry);
454
+ }
455
+ const WAKE_MARKER = "primitive-agent-wake-v1";
456
+ const PENDING_MARKER$1 = "primitive-pending-mail-v1";
457
+ const SESSION_EVENTS = [
458
+ "Stop",
459
+ "SessionStart",
460
+ "PostToolUse"
461
+ ];
462
+ /** Which receive hook each event carries. */
463
+ const SESSION_HOOK_KIND = {
464
+ Stop: "wake",
465
+ SessionStart: "wake",
466
+ PostToolUse: "pending"
467
+ };
468
+ /**
469
+ * The one test of whether an event already carries a current receive hook
470
+ * for this exact profile, address and session, run by the Node binary at
471
+ * `node` (compared as files). `agent connect --status` and machine doctor
472
+ * both use it, so they never disagree about a hook being present.
473
+ */
474
+ function hasSessionReceiveHook(hooks, event, target, node) {
475
+ const entries = hooks[event];
476
+ if (!Array.isArray(entries)) return false;
477
+ const pending = SESSION_HOOK_KIND[event] === "pending";
478
+ const configDir = resolve(target.configDir);
479
+ const session = target.session.toLowerCase();
480
+ return entries.some((entry) => record$2(entry) && Array.isArray(entry.hooks) && entry.hooks.some((hook) => {
481
+ if (!record$2(hook) || hook.type !== "command") return false;
482
+ const args = hook.args;
483
+ 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) && samePath(hook.command, node);
484
+ }));
485
+ }
486
+ /** A per-session receive hook written by `agent connect` or `agent enroll`, any version. */
487
+ function parseSessionHook(hook) {
488
+ if (!record$2(hook) || hook.type !== "command" || !Array.isArray(hook.args)) return null;
489
+ const args = hook.args;
490
+ if (!args.every((value) => typeof value === "string")) return null;
491
+ const strings = args;
492
+ const script = basename(strings[0] ?? "");
493
+ if (script === "claude-pending-mail.mjs") return strings.length === 7 && strings[6] === PENDING_MARKER$1 ? {
494
+ kind: "pending",
495
+ configDir: strings[2] ?? "",
496
+ profile: strings[3] ?? null,
497
+ address: strings[4] ?? null,
498
+ session: strings[5] ?? null
499
+ } : null;
500
+ if (script !== "claude-wake.mjs") return null;
501
+ if (strings.length === 7 && strings[6] === WAKE_MARKER) return {
502
+ kind: "wake",
503
+ configDir: strings[2] ?? "",
504
+ profile: strings[3] ?? null,
505
+ address: strings[4] ?? null,
506
+ session: strings[5] ?? null
507
+ };
508
+ if (strings.length === 6 && strings[5] === WAKE_MARKER) return {
509
+ kind: "wake",
510
+ configDir: strings[2] ?? "",
511
+ profile: strings[3] ?? null,
512
+ address: strings[4] ?? null,
513
+ session: null
514
+ };
515
+ if (strings.length === 4 && strings[3] === WAKE_MARKER) return {
516
+ kind: "wake",
517
+ configDir: strings[2] ?? "",
518
+ profile: null,
519
+ address: null,
520
+ session: null
521
+ };
522
+ return null;
523
+ }
524
+ /** A saved connected profile still holds a credential. Unreadable counts as live. */
525
+ function profileStillConnected(configDir, profile) {
526
+ try {
527
+ return loadConnectedAgentProfile(configDir, profile) !== null;
528
+ } catch {
529
+ return true;
530
+ }
531
+ }
532
+ function sessionHookPathsCurrent(hook, parsed, cli) {
533
+ const args = hook.args;
534
+ if (!cli) return existsSync(String(hook.command)) && existsSync(args[0] ?? "") && existsSync(args[1] ?? "");
535
+ return nodeCommandCurrent(hook.command, cli.node) && samePath(args[0], parsed.kind === "wake" ? cli.wake : cli.pending) && samePath(args[1], cli.entry);
536
+ }
537
+ function judgeSessionHooks(hooks, cli, bound = []) {
538
+ const verdicts = /* @__PURE__ */ new Map();
539
+ const seen = /* @__PURE__ */ new Set();
540
+ for (const event of SESSION_EVENTS) {
541
+ const entries = hooks[event];
542
+ if (!Array.isArray(entries)) continue;
543
+ for (const entry of entries) {
544
+ if (!record$2(entry) || !Array.isArray(entry.hooks)) continue;
545
+ for (const hook of entry.hooks) {
546
+ const parsed = parseSessionHook(hook);
547
+ if (!parsed || !record$2(hook)) continue;
548
+ const pathsExist = existsSync(String(hook.command)) && existsSync(String(hook.args[0])) && existsSync(String(hook.args[1]));
549
+ if (!(parsed.profile ? existsSync(parsed.configDir) && profileStillConnected(parsed.configDir, parsed.profile) : existsSync(parsed.configDir) && pathsExist)) {
550
+ verdicts.set(hook, "stale");
551
+ continue;
552
+ }
553
+ const current = bound.find((item) => item.configDir === resolve(parsed.configDir) && item.profile === parsed.profile && item.session === parsed.session?.toLowerCase());
554
+ if (current && parsed.address !== current.address) {
555
+ verdicts.set(hook, "old_address");
556
+ continue;
557
+ }
558
+ const key = [
559
+ event,
560
+ parsed.kind,
561
+ resolve(parsed.configDir),
562
+ parsed.profile ?? "",
563
+ parsed.session?.toLowerCase() ?? ""
564
+ ].join("\0");
565
+ if (seen.has(key)) {
566
+ verdicts.set(hook, "duplicate");
567
+ continue;
568
+ }
569
+ seen.add(key);
570
+ verdicts.set(hook, sessionHookPathsCurrent(hook, parsed, cli) ? "keep" : "outdated");
571
+ }
572
+ }
573
+ }
574
+ return verdicts;
575
+ }
576
+ function eventEntries(hooks, event) {
577
+ const entries = hooks[event];
578
+ if (entries === void 0) return [];
579
+ return Array.isArray(entries) ? entries : "invalid";
580
+ }
581
+ /**
582
+ * Connected profiles whose setup binds an external (hook) receiver to a
583
+ * Claude session that this machine has not recorded as ended.
584
+ */
585
+ function boundSessionProfiles(configDir) {
586
+ const root = resolve(configDir);
587
+ let names;
588
+ try {
589
+ names = readdirSync(join(agentProfilesDirectory(root), "profiles")).sort();
590
+ } catch {
591
+ return [];
592
+ }
593
+ const bound = [];
594
+ for (const name of names) try {
595
+ agentProfileName(name);
596
+ const setup = readMailJson(join(agentProfileDirectory(root, name), "setup.json"));
597
+ if (setup?.receiverMode !== "external" || typeof setup.session !== "string" || !SESSION_UUID.test(setup.session)) continue;
598
+ const session = setup.session.toLowerCase();
599
+ if (typeof readMailJson(join(root, "machine", "sessions", `${session}.json`))?.endedAt === "string") continue;
600
+ const profile = loadConnectedAgentProfile(root, name);
601
+ if (profile) bound.push({
602
+ configDir: root,
603
+ profile: name,
604
+ address: profile.agent_address,
605
+ session
606
+ });
607
+ } catch {}
608
+ return bound;
609
+ }
610
+ function boundKey(item) {
611
+ return [
612
+ resolve(item.configDir),
613
+ item.profile,
614
+ item.session
615
+ ].join("\0");
616
+ }
617
+ /**
618
+ * Bound profiles whose receive hooks are entirely absent while the same
619
+ * session receives through another connected profile, or while several profiles are
620
+ * bound to it and none receives. Such a profile is not restored without
621
+ * being named: its hooks were removed on purpose, or it would give the
622
+ * session a second address. A profile that still has any receive hook for
623
+ * the session, or the only profile bound to a session, is restored as usual.
624
+ */
625
+ /**
626
+ * For each session, the connected profiles it receives as through the
627
+ * per-session receive hooks in `hooks`, keyed like `boundKey`. A hook for a
628
+ * disconnected or removed profile is stale (machine doctor removes it), so
629
+ * the session does not receive through it.
630
+ */
631
+ function receivingProfilesBySession(hooks, always) {
632
+ const receiving = /* @__PURE__ */ new Map();
633
+ for (const event of SESSION_EVENTS) {
634
+ const entries = hooks[event];
635
+ if (!Array.isArray(entries)) continue;
636
+ for (const entry of entries) {
637
+ if (!record$2(entry) || !Array.isArray(entry.hooks)) continue;
638
+ for (const hook of entry.hooks) {
639
+ const parsed = parseSessionHook(hook);
640
+ if (!parsed?.profile || !parsed.session) continue;
641
+ const session = parsed.session.toLowerCase();
642
+ const key = [
643
+ resolve(parsed.configDir),
644
+ parsed.profile,
645
+ session
646
+ ].join("\0");
647
+ if (key !== always && (!existsSync(parsed.configDir) || !profileStillConnected(parsed.configDir, parsed.profile))) continue;
648
+ const profiles = receiving.get(session) ?? /* @__PURE__ */ new Set();
649
+ profiles.add(key);
650
+ receiving.set(session, profiles);
651
+ }
652
+ }
653
+ }
654
+ return receiving;
655
+ }
656
+ /**
657
+ * The other connected profiles a session already receives as, when `target`
658
+ * itself has no receive hook for it. Adding `target`'s hooks then would give
659
+ * the session a second address; an empty list means it would not (the
660
+ * session receives as nobody else, or `target` already has hooks there and
661
+ * only needs them repaired). Uses the same rule as machine doctor, so an
662
+ * automatic install never binds a profile doctor would leave alone.
663
+ */
664
+ function otherSessionReceivers(hooks, target) {
665
+ const session = target.session.toLowerCase();
666
+ const own = [
667
+ resolve(target.configDir),
668
+ target.profile,
669
+ session
670
+ ].join("\0");
671
+ const present = receivingProfilesBySession(hooks, own).get(session) ?? /* @__PURE__ */ new Set();
672
+ if (present.has(own)) return [];
673
+ return [...present].map((other) => other.split("\0")[1] ?? "").sort();
674
+ }
675
+ function heldSessionProfiles(hooks, bound, restore) {
676
+ const receiving = receivingProfilesBySession(hooks);
677
+ const held = /* @__PURE__ */ new Map();
678
+ for (const item of bound) {
679
+ const key = boundKey(item);
680
+ const present = receiving.get(item.session) ?? /* @__PURE__ */ new Set();
681
+ if (present.has(key) || restore?.has(item.profile)) continue;
682
+ const others = [...present].map((other) => other.split("\0")[1] ?? "");
683
+ const siblings = bound.filter((candidate) => candidate.session === item.session);
684
+ if (others.length > 0 || siblings.length > 1) held.set(key, {
685
+ item,
686
+ receivingAs: others.sort()
687
+ });
688
+ }
689
+ return held;
690
+ }
691
+ /** Receive hooks each bound profile should have and does not. */
692
+ function missingSessionHooks(hooks, bound, node) {
693
+ return bound.flatMap((item) => SESSION_EVENTS.filter((event) => !hasSessionReceiveHook(hooks, event, item, node)).map((event) => ({
694
+ event,
695
+ bound: item
696
+ })));
697
+ }
698
+ /** Every hook judged for repair, in settings order, then every missing one. */
699
+ function sessionHookItems(hooks, verdicts, missing) {
700
+ const items = missing.map(({ event, bound }) => ({
701
+ profile: bound.profile,
702
+ session: bound.session,
703
+ hook: event,
704
+ state: "missing"
705
+ }));
706
+ for (const event of SESSION_EVENTS) {
707
+ const entries = hooks[event];
708
+ if (!Array.isArray(entries)) continue;
709
+ for (const entry of entries) {
710
+ if (!record$2(entry) || !Array.isArray(entry.hooks)) continue;
711
+ for (const hook of entry.hooks) {
712
+ const verdict = verdicts.get(hook);
713
+ const parsed = parseSessionHook(hook);
714
+ if (!verdict || verdict === "keep" || !parsed) continue;
715
+ items.push({
716
+ profile: parsed.profile,
717
+ session: parsed.session?.toLowerCase() ?? null,
718
+ hook: event,
719
+ state: verdict
720
+ });
721
+ }
722
+ }
723
+ }
724
+ return items;
725
+ }
726
+ /** The exact entry `agent connect` and `session-register` install. */
727
+ function sessionHookEntry(event, item, cli) {
728
+ const pending = SESSION_HOOK_KIND[event] === "pending";
729
+ const hook = {
730
+ type: "command",
731
+ command: cli.node,
732
+ args: [
733
+ pending ? cli.pending : cli.wake,
734
+ cli.entry,
735
+ item.configDir,
736
+ item.profile,
737
+ item.address,
738
+ item.session,
739
+ pending ? PENDING_MARKER$1 : WAKE_MARKER
740
+ ],
741
+ ...pending ? { timeout: 10 } : {
742
+ asyncRewake: true,
743
+ timeout: 604800
744
+ }
745
+ };
746
+ return event === "SessionStart" ? {
747
+ matcher: "resume",
748
+ hooks: [hook]
749
+ } : { hooks: [hook] };
750
+ }
751
+ /** Inspect every Primitive-owned Claude hook without changing anything. */
752
+ function inspectClaudeHooks(settings, context) {
753
+ const hooks = record$2(settings.hooks) ? settings.hooks : {};
754
+ const canFix = context.cli !== null && context.blocked === null;
755
+ const blockedNote = context.blocked ? ` ${context.blocked}` : "";
756
+ const findings = {};
757
+ for (const [id, spec] of Object.entries(GLOBAL_HOOKS)) {
758
+ const entries = eventEntries(hooks, spec.event);
759
+ if (entries === "invalid") {
760
+ findings[id] = {
761
+ status: "fail",
762
+ detail: `hooks.${spec.event} is not a list. Fix settings.json by hand; it was not modified.`,
763
+ fixable: false
764
+ };
765
+ continue;
766
+ }
767
+ const managed = entries.flatMap((entry) => record$2(entry) && Array.isArray(entry.hooks) ? entry.hooks.filter((hook) => isGlobalManaged(hook, spec)) : []);
768
+ const [first] = managed;
769
+ if (!first) findings[id] = {
770
+ status: "fail",
771
+ detail: `No ${spec.event} hook runs \`primitive agent ${spec.subcommand}\`.${blockedNote}`,
772
+ fixable: canFix
773
+ };
774
+ else if (managed.length > 1) findings[id] = {
775
+ status: "fail",
776
+ detail: `${managed.length} ${spec.event} hooks run \`primitive agent ${spec.subcommand}\`; expected exactly one.${blockedNote}`,
777
+ fixable: canFix
778
+ };
779
+ else if (!context.cli || !globalHookCurrent(first, context.cli)) findings[id] = {
780
+ status: "fail",
781
+ detail: `The ${spec.event} hook runs ${String(first.args[0])}, which ${existsSync(String(first.args[0])) ? "is not this CLI" : "no longer exists"}.${blockedNote}`,
782
+ fixable: canFix
783
+ };
784
+ else findings[id] = {
785
+ status: "ok",
786
+ detail: `${spec.event} runs \`primitive agent ${spec.subcommand}\` from ${context.cli.entry}.`,
787
+ fixable: false
788
+ };
789
+ }
790
+ const invalid = SESSION_EVENTS.find((event) => eventEntries(hooks, event) === "invalid");
791
+ if (invalid) {
792
+ findings["claude.hook.stop"] = {
793
+ status: "fail",
794
+ detail: `hooks.${invalid} is not a list. Fix settings.json by hand; it was not modified.`,
795
+ fixable: false
796
+ };
797
+ return findings;
798
+ }
799
+ const held = heldSessionProfiles(hooks, context.bound ?? [], context.restoreProfiles);
800
+ const bound = (context.bound ?? []).filter((item) => !held.has(boundKey(item)));
801
+ const heldNote = held.size ? ` Left alone: ${[...held.values()].map(({ item, receivingAs }) => `profile ${item.profile} is bound to session ${item.session}, which ${receivingAs.length ? `receives as ${receivingAs.join(", ")}` : "has several bound profiles and receives as none"}`).join("; ")}. Run \`primitive machine doctor --fix --profile <profile>\` to restore one deliberately.` : "";
802
+ const counts = {
803
+ keep: 0,
804
+ stale: 0,
805
+ duplicate: 0,
806
+ outdated: 0,
807
+ old_address: 0
808
+ };
809
+ const verdicts = judgeSessionHooks(hooks, context.cli, bound);
810
+ for (const verdict of verdicts.values()) counts[verdict]++;
811
+ const missingHooks = missingSessionHooks(hooks, bound, context.cli?.node ?? process.execPath);
812
+ const missing = missingHooks.length;
813
+ const items = sessionHookItems(hooks, verdicts, missingHooks);
814
+ const problems = [
815
+ missing ? `${missing} missing for connected sessions` : "",
816
+ counts.old_address ? `${counts.old_address} naming an old agent address` : "",
817
+ counts.stale ? `${counts.stale} for disconnected or removed sessions` : "",
818
+ counts.duplicate ? `${counts.duplicate} duplicates` : "",
819
+ counts.outdated ? `${counts.outdated} pointing at an old CLI path` : ""
820
+ ].filter(Boolean);
821
+ findings["claude.hook.stop"] = problems.length ? {
822
+ status: "fail",
823
+ detail: `Per-session receive hooks need cleanup: ${problems.join(", ")}. Affected: ${summarizeOwners(items)}.${blockedNote}${heldNote}`,
824
+ fixable: canFix || counts.outdated === 0 && missing === 0 && context.blocked === null,
825
+ items
826
+ } : {
827
+ status: "ok",
828
+ 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}`,
829
+ fixable: false
830
+ };
831
+ return findings;
832
+ }
833
+ function rewriteEntries(entries, transform) {
834
+ let changed = false;
835
+ return {
836
+ entries: entries.flatMap((entry) => {
837
+ if (!record$2(entry) || !Array.isArray(entry.hooks)) return [entry];
838
+ let touched = false;
839
+ const kept = entry.hooks.flatMap((hook) => {
840
+ const replacement = transform(hook);
841
+ if (replacement === hook) return [hook];
842
+ touched = true;
843
+ return replacement === void 0 ? [] : [replacement];
844
+ });
845
+ if (!touched) return [entry];
846
+ changed = true;
847
+ return kept.length ? [{
848
+ ...entry,
849
+ hooks: kept
850
+ }] : [];
851
+ }),
852
+ changed
853
+ };
854
+ }
855
+ /**
856
+ * Apply fixes for the requested checks to a copy of the settings. Only
857
+ * Primitive-owned hooks are added, rewritten or removed; every other key,
858
+ * entry and sibling hook is carried over unchanged.
859
+ */
860
+ function repairClaudeHooks(settings, context, checks, options = {}) {
861
+ const changed = /* @__PURE__ */ new Set();
862
+ const changes = [];
863
+ const selected = (profile) => !options.profiles || profile !== null && options.profiles.has(profile);
864
+ const hooks = record$2(settings.hooks) ? { ...settings.hooks } : {};
865
+ const cli = context.blocked === null ? context.cli : null;
866
+ for (const [id, spec] of Object.entries(GLOBAL_HOOKS)) {
867
+ if (!checks.has(id) || !cli) continue;
868
+ const entries = eventEntries(hooks, spec.event);
869
+ if (entries === "invalid") continue;
870
+ let placed = false;
871
+ const result = rewriteEntries(entries, (hook) => {
872
+ if (!isGlobalManaged(hook, spec)) return hook;
873
+ if (placed) return void 0;
874
+ placed = true;
875
+ if (globalHookCurrent(hook, cli)) return hook;
876
+ return {
877
+ ...hook,
878
+ command: cli.node,
879
+ args: globalHookArgs(cli, spec)
880
+ };
881
+ });
882
+ let next = result.entries;
883
+ if (!placed) next = [...next, { hooks: [{
884
+ type: "command",
885
+ command: cli.node,
886
+ args: globalHookArgs(cli, spec),
887
+ timeout: spec.timeout
888
+ }] }];
889
+ if (result.changed || !placed) {
890
+ hooks[spec.event] = next;
891
+ changed.add(id);
892
+ }
893
+ }
894
+ if (checks.has("claude.hook.stop") && context.blocked === null) {
895
+ if (!SESSION_EVENTS.some((event) => eventEntries(hooks, event) === "invalid")) {
896
+ const held = heldSessionProfiles(hooks, context.bound ?? [], context.restoreProfiles);
897
+ const bound = (context.bound ?? []).filter((item) => !held.has(boundKey(item)));
898
+ const verdicts = judgeSessionHooks(hooks, context.cli, bound);
899
+ for (const event of SESSION_EVENTS) {
900
+ const entries = eventEntries(hooks, event);
901
+ if (entries === "invalid" || entries.length === 0) continue;
902
+ const result = rewriteEntries(entries, (hook) => {
903
+ const verdict = verdicts.get(hook);
904
+ const parsed = parseSessionHook(hook);
905
+ if (!verdict || verdict === "keep" || !parsed) return hook;
906
+ if (!selected(parsed.profile)) return hook;
907
+ const item = {
908
+ profile: parsed.profile,
909
+ session: parsed.session?.toLowerCase() ?? null,
910
+ hook: event,
911
+ state: verdict
912
+ };
913
+ if (verdict === "stale" || verdict === "duplicate" || verdict === "old_address") {
914
+ changes.push({
915
+ ...item,
916
+ action: "removed"
917
+ });
918
+ return;
919
+ }
920
+ if (!cli || !record$2(hook)) return hook;
921
+ const args = [...hook.args];
922
+ args[0] = parsed.kind === "pending" ? cli.pending : cli.wake;
923
+ args[1] = cli.entry;
924
+ changes.push({
925
+ ...item,
926
+ action: "updated"
927
+ });
928
+ return {
929
+ ...hook,
930
+ command: cli.node,
931
+ args
932
+ };
933
+ });
934
+ if (result.changed) {
935
+ hooks[event] = result.entries;
936
+ changed.add("claude.hook.stop");
937
+ }
938
+ }
939
+ if (cli) for (const missing of missingSessionHooks(hooks, bound, cli.node)) {
940
+ if (!selected(missing.bound.profile)) continue;
941
+ const entries = eventEntries(hooks, missing.event);
942
+ if (entries === "invalid") continue;
943
+ changes.push({
944
+ profile: missing.bound.profile,
945
+ session: missing.bound.session,
946
+ hook: missing.event,
947
+ state: "missing",
948
+ action: "added"
949
+ });
950
+ hooks[missing.event] = [...entries, sessionHookEntry(missing.event, missing.bound, cli)];
951
+ changed.add("claude.hook.stop");
952
+ }
953
+ }
954
+ }
955
+ if (!changed.size) return {
956
+ settings,
957
+ changed,
958
+ changes
959
+ };
960
+ return {
961
+ settings: {
962
+ ...settings,
963
+ hooks
964
+ },
965
+ changed,
966
+ changes
967
+ };
968
+ }
969
+ //#endregion
970
+ //#region src/oclif/claude-wake-install.ts
971
+ const HOOK_MARKER = "primitive-agent-wake-v1";
972
+ const WAKE_EVENTS = ["Stop", "SessionStart"];
973
+ const PENDING_MARKER = "primitive-pending-mail-v1";
974
+ const PENDING_EVENT = "PostToolUse";
975
+ const LOCK_WAIT = new Int32Array(new SharedArrayBuffer(4));
976
+ function record$1(value) {
977
+ return value !== null && typeof value === "object" && !Array.isArray(value);
978
+ }
979
+ /** Clock skew tolerated for a record written slightly in the future. */
980
+ const RECORD_CLOCK_SKEW_MS = 6e4;
981
+ function hookMailCheckPath(configDir, profileName, sessionId) {
982
+ return join(agentProfileDirectory(configDir, profileName), `pending-mail-${sessionId}.mail-check.json`);
983
+ }
984
+ /**
985
+ * Record that a listener run by this session's receive hook just completed a
986
+ * mail check with the server. Fails open: receiving never depends on it.
987
+ */
988
+ function recordHookMailCheck(options) {
989
+ try {
990
+ if (!SESSION_UUID.test(options.sessionId)) return;
991
+ writeMailJson(hookMailCheckPath(resolve(options.configDir), agentProfileName(options.profileName), options.sessionId.toLowerCase()), {
992
+ version: 1,
993
+ at: new Date(options.at ?? Date.now()).toISOString()
994
+ });
995
+ } catch {}
996
+ }
997
+ function recordedAt(value) {
998
+ return record$1(value) && value.version === 1 && typeof value.at === "string" && Number.isFinite(Date.parse(value.at)) ? value.at : null;
999
+ }
1000
+ function claudeWakeHookStatus(options) {
1001
+ const unavailable = {
1002
+ installed: false,
1003
+ lastFiredAt: null,
1004
+ lastMailCheckAt: null,
1005
+ firedRecently: false,
1006
+ liveness: "unknown"
1007
+ };
1008
+ try {
1009
+ const configDir = resolve(options.configDir);
1010
+ const profileName = agentProfileName(options.profileName);
1011
+ const agentAddress = mailAddress(options.agentAddress);
1012
+ if (!SESSION_UUID.test(options.sessionId)) return unavailable;
1013
+ const sessionId = options.sessionId.toLowerCase();
1014
+ const env = options.env ?? process.env;
1015
+ const read = readManagedFile(join(resolve(env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude")), "settings.json"));
1016
+ if (read.state !== "present") return unavailable;
1017
+ const settings = JSON.parse(read.text);
1018
+ if (!record$1(settings) || !record$1(settings.hooks)) return unavailable;
1019
+ const hooks = settings.hooks;
1020
+ const target = {
1021
+ configDir,
1022
+ profile: profileName,
1023
+ address: agentAddress,
1024
+ session: sessionId
1025
+ };
1026
+ const node = stableNodePath(env);
1027
+ const installed = [
1028
+ "Stop",
1029
+ "SessionStart",
1030
+ "PostToolUse"
1031
+ ].every((event) => hasSessionReceiveHook(hooks, event, target, node));
1032
+ const lastFiredAt = recordedAt(readMailJson(join(agentProfileDirectory(configDir, profileName), `pending-mail-${sessionId}.fired.json`)));
1033
+ const lastMailCheckAt = recordedAt(readMailJson(hookMailCheckPath(configDir, profileName, sessionId)));
1034
+ const now = options.now ?? Date.now();
1035
+ const recent = (at) => {
1036
+ if (at === null) return false;
1037
+ const age = now - Date.parse(at);
1038
+ return age >= -RECORD_CLOCK_SKEW_MS && age <= 6e5;
1039
+ };
1040
+ return {
1041
+ installed,
1042
+ lastFiredAt,
1043
+ lastMailCheckAt,
1044
+ firedRecently: installed && recent(lastFiredAt),
1045
+ liveness: installed && recent(lastMailCheckAt) ? "checked_recently" : "unknown"
1046
+ };
1047
+ } catch {
1048
+ return unavailable;
1049
+ }
1050
+ }
1051
+ /**
1052
+ * Apply one edit to Claude's settings under the shared lock, backing up the
1053
+ * previous file. Exported for tests.
1054
+ */
1055
+ function editClaudeSettings(claudeDir, edit) {
1056
+ mkdirSync(claudeDir, {
1057
+ recursive: true,
1058
+ mode: 448
1059
+ });
1060
+ let release;
1061
+ for (let attempt = 0; attempt < 40; attempt++) try {
1062
+ release = acquireListenLock(claudeDir, "primitive-claude-settings");
1063
+ break;
1064
+ } catch {
1065
+ Atomics.wait(LOCK_WAIT, 0, 0, 25);
1066
+ }
1067
+ if (!release) return false;
1068
+ try {
1069
+ const settingsPath = join(claudeDir, "settings.json");
1070
+ for (let attempt = 0; attempt < 3; attempt++) {
1071
+ const read = readManagedFile(settingsPath);
1072
+ if (read.state === "invalid") return false;
1073
+ let settings = {};
1074
+ if (read.state === "present") {
1075
+ const parsed = JSON.parse(read.text);
1076
+ if (!record$1(parsed)) return false;
1077
+ settings = parsed;
1078
+ }
1079
+ const next = edit(settings);
1080
+ if (next === null) return true;
1081
+ const content = `${JSON.stringify(next, null, 2)}\n`;
1082
+ if (read.state === "present" && content === read.text) return true;
1083
+ try {
1084
+ writeManagedFile({
1085
+ read,
1086
+ content,
1087
+ newFileMode: 384
1088
+ });
1089
+ return true;
1090
+ } catch (error) {
1091
+ if (!(error instanceof MachineFileError)) throw error;
1092
+ }
1093
+ }
1094
+ return false;
1095
+ } finally {
1096
+ release();
1097
+ }
1098
+ }
1099
+ /** Add exact-session fail-open receiving hooks while preserving unrelated settings. */
1100
+ function installClaudeWakeHook(options) {
1101
+ try {
1102
+ const cliPath = realpathSync(options.cliPath);
1103
+ const wrapperPath = realpathSync(join(dirname(cliPath), "claude-wake.mjs"));
1104
+ const pendingPath = realpathSync(join(dirname(cliPath), "claude-pending-mail.mjs"));
1105
+ const configDir = resolve(options.configDir);
1106
+ const profileName = agentProfileName(options.profileName);
1107
+ const agentAddress = mailAddress(options.agentAddress);
1108
+ if (!SESSION_UUID.test(options.sessionId)) return "unavailable";
1109
+ const sessionId = options.sessionId.toLowerCase();
1110
+ const env = options.env ?? process.env;
1111
+ const claudeDir = resolve(env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"));
1112
+ const node = stableNodePath(env);
1113
+ let held = false;
1114
+ if (!editClaudeSettings(claudeDir, (settings) => {
1115
+ const hooks = settings.hooks ?? {};
1116
+ if (!record$1(hooks)) throw new Error("Invalid hook settings");
1117
+ for (const event of WAKE_EVENTS) if (!Array.isArray(hooks[event] ?? [])) throw new Error(`Invalid ${event} hooks`);
1118
+ if (!Array.isArray(hooks[PENDING_EVENT] ?? [])) throw new Error(`Invalid ${PENDING_EVENT} hooks`);
1119
+ held = options.yieldToOtherProfiles === true && otherSessionReceivers(hooks, {
1120
+ configDir,
1121
+ profile: profileName,
1122
+ session: sessionId
1123
+ }).length > 0;
1124
+ const hook = {
1125
+ type: "command",
1126
+ command: node,
1127
+ args: [
1128
+ wrapperPath,
1129
+ cliPath,
1130
+ configDir,
1131
+ profileName,
1132
+ agentAddress,
1133
+ sessionId,
1134
+ HOOK_MARKER
1135
+ ],
1136
+ asyncRewake: true,
1137
+ timeout: 604800
1138
+ };
1139
+ const pendingHook = {
1140
+ type: "command",
1141
+ command: node,
1142
+ args: [
1143
+ pendingPath,
1144
+ cliPath,
1145
+ configDir,
1146
+ profileName,
1147
+ agentAddress,
1148
+ sessionId,
1149
+ PENDING_MARKER
1150
+ ],
1151
+ timeout: 10
1152
+ };
1153
+ 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;
1154
+ const isOwnHook = (candidate) => {
1155
+ if (!record$1(candidate) || !Array.isArray(candidate.args)) return false;
1156
+ if (typeof candidate.args[0] !== "string" || basename(candidate.args[0]) !== "claude-wake.mjs" || candidate.args[2] !== configDir || candidate.args[3] !== profileName) return false;
1157
+ if (candidate.args.length === 7 && candidate.args[5] === sessionId && candidate.args[6] === HOOK_MARKER) return true;
1158
+ if (candidate.args.length !== 6 || candidate.args[5] !== HOOK_MARKER) return false;
1159
+ try {
1160
+ const state = readMailJson(join(agentProfileDirectory(configDir, profileName), "setup.json"));
1161
+ return record$1(state) && state.session === sessionId;
1162
+ } catch {
1163
+ return false;
1164
+ }
1165
+ };
1166
+ const nextHooks = { ...hooks };
1167
+ for (const event of WAKE_EVENTS) {
1168
+ const entries = hooks[event];
1169
+ const retained = (entries ?? []).flatMap((entry) => {
1170
+ if (!record$1(entry) || !Array.isArray(entry.hooks)) return [entry];
1171
+ const siblings = entry.hooks.filter((candidate) => !isOwnHook(candidate));
1172
+ if (siblings.length === entry.hooks.length) return [entry];
1173
+ return siblings.length ? [{
1174
+ ...entry,
1175
+ hooks: siblings
1176
+ }] : [];
1177
+ });
1178
+ if (held) {
1179
+ if (entries) nextHooks[event] = retained;
1180
+ } else nextHooks[event] = [...retained, event === "SessionStart" ? {
1181
+ matcher: "resume",
1182
+ hooks: [hook]
1183
+ } : { hooks: [hook] }];
1184
+ }
1185
+ const pendingEntries = hooks[PENDING_EVENT];
1186
+ const pendingRetained = (pendingEntries ?? []).flatMap((entry) => {
1187
+ if (!record$1(entry) || !Array.isArray(entry.hooks)) return [entry];
1188
+ const siblings = entry.hooks.filter((candidate) => !isOwnPendingHook(candidate));
1189
+ if (siblings.length === entry.hooks.length) return [entry];
1190
+ return siblings.length ? [{
1191
+ ...entry,
1192
+ hooks: siblings
1193
+ }] : [];
1194
+ });
1195
+ if (held) {
1196
+ if (pendingEntries) nextHooks[PENDING_EVENT] = pendingRetained;
1197
+ } else nextHooks[PENDING_EVENT] = [...pendingRetained, { hooks: [pendingHook] }];
1198
+ if (held && JSON.stringify(nextHooks) === JSON.stringify(hooks)) return null;
1199
+ return {
1200
+ ...settings,
1201
+ hooks: nextHooks
1202
+ };
1203
+ })) return "unavailable";
1204
+ return held ? "held_for_other_profile" : "installed_unverified";
1205
+ } catch {
1206
+ return "unavailable";
1207
+ }
1208
+ }
1209
+ /** Remove only this profile's exact-session hooks after disconnection. */
1210
+ function uninstallClaudeWakeHook(options) {
1211
+ try {
1212
+ const configDir = resolve(options.configDir);
1213
+ const profileName = agentProfileName(options.profileName);
1214
+ const agentAddress = mailAddress(options.agentAddress);
1215
+ if (!SESSION_UUID.test(options.sessionId)) return false;
1216
+ const sessionId = options.sessionId.toLowerCase();
1217
+ const claudeDir = resolve((options.env ?? process.env).CLAUDE_CONFIG_DIR || join(homedir(), ".claude"));
1218
+ if (!existsSync(join(claudeDir, "settings.json"))) return true;
1219
+ return editClaudeSettings(claudeDir, (settings) => {
1220
+ const hooks = settings.hooks ?? {};
1221
+ if (!record$1(hooks)) throw new Error("Invalid hook settings");
1222
+ for (const event of WAKE_EVENTS) if (!Array.isArray(hooks[event] ?? [])) throw new Error(`Invalid ${event} hooks`);
1223
+ if (!Array.isArray(hooks[PENDING_EVENT] ?? [])) throw new Error(`Invalid ${PENDING_EVENT} hooks`);
1224
+ const legacySessionMatches = () => {
1225
+ try {
1226
+ const setup = readMailJson(join(agentProfileDirectory(configDir, profileName), "setup.json"));
1227
+ return record$1(setup) && setup.session === sessionId;
1228
+ } catch {
1229
+ return false;
1230
+ }
1231
+ };
1232
+ const isOwnHook = (candidate) => {
1233
+ if (!record$1(candidate) || !Array.isArray(candidate.args)) return false;
1234
+ const args = candidate.args;
1235
+ 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());
1236
+ };
1237
+ const isOwnPendingHook = (candidate) => {
1238
+ if (!record$1(candidate) || !Array.isArray(candidate.args)) return false;
1239
+ const args = candidate.args;
1240
+ 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;
1241
+ };
1242
+ let removed = false;
1243
+ const nextHooks = { ...hooks };
1244
+ for (const event of WAKE_EVENTS) nextHooks[event] = (hooks[event] ?? []).flatMap((entry) => {
1245
+ if (!record$1(entry) || !Array.isArray(entry.hooks)) return [entry];
1246
+ const siblings = entry.hooks.filter((candidate) => !isOwnHook(candidate));
1247
+ if (siblings.length === entry.hooks.length) return [entry];
1248
+ removed = true;
1249
+ return siblings.length ? [{
1250
+ ...entry,
1251
+ hooks: siblings
1252
+ }] : [];
1253
+ });
1254
+ nextHooks[PENDING_EVENT] = (hooks[PENDING_EVENT] ?? []).flatMap((entry) => {
1255
+ if (!record$1(entry) || !Array.isArray(entry.hooks)) return [entry];
1256
+ const siblings = entry.hooks.filter((candidate) => !isOwnPendingHook(candidate));
1257
+ if (siblings.length === entry.hooks.length) return [entry];
1258
+ removed = true;
1259
+ return siblings.length ? [{
1260
+ ...entry,
1261
+ hooks: siblings
1262
+ }] : [];
1263
+ });
1264
+ return removed ? {
1265
+ ...settings,
1266
+ hooks: nextHooks
1267
+ } : null;
1268
+ });
1269
+ } catch {
1270
+ return false;
1271
+ }
1272
+ }
1273
+ //#endregion
1274
+ //#region src/oclif/agent-connect.ts
1275
+ const MAX_INVITATION_BYTES = 4096;
1276
+ const MAX_RESPONSE_BYTES$1 = 32768;
1277
+ const UNCERTAIN_CLAIM = "Setup did not complete safely. The invitation may have been consumed. Request a fresh invitation from the owner; do not retry this invitation.";
1278
+ /** The receiver a saved profile's setup record names. Offline. */
1279
+ function savedReceiver(configDir, profileName) {
1280
+ try {
1281
+ const setup = readMailJson(join(agentProfileDirectory(configDir, profileName), "setup.json"));
1282
+ if (!setup || typeof setup !== "object" || Array.isArray(setup)) return null;
1283
+ const saved = setup;
1284
+ return {
1285
+ mode: saved.receiverMode === "external" || saved.receiverMode === "poll" ? saved.receiverMode : "native",
1286
+ session: typeof saved.session === "string" && SESSION_UUID.test(saved.session) ? saved.session : null
1287
+ };
1288
+ } catch {
1289
+ return null;
1290
+ }
1291
+ }
1292
+ /**
1293
+ * Where to inspect a saved profile's receiver, by how it receives. Hook and
1294
+ * poll receivers run no background listener, so `listen --status` would
1295
+ * report one absent; they are inspected with `agent connect --status`.
1296
+ */
1297
+ function receiverStatusCommand(configDir, profileName) {
1298
+ const receiver = savedReceiver(configDir, profileName);
1299
+ if (receiver?.mode === "native" && receiver.session) return {
1300
+ mode: "native",
1301
+ command: `PRIMITIVE_AGENT_PROFILE=${profileName} primitive listen --status --notify-session ${receiver.session}`
1302
+ };
1303
+ return {
1304
+ mode: receiver?.mode ?? null,
1305
+ command: `primitive agent connect --profile ${profileName} --status --json`
1306
+ };
1307
+ }
1308
+ /** Offline metadata only; a saved profile is not proof of current API or listener readiness. */
1309
+ function agentConnectionStatus(configDir, profileName, env = process.env) {
1310
+ agentProfileName(profileName);
1311
+ const profile = loadConnectedAgentProfile(configDir, profileName);
1312
+ const setup = profile ? readMailJson(join(agentProfileDirectory(configDir, profileName), "setup.json")) : null;
1313
+ const saved = setup && typeof setup === "object" && !Array.isArray(setup) ? setup : null;
1314
+ const session = saved && typeof saved.session === "string" && SESSION_UUID.test(saved.session) ? saved.session : null;
1315
+ const mode = saved?.receiverMode === "external" || saved?.receiverMode === "poll" ? saved.receiverMode : "native";
1316
+ const receiving = profile && saved && mode === "poll" ? {
1317
+ mode,
1318
+ sessionId: session,
1319
+ state: "poll",
1320
+ reason: "agent_checks_mail",
1321
+ lastSuccessfulMailCheckAt: null,
1322
+ liveness: "unknown"
1323
+ } : profile && session && mode === "native" ? (() => {
1324
+ const scope = notificationScope(profile.api_base_url, profile.api_key);
1325
+ const listener = backgroundListenStatus({
1326
+ configDir,
1327
+ scope,
1328
+ threadId: session
1329
+ });
1330
+ const mailOwner = readSharedMailOwner({ directory: join(configDir, "shared-mail", createHash("sha256").update(scope).digest("hex")) });
1331
+ return {
1332
+ mode,
1333
+ sessionId: session,
1334
+ state: listener.healthy ? listener.phase === "receiving" ? "running" : "degraded" : listener.reason === "restarting" ? "degraded" : listener.reason === "absent" ? "unknown" : "down",
1335
+ reason: listener.reason ?? listener.failureCode,
1336
+ failureCode: listener.failureCode,
1337
+ detail: listener.detail ?? null,
1338
+ lastSuccessfulMailCheckAt: mailOwner?.lastMailCheckAt ?? null,
1339
+ liveness: listener.healthy && listener.phase === "receiving" ? "live" : "unknown",
1340
+ listener
1341
+ };
1342
+ })() : profile && session ? (() => {
1343
+ const hook = claudeWakeHookStatus({
1344
+ configDir,
1345
+ profileName,
1346
+ agentAddress: profile.agent_address,
1347
+ sessionId: session,
1348
+ env
1349
+ });
1350
+ const checked = hook.liveness === "checked_recently";
1351
+ const unconfirmed = !checked && hook.firedRecently;
1352
+ return {
1353
+ mode,
1354
+ sessionId: session,
1355
+ state: !hook.installed ? "down" : checked ? "running" : "unknown",
1356
+ reason: !hook.installed ? "hook_missing" : checked ? "hook_mail_check_recent" : unconfirmed ? "hook_mail_check_unconfirmed" : "hook_liveness_unverified",
1357
+ detail: !hook.installed ? "The receive hook for this session is missing, so mail will not wake this session. Run `primitive machine doctor --fix` to reinstall it." : checked ? "A listener run by this session's receive hook completed a mail check recently. A wake from idle is confirmed only when new mail arrives." : unconfirmed ? "The receive hook ran recently, but no completed mail check has been recorded in that time. Mail may not be arriving; check this session's Primitive connection." : null,
1358
+ lastSuccessfulMailCheckAt: hook.lastMailCheckAt,
1359
+ lastFiredAt: hook.lastFiredAt,
1360
+ liveness: hook.liveness,
1361
+ hook
1362
+ };
1363
+ })() : {
1364
+ mode: "unknown",
1365
+ sessionId: null,
1366
+ state: "unknown",
1367
+ reason: "session_not_configured",
1368
+ lastSuccessfulMailCheckAt: null,
1369
+ liveness: "unknown"
1370
+ };
1371
+ return profile ? {
1372
+ status: "configured",
1373
+ identity: connectedAgentIdentity(profileName, profile),
1374
+ receiving
1375
+ } : {
1376
+ status: "not_configured",
1377
+ profileName
1378
+ };
1379
+ }
1380
+ function parseAgentInvitation(input) {
1381
+ try {
1382
+ if (Buffer.byteLength(input) > MAX_INVITATION_BYTES) throw new Error();
1383
+ const text = input.trim();
1384
+ let token;
1385
+ let base;
1386
+ if (text.startsWith("{")) {
1387
+ const value = JSON.parse(text);
1388
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error();
1389
+ const row = value;
1390
+ if (Object.keys(row).some((key) => key !== "token" && key !== "api_base_url")) throw new Error();
1391
+ token = row.token;
1392
+ base = row.api_base_url ?? "https://api.primitive.dev/v1";
1393
+ } else {
1394
+ const url = new URL(text);
1395
+ if (url.username || url.password || url.search || url.pathname !== "/v1/agent-connections/setup") throw new Error();
1396
+ const fragment = new URLSearchParams(url.hash.slice(1));
1397
+ if (Array.from(fragment.keys()).length !== 1 || fragment.getAll("token").length !== 1) throw new Error();
1398
+ token = fragment.get("token");
1399
+ base = `${url.origin}/v1`;
1400
+ }
1401
+ if (typeof token !== "string" || token.length < 32 || token.length > 256 || /\s/.test(token) || Array.from(token).some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127)) throw new Error();
1402
+ return {
1403
+ token,
1404
+ apiBaseUrl: connectedApiBaseUrl(base)
1405
+ };
1406
+ } catch {
1407
+ throw new AgentConnectionSetupError("Pipe the private Primitive setup URL or a JSON object containing token and optional api_base_url to stdin. Invitation values are never accepted as command arguments.");
1408
+ }
1409
+ }
1410
+ /** The saved binding of an invitation: a one-way hash of its origin and token. */
1411
+ function agentInvitationHash(input) {
1412
+ const invitation = parseAgentInvitation(input);
1413
+ return createHash("sha256").update(invitation.apiBaseUrl).update("\0").update(invitation.token).digest("hex");
1414
+ }
1415
+ async function readAgentInvitation(input, isTTY) {
1416
+ if (isTTY) throw new AgentConnectionSetupError("Pipe the private setup invitation to stdin.");
1417
+ const chunks = [];
1418
+ let size = 0;
1419
+ for await (const chunk of input) {
1420
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1421
+ size += bytes.length;
1422
+ if (size > MAX_INVITATION_BYTES) throw new AgentConnectionSetupError("Setup invitation exceeds the input limit.");
1423
+ chunks.push(bytes);
1424
+ }
1425
+ return Buffer.concat(chunks).toString("utf8");
1426
+ }
1427
+ async function readClaimResponse(response) {
1428
+ const reader = response.body?.getReader();
1429
+ if (!reader) throw new Error();
1430
+ const chunks = [];
1431
+ let size = 0;
1432
+ try {
1433
+ for (;;) {
1434
+ const part = await reader.read();
1435
+ if (part.done) break;
1436
+ size += part.value.byteLength;
1437
+ if (size > MAX_RESPONSE_BYTES$1) throw new Error();
1438
+ chunks.push(part.value);
1439
+ }
1440
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
1441
+ } finally {
1442
+ await reader.cancel().catch(() => void 0);
1443
+ }
1444
+ }
1445
+ function profileFromClaim(value, invitation, invitationHash, now) {
1446
+ const response = value;
1447
+ const data = response?.data;
1448
+ const connection = data?.connection;
1449
+ if (response?.success !== true || !data || !connection || data.api_base_url !== invitation.apiBaseUrl || data.owner_address !== connection.owner_address || !["claimed", "connected"].includes(String(connection.status))) throw new Error();
1450
+ let ownerMember;
1451
+ try {
1452
+ ownerMember = Object.hasOwn(data, "owner_member_address") ? parseOwnerMemberAddress(data.owner_member_address, mailAddress(data.owner_address), mailAddress(connection.address)) : void 0;
1453
+ } catch {
1454
+ ownerMember = void 0;
1455
+ }
1456
+ return parseConnectedAgentProfile({
1457
+ version: 1,
1458
+ auth_method: "agent_connection",
1459
+ api_key: data.api_key,
1460
+ api_base_url: data.api_base_url,
1461
+ org_id: data.org_id,
1462
+ agent_address: connection.address,
1463
+ owner_address: data.owner_address,
1464
+ ...ownerMember === void 0 ? {} : { owner_member_address: ownerMember },
1465
+ invitation_hash: invitationHash,
1466
+ created_at: new Date(now()).toISOString(),
1467
+ ...data.presence_profile === void 0 ? {} : { presence_profile: data.presence_profile }
1468
+ });
1469
+ }
1470
+ /** Claims once; saved identity is local configuration, not proof of email readiness. */
1471
+ async function connectAgent(params) {
1472
+ const profileName = agentProfileName(params.profileName);
1473
+ const invitation = parseAgentInvitation(params.invitation);
1474
+ const invitationHash = agentInvitationHash(params.invitation);
1475
+ const directory = agentProfilesDirectory(params.configDir);
1476
+ let release;
1477
+ try {
1478
+ privateMailDirectory(directory, true);
1479
+ release = acquireListenLock(directory, "agent-connection-setup");
1480
+ } catch {
1481
+ throw new AgentConnectionSetupError("Agent setup is already running, or its private directory is unavailable. No invitation was submitted.");
1482
+ }
1483
+ try {
1484
+ const existing = loadConnectedAgentProfile(params.configDir, profileName);
1485
+ if (existing) {
1486
+ if (existing.invitation_hash !== invitationHash) throw new AgentConnectionSetupError(profileAlreadyConnectedMessage(profileName, existing.agent_address));
1487
+ return {
1488
+ status: "already_configured",
1489
+ identity: connectedAgentIdentity(profileName, existing)
1490
+ };
1491
+ }
1492
+ privateMailDirectory(join(directory, "profiles"), true);
1493
+ privateMailDirectory(agentProfileDirectory(params.configDir, profileName), true);
1494
+ const journal = join(directory, "claims", `${invitationHash}.json`);
1495
+ privateMailDirectory(join(directory, "claims"), true);
1496
+ if (readMailJson(journal) !== null) throw new AgentConnectionSetupError(UNCERTAIN_CLAIM);
1497
+ writeMailJson(journal, {
1498
+ version: 1,
1499
+ profile_name: profileName,
1500
+ status: "attempted"
1501
+ });
1502
+ try {
1503
+ const response = await (params.fetch ?? fetch)(`${invitation.apiBaseUrl}/agent-connections/claim`, {
1504
+ method: "POST",
1505
+ redirect: "error",
1506
+ signal: AbortSignal.timeout(25e3),
1507
+ headers: {
1508
+ "content-type": "application/json",
1509
+ accept: "application/json"
1510
+ },
1511
+ body: JSON.stringify({
1512
+ token: invitation.token,
1513
+ ...params.presence ? { capabilities: ["primitive.presence/1"] } : {}
1514
+ })
1515
+ });
1516
+ if (response.status !== 200) {
1517
+ await response.body?.cancel();
1518
+ throw new Error();
1519
+ }
1520
+ const profile = profileFromClaim(await readClaimResponse(response), invitation, invitationHash, params.now ?? Date.now);
1521
+ saveConnectedAgentProfile(params.configDir, profileName, profile);
1522
+ return {
1523
+ status: "claimed",
1524
+ identity: connectedAgentIdentity(profileName, profile)
1525
+ };
1526
+ } catch {
1527
+ throw new AgentConnectionSetupError(UNCERTAIN_CLAIM);
1528
+ }
1529
+ } finally {
1530
+ release();
1531
+ }
1532
+ }
1533
+ //#endregion
1534
+ //#region src/oclif/contacts.ts
1535
+ var ContactsApiError = class extends Error {
1536
+ directoryAvailable = false;
1537
+ constructor(payload) {
1538
+ super("Contact request failed.");
1539
+ this.payload = payload;
1540
+ }
1541
+ };
1542
+ function contactAddress(value) {
1543
+ const address = value.trim().toLowerCase();
1544
+ if (address.length > 254 || !/^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/.test(address)) throw new Error("Provide a bare contact email address.");
1545
+ return address;
1546
+ }
1547
+ function contactAgentAddress(explicit, connected) {
1548
+ if (!explicit && !connected) throw new Error("Pass --agent or select a connected agent profile.");
1549
+ const address = contactAddress(explicit ?? connected ?? "");
1550
+ if (connected && address !== contactAddress(connected)) throw new Error("This connected profile can manage only its own agent contacts.");
1551
+ return address;
1552
+ }
1553
+ function version(value) {
1554
+ if (!/^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/i.test(value)) throw new Error("The contact version must be the UUID returned by the API.");
1555
+ return value;
1556
+ }
1557
+ async function response(pending) {
1558
+ const result = await pending;
1559
+ if (result.error !== void 0) throw new ContactsApiError(result.error);
1560
+ if (result.data?.success !== true || result.data.data == null) throw new Error("The contacts API returned an incomplete response.");
1561
+ return result.data;
1562
+ }
1563
+ function pageCursor(value) {
1564
+ const cursor = value.meta?.cursor;
1565
+ if (cursor === null) return null;
1566
+ if (typeof cursor !== "string") throw new Error("The contacts API omitted pagination state; no write was attempted.");
1567
+ return contactAddress(cursor);
1568
+ }
1569
+ async function membershipVersion(client, agent, address, signal) {
1570
+ let cursor;
1571
+ const seen = /* @__PURE__ */ new Set();
1572
+ for (let page = 0; page < 1e3; page++) {
1573
+ signal?.throwIfAborted();
1574
+ const result = await response(listAgentContacts({
1575
+ client,
1576
+ path: { agent_address: agent },
1577
+ query: {
1578
+ limit: 100,
1579
+ cursor
1580
+ },
1581
+ signal,
1582
+ responseStyle: "fields"
1583
+ }));
1584
+ if (!Array.isArray(result.data)) throw new Error("The contacts API returned an invalid page.");
1585
+ const next = pageCursor(result);
1586
+ const found = result.data.find((row) => row.contact_address === address);
1587
+ if (found) {
1588
+ if (found.agent_address !== agent) throw new Error("The contacts API returned a different agent.");
1589
+ return version(found.version);
1590
+ }
1591
+ if (next === null) throw new Error("Agent contact not found. Use agent contacts add to create it.");
1592
+ if (seen.has(next)) throw new Error("Contact pagination repeated a cursor; no write was attempted.");
1593
+ seen.add(next);
1594
+ cursor = next;
1595
+ }
1596
+ throw new Error("Contact lookup exceeded its page limit; pass an explicit --if-version.");
1597
+ }
1598
+ /** Public generated operations only. Every mutation uses one explicit CAS condition. */
1599
+ async function runContactRequest(client, request) {
1600
+ const { action, target, signal } = request;
1601
+ signal?.throwIfAborted();
1602
+ const address = action === "list" ? void 0 : contactAddress(request.address ?? "");
1603
+ const agent = target === "agent" ? contactAgentAddress(request.agent) : void 0;
1604
+ const limit = request.limit ?? 50;
1605
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new Error("--limit must be between 1 and 100.");
1606
+ const cursor = request.cursor === void 0 ? void 0 : contactAddress(request.cursor);
1607
+ if (request.name !== void 0 && request.clearName) throw new Error("Use either --name or --clear-name.");
1608
+ if (request.purpose !== void 0 && request.clearPurpose) throw new Error("Use either --purpose or --clear-purpose.");
1609
+ if (request.name !== void 0 && Array.from(request.name).length > 200) throw new Error("Contact names may contain at most 200 characters.");
1610
+ if (request.purpose !== void 0 && Array.from(request.purpose).length > 2e3) throw new Error("Contact purposes may contain at most 2000 characters.");
1611
+ const expected = request.ifVersion === void 0 ? void 0 : version(request.ifVersion);
1612
+ if (action === "add" && expected) throw new Error("Add never overwrites an existing contact. Use update with --if-version.");
1613
+ if (action === "list") {
1614
+ const result = target === "directory" ? await response(listContacts({
1615
+ client,
1616
+ signal,
1617
+ query: {
1618
+ cursor,
1619
+ limit
1620
+ },
1621
+ responseStyle: "fields"
1622
+ })) : await response(listAgentContacts({
1623
+ client,
1624
+ signal,
1625
+ path: { agent_address: agent ?? "" },
1626
+ query: {
1627
+ cursor,
1628
+ limit
1629
+ },
1630
+ responseStyle: "fields"
1631
+ }));
1632
+ if (!Array.isArray(result.data)) throw new Error("The contacts API returned an invalid page.");
1633
+ pageCursor(result);
1634
+ return result;
1635
+ }
1636
+ if (!address) throw new Error("Provide a contact email address.");
1637
+ if (target === "directory") {
1638
+ const name = request.clearName ? null : request.name;
1639
+ if (action === "update" && name === void 0) throw new Error("Pass --name or --clear-name to update the contact.");
1640
+ if (action === "get" || (action === "update" || action === "remove") && !expected) {
1641
+ const result = await response(getContact({
1642
+ client,
1643
+ signal,
1644
+ path: { address },
1645
+ responseStyle: "fields"
1646
+ }));
1647
+ if (result.data.address !== address) throw new Error("The contacts API returned a different contact.");
1648
+ if (action === "get") return result.data;
1649
+ request = {
1650
+ ...request,
1651
+ ifVersion: version(result.data.version)
1652
+ };
1653
+ }
1654
+ signal?.throwIfAborted();
1655
+ if (action === "remove") return (await response(deleteContact({
1656
+ client,
1657
+ signal,
1658
+ path: { address },
1659
+ query: { if_version: version(request.ifVersion ?? "") },
1660
+ responseStyle: "fields"
1661
+ }))).data;
1662
+ const result = await response(putContact({
1663
+ client,
1664
+ signal,
1665
+ path: { address },
1666
+ body: {
1667
+ ...action === "add" ? { if_absent: true } : { if_version: version(request.ifVersion ?? "") },
1668
+ ...name === void 0 ? {} : { display_name: name }
1669
+ },
1670
+ responseStyle: "fields"
1671
+ }));
1672
+ if (result.data.address !== address) throw new Error("The contacts API returned a different contact.");
1673
+ return result.data;
1674
+ }
1675
+ if (!agent) throw new Error("Provide an agent address.");
1676
+ if (action === "get") throw new Error("Use agent contacts list to inspect memberships.");
1677
+ const purpose = request.clearPurpose ? null : request.purpose;
1678
+ if (action === "update" && purpose === void 0 && request.notify === void 0) throw new Error("Pass --purpose, --clear-purpose, --notify or --no-notify.");
1679
+ const path = {
1680
+ agent_address: agent,
1681
+ contact_address: address
1682
+ };
1683
+ if (action === "add") {
1684
+ if ((await response(putContact({
1685
+ client,
1686
+ signal,
1687
+ path: { address },
1688
+ body: { if_absent: true },
1689
+ responseStyle: "fields"
1690
+ }))).data.address !== address) throw new Error("The contacts API returned a different contact.");
1691
+ }
1692
+ const ifVersion = action === "add" ? void 0 : expected ?? await membershipVersion(client, agent, address, signal);
1693
+ if (action === "remove") {
1694
+ signal?.throwIfAborted();
1695
+ return (await response(deleteAgentContact({
1696
+ client,
1697
+ signal,
1698
+ path,
1699
+ query: { if_version: version(ifVersion ?? "") },
1700
+ responseStyle: "fields"
1701
+ }))).data;
1702
+ }
1703
+ try {
1704
+ signal?.throwIfAborted();
1705
+ const result = await response(putAgentContact({
1706
+ client,
1707
+ signal,
1708
+ path,
1709
+ body: {
1710
+ ...action === "add" ? { if_absent: true } : { if_version: version(ifVersion ?? "") },
1711
+ ...purpose === void 0 ? {} : { purpose },
1712
+ ...request.notify === void 0 ? {} : { notify: request.notify }
1713
+ },
1714
+ responseStyle: "fields"
1715
+ }));
1716
+ if (result.data.agent_address !== agent || result.data.contact_address !== address) throw new Error("The contacts API returned a different membership.");
1717
+ return result.data;
1718
+ } catch (error) {
1719
+ if (action === "add") {
1720
+ const failure = error instanceof ContactsApiError ? error : new ContactsApiError({
1721
+ code: "contact_membership_unsaved",
1722
+ message: error instanceof Error ? error.message : "Agent contact request failed."
1723
+ });
1724
+ failure.directoryAvailable = true;
1725
+ throw failure;
1726
+ }
1727
+ throw error;
1728
+ }
1729
+ }
1730
+ //#endregion
1731
+ //#region src/oclif/address-notes.ts
1732
+ var AddressNotesApiError = class extends Error {
1733
+ constructor(payload, status) {
1734
+ super("Address note request failed.");
1735
+ this.payload = payload;
1736
+ this.status = status;
1737
+ }
1738
+ };
1739
+ const security = [{
1740
+ scheme: "bearer",
1741
+ type: "http"
1742
+ }];
1743
+ function addressNoteName(value) {
1744
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(value)) throw new Error("Note name must start with a letter or digit and contain at most 128 letters, digits, underscores, dots, or hyphens.");
1745
+ return value;
1746
+ }
1747
+ function addressNoteVersion(value) {
1748
+ if (!/^[0-9]{1,19}$/.test(value)) throw new Error("Note version must be the decimal version returned by the API.");
1749
+ return value;
1750
+ }
1751
+ function addressNoteTarget(explicit, connected, write) {
1752
+ if (!explicit && !connected) throw new Error("Pass --address or select a connected agent profile.");
1753
+ const address = contactAddress(explicit ?? connected ?? "");
1754
+ if (write && connected && address !== contactAddress(connected)) throw new Error("This connected profile can write only its own address notes.");
1755
+ return address;
1756
+ }
1757
+ function note(value, address, name) {
1758
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("The address notes API returned an invalid note.");
1759
+ const row = value;
1760
+ if (row.address !== address || typeof row.name !== "string" || name !== void 0 && row.name !== name || row.visibility !== "private" && row.visibility !== "public" || typeof row.version !== "string" || !/^[0-9]{1,19}$/.test(row.version) || typeof row.created_at !== "string" || typeof row.updated_at !== "string" || !("value" in row)) throw new Error("The address notes API returned an invalid note.");
1761
+ return row;
1762
+ }
1763
+ function envelope(value) {
1764
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("The address notes API returned an invalid response.");
1765
+ const row = value;
1766
+ if (row.success !== true || !("data" in row)) throw new Error("The address notes API returned an incomplete response.");
1767
+ return row;
1768
+ }
1769
+ async function result(pending) {
1770
+ const response = await pending;
1771
+ if (response.error !== void 0) throw new AddressNotesApiError(response.error, response.response?.status);
1772
+ return envelope(response.data).data;
1773
+ }
1774
+ async function getNote(client, address, name, signal) {
1775
+ return note(await result(client.get({
1776
+ security,
1777
+ url: "/address-notes/{address}/{name}",
1778
+ path: {
1779
+ address,
1780
+ name
1781
+ },
1782
+ responseStyle: "fields",
1783
+ ...signal ? { signal } : {}
1784
+ })), address, name);
1785
+ }
1786
+ async function runAddressNotesRequest(client, request) {
1787
+ const address = contactAddress(request.address);
1788
+ if (request.action === "list") {
1789
+ const limit = request.limit ?? 50;
1790
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new Error("--limit must be between 1 and 100.");
1791
+ const prefix = request.prefix;
1792
+ if (prefix !== void 0 && !/^[A-Za-z0-9_.-]{0,128}$/.test(prefix)) throw new Error("--prefix may contain at most 128 letters, digits, underscores, dots, or hyphens.");
1793
+ const cursor = request.cursor === void 0 ? void 0 : addressNoteName(request.cursor);
1794
+ const response = await client.get({
1795
+ security,
1796
+ url: "/address-notes",
1797
+ query: {
1798
+ address,
1799
+ prefix,
1800
+ cursor,
1801
+ limit
1802
+ },
1803
+ responseStyle: "fields"
1804
+ });
1805
+ if (response.error !== void 0) throw new AddressNotesApiError(response.error, response.response?.status);
1806
+ const body = envelope(response.data);
1807
+ if (!Array.isArray(body.data) || !body.meta || typeof body.meta !== "object" || Array.isArray(body.meta)) throw new Error("The address notes API returned an invalid page.");
1808
+ const next = body.meta.cursor;
1809
+ if (next !== null && (typeof next !== "string" || addressNoteName(next) !== next)) throw new Error("The address notes API omitted valid pagination state.");
1810
+ return {
1811
+ ...body,
1812
+ data: body.data.map((row) => note(row, address))
1813
+ };
1814
+ }
1815
+ const name = addressNoteName(request.name ?? "");
1816
+ if (request.action === "get") return getNote(client, address, name, request.signal);
1817
+ if (request.ifAbsent && request.ifVersion !== void 0) throw new Error("Use either --if-absent or --if-version.");
1818
+ const ifVersion = request.ifVersion === void 0 ? void 0 : addressNoteVersion(request.ifVersion);
1819
+ if (request.action === "set") {
1820
+ if (request.value === void 0) throw new Error("Provide a note value.");
1821
+ let condition;
1822
+ if (request.ifAbsent) condition = { if_absent: true };
1823
+ else if (ifVersion) condition = { if_version: ifVersion };
1824
+ else try {
1825
+ condition = { if_version: (await getNote(client, address, name)).version };
1826
+ } catch (error) {
1827
+ if (!(error instanceof AddressNotesApiError) || error.status !== 404) throw error;
1828
+ condition = { if_absent: true };
1829
+ }
1830
+ return note(await result(client.put({
1831
+ security,
1832
+ url: "/address-notes/{address}/{name}",
1833
+ path: {
1834
+ address,
1835
+ name
1836
+ },
1837
+ body: {
1838
+ value: request.value,
1839
+ ...condition,
1840
+ ...request.visibility ? { visibility: request.visibility } : {}
1841
+ },
1842
+ headers: { "Content-Type": "application/json" },
1843
+ responseStyle: "fields"
1844
+ })), address, name);
1845
+ }
1846
+ const version = ifVersion ?? (await getNote(client, address, name)).version;
1847
+ const data = await result(client.delete({
1848
+ security,
1849
+ url: "/address-notes/{address}/{name}",
1850
+ path: {
1851
+ address,
1852
+ name
1853
+ },
1854
+ query: { if_version: version },
1855
+ responseStyle: "fields"
1856
+ }));
1857
+ if (!data || typeof data !== "object" || Array.isArray(data) || typeof data.deleted !== "boolean") throw new Error("The address notes API returned an invalid deletion response.");
1858
+ return data;
1859
+ }
1860
+ //#endregion
1861
+ //#region src/oclif/agent-setup-read.ts
1862
+ function retryDelay(response, attempt, now) {
1863
+ const raw = response?.headers.get("retry-after")?.trim();
1864
+ if (raw && /^\d+(?:\.\d+)?$/.test(raw)) {
1865
+ const value = Number(raw) * 1e3;
1866
+ if (Number.isFinite(value)) return Math.max(1e3, Math.ceil(value));
1867
+ }
1868
+ if (raw) {
1869
+ const date = Date.parse(raw);
1870
+ if (Number.isFinite(date)) return Math.max(1e3, date - now);
1871
+ }
1872
+ return Math.min(1e3 * 2 ** attempt, 1e4);
1873
+ }
1874
+ /** Read-only recovery. The caller supplies a GET operation, never a claim or write. */
1875
+ async function readSetupApi(budget, stage, read) {
1876
+ for (let attempt = 0;; attempt++) {
1877
+ const remaining = budget.deadline - budget.now();
1878
+ if (remaining <= 0 || budget.signal.aborted) throw new AgentConnectionSetupError(`Setup ${stage} reached its read deadline. Resume the saved profile; no claim or send was replayed.`);
1879
+ let result;
1880
+ let failed = false;
1881
+ try {
1882
+ result = await read(AbortSignal.any([budget.signal, AbortSignal.timeout(Math.min(1e4, Math.max(1, remaining)))]));
1883
+ } catch {
1884
+ failed = true;
1885
+ }
1886
+ const status = result?.response?.status;
1887
+ if (!(status === 429 || status !== void 0 && status >= 500 && status <= 599 || status === void 0 && (failed || result?.error !== void 0))) {
1888
+ if (failed || !result || result.error !== void 0 || status !== void 0 && status >= 400) throw new AgentConnectionSetupError(`Setup ${stage} could not be read${status === void 0 ? "" : ` (HTTP ${status})`}. Resume the saved profile after resolving access; no claim or send was replayed.`);
1889
+ return result;
1890
+ }
1891
+ const waitMs = retryDelay(result?.response, attempt, budget.now());
1892
+ const remainingAfterRead = budget.deadline - budget.now();
1893
+ if (attempt >= 4 || waitMs >= remainingAfterRead || budget.signal.aborted) throw new AgentConnectionSetupError(`Setup ${stage} is temporarily unavailable${status === void 0 ? " (transport failure)" : ` (HTTP ${status})`}. Retry after ${Math.ceil(waitMs / 1e3)} seconds with the saved profile's --resume command; no claim or send was replayed.`);
1894
+ await budget.sleep(waitMs);
1895
+ budget.retries++;
1896
+ }
1897
+ }
1898
+ //#endregion
1899
+ //#region src/oclif/contact-rule-matcher.ts
1900
+ const localPart = /^[a-z0-9!#$%&'+/=^_`{|}~-]+(?:\.[a-z0-9!#$%&'+/=^_`{|}~-]+)*$/;
1901
+ const patternPart = /^[a-z0-9.!#$%&'+/=^_`{|}~*-]+$/;
1902
+ const label = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
1903
+ function domain(value) {
1904
+ const labels = value.split(".");
1905
+ return value.length <= 253 && labels.length >= 2 && labels.every((part) => label.test(part)) && /[a-z]/.test(labels.at(-1) ?? "");
1906
+ }
1907
+ function domainPattern(value) {
1908
+ return domain(value.startsWith("*.") ? value.slice(2) : value);
1909
+ }
1910
+ function splitAddress(value) {
1911
+ const parts = value.split("@");
1912
+ const [local, host] = parts;
1913
+ return parts.length === 2 && local && host ? [local, host] : null;
1914
+ }
1915
+ /** Restricted selectors are canonical data, never regular expressions. */
1916
+ function canonicalContactSelector(input) {
1917
+ const invalid = () => /* @__PURE__ */ new Error("Invalid contact approval selector.");
1918
+ const trimmed = input.value.trim();
1919
+ for (const character of trimmed) if (character.charCodeAt(0) > 127) throw invalid();
1920
+ const value = trimmed.toLowerCase();
1921
+ if (!value || value.length > 254) throw invalid();
1922
+ if (input.kind === "domain") {
1923
+ if (!domainPattern(value)) throw invalid();
1924
+ return {
1925
+ kind: input.kind,
1926
+ value
1927
+ };
1928
+ }
1929
+ const parts = splitAddress(value);
1930
+ if (!parts) throw invalid();
1931
+ const [local, host] = parts;
1932
+ if (local.length > 64 || local.startsWith(".") || local.endsWith(".") || local.includes("..")) throw invalid();
1933
+ if (input.kind === "address") {
1934
+ if (!localPart.test(local) || !domain(host)) throw invalid();
1935
+ } else if (input.kind === "pattern") {
1936
+ if (!patternPart.test(local) || local !== "*" && !localPart.test(local.endsWith("*") ? local.slice(0, -1) : local) || !domainPattern(host) || local.includes("*") && (!local.endsWith("*") || local.slice(0, -1).includes("*"))) throw invalid();
1937
+ } else throw invalid();
1938
+ return {
1939
+ kind: input.kind,
1940
+ value
1941
+ };
1942
+ }
1943
+ function matchesDomain(pattern, value) {
1944
+ if (!pattern.startsWith("*.")) return pattern === value;
1945
+ const suffix = pattern.slice(1);
1946
+ return value.length > suffix.length && value.endsWith(suffix);
1947
+ }
1948
+ function matchesLocal(pattern, value) {
1949
+ return pattern.endsWith("*") ? value.startsWith(pattern.slice(0, -1)) : value === pattern;
1950
+ }
1951
+ function matchesContactPattern(pattern, sender) {
1952
+ return matchesContactSelector({
1953
+ kind: "pattern",
1954
+ value: pattern
1955
+ }, sender);
1956
+ }
1957
+ /** Malformed sender identities never match; malformed stored rules fail closed. */
1958
+ function matchesContactSelector(input, sender) {
1959
+ const selector = canonicalContactSelector(input);
1960
+ let address;
1961
+ try {
1962
+ address = canonicalContactSelector({
1963
+ kind: "address",
1964
+ value: sender
1965
+ }).value;
1966
+ } catch {
1967
+ return false;
1968
+ }
1969
+ if (selector.kind === "address") return selector.value === address;
1970
+ const [local, host] = splitAddress(address) ?? [];
1971
+ if (!local || !host) return false;
1972
+ if (selector.kind === "domain") return matchesDomain(selector.value, host);
1973
+ const [localPattern, hostPattern] = splitAddress(selector.value) ?? [];
1974
+ return Boolean(localPattern && hostPattern && matchesDomain(hostPattern, host) && matchesLocal(localPattern, local));
1975
+ }
1976
+ //#endregion
1977
+ //#region src/oclif/contact-policy.ts
1978
+ function record(value) {
1979
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid contact policy.");
1980
+ return value;
1981
+ }
1982
+ function fingerprint(value) {
1983
+ if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) throw new Error("Invalid contact policy version.");
1984
+ return value;
1985
+ }
1986
+ function document(value, organization) {
1987
+ const row = record(value);
1988
+ if (!Array.isArray(row.rules) || row.rules.length > 100 || !(typeof row.allow_contact_requests === "boolean" || !organization && row.allow_contact_requests === null)) throw new Error("Invalid contact policy document.");
1989
+ const seen = /* @__PURE__ */ new Set();
1990
+ const rules = row.rules.map((value) => {
1991
+ const rule = record(value);
1992
+ if (typeof rule.pattern !== "string" || rule.effect !== "allow" && rule.effect !== "silence") throw new Error("Invalid contact policy rule.");
1993
+ const pattern = canonicalContactSelector({
1994
+ kind: "pattern",
1995
+ value: rule.pattern
1996
+ }).value;
1997
+ const key = `${pattern}:${rule.effect}`;
1998
+ if (pattern !== rule.pattern || seen.has(key)) throw new Error("Invalid contact policy rule.");
1999
+ seen.add(key);
2000
+ if (rule.effect === "silence") {
2001
+ if (rule.notify_since !== null || rule.notification_generation !== null) throw new Error("Invalid silence rule.");
2002
+ return {
2003
+ pattern,
2004
+ effect: "silence",
2005
+ notify_since: null,
2006
+ notification_generation: null
2007
+ };
2008
+ }
2009
+ return {
2010
+ pattern,
2011
+ effect: "allow",
2012
+ notify_since: mailTime(rule.notify_since),
2013
+ notification_generation: mailId(rule.notification_generation)
2014
+ };
2015
+ });
2016
+ const version = row.version === null ? null : mailId(row.version);
2017
+ const updatedAt = row.updated_at === null ? null : mailTime(row.updated_at);
2018
+ if (version === null !== (updatedAt === null)) throw new Error("Invalid contact policy document version.");
2019
+ const requests = row.allow_contact_requests;
2020
+ if (requests !== true && (row.contact_request_since !== null || row.contact_request_generation !== null)) throw new Error("Invalid contact request activation.");
2021
+ if (version === null && (rules.length || requests !== (organization ? false : null))) throw new Error("Invalid missing contact policy document.");
2022
+ return {
2023
+ rules,
2024
+ allow_contact_requests: requests,
2025
+ contact_request_since: requests === true ? mailTime(row.contact_request_since) : null,
2026
+ contact_request_generation: requests === true ? mailId(row.contact_request_generation) : null,
2027
+ version,
2028
+ updated_at: updatedAt
2029
+ };
2030
+ }
2031
+ function parseAgentContactPolicy(value, recipient) {
2032
+ const row = record(value);
2033
+ if (row.agent_address !== recipient || typeof row.allow_contact_requests !== "boolean") throw new Error("Contact policy recipient mismatch.");
2034
+ const org = document(row.org_policy, true);
2035
+ const agent = document(row.agent_policy, false);
2036
+ if (row.allow_contact_requests !== (agent.allow_contact_requests ?? org.allow_contact_requests)) throw new Error("Inconsistent contact request policy.");
2037
+ if (!row.allow_contact_requests && (row.contact_request_since !== null || row.contact_request_generation !== null)) throw new Error("Invalid effective contact request activation.");
2038
+ return {
2039
+ agent_address: recipient,
2040
+ org_policy: org,
2041
+ agent_policy: agent,
2042
+ effective_version: fingerprint(row.effective_version),
2043
+ effective_since: mailTime(row.effective_since),
2044
+ allow_contact_requests: row.allow_contact_requests,
2045
+ contact_request_since: row.allow_contact_requests ? mailTime(row.contact_request_since) : null,
2046
+ contact_request_generation: row.allow_contact_requests ? fingerprint(row.contact_request_generation) : null
2047
+ };
2048
+ }
2049
+ const latest = (left, right) => Date.parse(left) >= Date.parse(right) ? left : right;
2050
+ const arrived = (received, since) => Date.parse(received) >= Date.parse(since);
2051
+ /** Owner rules are unordered. Agent matches take precedence; silence wins ties. */
2052
+ function evaluateContactPolicy(options) {
2053
+ const { policy, sender, receivedAt, membership } = options;
2054
+ if (membership?.notify === false) return {
2055
+ kind: "silent",
2056
+ source: "membership"
2057
+ };
2058
+ for (const source of ["agent", "org"]) {
2059
+ const matches = policy[`${source}_policy`].rules.filter((rule) => matchesContactPattern(rule.pattern, sender));
2060
+ if (!matches.length) continue;
2061
+ if (matches.some((rule) => rule.effect === "silence")) return {
2062
+ kind: "silent",
2063
+ source
2064
+ };
2065
+ for (const rule of matches.sort((a, b) => a.pattern < b.pattern ? -1 : a.pattern > b.pattern ? 1 : 0)) {
2066
+ if (!rule.notify_since || !rule.notification_generation) throw new Error("Missing rule activation.");
2067
+ const since = latest(rule.notify_since, policy.effective_since);
2068
+ if (arrived(receivedAt, since)) return {
2069
+ kind: "allowed",
2070
+ source,
2071
+ pattern: rule.pattern,
2072
+ generation: rule.notification_generation,
2073
+ notifySince: since
2074
+ };
2075
+ }
2076
+ return {
2077
+ kind: "silent",
2078
+ source
2079
+ };
2080
+ }
2081
+ if (membership?.notify === true) {
2082
+ const since = membership.notifySince;
2083
+ return arrived(receivedAt, since) ? {
2084
+ kind: "allowed",
2085
+ source: "membership",
2086
+ generation: membership.generation,
2087
+ notifySince: since
2088
+ } : {
2089
+ kind: "silent",
2090
+ source: "membership"
2091
+ };
2092
+ }
2093
+ if (options.contactRequests && policy.allow_contact_requests && policy.contact_request_since && policy.contact_request_generation) {
2094
+ const since = latest(policy.contact_request_since, policy.effective_since);
2095
+ if (arrived(receivedAt, since)) return {
2096
+ kind: "request",
2097
+ generation: policy.contact_request_generation,
2098
+ notifySince: since
2099
+ };
2100
+ }
2101
+ return {
2102
+ kind: "silent",
2103
+ source: "default"
2104
+ };
2105
+ }
2106
+ const CONTACT_POLICY_RETRY_MIN_MS = 1e3;
2107
+ const CONTACT_POLICY_RETRY_MAX_MS = 3e4;
2108
+ const NETWORK_ADMISSION_PENDING_RETRY_MS = 5e3;
2109
+ var NetworkAdmissionPendingError = class extends NotificationRetryError {
2110
+ constructor() {
2111
+ super("Network mail admission is pending delivery proof.");
2112
+ }
2113
+ };
2114
+ var ContactPolicyReadRetryError = class extends NotificationRetryError {
2115
+ constructor() {
2116
+ super("Contact notification policy is temporarily unavailable. Mail remains pending while permissions are refreshed.");
2117
+ }
2118
+ };
2119
+ const unavailable = () => new ListenStateError("Contact notification preferences are unavailable or invalid. No contact notification was submitted; restart after access is restored.");
2120
+ const changed = () => new NotificationRetryError("Contact notification permission changed or expired before dispatch.");
2121
+ /** Read one connected address's policy. Partial pages never authorize delivery. */
2122
+ function createNotificationContactPolicy(options) {
2123
+ const recipient = mailAddress(options.recipient);
2124
+ const now = options.now ?? (() => performance.now());
2125
+ let snapshot;
2126
+ let retryAt = 0;
2127
+ let retryDelay = CONTACT_POLICY_RETRY_MIN_MS;
2128
+ function fresh(value) {
2129
+ if (!value) return false;
2130
+ const elapsed = now() - value.startedAt;
2131
+ return elapsed >= 0 && elapsed < 3e4;
2132
+ }
2133
+ async function refreshOnce(signal) {
2134
+ snapshot = void 0;
2135
+ signal.throwIfAborted();
2136
+ if (now() < retryAt) throw new ContactPolicyReadRetryError();
2137
+ const startedAt = now();
2138
+ const peers = /* @__PURE__ */ new Set();
2139
+ let cursor;
2140
+ try {
2141
+ const next = {
2142
+ startedAt,
2143
+ senders: /* @__PURE__ */ new Map(),
2144
+ policy: parseAgentContactPolicy(await options.readPolicy(signal), recipient)
2145
+ };
2146
+ do {
2147
+ signal.throwIfAborted();
2148
+ const page = await options.readPage(cursor, signal);
2149
+ signal.throwIfAborted();
2150
+ if (!fresh(next) || !Array.isArray(page.data) || page.data.length > 100) throw unavailable();
2151
+ let previous = cursor;
2152
+ for (const value of page.data) {
2153
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw unavailable();
2154
+ const row = value;
2155
+ const peer = mailAddress(row.contact_address);
2156
+ if (row.agent_address !== recipient || row.contact_address !== peer || previous !== void 0 && peer <= previous || peers.has(peer) || typeof row.notify !== "boolean") throw unavailable();
2157
+ mailId(row.version);
2158
+ peers.add(peer);
2159
+ previous = peer;
2160
+ if (row.notify) next.senders.set(peer, {
2161
+ notify: true,
2162
+ generation: mailId(row.notification_generation),
2163
+ notifySince: mailTime(row.notify_since)
2164
+ });
2165
+ else if (row.notification_generation !== null || row.notify_since !== null) throw unavailable();
2166
+ else next.senders.set(peer, { notify: false });
2167
+ }
2168
+ if (page.cursor === null) cursor = void 0;
2169
+ else {
2170
+ const following = mailAddress(page.cursor);
2171
+ if (!page.data.length || following !== previous) throw unavailable();
2172
+ cursor = following;
2173
+ }
2174
+ } while (cursor !== void 0);
2175
+ if (!fresh(next)) throw unavailable();
2176
+ snapshot = next;
2177
+ retryAt = 0;
2178
+ retryDelay = CONTACT_POLICY_RETRY_MIN_MS;
2179
+ return next;
2180
+ } catch (error) {
2181
+ snapshot = void 0;
2182
+ signal.throwIfAborted();
2183
+ if (error instanceof ContactPolicyReadRetryError) {
2184
+ retryAt = now() + retryDelay;
2185
+ retryDelay = Math.min(retryDelay * 2, CONTACT_POLICY_RETRY_MAX_MS);
2186
+ throw error;
2187
+ }
2188
+ throw unavailable();
2189
+ }
2190
+ }
2191
+ function admission(value, sender, receivedAt, solicited = false) {
2192
+ const decision = evaluateContactPolicy({
2193
+ policy: value.policy,
2194
+ sender,
2195
+ receivedAt,
2196
+ membership: value.senders.get(sender),
2197
+ contactRequests: options.contactRequests === true
2198
+ });
2199
+ if (decision.kind === "silent") {
2200
+ if (!solicited || decision.source !== "default") return null;
2201
+ return {
2202
+ sender,
2203
+ receivedAt,
2204
+ generation: value.policy.effective_version,
2205
+ notifySince: value.policy.effective_since,
2206
+ kind: "response",
2207
+ effectiveVersion: value.policy.effective_version
2208
+ };
2209
+ }
2210
+ return {
2211
+ sender,
2212
+ receivedAt,
2213
+ generation: decision.generation,
2214
+ notifySince: decision.notifySince,
2215
+ kind: solicited ? "response" : decision.kind,
2216
+ effectiveVersion: value.policy.effective_version
2217
+ };
2218
+ }
2219
+ async function admissionWithNetwork(value, sender, receivedAt, signal, emailId, networkReader = options.readNetworkAdmission, solicited = false) {
2220
+ const contact = admission(value, sender, receivedAt, solicited);
2221
+ const decision = evaluateContactPolicy({
2222
+ policy: value.policy,
2223
+ sender,
2224
+ receivedAt,
2225
+ membership: value.senders.get(sender),
2226
+ contactRequests: options.contactRequests === true
2227
+ });
2228
+ if (!networkReader) return contact;
2229
+ if (!emailId) throw unavailable();
2230
+ const network = await networkReader(emailId, sender, signal);
2231
+ signal.throwIfAborted();
2232
+ if (typeof network.member_policy_required !== "boolean") throw unavailable();
2233
+ if (network.sender_relation !== void 0 && (!network.member_policy_required || !["owner", "member"].includes(network.sender_relation))) throw unavailable();
2234
+ if (network.member_policy_required) {
2235
+ if (network.allowed && !network.sender_relation) throw unavailable();
2236
+ if (network.pending) throw new NetworkAdmissionPendingError();
2237
+ if (!network.allowed) return null;
2238
+ if (decision.kind === "silent" && decision.source !== "default") return null;
2239
+ return networkAdmission(value, sender, receivedAt, emailId, network, solicited);
2240
+ }
2241
+ const scopedContact = contact ? {
2242
+ ...contact,
2243
+ emailId
2244
+ } : null;
2245
+ if (contact?.kind === "allowed" || contact?.kind === "response") return scopedContact;
2246
+ if (decision.kind !== "request" && !(decision.kind === "silent" && decision.source === "default")) return scopedContact;
2247
+ if (network.pending) throw new NetworkAdmissionPendingError();
2248
+ if (!network.allowed) return scopedContact;
2249
+ return networkAdmission(value, sender, receivedAt, emailId, network, solicited);
2250
+ }
2251
+ function networkAdmission(value, sender, receivedAt, emailId, network, solicited) {
2252
+ const networkSince = network.allowed_since && mailTime(network.allowed_since);
2253
+ if (!networkSince) throw unavailable();
2254
+ const since = Date.parse(value.policy.effective_since) > Date.parse(networkSince) ? value.policy.effective_since : networkSince;
2255
+ if (Date.parse(receivedAt) < Date.parse(since)) return null;
2256
+ return {
2257
+ sender,
2258
+ emailId,
2259
+ receivedAt,
2260
+ generation: since,
2261
+ notifySince: since,
2262
+ kind: solicited ? "response" : "allowed",
2263
+ effectiveVersion: value.policy.effective_version,
2264
+ source: "network",
2265
+ ...network.sender_relation ? { senderRelation: network.sender_relation } : {}
2266
+ };
2267
+ }
2268
+ function permits(prior, current) {
2269
+ return current !== null && current.emailId === prior.emailId && current.kind === prior.kind && current.source === prior.source && current.senderRelation === prior.senderRelation && current.generation === prior.generation && current.notifySince === prior.notifySince && current.effectiveVersion === prior.effectiveVersion;
2270
+ }
2271
+ return {
2272
+ refresh: refreshOnce,
2273
+ members() {
2274
+ if (!fresh(snapshot)) throw unavailable();
2275
+ return snapshot.senders.keys();
2276
+ },
2277
+ async admit(sender, receivedAt, signal, emailId) {
2278
+ signal.throwIfAborted();
2279
+ let peer;
2280
+ try {
2281
+ peer = mailAddress(sender);
2282
+ } catch {
2283
+ return null;
2284
+ }
2285
+ const received = mailTime(receivedAt);
2286
+ const inboundId = emailId === void 0 ? void 0 : mailId(emailId);
2287
+ const reader = options.readNetworkAdmission;
2288
+ let networkResult;
2289
+ const networkReader = reader ? (id, address, abort) => networkResult ??= reader(id, address, abort) : void 0;
2290
+ const cached = fresh(snapshot);
2291
+ let current = cached && snapshot ? snapshot : await refreshOnce(signal);
2292
+ let allowed = await admissionWithNetwork(current, peer, received, signal, inboundId, networkReader);
2293
+ if (cached && allowed?.kind !== "allowed") {
2294
+ current = await refreshOnce(signal);
2295
+ allowed = await admissionWithNetwork(current, peer, received, signal, inboundId, networkReader);
2296
+ }
2297
+ return allowed;
2298
+ },
2299
+ async recheck(prior, signal) {
2300
+ const current = await refreshOnce(signal);
2301
+ const candidate = await admissionWithNetwork(current, prior.sender, prior.receivedAt, signal, prior.emailId, options.readNetworkAdmission, prior.kind === "response");
2302
+ if (!permits(prior, candidate)) throw changed();
2303
+ return () => {
2304
+ signal.throwIfAborted();
2305
+ if (!fresh(snapshot) || snapshot !== current || !permits(prior, candidate)) throw changed();
2306
+ };
2307
+ },
2308
+ async admitResponse(sender, receivedAt, signal, emailId) {
2309
+ return admissionWithNetwork(await refreshOnce(signal), mailAddress(sender), mailTime(receivedAt), signal, emailId, options.readNetworkAdmission, true);
2310
+ }
2311
+ };
2312
+ }
2313
+ //#endregion
2314
+ //#region src/oclif/agent-setup.ts
2315
+ const SUBJECT = "Connect your agent to Primitive";
2316
+ const MARKER = /\bprimitive-connection:([a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}):([1-9]\d*)\b/g;
2317
+ const fail = (message) => new AgentConnectionSetupError(message);
2318
+ const RECEIVER_MODES = [
2319
+ "native",
2320
+ "external",
2321
+ "poll"
2322
+ ];
2323
+ const RECEIPT_STATUSES = new Set([
2324
+ "queued",
2325
+ "submitted_to_agent",
2326
+ "agent_failed",
2327
+ "gate_denied",
2328
+ "unknown",
2329
+ "delivered",
2330
+ "bounced",
2331
+ "deferred",
2332
+ "wait_timeout",
2333
+ "scheduled",
2334
+ "canceled"
2335
+ ]);
2336
+ function parseReceipt(value) {
2337
+ const row = mailObject(value, ["id", "status"]);
2338
+ if (!RECEIPT_STATUSES.has(row.status)) throw fail("The verification send receipt is incomplete or invalid. Its outcome remains unknown; no verification was resent.");
2339
+ return {
2340
+ id: mailId(row.id),
2341
+ status: mailString(row.status, 100)
2342
+ };
2343
+ }
2344
+ /** Waits between status reads after the reply is submitted. The last value repeats. */
2345
+ const VERIFICATION_BACKOFF_MS = [
2346
+ 1e3,
2347
+ 1e3,
2348
+ 2e3,
2349
+ 2e3,
2350
+ 3e3,
2351
+ 5e3
2352
+ ];
2353
+ /** Both states mean the setup reply was accepted for sending. */
2354
+ function verificationReplySubmitted(state) {
2355
+ return state === "reply_submitted" || state === "verified";
2356
+ }
2357
+ /** Parse GET /agent-connections/me for the expected address. Unknown shapes are unavailable. */
2358
+ function parseVerificationCheck(value, agentAddress) {
2359
+ if (!value || typeof value !== "object") return { state: "unavailable" };
2360
+ const envelope = value;
2361
+ if (envelope.success !== true || !envelope.data) return { state: "unavailable" };
2362
+ if (typeof envelope.data !== "object") return { state: "unavailable" };
2363
+ const connection = envelope.data.connection;
2364
+ if (!connection || typeof connection !== "object") return { state: "unavailable" };
2365
+ const row = connection;
2366
+ if (typeof row.address !== "string" || row.address.toLowerCase() !== agentAddress.toLowerCase()) return { state: "unavailable" };
2367
+ if (row.status === "connected") return {
2368
+ state: "verified",
2369
+ verifiedAt: typeof row.verified_at === "string" && Number.isFinite(Date.parse(row.verified_at)) ? row.verified_at : null
2370
+ };
2371
+ if (row.status === "claimed") return { state: "pending" };
2372
+ return { state: "unavailable" };
2373
+ }
2374
+ async function checkVerification(context, timeoutMs) {
2375
+ try {
2376
+ const response = await context.fetch(`${context.profile.api_base_url}/agent-connections/me`, {
2377
+ method: "GET",
2378
+ redirect: "error",
2379
+ signal: AbortSignal.timeout(timeoutMs),
2380
+ headers: {
2381
+ authorization: `Bearer ${context.profile.api_key}`,
2382
+ accept: "application/json"
2383
+ }
2384
+ });
2385
+ if (response.status !== 200) {
2386
+ await response.body?.cancel();
2387
+ return { state: "unavailable" };
2388
+ }
2389
+ const text = await response.text();
2390
+ if (text.length > 65536) return { state: "unavailable" };
2391
+ return parseVerificationCheck(JSON.parse(text), context.identity.agentAddress);
2392
+ } catch {
2393
+ return { state: "unavailable" };
2394
+ }
2395
+ }
2396
+ /**
2397
+ * Poll until the server reports the connection verified, the budget runs out,
2398
+ * or the status read is unavailable. Elapsed time is the larger of the clock
2399
+ * and the requested waits, so slow reads count against the budget and a
2400
+ * frozen clock in tests cannot loop forever. Each read is capped at the time
2401
+ * remaining (and at five seconds).
2402
+ */
2403
+ async function awaitVerification(context, dependencies, timeoutMs) {
2404
+ const started = dependencies.now();
2405
+ let waited = 0;
2406
+ const remaining = () => timeoutMs - Math.max(waited, dependencies.now() - started);
2407
+ for (let attempt = 0;; attempt++) {
2408
+ const readBudget = Math.min(5e3, remaining());
2409
+ if (readBudget <= 0) return { state: "pending" };
2410
+ const check = await dependencies.checkVerification(context, readBudget);
2411
+ if (check.state !== "pending") return check;
2412
+ const step = VERIFICATION_BACKOFF_MS[Math.min(attempt, VERIFICATION_BACKOFF_MS.length - 1)] ?? 5e3;
2413
+ const wait = Math.min(step, remaining());
2414
+ if (wait <= 0) return check;
2415
+ await dependencies.sleep(wait);
2416
+ waited += wait;
2417
+ }
2418
+ }
2419
+ function parseState(value) {
2420
+ const keys = [
2421
+ "version",
2422
+ "session",
2423
+ "invitationHash",
2424
+ "since",
2425
+ "contactRequests",
2426
+ "challenge",
2427
+ "phase",
2428
+ "receipt"
2429
+ ];
2430
+ const row = mailObject(value, value && typeof value === "object" && Object.hasOwn(value, "receiverMode") ? [...keys, "receiverMode"] : keys);
2431
+ if (row.version !== 1 || row.receiverMode !== void 0 && !RECEIVER_MODES.includes(row.receiverMode) || typeof row.contactRequests !== "boolean" || ![
2432
+ "waiting",
2433
+ "sending",
2434
+ "sent"
2435
+ ].includes(String(row.phase))) throw fail("Saved setup state is invalid. Preserve its private files.");
2436
+ const challenge = row.challenge === null ? null : mailObject(row.challenge, [
2437
+ "id",
2438
+ "messageId",
2439
+ "marker"
2440
+ ]);
2441
+ const receipt = row.receipt === null ? null : parseReceipt(row.receipt);
2442
+ const invitationHash = mailString(row.invitationHash, 64);
2443
+ if (!/^[a-f0-9]{64}$/.test(invitationHash)) throw fail("Saved setup identity is invalid.");
2444
+ const state = {
2445
+ version: 1,
2446
+ session: row.session === null && row.receiverMode === "poll" ? null : mailId(row.session),
2447
+ receiverMode: row.receiverMode ?? "native",
2448
+ invitationHash,
2449
+ since: mailTime(row.since),
2450
+ contactRequests: row.contactRequests,
2451
+ phase: row.phase,
2452
+ challenge: challenge ? {
2453
+ id: mailId(challenge.id),
2454
+ messageId: mailString(challenge.messageId, 1e3),
2455
+ marker: mailString(challenge.marker, 100)
2456
+ } : null,
2457
+ receipt
2458
+ };
2459
+ if (state.phase !== "waiting" && !state.challenge || state.phase === "sent" && !state.receipt) throw fail("Saved setup progress is inconsistent. Preserve its private files.");
2460
+ return state;
2461
+ }
2462
+ /** Match only authenticated owner mail. Subject/search results alone grant no authority. */
2463
+ function setupChallenge(detail, identity, since) {
2464
+ if (detail.subject !== SUBJECT || detail.recipient.toLowerCase() !== identity.agentAddress || detail.to_email.toLowerCase() !== identity.agentAddress || !["accepted", "completed"].includes(detail.status) || !detail.message_id || /[\r\n]/.test(detail.message_id) || detail.parsed?.status !== "complete" || Date.parse(detail.received_at) < Date.parse(since) || !Number.isFinite(Date.parse(detail.received_at)) || !scopedChatSenderTrust(detail, identity.ownerAddress).trusted) return null;
2465
+ const markers = [...new Set((detail.body_text ?? "").match(MARKER) ?? [])];
2466
+ if (markers.length !== 1) return null;
2467
+ return {
2468
+ id: mailId(detail.id),
2469
+ messageId: detail.message_id,
2470
+ marker: markers[0] ?? ""
2471
+ };
2472
+ }
2473
+ async function findChallenge(context, since) {
2474
+ const page = await readSetupApi(context.readBudget, "challenge search", (signal) => searchEmails({
2475
+ client: context.client.client,
2476
+ query: {
2477
+ from: context.identity.ownerAddress,
2478
+ to: context.identity.agentAddress,
2479
+ subject: SUBJECT,
2480
+ date_from: since,
2481
+ limit: 10,
2482
+ snippet: "false",
2483
+ include_facets: "false"
2484
+ },
2485
+ signal,
2486
+ responseStyle: "fields"
2487
+ }));
2488
+ if (page.error || !Array.isArray(page.data?.data) || page.data.meta?.cursor !== null) throw fail("The setup challenge search is incomplete or ambiguous. No verification reply was sent.");
2489
+ const matches = [];
2490
+ for (const row of page.data.data) {
2491
+ const response = await readSetupApi(context.readBudget, "challenge detail", (signal) => getEmail({
2492
+ client: context.client.client,
2493
+ path: { id: row.id },
2494
+ signal,
2495
+ responseStyle: "fields"
2496
+ }));
2497
+ if (response.error || !response.data?.data) throw fail("The setup challenge could not be read. Resume this profile; do not claim again.");
2498
+ const match = setupChallenge(response.data.data, context.identity, since);
2499
+ if (match) matches.push(match);
2500
+ }
2501
+ if (matches.length > 1) throw fail("Several recent setup challenges match. No challenge was guessed or answered. Ask the owner to confirm the current challenge.");
2502
+ return matches[0] ?? null;
2503
+ }
2504
+ async function enableOwner(context) {
2505
+ let lastReadError;
2506
+ const policy = createNotificationContactPolicy({
2507
+ now: context.readBudget.now,
2508
+ recipient: context.identity.agentAddress,
2509
+ async readPolicy() {
2510
+ const result = await readSetupApi(context.readBudget, "owner notification policy", (signal) => getAgentContactPolicy({
2511
+ client: context.client.client,
2512
+ path: { agent_address: context.identity.agentAddress },
2513
+ signal,
2514
+ responseStyle: "fields"
2515
+ })).catch((error) => {
2516
+ lastReadError = error;
2517
+ throw error;
2518
+ });
2519
+ if (result.data?.success !== true || !result.data.data) throw fail("The owner notification policy response is incomplete. Resume the saved profile; no preference was changed.");
2520
+ return result.data.data;
2521
+ },
2522
+ async readPage(cursor) {
2523
+ const result = await readSetupApi(context.readBudget, "owner contact preferences", (signal) => listAgentContacts({
2524
+ client: context.client.client,
2525
+ path: { agent_address: context.identity.agentAddress },
2526
+ query: {
2527
+ limit: 100,
2528
+ ...cursor ? { cursor } : {}
2529
+ },
2530
+ signal,
2531
+ responseStyle: "fields"
2532
+ })).catch((error) => {
2533
+ lastReadError = error;
2534
+ throw error;
2535
+ });
2536
+ if (result.data?.success !== true) throw fail("The owner contact preferences response is incomplete. Resume the saved profile; no preference was changed.");
2537
+ return {
2538
+ data: result.data.data,
2539
+ cursor: result.data.meta?.cursor
2540
+ };
2541
+ }
2542
+ });
2543
+ async function refresh() {
2544
+ for (let attempt = 0;; attempt++) {
2545
+ lastReadError = void 0;
2546
+ const retries = context.readBudget.retries;
2547
+ try {
2548
+ return await policy.refresh(context.signal);
2549
+ } catch (error) {
2550
+ if (lastReadError) throw lastReadError;
2551
+ if (attempt >= 2 || context.readBudget.retries === retries) throw error;
2552
+ }
2553
+ }
2554
+ }
2555
+ const current = await refresh();
2556
+ const member = current.senders.get(context.identity.ownerAddress);
2557
+ const decision = evaluateContactPolicy({
2558
+ policy: current.policy,
2559
+ sender: context.identity.ownerAddress,
2560
+ receivedAt: (/* @__PURE__ */ new Date()).toISOString(),
2561
+ membership: member,
2562
+ contactRequests: false
2563
+ });
2564
+ if (member?.notify === false || decision.kind === "silent" && ["agent", "org"].includes(decision.source)) return "silenced";
2565
+ if (!member) await runContactRequest(context.client.client, {
2566
+ target: "agent",
2567
+ action: "add",
2568
+ agent: context.identity.agentAddress,
2569
+ address: context.identity.ownerAddress,
2570
+ notify: true,
2571
+ purpose: "Messages from this agent's owner",
2572
+ signal: context.signal
2573
+ });
2574
+ await refresh();
2575
+ return (await policy.admit(context.identity.ownerAddress, (/* @__PURE__ */ new Date()).toISOString(), context.signal))?.kind === "allowed" ? "enabled" : "silenced";
2576
+ }
2577
+ /** Probe that this exact session is loaded and reachable for native receiving. */
2578
+ async function nativeSessionPreflight(session) {
2579
+ (await connectNativeSession({
2580
+ threadId: session,
2581
+ expectedCwd: process.cwd(),
2582
+ signal: AbortSignal.timeout(1e4)
2583
+ })).close();
2584
+ }
2585
+ function defaults$1() {
2586
+ return {
2587
+ checkVerification,
2588
+ now: Date.now,
2589
+ sleep: async (ms) => {
2590
+ await setTimeout$1(ms);
2591
+ },
2592
+ preflight: nativeSessionPreflight,
2593
+ findChallenge,
2594
+ async sendVerification(context, challenge, key) {
2595
+ const result = await sendEmail({
2596
+ client: context.client.client,
2597
+ headers: { "Idempotency-Key": key },
2598
+ body: {
2599
+ from: context.identity.agentAddress,
2600
+ to: context.identity.ownerAddress,
2601
+ subject: `Re: ${SUBJECT}`,
2602
+ body_text: challenge.marker,
2603
+ in_reply_to: challenge.messageId,
2604
+ references: [challenge.messageId]
2605
+ },
2606
+ signal: context.signal,
2607
+ responseStyle: "fields"
2608
+ });
2609
+ if (result.error || !result.data?.data) throw fail("The verification send outcome is unknown. Resume this saved profile; do not resend or claim again.");
2610
+ return {
2611
+ id: mailId(result.data.data.id),
2612
+ status: result.data.data.status
2613
+ };
2614
+ },
2615
+ async reconcile(context, key) {
2616
+ const page = (await readSetupApi(context.readBudget, "verification send lookup", (signal) => listSentEmails({
2617
+ client: context.client.client,
2618
+ query: {
2619
+ idempotency_key: key,
2620
+ limit: 2
2621
+ },
2622
+ signal,
2623
+ responseStyle: "fields"
2624
+ }))).data;
2625
+ if (!Array.isArray(page?.data) || page.meta?.cursor !== null || page.data.length > 1) throw fail("The verification send lookup is incomplete or ambiguous. Its outcome remains unknown; no verification was resent.");
2626
+ const result = page.data[0];
2627
+ if (!result) return null;
2628
+ if (result.client_idempotency_key !== key || result.from_address !== context.identity.agentAddress || result.to_address !== context.identity.ownerAddress) throw fail("The verification send lookup returned conflicting identity metadata. Its outcome remains unknown; no verification was resent.");
2629
+ return {
2630
+ id: mailId(result.id),
2631
+ status: result.status
2632
+ };
2633
+ },
2634
+ enableOwner,
2635
+ async startListener(profile, session, contactRequests, configDir) {
2636
+ const entry = process.argv[1];
2637
+ if (!entry) throw fail("CLI entrypoint unavailable. Resume setup using the installed CLI.");
2638
+ const { stdout } = await promisify(execFile)(process.execPath, [
2639
+ entry,
2640
+ "listen",
2641
+ "--background",
2642
+ "--notify-session",
2643
+ session,
2644
+ "--contacts",
2645
+ ...contactRequests ? ["--contact-requests"] : []
2646
+ ], {
2647
+ env: {
2648
+ ...process.env,
2649
+ [AGENT_PROFILE_ENV]: profile,
2650
+ PRIMITIVE_CONFIG_DIR: configDir
2651
+ },
2652
+ timeout: 35e3,
2653
+ maxBuffer: 65536
2654
+ });
2655
+ const result = JSON.parse(stdout);
2656
+ if (!result || typeof result !== "object") return false;
2657
+ const status = result.status;
2658
+ return status?.healthy === true && status.phase === "receiving";
2659
+ }
2660
+ };
2661
+ }
2662
+ /**
2663
+ * Saved choices are reused when an option is omitted. Only an explicit option
2664
+ * that disagrees with the saved setup is refused, and the message names it so
2665
+ * the caller can drop or correct that one option.
2666
+ */
2667
+ function refuseSetupConflicts(state, params) {
2668
+ const conflicts = [
2669
+ ...(params.session === void 0 ? state.receiverMode !== "poll" : state.session !== params.session) ? [state.session ? `--session (this profile is bound to session ${state.session})` : "--session (this profile's setup binds no session)"] : [],
2670
+ ...params.receiverMode !== void 0 && state.receiverMode !== params.receiverMode ? [`--receiver ${params.receiverMode} (saved: ${state.receiverMode})`] : [],
2671
+ ...params.contactRequests !== void 0 && state.contactRequests !== params.contactRequests ? [state.contactRequests ? "contact requests off (saved setup has --contact-requests)" : "--contact-requests (saved setup did not enable it)"] : []
2672
+ ];
2673
+ 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."}`);
2674
+ }
2675
+ /** One resumable operation owns setup plumbing; it never stores or replays the invitation. */
2676
+ async function setupAgent(params) {
2677
+ const profileName = agentProfileName(params.profileName);
2678
+ if (params.session !== void 0 && !SESSION_UUID.test(params.session)) throw fail("--session requires the exact loaded session UUID.");
2679
+ if (params.resume ? params.invitation !== void 0 : params.invitation === void 0) throw fail("Use an invitation on stdin for setup, or --resume without an invitation.");
2680
+ const timeoutMs = params.timeoutMs ?? 3e4;
2681
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 12e4) throw fail("Setup timeout must be between zero and two minutes.");
2682
+ const verificationTimeoutMs = params.verificationTimeoutMs ?? 6e4;
2683
+ if (!Number.isSafeInteger(verificationTimeoutMs) || verificationTimeoutMs < 0 || verificationTimeoutMs > 12e4) throw fail("Verification wait must be between zero and two minutes.");
2684
+ const dependencies = {
2685
+ ...defaults$1(),
2686
+ ...params.dependencies
2687
+ };
2688
+ const directory = agentProfileDirectory(params.configDir, profileName);
2689
+ let saved = null;
2690
+ try {
2691
+ const value = readMailJson(join(directory, "setup.json"));
2692
+ saved = value === null ? null : parseState(value);
2693
+ } catch {}
2694
+ if (saved) refuseSetupConflicts(saved, params);
2695
+ const receiverMode = params.receiverMode ?? (params.resume ? saved?.receiverMode : void 0);
2696
+ 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.");
2697
+ if ((receiverMode ?? "native") === "native" && params.session) try {
2698
+ await dependencies.preflight(params.session);
2699
+ } catch {
2700
+ throw fail("This exact session is not available for native receiving. No invitation was claimed. Open a supported session and retry setup.");
2701
+ }
2702
+ privateMailDirectory(directory, true);
2703
+ const release = acquireListenLock(directory, "setup");
2704
+ try {
2705
+ const path = join(directory, "setup.json");
2706
+ const value = readMailJson(path);
2707
+ let state = value === null ? null : parseState(value);
2708
+ if (state) refuseSetupConflicts(state, params);
2709
+ 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.");
2710
+ if (params.resume && !state) throw fail("No resumable setup exists for this profile. Do not adopt another session's identity.");
2711
+ if (!params.resume) {
2712
+ const invitation = parseAgentInvitation(params.invitation ?? "");
2713
+ const hash = createHash("sha256").update(invitation.apiBaseUrl).update("\0").update(invitation.token).digest("hex");
2714
+ if (state && state.invitationHash !== hash) throw fail(profileAlreadyConnectedMessage(profileName, loadConnectedAgentProfile(params.configDir, profileName)?.agent_address ?? null));
2715
+ if (!state) {
2716
+ state = {
2717
+ version: 1,
2718
+ session: params.session ?? null,
2719
+ receiverMode: params.receiverMode ?? "native",
2720
+ invitationHash: hash,
2721
+ since: (/* @__PURE__ */ new Date(dependencies.now() - 15 * 6e4)).toISOString(),
2722
+ contactRequests: Boolean(params.contactRequests),
2723
+ phase: "waiting",
2724
+ challenge: null,
2725
+ receipt: null
2726
+ };
2727
+ writeMailJson(path, state);
2728
+ }
2729
+ await connectAgent({
2730
+ configDir: params.configDir,
2731
+ profileName,
2732
+ invitation: params.invitation ?? "",
2733
+ fetch: params.fetch,
2734
+ presence: true
2735
+ });
2736
+ }
2737
+ const profile = loadConnectedAgentProfile(params.configDir, profileName);
2738
+ 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.");
2739
+ const setupBudgetMs = Math.max(9e4, timeoutMs + 3e4);
2740
+ const readBudget = {
2741
+ deadline: dependencies.now() + setupBudgetMs,
2742
+ now: dependencies.now,
2743
+ sleep: dependencies.sleep,
2744
+ signal: AbortSignal.timeout(setupBudgetMs),
2745
+ retries: 0
2746
+ };
2747
+ const context = {
2748
+ identity: connectedAgentIdentity(profileName, profile),
2749
+ profile,
2750
+ client: new PrimitiveApiClient({
2751
+ apiKey: profile.api_key,
2752
+ apiBaseUrl: profile.api_base_url,
2753
+ fetch: params.fetch
2754
+ }),
2755
+ fetch: params.fetch ?? fetch,
2756
+ signal: readBudget.signal,
2757
+ readBudget
2758
+ };
2759
+ let confirmed = null;
2760
+ const submittedState = () => state?.phase === "sent" ? [
2761
+ "agent_failed",
2762
+ "gate_denied",
2763
+ "bounced",
2764
+ "canceled"
2765
+ ].includes(state.receipt?.status ?? "") ? "reply_failed" : ["unknown", "wait_timeout"].includes(state.receipt?.status ?? "") ? "delivery_unknown" : "reply_submitted" : state?.phase === "sending" ? "send_unknown" : "challenge_pending";
2766
+ const result = (receiving, ownerNotifications) => ({
2767
+ identity: context.identity,
2768
+ sessionId: params.session ?? state?.session ?? null,
2769
+ verification: {
2770
+ state: confirmed && submittedState() === "reply_submitted" ? "verified" : submittedState(),
2771
+ ...confirmed ? { verifiedAt: confirmed.verifiedAt } : {},
2772
+ ...state?.receipt ? {
2773
+ sentId: state.receipt.id,
2774
+ deliveryStatus: state.receipt.status
2775
+ } : {}
2776
+ },
2777
+ receiving: { state: receiving },
2778
+ ...ownerNotifications ? { ownerNotifications } : {},
2779
+ resumeCommand: `primitive agent connect --profile ${profileName}${state?.session ? ` --session ${state.session}` : ""}${state?.receiverMode === "external" || state?.receiverMode === "poll" ? ` --receiver ${state.receiverMode}` : ""} --resume${state?.contactRequests ? " --contact-requests" : ""} --json`,
2780
+ guidance: confirmed ? "Primitive verified this connection. Receiving health is reported separately. Keep this profile for all mail commands." : submittedState() === "reply_submitted" ? "The verification reply was submitted and Primitive has not confirmed it yet. Verification usually completes within seconds; check it later with GET /agent-connections/me. Receiving health is reported separately. Keep this profile for all mail commands." : "Reply submission is not proof of delivery or app verification. Receiving health is reported separately. Keep this profile for all mail commands."
2781
+ });
2782
+ const key = `connection-verification-${state.invitationHash}`;
2783
+ if (state.phase === "sending") {
2784
+ const receipt = await dependencies.reconcile(context, key);
2785
+ if (!receipt) return result("not_started");
2786
+ state.receipt = parseReceipt(receipt);
2787
+ state.phase = "sent";
2788
+ writeMailJson(path, state);
2789
+ }
2790
+ if (state.phase === "waiting") {
2791
+ const deadline = dependencies.now() + timeoutMs;
2792
+ do {
2793
+ state.challenge = await dependencies.findChallenge(context, state.since);
2794
+ if (state.challenge || dependencies.now() >= deadline) break;
2795
+ await dependencies.sleep(Math.min(1500, deadline - dependencies.now()));
2796
+ } while (dependencies.now() <= deadline);
2797
+ if (!state.challenge) return result("not_started");
2798
+ state.phase = "sending";
2799
+ writeMailJson(path, state);
2800
+ try {
2801
+ state.receipt = parseReceipt(await dependencies.sendVerification({
2802
+ ...context,
2803
+ signal: AbortSignal.any([context.signal, AbortSignal.timeout(3e4)])
2804
+ }, state.challenge, key));
2805
+ state.phase = "sent";
2806
+ writeMailJson(path, state);
2807
+ } catch {
2808
+ return result("not_started");
2809
+ }
2810
+ }
2811
+ const ownerNotifications = await dependencies.enableOwner(context);
2812
+ let healthy = false;
2813
+ if ((state.receiverMode ?? "native") === "native" && state.session) try {
2814
+ healthy = await dependencies.startListener(profileName, state.session, state.contactRequests, params.configDir);
2815
+ } catch {}
2816
+ if (verificationTimeoutMs > 0 && submittedState() === "reply_submitted") {
2817
+ const check = await awaitVerification(context, dependencies, verificationTimeoutMs);
2818
+ if (check.state === "verified") confirmed = { verifiedAt: check.verifiedAt };
2819
+ }
2820
+ return result(state.receiverMode === "external" ? "external_setup_required" : state.receiverMode === "poll" ? "poll" : healthy ? "healthy" : "not_ready", ownerNotifications);
2821
+ } finally {
2822
+ release();
2823
+ }
2824
+ }
2825
+ //#endregion
2826
+ //#region src/oclif/owner-member-address.ts
2827
+ const REFRESH_TIMEOUT_MS = 5e3;
2828
+ const MAX_RESPONSE_BYTES = 64 * 1024;
2829
+ /** Reads at most MAX_RESPONSE_BYTES, stopping as soon as the body exceeds it. */
2830
+ async function boundedText(response) {
2831
+ const reader = response.body?.getReader();
2832
+ if (!reader) return null;
2833
+ const chunks = [];
2834
+ let size = 0;
2835
+ try {
2836
+ for (;;) {
2837
+ const part = await reader.read();
2838
+ if (part.done) break;
2839
+ size += part.value.byteLength;
2840
+ if (size > MAX_RESPONSE_BYTES) return null;
2841
+ chunks.push(part.value);
2842
+ }
2843
+ return Buffer.concat(chunks).toString("utf8");
2844
+ } finally {
2845
+ await reader.cancel().catch(() => void 0);
2846
+ }
2847
+ }
2848
+ /**
2849
+ * Reads the owner's personal mailbox from the connection's own record
2850
+ * (GET /agent-connections/me) and saves it in the profile, so an agent paired
2851
+ * before the server reported it, or before its owner set one up, learns where
2852
+ * to send reports. Best effort: any failure keeps the saved value. Returns the
2853
+ * address to report to, or null when there is none or it is unknown.
2854
+ */
2855
+ async function refreshOwnerMemberAddress(params) {
2856
+ const profile = loadConnectedAgentProfile(params.configDir, params.profileName);
2857
+ if (!profile) return null;
2858
+ const saved = profile.owner_member_address ?? null;
2859
+ try {
2860
+ const response = await (params.fetch ?? fetch)(`${profile.api_base_url}/agent-connections/me`, {
2861
+ headers: { authorization: `Bearer ${profile.api_key}` },
2862
+ redirect: "error",
2863
+ signal: AbortSignal.timeout(params.timeoutMs ?? REFRESH_TIMEOUT_MS)
2864
+ });
2865
+ if (!response.ok) return saved;
2866
+ const text = await boundedText(response);
2867
+ if (text === null) return saved;
2868
+ const body = JSON.parse(text);
2869
+ const connection = body?.data?.connection;
2870
+ if (body?.success !== true || !connection || !Object.hasOwn(connection, "owner_member_address") || mailAddress(connection.address) !== profile.agent_address || mailAddress(connection.owner_address) !== profile.owner_address) return saved;
2871
+ const current = parseOwnerMemberAddress(connection.owner_member_address, profile.owner_address, profile.agent_address);
2872
+ if (profile.owner_member_address !== current) saveConnectedAgentProfile(params.configDir, params.profileName, {
2873
+ ...profile,
2874
+ owner_member_address: current
2875
+ });
2876
+ return current;
2877
+ } catch {
2878
+ return saved;
2879
+ }
2880
+ }
2881
+ /** One sentence telling the agent where its reports go. */
2882
+ function ownerReportGuidance(identity) {
2883
+ return identity.ownerMemberAddress ? `Send reports and questions to your owner's personal address ${identity.ownerMemberAddress}; ${identity.ownerAddress} is the setup and presence control address and nobody reads it.` : `No personal owner address is known, so reply to the member who wrote to you; never send reports to ${identity.ownerAddress}, the setup and presence control address.`;
2884
+ }
2885
+ /**
2886
+ * Adds the current personal address and report guidance to a result that
2887
+ * carries a connected identity, refreshing the saved profile first.
2888
+ */
2889
+ async function withOwnerMemberAddress(result, params) {
2890
+ const saved = loadConnectedAgentProfile(params.configDir, result.identity.profileName);
2891
+ const ownerMemberAddress = params.onlyIfUnknown && typeof saved?.owner_member_address === "string" ? saved.owner_member_address : await refreshOwnerMemberAddress({
2892
+ configDir: params.configDir,
2893
+ profileName: result.identity.profileName,
2894
+ fetch: params.fetch
2895
+ });
2896
+ const identity = {
2897
+ ...result.identity,
2898
+ ownerMemberAddress
2899
+ };
2900
+ return {
2901
+ ...result,
2902
+ identity,
2903
+ ownerReportGuidance: ownerReportGuidance(identity)
2904
+ };
2905
+ }
2906
+ //#endregion
2907
+ //#region src/oclif/agent-connect-flow.ts
2908
+ /** What this CLI version can do in one `agent connect --session` call. */
2909
+ const AGENT_CONNECT_CAPABILITIES = [
2910
+ "invitation_claim_from_stdin",
2911
+ "verification_reply",
2912
+ "native_background_receiver",
2913
+ "claude_external_hooks",
2914
+ "bundled_skill_install",
2915
+ "agent_info_seed",
2916
+ "resume",
2917
+ "poll_receiver"
2918
+ ];
2919
+ const AGENT_INFO_NOTE = "AGENT_INFO";
2920
+ const MAX_NAME_LENGTH = 80;
2921
+ const MAX_INFO_LENGTH = 1e3;
2922
+ const MAIL_CHECK_WAIT_MS = 2e4;
2923
+ const PENDING_AGENT_INFO_FILE = "agent-info-pending.json";
2924
+ function readPendingAgentInfo(path) {
2925
+ try {
2926
+ const saved = readMailJson(path);
2927
+ if (saved && typeof saved === "object" && !Array.isArray(saved) && saved.version === 1) {
2928
+ const value = saved.value;
2929
+ if (typeof value === "string" && value.length > 0) return value;
2930
+ }
2931
+ } catch {}
2932
+ return null;
2933
+ }
2934
+ function savedSetup(configDir, profileName) {
2935
+ try {
2936
+ const saved = readMailJson(join(agentProfileDirectory(configDir, profileName), "setup.json"));
2937
+ if (!saved || typeof saved !== "object" || Array.isArray(saved)) return null;
2938
+ const row = saved;
2939
+ return {
2940
+ receiverMode: row.receiverMode === "external" || row.receiverMode === "poll" ? row.receiverMode : "native",
2941
+ contactRequests: row.contactRequests === true
2942
+ };
2943
+ } catch {
2944
+ return null;
2945
+ }
2946
+ }
2947
+ /** Best effort: a lost pending note only means --name/--info must be passed again. */
2948
+ function savePendingAgentInfo(path, value) {
2949
+ try {
2950
+ writeMailJson(path, {
2951
+ version: 1,
2952
+ value
2953
+ });
2954
+ } catch {}
2955
+ }
2956
+ function clearPendingAgentInfo(path) {
2957
+ try {
2958
+ removeMailFile(path);
2959
+ } catch {}
2960
+ }
2961
+ function defaultAgentProfileName(session) {
2962
+ return `session-${session.toLowerCase()}`;
2963
+ }
2964
+ /**
2965
+ * The profile for a setup that binds no session. It is derived from the
2966
+ * invitation, so running the same invitation again finds the same profile and
2967
+ * its saved claim instead of claiming twice. A one-way hash prefix reveals
2968
+ * nothing usable about the invitation.
2969
+ */
2970
+ function invitationProfileName(invitation) {
2971
+ const parsed = parseAgentInvitation(invitation);
2972
+ return `connection-${createHash("sha256").update(parsed.apiBaseUrl).update("\0").update(parsed.token).digest("hex").slice(0, 12)}`;
2973
+ }
2974
+ /** The command that checks a poll-receiving profile for new mail. */
2975
+ function pollCheckCommand(invocation, profileName) {
2976
+ return `PRIMITIVE_AGENT_PROFILE=${profileName} ${invocation} agent check-mail --json`;
2977
+ }
2978
+ /** A short private role note; never a transcript or secret. */
2979
+ function agentInfoValue(name, info) {
2980
+ const clean = (value, max, label) => {
2981
+ if (value === void 0) return void 0;
2982
+ const trimmed = value.trim();
2983
+ if (!trimmed || trimmed.length > max || [...trimmed].some((character) => {
2984
+ const code = character.charCodeAt(0);
2985
+ return code < 32 && character !== "\n" || code === 127;
2986
+ })) throw new AgentConnectionSetupError(`--${label} must be 1-${max} characters without control characters.`);
2987
+ return trimmed;
2988
+ };
2989
+ const cleanName = clean(name, MAX_NAME_LENGTH, "name");
2990
+ const cleanInfo = clean(info, MAX_INFO_LENGTH, "info");
2991
+ if (cleanName && cleanInfo) return `${cleanName}: ${cleanInfo}`;
2992
+ return cleanName ?? cleanInfo ?? null;
2993
+ }
2994
+ function defaults(options) {
2995
+ return {
2996
+ setupAgent,
2997
+ nativePreflight: nativeSessionPreflight,
2998
+ installClaudeWakeHook,
2999
+ installSkill(runtime) {
3000
+ return installConnectSkill({
3001
+ bundle: readBundledConnectSkill(options.packageRoot),
3002
+ runtime,
3003
+ project: options.project,
3004
+ cwd: options.cwd,
3005
+ env: options.env
3006
+ });
3007
+ },
3008
+ seedAgentInfo: (profileName, value) => seedAgentInfoNote(options.configDir, profileName, value),
3009
+ refreshOwnerMemberAddress: (profileName) => refreshOwnerMemberAddress({
3010
+ configDir: options.configDir,
3011
+ profileName
3012
+ }),
3013
+ async awaitMailCheck(profileName, since) {
3014
+ const deadline = Date.now() + (options.mailCheckWaitMs ?? MAIL_CHECK_WAIT_MS);
3015
+ for (;;) {
3016
+ const status = agentConnectionStatus(options.configDir, profileName);
3017
+ const at = status.status === "configured" ? status.receiving.lastSuccessfulMailCheckAt : null;
3018
+ if (at && Date.parse(at) >= since) return {
3019
+ state: "confirmed",
3020
+ lastSuccessfulMailCheckAt: at
3021
+ };
3022
+ if (Date.now() >= deadline) return {
3023
+ state: "pending",
3024
+ lastSuccessfulMailCheckAt: null
3025
+ };
3026
+ await setTimeout$1(250);
3027
+ }
3028
+ }
3029
+ };
3030
+ }
3031
+ /** Create the private AGENT_INFO note only when the address has none. */
3032
+ async function seedAgentInfoNote(configDir, profileName, value, fetchImpl) {
3033
+ const profile = loadConnectedAgentProfile(configDir, profileName);
3034
+ if (!profile) return "failed";
3035
+ const client = new PrimitiveApiClient({
3036
+ apiKey: profile.api_key,
3037
+ apiBaseUrl: profile.api_base_url,
3038
+ ...fetchImpl ? { fetch: fetchImpl } : {}
3039
+ });
3040
+ try {
3041
+ await runAddressNotesRequest(client.client, {
3042
+ action: "set",
3043
+ address: profile.agent_address,
3044
+ name: AGENT_INFO_NOTE,
3045
+ value,
3046
+ visibility: "private",
3047
+ ifAbsent: true
3048
+ });
3049
+ return "created";
3050
+ } catch (error) {
3051
+ if (error instanceof AddressNotesApiError && error.status === 409) return "already_present";
3052
+ return "failed";
3053
+ }
3054
+ }
3055
+ /**
3056
+ * One call: self check, install the matching skill, claim and verify, start
3057
+ * receiving, seed AGENT_INFO, and report everything as one result. Every step
3058
+ * after the claim is safe to repeat with --resume.
3059
+ */
3060
+ async function runAgentConnect(options) {
3061
+ const env = options.env ?? process.env;
3062
+ const dependencies = {
3063
+ ...defaults(options),
3064
+ ...Object.fromEntries(Object.entries(options.dependencies ?? {}).filter(([, value]) => value !== void 0))
3065
+ };
3066
+ const session = options.session?.trim() ? options.session : void 0;
3067
+ if (session !== void 0 && !SESSION_UUID.test(session)) throw new AgentConnectionSetupError("--session requires the exact loaded session UUID.");
3068
+ if (session === void 0 && options.receiver && options.receiver !== "poll") throw new AgentConnectionSetupError(`--receiver ${options.receiver} requires the exact loaded session UUID. Without one, use --receiver poll. No invitation was claimed.`);
3069
+ if (session === void 0 && options.resume && !options.profileName) throw new AgentConnectionSetupError("Pass --profile to resume a setup that binds no session.");
3070
+ const runtime = session ? detectAgentRuntime(session, env) : null;
3071
+ const invitation = options.resume || session || options.profileName ? void 0 : await options.readInvitation();
3072
+ const profileName = agentProfileName(options.profileName ?? (session ? defaultAgentProfileName(session) : invitationProfileName(invitation ?? "")));
3073
+ const receiver = options.receiver ?? (options.resume ? savedSetup(options.configDir, profileName)?.receiverMode ?? null : null) ?? (!session ? "poll" : runtime === "claude" ? "external" : "native");
3074
+ if (receiver === "external" && env.CLAUDE_CODE_SESSION_ID?.trim().toLowerCase() !== session?.toLowerCase()) throw new AgentConnectionSetupError("External receiving requires this exact Claude session ID. No invitation was claimed.");
3075
+ const agentInfo = agentInfoValue(options.name, options.info);
3076
+ const skipped = [];
3077
+ let skill;
3078
+ if (options.skill === false) skill = {
3079
+ state: "skipped",
3080
+ runtime,
3081
+ path: null,
3082
+ version: null,
3083
+ reason: "not_requested"
3084
+ };
3085
+ else if (!runtime) skill = {
3086
+ state: "skipped",
3087
+ runtime: null,
3088
+ path: null,
3089
+ version: null,
3090
+ reason: "runtime_unknown"
3091
+ };
3092
+ else try {
3093
+ skill = dependencies.installSkill(runtime);
3094
+ } catch {
3095
+ skill = {
3096
+ state: "failed",
3097
+ runtime,
3098
+ path: null,
3099
+ version: null,
3100
+ reason: "bundled_skill_unavailable"
3101
+ };
3102
+ }
3103
+ if (skill.state === "skipped" || skill.state === "failed") skipped.push({
3104
+ step: "skill",
3105
+ reason: skill.reason ?? skill.state
3106
+ });
3107
+ const startedAt = Date.now();
3108
+ const setupInvitation = options.resume ? void 0 : invitation ?? await options.readInvitation();
3109
+ if (options.beforeSetup) {
3110
+ if (receiver === "native" && session) try {
3111
+ await dependencies.nativePreflight(session);
3112
+ } catch {
3113
+ throw new AgentConnectionSetupError("This exact session is not available for native receiving. No invitation was claimed and nothing was changed. Open a supported session and retry setup.");
3114
+ }
3115
+ await options.beforeSetup();
3116
+ }
3117
+ const result = await dependencies.setupAgent({
3118
+ configDir: options.configDir,
3119
+ profileName,
3120
+ ...session ? { session } : {},
3121
+ receiverMode: receiver,
3122
+ resume: options.resume,
3123
+ contactRequests: options.contactRequests,
3124
+ ...setupInvitation === void 0 ? {} : { invitation: setupInvitation }
3125
+ });
3126
+ const verified = verificationReplySubmitted(result.verification.state);
3127
+ const ownerMemberAddress = await dependencies.refreshOwnerMemberAddress(result.identity.profileName);
3128
+ const identity = {
3129
+ ...result.identity,
3130
+ ownerMemberAddress
3131
+ };
3132
+ let externalHook = null;
3133
+ if (receiver === "external") if (verified) externalHook = dependencies.installClaudeWakeHook({
3134
+ cliPath: options.cliPath,
3135
+ configDir: options.configDir,
3136
+ profileName: result.identity.profileName,
3137
+ agentAddress: result.identity.agentAddress,
3138
+ sessionId: session ?? "",
3139
+ env
3140
+ });
3141
+ else skipped.push({
3142
+ step: "receiver",
3143
+ reason: "pending_verification"
3144
+ });
3145
+ let mailCheck = null;
3146
+ if (receiver === "native") if (result.receiving.state === "healthy") {
3147
+ mailCheck = await dependencies.awaitMailCheck(profileName, startedAt);
3148
+ if (mailCheck.state === "pending") skipped.push({
3149
+ step: "mail_check",
3150
+ reason: "not_yet_observed"
3151
+ });
3152
+ } else skipped.push({
3153
+ step: "receiver",
3154
+ reason: verified ? "not_ready" : "pending_verification"
3155
+ });
3156
+ const pendingPath = join(agentProfileDirectory(options.configDir, profileName), PENDING_AGENT_INFO_FILE);
3157
+ const noteValue = agentInfo ?? readPendingAgentInfo(pendingPath);
3158
+ let agentInfoState = "not_requested";
3159
+ if (noteValue === null) skipped.push({
3160
+ step: "agent_info",
3161
+ reason: "not_requested"
3162
+ });
3163
+ else if (!verified) {
3164
+ agentInfoState = "pending_verification";
3165
+ savePendingAgentInfo(pendingPath, noteValue);
3166
+ skipped.push({
3167
+ step: "agent_info",
3168
+ reason: "pending_verification"
3169
+ });
3170
+ } else {
3171
+ agentInfoState = await dependencies.seedAgentInfo(profileName, noteValue);
3172
+ if (agentInfoState === "failed") {
3173
+ savePendingAgentInfo(pendingPath, noteValue);
3174
+ skipped.push({
3175
+ step: "agent_info",
3176
+ reason: "failed"
3177
+ });
3178
+ } else clearPendingAgentInfo(pendingPath);
3179
+ if (agentInfoState === "already_present") skipped.push({
3180
+ step: "agent_info",
3181
+ reason: "already_present"
3182
+ });
3183
+ }
3184
+ const invocation = options.invocation ?? "primitive";
3185
+ const receiving = receiver === "external" ? {
3186
+ ...result.receiving,
3187
+ mode: "external",
3188
+ hook: externalHook
3189
+ } : receiver === "poll" ? {
3190
+ ...result.receiving,
3191
+ mode: "poll",
3192
+ checkCommand: pollCheckCommand(invocation, result.identity.profileName)
3193
+ } : {
3194
+ ...result.receiving,
3195
+ mode: "native",
3196
+ mailCheck: mailCheck?.state ?? "not_started",
3197
+ lastSuccessfulMailCheckAt: mailCheck?.lastSuccessfulMailCheckAt ?? null
3198
+ };
3199
+ const receiverReady = receiver === "external" ? externalHook === "installed_unverified" : receiver === "poll" || result.receiving.state === "healthy";
3200
+ const resumeCommand = [
3201
+ invocation,
3202
+ "agent connect",
3203
+ `--profile ${result.identity.profileName}`,
3204
+ ...session ? [`--session ${session}`] : [],
3205
+ `--receiver ${receiver}`,
3206
+ "--resume",
3207
+ ...options.contactRequests ?? savedSetup(options.configDir, profileName)?.contactRequests ? ["--contact-requests"] : [],
3208
+ ...options.skill === false ? ["--no-skill"] : [],
3209
+ ...options.project ? ["--project"] : [],
3210
+ "--json"
3211
+ ].join(" ");
3212
+ return {
3213
+ status: verified && receiverReady ? "connected" : "pending",
3214
+ address: result.identity.agentAddress,
3215
+ orgId: result.identity.orgId,
3216
+ ownerAddress: result.identity.ownerAddress,
3217
+ ownerMemberAddress,
3218
+ profile: result.identity.profileName,
3219
+ sessionId: session ?? null,
3220
+ runtime,
3221
+ cli: {
3222
+ version: options.cliVersion,
3223
+ capabilities: [...AGENT_CONNECT_CAPABILITIES]
3224
+ },
3225
+ skill,
3226
+ verification: result.verification,
3227
+ receiving,
3228
+ ...result.ownerNotifications ? { ownerNotifications: result.ownerNotifications } : {},
3229
+ agentInfo: agentInfoState,
3230
+ skipped,
3231
+ selectProfile: `PRIMITIVE_AGENT_PROFILE=${result.identity.profileName}`,
3232
+ resumeCommand,
3233
+ guidance: `${result.guidance} ${ownerReportGuidance(identity)}`,
3234
+ identity,
3235
+ externalHook
3236
+ };
3237
+ }
3238
+ //#endregion
3239
+ //#region src/oclif/agent-identity-suggestions.ts
3240
+ /** The connection name enrollment uses when none is given. */
3241
+ const DEFAULT_ENROLL_NAME = "Coding agent";
3242
+ /** npx runs the CLI from its cache; print follow-up commands the same way. */
3243
+ function cliInvocation(entry) {
3244
+ return entry && /[\\/]_npx[\\/]/.test(entry) ? "npx -y primitive@latest" : "primitive";
3245
+ }
3246
+ /**
3247
+ * Whether an enrollment's connection name is the fallback it chose by itself.
3248
+ * Session registration's `<runtime>-<repository>` names are reported by
3249
+ * registration, which knows the exact name it generated.
3250
+ */
3251
+ function generatedAgentName(name) {
3252
+ return name === DEFAULT_ENROLL_NAME;
3253
+ }
3254
+ /**
3255
+ * Additive result fields for a connect, enroll or session registration.
3256
+ * `nameIsDefault` appears only when it is known whether the connection name
3257
+ * is one this CLI generated. The runtime note is suggested once, after a
3258
+ * connection this run completed.
3259
+ */
3260
+ function identitySuggestions(params) {
3261
+ const prefix = `PRIMITIVE_AGENT_PROFILE=${params.profile} ${params.invocation}`;
3262
+ const suggestions = [];
3263
+ const nameIsDefault = params.nameIsDefault;
3264
+ if (nameIsDefault) suggestions.push({
3265
+ kind: "rename",
3266
+ command: `${prefix} agent rename "<new name>"`
3267
+ });
3268
+ if (params.connectedNow) suggestions.push({
3269
+ kind: "runtime_note",
3270
+ command: `${prefix} agent runtime set`
3271
+ });
3272
+ return nameIsDefault === void 0 ? { suggestions } : {
3273
+ nameIsDefault,
3274
+ suggestions
3275
+ };
3276
+ }
3277
+ //#endregion
3278
+ //#region src/oclif/agent-disconnect.ts
3279
+ var AgentDisconnectError = class extends Error {};
3280
+ function credentialDigest(profile) {
3281
+ return createHash("sha256").update("primitive-agent-disconnect-v1\0").update(profile.api_key).digest("hex");
3282
+ }
3283
+ function boundSession(directory, profile) {
3284
+ const raw = readMailJson(join(directory, "setup.json"));
3285
+ if (raw === null) return null;
3286
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new AgentDisconnectError("Saved session setup is invalid. The connection was not changed.");
3287
+ const row = raw;
3288
+ if (row.receiverMode === "poll" && row.session === null && row.invitationHash === profile.invitation_hash) return null;
3289
+ if (typeof row.session !== "string" || !SESSION_UUID.test(row.session) || row.invitationHash !== profile.invitation_hash) throw new AgentDisconnectError("Saved session binding does not match this profile. The connection was not changed.");
3290
+ return row.session.toLowerCase();
3291
+ }
3292
+ function receiverStopped(status) {
3293
+ return status.phase === null || status.phase === "stopped" || status.phase === "failed" || status.reason === "exited";
3294
+ }
3295
+ function confirmationPath(directory, profile) {
3296
+ return join(directory, `disconnected-${profile.invitation_hash}.json`);
3297
+ }
3298
+ function confirmedLocally(directory, profile) {
3299
+ const raw = readMailJson(confirmationPath(directory, profile));
3300
+ if (raw === null) return false;
3301
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new AgentDisconnectError("Saved revocation confirmation is invalid. The credential was preserved.");
3302
+ const row = raw;
3303
+ if (row.version !== 1 || row.address !== profile.agent_address || row.api_base_url !== profile.api_base_url || row.credential_digest !== credentialDigest(profile) || typeof row.revoked_at !== "string" || !Number.isFinite(Date.parse(row.revoked_at))) throw new AgentDisconnectError("Saved revocation confirmation does not match this credential. The credential was preserved.");
3304
+ return true;
3305
+ }
3306
+ /** The API error code from an error envelope, when it has one. */
3307
+ function errorCode(error) {
3308
+ if (!error || typeof error !== "object") return void 0;
3309
+ const inner = error.error;
3310
+ const code = inner && typeof inner === "object" ? inner.code : error.code;
3311
+ return typeof code === "string" && /^[a-z0-9_]{1,64}$/.test(code) ? code : void 0;
3312
+ }
3313
+ async function revoke(profile, fetcher) {
3314
+ const client = new PrimitiveApiClient({
3315
+ apiKey: profile.api_key,
3316
+ apiBaseUrl: profile.api_base_url,
3317
+ ...fetcher ? { fetch: fetcher } : {}
3318
+ });
3319
+ let result;
3320
+ try {
3321
+ result = await client.client.delete({
3322
+ security: [{
3323
+ scheme: "bearer",
3324
+ type: "http"
3325
+ }],
3326
+ url: "/agent-connections/{address}",
3327
+ path: { address: profile.agent_address },
3328
+ redirect: "error",
3329
+ signal: AbortSignal.timeout(25e3),
3330
+ responseStyle: "fields"
3331
+ });
3332
+ } catch {
3333
+ throw new AgentDisconnectError("Revocation outcome is unknown after a transport error. The profile and credential were preserved. Check the agent in the app before retrying.");
3334
+ }
3335
+ if (result.error !== void 0) {
3336
+ const status = result.response?.status;
3337
+ if (status === void 0) throw new AgentDisconnectError("Revocation outcome is unknown: no response was received from Primitive. The profile and credential were preserved. Check your connection, then retry or check the agent in the app.");
3338
+ const code = errorCode(result.error);
3339
+ const label = `HTTP ${status}${code ? `, ${code}` : ""}`;
3340
+ throw new AgentDisconnectError(status === 401 ? `Revocation was not confirmed (${label}). The credential was preserved; it may already be invalid. Check the agent in the app before retrying.` : status >= 500 ? `Primitive returned a server error while revoking this agent (${label}), so revocation is unconfirmed. The profile was preserved. Retry the same command; a 401 on retry means the credential was already revoked.` : `Revocation was refused by Primitive (${label}). The credential was preserved. Check the agent in the app before retrying.`);
3341
+ }
3342
+ const envelope = result.data;
3343
+ if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) throw new AgentDisconnectError("Revocation response was incomplete. The credential was preserved. Check the agent in the app before retrying.");
3344
+ const row = envelope;
3345
+ const data = row.data;
3346
+ const connection = data && typeof data === "object" && !Array.isArray(data) ? data.connection : void 0;
3347
+ if (row.success !== true || !connection || typeof connection !== "object" || Array.isArray(connection) || connection.address !== profile.agent_address || connection.owner_address !== profile.owner_address || connection.status !== "revoked") throw new AgentDisconnectError("Revocation response was incomplete. The credential was preserved. Check the agent in the app before retrying.");
3348
+ }
3349
+ /** Revoke one selected connected credential; never alter OAuth login or mail evidence. */
3350
+ async function disconnectAgent(params) {
3351
+ const profileName = agentProfileName(params.profileName);
3352
+ if (!loadConnectedAgentProfile(params.configDir, profileName)) throw new AgentDisconnectError("This agent profile is not configured. Nothing was changed.");
3353
+ const directory = agentProfileDirectory(params.configDir, profileName);
3354
+ let releaseSetup;
3355
+ let releaseClaim;
3356
+ try {
3357
+ releaseSetup = acquireListenLock(directory, "setup");
3358
+ releaseClaim = acquireListenLock(agentProfilesDirectory(params.configDir), "agent-connection-setup");
3359
+ const profile = loadConnectedAgentProfile(params.configDir, profileName);
3360
+ if (!profile) throw new AgentDisconnectError("The selected profile changed before disconnect. Nothing was changed.");
3361
+ const session = boundSession(directory, profile);
3362
+ const receiver = session ? await (params.stopReceiver ?? stopBackgroundListen)({
3363
+ configDir: params.configDir,
3364
+ scope: notificationScope(profile.api_base_url, profile.api_key),
3365
+ threadId: session
3366
+ }) : null;
3367
+ 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.");
3368
+ const previous = confirmedLocally(directory, profile);
3369
+ if (!previous) await revoke(profile, params.fetch);
3370
+ if (!previous) try {
3371
+ writeMailJson(confirmationPath(directory, profile), {
3372
+ version: 1,
3373
+ address: profile.agent_address,
3374
+ api_base_url: profile.api_base_url,
3375
+ credential_digest: credentialDigest(profile),
3376
+ revoked_at: (params.now ?? (() => /* @__PURE__ */ new Date()))().toISOString()
3377
+ });
3378
+ } catch {
3379
+ throw new AgentDisconnectError("Primitive confirmed revocation, but its local confirmation could not be saved. The credential was preserved. Keep this profile for recovery.");
3380
+ }
3381
+ const current = loadConnectedAgentProfile(params.configDir, profileName);
3382
+ if (!current || current.api_key !== profile.api_key) throw new AgentDisconnectError("The selected profile changed after revocation. Its credential was preserved for inspection.");
3383
+ try {
3384
+ removeMailFile(join(directory, "connection.json"));
3385
+ } catch {
3386
+ throw new AgentDisconnectError("Primitive confirmed revocation, but the local credential could not be removed. Confirmation is saved; retry this command to finish local cleanup.");
3387
+ }
3388
+ const externalHook = session ? uninstallClaudeWakeHook({
3389
+ configDir: params.configDir,
3390
+ profileName,
3391
+ agentAddress: profile.agent_address,
3392
+ sessionId: session,
3393
+ env: params.env
3394
+ }) ? "removed" : "unavailable" : null;
3395
+ return {
3396
+ status: "disconnected",
3397
+ identity: connectedAgentIdentity(profileName, profile),
3398
+ receiver,
3399
+ revocation: previous ? "previously_confirmed" : "confirmed",
3400
+ externalHook
3401
+ };
3402
+ } catch (error) {
3403
+ if (error instanceof AgentDisconnectError) throw error;
3404
+ throw new AgentDisconnectError("Disconnect could not safely complete. The credential and local evidence were preserved. Check this profile and listener status before retrying.");
3405
+ } finally {
3406
+ releaseClaim?.();
3407
+ releaseSetup?.();
3408
+ }
3409
+ }
3410
+ //#endregion
3411
+ //#region src/oclif/agent-enroll.ts
3412
+ const RESPONSE_LIMIT = 32768;
3413
+ const CONNECTION_LIST_LIMIT = 131072;
3414
+ const CONFIRMATION_BUDGET_MS = 12e4;
3415
+ const CONFIRMATION_MAX_ATTEMPTS = 40;
3416
+ const CONFIRMATION_PAGE_LIMIT = 20;
3417
+ const refuse = (message) => new AgentConnectionSetupError(message);
3418
+ function plainRecord(value) {
3419
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
3420
+ }
3421
+ function savedEnrollment(value) {
3422
+ const row = plainRecord(value);
3423
+ const keys = [
3424
+ "version",
3425
+ "session",
3426
+ "profile",
3427
+ "name",
3428
+ "address",
3429
+ "orgId",
3430
+ "grantId",
3431
+ "apiBaseUrl",
3432
+ "receiverMode",
3433
+ "contactRequests",
3434
+ "startedAt",
3435
+ "phase",
3436
+ "invitationHash",
3437
+ "ownerAddress"
3438
+ ];
3439
+ if (row?.version === 2) keys.push("createRequestId", "continueAttempted");
3440
+ if (!row || Object.keys(row).length !== keys.length || keys.some((key) => !Object.hasOwn(row, key)) || row.version !== 1 && row.version !== 2 || row.version === 2 && (typeof row.createRequestId !== "string" || !SESSION_UUID.test(row.createRequestId) || typeof row.continueAttempted !== "boolean") || typeof row.session !== "string" || !SESSION_UUID.test(row.session) || row.profile !== `session-${row.session}` || typeof row.name !== "string" || !validName(row.name) || !(typeof row.address === "string" && validAddress(row.address) || row.version === 2 && row.address === null && row.phase === "create_attempted") || typeof row.orgId !== "string" || !row.orgId || typeof row.grantId !== "string" || !row.grantId || typeof row.apiBaseUrl !== "string" || !RECEIVER_MODES.includes(row.receiverMode) || typeof row.contactRequests !== "boolean" || typeof row.startedAt !== "string" || !Number.isFinite(Date.parse(row.startedAt)) || !["create_attempted", "setup_attempted"].includes(String(row.phase)) || row.phase === "create_attempted" && (row.invitationHash !== null || row.ownerAddress !== null) || row.phase === "setup_attempted" && (typeof row.address !== "string" || typeof row.invitationHash !== "string" || !/^[a-f0-9]{64}$/.test(row.invitationHash) || typeof row.ownerAddress !== "string" || !row.ownerAddress.includes("@"))) throw refuse("Saved enrollment state is invalid. Preserve its private files.");
3441
+ connectedApiBaseUrl(row.apiBaseUrl);
3442
+ if (row.ownerAddress !== null) try {
3443
+ if (mailAddress(row.ownerAddress) !== row.ownerAddress) throw new Error();
3444
+ } catch {
3445
+ throw refuse("Saved enrollment owner is invalid. Preserve its private files.");
3446
+ }
3447
+ return row;
3448
+ }
3449
+ function validName(value) {
3450
+ return value.length >= 1 && value.length <= 80 && value.trim() === value && Array.from(value).every((character) => {
3451
+ const code = character.charCodeAt(0);
3452
+ return code >= 32 && code !== 127;
3453
+ });
3454
+ }
3455
+ function validAddress(value) {
3456
+ return value.length <= 254 && /^[a-z0-9][a-z0-9-]{0,62}@[a-z0-9](?:[a-z0-9.-]*[a-z0-9])$/.test(value);
3457
+ }
3458
+ function resolvedEnrollment(state) {
3459
+ if (!state.address) throw refuse("The server has not assigned this session an address yet. Preserve its setup request.");
3460
+ return {
3461
+ ...state,
3462
+ address: state.address
3463
+ };
3464
+ }
3465
+ async function preflight(session, receiver, env) {
3466
+ if (receiver === "poll") return;
3467
+ if (receiver === "external") {
3468
+ if (env.CLAUDE_CODE_SESSION_ID !== session) throw refuse("External receiving requires this exact Claude session ID. No address was created.");
3469
+ return;
3470
+ }
3471
+ assertNativeSessionIdentity(session, env);
3472
+ (await connectNativeSession({
3473
+ threadId: session,
3474
+ expectedCwd: process.cwd(),
3475
+ signal: AbortSignal.timeout(1e4)
3476
+ })).close();
3477
+ }
3478
+ /** Refuse a different loaded Codex thread before creating an address. */
3479
+ function assertNativeSessionIdentity(session, env) {
3480
+ if (env.CODEX_THREAD_ID && env.CODEX_THREAD_ID !== session) throw refuse("The requested session differs from this Codex thread. No address was created.");
3481
+ if (!env.CODEX_THREAD_ID && env.CODEX_SESSION_ID && env.CODEX_SESSION_ID !== session) throw refuse("The requested session differs from this Codex session. No address was created.");
3482
+ }
3483
+ async function managedDomains(client, apiBaseUrl) {
3484
+ const response = await listDomains({
3485
+ client: client.client,
3486
+ responseStyle: "fields"
3487
+ });
3488
+ const rows = response.data?.data;
3489
+ if (response.error || !Array.isArray(rows)) throw refuse("Could not list the owner's managed domains. No address was created.");
3490
+ const suffix = apiBaseUrl === "https://api.primitive-staging-1.com/v1" ? ".primitive-staging.email" : ".primitive.email";
3491
+ const domains = [...new Set(rows.filter((row) => row.verified === true && "is_active" in row && row.is_active === true && row.domain.toLowerCase().endsWith(suffix)).map((row) => row.domain.toLowerCase()).filter((domain) => /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])$/.test(domain)))].sort();
3492
+ if (!domains[0]) throw refuse("This organization has no verified managed domain for agent enrollment.");
3493
+ return domains;
3494
+ }
3495
+ async function boundedJson(response, label = "connection creation", limit = RESPONSE_LIMIT) {
3496
+ const reader = response.body?.getReader();
3497
+ if (!reader) throw refuse(`The ${label} response is incomplete.`);
3498
+ const chunks = [];
3499
+ let size = 0;
3500
+ try {
3501
+ for (;;) {
3502
+ const part = await reader.read();
3503
+ if (part.done) break;
3504
+ size += part.value.byteLength;
3505
+ if (size > limit) throw refuse(`The ${label} response is too large.`);
3506
+ chunks.push(part.value);
3507
+ }
3508
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
3509
+ } catch {
3510
+ throw refuse(`The ${label} response is incomplete or invalid.`);
3511
+ } finally {
3512
+ await reader.cancel().catch(() => void 0);
3513
+ }
3514
+ }
3515
+ async function rejectedUnsuitableDomain(response) {
3516
+ if (response.status !== 400) return false;
3517
+ try {
3518
+ const envelope = plainRecord(await boundedJson(response, "connection rejection"));
3519
+ return envelope?.success === false && plainRecord(envelope.error)?.code === "connection_domain_unavailable";
3520
+ } catch {
3521
+ return false;
3522
+ }
3523
+ }
3524
+ function invitationFrom(value, expected, now) {
3525
+ const envelope = plainRecord(value);
3526
+ const data = plainRecord(envelope?.data);
3527
+ const connection = plainRecord(data?.connection);
3528
+ const invitation = plainRecord(data?.invitation);
3529
+ if (envelope?.success !== true || connection?.address !== expected.address || typeof connection?.name !== "string" || connection?.status !== "pending" || typeof connection.owner_address !== "string" || typeof invitation?.claim_url !== "string" || typeof invitation.expires_at !== "string" || !Number.isFinite(Date.parse(invitation.expires_at)) || Date.parse(invitation.expires_at) <= now + 3e4) throw refuse("The connection creation result was incomplete or did not match this session. No invitation was claimed.");
3530
+ const parsed = parseAgentInvitation(invitation.claim_url);
3531
+ if (parsed.apiBaseUrl !== expected.apiBaseUrl) throw refuse("The connection invitation origin differs from the owner's login. No invitation was claimed.");
3532
+ let ownerAddress;
3533
+ try {
3534
+ ownerAddress = mailAddress(connection.owner_address);
3535
+ } catch {
3536
+ throw refuse("The connection owner address was invalid. No invitation was claimed.");
3537
+ }
3538
+ return {
3539
+ url: invitation.claim_url,
3540
+ hash: createHash("sha256").update(parsed.apiBaseUrl).update("\0").update(parsed.token).digest("hex"),
3541
+ ownerAddress
3542
+ };
3543
+ }
3544
+ function policyRuleInputs(rules) {
3545
+ return rules.map(({ pattern, effect }) => ({
3546
+ pattern,
3547
+ effect
3548
+ })).sort((left, right) => {
3549
+ const a = `${left.pattern}:${left.effect}`;
3550
+ const b = `${right.pattern}:${right.effect}`;
3551
+ return a < b ? -1 : a > b ? 1 : 0;
3552
+ });
3553
+ }
3554
+ async function enableAgentContactRequests(options) {
3555
+ const saved = loadCliCredentials(options.configDir);
3556
+ if (!saved || saved.org_id !== options.orgId || saved.oauth_grant_id !== options.grantId || saved.api_base_url !== options.apiBaseUrl) throw refuse("The saved member login changed before contact requests were enabled. This agent remains connected; sign in to its organization and resume this exact enrollment.");
3557
+ const credentials = await refreshStoredCliCredentials({
3558
+ apiBaseUrl: options.apiBaseUrl,
3559
+ configDir: options.configDir,
3560
+ credentials: saved,
3561
+ fetch: options.fetch,
3562
+ now: options.now
3563
+ });
3564
+ if (credentials.org_id !== options.orgId || credentials.oauth_grant_id !== options.grantId || credentials.api_base_url !== options.apiBaseUrl) throw refuse("The saved member login changed before contact requests were enabled. This agent remains connected; sign in to its organization and resume this exact enrollment.");
3565
+ const client = new PrimitiveApiClient({
3566
+ apiKey: credentials.access_token,
3567
+ apiBaseUrl: options.apiBaseUrl,
3568
+ fetch: options.fetch
3569
+ });
3570
+ async function readPolicy() {
3571
+ try {
3572
+ const response = await getAgentContactPolicy({
3573
+ client: client.client,
3574
+ path: { agent_address: options.address },
3575
+ signal: AbortSignal.timeout(1e4),
3576
+ responseStyle: "fields"
3577
+ });
3578
+ if (response.error || response.data?.success !== true || !response.data.data) throw new Error();
3579
+ return parseAgentContactPolicy(response.data.data, options.address);
3580
+ } catch {
3581
+ return null;
3582
+ }
3583
+ }
3584
+ const before = await readPolicy();
3585
+ if (!before) return "unavailable";
3586
+ if (before.agent_policy.allow_contact_requests === false) return "owner_disabled";
3587
+ const rules = policyRuleInputs(before.agent_policy.rules);
3588
+ if (before.agent_policy.allow_contact_requests !== true) try {
3589
+ const response = await putAgentContactPolicy({
3590
+ client: client.client,
3591
+ path: { agent_address: options.address },
3592
+ body: {
3593
+ rules,
3594
+ allow_contact_requests: true,
3595
+ ...before.agent_policy.version ? { if_version: before.agent_policy.version } : { if_absent: true }
3596
+ },
3597
+ signal: AbortSignal.timeout(1e4),
3598
+ responseStyle: "fields"
3599
+ });
3600
+ if (response.error || response.data?.success !== true || !response.data.data) throw new Error();
3601
+ const updated = parseAgentContactPolicy(response.data.data, options.address);
3602
+ if (updated.agent_policy.allow_contact_requests !== true || JSON.stringify(policyRuleInputs(updated.agent_policy.rules)) !== JSON.stringify(rules)) throw new Error();
3603
+ } catch {
3604
+ return "unavailable";
3605
+ }
3606
+ const after = await readPolicy();
3607
+ if (!after || after.agent_policy.allow_contact_requests !== true || after.allow_contact_requests !== true || !after.contact_request_since || !after.contact_request_generation || JSON.stringify(policyRuleInputs(after.agent_policy.rules)) !== JSON.stringify(rules)) return "unavailable";
3608
+ return "enabled";
3609
+ }
3610
+ async function readOwnerConnection(enrollment, params, deadline, listed) {
3611
+ const saved = loadCliCredentials(params.configDir);
3612
+ 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.");
3613
+ const credentials = await refreshStoredCliCredentials({
3614
+ apiBaseUrl: enrollment.apiBaseUrl,
3615
+ configDir: params.configDir,
3616
+ credentials: saved,
3617
+ fetch: params.fetch,
3618
+ now: params.now
3619
+ });
3620
+ 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.");
3621
+ let cursor;
3622
+ for (let page = 0; page < CONFIRMATION_PAGE_LIMIT; page++) {
3623
+ const remaining = deadline - Date.now();
3624
+ if (remaining <= 0) return "unavailable";
3625
+ const url = new URL(`${enrollment.apiBaseUrl}/agent-connections`);
3626
+ url.searchParams.set("limit", "50");
3627
+ if (cursor) url.searchParams.set("cursor", cursor);
3628
+ let value;
3629
+ try {
3630
+ const response = await (params.fetch ?? fetch)(url, {
3631
+ method: "GET",
3632
+ redirect: "error",
3633
+ signal: AbortSignal.timeout(Math.min(5e3, remaining)),
3634
+ headers: {
3635
+ authorization: `Bearer ${credentials.access_token}`,
3636
+ accept: "application/json"
3637
+ }
3638
+ });
3639
+ if (response.status !== 200) {
3640
+ await response.body?.cancel();
3641
+ return "unavailable";
3642
+ }
3643
+ value = await boundedJson(response, "connection list", CONNECTION_LIST_LIMIT);
3644
+ } catch {
3645
+ return "unavailable";
3646
+ }
3647
+ const envelope = plainRecord(value);
3648
+ if (envelope?.success !== true || !Array.isArray(envelope.data)) return "unavailable";
3649
+ for (const candidate of envelope.data) {
3650
+ const connection = plainRecord(candidate);
3651
+ if (connection?.address !== enrollment.address) continue;
3652
+ 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.");
3653
+ if (typeof connection.name === "string") listed.name = connection.name;
3654
+ if (connection.owner_active === false) return "owner_inactive";
3655
+ if (connection.status === "revoked") return "revoked";
3656
+ if (connection.status === "connected" && typeof connection.verified_at === "string" && Number.isFinite(Date.parse(connection.verified_at))) return "connected";
3657
+ return "pending";
3658
+ }
3659
+ const next = plainRecord(envelope.meta)?.cursor;
3660
+ if (next === null) return "unavailable";
3661
+ if (typeof next !== "string" || !SESSION_UUID.test(next) || next === cursor) return "unavailable";
3662
+ cursor = next;
3663
+ }
3664
+ return "unavailable";
3665
+ }
3666
+ async function confirmOwnerConnection(enrollment, params, listed) {
3667
+ const deadline = Date.now() + CONFIRMATION_BUDGET_MS;
3668
+ let last = "unavailable";
3669
+ for (let attempt = 0; attempt < CONFIRMATION_MAX_ATTEMPTS; attempt++) {
3670
+ last = await readOwnerConnection(enrollment, params, deadline, listed);
3671
+ if (last === "connected" || last === "revoked" || last === "owner_inactive") return last;
3672
+ const remaining = deadline - Date.now();
3673
+ if (remaining <= 0 || attempt === CONFIRMATION_MAX_ATTEMPTS - 1) break;
3674
+ await (params.confirmationSleep ?? ((milliseconds) => setTimeout$1(milliseconds)))(Math.min(3e3, remaining));
3675
+ }
3676
+ return last;
3677
+ }
3678
+ /**
3679
+ * True when `session-<session>` holds this session's own enrollment, saved
3680
+ * state and credential agreeing, so rerunning enroll resumes it rather than
3681
+ * adding a second address. Offline and read-only.
3682
+ */
3683
+ function enrollmentResumes(configDir, session) {
3684
+ const profile = `session-${session}`;
3685
+ try {
3686
+ const raw = readMailJson(join(agentProfileDirectory(configDir, profile), "enrollment", "state.json"));
3687
+ if (raw === null) return false;
3688
+ const state = savedEnrollment(raw);
3689
+ if (state.profile !== profile) return false;
3690
+ const existing = loadConnectedAgentProfile(configDir, profile);
3691
+ return !existing || 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);
3692
+ } catch {
3693
+ return false;
3694
+ }
3695
+ }
3696
+ /** Trusted-machine enrollment using only saved member OAuth and the public API. */
3697
+ async function enrollAgent(params) {
3698
+ if (!SESSION_UUID.test(params.session)) throw refuse("Enrollment requires the exact current session UUID.");
3699
+ let name = params.name ?? "Coding agent";
3700
+ if (!validName(name)) throw refuse("Agent name must be 1-80 characters without control characters.");
3701
+ const receiverMode = params.receiverMode ?? "native";
3702
+ const contactRequests = Boolean(params.contactRequests);
3703
+ const env = params.env ?? process.env;
3704
+ if (env["PRIMITIVE_AGENT_PROFILE"]?.trim() || env.PRIMITIVE_API_KEY?.trim() || env.PRIMITIVE_KEY?.trim()) throw refuse("Enrollment requires the saved member OAuth login. Unset agent-profile and API-key environment overrides.");
3705
+ const saved = loadCliCredentials(params.configDir);
3706
+ if (!saved) throw refuse("Sign in with `primitive signin` as a member of this organization first.");
3707
+ const apiBaseUrl = connectedApiBaseUrl(saved.api_base_url);
3708
+ const profile = `session-${params.session}`;
3709
+ const directory = join(agentProfileDirectory(params.configDir, profile), "enrollment");
3710
+ privateMailDirectory(directory, true);
3711
+ const release = acquireListenLock(directory, "agent-enrollment");
3712
+ try {
3713
+ await (params.preflight ?? ((session, receiver) => preflight(session, receiver, env)))(params.session, receiverMode);
3714
+ await params.beforeCreate?.();
3715
+ const path = join(directory, "state.json");
3716
+ const old = readMailJson(path);
3717
+ let state = old === null ? null : savedEnrollment(old);
3718
+ if (state && params.name === void 0) name = state.name;
3719
+ 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.");
3720
+ if (state && Date.parse(state.startedAt) > (params.now ?? Date.now)() + 6e4) throw refuse("Saved enrollment time is inconsistent. Preserve its private files.");
3721
+ const existing = loadConnectedAgentProfile(params.configDir, profile);
3722
+ 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.");
3723
+ const finish = async (enrollment, resume, invitation) => {
3724
+ const setup = await (params.setup ?? setupAgent)({
3725
+ configDir: params.configDir,
3726
+ profileName: profile,
3727
+ session: params.session,
3728
+ receiverMode,
3729
+ ...resume ? { resume: true } : { invitation },
3730
+ contactRequests,
3731
+ fetch: params.fetch
3732
+ });
3733
+ const ownerMemberAddress = await refreshOwnerMemberAddress({
3734
+ configDir: params.configDir,
3735
+ profileName: profile,
3736
+ fetch: params.fetch
3737
+ });
3738
+ const identity = {
3739
+ ...setup.identity,
3740
+ ownerMemberAddress
3741
+ };
3742
+ const report = ownerReportGuidance(identity);
3743
+ const result = {
3744
+ ...setup,
3745
+ identity,
3746
+ name
3747
+ };
3748
+ if (!verificationReplySubmitted(result.verification.state)) return {
3749
+ ...result,
3750
+ guidance: `${result.guidance} ${report}`,
3751
+ contactRequestPolicy: contactRequests ? "pending_verification" : "not_requested",
3752
+ connection: { status: "pending" }
3753
+ };
3754
+ const contactRequestPolicy = contactRequests ? await enableAgentContactRequests({
3755
+ configDir: params.configDir,
3756
+ address: enrollment.address,
3757
+ orgId: enrollment.orgId,
3758
+ grantId: enrollment.grantId,
3759
+ apiBaseUrl: enrollment.apiBaseUrl,
3760
+ fetch: params.fetch,
3761
+ now: params.now
3762
+ }) : "not_requested";
3763
+ const current = {};
3764
+ const listed = await confirmOwnerConnection(enrollment, params, current);
3765
+ const verifiedBySetup = result.verification.state === "verified" && (listed === "pending" || listed === "unavailable");
3766
+ const status = verifiedBySetup ? "connected" : listed;
3767
+ return {
3768
+ ...result,
3769
+ ...status === "owner_inactive" ? { receiving: { state: "not_ready" } } : {},
3770
+ guidance: `${verifiedBySetup ? "This agent's connection status reports pairing verified. Receiving is separate; configure and verify this session's external hook if external mode was selected." : status === "connected" ? "The owner connection list confirms pairing. Receiving is separate; configure and verify this session's external hook if external mode was selected." : status === "revoked" ? "The owner connection list reports this pairing revoked. Stop using this profile and ask the owner to review it." : status === "owner_inactive" ? "The original human owner is no longer an active member. This profile must not be treated as receiving; ask an organization manager to review or remove it." : "The challenge reply was submitted, but pairing is not confirmed. Resume this exact enrollment with the same options; do not create another address or resend the reply."}${status === "revoked" || status === "owner_inactive" ? "" : ` ${report}`}`,
3771
+ contactRequestPolicy,
3772
+ connection: { status },
3773
+ name: current.name ?? name
3774
+ };
3775
+ };
3776
+ if (state?.phase === "setup_attempted") {
3777
+ 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.");
3778
+ return finish(resolvedEnrollment(state), true);
3779
+ }
3780
+ if (existing) throw refuse("This profile is already connected without a matching enrollment. Nothing was changed.");
3781
+ 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.");
3782
+ const credentials = await refreshStoredCliCredentials({
3783
+ apiBaseUrl,
3784
+ configDir: params.configDir,
3785
+ credentials: saved,
3786
+ fetch: params.fetch,
3787
+ now: params.now
3788
+ });
3789
+ 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.");
3790
+ const fetchImpl = params.fetch ?? fetch;
3791
+ const firstAttempt = state === null;
3792
+ 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.");
3793
+ if (!state) {
3794
+ if (params.continueSetup) throw refuse("There is no saved enrollment to continue. Start enrollment normally first.");
3795
+ if (!(await managedDomains(new PrimitiveApiClient({
3796
+ apiKey: credentials.access_token,
3797
+ apiBaseUrl,
3798
+ fetch: params.fetch
3799
+ }), apiBaseUrl)).length) throw refuse("No managed domain is available for enrollment.");
3800
+ state = {
3801
+ version: 2,
3802
+ session: params.session,
3803
+ profile,
3804
+ name,
3805
+ address: null,
3806
+ createRequestId: randomUUID(),
3807
+ continueAttempted: false,
3808
+ orgId: saved.org_id,
3809
+ grantId: saved.oauth_grant_id,
3810
+ apiBaseUrl,
3811
+ receiverMode,
3812
+ contactRequests,
3813
+ startedAt: new Date((params.now ?? Date.now)()).toISOString(),
3814
+ phase: "create_attempted",
3815
+ invitationHash: null,
3816
+ ownerAddress: null
3817
+ };
3818
+ writeMailJson(path, state);
3819
+ }
3820
+ let payload;
3821
+ try {
3822
+ const response = await fetchImpl(`${apiBaseUrl}/agent-connections`, {
3823
+ method: "POST",
3824
+ redirect: "error",
3825
+ signal: AbortSignal.timeout(25e3),
3826
+ headers: {
3827
+ authorization: `Bearer ${credentials.access_token}`,
3828
+ "content-type": "application/json",
3829
+ accept: "application/json"
3830
+ },
3831
+ body: JSON.stringify({
3832
+ name: state.name,
3833
+ create_request_id: state.createRequestId
3834
+ })
3835
+ });
3836
+ if (await rejectedUnsuitableDomain(response)) {
3837
+ if (firstAttempt) unlinkSync(path);
3838
+ throw refuse("No verified managed domain is currently sendable for agent enrollment. No address was created by this request.");
3839
+ }
3840
+ if ([
3841
+ 400,
3842
+ 401,
3843
+ 403
3844
+ ].includes(response.status)) {
3845
+ await response.body?.cancel().catch(() => void 0);
3846
+ if (firstAttempt) unlinkSync(path);
3847
+ throw refuse("Connection creation was rejected before an address was created. Check the selected organization, managed domain, and member access, then retry this exact session.");
3848
+ }
3849
+ if (response.status !== 200) {
3850
+ const code = plainRecord(plainRecord(await boundedJson(response, "connection recovery"))?.error)?.code;
3851
+ if (code === "connection_removed") throw refuse("This saved setup belongs to a removed agent. It cannot create another identity. Review the removed agent in the app.");
3852
+ if (code === "connection_create_request_conflict") throw refuse("This saved setup belongs to different choices or membership. Preserve its request and inspect the original agent in the app.");
3853
+ throw new Error();
3854
+ }
3855
+ payload = await boundedJson(response);
3856
+ } catch (error) {
3857
+ if (error instanceof AgentConnectionSetupError) throw error;
3858
+ throw refuse("Connection creation has an unknown outcome. Rerun this exact enrollment to recover the same saved request; no second identity or invitation will be created automatically.");
3859
+ }
3860
+ const envelope = plainRecord(payload);
3861
+ const data = plainRecord(envelope?.data);
3862
+ const connection = plainRecord(data?.connection);
3863
+ if (envelope?.success !== true || typeof connection?.address !== "string" || !validAddress(connection.address) || typeof connection.name !== "string" || !validName(connection.name) || ![
3864
+ "pending",
3865
+ "claimed",
3866
+ "connected",
3867
+ "revoked"
3868
+ ].includes(String(connection.status)) || state.address !== null && state.address !== connection.address) throw refuse("The connection result was incomplete or changed the saved identity. Preserve this setup request; no invitation was claimed.");
3869
+ state = {
3870
+ ...state,
3871
+ address: connection.address
3872
+ };
3873
+ writeMailJson(path, state);
3874
+ if (data?.recovered === true) {
3875
+ if (data.invitation !== null) throw refuse("Recovered setup unexpectedly included an invitation. Preserve the saved identity; no invitation was claimed.");
3876
+ 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.");
3877
+ if (!params.continueSetup) throw refuse(`Recovered ${state.address}. Run this exact command with --continue-setup to explicitly obtain one pending-only invitation.`);
3878
+ state = {
3879
+ ...state,
3880
+ continueAttempted: true
3881
+ };
3882
+ writeMailJson(path, state);
3883
+ try {
3884
+ const response = await fetchImpl(`${apiBaseUrl}/agent-connections/${encodeURIComponent(connection.address)}/invitation`, {
3885
+ method: "POST",
3886
+ redirect: "error",
3887
+ signal: AbortSignal.timeout(25e3),
3888
+ headers: {
3889
+ authorization: `Bearer ${credentials.access_token}`,
3890
+ "content-type": "application/json",
3891
+ accept: "application/json"
3892
+ },
3893
+ body: JSON.stringify({ pending_only: true })
3894
+ });
3895
+ if (response.status !== 200) {
3896
+ 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.");
3897
+ throw new Error();
3898
+ }
3899
+ payload = await boundedJson(response);
3900
+ } catch (error) {
3901
+ if (error instanceof AgentConnectionSetupError) throw error;
3902
+ throw refuse("Continue setup has an uncertain outcome. Inspect the saved agent in the app; do not repeat it and invalidate another invitation.");
3903
+ }
3904
+ }
3905
+ const invitation = invitationFrom(payload, state, (params.now ?? Date.now)());
3906
+ state = {
3907
+ ...state,
3908
+ phase: "setup_attempted",
3909
+ invitationHash: invitation.hash,
3910
+ ownerAddress: invitation.ownerAddress
3911
+ };
3912
+ writeMailJson(path, state);
3913
+ return finish(resolvedEnrollment(state), false, invitation.url);
3914
+ } finally {
3915
+ release();
3916
+ }
3917
+ }
3918
+ //#endregion
3919
+ //#region src/oclif/machine-session.ts
3920
+ var machine_session_exports = /* @__PURE__ */ __exportAll({
3921
+ MACHINE_RUNTIMES: () => MACHINE_RUNTIMES,
3922
+ OMP_MAIN: () => OMP_MAIN,
3923
+ agentConnectionState: () => agentConnectionState,
3924
+ agentInfoForSession: () => agentInfoForSession,
3925
+ connectedProfilesForSession: () => connectedProfilesForSession,
3926
+ defaultSessionName: () => defaultSessionName,
3927
+ endSession: () => endSession,
3928
+ headlessClaudeRun: () => headlessClaudeRun,
3929
+ ompProcessSession: () => ompProcessSession,
3930
+ pendingSessionDisconnects: () => pendingSessionDisconnects,
3931
+ psProcessInfo: () => psProcessInfo,
3932
+ readClaudeHookInput: () => readClaudeHookInput,
3933
+ registerSession: () => registerSession,
3934
+ retryPendingDisconnects: () => retryPendingDisconnects,
3935
+ revokeWithMemberLogin: () => revokeWithMemberLogin,
3936
+ runDetached: () => runDetached,
3937
+ runtimeSessionId: () => runtimeSessionId,
3938
+ workspaceName: () => workspaceName
3939
+ });
3940
+ const MACHINE_RUNTIMES = [
3941
+ "claude",
3942
+ "codex",
3943
+ "omp"
3944
+ ];
3945
+ function sessionRecordPath(configDir, session) {
3946
+ return join(configDir, "machine", "sessions", `${session}.json`);
3947
+ }
3948
+ function readSessionRecord(configDir, session) {
3949
+ try {
3950
+ const value = readMailJson(sessionRecordPath(configDir, session));
3951
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
3952
+ const row = value;
3953
+ if (row.version !== 1 || row.session !== session || typeof row.profile !== "string" || typeof row.name !== "string" || !MACHINE_RUNTIMES.includes(row.runtime) || row.createdBy !== "session-register" && row.createdBy !== "existing") return null;
3954
+ return {
3955
+ version: 1,
3956
+ runtime: row.runtime,
3957
+ session,
3958
+ profile: row.profile,
3959
+ name: row.name,
3960
+ createdBy: row.createdBy,
3961
+ address: typeof row.address === "string" ? row.address : null,
3962
+ agentInfo: row.agentInfo === "created" || row.agentInfo === "already_present" ? row.agentInfo : null,
3963
+ registeredAt: typeof row.registeredAt === "string" ? row.registeredAt : (/* @__PURE__ */ new Date(0)).toISOString(),
3964
+ endedAt: typeof row.endedAt === "string" ? row.endedAt : null,
3965
+ disconnect: row.disconnect === "done" || row.disconnect === "pending" || row.disconnect === "pending_enrollment" ? row.disconnect : typeof row.endedAt === "string" ? "done" : null
3966
+ };
3967
+ } catch {
3968
+ return null;
3969
+ }
3970
+ }
3971
+ function saveSessionRecord(configDir, row) {
3972
+ writeMailJson(sessionRecordPath(configDir, row.session), row);
3973
+ }
3974
+ /** The repository (or directory) a session works in, for its default name. */
3975
+ function workspaceName(cwd) {
3976
+ let current = resolve(cwd);
3977
+ for (let depth = 0; depth < 64; depth++) {
3978
+ if (existsSync(join(current, ".git"))) return basename(current);
3979
+ const parent = dirname(current);
3980
+ if (parent === current) break;
3981
+ current = parent;
3982
+ }
3983
+ return basename(resolve(cwd));
3984
+ }
3985
+ function defaultSessionName(runtime, cwd) {
3986
+ return Array.from(`${runtime}-${workspaceName(cwd)}`).filter((character) => {
3987
+ const code = character.charCodeAt(0);
3988
+ return code >= 32 && code !== 127;
3989
+ }).join("").replace(/\s+/g, " ").trim().slice(0, 80).trim() || runtime;
3990
+ }
3991
+ /** A short private role note. Never transcript content. */
3992
+ function agentInfoForSession(runtime, cwd) {
3993
+ return `${runtime === "claude" ? "Claude Code" : runtime === "codex" ? "Codex" : "omp"} coding session. Repository: ${workspaceName(cwd)}. Directory: ${basename(resolve(cwd))}.`.slice(0, 1e3);
3994
+ }
3995
+ function psProcessInfo(pid) {
3996
+ try {
3997
+ const line = execFileSync("ps", [
3998
+ "-o",
3999
+ "ppid=,lstart=,command=",
4000
+ "-p",
4001
+ String(pid)
4002
+ ], {
4003
+ encoding: "utf8",
4004
+ timeout: 2e3,
4005
+ env: {
4006
+ ...process.env,
4007
+ LC_ALL: "C"
4008
+ },
4009
+ stdio: [
4010
+ "ignore",
4011
+ "pipe",
4012
+ "ignore"
4013
+ ]
4014
+ }).trim();
4015
+ const match = /^(\d+)\s+(\w{3}\s+\w{3}\s+\d+\s+\d\d:\d\d:\d\d\s+\d{4})\s+(.*)$/.exec(line);
4016
+ if (!match) return null;
4017
+ return {
4018
+ ppid: Number(match[1]),
4019
+ started: match[2] ?? "",
4020
+ command: match[3] ?? ""
4021
+ };
4022
+ } catch {
4023
+ return null;
4024
+ }
4025
+ }
4026
+ const OMP_MAIN = /(?:^|[\s/\\])omp(?:\s|$)|pi-coding-agent[\\/]dist[\\/]cli\.js(?:\s|$)/;
4027
+ /**
4028
+ * omp does not expose a session ID to the commands it runs. Use one generated
4029
+ * ID per running omp process, keyed by its PID and start time so a reused PID
4030
+ * never inherits another process's identity. A command reached through one of
4031
+ * omp's shared worker processes gets no ID rather than a guessed one.
4032
+ */
4033
+ function ompProcessSession(configDir, options = {}) {
4034
+ if ((options.platform ?? process.platform) === "win32") return null;
4035
+ const info = options.processInfo ?? psProcessInfo;
4036
+ let pid = options.startPid ?? process.ppid;
4037
+ for (let hop = 0; hop < 16 && pid > 1; hop++) {
4038
+ const row = info(pid);
4039
+ if (!row) return null;
4040
+ if (row.command.includes("__omp_worker")) return null;
4041
+ if (OMP_MAIN.test(row.command)) {
4042
+ const key = createHash("sha256").update(`${pid}\0${row.started}`).digest("hex").slice(0, 32);
4043
+ const directory = join(configDir, "machine", "omp-processes");
4044
+ const path = join(directory, `${key}.json`);
4045
+ privateMailDirectory(directory, true);
4046
+ try {
4047
+ const fd = openSync(path, "wx", 384);
4048
+ try {
4049
+ writeFileSync(fd, `${JSON.stringify({
4050
+ version: 1,
4051
+ session: randomUUID()
4052
+ })}\n`);
4053
+ fsyncSync(fd);
4054
+ } finally {
4055
+ closeSync(fd);
4056
+ }
4057
+ } catch (error) {
4058
+ if (error.code !== "EEXIST") return null;
4059
+ }
4060
+ const saved = readMailJson(path);
4061
+ return typeof saved?.session === "string" && SESSION_UUID.test(saved.session) ? saved.session : null;
4062
+ }
4063
+ pid = row.ppid;
4064
+ }
4065
+ return null;
4066
+ }
4067
+ /** The session this runtime says the command is running in. */
4068
+ function runtimeSessionId(runtime, env, configDir, ompSession = ompProcessSession) {
4069
+ return (runtime === "claude" ? env.CLAUDE_CODE_SESSION_ID : runtime === "codex" ? env.CODEX_THREAD_ID?.trim() || env.CODEX_SESSION_ID : ompSession(configDir))?.trim() || null;
4070
+ }
4071
+ /**
4072
+ * Claude sets CLAUDE_CODE_ENTRYPOINT to "cli" for an interactive session and
4073
+ * to "sdk-cli" for `claude -p`; SDK hosts set "sdk-ts" or "sdk-py", and the
4074
+ * GitHub action and MCP server modes have their own values. Those runs are
4075
+ * not sessions a person works in, so they get no address. Any other or
4076
+ * missing value is treated as interactive.
4077
+ */
4078
+ const HEADLESS_CLAUDE_ENTRYPOINTS = new Set([
4079
+ "sdk-cli",
4080
+ "sdk-ts",
4081
+ "sdk-py",
4082
+ "claude-code-github-action",
4083
+ "mcp"
4084
+ ]);
4085
+ function headlessClaudeRun(env) {
4086
+ return HEADLESS_CLAUDE_ENTRYPOINTS.has(env.CLAUDE_CODE_ENTRYPOINT?.trim() ?? "");
4087
+ }
4088
+ const HEADLESS_DETAIL = "This is a non-interactive Claude run (CLAUDE_CODE_ENTRYPOINT), so no address is used for it.";
4089
+ /** Read the connection's own record with its credential. */
4090
+ async function agentConnectionState(profile, fetchImpl = fetch, timeoutMs = 5e3) {
4091
+ try {
4092
+ const response = await fetchImpl(`${profile.api_base_url}/agent-connections/me`, {
4093
+ method: "GET",
4094
+ redirect: "error",
4095
+ signal: AbortSignal.timeout(timeoutMs),
4096
+ headers: {
4097
+ authorization: `Bearer ${profile.api_key}`,
4098
+ accept: "application/json"
4099
+ }
4100
+ });
4101
+ if (response.status === 401 || response.status === 403) {
4102
+ await response.body?.cancel().catch(() => void 0);
4103
+ return "rejected";
4104
+ }
4105
+ if (response.status !== 200) {
4106
+ await response.body?.cancel().catch(() => void 0);
4107
+ return "unavailable";
4108
+ }
4109
+ const text = await response.text();
4110
+ if (text.length > 65536) return "unavailable";
4111
+ const body = JSON.parse(text);
4112
+ const connection = body?.data?.connection;
4113
+ if (body?.success !== true || typeof connection?.address !== "string" || connection.address.toLowerCase() !== profile.agent_address.toLowerCase()) return "unavailable";
4114
+ if (connection.status === "connected") return "connected";
4115
+ if (connection.status === "revoked") return "revoked";
4116
+ if (connection.status === "claimed" || connection.status === "pending") return "pending";
4117
+ return "unavailable";
4118
+ } catch {
4119
+ return "unavailable";
4120
+ }
4121
+ }
4122
+ function receiverMode(setupDirectory) {
4123
+ try {
4124
+ const saved = readMailJson(join(setupDirectory, "setup.json"));
4125
+ if (!saved) return null;
4126
+ return saved.receiverMode === "external" ? "external" : "native";
4127
+ } catch {
4128
+ return null;
4129
+ }
4130
+ }
4131
+ /** Serialize every read-modify-write of one session's record. */
4132
+ async function withRecordLock(configDir, session, action) {
4133
+ const directory = join(configDir, "machine", "sessions");
4134
+ privateMailDirectory(directory, true);
4135
+ let release;
4136
+ for (let attempt = 0; attempt < 200 && !release; attempt++) try {
4137
+ release = acquireListenLock(directory, `session-record-${session}`);
4138
+ } catch {
4139
+ await setTimeout$1(50);
4140
+ }
4141
+ if (!release) throw new ListenStateError("The session record is locked by another run.");
4142
+ try {
4143
+ return await action();
4144
+ } finally {
4145
+ release();
4146
+ }
4147
+ }
4148
+ /** Read, change and save one record under its lock. Returns the saved record. */
4149
+ function updateSessionRecord(configDir, session, change) {
4150
+ return withRecordLock(configDir, session, () => {
4151
+ const current = readSessionRecord(configDir, session);
4152
+ const next = change(current);
4153
+ if (next && next !== current) saveSessionRecord(configDir, next);
4154
+ return next ?? current;
4155
+ });
4156
+ }
4157
+ /** The address an interrupted enrollment saved, when there is one. */
4158
+ function enrollmentAddress(configDir, profileName) {
4159
+ try {
4160
+ const state = readMailJson(join(agentProfileDirectory(configDir, profileName), "enrollment", "state.json"), 65536);
4161
+ if (state === null) return {
4162
+ exists: false,
4163
+ address: null
4164
+ };
4165
+ return {
4166
+ exists: true,
4167
+ address: typeof state.address === "string" ? state.address : null
4168
+ };
4169
+ } catch {
4170
+ return {
4171
+ exists: true,
4172
+ address: null
4173
+ };
4174
+ }
4175
+ }
4176
+ /**
4177
+ * Saved profiles, other than the session's own, whose setup says they were
4178
+ * connected for this exact session. Sorted for a stable choice.
4179
+ */
4180
+ function profilesBoundToSession(configDir, sessionId, ownProfile, mode) {
4181
+ let names;
4182
+ try {
4183
+ names = readdirSync(join(agentProfilesDirectory(configDir), "profiles"));
4184
+ } catch {
4185
+ return [];
4186
+ }
4187
+ const bound = [];
4188
+ for (const name of names.sort()) {
4189
+ if (name === ownProfile) continue;
4190
+ try {
4191
+ const setup = readMailJson(join(agentProfileDirectory(configDir, name), "setup.json"));
4192
+ const saved = setup?.receiverMode === void 0 ? "native" : setup.receiverMode;
4193
+ if (typeof setup?.session !== "string" || setup.session.toLowerCase() !== sessionId || saved !== mode) continue;
4194
+ const profile = loadConnectedAgentProfile(configDir, name);
4195
+ if (profile) bound.push({
4196
+ name,
4197
+ profile
4198
+ });
4199
+ } catch {}
4200
+ }
4201
+ return bound;
4202
+ }
4203
+ /**
4204
+ * Every saved profile that still holds a credential and is bound to this
4205
+ * session: the session's own `session-<id>` profile, the profile its session
4206
+ * record names, and any profile whose setup names the session, in any
4207
+ * receiver mode. Mail to any of them wakes the session, so a second one
4208
+ * splits its mail across two addresses. Offline and read-only.
4209
+ */
4210
+ function connectedProfilesForSession(configDir, session, exclude) {
4211
+ const sessionId = session.trim().toLowerCase();
4212
+ if (!SESSION_UUID.test(sessionId)) return [];
4213
+ const candidates = new Set([`session-${sessionId}`]);
4214
+ const record = readSessionRecord(configDir, sessionId);
4215
+ if (record) candidates.add(record.profile);
4216
+ try {
4217
+ for (const name of readdirSync(join(agentProfilesDirectory(configDir), "profiles")).sort()) try {
4218
+ const setup = readMailJson(join(agentProfileDirectory(configDir, name), "setup.json"));
4219
+ if (typeof setup?.session === "string" && setup.session.toLowerCase() === sessionId) candidates.add(name);
4220
+ } catch {}
4221
+ } catch {}
4222
+ const bound = [];
4223
+ for (const name of [...candidates].sort()) {
4224
+ if (name === exclude) continue;
4225
+ try {
4226
+ const profile = loadConnectedAgentProfile(configDir, name);
4227
+ if (profile && !existsSync(join(agentProfileDirectory(configDir, name), `disconnected-${profile.invitation_hash}.json`))) bound.push({
4228
+ profile: name,
4229
+ address: profile.agent_address
4230
+ });
4231
+ } catch {}
4232
+ }
4233
+ return bound;
4234
+ }
4235
+ /** `agent disconnect` already confirmed revocation of this profile's credential. */
4236
+ function disconnectConfirmedLocally(configDir, profileName) {
4237
+ try {
4238
+ return readdirSync(agentProfileDirectory(configDir, profileName)).some((name) => /^disconnected-[a-f0-9]{64}\.json$/.test(name));
4239
+ } catch {
4240
+ return false;
4241
+ }
4242
+ }
4243
+ /**
4244
+ * Revoke a connection by address with the saved member login, for a session
4245
+ * whose local credential is gone. True only when Primitive confirms it.
4246
+ */
4247
+ async function revokeWithMemberLogin(configDir, address, fetchImpl = fetch) {
4248
+ try {
4249
+ const saved = loadCliCredentials(configDir);
4250
+ if (!saved) return false;
4251
+ const credentials = await refreshStoredCliCredentials({
4252
+ apiBaseUrl: saved.api_base_url,
4253
+ configDir,
4254
+ credentials: saved,
4255
+ fetch: fetchImpl
4256
+ });
4257
+ const response = await fetchImpl(`${credentials.api_base_url}/agent-connections/${encodeURIComponent(address)}`, {
4258
+ method: "DELETE",
4259
+ redirect: "error",
4260
+ signal: AbortSignal.timeout(15e3),
4261
+ headers: {
4262
+ authorization: `Bearer ${credentials.access_token}`,
4263
+ accept: "application/json"
4264
+ }
4265
+ });
4266
+ if (response.status !== 200) {
4267
+ await response.body?.cancel().catch(() => void 0);
4268
+ return false;
4269
+ }
4270
+ const text = await response.text();
4271
+ if (text.length > 65536) return false;
4272
+ const body = JSON.parse(text);
4273
+ return body?.success === true && body.data?.connection?.address === address && body.data.connection.status === "revoked";
4274
+ } catch {
4275
+ return false;
4276
+ }
4277
+ }
4278
+ /** Enrollment saves its state before any request, so a quiet record this old created nothing. */
4279
+ const ENROLLMENT_SETTLED_MS = 10 * 6e4;
4280
+ async function disconnectSessionAgent(configDir, record, deps, env, fetchImpl, enrollmentSettled = false) {
4281
+ try {
4282
+ if (loadConnectedAgentProfile(configDir, record.profile)) {
4283
+ await deps.disconnect({
4284
+ configDir,
4285
+ profileName: record.profile,
4286
+ fetch: fetchImpl,
4287
+ env
4288
+ });
4289
+ return "done";
4290
+ }
4291
+ if (disconnectConfirmedLocally(configDir, record.profile)) return "done";
4292
+ const enrollment = enrollmentAddress(configDir, record.profile);
4293
+ const address = record.address ?? enrollment.address;
4294
+ if (!address) {
4295
+ if (enrollment.exists) return "pending";
4296
+ return enrollmentSettled || Date.now() - Date.parse(record.registeredAt) > ENROLLMENT_SETTLED_MS ? "done" : "pending_enrollment";
4297
+ }
4298
+ return await deps.revokeByAddress(configDir, address, fetchImpl) ? "done" : "pending";
4299
+ } catch {
4300
+ return "pending";
4301
+ }
4302
+ }
4303
+ async function finishSessionDisconnect(configDir, record, deps, env, fetchImpl, enrollmentSettled = false) {
4304
+ const outcome = await disconnectSessionAgent(configDir, record, deps, env, fetchImpl, enrollmentSettled);
4305
+ return (await updateSessionRecord(configDir, record.session, (current) => !current || current.disconnect === "done" || current.disconnect === outcome ? current : {
4306
+ ...current,
4307
+ disconnect: outcome
4308
+ }))?.disconnect === "done" ? "done" : outcome;
4309
+ }
4310
+ const RECEIVING = {
4311
+ claude: "external_hook",
4312
+ codex: "native",
4313
+ omp: "unsupported"
4314
+ };
4315
+ function withDefined(defaults, overrides) {
4316
+ return {
4317
+ ...defaults,
4318
+ ...Object.fromEntries(Object.entries(overrides ?? {}).filter(([, value]) => value !== void 0))
4319
+ };
4320
+ }
4321
+ 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.";
4322
+ const ENDED_DETAIL = "This session ended, so its agent was disconnected. A new address is never created for the same session.";
4323
+ const ENDED_PENDING_DETAIL = "This session ended, but disconnecting its agent has not been confirmed yet; `primitive machine doctor --fix` retries it.";
4324
+ /**
4325
+ * Give this session an address once. Safe to run on every start and resume:
4326
+ * a session already bound to a profile is only re-verified, and a session
4327
+ * whose agent was disconnected or removed is never given a second address.
4328
+ * Never throws; every failure is a status.
4329
+ */
4330
+ async function registerSession(options) {
4331
+ const env = options.env ?? process.env;
4332
+ const deps = withDefined({
4333
+ enroll: enrollAgent,
4334
+ installClaudeHook: installClaudeWakeHook,
4335
+ seedAgentInfo: (configDir, profileName, value) => seedAgentInfoNote(configDir, profileName, value, options.fetch),
4336
+ connectionState: (profile) => agentConnectionState(profile, options.fetch),
4337
+ ompSession: ompProcessSession,
4338
+ now: () => /* @__PURE__ */ new Date(),
4339
+ disconnect: disconnectAgent,
4340
+ revokeByAddress: revokeWithMemberLogin
4341
+ }, options.dependencies);
4342
+ const runtime = options.runtime;
4343
+ const base = {
4344
+ runtime,
4345
+ session: null,
4346
+ profile: null,
4347
+ address: null,
4348
+ receiving: null
4349
+ };
4350
+ if (runtime === "claude" && headlessClaudeRun(env)) return {
4351
+ ...base,
4352
+ status: "skipped_headless",
4353
+ detail: HEADLESS_DETAIL
4354
+ };
4355
+ let session;
4356
+ try {
4357
+ session = options.session?.trim() || runtimeSessionId(runtime, env, options.configDir, deps.ompSession);
4358
+ } catch {
4359
+ session = null;
4360
+ }
4361
+ if (!session) return {
4362
+ ...base,
4363
+ status: "no_session",
4364
+ detail: runtime === "omp" ? "omp does not expose a session ID to commands, and no running omp process was found. Pass --session." : `No ${runtime === "claude" ? "CLAUDE_CODE_SESSION_ID" : "CODEX_THREAD_ID"} in this environment. Pass --session.`
4365
+ };
4366
+ if (!SESSION_UUID.test(session)) return {
4367
+ ...base,
4368
+ status: "failed",
4369
+ detail: "The session ID is not a UUID. Nothing was changed."
4370
+ };
4371
+ const sessionId = session.toLowerCase();
4372
+ const ownProfile = `session-${sessionId}`;
4373
+ let profileName = ownProfile;
4374
+ try {
4375
+ const saved = readSessionRecord(options.configDir, sessionId);
4376
+ if (saved?.createdBy === "existing" && saved.profile !== ownProfile) profileName = agentProfileName(saved.profile);
4377
+ else if (!saved && !loadConnectedAgentProfile(options.configDir, ownProfile) && !enrollmentAddress(options.configDir, ownProfile).exists) {
4378
+ let offline = false;
4379
+ for (const candidate of profilesBoundToSession(options.configDir, sessionId, ownProfile, runtime === "codex" ? "native" : "external")) {
4380
+ const state = await deps.connectionState(candidate.profile);
4381
+ if (state === "connected") {
4382
+ profileName = candidate.name;
4383
+ offline = false;
4384
+ break;
4385
+ }
4386
+ if (state === "unavailable") offline = true;
4387
+ }
4388
+ if (offline) return {
4389
+ ...base,
4390
+ session: sessionId,
4391
+ receiving: RECEIVING[runtime],
4392
+ status: "offline",
4393
+ detail: "A saved profile is connected for this session, but its status could not be checked right now. No new address was created."
4394
+ };
4395
+ }
4396
+ } catch {
4397
+ profileName = ownProfile;
4398
+ }
4399
+ const reused = profileName !== ownProfile;
4400
+ const known = {
4401
+ ...base,
4402
+ session: sessionId,
4403
+ profile: profileName
4404
+ };
4405
+ const cwd = resolve(options.cwd ?? process.cwd());
4406
+ const ended = async (current, enrolledNow = false) => {
4407
+ const outcome = current.disconnect === "done" && !enrolledNow ? "done" : await finishSessionDisconnect(options.configDir, current, deps, env, options.fetch, enrolledNow);
4408
+ return {
4409
+ ...known,
4410
+ address: current.address,
4411
+ status: "ended",
4412
+ detail: outcome === "done" ? ENDED_DETAIL : ENDED_PENDING_DETAIL
4413
+ };
4414
+ };
4415
+ const newRecord = (createdBy, address) => ({
4416
+ version: 1,
4417
+ runtime,
4418
+ session: sessionId,
4419
+ profile: profileName,
4420
+ name: defaultSessionName(runtime, cwd),
4421
+ createdBy,
4422
+ address,
4423
+ agentInfo: null,
4424
+ registeredAt: deps.now().toISOString(),
4425
+ endedAt: null,
4426
+ disconnect: null
4427
+ });
4428
+ try {
4429
+ let record = readSessionRecord(options.configDir, sessionId);
4430
+ if (record?.endedAt) return await ended(record);
4431
+ const finishAgentInfo = async () => {
4432
+ if (reused || record?.agentInfo) return;
4433
+ const seeded = await deps.seedAgentInfo(options.configDir, profileName, agentInfoForSession(runtime, cwd));
4434
+ if (seeded !== "created" && seeded !== "already_present") return;
4435
+ record = await updateSessionRecord(options.configDir, sessionId, (current) => current ? {
4436
+ ...current,
4437
+ agentInfo: seeded
4438
+ } : current);
4439
+ };
4440
+ const claudeHook = (agentAddress) => {
4441
+ const outcome = deps.installClaudeHook({
4442
+ cliPath: options.cliPath,
4443
+ configDir: options.configDir,
4444
+ profileName,
4445
+ agentAddress,
4446
+ sessionId,
4447
+ env,
4448
+ yieldToOtherProfiles: true
4449
+ });
4450
+ return outcome === "installed_unverified" ? "installed" : outcome === "held_for_other_profile" ? "held" : "failed";
4451
+ };
4452
+ const installHook = (profile) => runtime !== "claude" || receiverMode(agentProfileDirectory(options.configDir, profileName)) !== "external" ? "installed" : claudeHook(profile.agent_address);
4453
+ const existing = loadConnectedAgentProfile(options.configDir, profileName);
4454
+ if (existing) {
4455
+ record = await updateSessionRecord(options.configDir, sessionId, (current) => current ?? newRecord("existing", existing.agent_address));
4456
+ if (!record) throw new Error("session record unavailable");
4457
+ if (record.endedAt) return await ended(record);
4458
+ const state = await deps.connectionState(existing);
4459
+ const withAddress = {
4460
+ ...known,
4461
+ address: existing.agent_address
4462
+ };
4463
+ if (state === "revoked" || state === "rejected") return {
4464
+ ...withAddress,
4465
+ status: "revoked",
4466
+ detail: "This session's agent was disconnected in Primitive. No new address was created. `primitive machine doctor --fix` removes the stale local profile."
4467
+ };
4468
+ if (state === "unavailable") return {
4469
+ ...withAddress,
4470
+ receiving: RECEIVING[runtime],
4471
+ status: "offline",
4472
+ detail: "This session already has an address; its status could not be checked right now."
4473
+ };
4474
+ if (state === "pending" && record.createdBy !== "session-register") return {
4475
+ ...withAddress,
4476
+ status: "pending",
4477
+ detail: "This session's agent is waiting for pairing confirmation. Finish it with the command that started it."
4478
+ };
4479
+ if (state === "connected") {
4480
+ const hooked = installHook(existing);
4481
+ await finishAgentInfo();
4482
+ return {
4483
+ ...withAddress,
4484
+ receiving: RECEIVING[runtime],
4485
+ status: "already_registered",
4486
+ 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`."
4487
+ };
4488
+ }
4489
+ } else if (record?.address && record.createdBy === "session-register" && !enrollmentAddress(options.configDir, profileName).exists || record?.createdBy === "existing") return {
4490
+ ...known,
4491
+ address: record.address,
4492
+ status: "removed",
4493
+ detail: "This session's local profile was removed. No new address was created for the same session."
4494
+ };
4495
+ let credentials;
4496
+ try {
4497
+ credentials = loadCliCredentials(options.configDir);
4498
+ } catch {
4499
+ credentials = null;
4500
+ }
4501
+ if (!credentials) return {
4502
+ ...known,
4503
+ status: "not_logged_in",
4504
+ detail: "No saved member login on this machine. Run `primitive signin`, then start the session again."
4505
+ };
4506
+ record = await updateSessionRecord(options.configDir, sessionId, (current) => current ?? newRecord("session-register", null));
4507
+ if (!record) throw new Error("session record unavailable");
4508
+ if (record.endedAt) return await ended(record);
4509
+ const enrollEnv = { ...env };
4510
+ delete enrollEnv.PRIMITIVE_AGENT_PROFILE;
4511
+ delete enrollEnv.PRIMITIVE_API_KEY;
4512
+ delete enrollEnv.PRIMITIVE_KEY;
4513
+ if (runtime === "claude" && options.trustedSession) enrollEnv.CLAUDE_CODE_SESSION_ID = sessionId;
4514
+ let result = null;
4515
+ let enrollError = null;
4516
+ try {
4517
+ result = await deps.enroll({
4518
+ configDir: options.configDir,
4519
+ session: sessionId,
4520
+ name: record.name,
4521
+ receiverMode: runtime === "codex" ? "native" : "external",
4522
+ contactRequests: false,
4523
+ env: enrollEnv,
4524
+ fetch: options.fetch,
4525
+ ...runtime === "omp" ? { preflight: async () => void 0 } : {}
4526
+ });
4527
+ } catch (error) {
4528
+ if (error instanceof ListenStateError) throw error;
4529
+ enrollError = error;
4530
+ }
4531
+ let created = result?.identity.agentAddress ?? null;
4532
+ if (!created) try {
4533
+ created = loadConnectedAgentProfile(options.configDir, profileName)?.agent_address ?? enrollmentAddress(options.configDir, profileName).address;
4534
+ } catch {
4535
+ created = enrollmentAddress(options.configDir, profileName).address;
4536
+ }
4537
+ record = await updateSessionRecord(options.configDir, sessionId, (current) => current && created && current.address !== created ? {
4538
+ ...current,
4539
+ address: created
4540
+ } : current);
4541
+ if (!record) throw new Error("session record unavailable");
4542
+ if (record.endedAt) return await ended(record, true);
4543
+ if (enrollError || !result) throw enrollError;
4544
+ const address = result.identity.agentAddress;
4545
+ const submitted = verificationReplySubmitted(result.verification.state);
4546
+ const status = result.connection.status;
4547
+ let hooked = "installed";
4548
+ if (runtime === "claude" && submitted && status !== "owner_inactive") hooked = claudeHook(address);
4549
+ if (status === "connected") await finishAgentInfo();
4550
+ const withAddress = {
4551
+ ...known,
4552
+ address,
4553
+ receiving: RECEIVING[runtime]
4554
+ };
4555
+ const name = result.name ?? record.name;
4556
+ if (status === "connected") return {
4557
+ ...withAddress,
4558
+ status: "registered",
4559
+ name,
4560
+ nameIsDefault: name === record.name,
4561
+ 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\`.`
4562
+ };
4563
+ if (status === "revoked" || status === "owner_inactive") return {
4564
+ ...withAddress,
4565
+ status,
4566
+ detail: status === "revoked" ? "The new agent was disconnected in Primitive before setup finished." : "The member who owns this login is no longer active in the organization."
4567
+ };
4568
+ return {
4569
+ ...withAddress,
4570
+ status: "pending",
4571
+ detail: "The address exists and pairing is not confirmed yet. The next session start resumes it; no second address is created."
4572
+ };
4573
+ } catch (error) {
4574
+ if (error instanceof ListenStateError) return {
4575
+ ...known,
4576
+ status: "busy",
4577
+ detail: "Another registration for this session is already running."
4578
+ };
4579
+ return {
4580
+ ...known,
4581
+ status: "failed",
4582
+ detail: error instanceof AgentConnectionSetupError ? error.message : "Registration stopped before finishing. The session continues normally; the next start resumes it."
4583
+ };
4584
+ }
4585
+ }
4586
+ /**
4587
+ * Disconnect the agent a session got from `session-register`. Agents
4588
+ * connected any other way are left alone. The end is recorded first, under
4589
+ * the record's lock, so a registration still enrolling sees it and
4590
+ * disconnects the agent it creates. Never throws.
4591
+ */
4592
+ async function endSession(options) {
4593
+ const env = options.env ?? process.env;
4594
+ const deps = withDefined({
4595
+ disconnect: disconnectAgent,
4596
+ revokeByAddress: revokeWithMemberLogin,
4597
+ now: () => /* @__PURE__ */ new Date()
4598
+ }, options.dependencies);
4599
+ const runtime = options.runtime;
4600
+ const base = {
4601
+ runtime,
4602
+ session: null,
4603
+ address: null
4604
+ };
4605
+ if (runtime === "claude" && headlessClaudeRun(env)) return {
4606
+ ...base,
4607
+ status: "skipped_headless",
4608
+ detail: HEADLESS_DETAIL
4609
+ };
4610
+ let session;
4611
+ try {
4612
+ session = options.session?.trim() || runtimeSessionId(runtime, env, options.configDir, () => null);
4613
+ } catch {
4614
+ session = null;
4615
+ }
4616
+ if (!session || !SESSION_UUID.test(session)) return {
4617
+ ...base,
4618
+ status: "no_session",
4619
+ detail: "No session ID was given or found. Nothing was changed."
4620
+ };
4621
+ const sessionId = session.toLowerCase();
4622
+ try {
4623
+ let managed = false;
4624
+ let alreadyDone = false;
4625
+ const record = await updateSessionRecord(options.configDir, sessionId, (current) => {
4626
+ if (!current || current.createdBy !== "session-register") return current;
4627
+ managed = true;
4628
+ if (current.endedAt && current.disconnect === "done") {
4629
+ alreadyDone = true;
4630
+ return current;
4631
+ }
4632
+ return {
4633
+ ...current,
4634
+ endedAt: current.endedAt ?? deps.now().toISOString(),
4635
+ disconnect: "pending"
4636
+ };
4637
+ });
4638
+ const known = {
4639
+ ...base,
4640
+ session: sessionId,
4641
+ address: record?.address ?? null
4642
+ };
4643
+ if (!record || !managed) return {
4644
+ ...known,
4645
+ status: "not_managed",
4646
+ detail: "This session's agent was not created by `agent session-register`, so it was left connected."
4647
+ };
4648
+ if (alreadyDone) return {
4649
+ ...known,
4650
+ status: "already_ended",
4651
+ detail: "This session's agent was already disconnected."
4652
+ };
4653
+ return await finishSessionDisconnect(options.configDir, record, deps, env, options.fetch) === "done" ? {
4654
+ ...known,
4655
+ status: "disconnected",
4656
+ detail: "This session's agent was disconnected."
4657
+ } : {
4658
+ ...known,
4659
+ status: "disconnect_pending",
4660
+ detail: "The session is marked ended, but disconnecting its agent is not confirmed yet. The next `primitive machine doctor --fix` (or a registration still finishing) retries it."
4661
+ };
4662
+ } catch (error) {
4663
+ return {
4664
+ ...base,
4665
+ session: sessionId,
4666
+ status: "failed",
4667
+ detail: error instanceof AgentDisconnectError ? error.message : "Disconnect did not finish. The agent was left as it was."
4668
+ };
4669
+ }
4670
+ }
4671
+ /** Ended sessions whose agent disconnect still needs confirming. */
4672
+ function pendingSessionDisconnects(configDir) {
4673
+ const directory = join(configDir, "machine", "sessions");
4674
+ let names;
4675
+ try {
4676
+ names = readdirSync(directory);
4677
+ } catch {
4678
+ return [];
4679
+ }
4680
+ return names.flatMap((name) => {
4681
+ const match = /^([0-9a-f-]{36})\.json$/.exec(name);
4682
+ if (!match?.[1]) return [];
4683
+ const record = readSessionRecord(configDir, match[1]);
4684
+ return record?.endedAt && (record.disconnect === "pending" || record.disconnect === "pending_enrollment") ? [record] : [];
4685
+ });
4686
+ }
4687
+ /** Retry every pending disconnect. Returns how many are now confirmed. */
4688
+ async function retryPendingDisconnects(configDir, options = {}) {
4689
+ const deps = withDefined({
4690
+ disconnect: disconnectAgent,
4691
+ revokeByAddress: revokeWithMemberLogin
4692
+ }, options.dependencies);
4693
+ let done = 0;
4694
+ for (const record of pendingSessionDisconnects(configDir)) if (await finishSessionDisconnect(configDir, record, deps, options.env ?? process.env, options.fetch) === "done") done++;
4695
+ return done;
4696
+ }
4697
+ /** Read Claude's hook JSON from stdin within a short budget. */
4698
+ async function readClaudeHookInput(stdin, timeoutMs = 2e3) {
4699
+ let input = "";
4700
+ const read = (async () => {
4701
+ for await (const chunk of stdin) {
4702
+ input += String(chunk);
4703
+ if (input.length > 65536) return false;
4704
+ }
4705
+ return true;
4706
+ })().catch(() => false);
4707
+ let timer;
4708
+ const complete = await Promise.race([read, new Promise((done) => {
4709
+ timer = setTimeout(() => done(false), timeoutMs);
4710
+ timer.unref();
4711
+ })]);
4712
+ if (timer) clearTimeout(timer);
4713
+ if (!complete) return null;
4714
+ try {
4715
+ const value = JSON.parse(input);
4716
+ if (typeof value?.session_id !== "string" || !SESSION_UUID.test(value.session_id)) return null;
4717
+ return {
4718
+ sessionId: value.session_id.toLowerCase(),
4719
+ cwd: typeof value.cwd === "string" && value.cwd ? value.cwd : null
4720
+ };
4721
+ } catch {
4722
+ return null;
4723
+ }
4724
+ }
4725
+ /**
4726
+ * Run the real work in a detached process so a hook never holds up the
4727
+ * session; wait for it only up to the budget.
4728
+ */
4729
+ async function runDetached(params) {
4730
+ try {
4731
+ const child = spawn(params.node, [params.entry, ...params.args], {
4732
+ detached: true,
4733
+ stdio: "ignore",
4734
+ env: params.env,
4735
+ ...params.cwd && existsSync(params.cwd) ? { cwd: params.cwd } : {}
4736
+ });
4737
+ child.unref();
4738
+ return await new Promise((done) => {
4739
+ const timer = setTimeout(() => done("detached"), params.waitMs);
4740
+ child.once("error", () => {
4741
+ clearTimeout(timer);
4742
+ done("failed");
4743
+ });
4744
+ child.once("exit", () => {
4745
+ clearTimeout(timer);
4746
+ done("finished");
4747
+ });
4748
+ });
4749
+ } catch {
4750
+ return "failed";
4751
+ }
4752
+ }
4753
+ //#endregion
4754
+ export { describeSessionHookItem as $, NetworkAdmissionPendingError as A, runContactRequest as B, pollCheckCommand as C, verificationReplySubmitted as D, RECEIVER_MODES as E, addressNoteTarget as F, receiverStatusCommand as G, agentInvitationHash as H, runAddressNotesRequest as I, recordHookMailCheck as J, savedReceiver as K, ContactsApiError as L, evaluateContactPolicy as M, canonicalContactSelector as N, ContactPolicyReadRetryError as O, AddressNotesApiError as P, describeSessionHookChange as Q, contactAddress as R, invitationProfileName as S, withOwnerMemberAddress as T, connectAgent as U, agentConnectionStatus as V, readAgentInvitation as W, claudeConfigDir as X, boundSessionProfiles as Y, currentCliLocation as Z, disconnectAgent as _, endSession as a, samePath as at, identitySuggestions as b, pendingSessionDisconnects as c, inspectManagedBlock as ct, registerSession as d, upsertManagedBlock as dt, ephemeralCliReason as et, retryPendingDisconnects as f, writeManagedFile as ft, AgentDisconnectError as g, enrollmentResumes as h, connectedProfilesForSession as i, repairClaudeHooks as it, createNotificationContactPolicy as j, NETWORK_ADMISSION_PENDING_RETRY_MS as k, psProcessInfo as l, jsonIndentation as lt, enrollAgent as m, OMP_MAIN as n, inspectClaudeHooks as nt, headlessClaudeRun as o, writeClaudeSettings as ot, runDetached as p, installClaudeWakeHook as q, agentConnectionState as r, readClaudeSettings as rt, machine_session_exports as s, MachineFileError as st, MACHINE_RUNTIMES as t, findOnPath as tt, readClaudeHookInput as u, readManagedFile as ut, cliInvocation as v, runAgentConnect as w, defaultAgentProfileName as x, generatedAgentName as y, contactAgentAddress as z };