@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.
- package/dist/cli.mjs +5 -5
- package/dist/index.d.mts +307 -1
- package/dist/index.mjs +2 -2
- package/dist/{server-BDxtM4I0.mjs → server-DjDSXoQK.mjs} +5 -2
- package/dist/{stdio-entry-C9jbMwY3.mjs → stdio-entry-CSkJ5urt.mjs} +1 -1
- package/dist/{sync-DSl-bmBj.mjs → sync-NJRjuwaR.mjs} +1506 -48
- package/package.json +3 -3
|
@@ -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 {
|
|
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.
|
|
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
|
-
|
|
31
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3963
|
-
accessSync(path, constants
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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-
|
|
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
|
|
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
|
|
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);
|