pk-annotator 0.6.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.
@@ -0,0 +1,1644 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { constants, readFileSync, readlinkSync, watch } from "node:fs";
3
+ import { link, lstat, mkdir, open, readdir, realpath, rename, rm, stat, unlink } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { z } from "zod";
6
+ import { hostname } from "node:os";
7
+ //#region src/shared/schema.ts
8
+ const ID_PATTERN = /^[a-z0-9-]{8,40}$/;
9
+ const Id = z.string().regex(ID_PATTERN, "id must match ^[a-z0-9-]{8,40}$");
10
+ const RelativePath = z.string().max(200).regex(/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*$/, "path must be relative to the annotation directory, without . or .. segments");
11
+ const Timestamp = z.iso.datetime({ offset: true });
12
+ const Viewport = z.strictObject({
13
+ w: z.number().int().positive(),
14
+ h: z.number().int().positive(),
15
+ dpr: z.number().positive(),
16
+ scrollX: z.number(),
17
+ scrollY: z.number()
18
+ });
19
+ const Box = z.strictObject({
20
+ x: z.number(),
21
+ y: z.number(),
22
+ w: z.number().nonnegative(),
23
+ h: z.number().nonnegative()
24
+ });
25
+ const Selector = z.strictObject({
26
+ role: z.string().optional(),
27
+ name: z.string().optional(),
28
+ testId: z.string().optional(),
29
+ css: z.string()
30
+ });
31
+ const ElementRef = z.strictObject({
32
+ n: z.number().int().positive(),
33
+ source: z.string().regex(/^.+:\d+:\d+$/, "source must be file:line:col").optional(),
34
+ usedAt: z.string().regex(/^.+:\d+:\d+$/, "usedAt must be file:line:col").optional(),
35
+ owners: z.array(z.string()),
36
+ selector: Selector,
37
+ html: z.string(),
38
+ box: Box,
39
+ crop: RelativePath.optional(),
40
+ text: z.string().optional(),
41
+ nearbyText: z.string().optional()
42
+ });
43
+ const AttachmentKind = z.enum([
44
+ "recording",
45
+ "errors",
46
+ "network",
47
+ "perf",
48
+ "frame",
49
+ "video"
50
+ ]);
51
+ const Attachment = z.strictObject({
52
+ kind: AttachmentKind,
53
+ path: RelativePath,
54
+ summary: z.string()
55
+ });
56
+ const Annotation = z.strictObject({
57
+ id: Id,
58
+ createdAt: Timestamp,
59
+ url: z.string(),
60
+ route: z.string(),
61
+ viewport: Viewport,
62
+ prompt: z.string(),
63
+ elements: z.array(ElementRef),
64
+ attachments: z.array(Attachment)
65
+ });
66
+ const AnnotationDraft = Annotation.omit({
67
+ id: true,
68
+ createdAt: true
69
+ });
70
+ const Status = z.enum([
71
+ "pending",
72
+ "acknowledged",
73
+ "resolved",
74
+ "dismissed"
75
+ ]);
76
+ const StatusEvent = z.strictObject({
77
+ status: Status,
78
+ at: Timestamp,
79
+ by: z.string().optional(),
80
+ note: z.string().optional()
81
+ });
82
+ const State = z.strictObject({
83
+ status: Status,
84
+ history: z.array(StatusEvent).min(1)
85
+ });
86
+ /**
87
+ * The claiming process. A pid means something only inside its PID namespace, so `namespace`
88
+ * names that namespace on its host; `startTime` (Linux) tells a reused pid apart.
89
+ */
90
+ const ClaimProcess = z.strictObject({
91
+ pid: z.number().int().positive(),
92
+ namespace: z.string().min(1).max(200),
93
+ startTime: z.string().regex(/^\d+$/).optional()
94
+ });
95
+ const Claim = z.strictObject({
96
+ by: z.string().min(1),
97
+ at: Timestamp,
98
+ process: ClaimProcess.optional()
99
+ });
100
+ const ThreadEntry = z.strictObject({
101
+ at: Timestamp,
102
+ from: z.enum(["agent", "human"]),
103
+ text: z.string().min(1)
104
+ });
105
+ const ErrorGroupStatus = z.enum([
106
+ "open",
107
+ "sent",
108
+ "cleared"
109
+ ]);
110
+ const ErrorGroup = z.strictObject({
111
+ fingerprint: z.string().min(1).max(200),
112
+ message: z.string(),
113
+ type: z.string(),
114
+ count: z.number().int().positive(),
115
+ firstSeen: Timestamp,
116
+ lastSeen: Timestamp,
117
+ lastSeq: z.number().int().nonnegative(),
118
+ topFrame: z.string().regex(/^.+:\d+$/, "topFrame must be file:line").optional(),
119
+ stack: z.string(),
120
+ status: ErrorGroupStatus
121
+ });
122
+ const LiveErrorsSnapshot = z.strictObject({
123
+ updatedAt: Timestamp,
124
+ groups: z.array(ErrorGroup)
125
+ });
126
+ //#endregion
127
+ //#region src/store/store.ts
128
+ const STORE_SUBDIR = path.join("_interim", "annotations");
129
+ const DEFAULT_SIZE_CAP_BYTES = 524288e3;
130
+ const PRUNE_AGE_MS = 6048e5;
131
+ const STAGING_PREFIX = ".creating-";
132
+ const STAGING_AGE_MS = 864e5;
133
+ const CREATE_EXCLUSIVE = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW;
134
+ /** An error whose message is meant for the person or agent that called the operation. */
135
+ var PkaError = class extends Error {
136
+ name = "PkaError";
137
+ };
138
+ /** The annotation directory does not exist, for example because prune removed it. */
139
+ var MissingAnnotationError = class extends PkaError {
140
+ name = "MissingAnnotationError";
141
+ };
142
+ function isErrno(cause, code) {
143
+ return cause instanceof Error && "code" in cause && cause.code === code;
144
+ }
145
+ async function storeUnder(root) {
146
+ const dir = path.resolve(root, STORE_SUBDIR);
147
+ try {
148
+ if (!(await stat(dir)).isDirectory()) throw new PkaError(`${dir} exists but is not a directory`);
149
+ } catch (thrown) {
150
+ if (isErrno(thrown, "ENOENT")) return void 0;
151
+ throw thrown;
152
+ }
153
+ return realpath(dir);
154
+ }
155
+ /**
156
+ * Finds the annotation store without creating anything: the explicit root,
157
+ * then CLAUDE_PROJECT_DIR, then the nearest ancestor of cwd that has one.
158
+ */
159
+ async function findStore(sources) {
160
+ if (sources.explicit !== void 0) {
161
+ const store = await storeUnder(sources.explicit);
162
+ if (store === void 0) throw new PkaError(`No annotation store at ${path.resolve(sources.explicit, STORE_SUBDIR)}. Start the app's Vite dev server with the pk-annotator plugin once to create it, or pass a different --root.`);
163
+ return store;
164
+ }
165
+ if (sources.claudeProjectDir !== void 0) {
166
+ const store = await storeUnder(sources.claudeProjectDir);
167
+ if (store !== void 0) return store;
168
+ }
169
+ for (let dir = path.resolve(sources.cwd);; dir = path.dirname(dir)) {
170
+ const store = await storeUnder(dir);
171
+ if (store !== void 0) return store;
172
+ if (path.dirname(dir) === dir) break;
173
+ }
174
+ const checked = [...new Set([sources.claudeProjectDir, sources.cwd].filter((dir) => dir !== void 0).map((dir) => path.resolve(dir)))];
175
+ throw new PkaError(`No ${STORE_SUBDIR} found in ${checked.join(" or ")} or any parent directory. Start the app's Vite dev server with the pk-annotator plugin once to create it, or pass --root <project> (or PKA_ROOT).`);
176
+ }
177
+ /** Creates the store under a project root. Only the Vite plugin calls this. */
178
+ async function createStore(projectRoot) {
179
+ const dir = path.resolve(projectRoot, STORE_SUBDIR);
180
+ await mkdir(path.join(dir, "live"), { recursive: true });
181
+ return realpath(dir);
182
+ }
183
+ /** A sortable id: base-36 milliseconds, then random hex. */
184
+ function newId(now) {
185
+ return `${now.toString(36).padStart(9, "0")}-${randomBytes(4).toString("hex")}`;
186
+ }
187
+ function checkId(id) {
188
+ if (!ID_PATTERN.test(id)) throw new PkaError(`Invalid annotation id ${JSON.stringify(id)}: ids match ^[a-z0-9-]{8,40}$. List annotations to get a valid id.`);
189
+ return id;
190
+ }
191
+ function resolveInside(base, ...segments) {
192
+ const target = path.resolve(base, ...segments);
193
+ const relative = path.relative(base, target);
194
+ if (relative === "" || relative.split(path.sep)[0] === ".." || path.isAbsolute(relative)) throw new PkaError(`Path ${segments.join("/")} resolves outside ${base}`);
195
+ return target;
196
+ }
197
+ /** Refuses a symlink anywhere between the store and the target. Missing components end the walk. */
198
+ async function refuseSymlinks(store, target) {
199
+ let current = store;
200
+ for (const segment of path.relative(store, target).split(path.sep)) {
201
+ current = path.join(current, segment);
202
+ try {
203
+ if ((await lstat(current)).isSymbolicLink()) throw new PkaError(`Refusing symlink in the annotation store: ${current}`);
204
+ } catch (thrown) {
205
+ if (isErrno(thrown, "ENOENT")) return;
206
+ throw thrown;
207
+ }
208
+ }
209
+ }
210
+ function annotationFiles(store, id) {
211
+ const dir = resolveInside(store, checkId(id));
212
+ return {
213
+ dir,
214
+ annotation: resolveInside(dir, "annotation.json"),
215
+ state: resolveInside(dir, "state.json"),
216
+ claim: resolveInside(dir, "claim.json"),
217
+ thread: resolveInside(dir, "thread.jsonl")
218
+ };
219
+ }
220
+ function liveErrorsFile(store) {
221
+ return resolveInside(store, "live", "errors.json");
222
+ }
223
+ function processAlive(pid) {
224
+ try {
225
+ process.kill(pid, 0);
226
+ return true;
227
+ } catch (cause) {
228
+ return !isErrno(cause, "ESRCH");
229
+ }
230
+ }
231
+ /** Field 22 of /proc/<pid>/stat, clock ticks after boot. The command name before it may hold spaces and parentheses. */
232
+ function linuxStartTime(pid) {
233
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
234
+ const field = stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19];
235
+ if (field === void 0 || !/^\d+$/.test(field)) throw new Error(`Unexpected /proc/${pid}/stat: ${stat}`);
236
+ return field;
237
+ }
238
+ let ownNamespace;
239
+ /**
240
+ * Names this process's PID namespace on its host. Every Linux host gives its initial
241
+ * namespace the same inode, so the boot id tells hosts (and boots) apart.
242
+ */
243
+ function pidNamespace() {
244
+ ownNamespace ??= process.platform === "linux" ? `linux:${readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim()}:${readlinkSync("/proc/self/ns/pid")}` : `${process.platform}:${hostname()}`;
245
+ return ownNamespace;
246
+ }
247
+ let ownProcess;
248
+ /** This process, as recorded in a claim or lock so another process can tell when it has exited. */
249
+ function thisProcess() {
250
+ if (ownProcess === void 0) {
251
+ ownProcess = {
252
+ pid: process.pid,
253
+ namespace: pidNamespace()
254
+ };
255
+ if (process.platform === "linux") ownProcess.startTime = linuxStartTime("self");
256
+ }
257
+ return ownProcess;
258
+ }
259
+ /**
260
+ * True only when this process can tell that `owner` has exited: it names a process in this
261
+ * PID namespace, and that pid is gone or now belongs to a process started later. A process
262
+ * from another namespace is never judged.
263
+ */
264
+ function processExited(owner) {
265
+ if (owner.namespace !== pidNamespace()) return false;
266
+ if (owner.startTime !== void 0) try {
267
+ return linuxStartTime(owner.pid) !== owner.startTime;
268
+ } catch (cause) {
269
+ if (![
270
+ "ENOENT",
271
+ "ESRCH",
272
+ "EACCES",
273
+ "EPERM"
274
+ ].some((code) => isErrno(cause, code))) throw cause;
275
+ }
276
+ return !processAlive(owner.pid);
277
+ }
278
+ /** Throws a PkaError naming the next step when the annotation directory is absent. */
279
+ async function requireAnnotation(store, id) {
280
+ const files = annotationFiles(store, id);
281
+ try {
282
+ const info = await lstat(files.dir);
283
+ if (info.isSymbolicLink()) throw new PkaError(`Refusing symlink in the annotation store: ${files.dir}`);
284
+ if (!info.isDirectory()) throw new PkaError(`${files.dir} is not a directory`);
285
+ } catch (thrown) {
286
+ if (isErrno(thrown, "ENOENT")) throw new MissingAnnotationError(`No annotation ${id}. List annotations to see the ids that exist.`);
287
+ throw thrown;
288
+ }
289
+ return files;
290
+ }
291
+ async function readText(store, file) {
292
+ await refuseSymlinks(store, file);
293
+ const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
294
+ try {
295
+ return await handle.readFile("utf8");
296
+ } finally {
297
+ await handle.close();
298
+ }
299
+ }
300
+ function parseWith(schema, text, where) {
301
+ let data;
302
+ try {
303
+ data = JSON.parse(text);
304
+ } catch (thrown) {
305
+ throw new PkaError(`Invalid JSON in ${where}`, { cause: thrown });
306
+ }
307
+ const parsed = schema.safeParse(data);
308
+ if (!parsed.success) throw new PkaError(`Invalid data in ${where}:\n${z.prettifyError(parsed.error)}`);
309
+ return parsed.data;
310
+ }
311
+ async function readJson(store, file, schema) {
312
+ return parseWith(schema, await readText(store, file), file);
313
+ }
314
+ /** Reads a JSON-lines file; an absent file has no entries. */
315
+ async function readJsonLines(store, file, schema) {
316
+ let text;
317
+ try {
318
+ text = await readText(store, file);
319
+ } catch (thrown) {
320
+ if (isErrno(thrown, "ENOENT")) return [];
321
+ throw thrown;
322
+ }
323
+ return text.split("\n").flatMap((line, index) => line.trim() === "" ? [] : [parseWith(schema, line, `${file}:${index + 1}`)]);
324
+ }
325
+ async function writeExclusive(file, data) {
326
+ const handle = await open(file, CREATE_EXCLUSIVE, 420);
327
+ try {
328
+ await handle.writeFile(data);
329
+ } finally {
330
+ await handle.close();
331
+ }
332
+ }
333
+ function temporaryName(file) {
334
+ return `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
335
+ }
336
+ /** Writes through a temporary file and a rename, so readers see the old or the new content. */
337
+ async function writeJsonAtomic(store, file, value) {
338
+ await refuseSymlinks(store, path.dirname(file));
339
+ const temporary = temporaryName(file);
340
+ try {
341
+ await writeExclusive(temporary, `${JSON.stringify(value, null, 2)}\n`);
342
+ await rename(temporary, file);
343
+ } catch (thrown) {
344
+ await rm(temporary, { force: true });
345
+ throw thrown;
346
+ }
347
+ }
348
+ async function appendJsonLine(store, file, value) {
349
+ await refuseSymlinks(store, file);
350
+ const handle = await open(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 420);
351
+ try {
352
+ await handle.write(`${JSON.stringify(value)}\n`);
353
+ } finally {
354
+ await handle.close();
355
+ }
356
+ }
357
+ /**
358
+ * Claims an annotation. The claim is written to a temporary file opened with
359
+ * O_EXCL | O_NOFOLLOW and then hard-linked into place, so exactly one caller
360
+ * wins and a loser never reads a half-written claim. A claim that disappears
361
+ * before the loser reads it was removed by replaceClaim, so the link is retried.
362
+ */
363
+ async function createClaim(store, id, claim) {
364
+ const files = await requireAnnotation(store, id);
365
+ const temporary = temporaryName(files.claim);
366
+ await writeExclusive(temporary, `${JSON.stringify(claim)}\n`);
367
+ try {
368
+ for (;;) {
369
+ try {
370
+ await link(temporary, files.claim);
371
+ return {
372
+ won: true,
373
+ claim
374
+ };
375
+ } catch (thrown) {
376
+ if (!isErrno(thrown, "EEXIST")) throw thrown;
377
+ }
378
+ try {
379
+ return {
380
+ won: false,
381
+ claim: await readJson(store, files.claim, Claim)
382
+ };
383
+ } catch (thrown) {
384
+ if (!isErrno(thrown, "ENOENT")) throw thrown;
385
+ }
386
+ }
387
+ } finally {
388
+ await unlink(temporary);
389
+ }
390
+ }
391
+ /** Replaces claim.json with `claim` in one rename, so it never goes missing. Hold the annotation lock. */
392
+ async function writeClaim(store, id, claim) {
393
+ const files = await requireAnnotation(store, id);
394
+ await refuseSymlinks(store, files.claim);
395
+ const temporary = temporaryName(files.claim);
396
+ try {
397
+ await writeExclusive(temporary, `${JSON.stringify(claim)}\n`);
398
+ await rename(temporary, files.claim);
399
+ } catch (thrown) {
400
+ await rm(temporary, { force: true });
401
+ throw thrown;
402
+ }
403
+ }
404
+ /** How long a caller waits for a lock whose owner is still running before it reports it. */
405
+ const LOCK_WAIT_MS = 1e4;
406
+ const LockOwner = z.strictObject({
407
+ token: z.string().regex(/^[0-9a-f]{16}$/),
408
+ process: ClaimProcess
409
+ });
410
+ async function readLockOwner(store, lock) {
411
+ try {
412
+ return await readJson(store, lock, LockOwner);
413
+ } catch (thrown) {
414
+ if (isErrno(thrown, "ENOENT")) return void 0;
415
+ throw thrown;
416
+ }
417
+ }
418
+ function lockChange(lock, deadline) {
419
+ const name = path.basename(lock);
420
+ const watcher = watch(path.dirname(lock));
421
+ let failure;
422
+ let finish = () => {};
423
+ const settled = new Promise((resolve) => {
424
+ finish = resolve;
425
+ });
426
+ watcher.on("change", (_event, changedName) => {
427
+ if (changedName === null || changedName === name) finish();
428
+ });
429
+ watcher.on("error", (cause) => {
430
+ failure = cause;
431
+ finish();
432
+ });
433
+ const timer = setTimeout(finish, Math.max(0, deadline - Date.now()));
434
+ return {
435
+ async changed() {
436
+ await settled;
437
+ if (failure !== void 0) throw failure;
438
+ },
439
+ close() {
440
+ watcher.close();
441
+ clearTimeout(timer);
442
+ }
443
+ };
444
+ }
445
+ /**
446
+ * Takes the lock file `lock` and returns its token. The owner record is complete before the
447
+ * link makes it visible. A lock whose owner has exited is broken. A caller waits for a lock
448
+ * whose owner runs, or cannot be judged from this PID namespace, by watching for its removal
449
+ * until `deadline`; it then checks the owner once more and reports a lock that is still held,
450
+ * never removing it. A killed owner writes nothing, so a caller already waiting finds it
451
+ * dead only at the deadline.
452
+ */
453
+ async function acquireLock(store, lock, deadline) {
454
+ const token = randomBytes(8).toString("hex");
455
+ const temporary = temporaryName(lock);
456
+ await writeExclusive(temporary, `${JSON.stringify({
457
+ token,
458
+ process: thisProcess()
459
+ })}\n`);
460
+ try {
461
+ for (;;) {
462
+ const wake = lockChange(lock, deadline);
463
+ try {
464
+ try {
465
+ await link(temporary, lock);
466
+ return token;
467
+ } catch (thrown) {
468
+ if (!isErrno(thrown, "EEXIST")) throw thrown;
469
+ }
470
+ const owner = await readLockOwner(store, lock);
471
+ if (owner === void 0) continue;
472
+ if (processExited(owner.process)) {
473
+ await breakLock(store, lock, owner, deadline);
474
+ continue;
475
+ }
476
+ if (Date.now() >= deadline) throw new PkaError(`${lock} is held by process ${owner.process.pid} (${owner.process.namespace}); retry later. A process in another PID namespace cannot be checked from here: remove the file only after that process has exited.`);
477
+ await wake.changed();
478
+ } finally {
479
+ wake.close();
480
+ }
481
+ }
482
+ } finally {
483
+ await unlink(temporary);
484
+ }
485
+ }
486
+ /**
487
+ * Removes `lock` if it still names the exited owner `dead`. Breakers of one owner take turns
488
+ * through a lock of their own, so while one rereads and removes `lock`, no other caller can
489
+ * remove it: a breaker that read `dead` long ago finds the newer lock and leaves it.
490
+ */
491
+ async function breakLock(store, lock, dead, deadline) {
492
+ await withLock(store, `${lock}.${dead.token}.break`, deadline, async () => {
493
+ if ((await readLockOwner(store, lock))?.token === dead.token) await unlink(lock);
494
+ });
495
+ }
496
+ async function withLock(store, lock, deadline, act) {
497
+ const token = await acquireLock(store, lock, deadline);
498
+ let result;
499
+ try {
500
+ result = await act();
501
+ } catch (thrown) {
502
+ await releaseLock(store, lock, token);
503
+ throw thrown;
504
+ }
505
+ await releaseLock(store, lock, token);
506
+ return result;
507
+ }
508
+ async function releaseLock(store, lock, token) {
509
+ const owner = await readLockOwner(store, lock);
510
+ if (owner === void 0) return;
511
+ if (owner.token !== token) throw new Error(`${lock} was replaced by process ${owner.process.pid} while held`);
512
+ await rm(lock, { force: true });
513
+ }
514
+ /**
515
+ * Runs `act` while this caller alone holds the annotation's state.lock, so status, claim,
516
+ * reply, and attachment writes from separate processes never interleave. Read the state that
517
+ * `act` decides on inside it. A crashed holder's lock is removed only once its process is
518
+ * known to have exited.
519
+ */
520
+ async function withAnnotationLock(store, id, act) {
521
+ const files = await requireAnnotation(store, id);
522
+ return withLock(store, resolveInside(files.dir, "state.lock"), Date.now() + LOCK_WAIT_MS, () => act(files));
523
+ }
524
+ async function readClaim(store, id) {
525
+ const files = annotationFiles(store, id);
526
+ try {
527
+ return await readJson(store, files.claim, Claim);
528
+ } catch (thrown) {
529
+ if (isErrno(thrown, "ENOENT")) return void 0;
530
+ throw thrown;
531
+ }
532
+ }
533
+ /** Annotation ids in the store, oldest first. Symlinks and other names are skipped. */
534
+ async function listIds(store) {
535
+ return (await readdir(store, { withFileTypes: true })).filter((entry) => entry.isDirectory() && ID_PATTERN.test(entry.name)).map((entry) => entry.name).sort();
536
+ }
537
+ /**
538
+ * Writes a complete annotation directory in a staging directory and renames it
539
+ * into place. With `staged`, that directory already holds the listed capture
540
+ * files and becomes the annotation directory; otherwise a new one is made.
541
+ */
542
+ async function writeAnnotationDir(store, id, files, staged) {
543
+ const dir = annotationFiles(store, id).dir;
544
+ const staging = resolveInside(store, staged?.dir ?? `${STAGING_PREFIX}${id}`);
545
+ try {
546
+ if (staged === void 0) await mkdir(staging);
547
+ else for (const relative of staged.paths) {
548
+ const file = resolveInside(staging, relative);
549
+ await refuseSymlinks(store, file);
550
+ if (!(await lstat(file)).isFile()) throw new PkaError(`Staged capture file ${relative} is not a regular file`);
551
+ }
552
+ for (const file of files) {
553
+ const target = resolveInside(staging, file.path);
554
+ await mkdir(path.dirname(target), { recursive: true });
555
+ await writeExclusive(target, file.data);
556
+ }
557
+ await rename(staging, dir);
558
+ } catch (thrown) {
559
+ await rm(staging, {
560
+ recursive: true,
561
+ force: true
562
+ });
563
+ throw thrown;
564
+ }
565
+ return dir;
566
+ }
567
+ /**
568
+ * Adds files to an existing annotation directory without overwriting any:
569
+ * each is written to a temporary file and hard-linked into place. If one
570
+ * fails, the files already placed are removed. Returns the placed paths.
571
+ */
572
+ async function placeNewFiles(store, dir, files) {
573
+ const placed = [];
574
+ try {
575
+ for (const file of files) {
576
+ const target = resolveInside(dir, file.path);
577
+ await refuseSymlinks(store, path.dirname(target));
578
+ await mkdir(path.dirname(target), { recursive: true });
579
+ const temporary = temporaryName(target);
580
+ try {
581
+ await writeExclusive(temporary, file.data);
582
+ await link(temporary, target);
583
+ } catch (thrown) {
584
+ if (isErrno(thrown, "EEXIST")) throw new PkaError(`${file.path} already exists in ${dir}`);
585
+ throw thrown;
586
+ } finally {
587
+ await rm(temporary, { force: true });
588
+ }
589
+ placed.push(target);
590
+ }
591
+ } catch (thrown) {
592
+ await Promise.all(placed.map((target) => rm(target, { force: true })));
593
+ throw thrown;
594
+ }
595
+ return placed;
596
+ }
597
+ /**
598
+ * Removes resolved and dismissed annotations whose last status change is
599
+ * older than seven days, and staging directories left by a crashed writer.
600
+ */
601
+ async function prune$1(store, now) {
602
+ const removed = [];
603
+ for (const entry of await readdir(store, { withFileTypes: true })) {
604
+ if (entry.isDirectory() && entry.name.startsWith(STAGING_PREFIX)) {
605
+ const staging = resolveInside(store, entry.name);
606
+ if (now - (await lstat(staging)).mtimeMs > STAGING_AGE_MS) {
607
+ await rm(staging, {
608
+ recursive: true,
609
+ force: true
610
+ });
611
+ removed.push(entry.name);
612
+ }
613
+ continue;
614
+ }
615
+ if (!entry.isDirectory() || !ID_PATTERN.test(entry.name)) continue;
616
+ const files = annotationFiles(store, entry.name);
617
+ const state = await readJson(store, files.state, State);
618
+ const last = state.history.at(-1);
619
+ if (last === void 0) throw new PkaError(`${files.state} has an empty history`);
620
+ if ((state.status === "resolved" || state.status === "dismissed") && now - Date.parse(last.at) > PRUNE_AGE_MS) {
621
+ await rm(files.dir, {
622
+ recursive: true,
623
+ force: true
624
+ });
625
+ removed.push(entry.name);
626
+ }
627
+ }
628
+ return removed;
629
+ }
630
+ /** Total size of regular files in the store; the plugin refuses new video when it is over the cap. */
631
+ async function checkStoreSize(store, capBytes = DEFAULT_SIZE_CAP_BYTES) {
632
+ let bytes = 0;
633
+ for (const entry of await readdir(store, {
634
+ withFileTypes: true,
635
+ recursive: true
636
+ })) if (entry.isFile()) bytes += (await lstat(path.join(entry.parentPath, entry.name))).size;
637
+ return {
638
+ bytes,
639
+ capBytes,
640
+ overCap: bytes > capBytes
641
+ };
642
+ }
643
+ //#endregion
644
+ //#region src/shared/agent.ts
645
+ /** Presence files of running pka-mcp sessions, relative to the store. */
646
+ const AGENTS_DIR = ["live", "agents"];
647
+ const AgentPresence = z.strictObject({
648
+ name: z.string().min(1).max(200),
649
+ version: z.string().max(200),
650
+ pid: z.number().int().positive(),
651
+ connectedAt: Timestamp
652
+ });
653
+ //#endregion
654
+ //#region src/ops/presence.ts
655
+ function agentsDir(store) {
656
+ return resolveInside(store, ...AGENTS_DIR);
657
+ }
658
+ /**
659
+ * True only when this process can tell that the claimant's process has exited; see
660
+ * processExited. A claim without a process (the CLI) is never judged.
661
+ */
662
+ function claimantExited(claim) {
663
+ return claim.process !== void 0 && processExited(claim.process);
664
+ }
665
+ /** Reads the presence files and removes those whose process has exited. */
666
+ async function liveAgents(store) {
667
+ const dir = agentsDir(store);
668
+ let names;
669
+ try {
670
+ names = await readdir(dir);
671
+ } catch (cause) {
672
+ if (isErrno(cause, "ENOENT")) return {
673
+ agents: [],
674
+ invalid: []
675
+ };
676
+ throw cause;
677
+ }
678
+ const result = {
679
+ agents: [],
680
+ invalid: []
681
+ };
682
+ for (const name of names) {
683
+ if (!name.endsWith(".json")) continue;
684
+ const file = resolveInside(dir, name);
685
+ let presence;
686
+ try {
687
+ presence = await readJson(store, file, AgentPresence);
688
+ } catch (cause) {
689
+ if (isErrno(cause, "ENOENT")) continue;
690
+ if (!(cause instanceof PkaError)) throw cause;
691
+ result.invalid.push({
692
+ file,
693
+ reason: cause.message
694
+ });
695
+ continue;
696
+ }
697
+ if (processAlive(presence.pid)) result.agents.push(presence);
698
+ else await rm(file, { force: true });
699
+ }
700
+ result.agents.sort((a, b) => Date.parse(b.connectedAt) - Date.parse(a.connectedAt));
701
+ return result;
702
+ }
703
+ //#endregion
704
+ //#region src/ops/views.ts
705
+ const Detail = z.enum(["concise", "full"]);
706
+ const CAP = {
707
+ short: 300,
708
+ label: 500,
709
+ text: 1e3,
710
+ html: 4e3,
711
+ stack: 8e3,
712
+ owners: 12
713
+ };
714
+ /**
715
+ * UTF-8 bytes of a concise annotation or list page, so it stays well under Claude Code's
716
+ * 10k-token warning. detail full has no budget.
717
+ */
718
+ const CONCISE_BYTES = 2e4;
719
+ function jsonBytes(value) {
720
+ return Buffer.byteLength(JSON.stringify(value));
721
+ }
722
+ const CONTROL = /(?![\t\n])\p{Cc}/gu;
723
+ const BIDI = /\p{Bidi_Control}/gu;
724
+ /** Strips control and bidirectional-override characters from page-derived text and caps its length. */
725
+ function pageText(value, max) {
726
+ return capped(value.replace(CONTROL, "").replace(BIDI, ""), max);
727
+ }
728
+ function capped(value, max) {
729
+ if (value.length <= max) return value;
730
+ return `${value.slice(0, max)}… [${value.length - max} more characters]`;
731
+ }
732
+ function optionalPageText(value, max) {
733
+ return value === void 0 ? void 0 : pageText(value, max);
734
+ }
735
+ const ElementView = z.strictObject({
736
+ n: z.number(),
737
+ source: z.string().optional(),
738
+ usedAt: z.string().optional(),
739
+ owners: z.array(z.string()),
740
+ selector: z.strictObject({
741
+ role: z.string().optional(),
742
+ name: z.string().optional(),
743
+ testId: z.string().optional(),
744
+ css: z.string()
745
+ }),
746
+ crop: z.string().optional(),
747
+ text: z.string().optional(),
748
+ nearbyText: z.string().optional(),
749
+ html: z.string().optional(),
750
+ box: Box.optional()
751
+ });
752
+ const AnnotationView = z.strictObject({
753
+ id: Id,
754
+ createdAt: Timestamp,
755
+ status: Status,
756
+ claimedBy: z.string().optional(),
757
+ dir: z.string().describe("Absolute annotation directory; attachment and crop paths are relative to it"),
758
+ url: z.string(),
759
+ route: z.string(),
760
+ viewport: Viewport.optional(),
761
+ prompt: z.string().describe("The human's request"),
762
+ elements: z.array(ElementView),
763
+ attachments: z.array(z.strictObject({
764
+ kind: AttachmentKind,
765
+ path: z.string(),
766
+ summary: z.string().optional()
767
+ })),
768
+ threadCount: z.number(),
769
+ omitted: z.strictObject({
770
+ elements: z.number(),
771
+ attachments: z.number(),
772
+ promptCharacters: z.number(),
773
+ note: z.string()
774
+ }).optional().describe("Elements and attachments left out of a concise response"),
775
+ history: z.array(StatusEvent).optional(),
776
+ thread: z.array(ThreadEntry).optional()
777
+ });
778
+ const ListItem = z.strictObject({
779
+ id: Id,
780
+ createdAt: Timestamp,
781
+ status: Status,
782
+ claimedBy: z.string().optional(),
783
+ route: z.string(),
784
+ prompt: z.string(),
785
+ elementCount: z.number(),
786
+ attachmentCount: z.number()
787
+ });
788
+ const ErrorGroupView = z.strictObject({
789
+ fingerprint: z.string(),
790
+ type: z.string(),
791
+ message: z.string(),
792
+ count: z.number(),
793
+ lastSeen: Timestamp,
794
+ topFrame: z.string().optional(),
795
+ status: ErrorGroupStatus,
796
+ firstSeen: Timestamp.optional(),
797
+ lastSeq: z.number().optional(),
798
+ stack: z.string().optional()
799
+ });
800
+ function elementView(element, detail) {
801
+ const full = detail === "full";
802
+ const view = {
803
+ n: element.n,
804
+ source: optionalPageText(element.source, CAP.label),
805
+ usedAt: optionalPageText(element.usedAt, CAP.label),
806
+ owners: element.owners.slice(0, CAP.owners).map((owner) => pageText(owner, CAP.short)),
807
+ selector: {
808
+ role: optionalPageText(element.selector.role, CAP.short),
809
+ name: optionalPageText(element.selector.name, CAP.short),
810
+ testId: optionalPageText(element.selector.testId, CAP.short),
811
+ css: pageText(element.selector.css, CAP.label)
812
+ },
813
+ crop: element.crop,
814
+ text: optionalPageText(element.text, full ? CAP.text : CAP.short)
815
+ };
816
+ if (full) {
817
+ view.nearbyText = optionalPageText(element.nearbyText, CAP.text);
818
+ view.html = pageText(element.html, CAP.html);
819
+ view.box = element.box;
820
+ }
821
+ return view;
822
+ }
823
+ function annotationView(record, detail) {
824
+ const { annotation, state } = record;
825
+ const full = detail === "full";
826
+ const view = {
827
+ id: annotation.id,
828
+ createdAt: annotation.createdAt,
829
+ status: state.status,
830
+ claimedBy: record.claim === void 0 || full ? record.claim?.by : capped(record.claim.by, CAP.short),
831
+ dir: record.dir,
832
+ url: pageText(annotation.url, CAP.label),
833
+ route: pageText(annotation.route, CAP.label),
834
+ prompt: annotation.prompt,
835
+ elements: annotation.elements.map((element) => elementView(element, detail)),
836
+ attachments: annotation.attachments.map((attachment) => ({
837
+ kind: attachment.kind,
838
+ path: attachment.path,
839
+ summary: pageText(attachment.summary, full ? CAP.text : CAP.short)
840
+ })),
841
+ threadCount: record.thread.length
842
+ };
843
+ if (full) {
844
+ view.viewport = annotation.viewport;
845
+ view.history = state.history;
846
+ view.thread = record.thread;
847
+ return view;
848
+ }
849
+ return withinBudget(view);
850
+ }
851
+ /** Most of CONCISE_BYTES that the human's prompt may take, so its elements still fit. */
852
+ const PROMPT_BYTES = 1e4;
853
+ /** The longest leading part of `prompt` whose JSON string fits PROMPT_BYTES. */
854
+ function promptPrefix(prompt) {
855
+ let length = Math.min(prompt.length, PROMPT_BYTES);
856
+ for (;;) {
857
+ if (/[\uD800-\uDBFF]/.test(prompt.charAt(length - 1))) length -= 1;
858
+ const bytes = Buffer.byteLength(JSON.stringify(prompt.slice(0, length)));
859
+ if (bytes <= PROMPT_BYTES) return prompt.slice(0, length);
860
+ length = Math.floor(length * PROMPT_BYTES / bytes);
861
+ }
862
+ }
863
+ /**
864
+ * Caps the prompt, then keeps the leading elements and attachments that fit CONCISE_BYTES, so
865
+ * `[element n]` and `[attachment n]` keep their numbers, and says how much was left out.
866
+ */
867
+ function withinBudget(view) {
868
+ const { elements, attachments } = view;
869
+ const prompt = promptPrefix(view.prompt);
870
+ const promptCharacters = view.prompt.length - prompt.length;
871
+ const omitted = (keptElements, keptAttachments) => ({
872
+ elements: elements.length - keptElements,
873
+ attachments: attachments.length - keptAttachments,
874
+ promptCharacters,
875
+ note: OMITTED_NOTE
876
+ });
877
+ let used = jsonBytes({
878
+ ...view,
879
+ prompt,
880
+ elements: [],
881
+ attachments: [],
882
+ omitted: omitted(0, 0)
883
+ });
884
+ const fit = (items) => {
885
+ const kept = [];
886
+ for (const item of items) {
887
+ const bytes = jsonBytes(item) + 1;
888
+ if (used + bytes > 2e4) break;
889
+ used += bytes;
890
+ kept.push(item);
891
+ }
892
+ return kept;
893
+ };
894
+ const keptElements = fit(elements);
895
+ const keptAttachments = fit(attachments);
896
+ const result = promptCharacters === 0 && keptElements.length === elements.length && keptAttachments.length === attachments.length ? view : {
897
+ ...view,
898
+ prompt,
899
+ elements: keptElements,
900
+ attachments: keptAttachments,
901
+ omitted: omitted(keptElements.length, keptAttachments.length)
902
+ };
903
+ if (jsonBytes(result) > 2e4) throw new PkaError(`The concise view of annotation ${view.id} is over ${CONCISE_BYTES} bytes without its elements and attachments; request detail full.`);
904
+ return result;
905
+ }
906
+ const OMITTED_NOTE = "Left out to keep this response small. get_annotation (pka get) with detail full returns all of them.";
907
+ function listItem(record) {
908
+ const { annotation } = record;
909
+ return {
910
+ id: annotation.id,
911
+ createdAt: annotation.createdAt,
912
+ status: record.state.status,
913
+ claimedBy: record.claim === void 0 ? void 0 : capped(record.claim.by, CAP.short),
914
+ route: pageText(annotation.route, CAP.label),
915
+ prompt: annotation.prompt.length > CAP.short ? `${annotation.prompt.slice(0, CAP.short)}…` : annotation.prompt,
916
+ elementCount: annotation.elements.length,
917
+ attachmentCount: annotation.attachments.length
918
+ };
919
+ }
920
+ function errorGroupView(group, detail) {
921
+ const full = detail === "full";
922
+ const view = {
923
+ fingerprint: pageText(group.fingerprint, CAP.short),
924
+ type: pageText(group.type, CAP.short),
925
+ message: pageText(group.message, full ? CAP.text : CAP.short),
926
+ count: group.count,
927
+ lastSeen: group.lastSeen,
928
+ topFrame: optionalPageText(group.topFrame, CAP.label),
929
+ status: group.status
930
+ };
931
+ if (full) {
932
+ view.firstSeen = group.firstSeen;
933
+ view.lastSeq = group.lastSeq;
934
+ view.stack = pageText(group.stack, CAP.stack);
935
+ }
936
+ return view;
937
+ }
938
+ //#endregion
939
+ //#region src/ops/ops.ts
940
+ const PROGRESS_INTERVAL_MS = 15e3;
941
+ /** A claim this old on a still-pending annotation lost its claimant between the claim and the state write. */
942
+ const ORPHANED_CLAIM_MS = 6e4;
943
+ const MAX_ERROR_GROUPS = 200;
944
+ /** Annotations read at once by a scan; libuv runs four file system calls in parallel by default. */
945
+ const SCAN_BATCH = 16;
946
+ const detailField = Detail.default("concise").describe("concise (default) keeps the response small; full adds HTML, boxes, nearby text, summaries, history, and the thread");
947
+ const limitField = z.number().int().min(1).max(100).default(20);
948
+ const ListInput = z.strictObject({
949
+ status: z.enum([...Status.options, "all"]).default("pending").describe("Filter by status; pending by default"),
950
+ limit: limitField,
951
+ cursor: Id.optional().describe("nextCursor from the previous page"),
952
+ detail: detailField
953
+ });
954
+ const GetInput = z.strictObject({
955
+ id: Id,
956
+ detail: detailField
957
+ });
958
+ const SetStatusInput = z.strictObject({
959
+ id: Id,
960
+ status: z.enum([
961
+ "acknowledged",
962
+ "resolved",
963
+ "dismissed"
964
+ ]),
965
+ note: z.string().min(1).max(2e3).optional().describe("What was done, or why it was dismissed")
966
+ });
967
+ const ReplyInput = z.strictObject({
968
+ id: Id,
969
+ text: z.string().min(1).max(1e4)
970
+ });
971
+ const ErrorsInput = z.strictObject({
972
+ since: Timestamp.optional().describe("Only groups seen at or after this ISO time"),
973
+ limit: limitField,
974
+ detail: detailField
975
+ });
976
+ z.strictObject({
977
+ items: z.array(z.union([ListItem, AnnotationView])),
978
+ nextCursor: Id.optional()
979
+ });
980
+ z.strictObject({ annotation: AnnotationView });
981
+ z.strictObject({
982
+ timedOut: z.boolean(),
983
+ annotation: AnnotationView.optional()
984
+ });
985
+ z.strictObject({
986
+ id: Id,
987
+ status: Status,
988
+ changed: z.boolean(),
989
+ claimedBy: z.string().optional()
990
+ });
991
+ z.strictObject({
992
+ id: Id,
993
+ entry: ThreadEntry
994
+ });
995
+ z.strictObject({
996
+ updatedAt: Timestamp.nullable().describe("null when the dev server has not recorded errors yet"),
997
+ total: z.number().describe("Open groups that matched before the limit"),
998
+ groups: z.array(ErrorGroupView)
999
+ });
1000
+ /** Reads one annotation with its state, claim, and thread. The Vite plugin uses it to push changes. */
1001
+ async function loadAnnotation(store, id) {
1002
+ const files = await requireAnnotation(store, id);
1003
+ const [annotation, state, claim, thread] = await Promise.all([
1004
+ readJson(store, files.annotation, Annotation),
1005
+ readJson(store, files.state, State),
1006
+ readClaim(store, id),
1007
+ readJsonLines(store, files.thread, ThreadEntry)
1008
+ ]);
1009
+ if (annotation.id !== id) throw new PkaError(`${files.annotation} has id ${annotation.id}, expected ${id}`);
1010
+ return {
1011
+ dir: files.dir,
1012
+ annotation,
1013
+ state,
1014
+ claim,
1015
+ thread
1016
+ };
1017
+ }
1018
+ /**
1019
+ * Reads the parts of an annotation that change after it is written: its state and reply
1020
+ * thread. The Vite plugin uses it to push changes without rereading annotation.json.
1021
+ */
1022
+ async function loadAnnotationUpdates(store, id) {
1023
+ const files = await requireAnnotation(store, id);
1024
+ const [state, thread] = await Promise.all([readJson(store, files.state, State), readJsonLines(store, files.thread, ThreadEntry)]);
1025
+ return {
1026
+ dir: files.dir,
1027
+ state,
1028
+ thread
1029
+ };
1030
+ }
1031
+ /** Like loadAnnotation, but an annotation removed between listing and reading (prune, rm) is absent. */
1032
+ async function loadListed(store, id) {
1033
+ try {
1034
+ return await loadAnnotation(store, id);
1035
+ } catch (thrown) {
1036
+ if (thrown instanceof MissingAnnotationError || isErrno(thrown, "ENOENT")) return void 0;
1037
+ throw thrown;
1038
+ }
1039
+ }
1040
+ /** An annotation removed after listing (prune, rm) has no state. */
1041
+ async function readListedState(store, id) {
1042
+ try {
1043
+ return await readJson(store, annotationFiles(store, id).state, State);
1044
+ } catch (thrown) {
1045
+ if (isErrno(thrown, "ENOENT")) return void 0;
1046
+ throw thrown;
1047
+ }
1048
+ }
1049
+ /**
1050
+ * Returns up to `want` of `ids` that `matches` accepts, in order. Ids are checked in
1051
+ * batches, so a store of resolved annotations costs one small state read per id.
1052
+ */
1053
+ async function firstMatching(ids, want, matches) {
1054
+ const found = [];
1055
+ for (let start = 0; start < ids.length && found.length < want; start += SCAN_BATCH) {
1056
+ const batch = ids.slice(start, start + SCAN_BATCH);
1057
+ const accepted = await Promise.all(batch.map(matches));
1058
+ found.push(...batch.filter((_, index) => accepted[index]));
1059
+ }
1060
+ return found.slice(0, want);
1061
+ }
1062
+ async function list(store, input) {
1063
+ const ids = (await listIds(store)).filter((id) => input.cursor === void 0 || id > input.cursor);
1064
+ const { status } = input;
1065
+ const matched = status === "all" ? ids.slice(0, input.limit + 1) : await firstMatching(ids, input.limit + 1, async (id) => (await readListedState(store, id))?.status === status);
1066
+ const page = matched.slice(0, input.limit);
1067
+ const items = [];
1068
+ let bytes = jsonBytes({
1069
+ items: [],
1070
+ nextCursor: page.at(-1)
1071
+ });
1072
+ for (let start = 0; start < page.length; start += SCAN_BATCH) {
1073
+ const records = await Promise.all(page.slice(start, start + SCAN_BATCH).map((id) => loadListed(store, id)));
1074
+ for (const record of records) {
1075
+ if (record === void 0 || status !== "all" && record.state.status !== status) continue;
1076
+ if (input.detail === "full") {
1077
+ items.push(annotationView(record, "full"));
1078
+ continue;
1079
+ }
1080
+ const item = listItem(record);
1081
+ bytes += jsonBytes(item) + 1;
1082
+ const last = items.at(-1);
1083
+ if (last !== void 0 && bytes > 2e4) return {
1084
+ items,
1085
+ nextCursor: last.id
1086
+ };
1087
+ items.push(item);
1088
+ }
1089
+ }
1090
+ return {
1091
+ items,
1092
+ nextCursor: matched.length > input.limit ? page.at(-1) : void 0
1093
+ };
1094
+ }
1095
+ async function get(store, input) {
1096
+ return { annotation: annotationView(await loadAnnotation(store, input.id), input.detail) };
1097
+ }
1098
+ /**
1099
+ * An open annotation's claim is orphaned when its claimant's process has exited, or, when
1100
+ * that cannot be told, when it is old and the annotation is still pending.
1101
+ */
1102
+ function isOrphaned(state, claim, now) {
1103
+ if (isClosed(state.status)) return false;
1104
+ if (claimantExited(claim)) return true;
1105
+ return state.status === "pending" && now - Date.parse(claim.at) > ORPHANED_CLAIM_MS;
1106
+ }
1107
+ function isOffered(state, claim) {
1108
+ if (claim === void 0) return state.status === "pending";
1109
+ return isOrphaned(state, claim, Date.now());
1110
+ }
1111
+ async function oldestOffered(store, skip) {
1112
+ const ids = (await listIds(store)).filter((id) => !skip.has(id));
1113
+ const offered = async (id) => {
1114
+ const state = await readListedState(store, id);
1115
+ return state !== void 0 && !isClosed(state.status) && isOffered(state, await readClaim(store, id));
1116
+ };
1117
+ for (let start = 0; start < ids.length; start += SCAN_BATCH) for (const id of await firstMatching(ids.slice(start, start + SCAN_BATCH), SCAN_BATCH, offered)) {
1118
+ const record = await loadListed(store, id);
1119
+ if (record !== void 0 && isOffered(record.state, record.claim)) return record;
1120
+ }
1121
+ }
1122
+ /**
1123
+ * Returns the oldest offered annotation (pending and unclaimed, or open with an orphaned
1124
+ * claim) at once if there is one.
1125
+ * Otherwise holds one fs.watch on the store until one appears, the timeout
1126
+ * elapses, or the signal aborts. The watcher and timers are released on every
1127
+ * outcome.
1128
+ */
1129
+ function wait(store, options) {
1130
+ const { signal } = options;
1131
+ signal?.throwIfAborted();
1132
+ return new Promise((resolve, reject) => {
1133
+ const started = Date.now();
1134
+ let settled = false;
1135
+ let scanning = false;
1136
+ let rescan = false;
1137
+ const watcher = watch(store, { persistent: true }, (_event, name) => {
1138
+ if (name === null || ID_PATTERN.test(name)) scan();
1139
+ });
1140
+ const timeout = options.timeoutMs === void 0 ? void 0 : setTimeout(() => settle({ timedOut: true }, void 0), options.timeoutMs);
1141
+ const { onProgress } = options;
1142
+ const progress = onProgress === void 0 ? void 0 : setInterval(() => onProgress(Date.now() - started), PROGRESS_INTERVAL_MS);
1143
+ const onAbort = () => settle(void 0, signal?.reason);
1144
+ signal?.addEventListener("abort", onAbort, { once: true });
1145
+ watcher.on("error", (cause) => settle(void 0, cause));
1146
+ function settle(result, cause) {
1147
+ if (settled) return;
1148
+ settled = true;
1149
+ watcher.close();
1150
+ clearTimeout(timeout);
1151
+ clearInterval(progress);
1152
+ signal?.removeEventListener("abort", onAbort);
1153
+ if (result === void 0) reject(cause);
1154
+ else resolve(result);
1155
+ }
1156
+ async function scan() {
1157
+ if (scanning) {
1158
+ rescan = true;
1159
+ return;
1160
+ }
1161
+ scanning = true;
1162
+ try {
1163
+ do {
1164
+ rescan = false;
1165
+ const record = await oldestOffered(store, options.skip);
1166
+ if (settled) return;
1167
+ if (record !== void 0) {
1168
+ settle({
1169
+ timedOut: false,
1170
+ annotation: annotationView(record, "concise")
1171
+ }, void 0);
1172
+ return;
1173
+ }
1174
+ } while (rescan && !settled);
1175
+ } catch (cause) {
1176
+ settle(void 0, cause);
1177
+ } finally {
1178
+ scanning = false;
1179
+ }
1180
+ }
1181
+ scan();
1182
+ });
1183
+ }
1184
+ function isClosed(status) {
1185
+ return status === "resolved" || status === "dismissed";
1186
+ }
1187
+ function closedError(id, status) {
1188
+ return new PkaError(`Annotation ${id} is ${status}; reply before set_status ${status}.`);
1189
+ }
1190
+ function claimedError(id, claim) {
1191
+ return new PkaError(claimantExited(claim) ? `Annotation ${id} was claimed by ${claim.by} at ${claim.at}, whose session has exited. Call set_status acknowledged to take it over, then retry.` : `Annotation ${id} was claimed by ${claim.by} at ${claim.at}. Pick another pending annotation.`);
1192
+ }
1193
+ function requireClaimant(id, claim, by) {
1194
+ if (claim !== void 0 && claim.by !== by) throw claimedError(id, claim);
1195
+ }
1196
+ /**
1197
+ * `owner` identifies a long-lived claimant's process, so its claim can be taken over once it
1198
+ * exits. The decision and the write happen under the annotation lock, so a competing change
1199
+ * from another process is either seen here or sees this one.
1200
+ */
1201
+ async function setStatus(store, input, by, owner) {
1202
+ return withAnnotationLock(store, input.id, async (files) => {
1203
+ const state = await readJson(store, files.state, State);
1204
+ if (isClosed(state.status)) {
1205
+ if (state.status === input.status) return {
1206
+ id: input.id,
1207
+ status: state.status,
1208
+ changed: false
1209
+ };
1210
+ throw closedError(input.id, state.status);
1211
+ }
1212
+ let claim = await readClaim(store, input.id);
1213
+ const at = (/* @__PURE__ */ new Date()).toISOString();
1214
+ if (input.status === "acknowledged") {
1215
+ const mine = owner === void 0 ? {
1216
+ by,
1217
+ at
1218
+ } : {
1219
+ by,
1220
+ at,
1221
+ process: owner
1222
+ };
1223
+ let tookOver = false;
1224
+ if (claim === void 0) {
1225
+ if (state.status !== "pending") throw new PkaError(`Annotation ${input.id} is ${state.status}. Pick another pending annotation.`);
1226
+ claim = (await createClaim(store, input.id, mine)).claim;
1227
+ } else if (claim.by !== by && isOrphaned(state, claim, Date.parse(at))) {
1228
+ await writeClaim(store, input.id, mine);
1229
+ claim = mine;
1230
+ tookOver = true;
1231
+ }
1232
+ requireClaimant(input.id, claim, by);
1233
+ if (state.status !== "pending" && !tookOver) return {
1234
+ id: input.id,
1235
+ status: state.status,
1236
+ changed: false,
1237
+ claimedBy: by
1238
+ };
1239
+ } else requireClaimant(input.id, claim, by);
1240
+ const event = {
1241
+ status: input.status,
1242
+ at,
1243
+ by
1244
+ };
1245
+ if (input.note !== void 0) event.note = input.note;
1246
+ await writeJsonAtomic(store, files.state, {
1247
+ status: input.status,
1248
+ history: [...state.history, event]
1249
+ });
1250
+ return {
1251
+ id: input.id,
1252
+ status: input.status,
1253
+ changed: true,
1254
+ claimedBy: input.status === "acknowledged" ? by : void 0
1255
+ };
1256
+ });
1257
+ }
1258
+ async function reply(store, input, by) {
1259
+ return withAnnotationLock(store, input.id, async (files) => {
1260
+ const state = await readJson(store, files.state, State);
1261
+ if (isClosed(state.status)) throw closedError(input.id, state.status);
1262
+ requireClaimant(input.id, await readClaim(store, input.id), by);
1263
+ const entry = {
1264
+ at: (/* @__PURE__ */ new Date()).toISOString(),
1265
+ from: "agent",
1266
+ text: input.text
1267
+ };
1268
+ await appendJsonLine(store, files.thread, entry);
1269
+ return {
1270
+ id: input.id,
1271
+ entry
1272
+ };
1273
+ });
1274
+ }
1275
+ async function readErrors(store) {
1276
+ try {
1277
+ return await readJson(store, liveErrorsFile(store), LiveErrorsSnapshot);
1278
+ } catch (thrown) {
1279
+ if (isErrno(thrown, "ENOENT")) return void 0;
1280
+ throw thrown;
1281
+ }
1282
+ }
1283
+ async function errors(store, input) {
1284
+ const snapshot = await readErrors(store);
1285
+ if (snapshot === void 0) return {
1286
+ updatedAt: null,
1287
+ total: 0,
1288
+ groups: []
1289
+ };
1290
+ const since = input.since === void 0 ? void 0 : Date.parse(input.since);
1291
+ const open = snapshot.groups.filter((group) => group.status === "open" && (since === void 0 || Date.parse(group.lastSeen) >= since)).sort((a, b) => Date.parse(b.lastSeen) - Date.parse(a.lastSeen));
1292
+ return {
1293
+ updatedAt: snapshot.updatedAt,
1294
+ total: open.length,
1295
+ groups: open.slice(0, input.limit).map((group) => errorGroupView(group, input.detail))
1296
+ };
1297
+ }
1298
+ /** Merges new or changed groups into live/errors.json by fingerprint. Used by the Vite plugin. */
1299
+ async function upsertErrorGroups(store, groups) {
1300
+ const merged = new Map(((await readErrors(store))?.groups ?? []).map((group) => [group.fingerprint, group]));
1301
+ for (const group of groups) merged.set(group.fingerprint, group);
1302
+ const snapshot = {
1303
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1304
+ groups: [...merged.values()].sort((a, b) => Date.parse(b.lastSeen) - Date.parse(a.lastSeen)).slice(0, MAX_ERROR_GROUPS)
1305
+ };
1306
+ await writeJsonAtomic(store, liveErrorsFile(store), snapshot);
1307
+ return snapshot;
1308
+ }
1309
+ /**
1310
+ * Checks the capture files an annotation declares: each under capture/, listed
1311
+ * once, and every attachment and crop path among them.
1312
+ */
1313
+ function checkCaptureFiles(draft, paths) {
1314
+ const seen = /* @__PURE__ */ new Set();
1315
+ for (const file of paths) {
1316
+ if (!file.startsWith("capture/")) throw new PkaError(`Capture file ${file} must be under capture/`);
1317
+ if (seen.has(file)) throw new PkaError(`Capture file ${file} is listed twice`);
1318
+ seen.add(file);
1319
+ }
1320
+ const missing = [...draft.attachments.map((attachment) => attachment.path), ...draft.elements.flatMap((element) => element.crop === void 0 ? [] : [element.crop])].filter((reference) => !seen.has(reference));
1321
+ if (missing.length > 0) throw new PkaError(`Annotation references files that were not sent: ${missing.join(", ")}`);
1322
+ }
1323
+ /**
1324
+ * Writes a new pending annotation. Capture files are staged on disk by the
1325
+ * Vite plugin and move into place with the annotation in one directory rename.
1326
+ */
1327
+ async function create(store, draft, staged) {
1328
+ checkCaptureFiles(draft, staged?.paths ?? []);
1329
+ const now = /* @__PURE__ */ new Date();
1330
+ const id = newId(now.getTime());
1331
+ const annotation = Annotation.parse({
1332
+ id,
1333
+ createdAt: now.toISOString(),
1334
+ ...draft
1335
+ });
1336
+ const state = {
1337
+ status: "pending",
1338
+ history: [{
1339
+ status: "pending",
1340
+ at: now.toISOString()
1341
+ }]
1342
+ };
1343
+ await writeAnnotationDir(store, id, [{
1344
+ path: "annotation.json",
1345
+ data: `${JSON.stringify(annotation, null, 2)}\n`
1346
+ }, {
1347
+ path: "state.json",
1348
+ data: `${JSON.stringify(state, null, 2)}\n`
1349
+ }], staged);
1350
+ return { id };
1351
+ }
1352
+ async function checkAttachTarget(store, files, id, by) {
1353
+ const state = await readJson(store, files.state, State);
1354
+ if (isClosed(state.status)) throw closedError(id, state.status);
1355
+ requireClaimant(id, await readClaim(store, id), by);
1356
+ }
1357
+ /**
1358
+ * Fails as attach would when the annotation is missing, closed, or claimed by another
1359
+ * claimant, without writing. Check before expensive work; attach checks again because the
1360
+ * annotation can change in between.
1361
+ */
1362
+ async function validateAttachTarget(store, id, by) {
1363
+ await checkAttachTarget(store, await requireAnnotation(store, id), id, by);
1364
+ }
1365
+ /**
1366
+ * Adds files to an existing annotation's capture/ directory and lists them in
1367
+ * annotation.json. An agent write: resolved and dismissed annotations refuse
1368
+ * it. Existing files are never overwritten; on any failure no new file stays
1369
+ * behind and annotation.json is unchanged.
1370
+ */
1371
+ async function attach(store, id, files, by) {
1372
+ const added = files.map((file) => {
1373
+ const parsed = Attachment.safeParse({
1374
+ kind: file.kind,
1375
+ path: file.path,
1376
+ summary: file.summary
1377
+ });
1378
+ if (!parsed.success) throw new PkaError(`Invalid attachment ${file.path}:\n${z.prettifyError(parsed.error)}`);
1379
+ return parsed.data;
1380
+ });
1381
+ return withAnnotationLock(store, id, async (paths) => {
1382
+ await checkAttachTarget(store, paths, id, by);
1383
+ const annotation = await readJson(store, paths.annotation, Annotation);
1384
+ checkCaptureFiles({
1385
+ ...annotation,
1386
+ elements: [],
1387
+ attachments: added
1388
+ }, added.map((attachment) => attachment.path));
1389
+ const listed = new Set(annotation.attachments.map((attachment) => attachment.path));
1390
+ const relisted = added.filter((attachment) => listed.has(attachment.path));
1391
+ if (relisted.length > 0) throw new PkaError(`Annotation ${id} already lists ${relisted.map((a) => a.path).join(", ")}`);
1392
+ const placed = await placeNewFiles(store, paths.dir, files.map((file) => ({
1393
+ path: file.path,
1394
+ data: file.data
1395
+ })));
1396
+ const next = {
1397
+ ...annotation,
1398
+ attachments: [...annotation.attachments, ...added]
1399
+ };
1400
+ try {
1401
+ await writeJsonAtomic(store, paths.annotation, next);
1402
+ } catch (thrown) {
1403
+ await Promise.all(placed.map((file) => rm(file, { force: true })));
1404
+ throw thrown;
1405
+ }
1406
+ return {
1407
+ id,
1408
+ attachments: next.attachments
1409
+ };
1410
+ });
1411
+ }
1412
+ async function prune(store) {
1413
+ return { removed: await prune$1(store, Date.now()) };
1414
+ }
1415
+ //#endregion
1416
+ //#region src/shared/timeline.ts
1417
+ const Seq$1 = z.number().int().nonnegative();
1418
+ const ConsoleEntry = z.strictObject({
1419
+ kind: z.literal("console"),
1420
+ seq: Seq$1,
1421
+ at: Timestamp,
1422
+ level: z.enum([
1423
+ "log",
1424
+ "info",
1425
+ "warn",
1426
+ "error",
1427
+ "debug"
1428
+ ]),
1429
+ args: z.array(z.json())
1430
+ });
1431
+ const ErrorSource = z.enum([
1432
+ "window",
1433
+ "resource",
1434
+ "rejection",
1435
+ "console",
1436
+ "react-caught",
1437
+ "react-uncaught",
1438
+ "react-recoverable"
1439
+ ]);
1440
+ const ErrorEntry = z.strictObject({
1441
+ kind: z.literal("error"),
1442
+ seq: Seq$1,
1443
+ at: Timestamp,
1444
+ source: ErrorSource,
1445
+ fingerprint: z.string().min(1).max(200),
1446
+ type: z.string(),
1447
+ message: z.string(),
1448
+ stack: z.string().optional(),
1449
+ componentStack: z.string().optional(),
1450
+ ownerStack: z.string().optional(),
1451
+ resource: z.strictObject({
1452
+ tag: z.string(),
1453
+ url: z.string()
1454
+ }).optional()
1455
+ });
1456
+ const ServerTiming = z.strictObject({
1457
+ name: z.string(),
1458
+ duration: z.number(),
1459
+ description: z.string()
1460
+ });
1461
+ const RequestEntry = z.strictObject({
1462
+ kind: z.literal("request"),
1463
+ seq: Seq$1,
1464
+ at: Timestamp,
1465
+ performanceMs: z.number().nonnegative().optional(),
1466
+ initiator: z.enum(["fetch", "xhr"]),
1467
+ method: z.string(),
1468
+ url: z.string(),
1469
+ state: z.enum([
1470
+ "pending",
1471
+ "open",
1472
+ "done",
1473
+ "failed",
1474
+ "aborted"
1475
+ ]),
1476
+ status: z.number().int().nonnegative().optional(),
1477
+ error: z.string().optional(),
1478
+ durationMs: z.number().nonnegative().optional(),
1479
+ requestSize: z.number().int().nonnegative().optional(),
1480
+ responseSize: z.number().int().nonnegative().optional(),
1481
+ transferSize: z.number().int().nonnegative().optional(),
1482
+ responseType: z.string().optional(),
1483
+ contentType: z.string().optional(),
1484
+ stream: z.boolean(),
1485
+ serverFn: z.boolean(),
1486
+ traceparent: z.string().optional(),
1487
+ requestHeaders: z.record(z.string(), z.string()),
1488
+ responseHeaders: z.record(z.string(), z.string()),
1489
+ requestBody: z.string().optional(),
1490
+ responseBody: z.string().optional(),
1491
+ serverTiming: z.array(ServerTiming).optional()
1492
+ });
1493
+ const ActionTarget = z.strictObject({
1494
+ tag: z.string(),
1495
+ id: z.string().optional(),
1496
+ testId: z.string().optional(),
1497
+ src: z.string().optional(),
1498
+ role: z.string().optional(),
1499
+ text: z.string().optional(),
1500
+ label: z.string().optional()
1501
+ });
1502
+ const ActionEntry = z.strictObject({
1503
+ kind: z.literal("action"),
1504
+ seq: Seq$1,
1505
+ at: Timestamp,
1506
+ performanceMs: z.number().nonnegative().optional(),
1507
+ durationMs: z.number().nonnegative().optional(),
1508
+ type: z.enum([
1509
+ "click",
1510
+ "input",
1511
+ "change",
1512
+ "submit",
1513
+ "keydown"
1514
+ ]),
1515
+ key: z.enum(["Enter", "Escape"]).optional(),
1516
+ target: ActionTarget
1517
+ });
1518
+ const NavigationEntry = z.strictObject({
1519
+ kind: z.literal("navigation"),
1520
+ seq: Seq$1,
1521
+ at: Timestamp,
1522
+ type: z.enum([
1523
+ "load",
1524
+ "push",
1525
+ "replace",
1526
+ "reload",
1527
+ "traverse"
1528
+ ]),
1529
+ from: z.string().optional(),
1530
+ to: z.string()
1531
+ });
1532
+ const TimelineEntry = z.discriminatedUnion("kind", [
1533
+ ConsoleEntry,
1534
+ ErrorEntry,
1535
+ RequestEntry,
1536
+ ActionEntry,
1537
+ NavigationEntry
1538
+ ]);
1539
+ //#endregion
1540
+ //#region src/shared/recording.ts
1541
+ const Seq = z.number().int().nonnegative();
1542
+ const Count = z.number().int().nonnegative();
1543
+ const RecordingFrame = z.strictObject({
1544
+ path: RelativePath,
1545
+ seq: Seq,
1546
+ at: Timestamp
1547
+ });
1548
+ const RecordingVideo = z.union([z.strictObject({
1549
+ path: RelativePath,
1550
+ mimeType: z.string(),
1551
+ bytes: Count,
1552
+ startedAt: Timestamp,
1553
+ endedAt: Timestamp,
1554
+ restrictedTo: z.literal("body"),
1555
+ truncated: z.boolean()
1556
+ }), z.strictObject({
1557
+ path: z.null(),
1558
+ reason: z.string()
1559
+ })]);
1560
+ const RecordingManifestDraft = z.strictObject({
1561
+ url: z.string(),
1562
+ endUrl: z.string(),
1563
+ viewport: Viewport,
1564
+ startedAt: Timestamp,
1565
+ endedAt: Timestamp,
1566
+ seq: z.strictObject({
1567
+ first: Seq,
1568
+ last: Seq
1569
+ }).nullable(),
1570
+ entries: z.strictObject({
1571
+ recorded: Count,
1572
+ limit: Count,
1573
+ dropped: Count
1574
+ }),
1575
+ bodies: z.strictObject({
1576
+ limitBytes: Count,
1577
+ dropped: Count
1578
+ }),
1579
+ frames: z.strictObject({
1580
+ items: z.array(RecordingFrame),
1581
+ limit: Count,
1582
+ dropped: Count,
1583
+ failed: Count
1584
+ }),
1585
+ video: RecordingVideo,
1586
+ region: Box.nullable().optional(),
1587
+ gif: z.strictObject({
1588
+ path: RelativePath,
1589
+ width: Count,
1590
+ height: Count,
1591
+ frames: Count,
1592
+ durationMs: Count,
1593
+ truncated: z.boolean()
1594
+ }).optional(),
1595
+ redaction: z.strictObject({
1596
+ inputs: z.literal("values never recorded; masked in keyframes"),
1597
+ headers: z.literal("credential headers dropped"),
1598
+ urlParams: z.literal("credential-like values replaced with REDACTED"),
1599
+ bodies: z.array(z.string()),
1600
+ storage: z.literal("not read"),
1601
+ video: z.literal("not redacted; shows what the tab showed, typed values included")
1602
+ })
1603
+ });
1604
+ const RecordingManifest = RecordingManifestDraft.extend({ gitSha: z.string().regex(/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/).nullable() });
1605
+ const RecordingErrors = z.strictObject({ groups: z.array(ErrorGroup) });
1606
+ const Header = z.strictObject({
1607
+ name: z.string(),
1608
+ value: z.string()
1609
+ });
1610
+ RequestEntry.pick({
1611
+ seq: true,
1612
+ state: true,
1613
+ initiator: true,
1614
+ error: true,
1615
+ stream: true,
1616
+ serverFn: true,
1617
+ traceparent: true,
1618
+ serverTiming: true
1619
+ }).extend({
1620
+ startedDateTime: Timestamp,
1621
+ time: z.number().nonnegative().nullable(),
1622
+ request: z.strictObject({
1623
+ method: z.string(),
1624
+ url: z.string(),
1625
+ headers: z.array(Header),
1626
+ bodySize: z.number().int().min(-1),
1627
+ postData: z.strictObject({
1628
+ mimeType: z.string(),
1629
+ text: z.string()
1630
+ }).optional()
1631
+ }),
1632
+ response: z.strictObject({
1633
+ status: Count,
1634
+ headers: z.array(Header),
1635
+ content: z.strictObject({
1636
+ size: z.number().int().min(-1),
1637
+ mimeType: z.string(),
1638
+ text: z.string().optional()
1639
+ }),
1640
+ transferSize: z.number().int().min(-1)
1641
+ })
1642
+ });
1643
+ //#endregion
1644
+ export { createStore as A, Id as B, agentsDir as C, MissingAnnotationError as D, DEFAULT_SIZE_CAP_BYTES as E, readJsonLines as F, State as H, resolveInside as I, AnnotationDraft as L, isErrno as M, listIds as N, PkaError as O, readJson as P, ErrorGroup as R, wait as S, AgentPresence as T, ThreadEntry as U, RelativePath as V, Timestamp as W, prune as _, ErrorsInput as a, upsertErrorGroups as b, ReplyInput as c, checkCaptureFiles as d, create as f, loadAnnotationUpdates as g, list as h, TimelineEntry as i, findStore as j, checkStoreSize as k, SetStatusInput as l, get as m, RecordingManifest as n, GetInput as o, errors as p, RecordingManifestDraft as r, ListInput as s, RecordingErrors as t, attach as u, reply as v, liveAgents as w, validateAttachTarget as x, setStatus as y, ID_PATTERN as z };