@agentcomms/whatsapp 0.28.0 → 0.29.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.
@@ -1,17 +1,17 @@
1
- import { C as unknown, S as union, b as strictObject, c as boolean, g as object, h as number, i as _enum, l as discriminatedUnion, m as looseObject, n as datetime, p as literal, s as array, x as string, y as record } from "./iso-5-9R47LC.mjs";
2
- import { access, chmod, constants, link, lstat, mkdir, open, readFile, readdir, readlink, realpath, rename, rm, stat, unlink, utimes } from "node:fs/promises";
1
+ import { C as unknown, S as union$1, b as strictObject, c as boolean, g as object, h as number, i as _enum, l as discriminatedUnion, m as looseObject, n as datetime, p as literal, s as array, x as string, y as record } from "./iso-5-9R47LC.mjs";
2
+ import { execFile, spawn } from "node:child_process";
3
+ import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
4
+ import { accessSync, constants, existsSync, lstatSync, readFileSync, readlinkSync, realpathSync, statSync } from "node:fs";
5
+ import { access, chmod, constants as constants$1, link, lstat, mkdir, open, readFile, readdir, readlink, realpath, rename, rm, stat, unlink, utimes } from "node:fs/promises";
6
+ import { homedir, platform, userInfo } from "node:os";
3
7
  import path, { basename, delimiter, dirname, isAbsolute, join, posix, relative, resolve, sep, win32 } from "node:path";
4
8
  import { domainToASCII, fileURLToPath, pathToFileURL } from "node:url";
5
- import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
6
9
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
7
10
  import { styleText } from "node:util";
8
- import { homedir, platform, userInfo } from "node:os";
9
- import { accessSync, constants as constants$1, lstatSync, readFileSync, readlinkSync, realpathSync, statSync } from "node:fs";
10
- import { execFile, spawn } from "node:child_process";
11
11
  //#endregion
12
12
  //#region src/version.ts
13
13
  /** The package version, read from package.json at build time. */
14
- const VERSION$2 = "0.28.0";
14
+ const VERSION$2 = "0.29.0";
15
15
  //#endregion
16
16
  //#region src/caller.ts
17
17
  /** This package's name, as its manifest has it. */
@@ -27,8 +27,70 @@ const CALLER = Object.freeze({
27
27
  url: import.meta.url,
28
28
  packageName: PACKAGE_NAME
29
29
  });
30
- //#endregion
31
- //#region ../core/dist/update-check-CEMQ4eVV.mjs
30
+ Object.freeze(["on", "off"]);
31
+ /** Whether `value` is a retention a person can set: a whole number of days from 1 to 365. */
32
+ function isActivityRetention(value) {
33
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 365;
34
+ }
35
+ function isActivityRecordSetting(value) {
36
+ return value === "on" || value === "off";
37
+ }
38
+ /** The `activity` block as the file holds it, raw: an object, or nothing. */
39
+ function storedActivity(config) {
40
+ const stored = config.activity;
41
+ return typeof stored === "object" && stored !== null && !Array.isArray(stored) ? stored : null;
42
+ }
43
+ /**
44
+ * The settings in force for `config`. A missing block, or a missing key, is the default. Anything else this release
45
+ * cannot read is named in `problems`, and read so that nothing is recorded and nothing is deleted: a word other than
46
+ * `on` or `off` as `off`; a retention that is not a whole number from 1 to 365 — out of range, a fraction, not a
47
+ * number — as 365 days with recording `off`; a block that is not an object as both.
48
+ */
49
+ function readActivitySettings(config) {
50
+ const problems = [];
51
+ if (config.activity === void 0) return {
52
+ record: "on",
53
+ retentionDays: 30,
54
+ problems
55
+ };
56
+ const stored = storedActivity(config);
57
+ if (stored === null) {
58
+ problems.push(`activity in config.json is not an object, so recording is off and 365 days are kept`);
59
+ return {
60
+ record: "off",
61
+ retentionDays: 365,
62
+ problems
63
+ };
64
+ }
65
+ let record = "on";
66
+ if (stored.record !== void 0) {
67
+ if (isActivityRecordSetting(stored.record)) record = stored.record;
68
+ else {
69
+ record = "off";
70
+ problems.push(`activity.record is ${describe(stored.record)}, not on or off, so recording is off`);
71
+ }
72
+ }
73
+ let retentionDays = 30;
74
+ const days = stored.retentionDays;
75
+ if (days !== void 0) {
76
+ if (isActivityRetention(days)) retentionDays = days;
77
+ else {
78
+ retentionDays = 365;
79
+ record = "off";
80
+ problems.push(`activity.retentionDays is ${describe(days)}, not a whole number from 1 to 365, so recording is off and 365 days are kept`);
81
+ }
82
+ }
83
+ return {
84
+ record,
85
+ retentionDays,
86
+ problems
87
+ };
88
+ }
89
+ /** A stored value in a sentence: short, and never more than a few dozen characters of whatever the file held. */
90
+ function describe(value) {
91
+ const text = typeof value === "string" ? JSON.stringify(value) : String(JSON.stringify(value) ?? value);
92
+ return text.length > 40 ? `${text.slice(0, 39)}…` : text;
93
+ }
32
94
  /**
33
95
  * Every failure a user or an agent can act on carries a specific, stable code from this registry, a message, an
34
96
  * optional one-line hint and structured `details`. The code decides the process exit status; agents and skills branch
@@ -228,19 +290,6 @@ function toCommsError(value) {
228
290
  if (isCommsError(value)) return value;
229
291
  return new CommsError("UNEXPECTED", value instanceof Error ? value.message : String(value), { cause: value });
230
292
  }
231
- function sha256Hex(data) {
232
- return createHash("sha256").update(data).digest("hex");
233
- }
234
- /** Lower-cases the address part of `Name <addr>` or a bare address; display names are dropped. */
235
- function normaliseAddress(value) {
236
- return (/<([^<>]+)>\s*$/.exec(value)?.[1] ?? value).trim().toLowerCase();
237
- }
238
- /** Deterministic JSON: keys sorted at every level, undefined members dropped. */
239
- function canonicalJson$1(value) {
240
- if (Array.isArray(value)) return `[${value.map(canonicalJson$1).join(",")}]`;
241
- if (value && typeof value === "object") return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson$1(v)}`).join(",")}}`;
242
- return JSON.stringify(value);
243
- }
244
293
  /** Creates a directory (and parents) readable only by the current user. Tightens an existing one. */
245
294
  async function ensurePrivateDir(path) {
246
295
  await mkdir(path, {
@@ -364,6 +413,1248 @@ async function isGroupOrWorldAccessible(path) {
364
413
  return ((await stat(path)).mode & 63) !== 0;
365
414
  }
366
415
  /**
416
+ * The activity log (design 2026-10-25-activity, §4 with S1–S5): one line for every call an agent session makes that
417
+ * reads or writes account data, tied to the session that made it — what it touched, never what it said.
418
+ *
419
+ * - **Where:** `<state>/activity/YYYY-MM-DD.jsonl` (the UTC day of the line), each file owner-only (600) in an
420
+ * owner-only folder (700), each line appended whole with `appendPrivateLine`, as the audit log's are. Daily files,
421
+ * so retention drops whole days (`prune`).
422
+ * - **What a line holds:** `ActivityLine`, and nothing else. Every line is rebuilt field by field from the fields a
423
+ * line has (`activityLineOf`), so a channel that hands over a whole message — body, text, HTML, attachment bytes —
424
+ * still writes only its id, subject and sender: the type says so, and the copy makes it so. Subjects and names are
425
+ * kept as received, cut to their bounds, and shown only inside the untrusted-content envelope.
426
+ * - **Never in the way:** `append` never throws into its caller. A line that cannot be written is said once per
427
+ * process on stderr, remembered for `doctor`, and the call it describes goes on (§4.6).
428
+ * - **The audit log is unchanged.** It stays small, content-free and shareable; this one is private.
429
+ */
430
+ /** What the call did with account data: read it, wrote it, was told of it (an event delivery, S1), or approved it. */
431
+ const ACTIVITY_KINDS = Object.freeze([
432
+ "read",
433
+ "write",
434
+ "told",
435
+ "approval"
436
+ ]);
437
+ /** How it ended: done, refused by a policy, an approval or a rule, or failed. */
438
+ const ACTIVITY_OUTCOMES = Object.freeze([
439
+ "ok",
440
+ "refused",
441
+ "failed"
442
+ ]);
443
+ /** Which kind of process recorded it: an MCP server, a CLI call, or the event service's own work. */
444
+ const ACTIVITY_SURFACES = Object.freeze([
445
+ "mcp",
446
+ "cli",
447
+ "daemon"
448
+ ]);
449
+ /**
450
+ * The most a line may take as written, its newline included: 16 KiB, well inside the one write `appendPrivateLine`
451
+ * makes of it, so a line is always whole or absent, whatever its items hold. Past it, `fitted` cuts it.
452
+ */
453
+ const MAX_ACTIVITY_LINE_BYTES = 16384;
454
+ const BOUNDS = {
455
+ id: 200,
456
+ name: 200,
457
+ address: 320,
458
+ fileName: 255,
459
+ conversation: 200,
460
+ query: 500,
461
+ reason: 300,
462
+ word: 100,
463
+ alias: 128,
464
+ folder: 4096,
465
+ startedAt: 64,
466
+ version: 64
467
+ };
468
+ const MAX_RECIPIENTS = 50;
469
+ const MAX_COUNTS = 10;
470
+ const SESSION_KEY = /^[0-9a-f]{16}$/;
471
+ const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
472
+ const DAY_FILE = /^(\d{4}-\d{2}-\d{2})\.jsonl$/;
473
+ const DAY_MS$1 = 864e5;
474
+ /** `value` cut to `max` characters — whole characters, never half a surrogate pair — ending in `…` when cut. */
475
+ function cut(value, max) {
476
+ if (typeof value !== "string") return void 0;
477
+ if (value.length <= max) return value;
478
+ const chars = Array.from(value.slice(0, max * 2 + 2));
479
+ return chars.length <= max ? chars.join("") : `${chars.slice(0, max - 1).join("")}…`;
480
+ }
481
+ function whole(value) {
482
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.round(value) : void 0;
483
+ }
484
+ function isoDay(at) {
485
+ return new Date(at).toISOString().slice(0, 10);
486
+ }
487
+ function own$3(value) {
488
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
489
+ }
490
+ function itemOf(raw) {
491
+ const item = own$3(raw);
492
+ const id = cut(item?.id, BOUNDS.id);
493
+ if (item === void 0 || id === void 0) return null;
494
+ const sender = own$3(item.from);
495
+ const from = sender === void 0 ? void 0 : {
496
+ ...typeof sender.name === "string" ? { name: cut(sender.name, BOUNDS.name) } : {},
497
+ ...typeof sender.address === "string" ? { address: cut(sender.address, BOUNDS.address) } : {}
498
+ };
499
+ const size = whole(item.size);
500
+ return {
501
+ id,
502
+ ...typeof item.subject === "string" ? { subject: cut(item.subject, 200) } : {},
503
+ ...from !== void 0 && Object.keys(from).length > 0 ? { from } : {},
504
+ ...typeof item.name === "string" ? { name: cut(item.name, BOUNDS.fileName) } : {},
505
+ ...size !== void 0 ? { size } : {},
506
+ ...typeof item.conversation === "string" ? { conversation: cut(item.conversation, BOUNDS.conversation) } : {}
507
+ };
508
+ }
509
+ function clientOf(raw) {
510
+ const client = own$3(raw);
511
+ const name = cut(client?.name, BOUNDS.word);
512
+ if (name === void 0 || name === "") return void 0;
513
+ const version = cut(client?.version, BOUNDS.version);
514
+ return {
515
+ name,
516
+ ...version ? { version } : {}
517
+ };
518
+ }
519
+ /** A parent in this exact key order — the order `sessionOfAncestor` finds it by. */
520
+ function parentOf(raw) {
521
+ const parent = own$3(raw);
522
+ const pid = whole(parent?.pid);
523
+ const startedAt = cut(parent?.startedAt, BOUNDS.startedAt);
524
+ if (pid === void 0 || startedAt === void 0) return void 0;
525
+ return {
526
+ pid,
527
+ startedAt,
528
+ name: cut(parent?.name, BOUNDS.word) ?? ""
529
+ };
530
+ }
531
+ function sessionOf(raw) {
532
+ const session = own$3(raw) ?? {};
533
+ const key = typeof session.key === "string" && SESSION_KEY.test(session.key) ? session.key : null;
534
+ const surface = ACTIVITY_SURFACES.includes(session.surface) ? session.surface : "mcp";
535
+ const client = clientOf(session.client);
536
+ const parent = parentOf(session.parent);
537
+ const ancestors = Array.isArray(session.ancestors) ? session.ancestors.slice(0, 3).flatMap((name) => typeof name === "string" ? [cut(name, BOUNDS.word) ?? ""] : []) : void 0;
538
+ return {
539
+ key: key ?? randomKey(),
540
+ process: cut(session.process, BOUNDS.word) ?? PROCESS_ID,
541
+ pid: whole(session.pid) ?? 0,
542
+ ...client === void 0 ? {} : { client },
543
+ folder: typeof session.folder === "string" ? cut(session.folder, BOUNDS.folder) ?? null : null,
544
+ ...parent === void 0 ? {} : { parent },
545
+ ...ancestors === void 0 ? {} : { ancestors },
546
+ grouped: key !== null && session.grouped === true,
547
+ surface
548
+ };
549
+ }
550
+ /**
551
+ * The line as it is written: every field copied from what a line has, each cut to its bound, in one fixed order.
552
+ * Anything else the input carries — a body, a text, an HTML part, bytes — is not copied, whatever it is called.
553
+ */
554
+ function activityLineOf(input, now = /* @__PURE__ */ new Date()) {
555
+ return fitted(boundedLine(input, now));
556
+ }
557
+ /** How many bytes a line takes as written, its newline included. */
558
+ function bytesOf(line) {
559
+ return Buffer.byteLength(JSON.stringify(line)) + 1;
560
+ }
561
+ /**
562
+ * The line, cut until it fits in `MAX_ACTIVITY_LINE_BYTES`, and marked `truncated` when it was: items from the end
563
+ * first — `count` keeps how many there were — then recipients from the end, then the longer words shortened, and
564
+ * last the words that are not the line's own dropped. Text a sender wrote can be many times its length as JSON (a
565
+ * control character is six bytes), so the bounds on each field alone do not bound the line.
566
+ */
567
+ function fitted(line) {
568
+ if (bytesOf(line) <= 16384) return line;
569
+ const items = [...line.items ?? []];
570
+ const recipients = [...line.recipients ?? []];
571
+ let cutLine = {
572
+ ...line,
573
+ truncated: true
574
+ };
575
+ const fits = () => bytesOf(cutLine) <= MAX_ACTIVITY_LINE_BYTES;
576
+ while (items.length > 0 && !fits()) {
577
+ items.pop();
578
+ cutLine = {
579
+ ...cutLine,
580
+ items: [...items]
581
+ };
582
+ }
583
+ while (recipients.length > 0 && !fits()) {
584
+ recipients.pop();
585
+ cutLine = {
586
+ ...cutLine,
587
+ recipients: [...recipients]
588
+ };
589
+ }
590
+ if (fits()) return cutLine;
591
+ const short = (text, max) => text === void 0 ? void 0 : cut(text, max);
592
+ const { ancestors: _ancestors, ...session } = cutLine.session;
593
+ cutLine = {
594
+ ...cutLine,
595
+ channel: short(cutLine.channel, 40) ?? "",
596
+ operation: short(cutLine.operation, 64) ?? "",
597
+ account: {
598
+ id: short(cutLine.account.id, 64) ?? "",
599
+ alias: short(cutLine.account.alias, 64) ?? ""
600
+ },
601
+ session: {
602
+ ...session,
603
+ folder: cutLine.session.folder === null ? null : short(cutLine.session.folder, 256) ?? null,
604
+ ...session.client === void 0 ? {} : { client: {
605
+ name: short(session.client.name, 40) ?? "",
606
+ ...session.client.version === void 0 ? {} : { version: short(session.client.version, 20) }
607
+ } },
608
+ ...session.parent === void 0 ? {} : { parent: {
609
+ ...session.parent,
610
+ name: short(session.parent.name, 40) ?? ""
611
+ } }
612
+ },
613
+ ...cutLine.reason === void 0 ? {} : { reason: short(cutLine.reason, 100) },
614
+ ...cutLine.query === void 0 ? {} : { query: short(cutLine.query, 100) },
615
+ ...cutLine.told === void 0 ? {} : { told: {
616
+ rule: short(cutLine.told.rule, 60) ?? "",
617
+ target: short(cutLine.told.target, 60) ?? ""
618
+ } }
619
+ };
620
+ if (fits()) return cutLine;
621
+ const { reason: _reason, query: _query, told: _told, counts: _counts, ...rest } = cutLine;
622
+ return {
623
+ ...rest,
624
+ truncated: true
625
+ };
626
+ }
627
+ /** The line every field of it bounded, before the line as a whole is (`fitted`). */
628
+ function boundedLine(input, now) {
629
+ const raw = input;
630
+ const at = typeof raw.at === "string" && ISO_TIME.test(raw.at) ? raw.at : now.toISOString();
631
+ const account = own$3(raw.account);
632
+ const items = Array.isArray(raw.items) ? raw.items : void 0;
633
+ const kept = items?.slice(0, 50).flatMap((item) => itemOf(item) ?? []);
634
+ const given = whole(raw.count);
635
+ const count = items === void 0 ? given : Math.max(items.length, given ?? 0);
636
+ const counts = own$3(raw.counts);
637
+ const keptCounts = counts === void 0 ? void 0 : Object.fromEntries(Object.entries(counts).filter(([name, value]) => /^[a-z][A-Za-z]{0,31}$/.test(name) && whole(value) !== void 0).slice(0, MAX_COUNTS).map(([name, value]) => [name, whole(value)]));
638
+ const recipients = Array.isArray(raw.recipients) ? raw.recipients.slice(0, MAX_RECIPIENTS).flatMap((address) => typeof address === "string" ? [cut(address, BOUNDS.address)] : []) : void 0;
639
+ const told = own$3(raw.told);
640
+ const reason = cut(raw.reason, BOUNDS.reason);
641
+ const query = cut(raw.query, BOUNDS.query);
642
+ const approvalId = typeof raw.approvalId === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(raw.approvalId) ? raw.approvalId : void 0;
643
+ return {
644
+ at,
645
+ channel: cut(raw.channel, BOUNDS.word) ?? "",
646
+ account: {
647
+ id: cut(account?.id, BOUNDS.alias) ?? "",
648
+ alias: cut(account?.alias, BOUNDS.alias) ?? ""
649
+ },
650
+ operation: cut(raw.operation, BOUNDS.word) ?? "",
651
+ kind: ACTIVITY_KINDS.includes(raw.kind) ? raw.kind : "read",
652
+ outcome: ACTIVITY_OUTCOMES.includes(raw.outcome) ? raw.outcome : "ok",
653
+ ...reason ? { reason } : {},
654
+ session: sessionOf(raw.session),
655
+ ms: whole(raw.ms) ?? 0,
656
+ ...kept === void 0 ? {} : { items: kept },
657
+ ...count === void 0 ? {} : { count },
658
+ ...keptCounts === void 0 || Object.keys(keptCounts).length === 0 ? {} : { counts: keptCounts },
659
+ ...query === void 0 ? {} : { query },
660
+ ...recipients === void 0 ? {} : { recipients },
661
+ ...approvalId === void 0 ? {} : { approvalId },
662
+ ...told === void 0 ? {} : { told: {
663
+ rule: cut(told.rule, BOUNDS.name) ?? "",
664
+ target: cut(told.target, BOUNDS.name) ?? ""
665
+ } },
666
+ ...raw.truncated === true ? { truncated: true } : {}
667
+ };
668
+ }
669
+ /** A line read back from a file: one this release can show, or null for anything else. */
670
+ function readLine(text) {
671
+ let raw;
672
+ try {
673
+ raw = JSON.parse(text);
674
+ } catch {
675
+ return null;
676
+ }
677
+ const line = own$3(raw);
678
+ if (line === void 0 || typeof line.at !== "string" || !ISO_TIME.test(line.at)) return null;
679
+ const session = own$3(line.session);
680
+ if (typeof session?.key !== "string" || !SESSION_KEY.test(session.key)) return null;
681
+ return activityLineOf(line);
682
+ }
683
+ function emptyKinds() {
684
+ return {
685
+ read: 0,
686
+ write: 0,
687
+ told: 0,
688
+ approval: 0
689
+ };
690
+ }
691
+ function emptyOutcomes() {
692
+ return {
693
+ ok: 0,
694
+ refused: 0,
695
+ failed: 0
696
+ };
697
+ }
698
+ function aggregateOf(line) {
699
+ const kind = emptyKinds();
700
+ kind[line.kind] += 1;
701
+ const outcome = emptyOutcomes();
702
+ outcome[line.outcome] += 1;
703
+ return {
704
+ key: line.session.key,
705
+ grouped: line.session.grouped,
706
+ surfaces: [line.session.surface],
707
+ primary: {
708
+ at: line.at,
709
+ cli: line.session.surface === "cli",
710
+ folder: line.session.folder,
711
+ client: line.session.client ?? null,
712
+ parent: line.session.parent ?? null
713
+ },
714
+ firstSeen: line.at,
715
+ lastSeen: line.at,
716
+ processes: [line.session.process],
717
+ channels: line.channel ? [line.channel] : [],
718
+ accounts: line.account.alias ? [line.account.alias] : [],
719
+ kind,
720
+ outcome
721
+ };
722
+ }
723
+ function union(a, b) {
724
+ return [.../* @__PURE__ */ new Set([...a, ...b])];
725
+ }
726
+ /** Two aggregates of one session, added together. */
727
+ function merge$1(a, b) {
728
+ const first = (x, y) => x.cli !== y.cli ? x.cli ? y : x : x.at <= y.at ? x : y;
729
+ const primary = first(a.primary, b.primary);
730
+ const other = primary === a.primary ? b.primary : a.primary;
731
+ const kind = emptyKinds();
732
+ const outcome = emptyOutcomes();
733
+ for (const name of ACTIVITY_KINDS) kind[name] = a.kind[name] + b.kind[name];
734
+ for (const name of ACTIVITY_OUTCOMES) outcome[name] = a.outcome[name] + b.outcome[name];
735
+ return {
736
+ key: a.key,
737
+ grouped: a.grouped || b.grouped,
738
+ surfaces: union(a.surfaces, b.surfaces),
739
+ primary: {
740
+ ...primary,
741
+ client: primary.client ?? other.client,
742
+ parent: primary.parent ?? other.parent
743
+ },
744
+ firstSeen: a.firstSeen <= b.firstSeen ? a.firstSeen : b.firstSeen,
745
+ lastSeen: a.lastSeen >= b.lastSeen ? a.lastSeen : b.lastSeen,
746
+ processes: union(a.processes, b.processes),
747
+ channels: union(a.channels, b.channels),
748
+ accounts: union(a.accounts, b.accounts),
749
+ kind,
750
+ outcome
751
+ };
752
+ }
753
+ function add(groups, aggregate) {
754
+ const held = groups.get(aggregate.key);
755
+ groups.set(aggregate.key, held === void 0 ? aggregate : merge$1(held, aggregate));
756
+ }
757
+ function summaryOf$1(aggregate) {
758
+ return {
759
+ key: aggregate.key,
760
+ grouped: aggregate.grouped,
761
+ surfaces: [...aggregate.surfaces].sort(),
762
+ folder: aggregate.primary.folder,
763
+ client: aggregate.primary.client,
764
+ parent: aggregate.primary.parent,
765
+ firstSeen: aggregate.firstSeen,
766
+ lastSeen: aggregate.lastSeen,
767
+ processes: aggregate.processes.length,
768
+ channels: [...aggregate.channels].sort(),
769
+ accounts: [...aggregate.accounts].sort(),
770
+ counts: {
771
+ kind: { ...aggregate.kind },
772
+ outcome: { ...aggregate.outcome }
773
+ }
774
+ };
775
+ }
776
+ /** A day's aggregates read back from its index: trusted only as far as their shape, and dropped whole otherwise. */
777
+ function aggregatesOf(raw) {
778
+ if (!Array.isArray(raw)) return null;
779
+ const strings = (value) => Array.isArray(value) && value.every((each) => typeof each === "string");
780
+ for (const each of raw) {
781
+ const held = own$3(each);
782
+ if (held === void 0 || typeof held.key !== "string" || !SESSION_KEY.test(held.key) || typeof held.firstSeen !== "string" || typeof held.lastSeen !== "string" || own$3(held.primary) === void 0 || own$3(held.kind) === void 0 || own$3(held.outcome) === void 0 || !strings(held.processes) || !strings(held.channels) || !strings(held.accounts) || !strings(held.surfaces)) return null;
783
+ }
784
+ return raw;
785
+ }
786
+ const FAILURE_FILE = "activity-failure.json";
787
+ const INDEX_DIR = ".index";
788
+ /**
789
+ * Each day's server windows: one small line for each server process's window on that day — its key, parent and
790
+ * folder — so a CLI call finds the window it runs in without reading the day (`sessionOfAncestor`).
791
+ */
792
+ const PARENTS_DIR = ".parents";
793
+ const DEFAULT_SHOW_LIMIT = 1e3;
794
+ const ON_BY_DEFAULT = Object.freeze({
795
+ record: "on",
796
+ retentionDays: 30,
797
+ problems: []
798
+ });
799
+ const UNREADABLE = Object.freeze({
800
+ record: "off",
801
+ retentionDays: 365,
802
+ problems: ["the configuration could not be read, so nothing is recorded"]
803
+ });
804
+ /** The once-a-process line, when the stderr default is used: one process, one line, however many logs it opens. */
805
+ let warnedOnStderr = false;
806
+ function codeOf$1(error) {
807
+ if (error instanceof CommsError) return error.code;
808
+ const code = error?.code;
809
+ return typeof code === "string" ? code : "error";
810
+ }
811
+ function messageOf$1(error) {
812
+ return cut(error instanceof Error ? error.message : String(error), BOUNDS.reason) ?? "unknown";
813
+ }
814
+ var ActivityLog = class {
815
+ directory;
816
+ #failureFile;
817
+ #now;
818
+ #settings;
819
+ #warn;
820
+ #failure = null;
821
+ #failureWrittenAt = 0;
822
+ #warned = false;
823
+ #prunedDay = null;
824
+ /** The days this process has made sure of a parents file for, and the windows it has noted there, by day. */
825
+ #parentsReady = /* @__PURE__ */ new Set();
826
+ #parentsNoted = /* @__PURE__ */ new Set();
827
+ constructor(stateDir, options = {}) {
828
+ this.directory = join(stateDir, "activity");
829
+ this.#failureFile = join(stateDir, FAILURE_FILE);
830
+ this.#now = options.now ?? (() => /* @__PURE__ */ new Date());
831
+ this.#settings = options.settings ?? (async () => ON_BY_DEFAULT);
832
+ this.#warn = options.warn;
833
+ }
834
+ /** The last line this process could not write, or null. */
835
+ get lastFailure() {
836
+ return this.#failure;
837
+ }
838
+ /**
839
+ * Appends one line, unless recording is off. True when it was written. Never throws: a line that cannot be written
840
+ * is said once per process (`warn`, stderr by default), recorded beside the log for `doctor`, and returns false.
841
+ *
842
+ * The first call a process makes on each day — this, or any read — first prunes days older than the retention in
843
+ * force, whether or not recording is on: the log's daily maintenance, best effort.
844
+ */
845
+ async append(input) {
846
+ if ((await this.#maintained()).record !== "on") return false;
847
+ try {
848
+ const line = activityLineOf(input, this.#now());
849
+ await appendPrivateLine(join(this.directory, `${line.at.slice(0, 10)}.jsonl`), JSON.stringify(line));
850
+ await this.#noteParent(line);
851
+ if (this.#failure !== null) {
852
+ this.#failure = null;
853
+ await rm(this.#failureFile, { force: true }).catch(() => void 0);
854
+ }
855
+ } catch (error) {
856
+ await this.#failed(error);
857
+ return false;
858
+ }
859
+ return true;
860
+ }
861
+ async #failed(error) {
862
+ const now = this.#now();
863
+ const failure = {
864
+ at: now.toISOString(),
865
+ code: codeOf$1(error),
866
+ message: messageOf$1(error),
867
+ pid: process.pid
868
+ };
869
+ this.#failure = failure;
870
+ const said = `agent-communications: activity is not being recorded: ${failure.message}`;
871
+ if (this.#warn !== void 0) {
872
+ if (!this.#warned) this.#warn(said);
873
+ } else if (!warnedOnStderr) {
874
+ warnedOnStderr = true;
875
+ process.stderr.write(`${said}\n`);
876
+ }
877
+ this.#warned = true;
878
+ if (now.getTime() - this.#failureWrittenAt < 6e4) return;
879
+ this.#failureWrittenAt = now.getTime();
880
+ await writeFileAtomic(this.#failureFile, `${JSON.stringify(failure)}\n`).catch(() => void 0);
881
+ }
882
+ /**
883
+ * The day's parents file, made on a process's first line of the day — so a reader can tell a day with no window
884
+ * from one an earlier build wrote, with no index — and a grouped server's window noted in it once a day. Best
885
+ * effort: without it a CLI call reads the day itself.
886
+ */
887
+ async #noteParent(line) {
888
+ const day = line.at.slice(0, 10);
889
+ const path = join(this.directory, PARENTS_DIR, `${day}.jsonl`);
890
+ try {
891
+ if (!this.#parentsReady.has(day)) {
892
+ await ensurePrivateDir(join(this.directory, PARENTS_DIR));
893
+ await (await open(path, "a", 384)).close();
894
+ this.#parentsReady.add(day);
895
+ }
896
+ const { session } = line;
897
+ if (session.surface !== "mcp" || !session.grouped || session.parent === void 0) return;
898
+ const noted = `${day}\0${session.key}`;
899
+ if (this.#parentsNoted.has(noted)) return;
900
+ await appendPrivateLine(path, JSON.stringify({
901
+ key: session.key,
902
+ parent: session.parent,
903
+ folder: session.folder
904
+ }));
905
+ this.#parentsNoted.add(noted);
906
+ } catch {}
907
+ }
908
+ /** The windows noted on `day`, or null when the day has no parents file (an earlier build wrote it, or none). */
909
+ async #parentsOf(day) {
910
+ let text;
911
+ try {
912
+ text = await readFile(join(this.directory, PARENTS_DIR, `${day}.jsonl`), "utf8");
913
+ } catch {
914
+ return null;
915
+ }
916
+ return text.split("\n").flatMap((raw) => {
917
+ if (!raw) return [];
918
+ try {
919
+ const held = own$3(JSON.parse(raw));
920
+ const parent = parentOf(held?.parent);
921
+ const key = held?.key;
922
+ if (typeof key !== "string" || !SESSION_KEY.test(key) || parent === void 0) return [];
923
+ return [{
924
+ key,
925
+ parent,
926
+ folder: typeof held?.folder === "string" ? held.folder : null
927
+ }];
928
+ } catch {
929
+ return [];
930
+ }
931
+ });
932
+ }
933
+ /** The settings in force, read afresh; a reader that throws reads as `UNREADABLE` — off, and every day kept. */
934
+ async #settingsNow() {
935
+ try {
936
+ return await this.#settings();
937
+ } catch {
938
+ return UNREADABLE;
939
+ }
940
+ }
941
+ /**
942
+ * The settings in force, after the day's maintenance: the first call a process makes on a day — an append or a read,
943
+ * recording on or off — deletes the days past the retention. Never throws.
944
+ */
945
+ async #maintained() {
946
+ const settings = await this.#settingsNow();
947
+ const today = isoDay(this.#now().getTime());
948
+ if (this.#prunedDay !== today) {
949
+ this.#prunedDay = today;
950
+ await this.prune(settings.retentionDays).catch(() => void 0);
951
+ }
952
+ return settings;
953
+ }
954
+ /** The first day the retention in force keeps: any day before it is never shown, deleted yet or not. */
955
+ #cutoff(retentionDays) {
956
+ return isoDay(this.#now().getTime() - retentionDays * DAY_MS$1);
957
+ }
958
+ /** The days held, oldest first. */
959
+ async days() {
960
+ let names;
961
+ try {
962
+ names = await readdir(this.directory);
963
+ } catch (error) {
964
+ const code = error.code;
965
+ if (code === "ENOENT" || code === "ENOTDIR") return [];
966
+ throw error;
967
+ }
968
+ return names.flatMap((name) => DAY_FILE.exec(name)?.[1] ?? []).sort();
969
+ }
970
+ /**
971
+ * Deletes every day whose last line is older than `retentionDays` days: a day goes once all of it is that old, so
972
+ * every line is kept at least that long and at most a day longer. Whole days only; every other day is left whole.
973
+ */
974
+ async prune(retentionDays) {
975
+ if (!isActivityRetention(retentionDays)) throw new CommsError("USAGE", `the activity log keeps a whole number of days, 1 to 365, not ${String(retentionDays)}`);
976
+ const cutoff = this.#cutoff(retentionDays);
977
+ const deleted = [];
978
+ for (const day of await this.days()) {
979
+ if (day >= cutoff) continue;
980
+ await rm(join(this.directory, `${day}.jsonl`), { force: true });
981
+ await rm(join(this.directory, INDEX_DIR, `${day}.json`), { force: true }).catch(() => void 0);
982
+ await rm(join(this.directory, PARENTS_DIR, `${day}.jsonl`), { force: true }).catch(() => void 0);
983
+ deleted.push(day);
984
+ }
985
+ return { deleted };
986
+ }
987
+ async #lines(day) {
988
+ const text = await this.#text(day);
989
+ return text === null ? [] : text.split("\n").flatMap((raw) => raw ? readLine(raw) ?? [] : []);
990
+ }
991
+ async #text(day) {
992
+ try {
993
+ return await readFile(join(this.directory, `${day}.jsonl`), "utf8");
994
+ } catch (error) {
995
+ if (error.code === "ENOENT") return null;
996
+ throw error;
997
+ }
998
+ }
999
+ /**
1000
+ * A whole past day's sessions, from its index when the index was made from the file as it is now, else from the
1001
+ * file — and the index written again, best effort. Today's is never indexed: it is still being written.
1002
+ */
1003
+ async #dayAggregates(day) {
1004
+ const file = join(this.directory, `${day}.jsonl`);
1005
+ const index = join(this.directory, INDEX_DIR, `${day}.json`);
1006
+ let mark;
1007
+ try {
1008
+ const info = await stat(file);
1009
+ mark = `${info.size}:${info.mtimeMs}`;
1010
+ } catch {
1011
+ return [];
1012
+ }
1013
+ try {
1014
+ const held = JSON.parse(await readFile(index, "utf8"));
1015
+ const sessions = held.version === 1 && held.mark === mark ? aggregatesOf(held.sessions) : null;
1016
+ if (sessions !== null) return sessions;
1017
+ } catch {}
1018
+ const groups = /* @__PURE__ */ new Map();
1019
+ for (const line of await this.#lines(day)) add(groups, aggregateOf(line));
1020
+ const sessions = [...groups.values()];
1021
+ await writeFileAtomic(index, `${JSON.stringify({
1022
+ version: 1,
1023
+ mark,
1024
+ sessions
1025
+ })}\n`).catch(() => void 0);
1026
+ return sessions;
1027
+ }
1028
+ /**
1029
+ * Every session with a line at or after `since` (every day the retention keeps, when left out), newest first. A day
1030
+ * past the retention is never read, whether or not it has been deleted yet.
1031
+ */
1032
+ async sessions(options = {}) {
1033
+ const since = options.since;
1034
+ const today = isoDay(this.#now().getTime());
1035
+ const cutoff = this.#cutoff((await this.#maintained()).retentionDays);
1036
+ const groups = /* @__PURE__ */ new Map();
1037
+ for (const day of await this.days()) {
1038
+ if (day < cutoff) continue;
1039
+ if (since !== void 0 && `${day}T23:59:59.999Z` < since) continue;
1040
+ if ((since === void 0 || since <= `${day}T00:00:00.000Z`) && day < today) {
1041
+ for (const aggregate of await this.#dayAggregates(day)) add(groups, aggregate);
1042
+ continue;
1043
+ }
1044
+ for (const line of await this.#lines(day)) if (since === void 0 || line.at >= since) add(groups, aggregateOf(line));
1045
+ }
1046
+ return [...groups.values()].map(summaryOf$1).sort((a, b) => a.lastSeen === b.lastSeen ? a.key.localeCompare(b.key) : a.lastSeen < b.lastSeen ? 1 : -1);
1047
+ }
1048
+ /**
1049
+ * One session's lines, newest first, that match every filter given; and its summary over the time asked. Only
1050
+ * lines whose session key is exactly `key` are ever read into the answer, and none of a day past the retention.
1051
+ */
1052
+ async show(key, filters = {}) {
1053
+ if (!SESSION_KEY.test(key)) throw new CommsError("USAGE", `"${cut(key, 40)}" is not a session key`);
1054
+ const cutoff = this.#cutoff((await this.#maintained()).retentionDays);
1055
+ const limit = filters.limit ?? DEFAULT_SHOW_LIMIT;
1056
+ const since = filters.since;
1057
+ const needle = `"key":"${key}"`;
1058
+ const lines = [];
1059
+ let matched = 0;
1060
+ let summary = null;
1061
+ const days = (await this.days()).filter((day) => day >= cutoff).reverse();
1062
+ for (const day of days) {
1063
+ if (since !== void 0 && `${day}T23:59:59.999Z` < since) break;
1064
+ const text = await this.#text(day);
1065
+ if (text === null) continue;
1066
+ const raws = text.split("\n");
1067
+ for (let index = raws.length - 1; index >= 0; index -= 1) {
1068
+ const raw = raws[index];
1069
+ if (!raw.includes(needle)) continue;
1070
+ const line = readLine(raw);
1071
+ if (line === null || line.session.key !== key) continue;
1072
+ if (since !== void 0 && line.at < since) continue;
1073
+ const aggregate = aggregateOf(line);
1074
+ summary = summary === null ? aggregate : merge$1(summary, aggregate);
1075
+ if (!matches(line, filters)) continue;
1076
+ lines.push(line);
1077
+ matched += 1;
1078
+ if (lines.length >= limit * 2) {
1079
+ lines.sort(newestFirst);
1080
+ lines.length = limit;
1081
+ }
1082
+ }
1083
+ }
1084
+ lines.sort(newestFirst);
1085
+ if (lines.length > limit) lines.length = limit;
1086
+ return {
1087
+ lines,
1088
+ more: matched > limit,
1089
+ summary: summary === null ? null : summaryOf$1(summary)
1090
+ };
1091
+ }
1092
+ /**
1093
+ * The window a CLI call belongs to (S2): the session of a server process, today or yesterday, whose parent is one of
1094
+ * `ancestors` — the nearest first. Null when none is. Read from each day's parents file; a day with none — written
1095
+ * by an earlier build — is read whole, as before.
1096
+ */
1097
+ async sessionOfAncestor(ancestors) {
1098
+ const now = this.#now().getTime();
1099
+ const noted = [];
1100
+ const texts = [];
1101
+ for (const day of [isoDay(now), isoDay(now - DAY_MS$1)]) {
1102
+ const parents = await this.#parentsOf(day);
1103
+ if (parents !== null) {
1104
+ noted.push(...parents);
1105
+ continue;
1106
+ }
1107
+ const text = await this.#text(day).catch(() => null);
1108
+ if (text !== null) texts.push(text);
1109
+ }
1110
+ for (const ancestor of ancestors) {
1111
+ const found = noted.find((each) => each.parent.pid === ancestor.pid && each.parent.startedAt === ancestor.startedAt);
1112
+ if (found !== void 0) return found;
1113
+ const needle = `"parent":{"pid":${ancestor.pid},"startedAt":${JSON.stringify(ancestor.startedAt)}`;
1114
+ for (const text of texts) {
1115
+ let from = text.indexOf(needle);
1116
+ while (from !== -1) {
1117
+ const start = text.lastIndexOf("\n", from) + 1;
1118
+ const end = text.indexOf("\n", from);
1119
+ const line = readLine(text.slice(start, end === -1 ? text.length : end));
1120
+ const parent = line?.session.parent;
1121
+ if (line?.session.grouped && line.session.surface === "mcp" && parent?.pid === ancestor.pid && parent.startedAt === ancestor.startedAt) return {
1122
+ key: line.session.key,
1123
+ parent,
1124
+ folder: line.session.folder
1125
+ };
1126
+ from = text.indexOf(needle, from + needle.length);
1127
+ }
1128
+ }
1129
+ }
1130
+ return null;
1131
+ }
1132
+ /** The last failure recorded by any process — the newer of the file beside the log and this process's own. */
1133
+ async readFailure() {
1134
+ let stored = null;
1135
+ try {
1136
+ const raw = own$3(JSON.parse(await readFile(this.#failureFile, "utf8")));
1137
+ if (raw !== void 0 && typeof raw.at === "string" && ISO_TIME.test(raw.at)) stored = {
1138
+ at: raw.at,
1139
+ code: cut(raw.code, BOUNDS.word) ?? "error",
1140
+ message: cut(raw.message, BOUNDS.reason) ?? "unknown",
1141
+ pid: whole(raw.pid) ?? 0
1142
+ };
1143
+ } catch {}
1144
+ const mine = this.#failure;
1145
+ if (mine === null) return stored;
1146
+ if (stored === null) return mine;
1147
+ return mine.at >= stored.at ? mine : stored;
1148
+ }
1149
+ /**
1150
+ * What `doctor` shows of the log. It creates nothing; like every read it first deletes the days past the retention,
1151
+ * once a day per process, so the days it lists past the retention are those that could not be deleted.
1152
+ */
1153
+ async health() {
1154
+ await this.#maintained();
1155
+ const today = isoDay(this.#now().getTime());
1156
+ let exists = false;
1157
+ let isDirectory = true;
1158
+ try {
1159
+ const info = await stat(this.directory);
1160
+ exists = true;
1161
+ isDirectory = info.isDirectory();
1162
+ } catch {}
1163
+ const days = exists && isDirectory ? await this.days().catch(() => []) : [];
1164
+ let lastWritten = null;
1165
+ const newest = days.at(-1);
1166
+ if (newest !== void 0) lastWritten = await stat(join(this.directory, `${newest}.jsonl`)).then((info) => info.mtime.toISOString(), () => null);
1167
+ const state = join(this.directory, "..");
1168
+ const target = days.includes(today) ? join(this.directory, `${today}.jsonl`) : exists ? this.directory : existsSync(state) ? state : null;
1169
+ const writable = isDirectory && (target === null || await access(target, constants$1.W_OK).then(() => true, () => false));
1170
+ const loose = exists && isDirectory ? await isGroupOrWorldAccessible(this.directory).catch(() => false) : false;
1171
+ return {
1172
+ directory: this.directory,
1173
+ exists,
1174
+ isDirectory,
1175
+ private: !exists || !isDirectory || process.platform === "win32" ? null : !loose,
1176
+ writable,
1177
+ days,
1178
+ lastWritten,
1179
+ failure: await this.readFailure()
1180
+ };
1181
+ }
1182
+ };
1183
+ /** Newest first; lines of one time in the order they were met. */
1184
+ function newestFirst(a, b) {
1185
+ return a.at === b.at ? 0 : a.at < b.at ? 1 : -1;
1186
+ }
1187
+ function matches(line, filters) {
1188
+ if (filters.kind !== void 0 && line.kind !== filters.kind) return false;
1189
+ if (filters.outcome !== void 0 && line.outcome !== filters.outcome) return false;
1190
+ if (filters.channel !== void 0 && line.channel !== filters.channel) return false;
1191
+ if (filters.account !== void 0 && line.account.alias !== filters.account && line.account.id !== filters.account) return false;
1192
+ return true;
1193
+ }
1194
+ /** `ps -o ppid=,lstart=,comm=` in the C locale: the parent, the start as five words, and the executable — last. */
1195
+ const PS_LINE = /^\s*(\d+)\s+([A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d{1,2}\s+\d{1,2}:\d{2}:\d{2}\s+\d{4})\s+(.+?)\s*$/;
1196
+ /** One process's line of `ps -o ppid=,lstart=,comm=`, or null for anything else. The name is the executable's own. */
1197
+ function parsePsLine(text) {
1198
+ const first = text.split("\n").find((row) => row.trim() !== "");
1199
+ const match = first === void 0 ? null : PS_LINE.exec(first);
1200
+ if (match === null) return null;
1201
+ const command = match[3];
1202
+ const name = command.includes("/") ? command.split("/").at(-1) ?? command : command;
1203
+ return {
1204
+ ppid: Number(match[1]),
1205
+ startedAt: match[2].replace(/\s+/g, " "),
1206
+ name
1207
+ };
1208
+ }
1209
+ const PS_PROGRAMS = ["/bin/ps", "/usr/bin/ps"];
1210
+ /**
1211
+ * This machine's `ps`, by its full path, in the C locale so the start reads the same in every process, bounded by
1212
+ * `timeoutMs`. Never on Windows: no query there answers inside the bound (PowerShell alone takes longer to start),
1213
+ * so a Windows process is ungrouped.
1214
+ */
1215
+ const psProbe = (pid, timeoutMs) => new Promise((resolve) => {
1216
+ const program = PS_PROGRAMS.find((path) => existsSync(path));
1217
+ if (process.platform === "win32" || program === void 0) {
1218
+ resolve(null);
1219
+ return;
1220
+ }
1221
+ try {
1222
+ execFile(program, [
1223
+ "-o",
1224
+ "ppid=,lstart=,comm=",
1225
+ "-p",
1226
+ String(pid)
1227
+ ], {
1228
+ timeout: Math.max(1, Math.round(timeoutMs)),
1229
+ env: {
1230
+ LC_ALL: "C",
1231
+ LANG: "C",
1232
+ PATH: "/bin:/usr/bin"
1233
+ },
1234
+ maxBuffer: 65536,
1235
+ windowsHide: true
1236
+ }, (error, stdout) => resolve(error ? null : parsePsLine(String(stdout)))).on("error", () => resolve(null));
1237
+ } catch {
1238
+ resolve(null);
1239
+ }
1240
+ });
1241
+ function randomKey() {
1242
+ return randomBytes(8).toString("hex");
1243
+ }
1244
+ /**
1245
+ * What a process has worked out of its session: its random ids, its background reads of its parent, and a CLI call's
1246
+ * identity. One per process (`sessionIdentity` keeps its own); a test makes its own.
1247
+ */
1248
+ var SessionIdentityCache = class {
1249
+ /** A random id this process made when it started (§4.3). */
1250
+ processId = randomBytes(6).toString("hex");
1251
+ /** The key of this process's own session, when it is not grouped with others. */
1252
+ ownKey = randomKey();
1253
+ };
1254
+ /** Each cache's background reads, and its CLI identity by log: held here, so the class shows only its ids. */
1255
+ const READS$1 = /* @__PURE__ */ new WeakMap();
1256
+ const CLI_IDENTITIES = /* @__PURE__ */ new WeakMap();
1257
+ function heldIn(map, cache) {
1258
+ let held = map.get(cache);
1259
+ if (held === void 0) {
1260
+ held = /* @__PURE__ */ new Map();
1261
+ map.set(cache, held);
1262
+ }
1263
+ return held;
1264
+ }
1265
+ const PROCESS_CACHE = new SessionIdentityCache();
1266
+ /** This process's own random id: a line handed in without one names this process. */
1267
+ const PROCESS_ID = PROCESS_CACHE.processId;
1268
+ /** `promise`, or null after `ms`. Its timer is not unref'd: whoever awaits it keeps the process alive till then. */
1269
+ function within$2(promise, ms) {
1270
+ return new Promise((resolve) => {
1271
+ const timer = setTimeout(() => resolve(null), Math.max(0, ms));
1272
+ promise.then((value) => {
1273
+ clearTimeout(timer);
1274
+ resolve(value);
1275
+ }, () => {
1276
+ clearTimeout(timer);
1277
+ resolve(null);
1278
+ });
1279
+ });
1280
+ }
1281
+ function folderOf(options) {
1282
+ try {
1283
+ return (options.cwd ?? (() => process.cwd()))();
1284
+ } catch {
1285
+ return null;
1286
+ }
1287
+ }
1288
+ async function readAncestry(options, depth, initIsParent, budgetMs) {
1289
+ const platform = options.platform ?? process.platform;
1290
+ const pid = options.pid ?? process.pid;
1291
+ const folder = folderOf(options);
1292
+ const chain = [];
1293
+ if (platform === "win32") return {
1294
+ pid,
1295
+ folder,
1296
+ chain,
1297
+ complete: true
1298
+ };
1299
+ const probe = options.probe ?? psProbe;
1300
+ const deadline = Date.now() + budgetMs;
1301
+ let next = options.ppid ?? process.ppid;
1302
+ while (chain.length < depth && (next > 1 || initIsParent && next === 1 && chain.length === 0)) {
1303
+ const left = deadline - Date.now();
1304
+ if (left <= 0) return {
1305
+ pid,
1306
+ folder,
1307
+ chain,
1308
+ complete: false
1309
+ };
1310
+ const facts = await within$2(probe(next, left), left);
1311
+ if (facts === null) return {
1312
+ pid,
1313
+ folder,
1314
+ chain,
1315
+ complete: false
1316
+ };
1317
+ chain.push({
1318
+ pid: next,
1319
+ startedAt: facts.startedAt,
1320
+ name: facts.name,
1321
+ ppid: facts.ppid
1322
+ });
1323
+ if (next === 1) break;
1324
+ next = facts.ppid;
1325
+ }
1326
+ return {
1327
+ pid,
1328
+ folder,
1329
+ chain,
1330
+ complete: true
1331
+ };
1332
+ }
1333
+ /**
1334
+ * The background read of this process's parent, begun if none is under way or kept: a read that finished is kept for
1335
+ * good, one under way is waited on, and one that ran out of time is begun again once a minute has passed.
1336
+ */
1337
+ function ancestryRead(cache, options, initIsParent) {
1338
+ const id = initIsParent ? "daemon" : "server";
1339
+ const now = (options.now ?? Date.now)();
1340
+ const reads = heldIn(READS$1, cache);
1341
+ const held = reads.get(id);
1342
+ if (held !== void 0 && (held.settled === null || held.settled.complete || now - held.at < 6e4)) return held;
1343
+ const read = {
1344
+ at: now,
1345
+ settled: null,
1346
+ promise: readAncestry(options, 1, initIsParent, options.budgetMs ?? 2e3)
1347
+ };
1348
+ read.promise.then((ancestry) => {
1349
+ read.settled = ancestry;
1350
+ }, () => void 0);
1351
+ reads.set(id, read);
1352
+ return read;
1353
+ }
1354
+ function windowKey(parent, folder, connection) {
1355
+ const named = connection === void 0 || connection === "" ? "" : `\0${connection}`;
1356
+ return createHash("sha256").update(`${parent.pid}\0${parent.startedAt}\0${folder}${named}`).digest("hex").slice(0, 16);
1357
+ }
1358
+ /** System folders, each with everything under it: no project is started there. */
1359
+ const POSIX_SYSTEM_TREES = [
1360
+ "/System",
1361
+ "/Library",
1362
+ "/Applications",
1363
+ "/usr",
1364
+ "/bin",
1365
+ "/sbin",
1366
+ "/etc",
1367
+ "/dev",
1368
+ "/proc",
1369
+ "/sys",
1370
+ "/boot"
1371
+ ];
1372
+ /** Folders that hold everyone's projects, or anyone's temporary files — only the folder itself, not what is in it. */
1373
+ const POSIX_SHARED = [
1374
+ "/tmp",
1375
+ "/var",
1376
+ "/private",
1377
+ "/private/tmp",
1378
+ "/private/var",
1379
+ "/opt",
1380
+ "/Volumes",
1381
+ "/mnt",
1382
+ "/media"
1383
+ ];
1384
+ const WINDOWS_SYSTEM_TREES = [
1385
+ "\\windows",
1386
+ "\\program files",
1387
+ "\\program files (x86)",
1388
+ "\\programdata"
1389
+ ];
1390
+ /**
1391
+ * Whether a folder is one that cannot tell projects apart (S3): a filesystem's root, the home folder or a folder above
1392
+ * it, a system folder or anything in one, or a shared one such as `/tmp`. A client that starts every server from one
1393
+ * process there — a desktop app started in `/`, say — would otherwise make every project it serves one session.
1394
+ */
1395
+ function sharedFolder(folder, home, platform = process.platform) {
1396
+ if (platform === "win32") {
1397
+ const plain = (path) => path.replace(/\//g, "\\").replace(/\\+$/, "").toLowerCase();
1398
+ const at = plain(folder);
1399
+ const homeAt = plain(home);
1400
+ if (/^[a-z]:$/.test(at) || at === "") return true;
1401
+ if (homeAt !== "" && (at === homeAt || homeAt.startsWith(`${at}\\`))) return true;
1402
+ const rest = at.replace(/^[a-z]:/, "");
1403
+ return rest === "\\users" || WINDOWS_SYSTEM_TREES.some((tree) => rest === tree || rest.startsWith(`${tree}\\`));
1404
+ }
1405
+ const plain = (path) => (path.length > 1 ? path.replace(/\/+$/, "") : path) || "/";
1406
+ const at = plain(folder);
1407
+ const homeAt = plain(home);
1408
+ if (at === "/") return true;
1409
+ if (homeAt !== "/" && (at === homeAt || homeAt.startsWith(`${at}/`))) return true;
1410
+ if (POSIX_SHARED.includes(at)) return true;
1411
+ return POSIX_SYSTEM_TREES.some((tree) => at === tree || at.startsWith(`${tree}/`));
1412
+ }
1413
+ /**
1414
+ * Which session this process's calls belong to (S2): its id, its parent's id, start and name, its folder and the
1415
+ * session key. Never throws.
1416
+ *
1417
+ * - **An MCP server** (`mcp`) is grouped with every other server its parent started in the same folder: one agent
1418
+ * window. Its parent is read in the background — begun by `warmSessionIdentity` as the server starts, or by its first
1419
+ * call — for up to two seconds; a call waits for it about 200 ms and, when it is not done, goes on alone for now. A
1420
+ * read that ran out of time is not kept: one a minute or more later tries again. A parent that has gone, a folder
1421
+ * that cannot be read, and a folder that cannot tell projects apart (`sharedFolder`: `/`, the home folder, a system
1422
+ * folder) leave it ungrouped, with a key of its own.
1423
+ * - **A CLI call** (`cli`) reads up to three processes above it, within about 200 ms. When one of them is the parent
1424
+ * of a server process that recorded a line today (or yesterday) in `options.log`, the call joins that window's
1425
+ * session; otherwise it is a session of its own. Its `ancestors` name those processes either way.
1426
+ * - **The event service** (`daemon`) is grouped by its parent too, process 1 included, whatever its folder — it serves
1427
+ * no project — so it is one session since it started. It waits for the whole read: it records on a timer, and keeps
1428
+ * what it is given.
1429
+ * - **On Windows** no process is grouped: a query of a process's parent and start does not answer within the bound.
1430
+ *
1431
+ * `clientInfo` is what an MCP client said it is; it is stored as that, and nothing depends on it.
1432
+ */
1433
+ async function sessionIdentity(surface, clientInfo, options = {}) {
1434
+ const seams = options.pid !== void 0 || options.ppid !== void 0 || options.cwd !== void 0 || options.probe !== void 0 || options.platform !== void 0 || options.budgetMs !== void 0 || options.waitMs !== void 0 || options.home !== void 0 || options.now !== void 0;
1435
+ const cache = options.cache ?? (seams ? null : PROCESS_CACHE);
1436
+ const client = clientOf(clientInfo);
1437
+ try {
1438
+ if (surface === "cli" && cache !== null) {
1439
+ const directory = options.log?.directory ?? "";
1440
+ const identities = heldIn(CLI_IDENTITIES, cache);
1441
+ let held = identities.get(directory);
1442
+ if (held === void 0) {
1443
+ held = identityOf$1(surface, options, cache);
1444
+ identities.set(directory, held);
1445
+ }
1446
+ return {
1447
+ ...await held,
1448
+ ...client === void 0 ? {} : { client }
1449
+ };
1450
+ }
1451
+ return {
1452
+ ...await identityOf$1(surface, options, cache),
1453
+ ...client === void 0 ? {} : { client }
1454
+ };
1455
+ } catch {
1456
+ return {
1457
+ key: cache?.ownKey ?? randomKey(),
1458
+ process: cache?.processId ?? PROCESS_ID,
1459
+ pid: options.pid ?? process.pid,
1460
+ ...client === void 0 ? {} : { client },
1461
+ folder: null,
1462
+ grouped: false,
1463
+ surface
1464
+ };
1465
+ }
1466
+ }
1467
+ async function identityOf$1(surface, options, cache) {
1468
+ const own = cache?.ownKey ?? randomKey();
1469
+ const processId = cache?.processId ?? randomBytes(6).toString("hex");
1470
+ const parentOfChain = (chain, index) => {
1471
+ const held = chain[index];
1472
+ return held === void 0 ? void 0 : {
1473
+ pid: held.pid,
1474
+ startedAt: held.startedAt,
1475
+ name: held.name
1476
+ };
1477
+ };
1478
+ if (surface === "cli") {
1479
+ const { pid, folder, chain } = await readAncestry(options, 3, false, options.budgetMs ?? 200);
1480
+ const base = {
1481
+ process: processId,
1482
+ pid,
1483
+ folder,
1484
+ surface
1485
+ };
1486
+ const ancestors = chain.map((held) => held.name);
1487
+ const parents = chain.map((_, index) => parentOfChain(chain, index));
1488
+ const joined = parents.length > 0 && options.log ? await options.log.sessionOfAncestor(parents) : null;
1489
+ if (joined !== null) return {
1490
+ key: joined.key,
1491
+ ...base,
1492
+ parent: joined.parent,
1493
+ ancestors,
1494
+ grouped: true
1495
+ };
1496
+ const parent = parentOfChain(chain, 0);
1497
+ return {
1498
+ key: own,
1499
+ ...base,
1500
+ ...parent === void 0 ? {} : { parent },
1501
+ ancestors,
1502
+ grouped: false
1503
+ };
1504
+ }
1505
+ const initIsParent = surface === "daemon";
1506
+ let ancestry;
1507
+ if (cache === null) ancestry = await readAncestry(options, 1, initIsParent, options.budgetMs ?? 200);
1508
+ else {
1509
+ const read = ancestryRead(cache, options, initIsParent);
1510
+ const wait = initIsParent ? options.budgetMs ?? 2e3 : options.waitMs ?? 200;
1511
+ ancestry = read.settled ?? await within$2(read.promise, wait);
1512
+ }
1513
+ if (ancestry === null) return {
1514
+ key: own,
1515
+ process: processId,
1516
+ pid: options.pid ?? process.pid,
1517
+ folder: folderOf(options),
1518
+ grouped: false,
1519
+ surface
1520
+ };
1521
+ const { pid, folder, chain } = ancestry;
1522
+ const base = {
1523
+ process: processId,
1524
+ pid,
1525
+ folder,
1526
+ surface
1527
+ };
1528
+ const parent = parentOfChain(chain, 0);
1529
+ const home = options.home ?? homedir();
1530
+ const shared = !initIsParent && folder !== null && sharedFolder(folder, home, options.platform ?? process.platform);
1531
+ if (parent === void 0 || folder === null || shared) return {
1532
+ key: own,
1533
+ ...base,
1534
+ ...parent === void 0 ? {} : { parent },
1535
+ grouped: false
1536
+ };
1537
+ return {
1538
+ key: windowKey(parent, folder, options.connection),
1539
+ ...base,
1540
+ parent,
1541
+ grouped: true
1542
+ };
1543
+ }
1544
+ /**
1545
+ * Records one line in `core`'s activity log. Never throws, and never fails the call it describes: a line that cannot
1546
+ * be written is reported by the log itself (`ActivityLog.append`). Recording off writes nothing.
1547
+ *
1548
+ * For a line a channel builds itself — the event service's `told` lines (S1), say. Around an operation, use
1549
+ * `withActivity`, which times it and records how it ended.
1550
+ */
1551
+ async function recordActivity(core, line) {
1552
+ try {
1553
+ await core.activity.append(line);
1554
+ } catch {}
1555
+ }
1556
+ /** The refusals a call can end in — a policy, an approval, a rule, a cap or a usage mistake — rather than a failure. */
1557
+ const REFUSED_CODES = /* @__PURE__ */ new Set([
1558
+ "APPROVAL_REQUIRED",
1559
+ "APPROVAL_PENDING",
1560
+ "APPROVAL_EXPIRED",
1561
+ "APPROVAL_VOID",
1562
+ "SEND_REFUSED",
1563
+ "POLICY_NEVER",
1564
+ "ACT_NOT_ALLOWED",
1565
+ "STANDING_REFUSED",
1566
+ "STANDING_LIMIT",
1567
+ "RATE_CAPPED",
1568
+ "LOOSENING_REFUSED",
1569
+ "USAGE",
1570
+ "WHATSAPP_VISIBILITY_SEAM_REQUIRED"
1571
+ ]);
1572
+ function failureDetail(error) {
1573
+ const code = error instanceof CommsError ? error.code : "UNEXPECTED";
1574
+ return {
1575
+ outcome: REFUSED_CODES.has(code) ? "refused" : "failed",
1576
+ reason: `${code}: ${messageOf$1(error)}`
1577
+ };
1578
+ }
1579
+ async function recordCall(context, operation, kind, ms, detail) {
1580
+ try {
1581
+ const clientInfo = typeof context.clientInfo === "function" ? context.clientInfo() : context.clientInfo;
1582
+ const session = context.session ?? await sessionIdentity(context.surface, clientInfo, {
1583
+ log: context.core.activity,
1584
+ connection: typeof context.connection === "function" ? context.connection() : context.connection
1585
+ });
1586
+ const query = detail.query ?? context.query;
1587
+ await recordActivity(context.core, {
1588
+ channel: context.channel,
1589
+ account: detail.account ?? context.account,
1590
+ operation,
1591
+ kind,
1592
+ outcome: detail.outcome ?? "ok",
1593
+ ...detail.reason === void 0 ? {} : { reason: detail.reason },
1594
+ session,
1595
+ ms,
1596
+ ...detail.items === void 0 ? {} : { items: detail.items },
1597
+ ...detail.count === void 0 ? {} : { count: detail.count },
1598
+ ...detail.counts === void 0 ? {} : { counts: detail.counts },
1599
+ ...query === void 0 ? {} : { query },
1600
+ ...detail.recipients === void 0 ? {} : { recipients: detail.recipients },
1601
+ ...detail.approvalId === void 0 ? {} : { approvalId: detail.approvalId },
1602
+ ...detail.told === void 0 ? {} : { told: detail.told }
1603
+ });
1604
+ } catch {}
1605
+ }
1606
+ /**
1607
+ * Runs one operation and records its line (§8): times `fn`, and when it returns records `describe(result)` as `ok`
1608
+ * (or the outcome `describe` names); when it throws, records `refused` for a policy's, an approval's or a rule's
1609
+ * refusal and `failed` for anything else, with the error's code and message as the reason, and throws the same error
1610
+ * on. What it returns, or throws, is exactly what `fn` did: recording never changes or fails the call, and a
1611
+ * `describe` that throws records the line without its details.
1612
+ *
1613
+ * `operationId` is the capability id in `capabilities.json` (`gmail.search`); `kind` is `read`, `write`, `told` or
1614
+ * `approval`.
1615
+ *
1616
+ * @example
1617
+ * const found = await withActivity(
1618
+ * { core, channel: 'gmail', surface: 'mcp', account: { id: inbox.id, alias }, clientInfo: () => server.getClientVersion() },
1619
+ * 'gmail.search',
1620
+ * 'read',
1621
+ * () => search(core, request),
1622
+ * (result) => ({ query: request.query, count: result.total, items: result.messages.map((m) => ({ id: m.id, subject: m.subject, from: m.from })) }),
1623
+ * );
1624
+ */
1625
+ async function withActivity(context, operationId, kind, fn, describe) {
1626
+ const started = performance.now();
1627
+ let result;
1628
+ try {
1629
+ result = await fn();
1630
+ } catch (error) {
1631
+ await recordCall(context, operationId, kind, performance.now() - started, failureDetail(error));
1632
+ throw error;
1633
+ }
1634
+ const ms = performance.now() - started;
1635
+ let detail = {};
1636
+ try {
1637
+ detail = describe(result) ?? {};
1638
+ } catch {
1639
+ detail = {};
1640
+ }
1641
+ await recordCall(context, operationId, kind, ms, detail);
1642
+ return result;
1643
+ }
1644
+ function sha256Hex(data) {
1645
+ return createHash("sha256").update(data).digest("hex");
1646
+ }
1647
+ /** Lower-cases the address part of `Name <addr>` or a bare address; display names are dropped. */
1648
+ function normaliseAddress(value) {
1649
+ return (/<([^<>]+)>\s*$/.exec(value)?.[1] ?? value).trim().toLowerCase();
1650
+ }
1651
+ /** Deterministic JSON: keys sorted at every level, undefined members dropped. */
1652
+ function canonicalJson$1(value) {
1653
+ if (Array.isArray(value)) return `[${value.map(canonicalJson$1).join(",")}]`;
1654
+ if (value && typeof value === "object") return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson$1(v)}`).join(",")}}`;
1655
+ return JSON.stringify(value);
1656
+ }
1657
+ /**
367
1658
  * Opening `wx` failed because someone else holds the path — retry, rather than failing the command.
368
1659
  *
369
1660
  * EPERM and EBUSY are how Windows reports what EEXIST reports elsewhere: the file is there, or the holder is deleting
@@ -3371,7 +4662,7 @@ async function launcherCanStart(runtimeDir, name) {
3371
4662
  const cli = launcherCli(runtimeDir, name);
3372
4663
  try {
3373
4664
  if (!(await stat(cli)).isFile()) return false;
3374
- await access(cli, constants$1.R_OK);
4665
+ await access(cli, constants.R_OK);
3375
4666
  return true;
3376
4667
  } catch {
3377
4668
  return false;
@@ -3382,7 +4673,7 @@ function launcherCanStartSync(runtimeDir, name) {
3382
4673
  const cli = launcherCli(runtimeDir, name);
3383
4674
  try {
3384
4675
  if (!statSync(cli).isFile()) return false;
3385
- accessSync(cli, constants$1.R_OK);
4676
+ accessSync(cli, constants.R_OK);
3386
4677
  return true;
3387
4678
  } catch {
3388
4679
  return false;
@@ -3703,7 +4994,7 @@ function nodeProgram(env, platform = process.platform) {
3703
4994
  const candidate = join(directory, name);
3704
4995
  try {
3705
4996
  if (!statSync(candidate).isFile()) continue;
3706
- accessSync(candidate, constants$1.X_OK);
4997
+ accessSync(candidate, constants.X_OK);
3707
4998
  if (!sameRealPath(realpathSync(candidate), real, platform)) continue;
3708
4999
  return sameRealPath(candidate, own, platform) ? own : candidate;
3709
5000
  } catch {}
@@ -3959,8 +5250,8 @@ function managedShimStarting(context, binary, entry) {
3959
5250
  if (recorded === void 0 || recorded.path !== path) return null;
3960
5251
  if (!lstatSync(path).isFile() || sha256(readFileSync(path, "utf8")) !== recorded.digest) return null;
3961
5252
  if (realpathSync(recorded.entry) !== realpathSync(entry)) return null;
3962
- accessSync(recorded.node, constants$1.X_OK);
3963
- accessSync(path, constants$1.X_OK);
5253
+ accessSync(recorded.node, constants.X_OK);
5254
+ accessSync(path, constants.X_OK);
3964
5255
  return publicPath;
3965
5256
  } catch {
3966
5257
  return null;
@@ -4264,7 +5555,7 @@ async function executableIn(name, directories, env) {
4264
5555
  for (const directory of paths) for (const extension of extensions) {
4265
5556
  const candidate = join(directory, name + extension.toLowerCase());
4266
5557
  try {
4267
- await access(candidate, constants.X_OK);
5558
+ await access(candidate, constants$1.X_OK);
4268
5559
  return candidate;
4269
5560
  } catch {}
4270
5561
  }
@@ -4467,7 +5758,7 @@ async function installManagedRuntime(context, product, version) {
4467
5758
  async function findNpmCli() {
4468
5759
  const candidates = [join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"), join(dirname(process.execPath), "..", "lib", "node_modules", "npm", "bin", "npm-cli.js")];
4469
5760
  for (const candidate of candidates) try {
4470
- await access(candidate, constants.R_OK);
5761
+ await access(candidate, constants$1.R_OK);
4471
5762
  return resolve(candidate);
4472
5763
  } catch {}
4473
5764
  throw new CommsError("CONFIG", "npm could not be found next to this Node installation", { hint: "Install with `--launcher npx` instead, which runs the published package directly." });
@@ -4601,7 +5892,7 @@ async function localCliEntry(moduleUrl) {
4601
5892
  join(here, "..", "cli.mjs")
4602
5893
  ];
4603
5894
  for (const candidate of candidates) try {
4604
- await access(candidate, constants.R_OK);
5895
+ await access(candidate, constants$1.R_OK);
4605
5896
  return resolve(candidate);
4606
5897
  } catch {}
4607
5898
  throw new CommsError("UNEXPECTED", "cannot find this package's own command to register");
@@ -6861,7 +8152,7 @@ const configV1Schema = looseObject({
6861
8152
  version: literal(1),
6862
8153
  ...v1BodyShape
6863
8154
  }).superRefine(checkV1Body);
6864
- const servesSchema = union([literal("any"), looseObject({ domains: array(string().min(1)).min(1) })]);
8155
+ const servesSchema = union$1([literal("any"), looseObject({ domains: array(string().min(1)).min(1) })]);
6865
8156
  const generationSchema = looseObject({
6866
8157
  name: aliasSchema,
6867
8158
  clientId: string().min(1),
@@ -7017,7 +8308,7 @@ const configV3Naming2Schema = looseObject({
7017
8308
  ...v2BodyShape,
7018
8309
  ...v3Shape
7019
8310
  }).superRefine(checkV2Body);
7020
- union([configV3Naming1Schema, configV3Naming2Schema]);
8311
+ union$1([configV3Naming1Schema, configV3Naming2Schema]);
7021
8312
  function schemaFor(version, naming = 2) {
7022
8313
  if (version === 3) return naming === 1 ? configV3Naming1Schema : configV3Naming2Schema;
7023
8314
  return version === 2 ? configV2Schema : configV1Schema;
@@ -7784,6 +9075,10 @@ function classifyChange(before, after) {
7784
9075
  const levels = behaviourLevels(behaviour.deltas);
7785
9076
  loosen("behaviour.rules", levels.before, levels.after);
7786
9077
  }
9078
+ const activityBefore = readActivitySettings(before);
9079
+ const activityAfter = readActivitySettings(after);
9080
+ if (activityBefore.record === "off" && activityAfter.record === "on") loosen("activity.record", activityBefore.record, activityAfter.record);
9081
+ if (activityAfter.retentionDays > activityBefore.retentionDays) loosen("activity.retentionDays", activityBefore.retentionDays, activityAfter.retentionDays);
7787
9082
  return {
7788
9083
  loosened: changes.map((change) => change.path),
7789
9084
  changes
@@ -7867,7 +9162,7 @@ const ACCOUNT_SETTINGS = [
7867
9162
  ];
7868
9163
  /**
7869
9164
  * Every setting that differs between `before` and `after`, loosened or tightened: the defaults, the secret store,
7870
- * the agent label's settings, the behaviour rules, and each mailbox's and account's own settings — including those of
9165
+ * the agent label's settings, the behaviour rules, the activity log's, and each mailbox's and account's own settings — including those of
7871
9166
  * one added or removed.
7872
9167
  *
7873
9168
  * What a change approval binds, beside its loosenings. A preview shows the whole change — "sends approved by never;
@@ -7910,6 +9205,8 @@ function changedSettings(before, after) {
7910
9205
  const labelAfter = storedAgentLabel(after);
7911
9206
  for (const key of LABEL_SETTINGS) record(`agentLabel.${key}`, labelBefore[key], labelAfter[key]);
7912
9207
  record("behaviour.rules", storedBehaviourRules(before), storedBehaviourRules(after));
9208
+ record("activity.record", storedActivity(before)?.record, storedActivity(after)?.record);
9209
+ record("activity.retentionDays", storedActivity(before)?.retentionDays, storedActivity(after)?.retentionDays);
7913
9210
  return changes;
7914
9211
  }
7915
9212
  /** The behaviour rules as the file holds them — raw, ids and all, as `changedSettings` records every setting. */
@@ -12026,7 +13323,7 @@ function folding(platform) {
12026
13323
  return platform === "win32" ? (text) => text.toLowerCase() : (text) => text;
12027
13324
  }
12028
13325
  /** The package version, read from package.json at build time. */
12029
- const VERSION = "0.28.0";
13326
+ const VERSION = "0.29.0";
12030
13327
  /**
12031
13328
  * The command a person is told to run for one of this suite's own CLIs (design 2026-10-04, D1 and D5; CUE-403).
12032
13329
  *
@@ -12403,7 +13700,7 @@ function stableLauncher(dataDir, packageName, version, platform) {
12403
13700
  const name = launcherName(packageName);
12404
13701
  const { launcher } = launcherPaths(dataDir, name);
12405
13702
  if (!statSync(launcher).isFile()) return null;
12406
- accessSync(launcher, platform === "win32" ? constants$1.R_OK : constants$1.X_OK);
13703
+ accessSync(launcher, platform === "win32" ? constants.R_OK : constants.X_OK);
12407
13704
  const pointer = readPointerSync(dataDir, name);
12408
13705
  if (pointer === void 0 || !launcherCanStartSync(pointer.current, name)) return null;
12409
13706
  const started = readPackageManifest(join(pointer.current, "node_modules", ...packageName.split("/")));
@@ -12576,7 +13873,7 @@ function checkedEntry(root, entry) {
12576
13873
  ok: false,
12577
13874
  why: `${entry} is not a file`
12578
13875
  };
12579
- accessSync(real, constants$1.R_OK);
13876
+ accessSync(real, constants.R_OK);
12580
13877
  } catch {
12581
13878
  return {
12582
13879
  ok: false,
@@ -13270,7 +14567,9 @@ const SETTING_LABELS = {
13270
14567
  "agentLabel.rules": "who each kind of message appears to come from",
13271
14568
  "agentLabel.slackIcons": "the icon of the agent’s Slack posts",
13272
14569
  "agentLabel.slackStyles": "how the agent’s Slack messages look",
13273
- "behaviour.rules": "what an agent may do"
14570
+ "behaviour.rules": "what an agent may do",
14571
+ "activity.record": "recording what agent sessions read and send",
14572
+ "activity.retentionDays": "how many days the activity log keeps"
13274
14573
  };
13275
14574
  /**
13276
14575
  * What the loosening means for the person, in their terms.
@@ -13302,6 +14601,8 @@ function meaning(field, after, scope) {
13302
14601
  case "agentLabel.slackIcons": return "the app’s Slack posts will show this icon beside your name";
13303
14602
  case "agentLabel.slackStyles": return "every agent message there goes out as you, labelled in its words, even where the app could post: it trades Slack’s own APP tag, which no one can fake, for words in the message";
13304
14603
  case "behaviour.rules": return "where it allows more, an agent may start those acts; each one still waits for your yes";
14604
+ case "activity.record": return "every call an agent session makes to your accounts will be recorded on this machine again: the subjects and senders it read, what it searched for, what it sent and to whom — never a message body";
14605
+ case "activity.retentionDays": return "what agent sessions read and sent — subjects, senders, searches, recipients — will be kept on this machine for longer";
13305
14606
  default: return "";
13306
14607
  }
13307
14608
  }
@@ -13751,7 +15052,7 @@ var FileSecretStore = class {
13751
15052
  }
13752
15053
  async get(ref) {
13753
15054
  try {
13754
- const handle = await open(this.#path(ref), constants$1.O_RDONLY | constants$1.O_NOFOLLOW);
15055
+ const handle = await open(this.#path(ref), constants.O_RDONLY | constants.O_NOFOLLOW);
13755
15056
  let raw;
13756
15057
  try {
13757
15058
  raw = await handle.readFile("utf8");
@@ -14192,7 +15493,7 @@ strictObject({
14192
15493
  clientId: string().regex(GOOGLE_CLIENT_ID_PATTERN, "a Google client id is digits, a hyphen, then .apps.googleusercontent.com"),
14193
15494
  clientSecret: string().regex(/^[\x20-\x7e]{1,256}$/, "the client secret is 1–256 printable ASCII characters"),
14194
15495
  projectId: string().regex(/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/, "a Google Cloud project id is 6–30 lowercase letters, digits or hyphens").optional(),
14195
- serves: union([literal("any"), strictObject({ domains: array(string().regex(DOMAIN_PATTERN, "each domain is a lower-case host name")).min(1).max(50) })])
15496
+ serves: union$1([literal("any"), strictObject({ domains: array(string().regex(DOMAIN_PATTERN, "each domain is a lower-case host name")).min(1).max(50) })])
14196
15497
  }).optional(),
14197
15498
  slack: strictObject({
14198
15499
  workspace: string().regex(SLACK_WORKSPACE_ID_PATTERN, "a Slack workspace id is T followed by capital letters or digits"),
@@ -14233,7 +15534,7 @@ function shownPath(path) {
14233
15534
  async function readBounded(path, what) {
14234
15535
  let handle;
14235
15536
  try {
14236
- handle = await open(path, constants$1.O_RDONLY | (constants$1.O_NONBLOCK ?? 0));
15537
+ handle = await open(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
14237
15538
  } catch (error) {
14238
15539
  return what.unopened(error);
14239
15540
  }
@@ -14937,6 +16238,10 @@ function openCore(options = {}) {
14937
16238
  plans: new PlanStore(paths.stateDir, now),
14938
16239
  taint: new TaintStore(paths.stateDir, now),
14939
16240
  audit,
16241
+ activity: new ActivityLog(paths.stateDir, {
16242
+ now,
16243
+ settings: async () => readActivitySettings(await config.load())
16244
+ }),
14940
16245
  handoffs,
14941
16246
  async secrets(kind) {
14942
16247
  const chosen = kind ?? secretsStoreOf(await config.load());
@@ -15270,7 +16575,7 @@ async function corePackageRoot() {
15270
16575
  }
15271
16576
  throw new CommsError("UNEXPECTED", "cannot find the @agentcomms/core package this is running from");
15272
16577
  }
15273
- const exists = (path) => access(path, constants.R_OK).then(() => true, () => false);
16578
+ const exists = (path) => access(path, constants$1.R_OK).then(() => true, () => false);
15274
16579
  /**
15275
16580
  * A module URL inside a channel's package, for the `local` launcher, which registers a checkout's own code.
15276
16581
  *
@@ -15720,7 +17025,7 @@ function serverPruneChange(core, env, request, own) {
15720
17025
  };
15721
17026
  const { packageName, version } = ownProduct(request.channel, own) ?? {
15722
17027
  packageName: serverFacts(request.channel).packageName,
15723
- version: "0.28.0"
17028
+ version: "0.29.0"
15724
17029
  };
15725
17030
  const product = {
15726
17031
  packageName,
@@ -16260,7 +17565,7 @@ function commandDecisionLine(core, decision, channel, platform) {
16260
17565
  }
16261
17566
  channelServer("core").packageName;
16262
17567
  //#endregion
16263
- //#region ../core/dist/tool-arguments-Bh0ujwoA.mjs
17568
+ //#region ../core/dist/tool-arguments-1XgEf3VM.mjs
16264
17569
  const APPLIED = Symbol.for("agentcomms.strictToolArguments");
16265
17570
  const TARGET = "draft-2020-12";
16266
17571
  /**
@@ -18513,12 +19818,12 @@ var WhatsAppIndex = class WhatsAppIndex {
18513
19818
  const kinds = options.kinds;
18514
19819
  if (kinds.length === 0) return [];
18515
19820
  return this.#db.prepare(`${CHAT_SELECT} WHERE agent_sees_chat(c.id) AND ${KIND} IN (${kinds.map(() => "?").join(", ")})
18516
- ORDER BY c.last_message_at IS NULL, c.last_message_at DESC, c.id LIMIT ?`).all(...kinds, options.limit).map(chatOf);
19821
+ ORDER BY c.last_message_at IS NULL, c.last_message_at DESC, c.id LIMIT ?`).all(...kinds, options.limit).map(chatOf$1);
18517
19822
  }
18518
19823
  /** One chat by its id — or null, as for a chat that does not exist, when the lists hide it. */
18519
19824
  chat(id) {
18520
19825
  const row = this.#db.prepare(`${CHAT_SELECT} WHERE c.id = ? AND agent_sees_chat(c.id)`).get(id);
18521
- return row ? chatOf(row) : null;
19826
+ return row ? chatOf$1(row) : null;
18522
19827
  }
18523
19828
  message(id) {
18524
19829
  if (!/^\d{1,15}$/.test(id)) return null;
@@ -18564,7 +19869,7 @@ const CHAT_SELECT = `
18564
19869
  THEN (SELECT COUNT(*) FROM messages m WHERE m.chat_id = c.id AND ${SEES_MESSAGE})
18565
19870
  ELSE c.message_count END AS message_count
18566
19871
  FROM chats c`;
18567
- function chatOf(row) {
19872
+ function chatOf$1(row) {
18568
19873
  return {
18569
19874
  id: String(row.id),
18570
19875
  kind: row.kind,
@@ -18968,6 +20273,8 @@ var WhatsAppContext = class {
18968
20273
  lists;
18969
20274
  /** The name a pinned server was started for, as given. */
18970
20275
  pinned;
20276
+ /** What the MCP client said it is, for the activity log; undefined at the command line. */
20277
+ clientInfo;
18971
20278
  #log;
18972
20279
  #migration = null;
18973
20280
  #pinnedId;
@@ -18987,6 +20294,7 @@ var WhatsAppContext = class {
18987
20294
  this.handoffs = requireHandoffs(this.core).on(this.platform);
18988
20295
  this.lists = new ChatListStore(this.core.paths.configDir);
18989
20296
  this.pinned = options.account;
20297
+ this.clientInfo = options.clientInfo;
18990
20298
  this.#log = options.log;
18991
20299
  this.#source = {
18992
20300
  io: options.sourceIo,
@@ -19097,6 +20405,129 @@ var WhatsAppContext = class {
19097
20405
  }
19098
20406
  };
19099
20407
  //#endregion
20408
+ //#region src/activity.ts
20409
+ /**
20410
+ * What this package writes in core's activity log (design 2026-10-25-activity, S4, and §8 Task 2b): one line for each
20411
+ * call that reads an account's chats or drafts to one — **counts and nothing else**.
20412
+ *
20413
+ * - **A line holds** the operation, the account, `counts` (`chats`, `messages`), the outcome and the session.
20414
+ * - **Its kind:** `read` for a sync and every read of the index; `approval` for a draft, which reaches nothing — it is a
20415
+ * link the person checks and sends themselves (the lead's rule for 0.29.0: `write` is only an act that reaches the
20416
+ * service, and this package has none).
20417
+ * - **It never holds** a chat's id, a name, a number, a message or a preview — nor a search's words, which may be a
20418
+ * name or a number — and no item at all. So a chat the person denies later leaves nothing behind in the log, as it
20419
+ * leaves nothing in the index (`visibility.ts`), and core never needs WhatsApp's lists to keep the log clean.
20420
+ * - **Nor does its reason.** A refusal here names the chat it refused (`no chat 15555550101@s.whatsapp.net in …`), so a
20421
+ * failed or refused line keeps the error's code and none of its words. The call still throws the error it threw.
20422
+ * - **Its session is this process's own, ungrouped.** Grouping a process with its agent window (S2) reads the parent's
20423
+ * start with `ps`, and this package starts no process but the link opener (`no-network.test.ts`): so each server and
20424
+ * each command is a session of its own, marked ungrouped, with its folder and — over MCP — what the client said.
20425
+ */
20426
+ const CHANNEL = "whatsapp";
20427
+ /** What a failed or refused line says in place of the error's own words, which may name a chat or a number. */
20428
+ const WITHHELD = "details withheld: a WhatsApp line names no chat, person or number";
20429
+ /**
20430
+ * The account a call acts on: the one it names, or the pinned server's. With `anyAccount` — a draft, which applies
20431
+ * every account's lists when it names none — the only account there is, or every one of several: one line, their names
20432
+ * joined by commas and no id, as a call over several accounts is recorded (the lead's rule for 0.29.0). Undefined when
20433
+ * the call names an account this machine does not have, or none where one is needed: it is then refused before any
20434
+ * account is read, and records nothing.
20435
+ *
20436
+ * Read from the configuration alone — never through `context.account`, which reads the person's lists too: the
20437
+ * operation looks its account up itself, once, as it always has.
20438
+ */
20439
+ async function accountOf(context, named, anyAccount) {
20440
+ try {
20441
+ let names;
20442
+ if (context.pinned !== void 0) {
20443
+ names = await context.accountNames();
20444
+ if (named !== void 0 && !names.includes(named)) return void 0;
20445
+ } else if (named !== void 0) names = [named];
20446
+ else if (anyAccount) names = await context.accountNames();
20447
+ else return void 0;
20448
+ const config = await context.config();
20449
+ const accounts = names.map((name) => ({
20450
+ name,
20451
+ account: lookupName(config, "account", name)
20452
+ }));
20453
+ if (accounts.length === 0 || accounts.some(({ account }) => account?.platform !== "whatsapp")) return void 0;
20454
+ const [only] = accounts;
20455
+ if (accounts.length === 1 && only?.account !== void 0) return {
20456
+ id: only.account.id,
20457
+ alias: only.name
20458
+ };
20459
+ return {
20460
+ id: "",
20461
+ alias: names.join(",")
20462
+ };
20463
+ } catch {
20464
+ return;
20465
+ }
20466
+ }
20467
+ /** This process's own session key and id: made once, so every line of this process is one session. */
20468
+ const OWN = {
20469
+ key: randomBytes(8).toString("hex"),
20470
+ process: randomBytes(6).toString("hex")
20471
+ };
20472
+ /** The session a line of this process is recorded under: its own, ungrouped, with no process started to learn more. */
20473
+ function ownSession(context) {
20474
+ let folder;
20475
+ try {
20476
+ folder = process.cwd();
20477
+ } catch {
20478
+ folder = null;
20479
+ }
20480
+ const client = context.clientInfo?.();
20481
+ return {
20482
+ key: OWN.key,
20483
+ process: OWN.process,
20484
+ pid: process.pid,
20485
+ ...client?.name ? { client: {
20486
+ name: client.name,
20487
+ ...client.version ? { version: client.version } : {}
20488
+ } } : {},
20489
+ folder,
20490
+ grouped: false,
20491
+ surface: context.surface
20492
+ };
20493
+ }
20494
+ /** The counts of a result, as whole numbers, and only the two a WhatsApp line may hold. */
20495
+ function countsOf(counts) {
20496
+ return Object.fromEntries(Object.entries({
20497
+ chats: counts.chats,
20498
+ messages: counts.messages
20499
+ }).filter((entry) => typeof entry[1] === "number"));
20500
+ }
20501
+ /**
20502
+ * Runs `fn` — one capability's call on the account `named` — and records its line: `operation` is the capability id
20503
+ * in `capabilities.json`, and `counts` how many chats and messages it touched. What `fn` returns or throws is exactly
20504
+ * what this returns or throws: the error that is recorded is a copy with the same code and none of the words, and the
20505
+ * one `fn` threw is the one thrown on.
20506
+ */
20507
+ async function recordWhatsApp(context, named, operation, kind, fn, counts, options = {}) {
20508
+ const account = await accountOf(context, named, options.anyAccount === true);
20509
+ if (account === void 0) return fn();
20510
+ let thrown;
20511
+ try {
20512
+ return await withActivity({
20513
+ core: context.core,
20514
+ channel: CHANNEL,
20515
+ surface: context.surface,
20516
+ account,
20517
+ session: ownSession(context)
20518
+ }, operation, kind, async () => {
20519
+ try {
20520
+ return await fn();
20521
+ } catch (error) {
20522
+ thrown = { error };
20523
+ throw new CommsError(isCommsError(error) ? error.code : "UNEXPECTED", WITHHELD);
20524
+ }
20525
+ }, (result) => ({ counts: countsOf(counts(result)) }));
20526
+ } catch (error) {
20527
+ throw thrown === void 0 ? error : thrown.error;
20528
+ }
20529
+ }
20530
+ //#endregion
19100
20531
  //#region src/visibility.ts
19101
20532
  /** One entry per person or chat: a phone number and the ids that carry it compare equal. */
19102
20533
  function listKey(jid) {
@@ -19275,6 +20706,12 @@ function composeDraft(request) {
19275
20706
  * one account is not drafted to by naming another. A pinned server serves its account alone and consults no other.
19276
20707
  */
19277
20708
  async function draftMessage(context, request) {
20709
+ return recordWhatsApp(context, request.account, "whatsapp.draft", "approval", () => draftOf(context, request), () => ({
20710
+ chats: 1,
20711
+ messages: 1
20712
+ }), { anyAccount: true });
20713
+ }
20714
+ async function draftOf(context, request) {
19278
20715
  const draft = composeDraft({
19279
20716
  to: request.to,
19280
20717
  text: request.text
@@ -19483,6 +20920,9 @@ async function openIndex(context, accountName) {
19483
20920
  };
19484
20921
  }
19485
20922
  async function listChats(context, request) {
20923
+ return recordWhatsApp(context, request.account, "whatsapp.chats", "read", () => chatsOf(context, request), (result) => ({ chats: result.chats.length }));
20924
+ }
20925
+ async function chatsOf(context, request) {
19486
20926
  const limit = wholeNumber(request.limit ?? 50, {
19487
20927
  name: "limit",
19488
20928
  min: 1,
@@ -19507,6 +20947,12 @@ async function listChats(context, request) {
19507
20947
  }
19508
20948
  }
19509
20949
  async function readChat(context, request) {
20950
+ return recordWhatsApp(context, request.account, "whatsapp.read", "read", () => chatOf(context, request), (result) => ({
20951
+ chats: 1,
20952
+ messages: result.messages.length
20953
+ }));
20954
+ }
20955
+ async function chatOf(context, request) {
19510
20956
  const limit = wholeNumber(request.limit ?? 50, {
19511
20957
  name: "limit",
19512
20958
  min: 1,
@@ -19546,6 +20992,12 @@ function wordsOf(query) {
19546
20992
  return query.normalize("NFKC").split(/[^\p{L}\p{N}]+/u).filter((word) => word.length > 0).slice(0, 16);
19547
20993
  }
19548
20994
  async function searchMessages(context, request) {
20995
+ return recordWhatsApp(context, request.account, "whatsapp.search", "read", () => searchOf(context, request), (result) => ({
20996
+ chats: new Set(result.results.map((hit) => hit.chat.id)).size,
20997
+ messages: result.results.length
20998
+ }));
20999
+ }
21000
+ async function searchOf(context, request) {
19549
21001
  const limit = wholeNumber(request.limit ?? 20, {
19550
21002
  name: "limit",
19551
21003
  min: 1,
@@ -19600,7 +21052,7 @@ async function searchMessages(context, request) {
19600
21052
  }
19601
21053
  //#endregion
19602
21054
  //#region src/source/snapshot.ts
19603
- const SOURCE_FLAGS = constants$1.O_RDONLY | (constants$1.O_NOFOLLOW ?? 0) | (constants$1.O_NONBLOCK ?? 0);
21055
+ const SOURCE_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0);
19604
21056
  const nodeSourceIo = {
19605
21057
  lstat: (path) => lstat(path, { bigint: true }),
19606
21058
  async open(path) {
@@ -19752,7 +21204,7 @@ async function closeSources(sources) {
19752
21204
  const CHUNK_BYTES = 1048576;
19753
21205
  /** Every byte of an open source file, into a file created for it — exclusively, owner-only. */
19754
21206
  async function copyOpened(source, to) {
19755
- const out = await open(to, constants$1.O_WRONLY | constants$1.O_CREAT | constants$1.O_EXCL, 384);
21207
+ const out = await open(to, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 384);
19756
21208
  try {
19757
21209
  const buffer = Buffer.allocUnsafe(CHUNK_BYTES);
19758
21210
  for (let position = 0;;) {
@@ -20045,6 +21497,12 @@ async function withCheckedCopy(context, request, work) {
20045
21497
  * deny hides is never written, not merely filtered out when read.
20046
21498
  */
20047
21499
  async function syncAccount(context, request) {
21500
+ return recordWhatsApp(context, request.account, "whatsapp.sync", "read", () => syncOnce(context, request), (result) => ({
21501
+ chats: result.chats,
21502
+ messages: result.messages
21503
+ }));
21504
+ }
21505
+ async function syncOnce(context, request) {
20048
21506
  return withCheckedCopy(context, request, async (copy) => {
20049
21507
  let current = await context.accountById(copy.account.id);
20050
21508
  if (!current) throw removedWhileSyncing(context, copy.accountName, copy.account);