primitive 1.72.2 → 1.73.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 +41 -10
- package/bin/claude-pending-mail.mjs +292 -136
- package/bin/claude-wake.mjs +129 -60
- package/dist/api-command-fKP3NYa7.js +2904 -0
- package/dist/{cli-config--YTRdJ3t.js → cli-config-C0Y_Ppol.js} +3 -598
- package/dist/client.gen-CaaMP86o.js +597 -0
- package/dist/{listen-background--0peztKE.js → listen-background-cD6n8VYO.js} +1 -1
- package/dist/{machine-session-Be6j29GE.js → machine-session-B6V5-grn.js} +907 -207
- package/dist/{notify-session-Byt5sPWW.js → notify-session-DJAb9SGm.js} +3 -3
- package/dist/oclif/auto-signal-worker.js +4 -3
- package/dist/oclif/auto-signals.js +2 -2
- package/dist/oclif/index.js +260 -198
- package/dist/oclif/listen-supervisor.js +1 -1
- package/dist/oclif/receiver-heal.js +4 -4
- package/dist/oclif/root-signup-hint.js +1 -1
- package/dist/{repeats-BL7ZC-_6-CKMKBlx7.js → repeats-BL7ZC-_6-B_ESsWTH.js} +4005 -1
- package/dist/{scoped-chat-DTFkP360.js → scoped-chat-DAiOb622.js} +10 -2
- package/dist/{shared-mail-files-MwlVE90c.js → shared-mail-files-CZfm7E2k.js} +21 -1
- package/dist/{shared-mail-watch-DtKpsgg8.js → shared-mail-watch-DyQB0MK0.js} +1 -1
- package/dist/{signal-command-DeEEZPbQ.js → signal-command-Cte4mAL3.js} +8 -7
- package/package.json +1 -1
- package/dist/api-command-BrwPbyPP.js +0 -6546
package/README.md
CHANGED
|
@@ -1090,8 +1090,8 @@ with status `ok`, `warn`, `fail` or `skip`; `action` (`login`, `install_cli`,
|
|
|
1090
1090
|
and the command exits 0 only when every check is ok or skipped. Checks cover
|
|
1091
1091
|
the CLI install, version (`--min-cli-version` sets a floor) and location, the
|
|
1092
1092
|
saved member login, Claude `settings.json`, Primitive's Claude SessionStart,
|
|
1093
|
-
SessionEnd and
|
|
1094
|
-
|
|
1093
|
+
SessionEnd and receive hooks (each present once, pointing at this CLI, with
|
|
1094
|
+
stale copies from older installs removed), the Codex SessionStart hook
|
|
1095
1095
|
in `$CODEX_HOME/hooks.json` (repaired in place without moving any other hook,
|
|
1096
1096
|
since Codex keys approvals by hook position, and reported as `warn` until the
|
|
1097
1097
|
approval Codex recorded matches the hook's current hash), a managed block in
|
|
@@ -1100,11 +1100,25 @@ content) and `~/.omp/agent/AGENTS.md`, the bundled primitive-connect skill, and
|
|
|
1100
1100
|
saved agent profiles that were disconnected in Primitive (moved aside locally;
|
|
1101
1101
|
nothing changes server side).
|
|
1102
1102
|
|
|
1103
|
-
|
|
1104
|
-
|
|
1103
|
+
Claude receives mail through one shared set of receive hooks per config
|
|
1104
|
+
directory (`Stop`, `SessionStart` on resume, and `PostToolUse`). Each run reads
|
|
1105
|
+
the session id from the hook input and looks up the profiles that session
|
|
1106
|
+
receives as in `<config>/agent-connections/claude-sessions/<session>.json`,
|
|
1107
|
+
which `agent connect`, `agent enroll` and `agent session-register` write and
|
|
1108
|
+
`agent disconnect` and `agent session-end` remove from. A session with no
|
|
1109
|
+
entry exits at once, so the hooks cost almost nothing in unrelated sessions.
|
|
1110
|
+
Earlier versions wrote one exact-session hook set per session; registering a
|
|
1111
|
+
session, connecting, or `machine doctor --fix` folds every such hook for the
|
|
1112
|
+
config directory into the shared set (recording its profile as a receiver)
|
|
1113
|
+
and drops hooks for profiles that are no longer connected. Hooks of other
|
|
1114
|
+
tools and of other config directories are left alone.
|
|
1115
|
+
|
|
1116
|
+
`claude.hook.stop` names every receive hook a repair would change in
|
|
1117
|
+
`items: [{profile, session, hook, state}]`, where `hook` is the Claude event
|
|
1105
1118
|
(`Stop`, `SessionStart` or `PostToolUse`) and `state` is `missing`, `stale`,
|
|
1106
|
-
`duplicate`, `outdated
|
|
1107
|
-
|
|
1119
|
+
`duplicate`, `outdated`, `old_address` or `per_session` (an exact-session hook
|
|
1120
|
+
to fold into the shared set); `profile` and `session` are null for the shared
|
|
1121
|
+
hooks and for hooks written by old CLI versions that did not record them. After `--fix` it
|
|
1108
1122
|
also reports `changes`, the same fields plus `action` (`added`, `removed` or
|
|
1109
1123
|
`updated`), for each hook it changed. `--profile <name>` (repeatable, with
|
|
1110
1124
|
`--fix`) limits those per-session hook changes to the named profiles; combine it
|
|
@@ -1123,11 +1137,11 @@ The SessionStart hook runs `primitive agent session-register --runtime claude --
|
|
|
1123
1137
|
It gives the session an address once, named `<runtime>-<repository>`, using the
|
|
1124
1138
|
saved member login, and only re-verifies on resume; a session whose agent was
|
|
1125
1139
|
disconnected or removed never gets a second address. The SessionEnd hook runs
|
|
1126
|
-
`primitive agent session-end`, which marks the session ended and removes
|
|
1127
|
-
|
|
1140
|
+
`primitive agent session-end`, which marks the session ended and removes it
|
|
1141
|
+
from the receive hooks' session receivers, but keeps the agent's credential and profile, so
|
|
1128
1142
|
closing a terminal does not cost the session its agent: resuming the session
|
|
1129
|
-
(for example `claude --resume <session>`) re-verifies the agent and
|
|
1130
|
-
the
|
|
1143
|
+
(for example `claude --resume <session>`) re-verifies the agent and records it
|
|
1144
|
+
as the session's receiver again at the same address. Presence lapses on its own once the session
|
|
1131
1145
|
stops running. Claude reports the same SessionEnd for a restart, a switch to
|
|
1132
1146
|
another session and a real exit, so the hook waits five minutes first, and a
|
|
1133
1147
|
start of the same session within that time cancels the end.
|
|
@@ -1348,6 +1362,23 @@ hooks print the same line as the live wake, including the load-the-skill line
|
|
|
1348
1362
|
for verified mail (notices recorded by older CLI versions keep a shorter form
|
|
1349
1363
|
with the same `to=` field and profile-selecting read command).
|
|
1350
1364
|
|
|
1365
|
+
Each email is announced to a session once. The hook that shows a notice first
|
|
1366
|
+
claims its id with `primitive listen pending --claim <email-id>`, under the
|
|
1367
|
+
profile's notice lock, so a Stop, resume or PostToolUse hook running at the
|
|
1368
|
+
same time skips it, and a later hook never repeats it, even when the session
|
|
1369
|
+
handled the mail without reading it through this CLI. The claimed ids are kept
|
|
1370
|
+
in `pending-mail-<session>.announced.json` next to the notices. A wake also
|
|
1371
|
+
reports the other mail the listener took meanwhile: after waking it keeps
|
|
1372
|
+
taking already queued mail for about a second and a half, and the hook lists
|
|
1373
|
+
every notice not yet announced with the wake's own line. A hook shows at most
|
|
1374
|
+
10 notices; a final line counts the rest, which the next hook announces. Past
|
|
1375
|
+
50 mail notices the oldest already announced one gives way to new mail; mail
|
|
1376
|
+
the session has not been shown is never dropped.
|
|
1377
|
+
|
|
1378
|
+
`--timeout` bounds how long the listener waits for a delivery, not a delivery
|
|
1379
|
+
it already holds: that one is still handled and completed, so a short hook
|
|
1380
|
+
check never leaves a leased event to expire and count as a failed attempt.
|
|
1381
|
+
|
|
1351
1382
|
Mail in one thread is announced once, by its newest message. A message whose
|
|
1352
1383
|
thread already has newer mail (`newer=` above zero) does not wake the session
|
|
1353
1384
|
on its own: the listener moves on to the newer one, and wakes for the held
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import {
|
|
4
4
|
existsSync,
|
|
@@ -24,18 +24,19 @@ const interactionPattern =
|
|
|
24
24
|
/^(?:fyi|unknown|[a-z][a-z0-9._-]{0,63}\/[1-9][0-9]{0,3})$/;
|
|
25
25
|
const relationships = ["owner", "member", "agent", "contact", "other"];
|
|
26
26
|
const pollIntervalMs = 20_000;
|
|
27
|
-
|
|
27
|
+
/** The most notices one hook shows; the rest wait for the next hook. */
|
|
28
|
+
export const SHOWN_LIMIT = 10;
|
|
28
29
|
|
|
29
30
|
function profileDirectory(configDir, profile) {
|
|
30
31
|
if (!profilePattern.test(profile)) return null;
|
|
31
32
|
return join(resolve(configDir), "agent-connections", "profiles", profile);
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
function readJson(path) {
|
|
35
|
+
function readJson(path, maxBytes = 65_536) {
|
|
35
36
|
try {
|
|
36
37
|
const stat = lstatSync(path);
|
|
37
38
|
// A full pending list (mail cap plus status headroom) can pass 16 KiB.
|
|
38
|
-
if (!stat.isFile() || stat.isSymbolicLink() || stat.size >
|
|
39
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxBytes)
|
|
39
40
|
return null;
|
|
40
41
|
return JSON.parse(readFileSync(path, "utf8"));
|
|
41
42
|
} catch {
|
|
@@ -134,7 +135,8 @@ const REPEAT_TICK = "repeat.tick/1";
|
|
|
134
135
|
* A repeating message's tick that knows of newer mail in its thread is
|
|
135
136
|
* superseded even when that newer tick has no notice yet, since the newer
|
|
136
137
|
* one will arrive. Status notices and mail outside a thread are never
|
|
137
|
-
* collapsed. A kept notice carries `covered`, how many it stands for
|
|
138
|
+
* collapsed. A kept notice carries `covered`, how many it stands for, and
|
|
139
|
+
* `coveredIds`, their ids.
|
|
138
140
|
* Order is kept, so the oldest remaining notice still comes first.
|
|
139
141
|
*/
|
|
140
142
|
export function collapsePendingMail(notices) {
|
|
@@ -153,7 +155,10 @@ export function collapsePendingMail(notices) {
|
|
|
153
155
|
const kept = notices.filter((notice, index) => {
|
|
154
156
|
if (notice.kind === "status" || !notice.threadId) return true;
|
|
155
157
|
if (newest.get(notice.threadId) !== index) {
|
|
156
|
-
covered.set(notice.threadId,
|
|
158
|
+
covered.set(notice.threadId, [
|
|
159
|
+
...(covered.get(notice.threadId) ?? []),
|
|
160
|
+
notice.emailId,
|
|
161
|
+
]);
|
|
157
162
|
return false;
|
|
158
163
|
}
|
|
159
164
|
return !(
|
|
@@ -164,7 +169,11 @@ export function collapsePendingMail(notices) {
|
|
|
164
169
|
});
|
|
165
170
|
return kept.map((notice) =>
|
|
166
171
|
notice.kind !== "status" && notice.threadId && covered.get(notice.threadId)
|
|
167
|
-
? {
|
|
172
|
+
? {
|
|
173
|
+
...notice,
|
|
174
|
+
covered: covered.get(notice.threadId).length,
|
|
175
|
+
coveredIds: covered.get(notice.threadId),
|
|
176
|
+
}
|
|
168
177
|
: notice,
|
|
169
178
|
);
|
|
170
179
|
}
|
|
@@ -334,17 +343,76 @@ function formatLiveMail(notice, receiver) {
|
|
|
334
343
|
return `${skillFirst}Primitive mail arrived: ${notice.emailId}${recipientField(receiver.address)} from=${sender} relationship=${relationship} thread=${notice.threadId ?? "none"} in_thread=${notice.inThread ? "yes" : "no"} attachments=${notice.attachments ? "yes" : "no"}${newer}${interaction ? ` interaction=${interaction}` : ""}. Read with ${readCommand(notice.emailId, receiver.profile, receiver.cli)}.${interaction ? wakeSentence(interaction) : ""} ${authority}\n`;
|
|
335
344
|
}
|
|
336
345
|
|
|
337
|
-
|
|
338
|
-
|
|
346
|
+
/**
|
|
347
|
+
* Ids already announced to the session, read without the writer lock (the
|
|
348
|
+
* CLI replaces the file atomically). The earlier repeat-timer file (version
|
|
349
|
+
* 1) also listed ids a hook selected but did not show past its first 10, so
|
|
350
|
+
* it proves nothing and is ignored: each of its notices is announced once
|
|
351
|
+
* more after an upgrade rather than possibly never.
|
|
352
|
+
*/
|
|
353
|
+
export function announcedPendingMail(configDir, profile, sessionId) {
|
|
354
|
+
const directory = profileDirectory(configDir, profile);
|
|
355
|
+
if (!directory || !uuid.test(sessionId)) return new Set();
|
|
356
|
+
const state = readJson(
|
|
357
|
+
join(directory, `pending-mail-${sessionId.toLowerCase()}.announced.json`),
|
|
358
|
+
262_144,
|
|
359
|
+
);
|
|
360
|
+
const ids =
|
|
361
|
+
state?.version === 2
|
|
362
|
+
? state.ids
|
|
363
|
+
: state?.version === 1
|
|
364
|
+
? state.times
|
|
365
|
+
: null;
|
|
366
|
+
return new Set(
|
|
367
|
+
ids && typeof ids === "object" && !Array.isArray(ids)
|
|
368
|
+
? Object.keys(ids).map((id) => id.toLowerCase())
|
|
369
|
+
: [],
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The notices this hook would show: those not yet announced, one per
|
|
375
|
+
* thread, at most SHOWN_LIMIT, leaving out `exclude` (a live wake's own
|
|
376
|
+
* email, announced by its own line). `ids` are what showing them claims: each
|
|
377
|
+
* shown notice and the older notices in its thread it stands for.
|
|
378
|
+
* `remaining` counts the unannounced notices left for a later hook.
|
|
379
|
+
*/
|
|
380
|
+
export function selectPendingMail(
|
|
339
381
|
configDir,
|
|
340
382
|
profile,
|
|
341
383
|
sessionId,
|
|
342
384
|
notices,
|
|
385
|
+
exclude = [],
|
|
343
386
|
) {
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
387
|
+
const announced = announcedPendingMail(configDir, profile, sessionId);
|
|
388
|
+
const live = new Set(exclude);
|
|
389
|
+
// Threads are grouped with the live wake's own notice still in them, so
|
|
390
|
+
// the older mail it stands for is claimed with it rather than shown again.
|
|
391
|
+
const fresh = collapsePendingMail(
|
|
392
|
+
notices.filter((notice) => !announced.has(notice.emailId)),
|
|
393
|
+
);
|
|
394
|
+
const liveRows = fresh.filter((notice) => live.has(notice.emailId));
|
|
395
|
+
const others = fresh.filter((notice) => !live.has(notice.emailId));
|
|
396
|
+
const shown = others.slice(0, SHOWN_LIMIT);
|
|
397
|
+
return {
|
|
398
|
+
shown,
|
|
399
|
+
live: liveRows,
|
|
400
|
+
ids: [...liveRows, ...shown].flatMap((notice) => [
|
|
401
|
+
notice.emailId,
|
|
402
|
+
...(notice.coveredIds ?? []),
|
|
403
|
+
]),
|
|
404
|
+
remaining: others.length - shown.length,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Claim exact ids through the CLI, which takes the profile's writer lock, so
|
|
410
|
+
* each id is announced by one hook only. Returns the ids this call claimed,
|
|
411
|
+
* or null when the claim could not run (an older CLI, say); the caller then
|
|
412
|
+
* shows its notices anyway rather than risk never showing them.
|
|
413
|
+
*/
|
|
414
|
+
export function claimPendingMail(cli, configDir, profile, sessionId, ids) {
|
|
415
|
+
if (!ids.length) return new Set();
|
|
348
416
|
const env = {
|
|
349
417
|
...process.env,
|
|
350
418
|
PRIMITIVE_CONFIG_DIR: configDir,
|
|
@@ -352,55 +420,71 @@ export function clearDeliveredStatus(
|
|
|
352
420
|
};
|
|
353
421
|
delete env.PRIMITIVE_API_KEY;
|
|
354
422
|
delete env.PRIMITIVE_KEY;
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
maxBuffer:
|
|
370
|
-
|
|
371
|
-
|
|
423
|
+
try {
|
|
424
|
+
const result = spawnSync(
|
|
425
|
+
process.execPath,
|
|
426
|
+
[
|
|
427
|
+
cli,
|
|
428
|
+
"listen",
|
|
429
|
+
"pending",
|
|
430
|
+
"--session",
|
|
431
|
+
sessionId,
|
|
432
|
+
...ids.flatMap((id) => ["--claim", id]),
|
|
433
|
+
],
|
|
434
|
+
// PostToolUse hooks have 10 seconds in all: the mail check takes at
|
|
435
|
+
// most 5.5 and the claim 3, so a hook is never cut off after claiming
|
|
436
|
+
// notices it has not printed yet.
|
|
437
|
+
{ env, encoding: "utf8", timeout: 3_000, maxBuffer: 262_144 },
|
|
438
|
+
);
|
|
439
|
+
if (result.status !== 0) return null;
|
|
440
|
+
const claimed = JSON.parse(result.stdout)?.claimed;
|
|
441
|
+
if (!Array.isArray(claimed)) return null;
|
|
442
|
+
return new Set(
|
|
443
|
+
claimed
|
|
444
|
+
.filter((id) => typeof id === "string" && uuid.test(id))
|
|
445
|
+
.map((id) => id.toLowerCase()),
|
|
446
|
+
);
|
|
447
|
+
} catch {
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
372
450
|
}
|
|
373
451
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
452
|
+
/**
|
|
453
|
+
* Lines for the pending notices this hook claims, at most SHOWN_LIMIT, plus
|
|
454
|
+
* a count of those left for a later hook. `extraIds` (a live wake's own id)
|
|
455
|
+
* are claimed in the same call but not shown; `claimed` says which ids this
|
|
456
|
+
* hook won.
|
|
457
|
+
*/
|
|
458
|
+
export function announcePendingMail(
|
|
459
|
+
{ cli, configDir, profile, address, sessionId, skillFile },
|
|
460
|
+
extraIds = [],
|
|
461
|
+
) {
|
|
462
|
+
const { shown, live, ids, remaining } = selectPendingMail(
|
|
463
|
+
configDir,
|
|
464
|
+
profile,
|
|
465
|
+
sessionId,
|
|
466
|
+
readPendingMail(configDir, profile, sessionId),
|
|
467
|
+
extraIds,
|
|
381
468
|
);
|
|
382
|
-
const
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
]),
|
|
469
|
+
const wanted = [...new Set([...extraIds, ...ids])];
|
|
470
|
+
const claimed =
|
|
471
|
+
claimPendingMail(cli, configDir, profile, sessionId, wanted) ??
|
|
472
|
+
new Set(wanted);
|
|
473
|
+
const receiver = { profile, address, skillFile, cli };
|
|
474
|
+
const lines = [
|
|
475
|
+
// The live wake prints its own line; say which older mail it covers.
|
|
476
|
+
...live
|
|
477
|
+
.filter((notice) => claimed.has(notice.emailId))
|
|
478
|
+
.map((notice) => coveredLine(notice)),
|
|
479
|
+
...shown
|
|
480
|
+
.filter((notice) => claimed.has(notice.emailId))
|
|
481
|
+
.map((notice) => formatPendingMail(notice, receiver)),
|
|
482
|
+
].filter(Boolean);
|
|
483
|
+
if (remaining > 0)
|
|
484
|
+
lines.push(
|
|
485
|
+
`${remaining} more pending ${remaining === 1 ? "message" : "messages"}; they are announced next.\n`,
|
|
400
486
|
);
|
|
401
|
-
|
|
402
|
-
}
|
|
403
|
-
return due;
|
|
487
|
+
return { lines, claimed };
|
|
404
488
|
}
|
|
405
489
|
|
|
406
490
|
function pollDue(configDir, profile, sessionId) {
|
|
@@ -419,13 +503,8 @@ function pollDue(configDir, profile, sessionId) {
|
|
|
419
503
|
return true;
|
|
420
504
|
}
|
|
421
505
|
|
|
422
|
-
function context(
|
|
423
|
-
if (
|
|
424
|
-
const lines = notices
|
|
425
|
-
.slice(0, 10)
|
|
426
|
-
.map((notice) => formatPendingMail(notice, receiver));
|
|
427
|
-
if (notices.length > 10)
|
|
428
|
-
lines.push(`${notices.length - 10} more pending messages.\n`);
|
|
506
|
+
function context(lines) {
|
|
507
|
+
if (lines.length === 0) return;
|
|
429
508
|
process.stdout.write(
|
|
430
509
|
JSON.stringify({
|
|
431
510
|
hookSpecificOutput: {
|
|
@@ -455,18 +534,130 @@ function tryLock(path) {
|
|
|
455
534
|
return false;
|
|
456
535
|
}
|
|
457
536
|
|
|
537
|
+
/**
|
|
538
|
+
* The profiles a session receives as, from the config directory's session
|
|
539
|
+
* receivers (written when the session registers). Empty when it has none.
|
|
540
|
+
*/
|
|
541
|
+
export function readSessionReceivers(configDir, sessionId) {
|
|
542
|
+
if (!configDir || !uuid.test(sessionId ?? "")) return [];
|
|
543
|
+
const state = readJson(
|
|
544
|
+
join(
|
|
545
|
+
resolve(configDir),
|
|
546
|
+
"agent-connections",
|
|
547
|
+
"claude-sessions",
|
|
548
|
+
`${sessionId.toLowerCase()}.json`,
|
|
549
|
+
),
|
|
550
|
+
);
|
|
551
|
+
if (
|
|
552
|
+
state?.version !== 1 ||
|
|
553
|
+
state.session_id !== sessionId.toLowerCase() ||
|
|
554
|
+
!Array.isArray(state.receivers)
|
|
555
|
+
)
|
|
556
|
+
return [];
|
|
557
|
+
return state.receivers.flatMap((row) =>
|
|
558
|
+
profilePattern.test(row?.profile ?? "") &&
|
|
559
|
+
addressPattern.test(row?.address ?? "")
|
|
560
|
+
? [{ profile: row.profile, address: row.address.toLowerCase() }]
|
|
561
|
+
: [],
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function runListener(cli, configDir, profile, address, sessionId) {
|
|
566
|
+
const env = {
|
|
567
|
+
...process.env,
|
|
568
|
+
PRIMITIVE_CONFIG_DIR: configDir,
|
|
569
|
+
PRIMITIVE_AGENT_PROFILE: profile,
|
|
570
|
+
PRIMITIVE_HOOK_AGENT_ADDRESS: address.toLowerCase(),
|
|
571
|
+
};
|
|
572
|
+
delete env.PRIMITIVE_API_KEY;
|
|
573
|
+
delete env.PRIMITIVE_KEY;
|
|
574
|
+
return new Promise((done) => {
|
|
575
|
+
const child = spawn(
|
|
576
|
+
process.execPath,
|
|
577
|
+
[
|
|
578
|
+
cli,
|
|
579
|
+
"listen",
|
|
580
|
+
"--once",
|
|
581
|
+
"--wake",
|
|
582
|
+
"--hook-session",
|
|
583
|
+
"--events",
|
|
584
|
+
"email.received",
|
|
585
|
+
"--timeout",
|
|
586
|
+
"2",
|
|
587
|
+
],
|
|
588
|
+
{ env, stdio: ["pipe", "ignore", "ignore"] },
|
|
589
|
+
);
|
|
590
|
+
const timer = setTimeout(() => child.kill("SIGTERM"), 5_000);
|
|
591
|
+
child.once("error", () => {
|
|
592
|
+
clearTimeout(timer);
|
|
593
|
+
done();
|
|
594
|
+
});
|
|
595
|
+
child.once("close", () => {
|
|
596
|
+
clearTimeout(timer);
|
|
597
|
+
done();
|
|
598
|
+
});
|
|
599
|
+
child.stdin.on("error", () => undefined);
|
|
600
|
+
child.stdin.end(
|
|
601
|
+
JSON.stringify({ hook_event_name: "Stop", session_id: sessionId }),
|
|
602
|
+
);
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Claude Code stops a PostToolUse hook after 10 seconds. Mail checks run in
|
|
608
|
+
* parallel within 5 seconds; claims then run one profile at a time and none
|
|
609
|
+
* starts after this point, so a hook is never stopped between claiming
|
|
610
|
+
* notices and printing them. Notices left unclaimed go to the next hook.
|
|
611
|
+
*/
|
|
612
|
+
const CLAIM_CUTOFF_MS = 6_000;
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Start one profile's mail check after a tool call. Resolves with a release
|
|
616
|
+
* function once the check is done, or null when another hook holds it.
|
|
617
|
+
*/
|
|
618
|
+
async function checkProfile({ cli, configDir, profile, address, sessionId }) {
|
|
619
|
+
const directory = profileDirectory(configDir, profile);
|
|
620
|
+
if (!directory) return null;
|
|
621
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
622
|
+
writeJson(join(directory, `pending-mail-${sessionId}.fired.json`), {
|
|
623
|
+
version: 1,
|
|
624
|
+
at: new Date().toISOString(),
|
|
625
|
+
});
|
|
626
|
+
const lock = join(directory, `pending-mail-${sessionId}.lock`);
|
|
627
|
+
if (!tryLock(lock)) return null;
|
|
628
|
+
const release = () => {
|
|
629
|
+
try {
|
|
630
|
+
rmdirSync(lock);
|
|
631
|
+
} catch {
|
|
632
|
+
/* Fail open. */
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
try {
|
|
636
|
+
// A pending notice never stops the mail check: new mail keeps being
|
|
637
|
+
// fetched while an earlier notice waits to be read.
|
|
638
|
+
if (pollDue(configDir, profile, sessionId))
|
|
639
|
+
await runListener(cli, configDir, profile, address, sessionId);
|
|
640
|
+
} catch {
|
|
641
|
+
/* The claim below still runs. */
|
|
642
|
+
}
|
|
643
|
+
return release;
|
|
644
|
+
}
|
|
645
|
+
|
|
458
646
|
async function main() {
|
|
459
|
-
const
|
|
460
|
-
|
|
647
|
+
const args = process.argv.slice(2);
|
|
648
|
+
// The shared hook names only the CLI and config directory; a per-session
|
|
649
|
+
// hook from an earlier version also names its profile and session.
|
|
650
|
+
const shared = args.length === 3 && args[2] === "primitive-pending-mail-v2";
|
|
651
|
+
const [cli, configDir, profile, address, expectedSession, marker] = args;
|
|
461
652
|
if (
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
!uuid.test(expectedSession ?? "")
|
|
653
|
+
!shared &&
|
|
654
|
+
(marker !== "primitive-pending-mail-v1" ||
|
|
655
|
+
!profilePattern.test(profile ?? "") ||
|
|
656
|
+
!addressPattern.test(address ?? "") ||
|
|
657
|
+
!uuid.test(expectedSession ?? ""))
|
|
468
658
|
)
|
|
469
659
|
return;
|
|
660
|
+
if (!cli || !configDir) return;
|
|
470
661
|
let raw = "";
|
|
471
662
|
for await (const part of process.stdin) {
|
|
472
663
|
raw += String(part);
|
|
@@ -478,79 +669,44 @@ async function main() {
|
|
|
478
669
|
} catch {
|
|
479
670
|
return;
|
|
480
671
|
}
|
|
481
|
-
const sessionId = expectedSession.toLowerCase();
|
|
482
672
|
if (
|
|
483
673
|
input?.hook_event_name !== "PostToolUse" ||
|
|
484
674
|
typeof input.session_id !== "string" ||
|
|
485
|
-
input.session_id
|
|
675
|
+
!uuid.test(input.session_id)
|
|
486
676
|
)
|
|
487
677
|
return;
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
const
|
|
495
|
-
|
|
678
|
+
const sessionId = input.session_id.toLowerCase();
|
|
679
|
+
if (!shared && sessionId !== expectedSession.toLowerCase()) return;
|
|
680
|
+
const receivers = shared
|
|
681
|
+
? readSessionReceivers(configDir, sessionId)
|
|
682
|
+
: [{ profile, address }];
|
|
683
|
+
if (receivers.length === 0) return;
|
|
684
|
+
const started = Date.now();
|
|
685
|
+
const checks = await Promise.all(
|
|
686
|
+
receivers.map((receiver) =>
|
|
687
|
+
checkProfile({ cli, configDir, ...receiver, sessionId }).catch(
|
|
688
|
+
() => null,
|
|
689
|
+
),
|
|
690
|
+
),
|
|
691
|
+
);
|
|
692
|
+
const lines = [];
|
|
496
693
|
try {
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
...process.env,
|
|
502
|
-
PRIMITIVE_CONFIG_DIR: configDir,
|
|
503
|
-
PRIMITIVE_AGENT_PROFILE: profile,
|
|
504
|
-
PRIMITIVE_HOOK_AGENT_ADDRESS: address.toLowerCase(),
|
|
505
|
-
};
|
|
506
|
-
delete env.PRIMITIVE_API_KEY;
|
|
507
|
-
delete env.PRIMITIVE_KEY;
|
|
508
|
-
spawnSync(
|
|
509
|
-
process.execPath,
|
|
510
|
-
[
|
|
694
|
+
receivers.forEach((receiver, index) => {
|
|
695
|
+
if (!checks[index] || Date.now() - started > CLAIM_CUTOFF_MS) return;
|
|
696
|
+
lines.push(
|
|
697
|
+
...announcePendingMail({
|
|
511
698
|
cli,
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
"email.received",
|
|
518
|
-
"--timeout",
|
|
519
|
-
"2",
|
|
520
|
-
],
|
|
521
|
-
{
|
|
522
|
-
input: JSON.stringify({
|
|
523
|
-
hook_event_name: "Stop",
|
|
524
|
-
session_id: sessionId,
|
|
525
|
-
}),
|
|
526
|
-
encoding: "utf8",
|
|
527
|
-
env,
|
|
528
|
-
timeout: 8_000,
|
|
529
|
-
maxBuffer: 16_384,
|
|
530
|
-
},
|
|
699
|
+
configDir,
|
|
700
|
+
...receiver,
|
|
701
|
+
sessionId,
|
|
702
|
+
skillFile: claudeSkillFile(),
|
|
703
|
+
}).lines,
|
|
531
704
|
);
|
|
532
|
-
}
|
|
533
|
-
const notices = collapsePendingMail(
|
|
534
|
-
readPendingMail(configDir, profile, sessionId),
|
|
535
|
-
);
|
|
536
|
-
if (notices.length) {
|
|
537
|
-
const due = duePendingMail(configDir, profile, sessionId, notices);
|
|
538
|
-
context(due, { profile, address, skillFile: claudeSkillFile(), cli });
|
|
539
|
-
clearDeliveredStatus(
|
|
540
|
-
cli,
|
|
541
|
-
configDir,
|
|
542
|
-
profile,
|
|
543
|
-
sessionId,
|
|
544
|
-
due.slice(0, 10),
|
|
545
|
-
);
|
|
546
|
-
}
|
|
705
|
+
});
|
|
547
706
|
} finally {
|
|
548
|
-
|
|
549
|
-
rmdirSync(lock);
|
|
550
|
-
} catch {
|
|
551
|
-
/* Fail open. */
|
|
552
|
-
}
|
|
707
|
+
for (const release of checks) release?.();
|
|
553
708
|
}
|
|
709
|
+
context(lines);
|
|
554
710
|
}
|
|
555
711
|
|
|
556
712
|
if (
|