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 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 per-session receive hooks (each present once, pointing at this
1094
- CLI, with stale copies from older installs removed), the Codex SessionStart hook
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
- `claude.hook.stop` names every per-session receive hook a repair would change
1104
- in `items: [{profile, session, hook, state}]`, where `hook` is the Claude event
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` or `old_address`; `profile` and `session` are null for
1107
- hooks written by old CLI versions that did not record them. After `--fix` it
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 its
1127
- exact-session receive hooks, but keeps the agent's credential and profile, so
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 reinstalls
1130
- the hooks at the same address. Presence lapses on its own once the session
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
- const announceIntervalMs = 60_000;
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 > 65_536)
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, (covered.get(notice.threadId) ?? 0) + 1);
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
- ? { ...notice, covered: covered.get(notice.threadId) }
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
- export function clearDeliveredStatus(
338
- cli,
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 ids = notices
345
- .filter((notice) => notice.kind === "status")
346
- .map((notice) => notice.emailId);
347
- if (!ids.length) return;
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
- spawnSync(
356
- process.execPath,
357
- [
358
- cli,
359
- "listen",
360
- "pending",
361
- "--session",
362
- sessionId,
363
- ...ids.flatMap((id) => ["--clear", id]),
364
- ],
365
- {
366
- env,
367
- encoding: "utf8",
368
- timeout: 8_000,
369
- maxBuffer: 16_384,
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
- export function duePendingMail(configDir, profile, sessionId, notices) {
375
- const directory = profileDirectory(configDir, profile);
376
- if (!directory || !uuid.test(sessionId) || notices.length === 0) return [];
377
- mkdirSync(directory, { recursive: true, mode: 0o700 });
378
- const path = join(
379
- directory,
380
- `pending-mail-${sessionId.toLowerCase()}.announced.json`,
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 state = readJson(path);
383
- const previous =
384
- state?.version === 1 && state.times && typeof state.times === "object"
385
- ? state.times
386
- : {};
387
- const now = Date.now();
388
- const due = notices.filter((notice) => {
389
- const at = previous[notice.emailId];
390
- return !Number.isFinite(at) || at > now || now - at >= announceIntervalMs;
391
- });
392
- if (due.length) {
393
- const times = Object.fromEntries(
394
- notices.map((notice) => [
395
- notice.emailId,
396
- due.some((item) => item.emailId === notice.emailId)
397
- ? now
398
- : (previous[notice.emailId] ?? now),
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
- writeJson(path, { version: 1, times });
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(notices, receiver) {
423
- if (notices.length === 0) return;
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 [cli, configDir, profile, address, expectedSession, marker] =
460
- process.argv.slice(2);
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
- marker !== "primitive-pending-mail-v1" ||
463
- !cli ||
464
- !configDir ||
465
- !profilePattern.test(profile ?? "") ||
466
- !addressPattern.test(address ?? "") ||
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.toLowerCase() !== sessionId
675
+ !uuid.test(input.session_id)
486
676
  )
487
677
  return;
488
- const directory = profileDirectory(configDir, profile);
489
- mkdirSync(directory, { recursive: true, mode: 0o700 });
490
- writeJson(join(directory, `pending-mail-${sessionId}.fired.json`), {
491
- version: 1,
492
- at: new Date().toISOString(),
493
- });
494
- const lock = join(directory, `pending-mail-${sessionId}.lock`);
495
- if (!tryLock(lock)) return;
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
- // A pending notice never stops the mail check: new mail keeps being
498
- // fetched while an earlier notice waits to be read.
499
- if (pollDue(configDir, profile, sessionId)) {
500
- const env = {
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
- "listen",
513
- "--once",
514
- "--wake",
515
- "--hook-session",
516
- "--events",
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
- try {
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 (