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