primcli 1.35.0 → 1.37.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 +73 -10
- package/bin/run.js +37 -0
- package/dist/auto-signals-UPcJmV3j.js +702 -0
- package/dist/{cli-config-DmhlcS3s.js → cli-config-UOlrfcPz.js} +2 -711
- package/dist/connect-skill-DednypYJ.js +1084 -0
- package/dist/connected-agent-profile-8tpOMdq3.js +730 -0
- package/dist/{listen-background-D6UhIbkO.js → listen-background-Bsofqq_6.js} +243 -50
- package/dist/oclif/auto-signal-worker.js +167 -0
- package/dist/oclif/auto-signals.js +2 -0
- package/dist/oclif/index.js +31501 -48161
- package/dist/oclif/listen-supervisor.js +1 -1
- package/dist/oclif/proxy-auto-detect.js +1 -1
- package/dist/oclif/receiver-heal.js +91 -0
- package/dist/oclif/root-signup-hint.js +1 -1
- package/dist/repeats-CQmyvUGQ-CMnOmWgL.js +10442 -0
- package/dist/sdk.gen-CugwPoCf.js +3281 -0
- package/dist/{shared-mail-files-Cbwnf79a.js → shared-mail-files-CZqI1kj6.js} +5 -2
- package/dist/signal-command-CKaEqXro.js +4514 -0
- package/dist/skills/primitive-connect/SKILL.md +896 -0
- package/dist/skills/primitive-connect/package-lock.json +1033 -0
- package/dist/skills/primitive-connect/package.json +8 -0
- package/dist/skills/primitive-connect/references/communication.md +207 -0
- package/dist/skills/primitive-connect/references/contact-requests.md +161 -0
- package/dist/skills/primitive-connect/references/native-session.md +165 -0
- package/dist/skills/primitive-connect/references/private-api-fallback.md +107 -0
- package/dist/skills/primitive-connect/scripts/check-cli-capabilities.mjs +48 -0
- package/dist/skills/primitive-connect/scripts/connection.mjs +197 -0
- package/dist/skills/primitive-connect/scripts/mail.mjs +189 -0
- package/dist/skills/primitive-connect.json +21 -0
- package/man/primitive.1 +35 -4
- package/package.json +10 -1
- /package/dist/{json-output-KAIuP9rG.js → json-output-BVRgl6jf.js} +0 -0
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
import { _ as isTrustedSender, c as classifySignalContent } from "./repeats-CQmyvUGQ-CMnOmWgL.js";
|
|
2
|
+
import { d as readMailJson, h as writeMailJson, i as mailId, l as privateMailDirectory, r as mailAddress } from "./shared-mail-files-CZqI1kj6.js";
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
//#region src/oclif/presence-provenance.ts
|
|
9
|
+
function readPresenceProjection(detail) {
|
|
10
|
+
if (!detail || typeof detail !== "object") return null;
|
|
11
|
+
const value = detail.presence_control;
|
|
12
|
+
if (value === void 0 || value === null) return null;
|
|
13
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {
|
|
14
|
+
status: "pending",
|
|
15
|
+
valid_for_ms: 0
|
|
16
|
+
};
|
|
17
|
+
const row = value;
|
|
18
|
+
if (![
|
|
19
|
+
"verified",
|
|
20
|
+
"pending",
|
|
21
|
+
"rejected"
|
|
22
|
+
].includes(String(row.status)) || typeof row.valid_for_ms !== "number" || !Number.isSafeInteger(row.valid_for_ms) || row.valid_for_ms < 0 || row.valid_for_ms > 6e5) return {
|
|
23
|
+
status: "pending",
|
|
24
|
+
valid_for_ms: 0
|
|
25
|
+
};
|
|
26
|
+
return row;
|
|
27
|
+
}
|
|
28
|
+
/** Only the authenticated detail projection identifies control mail. */
|
|
29
|
+
function presenceDisposition(detail) {
|
|
30
|
+
const proof = readPresenceProjection(detail);
|
|
31
|
+
return !proof || proof.status === "rejected" ? "ordinary" : proof.status === "verified" ? "quiet" : "pending";
|
|
32
|
+
}
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/oclif/scoped-chat.ts
|
|
35
|
+
function isConnectedChatCredential(apiKey) {
|
|
36
|
+
return apiKey?.startsWith("pconn_") ?? false;
|
|
37
|
+
}
|
|
38
|
+
async function readBeforeDeadline(deadline, read) {
|
|
39
|
+
if (deadline == null) return read(void 0);
|
|
40
|
+
if (Date.now() >= deadline) return null;
|
|
41
|
+
const controller = new AbortController();
|
|
42
|
+
let timer;
|
|
43
|
+
const arm = () => {
|
|
44
|
+
const remaining = Math.max(0, deadline - Date.now());
|
|
45
|
+
const maximumDelay = 2 ** 31 - 1;
|
|
46
|
+
timer = setTimeout(remaining > maximumDelay ? arm : () => controller.abort(), Math.min(remaining, maximumDelay));
|
|
47
|
+
};
|
|
48
|
+
arm();
|
|
49
|
+
try {
|
|
50
|
+
const result = await read(controller.signal);
|
|
51
|
+
return controller.signal.aborted || Date.now() >= deadline ? null : result;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (controller.signal.aborted) return null;
|
|
54
|
+
throw error;
|
|
55
|
+
} finally {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function address(value) {
|
|
60
|
+
return value.trim().toLowerCase();
|
|
61
|
+
}
|
|
62
|
+
function isScopedChatReply(detail, params) {
|
|
63
|
+
if (presenceDisposition(detail) !== "ordinary") return false;
|
|
64
|
+
if (!["accepted", "completed"].includes(detail.status) || params.sentId !== void 0 && detail.reply_to_sent_email_id !== params.sentId || address(detail.recipient) !== address(params.from) || address(detail.to_email) !== address(params.from)) return false;
|
|
65
|
+
return scopedChatSenderTrust(detail, params.recipient).trusted;
|
|
66
|
+
}
|
|
67
|
+
function scopedChatSenderTrust(detail, peer) {
|
|
68
|
+
const sender = address(peer);
|
|
69
|
+
return isTrustedSender({ email: {
|
|
70
|
+
auth: detail.auth,
|
|
71
|
+
headers: { from: detail.from_header }
|
|
72
|
+
} }, {
|
|
73
|
+
sender,
|
|
74
|
+
domain: sender.slice(sender.lastIndexOf("@") + 1)
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function isPlainChatReply(detail) {
|
|
78
|
+
if (presenceDisposition(detail) !== "ordinary") return false;
|
|
79
|
+
if (detail.parsed?.status !== "complete" || !Array.isArray(detail.parsed.attachments)) return false;
|
|
80
|
+
return classifySignalContent({
|
|
81
|
+
inventory: {
|
|
82
|
+
status: "complete",
|
|
83
|
+
parts: detail.parsed.attachments.map((part) => ({
|
|
84
|
+
filename: part.filename ?? null,
|
|
85
|
+
contentType: part.content_type ?? null
|
|
86
|
+
}))
|
|
87
|
+
},
|
|
88
|
+
bodies: {
|
|
89
|
+
status: "complete",
|
|
90
|
+
text: detail.body_text ?? null,
|
|
91
|
+
html: detail.body_html ?? null
|
|
92
|
+
},
|
|
93
|
+
canonicalPartBytes: null
|
|
94
|
+
}).classification === "plain";
|
|
95
|
+
}
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region src/oclif/auto-signals.ts
|
|
98
|
+
/**
|
|
99
|
+
* Automatic communication signals. When mail from the verified owner or a
|
|
100
|
+
* verified same-organization peer is surfaced to an agent session, the CLI
|
|
101
|
+
* reports `read` once; when the session opens it with `emails get --brief`, the
|
|
102
|
+
* CLI reports `working` and renews it until the agent answers. Every step is
|
|
103
|
+
* best effort: nothing here may block, delay or fail the wake or the read.
|
|
104
|
+
*/
|
|
105
|
+
const AUTO_SIGNALS_OPT_OUT_ENV = "PRIMITIVE_NO_AUTO_SIGNALS";
|
|
106
|
+
const AUTO_SIGNAL_WORKER_ENV = "PRIMITIVE_AUTO_SIGNAL_WORKER";
|
|
107
|
+
const AUTO_SIGNAL_EMAIL_ENV = "PRIMITIVE_AUTO_SIGNAL_EMAIL";
|
|
108
|
+
const AUTO_SIGNAL_KIND_ENV = "PRIMITIVE_AUTO_SIGNAL_KIND";
|
|
109
|
+
/**
|
|
110
|
+
* Receivers accept a working signal only when it expires at most 60 seconds
|
|
111
|
+
* after the server accepted it. A little under 60 tolerates clock skew.
|
|
112
|
+
*/
|
|
113
|
+
const AUTO_WORKING_EXPIRES_SECONDS = 55;
|
|
114
|
+
/** Renew well before expiry so the indicator does not flicker off. */
|
|
115
|
+
const AUTO_WORKING_RENEW_MS = 4e4;
|
|
116
|
+
/** Stop renewing after this long even if no answer was seen. */
|
|
117
|
+
const AUTO_WORKING_CAP_MS = 15 * 6e4;
|
|
118
|
+
/**
|
|
119
|
+
* A renewal in flight is never older than this: the signal send and its
|
|
120
|
+
* reconciliation each abort after 5 seconds. Past it the marker is stale.
|
|
121
|
+
*/
|
|
122
|
+
const SENDING_STALE_MS = 15e3;
|
|
123
|
+
function autoSignalsDisabled(env = process.env) {
|
|
124
|
+
const value = env[AUTO_SIGNALS_OPT_OUT_ENV]?.trim().toLowerCase();
|
|
125
|
+
return value !== void 0 && value !== "" && value !== "0" && value !== "false" && value !== "no";
|
|
126
|
+
}
|
|
127
|
+
const profilePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/;
|
|
128
|
+
const lower = (value) => typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
129
|
+
/**
|
|
130
|
+
* Whether one admitted email may carry automatic signals. The sender must be
|
|
131
|
+
* authenticated (aligned DMARC) and either be this agent's pinned owner or
|
|
132
|
+
* have been admitted by the organization network policy (an active member or
|
|
133
|
+
* a same-organization agent). The email must be a fully parsed plain email
|
|
134
|
+
* addressed only to this agent, which did not send it. Signals, fyi
|
|
135
|
+
* acknowledgements, interactions, presence checks, contact requests and
|
|
136
|
+
* unverified or external senders never qualify.
|
|
137
|
+
*/
|
|
138
|
+
function autoSignalEligible(detail, identity, admission) {
|
|
139
|
+
try {
|
|
140
|
+
if (!admission || admission.kind === "request") return false;
|
|
141
|
+
const agent = lower(identity.agentAddress);
|
|
142
|
+
const owner = lower(identity.ownerAddress);
|
|
143
|
+
const sender = lower(detail.from_email);
|
|
144
|
+
if (!agent || !sender || sender === agent) return false;
|
|
145
|
+
if (admission.source !== "network" && !(owner && sender === owner)) return false;
|
|
146
|
+
if (!scopedChatSenderTrust(detail, sender).trusted) return false;
|
|
147
|
+
if (lower(detail.recipient) !== agent || lower(detail.to_email) !== agent) return false;
|
|
148
|
+
if (!["accepted", "completed"].includes(detail.status)) return false;
|
|
149
|
+
if (!detail.message_id) return false;
|
|
150
|
+
const parsed = detail.parsed;
|
|
151
|
+
const to = parsed?.to_addresses;
|
|
152
|
+
if (!Array.isArray(to) || to.length !== 1 || lower(to[0]?.address) !== agent || (parsed.cc?.length ?? 0) > 0 || (parsed.bcc?.length ?? 0) > 0) return false;
|
|
153
|
+
return isPlainChatReply(detail);
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function sentSignalPath(configDir, sentId) {
|
|
159
|
+
return join(configDir, "signals", "sent-ids", mailId(sentId));
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Remember a signal this profile sent. An automated responder that answers
|
|
163
|
+
* a signal with plain mail must not trigger another automatic signal, or the
|
|
164
|
+
* two would acknowledge each other forever. Never throws.
|
|
165
|
+
*/
|
|
166
|
+
function recordSentSignal(configDir, sentId) {
|
|
167
|
+
try {
|
|
168
|
+
const path = sentSignalPath(configDir, sentId);
|
|
169
|
+
privateMailDirectory(join(configDir, "signals", "sent-ids"), true);
|
|
170
|
+
writeFileSync(path, "", {
|
|
171
|
+
flag: "w",
|
|
172
|
+
mode: 384
|
|
173
|
+
});
|
|
174
|
+
} catch {}
|
|
175
|
+
}
|
|
176
|
+
function isSentSignal(configDir, sentId) {
|
|
177
|
+
try {
|
|
178
|
+
return Boolean(sentId) && existsSync(sentSignalPath(configDir, sentId ?? ""));
|
|
179
|
+
} catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function root(configDir) {
|
|
184
|
+
return join(configDir, "auto-signals");
|
|
185
|
+
}
|
|
186
|
+
function emailDirectory(configDir, emailId) {
|
|
187
|
+
return join(root(configDir), mailId(emailId));
|
|
188
|
+
}
|
|
189
|
+
function parseClaim(value) {
|
|
190
|
+
const row = value;
|
|
191
|
+
if (!row || row.version !== 1 || typeof row.profile !== "string" || !profilePattern.test(row.profile) || row.thread_id !== null && typeof row.thread_id !== "string") return null;
|
|
192
|
+
try {
|
|
193
|
+
return {
|
|
194
|
+
version: 1,
|
|
195
|
+
email_id: mailId(row.email_id),
|
|
196
|
+
profile: row.profile,
|
|
197
|
+
sender: mailAddress(row.sender),
|
|
198
|
+
thread_id: row.thread_id === null ? null : mailId(row.thread_id),
|
|
199
|
+
claimed_at: String(row.claimed_at)
|
|
200
|
+
};
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Record, once per email, that a session surfaced it and it qualified for
|
|
207
|
+
* automatic signals. Returns true only for the first caller, so repeated wakes,
|
|
208
|
+
* redeliveries and concurrent receivers dispatch at most one `read`.
|
|
209
|
+
*/
|
|
210
|
+
function claimAutoRead(configDir, claim, now = Date.now) {
|
|
211
|
+
if (!profilePattern.test(claim.profileName)) return false;
|
|
212
|
+
const directory = emailDirectory(configDir, claim.emailId);
|
|
213
|
+
privateMailDirectory(directory, true);
|
|
214
|
+
const value = {
|
|
215
|
+
version: 1,
|
|
216
|
+
email_id: mailId(claim.emailId),
|
|
217
|
+
profile: claim.profileName,
|
|
218
|
+
sender: mailAddress(claim.sender),
|
|
219
|
+
thread_id: claim.threadId ? mailId(claim.threadId) : null,
|
|
220
|
+
claimed_at: new Date(now()).toISOString()
|
|
221
|
+
};
|
|
222
|
+
try {
|
|
223
|
+
writeFileSync(join(directory, "read.json"), `${JSON.stringify(value)}\n`, {
|
|
224
|
+
flag: "wx",
|
|
225
|
+
mode: 384
|
|
226
|
+
});
|
|
227
|
+
return true;
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (error.code === "EEXIST") return false;
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function readAutoClaim(configDir, emailId) {
|
|
234
|
+
try {
|
|
235
|
+
const claim = parseClaim(readMailJson(join(emailDirectory(configDir, emailId), "read.json")));
|
|
236
|
+
return claim && claim.email_id === mailId(emailId) ? claim : null;
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function leasePath(configDir, emailId) {
|
|
242
|
+
return join(emailDirectory(configDir, emailId), "working.json");
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* The stop lives in its own exclusively created file, so a renewer that
|
|
246
|
+
* rewrites working.json with new signal IDs can never undo it.
|
|
247
|
+
*/
|
|
248
|
+
function stopPath(configDir, emailId) {
|
|
249
|
+
return join(emailDirectory(configDir, emailId), "stopped.json");
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Index of active leases, so the startup backstop and peer matching read only
|
|
253
|
+
* live leases instead of every email that was ever acknowledged.
|
|
254
|
+
*/
|
|
255
|
+
function activeIndex(configDir) {
|
|
256
|
+
return join(root(configDir), "active");
|
|
257
|
+
}
|
|
258
|
+
function markActive(configDir, emailId) {
|
|
259
|
+
privateMailDirectory(activeIndex(configDir), true);
|
|
260
|
+
writeFileSync(join(activeIndex(configDir), mailId(emailId)), "", { mode: 384 });
|
|
261
|
+
}
|
|
262
|
+
function unmarkActive(configDir, emailId) {
|
|
263
|
+
try {
|
|
264
|
+
unlinkSync(join(activeIndex(configDir), mailId(emailId)));
|
|
265
|
+
} catch {}
|
|
266
|
+
}
|
|
267
|
+
function readStop(configDir, emailId) {
|
|
268
|
+
try {
|
|
269
|
+
const row = readMailJson(stopPath(configDir, emailId), 4096);
|
|
270
|
+
if (!row || typeof row.at !== "number") return null;
|
|
271
|
+
return {
|
|
272
|
+
at: row.at,
|
|
273
|
+
reason: typeof row.reason === "string" ? row.reason : "stopped",
|
|
274
|
+
token: typeof row.token === "string" ? row.token : ""
|
|
275
|
+
};
|
|
276
|
+
} catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function readWorkingLease(configDir, emailId) {
|
|
281
|
+
try {
|
|
282
|
+
const row = readMailJson(leasePath(configDir, emailId), 64 * 1024);
|
|
283
|
+
if (!row || row.version !== 1 || typeof row.profile !== "string" || !profilePattern.test(row.profile) || typeof row.started_at !== "number" || !Number.isFinite(row.started_at) || row.stopped_at !== null && typeof row.stopped_at !== "number" || !Array.isArray(row.signal_sent_ids)) return null;
|
|
284
|
+
const stop = readStop(configDir, emailId);
|
|
285
|
+
return {
|
|
286
|
+
version: 1,
|
|
287
|
+
email_id: mailId(row.email_id),
|
|
288
|
+
profile: row.profile,
|
|
289
|
+
sender: mailAddress(row.sender),
|
|
290
|
+
thread_id: row.thread_id ? mailId(row.thread_id) : null,
|
|
291
|
+
started_at: row.started_at,
|
|
292
|
+
stopped_at: stop?.at ?? row.stopped_at,
|
|
293
|
+
stop_reason: stop?.reason ?? (typeof row.stop_reason === "string" ? row.stop_reason : null),
|
|
294
|
+
signal_sent_ids: row.signal_sent_ids.slice(-200).map(mailId)
|
|
295
|
+
};
|
|
296
|
+
} catch {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function writeWorkingLease(configDir, lease) {
|
|
301
|
+
writeMailJson(leasePath(configDir, lease.email_id), {
|
|
302
|
+
...lease,
|
|
303
|
+
stopped_at: null,
|
|
304
|
+
stop_reason: null
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Start the working lease for an email that already qualified for automatic
|
|
309
|
+
* signals. Only the first caller succeeds, so working is started once per
|
|
310
|
+
* email however many times the session reads it.
|
|
311
|
+
*/
|
|
312
|
+
function startWorkingLease(configDir, emailId, now = Date.now) {
|
|
313
|
+
const claim = readAutoClaim(configDir, emailId);
|
|
314
|
+
if (!claim) return null;
|
|
315
|
+
const lease = {
|
|
316
|
+
version: 1,
|
|
317
|
+
email_id: claim.email_id,
|
|
318
|
+
profile: claim.profile,
|
|
319
|
+
sender: claim.sender,
|
|
320
|
+
thread_id: claim.thread_id,
|
|
321
|
+
started_at: now(),
|
|
322
|
+
stopped_at: null,
|
|
323
|
+
stop_reason: null,
|
|
324
|
+
signal_sent_ids: []
|
|
325
|
+
};
|
|
326
|
+
try {
|
|
327
|
+
writeFileSync(leasePath(configDir, emailId), `${JSON.stringify(lease)}\n`, {
|
|
328
|
+
flag: "wx",
|
|
329
|
+
mode: 384
|
|
330
|
+
});
|
|
331
|
+
} catch {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
markActive(configDir, emailId);
|
|
336
|
+
} catch {}
|
|
337
|
+
return lease;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Answers that rely on a stop someone else created. While any exist, the
|
|
341
|
+
* creator of that stop may not undo it, even if its own answer was refused.
|
|
342
|
+
*/
|
|
343
|
+
function holdsDirectory(configDir, emailId) {
|
|
344
|
+
return join(emailDirectory(configDir, emailId), "stop-holds");
|
|
345
|
+
}
|
|
346
|
+
function stopHeld(configDir, emailId) {
|
|
347
|
+
try {
|
|
348
|
+
return readdirSync(holdsDirectory(configDir, emailId)).length > 0;
|
|
349
|
+
} catch {
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/** Stop a lease, or hold an existing stop. Null when there is no lease. */
|
|
354
|
+
function stopWorkingLease(configDir, emailId, reason, now = Date.now) {
|
|
355
|
+
if (!readWorkingLease(configDir, emailId)) return null;
|
|
356
|
+
const token = randomUUID();
|
|
357
|
+
const hold = join(holdsDirectory(configDir, emailId), token);
|
|
358
|
+
privateMailDirectory(holdsDirectory(configDir, emailId), true);
|
|
359
|
+
writeFileSync(hold, "", { mode: 384 });
|
|
360
|
+
try {
|
|
361
|
+
writeFileSync(stopPath(configDir, emailId), `${JSON.stringify({
|
|
362
|
+
at: now(),
|
|
363
|
+
reason,
|
|
364
|
+
token
|
|
365
|
+
})}\n`, {
|
|
366
|
+
flag: "wx",
|
|
367
|
+
mode: 384
|
|
368
|
+
});
|
|
369
|
+
} catch (error) {
|
|
370
|
+
if (error.code === "EEXIST") return {
|
|
371
|
+
token,
|
|
372
|
+
owner: false
|
|
373
|
+
};
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
unlinkSync(hold);
|
|
378
|
+
} catch {}
|
|
379
|
+
return {
|
|
380
|
+
token,
|
|
381
|
+
owner: true
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
function pendingPath(configDir, emailId) {
|
|
385
|
+
return join(emailDirectory(configDir, emailId), "restore-pending.json");
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Undo a stop this process made, for an answer known not to have been sent.
|
|
389
|
+
* A stop made by anyone else (cap, answered, another reply) is left alone.
|
|
390
|
+
* While a later answer holds the stop it stays in place, and the restore is
|
|
391
|
+
* left pending: the last holder to release finishes it if its answer was
|
|
392
|
+
* refused too (see releaseStopHold).
|
|
393
|
+
*/
|
|
394
|
+
function restoreWorkingLease(configDir, emailId, token) {
|
|
395
|
+
try {
|
|
396
|
+
const stop = readStop(configDir, emailId);
|
|
397
|
+
if (!stop || stop.token !== token) return false;
|
|
398
|
+
writeFileSync(pendingPath(configDir, emailId), JSON.stringify({ token }), { mode: 384 });
|
|
399
|
+
if (stopHeld(configDir, emailId)) return false;
|
|
400
|
+
markActive(configDir, emailId);
|
|
401
|
+
unlinkSync(stopPath(configDir, emailId));
|
|
402
|
+
if (stopHeld(configDir, emailId)) {
|
|
403
|
+
try {
|
|
404
|
+
writeFileSync(stopPath(configDir, emailId), `${JSON.stringify(stop)}\n`, {
|
|
405
|
+
flag: "wx",
|
|
406
|
+
mode: 384
|
|
407
|
+
});
|
|
408
|
+
} catch {}
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
unlinkSync(pendingPath(configDir, emailId));
|
|
413
|
+
} catch {}
|
|
414
|
+
return true;
|
|
415
|
+
} catch {
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Release a hold for an answer known not to have been sent. If the stop's
|
|
421
|
+
* owner was refused too and is waiting on holds, the last release restores
|
|
422
|
+
* the lease. Returns true when it restored it.
|
|
423
|
+
*/
|
|
424
|
+
function releaseStopHold(configDir, emailId, token) {
|
|
425
|
+
try {
|
|
426
|
+
try {
|
|
427
|
+
unlinkSync(join(holdsDirectory(configDir, emailId), token));
|
|
428
|
+
} catch {
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
if (stopHeld(configDir, emailId)) return false;
|
|
432
|
+
let pending = null;
|
|
433
|
+
try {
|
|
434
|
+
const row = JSON.parse(readFileSync(pendingPath(configDir, emailId), "utf8"));
|
|
435
|
+
pending = typeof row.token === "string" ? row.token : null;
|
|
436
|
+
} catch {
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
return pending !== null && restoreWorkingLease(configDir, emailId, pending);
|
|
440
|
+
} catch {
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
function sendingPath(configDir, emailId) {
|
|
445
|
+
return join(emailDirectory(configDir, emailId), "sending");
|
|
446
|
+
}
|
|
447
|
+
/** Mark a renewal in flight so a reply can wait for it to settle. */
|
|
448
|
+
function markWorkingSending(configDir, emailId) {
|
|
449
|
+
writeFileSync(sendingPath(configDir, emailId), JSON.stringify({
|
|
450
|
+
pid: process.pid,
|
|
451
|
+
at: Date.now()
|
|
452
|
+
}), { mode: 384 });
|
|
453
|
+
}
|
|
454
|
+
function clearWorkingSending(configDir, emailId) {
|
|
455
|
+
try {
|
|
456
|
+
unlinkSync(sendingPath(configDir, emailId));
|
|
457
|
+
} catch {}
|
|
458
|
+
}
|
|
459
|
+
function sendingInFlight(configDir, emailId) {
|
|
460
|
+
try {
|
|
461
|
+
const value = JSON.parse(readFileSync(sendingPath(configDir, emailId), "utf8"));
|
|
462
|
+
if (typeof value.at !== "number" || Date.now() - value.at > SENDING_STALE_MS) return false;
|
|
463
|
+
if (typeof value.pid === "number") process.kill(value.pid, 0);
|
|
464
|
+
return true;
|
|
465
|
+
} catch {
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* A stopped lease stays indexed this long, so a later answer to the same
|
|
471
|
+
* sender can still find it and hold its stop against a refused earlier one.
|
|
472
|
+
*/
|
|
473
|
+
const STOPPED_INDEX_MS = 10 * 6e4;
|
|
474
|
+
function indexedLeases(configDir, now) {
|
|
475
|
+
let entries;
|
|
476
|
+
try {
|
|
477
|
+
entries = readdirSync(activeIndex(configDir));
|
|
478
|
+
} catch {
|
|
479
|
+
return [];
|
|
480
|
+
}
|
|
481
|
+
return entries.flatMap((entry) => {
|
|
482
|
+
if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(entry)) return [];
|
|
483
|
+
const lease = readWorkingLease(configDir, entry);
|
|
484
|
+
if (!lease || lease.stopped_at !== null && now() - lease.stopped_at >= STOPPED_INDEX_MS || now() - lease.started_at >= 9e5 * 2) {
|
|
485
|
+
unmarkActive(configDir, entry);
|
|
486
|
+
return [];
|
|
487
|
+
}
|
|
488
|
+
return [lease];
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Active, unstopped leases in this config directory, read from the lease
|
|
493
|
+
* index rather than every email ever acknowledged. Old entries are pruned.
|
|
494
|
+
*/
|
|
495
|
+
function activeWorkingLeases(configDir, now = Date.now) {
|
|
496
|
+
return indexedLeases(configDir, now).filter((lease) => lease.stopped_at === null);
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Stop automatic working for the emails an outgoing message answers, before
|
|
500
|
+
* that message is sent, then wait for any renewal already in flight to land
|
|
501
|
+
* first. A renewal that reached receivers after the answer would show the
|
|
502
|
+
* agent as working again. The wait ends when the renewal settles, which is
|
|
503
|
+
* normally immediate and is bounded by the send timeouts. Never throws.
|
|
504
|
+
*/
|
|
505
|
+
async function haltAutoWorking(configDir, match, reason, options = {}) {
|
|
506
|
+
const halted = {
|
|
507
|
+
configDir,
|
|
508
|
+
stops: []
|
|
509
|
+
};
|
|
510
|
+
try {
|
|
511
|
+
const ids = /* @__PURE__ */ new Set();
|
|
512
|
+
for (const id of match.emailIds ?? []) try {
|
|
513
|
+
ids.add(mailId(id));
|
|
514
|
+
} catch {}
|
|
515
|
+
const peers = new Set((match.peers ?? []).map(lower).filter(Boolean));
|
|
516
|
+
const candidates = /* @__PURE__ */ new Map();
|
|
517
|
+
for (const lease of indexedLeases(configDir, Date.now)) candidates.set(lease.email_id, lease);
|
|
518
|
+
for (const id of ids) {
|
|
519
|
+
const lease = readWorkingLease(configDir, id);
|
|
520
|
+
if (lease) candidates.set(id, lease);
|
|
521
|
+
}
|
|
522
|
+
const stopped = [];
|
|
523
|
+
for (const lease of candidates.values()) {
|
|
524
|
+
const byPeer = peers.has(lease.sender) && match.profileName !== void 0 && match.profileName === lease.profile;
|
|
525
|
+
if (!ids.has(lease.email_id) && !byPeer) continue;
|
|
526
|
+
try {
|
|
527
|
+
const stop = stopWorkingLease(configDir, lease.email_id, reason);
|
|
528
|
+
if (stop) halted.stops.push({
|
|
529
|
+
emailId: lease.email_id,
|
|
530
|
+
...stop
|
|
531
|
+
});
|
|
532
|
+
stopped.push(lease.email_id);
|
|
533
|
+
} catch {}
|
|
534
|
+
}
|
|
535
|
+
const deadline = Date.now() + (options.settleMs ?? SENDING_STALE_MS);
|
|
536
|
+
while (stopped.some((id) => sendingInFlight(configDir, id)) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 50));
|
|
537
|
+
} catch {}
|
|
538
|
+
return halted;
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* The answer was definitively refused, so nothing reached the sender: put
|
|
542
|
+
* back the working indication this answer stopped. Never throws.
|
|
543
|
+
*/
|
|
544
|
+
function restoreAutoWorking(halted, options = {}) {
|
|
545
|
+
try {
|
|
546
|
+
let restored = 0;
|
|
547
|
+
for (const stop of halted.stops) {
|
|
548
|
+
if (!(stop.owner ? restoreWorkingLease(halted.configDir, stop.emailId, stop.token) : releaseStopHold(halted.configDir, stop.emailId, stop.token))) continue;
|
|
549
|
+
restored++;
|
|
550
|
+
const lease = readWorkingLease(halted.configDir, stop.emailId);
|
|
551
|
+
if (lease) spawnAutoSignalWorker({
|
|
552
|
+
configDir: halted.configDir,
|
|
553
|
+
emailId: lease.email_id,
|
|
554
|
+
kind: "working",
|
|
555
|
+
profileName: lease.profile,
|
|
556
|
+
env: options.env ?? process.env,
|
|
557
|
+
spawnImpl: options.spawnImpl
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
return restored;
|
|
561
|
+
} catch {
|
|
562
|
+
return 0;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
/** The CLI entrypoint that runs a detached signal worker. */
|
|
566
|
+
function autoSignalEntrypoint() {
|
|
567
|
+
const candidates = ["../../bin/run.js", "../bin/run.js"].map((path) => fileURLToPath(new URL(path, import.meta.url)));
|
|
568
|
+
return candidates.find((path) => existsSync(path)) ?? candidates[0] ?? "";
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Run one automatic signal in a detached process with no inherited stdio, so
|
|
572
|
+
* the calling command's output, exit status and timing are unchanged.
|
|
573
|
+
*/
|
|
574
|
+
function spawnAutoSignalWorker(options) {
|
|
575
|
+
try {
|
|
576
|
+
const env = { ...options.env ?? process.env };
|
|
577
|
+
if (autoSignalsDisabled(env)) return false;
|
|
578
|
+
if (!profilePattern.test(options.profileName)) return false;
|
|
579
|
+
const entry = options.entry ?? autoSignalEntrypoint();
|
|
580
|
+
if (!options.spawnImpl && !existsSync(entry)) return false;
|
|
581
|
+
delete env.PRIMITIVE_API_KEY;
|
|
582
|
+
delete env.PRIMITIVE_KEY;
|
|
583
|
+
const child = (options.spawnImpl ?? spawn)(process.execPath, [entry], {
|
|
584
|
+
detached: true,
|
|
585
|
+
stdio: "ignore",
|
|
586
|
+
windowsHide: true,
|
|
587
|
+
env: {
|
|
588
|
+
...env,
|
|
589
|
+
PRIMITIVE_CONFIG_DIR: options.configDir,
|
|
590
|
+
PRIMITIVE_AGENT_PROFILE: options.profileName,
|
|
591
|
+
PRIMITIVE_SKIP_NEW_VERSION_CHECK: "true",
|
|
592
|
+
[AUTO_SIGNAL_WORKER_ENV]: "1",
|
|
593
|
+
[AUTO_SIGNAL_EMAIL_ENV]: mailId(options.emailId),
|
|
594
|
+
[AUTO_SIGNAL_KIND_ENV]: options.kind
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
child.on("error", () => void 0);
|
|
598
|
+
child.unref();
|
|
599
|
+
return true;
|
|
600
|
+
} catch {
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Called when a receiver surfaces mail to a session: claim the email once and
|
|
606
|
+
* send `read` in the background. Never throws and never writes output.
|
|
607
|
+
*/
|
|
608
|
+
function dispatchAutoRead(options) {
|
|
609
|
+
try {
|
|
610
|
+
const env = options.env ?? process.env;
|
|
611
|
+
if (autoSignalsDisabled(env) || !options.profileName) return false;
|
|
612
|
+
if (isSentSignal(options.configDir, options.replyToSentEmailId)) return false;
|
|
613
|
+
if (!claimAutoRead(options.configDir, {
|
|
614
|
+
emailId: options.emailId,
|
|
615
|
+
profileName: options.profileName,
|
|
616
|
+
sender: options.sender,
|
|
617
|
+
threadId: options.threadId
|
|
618
|
+
})) return false;
|
|
619
|
+
return spawnAutoSignalWorker({
|
|
620
|
+
configDir: options.configDir,
|
|
621
|
+
emailId: options.emailId,
|
|
622
|
+
kind: "read",
|
|
623
|
+
profileName: options.profileName,
|
|
624
|
+
env,
|
|
625
|
+
spawnImpl: options.spawnImpl
|
|
626
|
+
});
|
|
627
|
+
} catch {
|
|
628
|
+
return false;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Called after the session reads an email with `emails get --brief`: start
|
|
633
|
+
* working once for mail that already qualified for automatic signals.
|
|
634
|
+
*/
|
|
635
|
+
function dispatchAutoWorking(options) {
|
|
636
|
+
try {
|
|
637
|
+
const env = options.env ?? process.env;
|
|
638
|
+
if (autoSignalsDisabled(env)) return false;
|
|
639
|
+
const lease = startWorkingLease(options.configDir, options.emailId);
|
|
640
|
+
if (!lease) return false;
|
|
641
|
+
return spawnAutoSignalWorker({
|
|
642
|
+
configDir: options.configDir,
|
|
643
|
+
emailId: lease.email_id,
|
|
644
|
+
kind: "working",
|
|
645
|
+
profileName: lease.profile,
|
|
646
|
+
env,
|
|
647
|
+
spawnImpl: options.spawnImpl
|
|
648
|
+
});
|
|
649
|
+
} catch {
|
|
650
|
+
return false;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
function renewerPath(configDir, emailId) {
|
|
654
|
+
return join(emailDirectory(configDir, emailId), "renewer.json");
|
|
655
|
+
}
|
|
656
|
+
function recordRenewer(configDir, emailId) {
|
|
657
|
+
writeFileSync(renewerPath(configDir, emailId), JSON.stringify({
|
|
658
|
+
pid: process.pid,
|
|
659
|
+
at: Date.now()
|
|
660
|
+
}), { mode: 384 });
|
|
661
|
+
}
|
|
662
|
+
function renewerAlive(configDir, emailId) {
|
|
663
|
+
try {
|
|
664
|
+
const value = JSON.parse(readFileSync(renewerPath(configDir, emailId), "utf8"));
|
|
665
|
+
if (typeof value.pid !== "number" || typeof value.at !== "number") return false;
|
|
666
|
+
if (Date.now() - value.at > 4e4 * 2) return false;
|
|
667
|
+
process.kill(value.pid, 0);
|
|
668
|
+
return true;
|
|
669
|
+
} catch {
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Backstop for runtimes that end background processes between tool calls:
|
|
675
|
+
* any CLI invocation restarts a renewer for an active lease whose renewer is
|
|
676
|
+
* gone. Cheap when there are no leases. Never throws.
|
|
677
|
+
*/
|
|
678
|
+
function resumeAutoWorking(configDir, options = {}) {
|
|
679
|
+
try {
|
|
680
|
+
const env = options.env ?? process.env;
|
|
681
|
+
if (autoSignalsDisabled(env) || env["PRIMITIVE_AUTO_SIGNAL_WORKER"] === "1") return 0;
|
|
682
|
+
const now = (options.now ?? Date.now)();
|
|
683
|
+
let started = 0;
|
|
684
|
+
for (const lease of activeWorkingLeases(configDir, () => now)) {
|
|
685
|
+
if (now - lease.started_at >= 9e5) continue;
|
|
686
|
+
if (renewerAlive(configDir, lease.email_id)) continue;
|
|
687
|
+
if (spawnAutoSignalWorker({
|
|
688
|
+
configDir,
|
|
689
|
+
emailId: lease.email_id,
|
|
690
|
+
kind: "working",
|
|
691
|
+
profileName: lease.profile,
|
|
692
|
+
env,
|
|
693
|
+
spawnImpl: options.spawnImpl
|
|
694
|
+
})) started++;
|
|
695
|
+
}
|
|
696
|
+
return started;
|
|
697
|
+
} catch {
|
|
698
|
+
return 0;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
//#endregion
|
|
702
|
+
export { stopWorkingLease as A, releaseStopHold as C, resumeAutoWorking as D, restoreWorkingLease as E, readBeforeDeadline as F, scopedChatSenderTrust as I, presenceDisposition as L, isConnectedChatCredential as M, isPlainChatReply as N, spawnAutoSignalWorker as O, isScopedChatReply as P, readPresenceProjection as R, recordSentSignal as S, restoreAutoWorking as T, isSentSignal as _, AUTO_WORKING_CAP_MS as a, readWorkingLease as b, activeWorkingLeases as c, autoSignalsDisabled as d, claimAutoRead as f, haltAutoWorking as g, dispatchAutoWorking as h, AUTO_SIGNAL_WORKER_ENV as i, writeWorkingLease as j, startWorkingLease as k, autoSignalEligible as l, dispatchAutoRead as m, AUTO_SIGNAL_EMAIL_ENV as n, AUTO_WORKING_EXPIRES_SECONDS as o, clearWorkingSending as p, AUTO_SIGNAL_KIND_ENV as r, AUTO_WORKING_RENEW_MS as s, AUTO_SIGNALS_OPT_OUT_ENV as t, autoSignalEntrypoint as u, markWorkingSending as v, renewerAlive as w, recordRenewer as x, readAutoClaim as y };
|