primcli 1.48.0 → 1.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,18 +1,334 @@
1
1
  import { n as __exportAll } from "./rolldown-runtime-CNZpIYwj.js";
2
2
  import { B as parseOwnerMemberAddress, F as agentProfilesDirectory, I as connectedAgentIdentity, L as connectedApiBaseUrl, M as AgentConnectionSetupError, N as agentProfileDirectory, P as agentProfileName, R as loadConnectedAgentProfile, U as saveConnectedAgentProfile, V as profileAlreadyConnectedMessage, W as PrimitiveApiClient, j as AGENT_PROFILE_ENV, x as loadCliCredentials, z as parseConnectedAgentProfile } from "./cli-config-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-DzQ8KxD9.js";
3
+ import { An as listDomains, Dn as listContacts, Gn as putContact, H as defaultConnectionName, Ln as listSentEmails, Qn as searchEmails, Tn as listAgentContacts, Un as putAgentContact, Wn as putAgentContactPolicy, Wt as refreshStoredCliCredentials, ln as getAgentContactPolicy, mn as getEmail, nn as deleteContact, tn as deleteAgentContact, tr as sendEmail, un as getContact } from "./api-command-Ci6eyQ8d.js";
4
4
  import { _ as acquireListenLock, c as mailTime, d as readMailJson, f as removeMailFile, g as ListenStateError, h as writeMailJson, i as mailId, l as privateMailDirectory, o as mailObject, r as mailAddress, s as mailString } from "./shared-mail-files-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-DHPc9dVN.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";
5
+ import { a as scopedChatSenderTrust } from "./scoped-chat-DSLrmgWQ.js";
6
+ import { _ as NativeSessionDisconnectedError, a as readBundledConnectSkill, g as defaultSessionSocket, h as connectNativeSession, m as SESSION_UUID, n as detectAgentRuntime, o as notificationScope, r as installConnectSkill, u as NotificationRetryError } from "./connect-skill-xtf3Q5IF.js";
7
+ import { l as stopBackgroundListen, r as backgroundListenStatus } from "./listen-background-CMfxkeVP.js";
8
+ import { t as readSharedMailOwner } from "./shared-mail-watch-CWhpfJ0N.js";
9
+ import { accessSync, chmodSync, closeSync, constants, copyFileSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
10
10
  import { createHash, randomUUID } from "node:crypto";
11
11
  import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path";
12
12
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
13
13
  import { execFile, execFileSync, spawn } from "node:child_process";
14
14
  import { homedir } from "node:os";
15
15
  import { promisify } from "node:util";
16
+ import { createConnection } from "node:net";
17
+ //#region src/oclif/owner-member-address.ts
18
+ const REFRESH_TIMEOUT_MS = 5e3;
19
+ const MAX_RESPONSE_BYTES$1 = 64 * 1024;
20
+ /** Reads at most MAX_RESPONSE_BYTES, stopping as soon as the body exceeds it. */
21
+ async function boundedText(response) {
22
+ const reader = response.body?.getReader();
23
+ if (!reader) return null;
24
+ const chunks = [];
25
+ let size = 0;
26
+ try {
27
+ for (;;) {
28
+ const part = await reader.read();
29
+ if (part.done) break;
30
+ size += part.value.byteLength;
31
+ if (size > MAX_RESPONSE_BYTES$1) return null;
32
+ chunks.push(part.value);
33
+ }
34
+ return Buffer.concat(chunks).toString("utf8");
35
+ } finally {
36
+ await reader.cancel().catch(() => void 0);
37
+ }
38
+ }
39
+ /**
40
+ * Reads the owner's personal mailbox from the connection's own record
41
+ * (GET /agent-connections/me) and saves it in the profile, so an agent paired
42
+ * before the server reported it, or before its owner set one up, learns where
43
+ * to send reports. Best effort: any failure keeps the saved value. Returns the
44
+ * address to report to, or null when there is none or it is unknown.
45
+ */
46
+ async function refreshOwnerMemberAddress(params) {
47
+ const profile = loadConnectedAgentProfile(params.configDir, params.profileName);
48
+ if (!profile) return null;
49
+ const saved = profile.owner_member_address ?? null;
50
+ try {
51
+ const response = await (params.fetch ?? fetch)(`${profile.api_base_url}/agent-connections/me`, {
52
+ headers: { authorization: `Bearer ${profile.api_key}` },
53
+ redirect: "error",
54
+ signal: AbortSignal.timeout(params.timeoutMs ?? REFRESH_TIMEOUT_MS)
55
+ });
56
+ if (!response.ok) return saved;
57
+ const text = await boundedText(response);
58
+ if (text === null) return saved;
59
+ const body = JSON.parse(text);
60
+ const connection = body?.data?.connection;
61
+ 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;
62
+ const current = parseOwnerMemberAddress(connection.owner_member_address, profile.owner_address, profile.agent_address);
63
+ const latest = loadConnectedAgentProfile(params.configDir, params.profileName);
64
+ if (!latest || latest.api_key !== profile.api_key || latest.agent_address !== profile.agent_address || latest.owner_address !== profile.owner_address) return latest?.owner_member_address ?? null;
65
+ if (latest.owner_member_address !== current) saveConnectedAgentProfile(params.configDir, params.profileName, {
66
+ ...latest,
67
+ owner_member_address: current
68
+ });
69
+ return current;
70
+ } catch {
71
+ return saved;
72
+ }
73
+ }
74
+ const REFRESH_STAMP_FILE = "owner-member-refresh.json";
75
+ function lastRefreshAttempt(path) {
76
+ try {
77
+ const saved = readMailJson(path);
78
+ if (saved?.version !== 1 || typeof saved.at !== "string") return null;
79
+ const at = Date.parse(saved.at);
80
+ return Number.isFinite(at) ? at : null;
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+ /**
86
+ * refreshOwnerMemberAddress for routine paths (each mail check, each start of
87
+ * a wake receiver): reads the connection's record at most once per interval,
88
+ * so an owner who sets up a personal address after pairing is learned without
89
+ * a reconnect. Between reads it returns the saved value without a request.
90
+ * The time of each attempt is saved with the profile, so a failing server is
91
+ * not asked again on every turn. Never throws.
92
+ */
93
+ async function refreshOwnerMemberAddressPeriodically(params) {
94
+ let saved = null;
95
+ let release;
96
+ try {
97
+ const profile = loadConnectedAgentProfile(params.configDir, params.profileName);
98
+ if (!profile) return null;
99
+ saved = profile.owner_member_address ?? null;
100
+ const directory = agentProfileDirectory(params.configDir, params.profileName);
101
+ try {
102
+ release = acquireListenLock(directory, "owner-member-refresh");
103
+ } catch {
104
+ return saved;
105
+ }
106
+ const now = (params.now ?? Date.now)();
107
+ const stamp = join(directory, REFRESH_STAMP_FILE);
108
+ const last = lastRefreshAttempt(stamp);
109
+ if (last !== null && last <= now && now - last < (params.intervalMs ?? 6e5)) return saved;
110
+ try {
111
+ writeMailJson(stamp, {
112
+ version: 1,
113
+ at: new Date(now).toISOString()
114
+ });
115
+ } catch {}
116
+ return await refreshOwnerMemberAddress(params);
117
+ } catch {
118
+ return saved;
119
+ } finally {
120
+ release?.();
121
+ }
122
+ }
123
+ /** One sentence telling the agent where its reports go. */
124
+ function ownerReportGuidance(identity) {
125
+ 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.`;
126
+ }
127
+ /**
128
+ * Adds the current personal address and report guidance to a result that
129
+ * carries a connected identity, refreshing the saved profile first.
130
+ */
131
+ async function withOwnerMemberAddress(result, params) {
132
+ const saved = loadConnectedAgentProfile(params.configDir, result.identity.profileName);
133
+ const ownerMemberAddress = params.onlyIfUnknown && typeof saved?.owner_member_address === "string" ? saved.owner_member_address : await refreshOwnerMemberAddress({
134
+ configDir: params.configDir,
135
+ profileName: result.identity.profileName,
136
+ fetch: params.fetch
137
+ });
138
+ const identity = {
139
+ ...result.identity,
140
+ ownerMemberAddress
141
+ };
142
+ return {
143
+ ...result,
144
+ identity,
145
+ ownerReportGuidance: ownerReportGuidance(identity)
146
+ };
147
+ }
148
+ //#endregion
149
+ //#region src/oclif/hook-launcher.ts
150
+ /**
151
+ * Claude Code hooks run Primitive through a small POSIX launcher instead of a
152
+ * pinned Node binary. A version manager (nvm, n, asdf) or a package-runner
153
+ * cache (npx) can remove the Node or CLI files a hook names at any time; the
154
+ * launcher then falls back to another Node and another copy of the CLI, and
155
+ * when it finds no Node at all it records a warning instead of failing
156
+ * silently. Windows keeps hooks that name Node directly.
157
+ */
158
+ const HOOK_LAUNCHER_MARKER = "primitive-hook-launcher-v1";
159
+ function hookLauncherSupported(platform = process.platform) {
160
+ return platform !== "win32";
161
+ }
162
+ function hookLauncherPath(configDir) {
163
+ return join(resolve(configDir), "bin", "primitive-node");
164
+ }
165
+ /** Written by the launcher when no Node (or no CLI copy) can be found. */
166
+ function hookLauncherWarningPath(configDir) {
167
+ return join(resolve(configDir), "bin", "primitive-node.warning");
168
+ }
169
+ function shellQuote(value) {
170
+ return `'${value.replace(/'/g, `'\\''`)}'`;
171
+ }
172
+ const NO_NODE_WARNING = `Primitive wake hooks could not find Node.js 22 or later, so new mail will not wake this session. Install it, then run \`primitive agent connect --resume\` for each connected session or \`primitive machine doctor --fix\`.`;
173
+ const NO_CLI_WARNING = "Primitive wake hooks could not find the Primitive CLI (its package cache was removed and it could not be fetched again), so new mail will not wake this session. Run `npm install -g primitive`, then `primitive machine doctor --fix`.";
174
+ /** The read version of the CLI package whose bin/run.js is `entry`. */
175
+ function cliPackageVersion(entry) {
176
+ try {
177
+ const manifest = JSON.parse(readFileSync(join(dirname(entry), "..", "package.json"), "utf8"));
178
+ return manifest.name === "primitive" && typeof manifest.version === "string" && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(manifest.version) ? manifest.version : null;
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+ /**
184
+ * The launcher script. It takes the hook's own arguments (the script to run,
185
+ * then its arguments) and:
186
+ *
187
+ * 1. runs the pinned Node when it still exists, else the first `node` on
188
+ * PATH, else a sibling version of the pinned one, else a common install
189
+ * location;
190
+ * 2. when the script the hook names is gone (a cleaned npx cache), runs the
191
+ * same script from the `primitive` on PATH, or from
192
+ * `npx -y -p primitive@<version>`;
193
+ * 3. when neither can be found, writes a one-line warning, shows it once to
194
+ * the session through the PostToolUse hook, and exits 0 so Claude Code is
195
+ * never blocked.
196
+ */
197
+ function hookLauncherScript(params) {
198
+ const warning = hookLauncherWarningPath(params.configDir);
199
+ const common = params.commonLocations === false ? "" : ` "$nvm_root/current/bin/node" "$nvm_root"/versions/node/*/bin/node /opt/homebrew/bin/node /usr/local/bin/node /usr/bin/node "$HOME/.volta/bin/node"`;
200
+ return `#!/bin/sh
201
+ # ${HOOK_LAUNCHER_MARKER}
202
+ # Written by the Primitive CLI. Claude Code hooks run Primitive through this
203
+ # file so they keep working when the Node.js or CLI copy they were installed
204
+ # with is removed. \`primitive agent connect --resume\` and
205
+ # \`primitive machine doctor --fix\` rewrite it.
206
+ pinned_node=${shellQuote(params.node)}
207
+ pinned_version=${shellQuote(params.version ?? "")}
208
+ warning_file=${shellQuote(warning)}
209
+
210
+ # Record why hooks cannot run, show it once to each session through its
211
+ # PostToolUse hook (the session id is the hook's sixth argument), and exit 0
212
+ # so Claude Code is never blocked.
213
+ fail_open() {
214
+ mkdir -p "\${warning_file%/*}" 2>/dev/null
215
+ printf '%s\\n' "$1" > "$warning_file" 2>/dev/null
216
+ case "\${hook_script##*/}" in
217
+ claude-pending-mail.mjs)
218
+ session=$hook_session
219
+ case "$session" in
220
+ '' | *[!0-9A-Fa-f-]*) session=unknown ;;
221
+ esac
222
+ # mkdir is atomic, so of several hooks running at once only one
223
+ # shows the warning.
224
+ if mkdir "$warning_file.shown-$session" 2>/dev/null; then
225
+ printf '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"%s"}}' "$1"
226
+ fi
227
+ ;;
228
+ esac
229
+ exit 0
230
+ }
231
+
232
+ # A fallback Node must be new enough to run this CLI.
233
+ supported() {
234
+ [ -x "$1" ] && "$1" -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 22 ? 0 : 1)' >/dev/null 2>&1
235
+ }
236
+
237
+ pick_node() {
238
+ if [ -x "$pinned_node" ]; then node=$pinned_node; return 0; fi
239
+ node=$(command -v node 2>/dev/null)
240
+ if [ -n "$node" ] && supported "$node"; then return 0; fi
241
+ versions=\${pinned_node%/*/bin/node}
242
+ nvm_root=\${NVM_DIR:-$HOME/.nvm}
243
+ for node in "$versions"/*/bin/node${common}; do
244
+ if supported "$node"; then return 0; fi
245
+ done
246
+ node=
247
+ return 1
248
+ }
249
+
250
+ package_dir() {
251
+ "$node" -e 'process.stdout.write(require("path").dirname(require("fs").realpathSync(process.argv[1])))' "$1" 2>/dev/null
252
+ }
253
+
254
+ # A CLI copy whose bin directory holds both the hook script and run.js.
255
+ complete_copy() {
256
+ [ -n "$1" ] && [ -f "$1/$name" ] && [ -f "$1/run.js" ]
257
+ }
258
+
259
+ hook_script=$1
260
+ hook_session=$6
261
+ pick_node || fail_open ${shellQuote(NO_NODE_WARNING)}
262
+ PATH=\${node%/*}:$PATH
263
+ export PATH
264
+
265
+ # The hook names a script and, after it, the CLI's run.js. When either is
266
+ # gone (a cleaned npx cache), both come from another copy of the CLI.
267
+ need_cli=
268
+ if [ $# -ge 1 ] && [ ! -f "$1" ]; then need_cli=1; fi
269
+ if [ $# -ge 2 ] && [ "\${2##*/}" = run.js ] && [ ! -f "$2" ]; then need_cli=1; fi
270
+ if [ -n "$need_cli" ]; then
271
+ name=\${1##*/}
272
+ dir=
273
+ if [ -f "$1" ] && complete_copy "\${1%/*}"; then dir=\${1%/*}; fi
274
+ if ! complete_copy "$dir"; then
275
+ found=$(command -v primitive 2>/dev/null)
276
+ if [ -n "$found" ]; then dir=$(package_dir "$found"); fi
277
+ fi
278
+ if ! complete_copy "$dir" && [ -n "$pinned_version" ]; then
279
+ npx=\${node%/*}/npx
280
+ [ -x "$npx" ] || npx=$(command -v npx 2>/dev/null)
281
+ if [ -n "$npx" ]; then
282
+ found=$("$npx" -y -p "primitive@$pinned_version" -c 'command -v primitive' 2>/dev/null | tail -n 1)
283
+ if [ -n "$found" ]; then dir=$(package_dir "$found"); fi
284
+ fi
285
+ fi
286
+ complete_copy "$dir" || fail_open ${shellQuote(NO_CLI_WARNING)}
287
+ shift
288
+ if [ $# -ge 1 ] && [ "\${1##*/}" = run.js ] && [ ! -f "$1" ]; then
289
+ shift
290
+ set -- "$dir/$name" "$dir/run.js" "$@"
291
+ else
292
+ set -- "$dir/$name" "$@"
293
+ fi
294
+ fi
295
+
296
+ if [ -f "$warning_file" ]; then rm -rf "$warning_file" "$warning_file".shown-* 2>/dev/null; fi
297
+ exec "$node" "$@"
298
+ `;
299
+ }
300
+ /**
301
+ * Write the launcher when its content differs, atomically and executable.
302
+ * Returns true when the file changed. Throws when it cannot be written.
303
+ */
304
+ function writeHookLauncher(path, content) {
305
+ try {
306
+ if (readFileSync(path, "utf8") === content) return false;
307
+ } catch {}
308
+ mkdirSync(dirname(path), {
309
+ recursive: true,
310
+ mode: 448
311
+ });
312
+ const temporary = `${path}.${randomUUID()}.tmp`;
313
+ try {
314
+ writeFileSync(temporary, content, { mode: 493 });
315
+ chmodSync(temporary, 493);
316
+ renameSync(temporary, path);
317
+ } catch (error) {
318
+ rmSync(temporary, { force: true });
319
+ throw error;
320
+ }
321
+ return true;
322
+ }
323
+ /** The warning the launcher last wrote, or null when it found what it needed. */
324
+ function readHookLauncherWarning(configDir) {
325
+ try {
326
+ return readFileSync(hookLauncherWarningPath(configDir), "utf8").trim() || null;
327
+ } catch {
328
+ return null;
329
+ }
330
+ }
331
+ //#endregion
16
332
  //#region src/oclif/machine-files.ts
17
333
  /** Hand-edited files larger than this are reported, never rewritten. */
18
334
  const MAX_MANAGED_FILE_BYTES = 1048576;
@@ -296,20 +612,39 @@ function stableNodePath(env, execPath = process.execPath) {
296
612
  if (onPath && isAbsolute(onPath) && onPath !== execPath && samePath(onPath, execPath)) return onPath;
297
613
  return execPath;
298
614
  }
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) {
615
+ /**
616
+ * Locate the running CLI. Null when its files are not where a package install
617
+ * puts them. With a config directory, Claude hooks run through the hook
618
+ * launcher in it (except on Windows).
619
+ */
620
+ function currentCliLocation(entryArg, env, execPath = process.execPath, options = {}) {
301
621
  const entry = realpathOrNull(entryArg);
302
622
  if (!entry) return null;
303
623
  const wake = realpathOrNull(join(dirname(entry), "claude-wake.mjs"));
304
624
  const pending = realpathOrNull(join(dirname(entry), "claude-pending-mail.mjs"));
305
625
  if (!wake || !pending) return null;
626
+ const runtimeNode = stableNodePath(env, execPath);
627
+ const launcher = options.configDir !== void 0 && hookLauncherSupported(options.platform) ? {
628
+ path: hookLauncherPath(options.configDir),
629
+ content: hookLauncherScript({
630
+ node: runtimeNode,
631
+ version: cliPackageVersion(entry),
632
+ configDir: options.configDir
633
+ })
634
+ } : null;
306
635
  return {
307
- node: stableNodePath(env, execPath),
636
+ node: launcher?.path ?? runtimeNode,
637
+ runtimeNode,
638
+ launcher,
308
639
  entry,
309
640
  wake,
310
641
  pending
311
642
  };
312
643
  }
644
+ /** Write the hook launcher a CLI location names. True when it changed. */
645
+ function ensureHookLauncher(cli) {
646
+ return cli.launcher ? writeHookLauncher(cli.launcher.path, cli.launcher.content) : false;
647
+ }
313
648
  /** Package-runner caches are cleaned up behind the user's back. */
314
649
  function ephemeralCliReason(entry) {
315
650
  if (/[\\/]_npx[\\/]/.test(entry)) return "an npx cache";
@@ -438,6 +773,17 @@ function isGlobalManaged(hook, spec) {
438
773
  const args = hook.args;
439
774
  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
775
  }
776
+ /**
777
+ * Whether settings carry either machine-wide session hook (SessionStart
778
+ * session-register or SessionEnd session-end), from any CLI path or version.
779
+ */
780
+ function hasMachineSessionHooks(settings) {
781
+ const hooks = record$2(settings.hooks) ? settings.hooks : {};
782
+ return Object.values(GLOBAL_HOOKS).some((spec) => {
783
+ const entries = eventEntries(hooks, spec.event);
784
+ return entries !== "invalid" && entries.some((entry) => record$2(entry) && Array.isArray(entry.hooks) && entry.hooks.some((hook) => isGlobalManaged(hook, spec)));
785
+ });
786
+ }
441
787
  function globalHookArgs(cli, spec) {
442
788
  return [
443
789
  cli.entry,
@@ -480,7 +826,7 @@ function hasSessionReceiveHook(hooks, event, target, node) {
480
826
  return entries.some((entry) => record$2(entry) && Array.isArray(entry.hooks) && entry.hooks.some((hook) => {
481
827
  if (!record$2(hook) || hook.type !== "command") return false;
482
828
  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);
829
+ return Array.isArray(args) && args.length === 7 && args.every((value) => typeof value === "string") && basename(args[0]) === (pending ? "claude-pending-mail.mjs" : "claude-wake.mjs") && resolve(args[2]) === configDir && args[3] === target.profile && args[4] === target.address && args[5].toLowerCase() === session && args[6] === (pending ? PENDING_MARKER$1 : WAKE_MARKER) && (node === null || samePath(hook.command, node));
484
830
  }));
485
831
  }
486
832
  /** A per-session receive hook written by `agent connect` or `agent enroll`, any version. */
@@ -688,9 +1034,13 @@ function heldSessionProfiles(hooks, bound, restore) {
688
1034
  }
689
1035
  return held;
690
1036
  }
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) => ({
1037
+ /**
1038
+ * Receive hooks each bound profile has in no form at all. A hook that exists
1039
+ * but names an old Node or CLI path is outdated, not missing, so one hook is
1040
+ * never reported (or repaired) as both.
1041
+ */
1042
+ function missingSessionHooks(hooks, bound) {
1043
+ return bound.flatMap((item) => SESSION_EVENTS.filter((event) => !hasSessionReceiveHook(hooks, event, item, null)).map((event) => ({
694
1044
  event,
695
1045
  bound: item
696
1046
  })));
@@ -808,7 +1158,7 @@ function inspectClaudeHooks(settings, context) {
808
1158
  };
809
1159
  const verdicts = judgeSessionHooks(hooks, context.cli, bound);
810
1160
  for (const verdict of verdicts.values()) counts[verdict]++;
811
- const missingHooks = missingSessionHooks(hooks, bound, context.cli?.node ?? process.execPath);
1161
+ const missingHooks = missingSessionHooks(hooks, bound);
812
1162
  const missing = missingHooks.length;
813
1163
  const items = sessionHookItems(hooks, verdicts, missingHooks);
814
1164
  const problems = [
@@ -936,7 +1286,7 @@ function repairClaudeHooks(settings, context, checks, options = {}) {
936
1286
  changed.add("claude.hook.stop");
937
1287
  }
938
1288
  }
939
- if (cli) for (const missing of missingSessionHooks(hooks, bound, cli.node)) {
1289
+ if (cli) for (const missing of missingSessionHooks(hooks, bound)) {
940
1290
  if (!selected(missing.bound.profile)) continue;
941
1291
  const entries = eventEntries(hooks, missing.event);
942
1292
  if (entries === "invalid") continue;
@@ -1023,12 +1373,12 @@ function claudeWakeHookStatus(options) {
1023
1373
  address: agentAddress,
1024
1374
  session: sessionId
1025
1375
  };
1026
- const node = stableNodePath(env);
1376
+ const commands = [...hookLauncherSupported(options.platform) ? [hookLauncherPath(configDir)] : [], stableNodePath(env)];
1027
1377
  const installed = [
1028
1378
  "Stop",
1029
1379
  "SessionStart",
1030
1380
  "PostToolUse"
1031
- ].every((event) => hasSessionReceiveHook(hooks, event, target, node));
1381
+ ].every((event) => commands.some((node) => hasSessionReceiveHook(hooks, event, target, node)));
1032
1382
  const lastFiredAt = recordedAt(readMailJson(join(agentProfileDirectory(configDir, profileName), `pending-mail-${sessionId}.fired.json`)));
1033
1383
  const lastMailCheckAt = recordedAt(readMailJson(hookMailCheckPath(configDir, profileName, sessionId)));
1034
1384
  const now = options.now ?? Date.now();
@@ -1099,17 +1449,27 @@ function editClaudeSettings(claudeDir, edit) {
1099
1449
  /** Add exact-session fail-open receiving hooks while preserving unrelated settings. */
1100
1450
  function installClaudeWakeHook(options) {
1101
1451
  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
1452
  const configDir = resolve(options.configDir);
1453
+ const env = options.env ?? process.env;
1454
+ const cli = currentCliLocation(options.cliPath, env, process.execPath, {
1455
+ configDir,
1456
+ platform: options.platform
1457
+ });
1458
+ if (!cli) return "unavailable";
1459
+ const cliPath = cli.entry;
1460
+ const wrapperPath = cli.wake;
1461
+ const pendingPath = cli.pending;
1106
1462
  const profileName = agentProfileName(options.profileName);
1107
1463
  const agentAddress = mailAddress(options.agentAddress);
1108
1464
  if (!SESSION_UUID.test(options.sessionId)) return "unavailable";
1109
1465
  const sessionId = options.sessionId.toLowerCase();
1110
- const env = options.env ?? process.env;
1111
1466
  const claudeDir = resolve(env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"));
1112
- const node = stableNodePath(env);
1467
+ let node = cli.node;
1468
+ try {
1469
+ ensureHookLauncher(cli);
1470
+ } catch {
1471
+ node = cli.runtimeNode;
1472
+ }
1113
1473
  let held = false;
1114
1474
  if (!editClaudeSettings(claudeDir, (settings) => {
1115
1475
  const hooks = settings.hooks ?? {};
@@ -1273,8 +1633,121 @@ function uninstallClaudeWakeHook(options) {
1273
1633
  //#endregion
1274
1634
  //#region src/oclif/agent-connect.ts
1275
1635
  const MAX_INVITATION_BYTES = 4096;
1276
- const MAX_RESPONSE_BYTES$1 = 32768;
1636
+ const MAX_RESPONSE_BYTES = 32768;
1277
1637
  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.";
1638
+ const INVITATION_UNAVAILABLE = "This invitation was already used, has expired, or was revoked, so it cannot connect this agent. Ask the owner to copy a fresh setup instruction from the app. Nothing was changed on this machine.";
1639
+ const INVITATION_ATTEMPTED_HERE = "An earlier setup on this machine already submitted this invitation and did not finish, so it was not submitted again; it may already be used. Ask the owner to copy a fresh setup instruction from the app. Nothing was changed on this machine.";
1640
+ const INVITATION_INVALID = "Primitive rejected this invitation as invalid. Ask the owner to copy a fresh setup instruction from the app. Nothing was changed on this machine.";
1641
+ /**
1642
+ * The server answered the claim with a definite refusal, so nothing was
1643
+ * claimed and no credential exists. Unlike a lost or malformed response, it
1644
+ * is safe to discard every local trace of the attempt.
1645
+ */
1646
+ var AgentInvitationRejectedError = class extends AgentConnectionSetupError {
1647
+ constructor(message, reason) {
1648
+ super(message);
1649
+ this.reason = reason;
1650
+ }
1651
+ };
1652
+ const NOTHING_CHANGED = "Nothing was changed on this machine.";
1653
+ /**
1654
+ * Restates a refusal that followed a replacement: the agent being replaced
1655
+ * was disconnected before the claim, so "nothing was changed" is not true.
1656
+ */
1657
+ function refusalAfterReplacement(error, replaced) {
1658
+ if (!(error instanceof AgentInvitationRejectedError) || !replaced.length) return error;
1659
+ const names = replaced.map((row) => row.address).join(", ");
1660
+ return new AgentInvitationRejectedError(error.message.replace(NOTHING_CHANGED, `The agent this was replacing (${names}) was already disconnected before the claim and stays disconnected.`), error.reason);
1661
+ }
1662
+ /** The error code of a refused claim, read without keeping the body. */
1663
+ async function claimErrorCode(response) {
1664
+ try {
1665
+ const body = await readClaimResponse(response);
1666
+ return body?.success === false && typeof body.error?.code === "string" ? body.error.code : null;
1667
+ } catch {
1668
+ return null;
1669
+ }
1670
+ }
1671
+ /** A definite refusal of the claim, or null when the outcome is uncertain. */
1672
+ async function definiteClaimRefusal(response) {
1673
+ if (![
1674
+ 400,
1675
+ 409,
1676
+ 429
1677
+ ].includes(response.status)) return null;
1678
+ const code = await claimErrorCode(response);
1679
+ if (response.status === 409 && code === "connection_invitation_unavailable") return new AgentInvitationRejectedError(INVITATION_UNAVAILABLE, "invitation_unavailable");
1680
+ if (response.status === 400 && code === "validation_error") return new AgentInvitationRejectedError(INVITATION_INVALID, "invitation_invalid");
1681
+ if (response.status === 429 && code === "rate_limit_exceeded") {
1682
+ const seconds = Number(response.headers.get("retry-after"));
1683
+ return new AgentInvitationRejectedError(`Primitive is limiting connection attempts from this network. The invitation was not claimed; wait ${Number.isSafeInteger(seconds) && seconds > 0 && seconds <= 3600 ? `${seconds} seconds` : "a minute"} and rerun the same command with the same invitation. Nothing was changed on this machine.`, "rate_limited");
1684
+ }
1685
+ return null;
1686
+ }
1687
+ /** The message for an invitation another profile on this machine already claimed. */
1688
+ function invitationUsedHereMessage(address) {
1689
+ return `This invitation was already used on this machine${address ? ` to connect ${address}` : ""} under another profile, so it cannot connect this agent. Ask the owner to copy a fresh setup instruction from the app. Nothing was changed on this machine.`;
1690
+ }
1691
+ /**
1692
+ * A local record says this invitation was submitted before. It is never
1693
+ * submitted again: the claim is one-use, and a second submission could not
1694
+ * recover a credential lost with an earlier response. The refusal names the
1695
+ * address when the earlier attempt finished, and is a definite local refusal
1696
+ * either way, so a caller may discard whatever it created for this run.
1697
+ */
1698
+ function earlierAttemptRefusal(configDir, journal, invitationHash) {
1699
+ const record = journal;
1700
+ let address = null;
1701
+ if (record?.status === "claimed" && typeof record.agent_address === "string") address = record.agent_address;
1702
+ else if (typeof record?.profile_name === "string") try {
1703
+ const earlier = loadConnectedAgentProfile(configDir, record.profile_name);
1704
+ if (earlier?.invitation_hash === invitationHash) address = earlier.agent_address;
1705
+ } catch {}
1706
+ return address !== null || record?.status === "claimed" ? new AgentInvitationRejectedError(invitationUsedHereMessage(address), "invitation_used_here") : new AgentInvitationRejectedError(INVITATION_ATTEMPTED_HERE, "invitation_attempted_here");
1707
+ }
1708
+ /** Removes a directory only when it is empty. Best effort. */
1709
+ function removeEmptyDirectory(path) {
1710
+ try {
1711
+ rmdirSync(path);
1712
+ } catch {}
1713
+ }
1714
+ const CONNECTION_NAME_FILE = "connection-name.json";
1715
+ /**
1716
+ * The display name this profile's claim reported, saved beside the profile
1717
+ * so a resumed setup can report it too. Undefined when none was saved.
1718
+ */
1719
+ function savedConnectionName(configDir, profileName) {
1720
+ try {
1721
+ const saved = readMailJson(join(agentProfileDirectory(configDir, profileName), CONNECTION_NAME_FILE));
1722
+ return saved?.version === 1 ? claimedConnectionName({ data: { connection: { name: saved.name } } }) : void 0;
1723
+ } catch {
1724
+ return;
1725
+ }
1726
+ }
1727
+ /**
1728
+ * Saves the connection's current display name beside its profile: from the
1729
+ * claim, and again after `agent rename`. Best effort: without it a resume
1730
+ * omits the name, as for a claim made by an older version.
1731
+ */
1732
+ function saveConnectionName(configDir, profileName, name) {
1733
+ try {
1734
+ writeMailJson(join(agentProfileDirectory(configDir, profileName), CONNECTION_NAME_FILE), {
1735
+ version: 1,
1736
+ name
1737
+ });
1738
+ } catch {}
1739
+ }
1740
+ /** The display name a claim response reports, or undefined when unusable. */
1741
+ function claimedConnectionName(value) {
1742
+ const name = value?.data?.connection?.name;
1743
+ if (typeof name !== "string") return void 0;
1744
+ const trimmed = name.trim();
1745
+ const printable = Array.from(trimmed).every((character) => {
1746
+ const code = character.charCodeAt(0);
1747
+ return code >= 32 && !(code >= 127 && code <= 159) && code !== 8232 && code !== 8233;
1748
+ });
1749
+ return trimmed.length > 0 && trimmed.length <= 80 && printable ? trimmed : void 0;
1750
+ }
1278
1751
  /** The receiver a saved profile's setup record names. Offline. */
1279
1752
  function savedReceiver(configDir, profileName) {
1280
1753
  try {
@@ -1434,7 +1907,7 @@ async function readClaimResponse(response) {
1434
1907
  const part = await reader.read();
1435
1908
  if (part.done) break;
1436
1909
  size += part.value.byteLength;
1437
- if (size > MAX_RESPONSE_BYTES$1) throw new Error();
1910
+ if (size > MAX_RESPONSE_BYTES) throw new Error();
1438
1911
  chunks.push(part.value);
1439
1912
  }
1440
1913
  return JSON.parse(Buffer.concat(chunks).toString("utf8"));
@@ -1489,11 +1962,18 @@ async function connectAgent(params) {
1489
1962
  identity: connectedAgentIdentity(profileName, existing)
1490
1963
  };
1491
1964
  }
1492
- privateMailDirectory(join(directory, "profiles"), true);
1493
- privateMailDirectory(agentProfileDirectory(params.configDir, profileName), true);
1494
1965
  const journal = join(directory, "claims", `${invitationHash}.json`);
1495
1966
  privateMailDirectory(join(directory, "claims"), true);
1496
- if (readMailJson(journal) !== null) throw new AgentConnectionSetupError(UNCERTAIN_CLAIM);
1967
+ let earlier;
1968
+ try {
1969
+ earlier = readMailJson(journal);
1970
+ } catch {
1971
+ throw new AgentConnectionSetupError(UNCERTAIN_CLAIM);
1972
+ }
1973
+ if (earlier !== null) throw earlierAttemptRefusal(params.configDir, earlier, invitationHash);
1974
+ privateMailDirectory(join(directory, "profiles"), true);
1975
+ const profileDirectory = agentProfileDirectory(params.configDir, profileName);
1976
+ privateMailDirectory(profileDirectory, true);
1497
1977
  writeMailJson(journal, {
1498
1978
  version: 1,
1499
1979
  profile_name: profileName,
@@ -1514,16 +1994,37 @@ async function connectAgent(params) {
1514
1994
  })
1515
1995
  });
1516
1996
  if (response.status !== 200) {
1517
- await response.body?.cancel();
1518
- throw new Error();
1997
+ const refusal = await definiteClaimRefusal(response);
1998
+ if (!refusal) {
1999
+ await response.body?.cancel().catch(() => void 0);
2000
+ throw new Error();
2001
+ }
2002
+ try {
2003
+ removeMailFile(journal);
2004
+ } catch {}
2005
+ removeEmptyDirectory(profileDirectory);
2006
+ throw refusal;
1519
2007
  }
1520
- const profile = profileFromClaim(await readClaimResponse(response), invitation, invitationHash, params.now ?? Date.now);
2008
+ const body = await readClaimResponse(response);
2009
+ const profile = profileFromClaim(body, invitation, invitationHash, params.now ?? Date.now);
2010
+ const name = claimedConnectionName(body);
1521
2011
  saveConnectedAgentProfile(params.configDir, profileName, profile);
2012
+ try {
2013
+ writeMailJson(journal, {
2014
+ version: 1,
2015
+ profile_name: profileName,
2016
+ status: "claimed",
2017
+ agent_address: profile.agent_address
2018
+ });
2019
+ } catch {}
2020
+ if (name !== void 0) saveConnectionName(params.configDir, profileName, name);
1522
2021
  return {
1523
2022
  status: "claimed",
1524
- identity: connectedAgentIdentity(profileName, profile)
2023
+ identity: connectedAgentIdentity(profileName, profile),
2024
+ ...name === void 0 ? {} : { name }
1525
2025
  };
1526
- } catch {
2026
+ } catch (error) {
2027
+ if (error instanceof AgentInvitationRejectedError) throw error;
1527
2028
  throw new AgentConnectionSetupError(UNCERTAIN_CLAIM);
1528
2029
  }
1529
2030
  } finally {
@@ -2350,6 +2851,20 @@ const VERIFICATION_BACKOFF_MS = [
2350
2851
  3e3,
2351
2852
  5e3
2352
2853
  ];
2854
+ /**
2855
+ * Setup's receiving state for an external receiver: setup itself installs no
2856
+ * hooks, so the caller installs them and restates the outcome with
2857
+ * {@link externalReceivingState}.
2858
+ */
2859
+ const EXTERNAL_HOOKS_PENDING = "hooks_pending";
2860
+ /**
2861
+ * The printed receiving state for an external receiver, from the hook
2862
+ * install outcome. `hooks_installed` is a finished setup: idle wake is only
2863
+ * proven by real mail, which `externalHook: "installed_unverified"` says.
2864
+ */
2865
+ function externalReceivingState(hook) {
2866
+ return hook === "installed_unverified" ? "hooks_installed" : hook === "unavailable" ? "hook_unavailable" : hook === "held_for_other_profile" ? "held_for_other_profile" : "pending_verification";
2867
+ }
2353
2868
  /** Both states mean the setup reply was accepted for sending. */
2354
2869
  function verificationReplySubmitted(state) {
2355
2870
  return state === "reply_submitted" || state === "verified";
@@ -2697,10 +3212,12 @@ async function setupAgent(params) {
2697
3212
  if ((receiverMode ?? "native") === "native" && params.session) try {
2698
3213
  await dependencies.preflight(params.session);
2699
3214
  } catch {
2700
- throw fail("This exact session is not available for native receiving. No invitation was claimed. Open a supported session and retry setup.");
3215
+ throw fail("This exact session is not available for native receiving. No invitation was claimed. Open a supported session and retry setup, or rerun with --receiver poll, which needs no session socket.");
2701
3216
  }
2702
3217
  privateMailDirectory(directory, true);
2703
3218
  const release = acquireListenLock(directory, "setup");
3219
+ let discardStub = false;
3220
+ let claimedName;
2704
3221
  try {
2705
3222
  const path = join(directory, "setup.json");
2706
3223
  const value = readMailJson(path);
@@ -2712,6 +3229,7 @@ async function setupAgent(params) {
2712
3229
  const invitation = parseAgentInvitation(params.invitation ?? "");
2713
3230
  const hash = createHash("sha256").update(invitation.apiBaseUrl).update("\0").update(invitation.token).digest("hex");
2714
3231
  if (state && state.invitationHash !== hash) throw fail(profileAlreadyConnectedMessage(profileName, loadConnectedAgentProfile(params.configDir, profileName)?.agent_address ?? null));
3232
+ const created = !state;
2715
3233
  if (!state) {
2716
3234
  state = {
2717
3235
  version: 1,
@@ -2726,13 +3244,25 @@ async function setupAgent(params) {
2726
3244
  };
2727
3245
  writeMailJson(path, state);
2728
3246
  }
2729
- await connectAgent({
2730
- configDir: params.configDir,
2731
- profileName,
2732
- invitation: params.invitation ?? "",
2733
- fetch: params.fetch,
2734
- presence: true
2735
- });
3247
+ try {
3248
+ claimedName = (await connectAgent({
3249
+ configDir: params.configDir,
3250
+ profileName,
3251
+ invitation: params.invitation ?? "",
3252
+ fetch: params.fetch,
3253
+ presence: true
3254
+ })).name;
3255
+ } catch (error) {
3256
+ if (error instanceof AgentInvitationRejectedError && created) {
3257
+ discardStub = true;
3258
+ try {
3259
+ removeMailFile(path);
3260
+ } catch {
3261
+ discardStub = false;
3262
+ }
3263
+ }
3264
+ throw error;
3265
+ }
2736
3266
  }
2737
3267
  const profile = loadConnectedAgentProfile(params.configDir, profileName);
2738
3268
  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.");
@@ -2763,8 +3293,10 @@ async function setupAgent(params) {
2763
3293
  "bounced",
2764
3294
  "canceled"
2765
3295
  ].includes(state.receipt?.status ?? "") ? "reply_failed" : ["unknown", "wait_timeout"].includes(state.receipt?.status ?? "") ? "delivery_unknown" : "reply_submitted" : state?.phase === "sending" ? "send_unknown" : "challenge_pending";
3296
+ claimedName ??= savedConnectionName(params.configDir, profileName);
2766
3297
  const result = (receiving, ownerNotifications) => ({
2767
3298
  identity: context.identity,
3299
+ ...claimedName === void 0 ? {} : { connectionName: claimedName },
2768
3300
  sessionId: params.session ?? state?.session ?? null,
2769
3301
  verification: {
2770
3302
  state: confirmed && submittedState() === "reply_submitted" ? "verified" : submittedState(),
@@ -2817,93 +3349,13 @@ async function setupAgent(params) {
2817
3349
  const check = await awaitVerification(context, dependencies, verificationTimeoutMs);
2818
3350
  if (check.state === "verified") confirmed = { verifiedAt: check.verifiedAt };
2819
3351
  }
2820
- return result(state.receiverMode === "external" ? "external_setup_required" : state.receiverMode === "poll" ? "poll" : healthy ? "healthy" : "not_ready", ownerNotifications);
3352
+ return result(state.receiverMode === "external" ? EXTERNAL_HOOKS_PENDING : state.receiverMode === "poll" ? "poll" : healthy ? "healthy" : "not_ready", ownerNotifications);
2821
3353
  } finally {
2822
3354
  release();
3355
+ if (discardStub) removeEmptyDirectory(directory);
2823
3356
  }
2824
3357
  }
2825
3358
  //#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
3359
  //#region src/oclif/agent-connect-flow.ts
2908
3360
  /** What this CLI version can do in one `agent connect --session` call. */
2909
3361
  const AGENT_CONNECT_CAPABILITIES = [
@@ -2921,6 +3373,51 @@ const MAX_NAME_LENGTH = 80;
2921
3373
  const MAX_INFO_LENGTH = 1e3;
2922
3374
  const MAIL_CHECK_WAIT_MS = 2e4;
2923
3375
  const PENDING_AGENT_INFO_FILE = "agent-info-pending.json";
3376
+ /**
3377
+ * The first Codex release whose shared app-server daemon exposes the local
3378
+ * session socket that native receiving connects to.
3379
+ */
3380
+ const CODEX_NATIVE_MIN_VERSION = "0.158.0";
3381
+ /** The `major.minor.patch` triple in a `codex --version` line, if any. */
3382
+ function parseCodexVersion(text) {
3383
+ const match = /(\d+)\.(\d+)\.(\d+)/.exec(text);
3384
+ return match ? `${match[1]}.${match[2]}.${match[3]}` : null;
3385
+ }
3386
+ /** True when this Codex version has the session socket native receiving needs. */
3387
+ function codexSupportsNativeReceiving(version) {
3388
+ const have = version.split(".").map(Number);
3389
+ const need = CODEX_NATIVE_MIN_VERSION.split(".").map(Number);
3390
+ for (let index = 0; index < need.length; index++) {
3391
+ const a = have[index] ?? 0;
3392
+ const b = need[index] ?? 0;
3393
+ if (a !== b) return a > b;
3394
+ }
3395
+ return true;
3396
+ }
3397
+ /** True only when connecting to the socket is refused (nothing serves it). */
3398
+ function socketRefusesConnections(path, timeoutMs = 2e3) {
3399
+ return new Promise((resolve) => {
3400
+ const socket = createConnection({ path });
3401
+ const done = (refused) => {
3402
+ socket.destroy();
3403
+ resolve(refused);
3404
+ };
3405
+ socket.setTimeout(timeoutMs, () => done(false));
3406
+ socket.once("connect", () => done(false));
3407
+ socket.once("error", (error) => done(error.code === "ECONNREFUSED"));
3408
+ });
3409
+ }
3410
+ async function installedCodexVersion() {
3411
+ try {
3412
+ const { stdout } = await promisify(execFile)("codex", ["--version"], {
3413
+ timeout: 3e3,
3414
+ maxBuffer: 4096
3415
+ });
3416
+ return parseCodexVersion(String(stdout));
3417
+ } catch {
3418
+ return null;
3419
+ }
3420
+ }
2924
3421
  function readPendingAgentInfo(path) {
2925
3422
  try {
2926
3423
  const saved = readMailJson(path);
@@ -2958,6 +3455,23 @@ function clearPendingAgentInfo(path) {
2958
3455
  removeMailFile(path);
2959
3456
  } catch {}
2960
3457
  }
3458
+ /**
3459
+ * Warns when the connection's own record says the owner has no personal
3460
+ * address in the organization. An unknown value (an older server, or a
3461
+ * failed read) warns nothing.
3462
+ */
3463
+ function connectWarnings(configDir, profileName) {
3464
+ let profile = null;
3465
+ try {
3466
+ profile = loadConnectedAgentProfile(configDir, profileName);
3467
+ } catch {
3468
+ return [];
3469
+ }
3470
+ return profile?.owner_member_address === null ? [{
3471
+ kind: "owner_member_address_missing",
3472
+ message: "Your owner has no personal address in this organization, so mail they send from their own address will not wake this agent and reports have nowhere to go. Ask them to open the Primitive app once (it sets one), or to run `primitive account provision-member-address --address <their address>` signed in as themselves."
3473
+ }] : [];
3474
+ }
2961
3475
  function defaultAgentProfileName(session) {
2962
3476
  return `session-${session.toLowerCase()}`;
2963
3477
  }
@@ -2995,6 +3509,9 @@ function defaults(options) {
2995
3509
  return {
2996
3510
  setupAgent,
2997
3511
  nativePreflight: nativeSessionPreflight,
3512
+ nativeSocketPresent: () => existsSync(defaultSessionSocket()),
3513
+ nativeSocketRefuses: () => socketRefusesConnections(defaultSessionSocket()),
3514
+ codexVersion: installedCodexVersion,
2998
3515
  installClaudeWakeHook,
2999
3516
  installSkill(runtime) {
3000
3517
  return installConnectSkill({
@@ -3070,7 +3587,7 @@ async function runAgentConnect(options) {
3070
3587
  const runtime = session ? detectAgentRuntime(session, env) : null;
3071
3588
  const invitation = options.resume || session || options.profileName ? void 0 : await options.readInvitation();
3072
3589
  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");
3590
+ let receiver = options.receiver ?? (options.resume ? savedSetup(options.configDir, profileName)?.receiverMode ?? null : null) ?? (!session ? "poll" : runtime === "claude" ? "external" : "native");
3074
3591
  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
3592
  const agentInfo = agentInfoValue(options.name, options.info);
3076
3593
  const skipped = [];
@@ -3105,15 +3622,31 @@ async function runAgentConnect(options) {
3105
3622
  reason: skill.reason ?? skill.state
3106
3623
  });
3107
3624
  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();
3625
+ const invocation = options.invocation ?? "primitive";
3626
+ let fallback = null;
3627
+ if (receiver === "native" && session && !options.resume) try {
3628
+ await dependencies.nativePreflight(session);
3629
+ } catch (error) {
3630
+ const socketPresent = dependencies.nativeSocketPresent();
3631
+ const socketDead = socketPresent && error instanceof NativeSessionDisconnectedError && await dependencies.nativeSocketRefuses();
3632
+ const codexVersion = socketPresent && !socketDead || runtime === "claude" ? null : await dependencies.codexVersion();
3633
+ const reason = socketDead ? "session_socket_unavailable" : socketPresent ? null : codexVersion !== null && !codexSupportsNativeReceiving(codexVersion) ? "codex_version_unsupported" : "session_socket_missing";
3634
+ const why = reason === "codex_version_unsupported" ? `Native receiving needs Codex ${CODEX_NATIVE_MIN_VERSION} or newer; this machine has Codex ${codexVersion}.` : reason === "session_socket_unavailable" ? "The local Codex session socket exists but nothing accepted the connection, so its app-server is not running." : reason === "session_socket_missing" ? `Native receiving needs the local Codex session socket (Codex ${CODEX_NATIVE_MIN_VERSION} or newer with its shared app-server running), and none was found${codexVersion ? ` for Codex ${codexVersion}` : ""}.` : `The local session socket exists but did not accept session ${session}: it may not be loaded in this terminal, or the ID may not be this session's.`;
3635
+ if (reason && options.receiver === void 0) {
3636
+ receiver = "poll";
3637
+ fallback = {
3638
+ reason,
3639
+ codexVersion,
3640
+ detail: `${why} Connected with poll receiving instead: nothing wakes this session, so check for mail with receiving.checkCommand at the start of each turn and after sending.`
3641
+ };
3642
+ } else throw new AgentConnectionSetupError(`This exact session is not available for native receiving. ${why} No invitation was claimed and nothing was changed. To connect with poll receiving instead, pipe the same invitation to: ${invocation} agent connect --session ${session} --receiver poll --json`);
3116
3643
  }
3644
+ if (fallback) skipped.push({
3645
+ step: "native_receiver",
3646
+ reason: fallback.reason
3647
+ });
3648
+ const setupInvitation = options.resume ? void 0 : invitation ?? await options.readInvitation();
3649
+ if (options.beforeSetup) await options.beforeSetup();
3117
3650
  const result = await dependencies.setupAgent({
3118
3651
  configDir: options.configDir,
3119
3652
  profileName,
@@ -3129,6 +3662,7 @@ async function runAgentConnect(options) {
3129
3662
  ...result.identity,
3130
3663
  ownerMemberAddress
3131
3664
  };
3665
+ const warnings = connectWarnings(options.configDir, result.identity.profileName);
3132
3666
  let externalHook = null;
3133
3667
  if (receiver === "external") if (verified) externalHook = dependencies.installClaudeWakeHook({
3134
3668
  cliPath: options.cliPath,
@@ -3181,15 +3715,20 @@ async function runAgentConnect(options) {
3181
3715
  reason: "already_present"
3182
3716
  });
3183
3717
  }
3184
- const invocation = options.invocation ?? "primitive";
3185
3718
  const receiving = receiver === "external" ? {
3186
3719
  ...result.receiving,
3720
+ state: externalReceivingState(externalHook),
3187
3721
  mode: "external",
3188
3722
  hook: externalHook
3189
3723
  } : receiver === "poll" ? {
3190
3724
  ...result.receiving,
3191
3725
  mode: "poll",
3192
- checkCommand: pollCheckCommand(invocation, result.identity.profileName)
3726
+ checkCommand: pollCheckCommand(invocation, result.identity.profileName),
3727
+ ...fallback ? {
3728
+ fallbackReason: fallback.reason,
3729
+ fallbackDetail: fallback.detail,
3730
+ codexVersion: fallback.codexVersion
3731
+ } : {}
3193
3732
  } : {
3194
3733
  ...result.receiving,
3195
3734
  mode: "native",
@@ -3216,6 +3755,10 @@ async function runAgentConnect(options) {
3216
3755
  ownerAddress: result.identity.ownerAddress,
3217
3756
  ownerMemberAddress,
3218
3757
  profile: result.identity.profileName,
3758
+ ...result.connectionName === void 0 ? {} : {
3759
+ name: result.connectionName,
3760
+ nameIsDefault: defaultConnectionName(result.connectionName, result.identity.agentAddress)
3761
+ },
3219
3762
  sessionId: session ?? null,
3220
3763
  runtime,
3221
3764
  cli: {
@@ -3228,6 +3771,7 @@ async function runAgentConnect(options) {
3228
3771
  ...result.ownerNotifications ? { ownerNotifications: result.ownerNotifications } : {},
3229
3772
  agentInfo: agentInfoState,
3230
3773
  skipped,
3774
+ warnings,
3231
3775
  selectProfile: `PRIMITIVE_AGENT_PROFILE=${result.identity.profileName}`,
3232
3776
  resumeCommand,
3233
3777
  guidance: `${result.guidance} ${ownerReportGuidance(identity)}`,
@@ -3236,45 +3780,6 @@ async function runAgentConnect(options) {
3236
3780
  };
3237
3781
  }
3238
3782
  //#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
3783
  //#region src/oclif/agent-disconnect.ts
3279
3784
  var AgentDisconnectError = class extends Error {};
3280
3785
  function credentialDigest(profile) {
@@ -4751,4 +5256,4 @@ async function runDetached(params) {
4751
5256
  }
4752
5257
  }
4753
5258
  //#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 };
5259
+ export { describeSessionHookItem as $, evaluateContactPolicy as A, agentInvitationHash as B, RECEIVER_MODES as C, NETWORK_ADMISSION_PENDING_RETRY_MS as D, ContactPolicyReadRetryError as E, ContactsApiError as F, saveConnectionName as G, readAgentInvitation as H, contactAddress as I, recordHookMailCheck as J, savedReceiver as K, contactAgentAddress as L, AddressNotesApiError as M, addressNoteTarget as N, NetworkAdmissionPendingError as O, runAddressNotesRequest as P, describeSessionHookChange as Q, runContactRequest as R, runAgentConnect as S, verificationReplySubmitted as T, receiverStatusCommand as U, connectAgent as V, refusalAfterReplacement as W, claudeConfigDir as X, boundSessionProfiles as Y, currentCliLocation as Z, disconnectAgent as _, withOwnerMemberAddress as _t, endSession as a, readClaudeSettings as at, invitationProfileName as b, pendingSessionDisconnects as c, writeClaudeSettings as ct, registerSession as d, jsonIndentation as dt, ensureHookLauncher as et, retryPendingDisconnects as f, readManagedFile as ft, AgentDisconnectError as g, refreshOwnerMemberAddressPeriodically as gt, enrollmentResumes as h, readHookLauncherWarning as ht, connectedProfilesForSession as i, inspectClaudeHooks as it, canonicalContactSelector as j, createNotificationContactPolicy as k, psProcessInfo as l, MachineFileError as lt, enrollAgent as m, writeManagedFile as mt, OMP_MAIN as n, findOnPath as nt, headlessClaudeRun as o, repairClaudeHooks as ot, runDetached as p, upsertManagedBlock as pt, installClaudeWakeHook as q, agentConnectionState as r, hasMachineSessionHooks as rt, machine_session_exports as s, samePath as st, MACHINE_RUNTIMES as t, ephemeralCliReason as tt, readClaudeHookInput as u, inspectManagedBlock as ut, connectWarnings as v, externalReceivingState as w, pollCheckCommand as x, defaultAgentProfileName as y, agentConnectionStatus as z };