primcli 1.47.0 → 1.49.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.
- package/README.md +2 -2
- package/dist/{api-command-C1JvJqV0.js → api-command-BixHVNwQ.js} +21 -25
- package/dist/{connect-skill-CH2pq436.js → connect-skill-DBkhFAkO.js} +3 -3
- package/dist/{machine-session-Dr5W2fHb.js → machine-session-BEkDi4_N.js} +590 -130
- package/dist/oclif/auto-signal-worker.js +2 -2
- package/dist/oclif/auto-signals.js +1 -1
- package/dist/oclif/index.js +245 -109
- package/dist/oclif/listen-supervisor.js +1 -1
- package/dist/oclif/receiver-heal.js +2 -2
- package/dist/oclif/root-signup-hint.js +0 -1
- package/dist/{scoped-chat-DhxtuNXs.js → scoped-chat-DSLrmgWQ.js} +1 -1
- package/dist/{signal-command-Dbyqyas9.js → signal-command-D0Rig6eA.js} +5 -5
- package/dist/skills/primitive-connect/SKILL.md +16 -266
- package/dist/skills/primitive-connect/references/manual-setup.md +294 -0
- package/dist/skills/primitive-connect.json +4 -3
- package/man/primitive.1 +1 -1
- package/package.json +1 -1
- /package/dist/{listen-background-CyLc8VBn.js → listen-background-CMfxkeVP.js} +0 -0
- /package/dist/{repeats-D2xtLium-DdiJECLI.js → repeats-D2xtLium-B8Wq41vG.js} +0 -0
- /package/dist/{shared-mail-watch-BUfUaq6j.js → shared-mail-watch-CWhpfJ0N.js} +0 -0
|
@@ -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-
|
|
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-BixHVNwQ.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
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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-DBkhFAkO.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
|
-
/**
|
|
300
|
-
|
|
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:
|
|
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
|
-
/**
|
|
692
|
-
|
|
693
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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,62 @@ 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
|
|
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_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.";
|
|
1640
|
+
/**
|
|
1641
|
+
* The server answered the claim with a definite refusal, so nothing was
|
|
1642
|
+
* claimed and no credential exists. Unlike a lost or malformed response, it
|
|
1643
|
+
* is safe to discard every local trace of the attempt.
|
|
1644
|
+
*/
|
|
1645
|
+
var AgentInvitationRejectedError = class extends AgentConnectionSetupError {
|
|
1646
|
+
constructor(message, reason) {
|
|
1647
|
+
super(message);
|
|
1648
|
+
this.reason = reason;
|
|
1649
|
+
}
|
|
1650
|
+
};
|
|
1651
|
+
const NOTHING_CHANGED = "Nothing was changed on this machine.";
|
|
1652
|
+
/**
|
|
1653
|
+
* Restates a refusal that followed a replacement: the agent being replaced
|
|
1654
|
+
* was disconnected before the claim, so "nothing was changed" is not true.
|
|
1655
|
+
*/
|
|
1656
|
+
function refusalAfterReplacement(error, replaced) {
|
|
1657
|
+
if (!(error instanceof AgentInvitationRejectedError) || !replaced.length) return error;
|
|
1658
|
+
const names = replaced.map((row) => row.address).join(", ");
|
|
1659
|
+
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);
|
|
1660
|
+
}
|
|
1661
|
+
/** The error code of a refused claim, read without keeping the body. */
|
|
1662
|
+
async function claimErrorCode(response) {
|
|
1663
|
+
try {
|
|
1664
|
+
const body = await readClaimResponse(response);
|
|
1665
|
+
return body?.success === false && typeof body.error?.code === "string" ? body.error.code : null;
|
|
1666
|
+
} catch {
|
|
1667
|
+
return null;
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
/** A definite refusal of the claim, or null when the outcome is uncertain. */
|
|
1671
|
+
async function definiteClaimRefusal(response) {
|
|
1672
|
+
if (![
|
|
1673
|
+
400,
|
|
1674
|
+
409,
|
|
1675
|
+
429
|
|
1676
|
+
].includes(response.status)) return null;
|
|
1677
|
+
const code = await claimErrorCode(response);
|
|
1678
|
+
if (response.status === 409 && code === "connection_invitation_unavailable") return new AgentInvitationRejectedError(INVITATION_UNAVAILABLE, "invitation_unavailable");
|
|
1679
|
+
if (response.status === 400 && code === "validation_error") return new AgentInvitationRejectedError(INVITATION_INVALID, "invitation_invalid");
|
|
1680
|
+
if (response.status === 429 && code === "rate_limit_exceeded") {
|
|
1681
|
+
const seconds = Number(response.headers.get("retry-after"));
|
|
1682
|
+
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");
|
|
1683
|
+
}
|
|
1684
|
+
return null;
|
|
1685
|
+
}
|
|
1686
|
+
/** Removes a directory only when it is empty. Best effort. */
|
|
1687
|
+
function removeEmptyDirectory(path) {
|
|
1688
|
+
try {
|
|
1689
|
+
rmdirSync(path);
|
|
1690
|
+
} catch {}
|
|
1691
|
+
}
|
|
1278
1692
|
/** The receiver a saved profile's setup record names. Offline. */
|
|
1279
1693
|
function savedReceiver(configDir, profileName) {
|
|
1280
1694
|
try {
|
|
@@ -1434,7 +1848,7 @@ async function readClaimResponse(response) {
|
|
|
1434
1848
|
const part = await reader.read();
|
|
1435
1849
|
if (part.done) break;
|
|
1436
1850
|
size += part.value.byteLength;
|
|
1437
|
-
if (size > MAX_RESPONSE_BYTES
|
|
1851
|
+
if (size > MAX_RESPONSE_BYTES) throw new Error();
|
|
1438
1852
|
chunks.push(part.value);
|
|
1439
1853
|
}
|
|
1440
1854
|
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
@@ -1490,7 +1904,8 @@ async function connectAgent(params) {
|
|
|
1490
1904
|
};
|
|
1491
1905
|
}
|
|
1492
1906
|
privateMailDirectory(join(directory, "profiles"), true);
|
|
1493
|
-
|
|
1907
|
+
const profileDirectory = agentProfileDirectory(params.configDir, profileName);
|
|
1908
|
+
privateMailDirectory(profileDirectory, true);
|
|
1494
1909
|
const journal = join(directory, "claims", `${invitationHash}.json`);
|
|
1495
1910
|
privateMailDirectory(join(directory, "claims"), true);
|
|
1496
1911
|
if (readMailJson(journal) !== null) throw new AgentConnectionSetupError(UNCERTAIN_CLAIM);
|
|
@@ -1514,8 +1929,16 @@ async function connectAgent(params) {
|
|
|
1514
1929
|
})
|
|
1515
1930
|
});
|
|
1516
1931
|
if (response.status !== 200) {
|
|
1517
|
-
await response
|
|
1518
|
-
|
|
1932
|
+
const refusal = await definiteClaimRefusal(response);
|
|
1933
|
+
if (!refusal) {
|
|
1934
|
+
await response.body?.cancel().catch(() => void 0);
|
|
1935
|
+
throw new Error();
|
|
1936
|
+
}
|
|
1937
|
+
try {
|
|
1938
|
+
removeMailFile(journal);
|
|
1939
|
+
} catch {}
|
|
1940
|
+
removeEmptyDirectory(profileDirectory);
|
|
1941
|
+
throw refusal;
|
|
1519
1942
|
}
|
|
1520
1943
|
const profile = profileFromClaim(await readClaimResponse(response), invitation, invitationHash, params.now ?? Date.now);
|
|
1521
1944
|
saveConnectedAgentProfile(params.configDir, profileName, profile);
|
|
@@ -1523,7 +1946,8 @@ async function connectAgent(params) {
|
|
|
1523
1946
|
status: "claimed",
|
|
1524
1947
|
identity: connectedAgentIdentity(profileName, profile)
|
|
1525
1948
|
};
|
|
1526
|
-
} catch {
|
|
1949
|
+
} catch (error) {
|
|
1950
|
+
if (error instanceof AgentInvitationRejectedError) throw error;
|
|
1527
1951
|
throw new AgentConnectionSetupError(UNCERTAIN_CLAIM);
|
|
1528
1952
|
}
|
|
1529
1953
|
} finally {
|
|
@@ -2350,6 +2774,20 @@ const VERIFICATION_BACKOFF_MS = [
|
|
|
2350
2774
|
3e3,
|
|
2351
2775
|
5e3
|
|
2352
2776
|
];
|
|
2777
|
+
/**
|
|
2778
|
+
* Setup's receiving state for an external receiver: setup itself installs no
|
|
2779
|
+
* hooks, so the caller installs them and restates the outcome with
|
|
2780
|
+
* {@link externalReceivingState}.
|
|
2781
|
+
*/
|
|
2782
|
+
const EXTERNAL_HOOKS_PENDING = "hooks_pending";
|
|
2783
|
+
/**
|
|
2784
|
+
* The printed receiving state for an external receiver, from the hook
|
|
2785
|
+
* install outcome. `hooks_installed` is a finished setup: idle wake is only
|
|
2786
|
+
* proven by real mail, which `externalHook: "installed_unverified"` says.
|
|
2787
|
+
*/
|
|
2788
|
+
function externalReceivingState(hook) {
|
|
2789
|
+
return hook === "installed_unverified" ? "hooks_installed" : hook === "unavailable" ? "hook_unavailable" : hook === "held_for_other_profile" ? "held_for_other_profile" : "pending_verification";
|
|
2790
|
+
}
|
|
2353
2791
|
/** Both states mean the setup reply was accepted for sending. */
|
|
2354
2792
|
function verificationReplySubmitted(state) {
|
|
2355
2793
|
return state === "reply_submitted" || state === "verified";
|
|
@@ -2697,10 +3135,11 @@ async function setupAgent(params) {
|
|
|
2697
3135
|
if ((receiverMode ?? "native") === "native" && params.session) try {
|
|
2698
3136
|
await dependencies.preflight(params.session);
|
|
2699
3137
|
} catch {
|
|
2700
|
-
throw fail("This exact session is not available for native receiving. No invitation was claimed. Open a supported session and retry setup.");
|
|
3138
|
+
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
3139
|
}
|
|
2702
3140
|
privateMailDirectory(directory, true);
|
|
2703
3141
|
const release = acquireListenLock(directory, "setup");
|
|
3142
|
+
let discardStub = false;
|
|
2704
3143
|
try {
|
|
2705
3144
|
const path = join(directory, "setup.json");
|
|
2706
3145
|
const value = readMailJson(path);
|
|
@@ -2712,6 +3151,7 @@ async function setupAgent(params) {
|
|
|
2712
3151
|
const invitation = parseAgentInvitation(params.invitation ?? "");
|
|
2713
3152
|
const hash = createHash("sha256").update(invitation.apiBaseUrl).update("\0").update(invitation.token).digest("hex");
|
|
2714
3153
|
if (state && state.invitationHash !== hash) throw fail(profileAlreadyConnectedMessage(profileName, loadConnectedAgentProfile(params.configDir, profileName)?.agent_address ?? null));
|
|
3154
|
+
const created = !state;
|
|
2715
3155
|
if (!state) {
|
|
2716
3156
|
state = {
|
|
2717
3157
|
version: 1,
|
|
@@ -2726,13 +3166,25 @@ async function setupAgent(params) {
|
|
|
2726
3166
|
};
|
|
2727
3167
|
writeMailJson(path, state);
|
|
2728
3168
|
}
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
3169
|
+
try {
|
|
3170
|
+
await connectAgent({
|
|
3171
|
+
configDir: params.configDir,
|
|
3172
|
+
profileName,
|
|
3173
|
+
invitation: params.invitation ?? "",
|
|
3174
|
+
fetch: params.fetch,
|
|
3175
|
+
presence: true
|
|
3176
|
+
});
|
|
3177
|
+
} catch (error) {
|
|
3178
|
+
if (error instanceof AgentInvitationRejectedError && created) {
|
|
3179
|
+
discardStub = true;
|
|
3180
|
+
try {
|
|
3181
|
+
removeMailFile(path);
|
|
3182
|
+
} catch {
|
|
3183
|
+
discardStub = false;
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
throw error;
|
|
3187
|
+
}
|
|
2736
3188
|
}
|
|
2737
3189
|
const profile = loadConnectedAgentProfile(params.configDir, profileName);
|
|
2738
3190
|
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.");
|
|
@@ -2817,93 +3269,13 @@ async function setupAgent(params) {
|
|
|
2817
3269
|
const check = await awaitVerification(context, dependencies, verificationTimeoutMs);
|
|
2818
3270
|
if (check.state === "verified") confirmed = { verifiedAt: check.verifiedAt };
|
|
2819
3271
|
}
|
|
2820
|
-
return result(state.receiverMode === "external" ?
|
|
3272
|
+
return result(state.receiverMode === "external" ? EXTERNAL_HOOKS_PENDING : state.receiverMode === "poll" ? "poll" : healthy ? "healthy" : "not_ready", ownerNotifications);
|
|
2821
3273
|
} finally {
|
|
2822
3274
|
release();
|
|
3275
|
+
if (discardStub) removeEmptyDirectory(directory);
|
|
2823
3276
|
}
|
|
2824
3277
|
}
|
|
2825
3278
|
//#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
3279
|
//#region src/oclif/agent-connect-flow.ts
|
|
2908
3280
|
/** What this CLI version can do in one `agent connect --session` call. */
|
|
2909
3281
|
const AGENT_CONNECT_CAPABILITIES = [
|
|
@@ -2921,6 +3293,51 @@ const MAX_NAME_LENGTH = 80;
|
|
|
2921
3293
|
const MAX_INFO_LENGTH = 1e3;
|
|
2922
3294
|
const MAIL_CHECK_WAIT_MS = 2e4;
|
|
2923
3295
|
const PENDING_AGENT_INFO_FILE = "agent-info-pending.json";
|
|
3296
|
+
/**
|
|
3297
|
+
* The first Codex release whose shared app-server daemon exposes the local
|
|
3298
|
+
* session socket that native receiving connects to.
|
|
3299
|
+
*/
|
|
3300
|
+
const CODEX_NATIVE_MIN_VERSION = "0.158.0";
|
|
3301
|
+
/** The `major.minor.patch` triple in a `codex --version` line, if any. */
|
|
3302
|
+
function parseCodexVersion(text) {
|
|
3303
|
+
const match = /(\d+)\.(\d+)\.(\d+)/.exec(text);
|
|
3304
|
+
return match ? `${match[1]}.${match[2]}.${match[3]}` : null;
|
|
3305
|
+
}
|
|
3306
|
+
/** True when this Codex version has the session socket native receiving needs. */
|
|
3307
|
+
function codexSupportsNativeReceiving(version) {
|
|
3308
|
+
const have = version.split(".").map(Number);
|
|
3309
|
+
const need = CODEX_NATIVE_MIN_VERSION.split(".").map(Number);
|
|
3310
|
+
for (let index = 0; index < need.length; index++) {
|
|
3311
|
+
const a = have[index] ?? 0;
|
|
3312
|
+
const b = need[index] ?? 0;
|
|
3313
|
+
if (a !== b) return a > b;
|
|
3314
|
+
}
|
|
3315
|
+
return true;
|
|
3316
|
+
}
|
|
3317
|
+
/** True only when connecting to the socket is refused (nothing serves it). */
|
|
3318
|
+
function socketRefusesConnections(path, timeoutMs = 2e3) {
|
|
3319
|
+
return new Promise((resolve) => {
|
|
3320
|
+
const socket = createConnection({ path });
|
|
3321
|
+
const done = (refused) => {
|
|
3322
|
+
socket.destroy();
|
|
3323
|
+
resolve(refused);
|
|
3324
|
+
};
|
|
3325
|
+
socket.setTimeout(timeoutMs, () => done(false));
|
|
3326
|
+
socket.once("connect", () => done(false));
|
|
3327
|
+
socket.once("error", (error) => done(error.code === "ECONNREFUSED"));
|
|
3328
|
+
});
|
|
3329
|
+
}
|
|
3330
|
+
async function installedCodexVersion() {
|
|
3331
|
+
try {
|
|
3332
|
+
const { stdout } = await promisify(execFile)("codex", ["--version"], {
|
|
3333
|
+
timeout: 3e3,
|
|
3334
|
+
maxBuffer: 4096
|
|
3335
|
+
});
|
|
3336
|
+
return parseCodexVersion(String(stdout));
|
|
3337
|
+
} catch {
|
|
3338
|
+
return null;
|
|
3339
|
+
}
|
|
3340
|
+
}
|
|
2924
3341
|
function readPendingAgentInfo(path) {
|
|
2925
3342
|
try {
|
|
2926
3343
|
const saved = readMailJson(path);
|
|
@@ -2958,6 +3375,23 @@ function clearPendingAgentInfo(path) {
|
|
|
2958
3375
|
removeMailFile(path);
|
|
2959
3376
|
} catch {}
|
|
2960
3377
|
}
|
|
3378
|
+
/**
|
|
3379
|
+
* Warns when the connection's own record says the owner has no personal
|
|
3380
|
+
* address in the organization. An unknown value (an older server, or a
|
|
3381
|
+
* failed read) warns nothing.
|
|
3382
|
+
*/
|
|
3383
|
+
function connectWarnings(configDir, profileName) {
|
|
3384
|
+
let profile = null;
|
|
3385
|
+
try {
|
|
3386
|
+
profile = loadConnectedAgentProfile(configDir, profileName);
|
|
3387
|
+
} catch {
|
|
3388
|
+
return [];
|
|
3389
|
+
}
|
|
3390
|
+
return profile?.owner_member_address === null ? [{
|
|
3391
|
+
kind: "owner_member_address_missing",
|
|
3392
|
+
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."
|
|
3393
|
+
}] : [];
|
|
3394
|
+
}
|
|
2961
3395
|
function defaultAgentProfileName(session) {
|
|
2962
3396
|
return `session-${session.toLowerCase()}`;
|
|
2963
3397
|
}
|
|
@@ -2995,6 +3429,9 @@ function defaults(options) {
|
|
|
2995
3429
|
return {
|
|
2996
3430
|
setupAgent,
|
|
2997
3431
|
nativePreflight: nativeSessionPreflight,
|
|
3432
|
+
nativeSocketPresent: () => existsSync(defaultSessionSocket()),
|
|
3433
|
+
nativeSocketRefuses: () => socketRefusesConnections(defaultSessionSocket()),
|
|
3434
|
+
codexVersion: installedCodexVersion,
|
|
2998
3435
|
installClaudeWakeHook,
|
|
2999
3436
|
installSkill(runtime) {
|
|
3000
3437
|
return installConnectSkill({
|
|
@@ -3070,7 +3507,7 @@ async function runAgentConnect(options) {
|
|
|
3070
3507
|
const runtime = session ? detectAgentRuntime(session, env) : null;
|
|
3071
3508
|
const invitation = options.resume || session || options.profileName ? void 0 : await options.readInvitation();
|
|
3072
3509
|
const profileName = agentProfileName(options.profileName ?? (session ? defaultAgentProfileName(session) : invitationProfileName(invitation ?? "")));
|
|
3073
|
-
|
|
3510
|
+
let receiver = options.receiver ?? (options.resume ? savedSetup(options.configDir, profileName)?.receiverMode ?? null : null) ?? (!session ? "poll" : runtime === "claude" ? "external" : "native");
|
|
3074
3511
|
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
3512
|
const agentInfo = agentInfoValue(options.name, options.info);
|
|
3076
3513
|
const skipped = [];
|
|
@@ -3105,15 +3542,31 @@ async function runAgentConnect(options) {
|
|
|
3105
3542
|
reason: skill.reason ?? skill.state
|
|
3106
3543
|
});
|
|
3107
3544
|
const startedAt = Date.now();
|
|
3108
|
-
const
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
await
|
|
3545
|
+
const invocation = options.invocation ?? "primitive";
|
|
3546
|
+
let fallback = null;
|
|
3547
|
+
if (receiver === "native" && session && !options.resume) try {
|
|
3548
|
+
await dependencies.nativePreflight(session);
|
|
3549
|
+
} catch (error) {
|
|
3550
|
+
const socketPresent = dependencies.nativeSocketPresent();
|
|
3551
|
+
const socketDead = socketPresent && error instanceof NativeSessionDisconnectedError && await dependencies.nativeSocketRefuses();
|
|
3552
|
+
const codexVersion = socketPresent && !socketDead || runtime === "claude" ? null : await dependencies.codexVersion();
|
|
3553
|
+
const reason = socketDead ? "session_socket_unavailable" : socketPresent ? null : codexVersion !== null && !codexSupportsNativeReceiving(codexVersion) ? "codex_version_unsupported" : "session_socket_missing";
|
|
3554
|
+
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.`;
|
|
3555
|
+
if (reason && options.receiver === void 0) {
|
|
3556
|
+
receiver = "poll";
|
|
3557
|
+
fallback = {
|
|
3558
|
+
reason,
|
|
3559
|
+
codexVersion,
|
|
3560
|
+
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.`
|
|
3561
|
+
};
|
|
3562
|
+
} 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
3563
|
}
|
|
3564
|
+
if (fallback) skipped.push({
|
|
3565
|
+
step: "native_receiver",
|
|
3566
|
+
reason: fallback.reason
|
|
3567
|
+
});
|
|
3568
|
+
const setupInvitation = options.resume ? void 0 : invitation ?? await options.readInvitation();
|
|
3569
|
+
if (options.beforeSetup) await options.beforeSetup();
|
|
3117
3570
|
const result = await dependencies.setupAgent({
|
|
3118
3571
|
configDir: options.configDir,
|
|
3119
3572
|
profileName,
|
|
@@ -3129,6 +3582,7 @@ async function runAgentConnect(options) {
|
|
|
3129
3582
|
...result.identity,
|
|
3130
3583
|
ownerMemberAddress
|
|
3131
3584
|
};
|
|
3585
|
+
const warnings = connectWarnings(options.configDir, result.identity.profileName);
|
|
3132
3586
|
let externalHook = null;
|
|
3133
3587
|
if (receiver === "external") if (verified) externalHook = dependencies.installClaudeWakeHook({
|
|
3134
3588
|
cliPath: options.cliPath,
|
|
@@ -3181,15 +3635,20 @@ async function runAgentConnect(options) {
|
|
|
3181
3635
|
reason: "already_present"
|
|
3182
3636
|
});
|
|
3183
3637
|
}
|
|
3184
|
-
const invocation = options.invocation ?? "primitive";
|
|
3185
3638
|
const receiving = receiver === "external" ? {
|
|
3186
3639
|
...result.receiving,
|
|
3640
|
+
state: externalReceivingState(externalHook),
|
|
3187
3641
|
mode: "external",
|
|
3188
3642
|
hook: externalHook
|
|
3189
3643
|
} : receiver === "poll" ? {
|
|
3190
3644
|
...result.receiving,
|
|
3191
3645
|
mode: "poll",
|
|
3192
|
-
checkCommand: pollCheckCommand(invocation, result.identity.profileName)
|
|
3646
|
+
checkCommand: pollCheckCommand(invocation, result.identity.profileName),
|
|
3647
|
+
...fallback ? {
|
|
3648
|
+
fallbackReason: fallback.reason,
|
|
3649
|
+
fallbackDetail: fallback.detail,
|
|
3650
|
+
codexVersion: fallback.codexVersion
|
|
3651
|
+
} : {}
|
|
3193
3652
|
} : {
|
|
3194
3653
|
...result.receiving,
|
|
3195
3654
|
mode: "native",
|
|
@@ -3228,6 +3687,7 @@ async function runAgentConnect(options) {
|
|
|
3228
3687
|
...result.ownerNotifications ? { ownerNotifications: result.ownerNotifications } : {},
|
|
3229
3688
|
agentInfo: agentInfoState,
|
|
3230
3689
|
skipped,
|
|
3690
|
+
warnings,
|
|
3231
3691
|
selectProfile: `PRIMITIVE_AGENT_PROFILE=${result.identity.profileName}`,
|
|
3232
3692
|
resumeCommand,
|
|
3233
3693
|
guidance: `${result.guidance} ${ownerReportGuidance(identity)}`,
|
|
@@ -4751,4 +5211,4 @@ async function runDetached(params) {
|
|
|
4751
5211
|
}
|
|
4752
5212
|
}
|
|
4753
5213
|
//#endregion
|
|
4754
|
-
export {
|
|
5214
|
+
export { currentCliLocation as $, NETWORK_ADMISSION_PENDING_RETRY_MS as A, contactAgentAddress as B, invitationProfileName as C, externalReceivingState as D, RECEIVER_MODES as E, AddressNotesApiError as F, readAgentInvitation as G, agentConnectionStatus as H, addressNoteTarget as I, savedReceiver as J, receiverStatusCommand as K, runAddressNotesRequest as L, createNotificationContactPolicy as M, evaluateContactPolicy as N, verificationReplySubmitted as O, canonicalContactSelector as P, claudeConfigDir as Q, ContactsApiError as R, defaultAgentProfileName as S, runAgentConnect as T, agentInvitationHash as U, runContactRequest as V, connectAgent as W, recordHookMailCheck as X, installClaudeWakeHook as Y, boundSessionProfiles as Z, disconnectAgent as _, readHookLauncherWarning as _t, endSession as a, hasMachineSessionHooks as at, identitySuggestions as b, pendingSessionDisconnects as c, repairClaudeHooks as ct, registerSession as d, MachineFileError as dt, describeSessionHookChange as et, retryPendingDisconnects as f, inspectManagedBlock as ft, AgentDisconnectError as g, writeManagedFile as gt, enrollmentResumes as h, upsertManagedBlock as ht, connectedProfilesForSession as i, findOnPath as it, NetworkAdmissionPendingError as j, ContactPolicyReadRetryError as k, psProcessInfo as l, samePath as lt, enrollAgent as m, readManagedFile as mt, OMP_MAIN as n, ensureHookLauncher as nt, headlessClaudeRun as o, inspectClaudeHooks as ot, runDetached as p, jsonIndentation as pt, refusalAfterReplacement as q, agentConnectionState as r, ephemeralCliReason as rt, machine_session_exports as s, readClaudeSettings as st, MACHINE_RUNTIMES as t, describeSessionHookItem as tt, readClaudeHookInput as u, writeClaudeSettings as ut, cliInvocation as v, refreshOwnerMemberAddressPeriodically as vt, pollCheckCommand as w, connectWarnings as x, generatedAgentName as y, withOwnerMemberAddress as yt, contactAddress as z };
|