agent-lookout 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,12 @@
1
1
  import {
2
2
  sortSessions
3
- } from "./chunk-XJM5RZNV.js";
3
+ } from "./chunk-54ODUY75.js";
4
4
  import {
5
+ EVENT_KINDS,
6
+ EVENT_SEVERITIES,
5
7
  MAX_NAME_LENGTH,
6
8
  MAX_NOTICE_TEXT_LENGTH,
9
+ SESSION_STATUSES,
7
10
  agentName,
8
11
  changeNotice,
9
12
  clean,
@@ -21,7 +24,7 @@ import {
21
24
  waitingPhrase,
22
25
  waitingText,
23
26
  withoutWaitingText
24
- } from "./chunk-PQ3B5TN6.js";
27
+ } from "./chunk-GOX5UB5L.js";
25
28
 
26
29
  // src/collector/hosts/server.ts
27
30
  import { createServer } from "node:http";
@@ -158,6 +161,7 @@ function sessionChanges(memory, snapshot) {
158
161
  }
159
162
 
160
163
  // src/core/api.ts
164
+ var CLEAR_HISTORY_ACTION = "clear-history";
161
165
  var TERMINAL_JUMP_TIMEOUT_MS = 6e4;
162
166
  var JUMP_INTERVAL_MS = 1e3;
163
167
  var ACTION_HEADER = "X-Agent-Lookout-Action";
@@ -212,12 +216,12 @@ function waitBegan(statusSince, seenAt) {
212
216
 
213
217
  // src/collector/outbound/outboundChannel.ts
214
218
  function createOutboundChannel(options) {
215
- const { afterMs, waitMessage, overMessage, failure: failure2 } = options;
219
+ const { afterMs, asking, waitMessage, overMessage, failure: failure2 } = options;
216
220
  const now = options.now ?? Date.now;
217
221
  const wanted = new Set(options.events);
218
222
  let memory = EMPTY_CHANGE_MEMORY;
219
223
  const over = [];
220
- const open3 = /* @__PURE__ */ new Map();
224
+ const open4 = /* @__PURE__ */ new Map();
221
225
  const done = /* @__PURE__ */ new Map();
222
226
  let sentAt = [];
223
227
  let last = null;
@@ -238,16 +242,21 @@ function createOutboundChannel(options) {
238
242
  const at = now();
239
243
  const result = sessionChanges(memory, snapshot);
240
244
  memory = result.memory;
241
- for (const id of result.stopped) open3.delete(id);
245
+ for (const id of result.stopped) open4.delete(id);
242
246
  for (const { event, session } of result.changes) {
243
247
  if (!wanted.has(event)) continue;
244
248
  if (event !== "needs-you") {
245
- over.push({ event, session, agent: agentName(session, snapshot.sources), seenAt: at });
249
+ over.push({
250
+ event,
251
+ session: withoutWaitingText(session),
252
+ agent: agentName(session, snapshot.sources),
253
+ seenAt: at
254
+ });
246
255
  continue;
247
256
  }
248
257
  const since = session.statusSince;
249
258
  if (since !== null && done.get(session.id) === since) continue;
250
- open3.set(session.id, waitBegan(since, at));
259
+ open4.set(session.id, waitBegan(since, at));
251
260
  }
252
261
  sentAt = sendsInLastHour(sentAt, at);
253
262
  while (over.length > 0 && limitLiftsAt(sentAt, at) === null) {
@@ -255,19 +264,26 @@ function createOutboundChannel(options) {
255
264
  sentAt.push(at);
256
265
  send2(overMessage({ ...facts, now: at }));
257
266
  }
258
- for (const [id, begunAt] of open3) {
267
+ for (const [id, begunAt] of open4) {
259
268
  const session = snapshot.sessions.find(
260
269
  (candidate) => candidate.id === id && candidate.status === "needs-you"
261
270
  );
262
271
  if (!session || sendTiming(begunAt, afterMs, sentAt, at) !== "send") continue;
263
- open3.delete(id);
272
+ open4.delete(id);
264
273
  sentAt.push(at);
265
274
  send2(
266
- waitMessage({ session, agent: agentName(session, snapshot.sources), begunAt, now: at })
275
+ waitMessage({
276
+ session: withoutWaitingText(session),
277
+ agent: agentName(session, snapshot.sources),
278
+ // Read now, from this poll's snapshot, and only when it is to go.
279
+ asking: asking ? waitingText(session.waitingText) ?? null : null,
280
+ begunAt,
281
+ now: at
282
+ })
267
283
  );
268
284
  }
269
285
  for (const session of snapshot.sessions) {
270
- if (session.status === "needs-you" && session.statusSince !== null && !open3.has(session.id)) {
286
+ if (session.status === "needs-you" && session.statusSince !== null && !open4.has(session.id)) {
271
287
  done.set(session.id, session.statusSince);
272
288
  }
273
289
  }
@@ -310,6 +326,13 @@ function readEvents(name, value) {
310
326
  }
311
327
  return events;
312
328
  }
329
+ function readAsking(name, value) {
330
+ if (value === void 0) return false;
331
+ const said = value.toLowerCase();
332
+ if (said === "on") return true;
333
+ if (said === "off") return false;
334
+ throw new SettingProblem(`${name} must be on or off.`);
335
+ }
313
336
 
314
337
  // src/collector/email/emailSettings.ts
315
338
  var EMAIL_TO_ENV = "AGENT_LOOKOUT_EMAIL_TO";
@@ -317,6 +340,7 @@ var SMTP_URL_ENV = "AGENT_LOOKOUT_SMTP_URL";
317
340
  var EMAIL_FROM_ENV = "AGENT_LOOKOUT_EMAIL_FROM";
318
341
  var EMAIL_AFTER_ENV = "AGENT_LOOKOUT_EMAIL_AFTER";
319
342
  var EMAIL_EVENTS_ENV = "AGENT_LOOKOUT_EMAIL_EVENTS";
343
+ var EMAIL_ASKING_ENV = "AGENT_LOOKOUT_EMAIL_ASKING";
320
344
  var ADDRESS = /^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
321
345
  var MAX_ADDRESS_LENGTH = 254;
322
346
  var HOST_NAME = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*$/;
@@ -375,6 +399,7 @@ function readEmailSetup(env) {
375
399
  const from = valueOf(env, EMAIL_FROM_ENV);
376
400
  const after = valueOf(env, EMAIL_AFTER_ENV);
377
401
  const events = valueOf(env, EMAIL_EVENTS_ENV);
402
+ const asking = valueOf(env, EMAIL_ASKING_ENV);
378
403
  if (to === void 0) return { on: false, problem: null };
379
404
  if (url === void 0) return { on: false, problem: `${SMTP_URL_ENV} is not set.` };
380
405
  try {
@@ -385,6 +410,7 @@ function readEmailSetup(env) {
385
410
  from: from === void 0 ? to : readAddress(EMAIL_FROM_ENV, from),
386
411
  events: readEvents(EMAIL_EVENTS_ENV, events),
387
412
  afterMs: readAfter(EMAIL_AFTER_ENV, after),
413
+ asking: readAsking(EMAIL_ASKING_ENV, asking),
388
414
  server: readServer(url)
389
415
  }
390
416
  };
@@ -402,8 +428,10 @@ function maskAddress(address) {
402
428
  }
403
429
 
404
430
  // src/collector/email/emailMessage.ts
405
- function emailOf(happened, when, session, agentName2) {
431
+ function emailOf(happened, when, session, agentName2, asking = null) {
406
432
  const lines = [`${happened}.`, "", when, ""];
433
+ const said = waitingText(asking);
434
+ if (said) lines.push(`Asking: ${said}`);
407
435
  const folder = session.project ? oneLine(session.project) : "";
408
436
  if (folder) lines.push(`Folder: ${folder}`);
409
437
  const app = surfaceLabel(session.surface);
@@ -423,7 +451,8 @@ function waitEmail(facts) {
423
451
  `${oneLine(sessionTitle(session))} ${waitingPhrase(session)}`,
424
452
  `It has waited ${durationInWords(now - begunAt)}, since ${clockAt(begunAt, now)}.`,
425
453
  session,
426
- facts.agent
454
+ facts.agent,
455
+ facts.asking
427
456
  );
428
457
  }
429
458
  function overEmail(facts) {
@@ -443,6 +472,7 @@ function emailOffStatus(problem) {
443
472
  to: null,
444
473
  events: null,
445
474
  afterMs: null,
475
+ asking: null,
446
476
  problem,
447
477
  last: null,
448
478
  limitedUntil: null
@@ -453,6 +483,7 @@ function createEmailNotifications(options) {
453
483
  const channel = createOutboundChannel({
454
484
  events: settings.events,
455
485
  afterMs: settings.afterMs,
486
+ asking: settings.asking,
456
487
  waitMessage: waitEmail,
457
488
  overMessage: overEmail,
458
489
  send: (content) => sender.send(content),
@@ -467,6 +498,7 @@ function createEmailNotifications(options) {
467
498
  to: maskAddress(settings.to),
468
499
  events: [...settings.events],
469
500
  afterMs: settings.afterMs,
501
+ asking: settings.asking,
470
502
  problem: null,
471
503
  last: channel.last(),
472
504
  limitedUntil: channel.limitedUntil()
@@ -476,6 +508,207 @@ function createEmailNotifications(options) {
476
508
  };
477
509
  }
478
510
 
511
+ // src/collector/history/historyFormat.ts
512
+ var HISTORY_FORMAT_VERSION = 1;
513
+ var MAX_LINE_LENGTH = 8 * 1024;
514
+ var MAX_FIELD_LENGTH = 1024;
515
+ var DAY_MS = 24 * 60 * 60 * 1e3;
516
+ var FILE_NAME = /^v([1-9]\d{0,3})-(\d{4})-(\d{2})-(\d{2})(?:-([1-9]\d{0,3}))?\.jsonl$/;
517
+ function dayOf(at) {
518
+ return new Date(at).toISOString().slice(0, 10);
519
+ }
520
+ function historyFileName(day, part = 1) {
521
+ const suffix = part > 1 ? `-${part}` : "";
522
+ return `v${HISTORY_FORMAT_VERSION}-${day}${suffix}.jsonl`;
523
+ }
524
+ function readHistoryFileName(name) {
525
+ const match = FILE_NAME.exec(name);
526
+ if (!match) return null;
527
+ const [, version, year, month, date, part] = match;
528
+ const day = `${year}-${month}-${date}`;
529
+ const dayStart = Date.parse(`${day}T00:00:00.000Z`);
530
+ if (!Number.isFinite(dayStart) || dayOf(dayStart) !== day) return null;
531
+ return { name, version: Number(version), day, dayStart, part: part ? Number(part) : 1 };
532
+ }
533
+ function byFileOrder(a, b) {
534
+ return a.dayStart - b.dayStart || a.part - b.part;
535
+ }
536
+ function dayEnd(file) {
537
+ return file.dayStart + DAY_MS;
538
+ }
539
+ function recordAt(record) {
540
+ if (record.kind === "point") return record.point.at;
541
+ if (record.kind === "event") return record.event.at;
542
+ return record.at;
543
+ }
544
+ function encodeRecord(record) {
545
+ return `${JSON.stringify(recordValue(record))}
546
+ `;
547
+ }
548
+ function recordValue(record) {
549
+ switch (record.kind) {
550
+ case "start":
551
+ return { start: record.at };
552
+ case "cleared":
553
+ return { cleared: record.at };
554
+ case "point": {
555
+ const { at, needsYou, working, idle, total } = record.point;
556
+ return { point: [at, needsYou, working, idle, total] };
557
+ }
558
+ case "event":
559
+ return { event: eventFields(record.event) };
560
+ }
561
+ }
562
+ function eventFields(event) {
563
+ const { id, at, sessionId, sessionName: sessionName2, kind, from, to, severity } = event;
564
+ return {
565
+ id,
566
+ at,
567
+ sessionId,
568
+ sessionName: sessionName2,
569
+ kind,
570
+ ...from !== void 0 && { from },
571
+ ...to !== void 0 && { to },
572
+ severity
573
+ };
574
+ }
575
+ function isRecord(value) {
576
+ return typeof value === "object" && value !== null && !Array.isArray(value);
577
+ }
578
+ function moment(value) {
579
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : null;
580
+ }
581
+ function count(value) {
582
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
583
+ }
584
+ function field(value) {
585
+ return typeof value === "string" && value !== "" && value.length <= MAX_FIELD_LENGTH ? value : null;
586
+ }
587
+ function oneOf(list, value) {
588
+ return list.find((item) => item === value) ?? null;
589
+ }
590
+ function readPoint(value) {
591
+ if (!Array.isArray(value) || value.length !== 5) return null;
592
+ const at = moment(value[0]);
593
+ const [needsYou, working, idle, total] = value.slice(1).map(count);
594
+ if (at === null || needsYou == null || working == null || idle == null || total == null) {
595
+ return null;
596
+ }
597
+ return { at, needsYou, working, idle, total };
598
+ }
599
+ function readEvent(value) {
600
+ if (!isRecord(value)) return null;
601
+ const id = field(value.id);
602
+ const at = moment(value.at);
603
+ const sessionId = field(value.sessionId);
604
+ const sessionName2 = field(value.sessionName);
605
+ const kind = oneOf(EVENT_KINDS, value.kind);
606
+ const severity = oneOf(EVENT_SEVERITIES, value.severity);
607
+ if (!id || at === null || !sessionId || !sessionName2 || !kind || !severity) return null;
608
+ const from = oneOf(SESSION_STATUSES, value.from);
609
+ const to = oneOf(SESSION_STATUSES, value.to);
610
+ if (value.from !== void 0 && !from || value.to !== void 0 && !to) return null;
611
+ return {
612
+ id,
613
+ at,
614
+ sessionId,
615
+ sessionName: sessionName2,
616
+ kind,
617
+ ...from && { from },
618
+ ...to && { to },
619
+ severity
620
+ };
621
+ }
622
+ function parseRecord(line) {
623
+ if (line === "" || line.length > MAX_LINE_LENGTH) return null;
624
+ let value;
625
+ try {
626
+ value = JSON.parse(line);
627
+ } catch {
628
+ return null;
629
+ }
630
+ if (!isRecord(value)) return null;
631
+ if ("point" in value) {
632
+ const point = readPoint(value.point);
633
+ return point && { kind: "point", point };
634
+ }
635
+ if ("event" in value) {
636
+ const event = readEvent(value.event);
637
+ return event && { kind: "event", event };
638
+ }
639
+ if ("start" in value) {
640
+ const at = moment(value.start);
641
+ return at === null ? null : { kind: "start", at };
642
+ }
643
+ if ("cleared" in value) {
644
+ const at = moment(value.cleared);
645
+ return at === null ? null : { kind: "cleared", at };
646
+ }
647
+ return null;
648
+ }
649
+ function restartsAmong(starts, times) {
650
+ const ordered = [...times].sort((a, b) => a - b);
651
+ const restarts = [];
652
+ let next = 0;
653
+ let lastBefore = null;
654
+ for (const at of [...new Set(starts)].sort((a, b) => a - b)) {
655
+ while (next < ordered.length && ordered[next] < at) {
656
+ lastBefore = ordered[next];
657
+ next += 1;
658
+ }
659
+ if (lastBefore !== null) restarts.push({ at, lastBefore });
660
+ }
661
+ return restarts;
662
+ }
663
+ function parseHistoryText(text4) {
664
+ const records = [];
665
+ let skipped = 0;
666
+ for (const line of text4.split("\n")) {
667
+ if (line.trim() === "") continue;
668
+ const record = parseRecord(line.trim());
669
+ if (record) records.push(record);
670
+ else skipped += 1;
671
+ }
672
+ return { records, skipped };
673
+ }
674
+
675
+ // src/collector/history/historyLimits.ts
676
+ var HISTORY_MAX_BYTES = 20 * 1024 * 1024;
677
+ var HISTORY_MAX_FILE_BYTES = 2 * 1024 * 1024;
678
+ var HISTORY_MAX_AGE_MS = 8 * 24 * 60 * 60 * 1e3;
679
+ function memoryOnlyStatus() {
680
+ return {
681
+ where: "memory",
682
+ folder: null,
683
+ bytes: null,
684
+ maxBytes: HISTORY_MAX_BYTES,
685
+ maxAgeMs: HISTORY_MAX_AGE_MS,
686
+ canClear: false,
687
+ problem: null
688
+ };
689
+ }
690
+ function filesToDelete(files, writingTo, incoming, limits) {
691
+ const ordered = [...files].sort(byFileOrder);
692
+ const doomed = [];
693
+ let total = 0;
694
+ const left = [];
695
+ for (const file of ordered) {
696
+ if (file.name !== writingTo && dayEnd(file) + limits.maxAgeMs < limits.now) {
697
+ doomed.push(file.name);
698
+ } else {
699
+ left.push(file);
700
+ total += file.size;
701
+ }
702
+ }
703
+ for (const file of left) {
704
+ if (total + incoming <= limits.maxBytes) break;
705
+ if (file.name === writingTo) continue;
706
+ doomed.push(file.name);
707
+ total -= file.size;
708
+ }
709
+ return doomed;
710
+ }
711
+
479
712
  // src/collector/historyStore.ts
480
713
  var HISTORY_CAPACITY = 10800;
481
714
  function createHistoryStore(capacity = HISTORY_CAPACITY) {
@@ -492,6 +725,9 @@ function createHistoryStore(capacity = HISTORY_CAPACITY) {
492
725
  while (start > 0 && points[start - 1].at >= from) start -= 1;
493
726
  return points.slice(start);
494
727
  },
728
+ clear() {
729
+ points = [];
730
+ },
495
731
  get size() {
496
732
  return points.length;
497
733
  }
@@ -609,6 +845,7 @@ function createPoller(options) {
609
845
  let inFlight = null;
610
846
  let timer = null;
611
847
  const memory = /* @__PURE__ */ new Map();
848
+ const resumed = /* @__PURE__ */ new Map();
612
849
  const overdue = /* @__PURE__ */ new Map();
613
850
  const settled = /* @__PURE__ */ new Map();
614
851
  function stillWaiting(adapter, since) {
@@ -625,11 +862,11 @@ function createPoller(options) {
625
862
  };
626
863
  }
627
864
  function ask(adapter) {
628
- const open3 = overdue.get(adapter);
629
- if (open3) {
630
- if (open3.late === void 0) return Promise.resolve(stillWaiting(adapter, open3.since));
865
+ const open4 = overdue.get(adapter);
866
+ if (open4) {
867
+ if (open4.late === void 0) return Promise.resolve(stillWaiting(adapter, open4.since));
631
868
  overdue.delete(adapter);
632
- return Promise.resolve(open3.late);
869
+ return Promise.resolve(open4.late);
633
870
  }
634
871
  const since = now();
635
872
  return new Promise((resolve) => {
@@ -683,9 +920,12 @@ function createPoller(options) {
683
920
  if (state !== "ok" && state !== "not-set-up") return;
684
921
  const id = adapters[index].id;
685
922
  let remembered = memory.get(id);
923
+ let lastSeen;
686
924
  if (!remembered) {
687
925
  remembered = { baselines: /* @__PURE__ */ new Map(), reported: /* @__PURE__ */ new Map() };
688
926
  memory.set(id, remembered);
927
+ lastSeen = resumed.get(id);
928
+ resumed.delete(id);
689
929
  }
690
930
  const { baselines, reported } = remembered;
691
931
  const basis = result.basis ?? "";
@@ -693,6 +933,14 @@ function createPoller(options) {
693
933
  if (previous) {
694
934
  changes.push(...withoutRepeats(diffSessions(previous, result.sessions, at), reported));
695
935
  } else {
936
+ if (lastSeen) {
937
+ const known = new Set(lastSeen.map((session) => session.id));
938
+ const before = lastSeen.filter(
939
+ (session) => session.status !== null
940
+ );
941
+ const after = result.sessions.filter((session) => known.has(session.id));
942
+ changes.push(...diffSessions(before, after, at));
943
+ }
696
944
  for (const session of result.sessions) {
697
945
  if (!reported.has(session.id)) reported.set(session.id, session.status);
698
946
  }
@@ -746,6 +994,24 @@ function createPoller(options) {
746
994
  timer = null;
747
995
  },
748
996
  pollOnce,
997
+ resume(kept) {
998
+ const newest2 = /* @__PURE__ */ new Map();
999
+ for (const event of kept) {
1000
+ const held = newest2.get(event.sessionId);
1001
+ if (!held || event.at >= held.at) newest2.set(event.sessionId, event);
1002
+ }
1003
+ resumed.clear();
1004
+ for (const event of newest2.values()) {
1005
+ const status = event.kind === "ended" ? null : event.to ?? null;
1006
+ if (event.kind !== "ended" && status === null) continue;
1007
+ const colon = event.sessionId.indexOf(":");
1008
+ if (colon <= 0) continue;
1009
+ const source = event.sessionId.slice(0, colon);
1010
+ const list = resumed.get(source) ?? [];
1011
+ list.push({ id: event.sessionId, name: event.sessionName, status });
1012
+ resumed.set(source, list);
1013
+ }
1014
+ },
749
1015
  getSnapshot() {
750
1016
  if (latest) return latest;
751
1017
  const at = now();
@@ -786,10 +1052,13 @@ function lineOf(happened, facts) {
786
1052
  }
787
1053
  function waitPost(facts) {
788
1054
  const { begunAt, now } = facts;
1055
+ const asking = waitingText(facts.asking);
789
1056
  const session = sessionOf(facts.session, facts.agent);
790
1057
  const waitedMs = Math.max(0, now - begunAt);
1058
+ const happened = `${session.name} ${waitingPhrase(facts.session)}`;
791
1059
  return {
792
- text: lineOf(`${session.name} ${waitingPhrase(facts.session)}`, [
1060
+ // In `text` it is made safe for Slack with the rest.
1061
+ text: lineOf(asking ? `${happened}: ${asking}` : happened, [
793
1062
  formatDuration(waitedMs),
794
1063
  session.folder,
795
1064
  session.app,
@@ -797,6 +1066,8 @@ function waitPost(facts) {
797
1066
  ]),
798
1067
  event: "needs-you",
799
1068
  reason: facts.session.waitingReason ?? "other",
1069
+ // Left out, not null, when it does not go, so a post without it is the post it always was.
1070
+ ...asking ? { asking } : {},
800
1071
  session,
801
1072
  at: new Date(begunAt).toISOString(),
802
1073
  waitedSeconds: Math.floor(waitedMs / 1e3)
@@ -823,6 +1094,7 @@ function webhookOffStatus(problem) {
823
1094
  host: null,
824
1095
  events: null,
825
1096
  afterMs: null,
1097
+ asking: null,
826
1098
  problem,
827
1099
  last: null,
828
1100
  limitedUntil: null
@@ -833,6 +1105,7 @@ function createWebhookNotifications(options) {
833
1105
  const channel = createOutboundChannel({
834
1106
  events: settings.events,
835
1107
  afterMs: settings.afterMs,
1108
+ asking: settings.asking,
836
1109
  waitMessage: waitPost,
837
1110
  overMessage: overPost,
838
1111
  send: (post) => sender.send(post),
@@ -848,6 +1121,7 @@ function createWebhookNotifications(options) {
848
1121
  host: settings.url.hostname,
849
1122
  events: [...settings.events],
850
1123
  afterMs: settings.afterMs,
1124
+ asking: settings.asking,
851
1125
  problem: null,
852
1126
  last: channel.last(),
853
1127
  limitedUntil: channel.limitedUntil()
@@ -859,6 +1133,7 @@ function createWebhookNotifications(options) {
859
1133
 
860
1134
  // src/collector/handler.ts
861
1135
  var JUMP_PATH = "/api/jump";
1136
+ var CLEAR_HISTORY_PATH = "/api/history/clear";
862
1137
  var MAX_HISTORY_WINDOW_MS = HISTORY_CAPACITY * POLL_INTERVAL_MS;
863
1138
  var LOOPBACK_HOST = /^(localhost|127\.0\.0\.1|\[::1\])(:\d{1,5})?$/i;
864
1139
  var LOOPBACK_ORIGIN = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d{1,5})?$/i;
@@ -886,6 +1161,66 @@ function refusalFor(req) {
886
1161
  }
887
1162
  return null;
888
1163
  }
1164
+ function header(req, name) {
1165
+ const value = req.headers[name.toLowerCase()];
1166
+ return typeof value === "string" ? value : void 0;
1167
+ }
1168
+ function refusal(status, error, headers = {}) {
1169
+ return {
1170
+ status,
1171
+ body: { error },
1172
+ headers: { Connection: "close", ...headers }
1173
+ };
1174
+ }
1175
+ function actionRefusalFor(req, action, maxBodyBytes) {
1176
+ if (req.method !== "POST") {
1177
+ return refusal(405, "This address only answers POST requests.", { Allow: "POST" });
1178
+ }
1179
+ const origin = header(req, "origin");
1180
+ if (origin === void 0 || !isLoopbackOrigin(origin)) {
1181
+ return refusal(403, "This address only acts for the dashboard page served from this machine.");
1182
+ }
1183
+ const site = req.headers["sec-fetch-site"];
1184
+ if (site !== void 0 && site !== "same-origin") {
1185
+ return refusal(403, "This address only acts for the dashboard page served from this machine.");
1186
+ }
1187
+ if (header(req, ACTION_HEADER) !== action) {
1188
+ return refusal(
1189
+ 403,
1190
+ `A request to this address must carry the header ${ACTION_HEADER}: ${action}.`
1191
+ );
1192
+ }
1193
+ const contentType = header(req, "content-type")?.split(";", 1)[0]?.trim().toLowerCase();
1194
+ if (contentType !== "application/json") {
1195
+ return refusal(415, "The body must be JSON, sent as application/json.");
1196
+ }
1197
+ const length = header(req, "content-length");
1198
+ if (length !== void 0 && (!/^\d+$/.test(length) || Number(length) > maxBodyBytes)) {
1199
+ return refusal(413, `The body must be no more than ${maxBodyBytes} bytes.`);
1200
+ }
1201
+ return null;
1202
+ }
1203
+ function readRequestBody(req, limit) {
1204
+ return new Promise((resolve) => {
1205
+ const chunks = [];
1206
+ let received = 0;
1207
+ let done = false;
1208
+ const finish = (body) => {
1209
+ if (done) return;
1210
+ done = true;
1211
+ resolve(body);
1212
+ };
1213
+ req.on("data", (chunk) => {
1214
+ if (done) return;
1215
+ received += chunk.byteLength;
1216
+ if (received > limit) finish({ ok: false, tooLarge: true });
1217
+ else chunks.push(chunk);
1218
+ });
1219
+ req.on("end", () => finish({ ok: true, text: Buffer.concat(chunks).toString("utf8") }));
1220
+ req.on("error", () => finish({ ok: false, tooLarge: false }));
1221
+ req.on("close", () => finish({ ok: false, tooLarge: false }));
1222
+ });
1223
+ }
889
1224
  function send(res, status, body, headers = {}) {
890
1225
  for (const name of res.getHeaderNames()) {
891
1226
  if (name.toLowerCase().startsWith("access-control-")) res.removeHeader(name);
@@ -921,10 +1256,13 @@ function numberParam(value) {
921
1256
  function createApiHandler(options) {
922
1257
  const { version, poller, events, history, notifications, email, webhook, jump } = options;
923
1258
  const now = options.now ?? Date.now;
1259
+ const actions = /* @__PURE__ */ new Map();
1260
+ if (jump) actions.set(JUMP_PATH, jump);
1261
+ if (options.clearHistory) actions.set(CLEAR_HISTORY_PATH, options.clearHistory);
924
1262
  function route(req, res) {
925
- const refusal = refusalFor(req);
926
- if (refusal) {
927
- fail(res, 403, refusal);
1263
+ const refusal2 = refusalFor(req);
1264
+ if (refusal2) {
1265
+ fail(res, 403, refusal2);
928
1266
  return;
929
1267
  }
930
1268
  let url;
@@ -933,8 +1271,9 @@ function createApiHandler(options) {
933
1271
  } catch {
934
1272
  url = null;
935
1273
  }
936
- if (jump && url?.pathname === JUMP_PATH) {
937
- jump(req).then((answer) => send(res, answer.status, answer.body, answer.headers)).catch(() => {
1274
+ const action = url === null ? void 0 : actions.get(url.pathname);
1275
+ if (action) {
1276
+ action(req).then((answer2) => send(res, answer2.status, answer2.body, answer2.headers)).catch(() => {
938
1277
  if (res.headersSent) res.end();
939
1278
  else fail(res, 500, "The collector ran into an unexpected problem.");
940
1279
  }).catch(() => {
@@ -988,24 +1327,49 @@ function createApiHandler(options) {
988
1327
  return;
989
1328
  }
990
1329
  const windowMs = Math.min(requested ?? DEFAULT_HISTORY_WINDOW_MS, MAX_HISTORY_WINDOW_MS);
1330
+ const held = options.historyKept?.();
1331
+ const since = held?.since ?? { at: poller.startedAt, by: "started" };
991
1332
  send(res, 200, {
992
1333
  points: history.list(windowMs, now()),
993
- startedAt: poller.startedAt
1334
+ startedAt: poller.startedAt,
1335
+ since,
1336
+ kept: held?.kept ?? memoryOnlyStatus(),
1337
+ // A restart before where the history now begins was cleared, or let go.
1338
+ restarts: (held?.restarts ?? []).filter((restart) => restart.at > since.at)
994
1339
  });
995
1340
  return;
996
1341
  }
1342
+ case "/api/waits": {
1343
+ if (options.waits) {
1344
+ send(res, 200, options.waits());
1345
+ return;
1346
+ }
1347
+ fail(res, 404, "There is nothing at that address.");
1348
+ return;
1349
+ }
997
1350
  default: {
998
1351
  fail(res, 404, "There is nothing at that address.");
999
1352
  }
1000
1353
  }
1001
1354
  }
1002
- return (req, res) => {
1355
+ function answer(req, res) {
1003
1356
  try {
1004
1357
  route(req, res);
1005
1358
  } catch {
1006
1359
  if (res.headersSent) res.end();
1007
1360
  else fail(res, 500, "The collector ran into an unexpected problem.");
1008
1361
  }
1362
+ }
1363
+ return (req, res) => {
1364
+ const reading = options.ready?.() ?? null;
1365
+ if (reading === null) {
1366
+ answer(req, res);
1367
+ return;
1368
+ }
1369
+ void reading.then(
1370
+ () => answer(req, res),
1371
+ () => answer(req, res)
1372
+ );
1009
1373
  };
1010
1374
  }
1011
1375
 
@@ -1223,7 +1587,229 @@ function urlFor(address) {
1223
1587
 
1224
1588
  // src/collector/hosts/standalone.ts
1225
1589
  import { existsSync } from "node:fs";
1226
- import path14 from "node:path";
1590
+ import path16 from "node:path";
1591
+
1592
+ // src/core/waits/measured.ts
1593
+ var POLL_GAP_MS = 8e3;
1594
+ function pollRuns(times, starts, gapMs = POLL_GAP_MS) {
1595
+ const ordered = [...times].sort((a, b) => a - b);
1596
+ const breaks = [...starts].sort((a, b) => a - b);
1597
+ const runs = [];
1598
+ let next = 0;
1599
+ for (const at of ordered) {
1600
+ const run = runs[runs.length - 1];
1601
+ let restarted = false;
1602
+ while (next < breaks.length && breaks[next] <= at) {
1603
+ if (run && breaks[next] > run.to) restarted = true;
1604
+ next += 1;
1605
+ }
1606
+ if (run && !restarted && at - run.to <= gapMs) run.to = Math.max(run.to, at);
1607
+ else runs.push({ from: at, to: at });
1608
+ }
1609
+ return runs;
1610
+ }
1611
+ function addPoll(runs, at, fresh = false, gapMs = POLL_GAP_MS) {
1612
+ const run = runs[runs.length - 1];
1613
+ if (run && !fresh && at >= run.to && at - run.to <= gapMs) {
1614
+ run.to = at;
1615
+ } else if (!run || fresh || at > run.to) {
1616
+ runs.push({ from: at, to: at });
1617
+ }
1618
+ }
1619
+ function joined(spans) {
1620
+ const ordered = spans.filter((span) => span.to >= span.from).sort((a, b) => a.from - b.from);
1621
+ const out = [];
1622
+ for (const span of ordered) {
1623
+ const last = out[out.length - 1];
1624
+ if (last && span.from <= last.to) last.to = Math.max(last.to, span.to);
1625
+ else out.push({ ...span });
1626
+ }
1627
+ return out;
1628
+ }
1629
+ function coveredWithin(spans, from, to) {
1630
+ if (to <= from) return 0;
1631
+ let low = 0;
1632
+ let high = spans.length;
1633
+ while (low < high) {
1634
+ const middle = low + high >> 1;
1635
+ if (spans[middle].to <= from) low = middle + 1;
1636
+ else high = middle;
1637
+ }
1638
+ let sum = 0;
1639
+ for (let index = low; index < spans.length; index += 1) {
1640
+ const span = spans[index];
1641
+ if (span.from >= to) break;
1642
+ sum += Math.min(span.to, to) - Math.max(span.from, from);
1643
+ }
1644
+ return sum;
1645
+ }
1646
+
1647
+ // src/core/waits/waitTotals.ts
1648
+ var MAX_WAIT_SESSIONS = 10;
1649
+ var WAIT_DAYS = 7;
1650
+ function isWaitEvent(event) {
1651
+ return event.to === "needs-you" || event.from === "needs-you";
1652
+ }
1653
+ function unendedWaits(events) {
1654
+ const newest2 = /* @__PURE__ */ new Map();
1655
+ for (const event of events) {
1656
+ if (!isWaitEvent(event)) continue;
1657
+ const held = newest2.get(event.sessionId);
1658
+ if (!held || event.at >= held.at) newest2.set(event.sessionId, event);
1659
+ }
1660
+ return [...newest2.values()].filter((event) => event.kind !== "ended" && event.to === "needs-you");
1661
+ }
1662
+ function midnightOf(at) {
1663
+ const date = new Date(at);
1664
+ date.setHours(0, 0, 0, 0);
1665
+ return date.getTime();
1666
+ }
1667
+ function midnightAfter(midnight, days) {
1668
+ const date = new Date(midnight);
1669
+ date.setDate(date.getDate() + days);
1670
+ date.setHours(0, 0, 0, 0);
1671
+ return date.getTime();
1672
+ }
1673
+ function pad2(value) {
1674
+ return String(value).padStart(2, "0");
1675
+ }
1676
+ function localDay(at) {
1677
+ const date = new Date(at);
1678
+ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
1679
+ }
1680
+ function latestAtOrBefore(starts, at, fallback) {
1681
+ let found = fallback;
1682
+ for (const start of starts) {
1683
+ if (start > at) break;
1684
+ found = Math.max(found, start);
1685
+ }
1686
+ return found;
1687
+ }
1688
+ function waitsOf(input) {
1689
+ const { now, since } = input;
1690
+ const starts = [...input.runStarts].sort((a, b) => a - b);
1691
+ const runStart = (at) => latestAtOrBefore(starts, at, since);
1692
+ const waitingNow = new Map(input.waitingNow.map((session) => [session.id, session.name]));
1693
+ const thisRun = runStart(now);
1694
+ const bySession = /* @__PURE__ */ new Map();
1695
+ for (const event of input.events) {
1696
+ if (!isWaitEvent(event) || event.at > now) continue;
1697
+ const own = bySession.get(event.sessionId);
1698
+ if (own) own.push(event);
1699
+ else bySession.set(event.sessionId, [event]);
1700
+ }
1701
+ const waits = [];
1702
+ const names = /* @__PURE__ */ new Map();
1703
+ const unended = /* @__PURE__ */ new Set();
1704
+ for (const [sessionId, own] of bySession) {
1705
+ own.sort((a, b) => a.at - b.at);
1706
+ let began = null;
1707
+ let previous = null;
1708
+ for (const event of own) {
1709
+ if (began === null && event.from === "needs-you") {
1710
+ began = Math.max(runStart(event.at), previous ?? -Infinity);
1711
+ }
1712
+ const waitsAfter = event.kind !== "ended" && event.to === "needs-you";
1713
+ if (began !== null && !waitsAfter) {
1714
+ waits.push({ sessionId, from: began, to: event.at, open: false });
1715
+ began = null;
1716
+ } else if (began === null && waitsAfter) {
1717
+ began = event.at;
1718
+ }
1719
+ previous = event.at;
1720
+ names.set(sessionId, event.sessionName);
1721
+ }
1722
+ if (began !== null) {
1723
+ const open4 = waitingNow.has(sessionId);
1724
+ waits.push({ sessionId, from: began, to: open4 || began >= thisRun ? now : thisRun, open: open4 });
1725
+ unended.add(sessionId);
1726
+ }
1727
+ }
1728
+ for (const [sessionId, name] of input.names ?? []) names.set(sessionId, name);
1729
+ for (const [sessionId, name] of waitingNow) {
1730
+ names.set(sessionId, name);
1731
+ if (unended.has(sessionId)) continue;
1732
+ const own = bySession.get(sessionId);
1733
+ const last = own?.[own.length - 1]?.at ?? -Infinity;
1734
+ waits.push({ sessionId, from: Math.max(thisRun, last), to: now, open: true });
1735
+ }
1736
+ const kept = waits.map((wait) => ({ ...wait, from: Math.max(wait.from, since) })).filter((wait) => wait.to > wait.from);
1737
+ kept.sort((a, b) => a.from - b.from);
1738
+ return { waits: kept, names };
1739
+ }
1740
+ function totalIn(waits, measured, from, to) {
1741
+ let waitedMs = 0;
1742
+ let openMs = 0;
1743
+ let count2 = 0;
1744
+ const bySession = /* @__PURE__ */ new Map();
1745
+ for (const wait of waits) {
1746
+ if (wait.from >= to) break;
1747
+ if (wait.to <= from) continue;
1748
+ const ms = coveredWithin(measured, Math.max(wait.from, from), Math.min(wait.to, to));
1749
+ if (ms <= 0) continue;
1750
+ waitedMs += ms;
1751
+ count2 += 1;
1752
+ if (wait.open) openMs += ms;
1753
+ const own = bySession.get(wait.sessionId) ?? { ms: 0, waits: 0, open: false };
1754
+ own.ms += ms;
1755
+ own.waits += 1;
1756
+ own.open ||= wait.open;
1757
+ bySession.set(wait.sessionId, own);
1758
+ }
1759
+ return {
1760
+ total: { waitedMs, openMs, waits: count2, measuredMs: coveredWithin(measured, from, to) },
1761
+ bySession
1762
+ };
1763
+ }
1764
+ function periodOf(waits, measured, names, from, to) {
1765
+ const { total, bySession } = totalIn(waits, measured, from, to);
1766
+ const days = [];
1767
+ for (let start = from; start < to; ) {
1768
+ const end = Math.min(midnightAfter(midnightOf(start), 1), to);
1769
+ days.push({
1770
+ day: localDay(start),
1771
+ from: start,
1772
+ to: end,
1773
+ ...totalIn(waits, measured, start, end).total
1774
+ });
1775
+ start = end;
1776
+ }
1777
+ if (days.length === 0) {
1778
+ days.push({ day: localDay(from), from, to, waitedMs: 0, openMs: 0, waits: 0, measuredMs: 0 });
1779
+ }
1780
+ const sessions = [...bySession].map(([sessionId, own]) => ({
1781
+ sessionId,
1782
+ name: names.get(sessionId) ?? sessionId,
1783
+ waitedMs: own.ms,
1784
+ waits: own.waits,
1785
+ open: own.open
1786
+ })).sort((a, b) => b.waitedMs - a.waitedMs || a.name.localeCompare(b.name));
1787
+ return {
1788
+ from,
1789
+ to,
1790
+ ...total,
1791
+ days,
1792
+ sessions: sessions.slice(0, MAX_WAIT_SESSIONS),
1793
+ sessionCount: sessions.length
1794
+ };
1795
+ }
1796
+ function waitTotals(input) {
1797
+ const { now, since } = input;
1798
+ const gapMs = input.gapMs ?? POLL_GAP_MS;
1799
+ const spans = joined(input.measured);
1800
+ const newest2 = spans[spans.length - 1];
1801
+ if (newest2 && newest2.to < now && now - newest2.to <= gapMs && !input.runStarts.some((start) => start > newest2.to && start <= now)) {
1802
+ newest2.to = now;
1803
+ }
1804
+ const measured = spans.map((span) => ({ from: Math.max(span.from, since), to: Math.min(span.to, now) })).filter((span) => span.to > span.from);
1805
+ const { waits, names } = waitsOf(input);
1806
+ const today = midnightOf(now);
1807
+ const weekStart = midnightAfter(today, -(WAIT_DAYS - 1));
1808
+ return {
1809
+ today: periodOf(waits, measured, names, today, now),
1810
+ sevenDays: periodOf(waits, measured, names, weekStart, now)
1811
+ };
1812
+ }
1227
1813
 
1228
1814
  // src/collector/adapters/claude-code/index.ts
1229
1815
  import os from "node:os";
@@ -1893,7 +2479,7 @@ function build(fields, origin, context) {
1893
2479
  const project = projectOf(fields.cwd);
1894
2480
  const surface = mapClaudeCodeSurface(fields.entrypoint);
1895
2481
  const mapped = mapClaudeCodeStatus(fields, origin);
1896
- const open3 = claudeCodeOpenLink(surface, fields.sessionId);
2482
+ const open4 = claudeCodeOpenLink(surface, fields.sessionId);
1897
2483
  const startedAt = plausibleTime(fields.startedAt, context.now);
1898
2484
  let statusSince = plausibleTime(fields.statusUpdatedAt, context.now);
1899
2485
  if (statusSince !== null && startedAt !== null && statusSince < startedAt - WRITE_ORDER_SLACK_MS) {
@@ -1910,7 +2496,7 @@ function build(fields, origin, context) {
1910
2496
  ...mapped,
1911
2497
  startedAt,
1912
2498
  statusSince,
1913
- links: open3 ? { open: open3 } : {},
2499
+ links: open4 ? { open: open4 } : {},
1914
2500
  stale: isStale({ status: mapped.status, statusSince }, context.now)
1915
2501
  };
1916
2502
  if (fields.pid !== void 0) {
@@ -2022,7 +2608,7 @@ function isMissing(error) {
2022
2608
  }
2023
2609
 
2024
2610
  // src/collector/adapters/claude-code/transcript/lastAsk.ts
2025
- function isRecord(value) {
2611
+ function isRecord2(value) {
2026
2612
  return typeof value === "object" && value !== null && !Array.isArray(value);
2027
2613
  }
2028
2614
  function nonEmpty(value) {
@@ -2030,8 +2616,8 @@ function nonEmpty(value) {
2030
2616
  }
2031
2617
  function blocksOf(line) {
2032
2618
  const message = line.message;
2033
- if (!isRecord(message) || !Array.isArray(message.content)) return [];
2034
- return message.content.filter(isRecord);
2619
+ if (!isRecord2(message) || !Array.isArray(message.content)) return [];
2620
+ return message.content.filter(isRecord2);
2035
2621
  }
2036
2622
  function linesOf(tail, fromStart) {
2037
2623
  const pieces = tail.split("\n");
@@ -2041,7 +2627,7 @@ function linesOf(tail, fromStart) {
2041
2627
  if (piece.trim() === "") continue;
2042
2628
  try {
2043
2629
  const value = JSON.parse(piece);
2044
- if (isRecord(value)) lines.push(value);
2630
+ if (isRecord2(value)) lines.push(value);
2045
2631
  } catch {
2046
2632
  }
2047
2633
  }
@@ -2049,7 +2635,7 @@ function linesOf(tail, fromStart) {
2049
2635
  }
2050
2636
  function isPrompt(line) {
2051
2637
  if (line.isMeta === true) return false;
2052
- const message = isRecord(line.message) ? line.message : null;
2638
+ const message = isRecord2(line.message) ? line.message : null;
2053
2639
  if (message === null) return false;
2054
2640
  if (typeof message.content === "string") return message.content.trim() !== "";
2055
2641
  return blocksOf(line).some((block) => block.type === "text");
@@ -2060,11 +2646,11 @@ function pendingToolUse(lines) {
2060
2646
  let movedOn = false;
2061
2647
  for (const line of lines) {
2062
2648
  if (line.isSidechain === true) continue;
2063
- const message = isRecord(line.message) ? line.message : null;
2649
+ const message = isRecord2(line.message) ? line.message : null;
2064
2650
  if (line.type === "assistant") {
2065
2651
  const messageId = nonEmpty(message?.id);
2066
- const newest2 = uses.at(-1);
2067
- if (newest2 !== void 0 && (messageId === null || messageId !== newest2.messageId)) {
2652
+ const newest3 = uses.at(-1);
2653
+ if (newest3 !== void 0 && (messageId === null || messageId !== newest3.messageId)) {
2068
2654
  movedOn = true;
2069
2655
  }
2070
2656
  for (const block of blocksOf(line)) {
@@ -2072,7 +2658,7 @@ function pendingToolUse(lines) {
2072
2658
  const id = nonEmpty(block.id);
2073
2659
  const name = nonEmpty(block.name);
2074
2660
  if (id === null || name === null) continue;
2075
- uses.push({ id, name, input: isRecord(block.input) ? block.input : {}, messageId });
2661
+ uses.push({ id, name, input: isRecord2(block.input) ? block.input : {}, messageId });
2076
2662
  movedOn = false;
2077
2663
  }
2078
2664
  } else if (line.type === "user") {
@@ -2083,10 +2669,10 @@ function pendingToolUse(lines) {
2083
2669
  if (uses.length > 0 && isPrompt(line)) movedOn = true;
2084
2670
  }
2085
2671
  }
2086
- const newest = uses.at(-1);
2087
- if (newest === void 0 || movedOn) return null;
2088
- if (newest.messageId === null) return answered.has(newest.id) ? null : newest;
2089
- return uses.find((use) => use.messageId === newest.messageId && !answered.has(use.id)) ?? null;
2672
+ const newest2 = uses.at(-1);
2673
+ if (newest2 === void 0 || movedOn) return null;
2674
+ if (newest2.messageId === null) return answered.has(newest2.id) ? null : newest2;
2675
+ return uses.find((use) => use.messageId === newest2.messageId && !answered.has(use.id)) ?? null;
2090
2676
  }
2091
2677
  function firstLine(command) {
2092
2678
  return command.split(/\r?\n/).find((line) => line.trim() !== "") ?? null;
@@ -2113,7 +2699,7 @@ function describeToolUse(name, input, cwd) {
2113
2699
  if (name === "AskUserQuestion") {
2114
2700
  const questions = Array.isArray(input.questions) ? input.questions : [];
2115
2701
  const first = questions[0];
2116
- const question = isRecord(first) ? nonEmpty(first.question) : null;
2702
+ const question = isRecord2(first) ? nonEmpty(first.question) : null;
2117
2703
  if (question === null) return null;
2118
2704
  return questions.length > 1 ? `${question} (+${questions.length - 1} more)` : question;
2119
2705
  }
@@ -2189,12 +2775,12 @@ function sameStamp(a, b) {
2189
2775
  return a.size === b.size && a.mtimeMs === b.mtimeMs && a.ino === b.ino;
2190
2776
  }
2191
2777
  async function readTranscriptTail(file, io, known, maxBytes = TRANSCRIPT_TAIL_BYTES) {
2192
- const open3 = await io.openRegular(file);
2778
+ const open4 = await io.openRegular(file);
2193
2779
  try {
2194
- const stamp = stampOf(open3.info);
2780
+ const stamp = stampOf(open4.info);
2195
2781
  if (known !== null && sameStamp(known, stamp)) return { unchanged: true, stamp };
2196
2782
  const start = Math.max(0, stamp.size - maxBytes);
2197
- const bytes2 = await open3.read(start, stamp.size - start);
2783
+ const bytes2 = await open4.read(start, stamp.size - start);
2198
2784
  return {
2199
2785
  unchanged: false,
2200
2786
  stamp,
@@ -2202,7 +2788,7 @@ async function readTranscriptTail(file, io, known, maxBytes = TRANSCRIPT_TAIL_BY
2202
2788
  fromStart: start === 0
2203
2789
  };
2204
2790
  } finally {
2205
- await open3.close();
2791
+ await open4.close();
2206
2792
  }
2207
2793
  }
2208
2794
 
@@ -2582,19 +3168,19 @@ var TURN_UNDER_WAY = /* @__PURE__ */ new Set(["task_started", "turn_started"]);
2582
3168
  var TURN_OVER = /* @__PURE__ */ new Set(["task_complete", "turn_complete", "turn_aborted"]);
2583
3169
  function mapCodexStatus(fields) {
2584
3170
  const { lastTurn, live } = fields;
2585
- let open3;
3171
+ let open4;
2586
3172
  if (lastTurn === null) {
2587
- open3 = "idle";
3173
+ open4 = "idle";
2588
3174
  } else if (typeof lastTurn !== "string") {
2589
3175
  return "unknown";
2590
3176
  } else if (TURN_UNDER_WAY.has(lastTurn)) {
2591
- open3 = "working";
3177
+ open4 = "working";
2592
3178
  } else if (TURN_OVER.has(lastTurn)) {
2593
- open3 = "idle";
3179
+ open4 = "idle";
2594
3180
  } else {
2595
3181
  return "unknown";
2596
3182
  }
2597
- return live === false ? "finished" : open3;
3183
+ return live === false ? "finished" : open4;
2598
3184
  }
2599
3185
  var WRITER_LOCKS_SINCE = [0, 155, 0];
2600
3186
  var VERSION = /^(\d{1,6})\.(\d{1,6})\.(\d{1,6})(-[0-9A-Za-z.-]{1,64})?(?:\+[0-9A-Za-z.-]{1,64})?$/;
@@ -2618,7 +3204,7 @@ var IMPORTED_TURN_ID = /^external-import-turn-\d{1,9}$/;
2618
3204
  function isImportedCodexTurn(turnId) {
2619
3205
  return typeof turnId === "string" && IMPORTED_TURN_ID.test(turnId);
2620
3206
  }
2621
- function isRecord2(value) {
3207
+ function isRecord3(value) {
2622
3208
  return typeof value === "object" && value !== null && !Array.isArray(value);
2623
3209
  }
2624
3210
  var CODEX_DESKTOP_ORIGINATOR = "Codex Desktop";
@@ -2626,7 +3212,7 @@ function mapCodexSurface(source, originator) {
2626
3212
  if (originator === CODEX_DESKTOP_ORIGINATOR) return "desktop";
2627
3213
  if (source === "cli" || source === "exec") return "terminal";
2628
3214
  if (source === "vscode") return "vscode";
2629
- if (isRecord2(source) && source.custom === "chatgpt") return "desktop";
3215
+ if (isRecord3(source) && source.custom === "chatgpt") return "desktop";
2630
3216
  return "unknown";
2631
3217
  }
2632
3218
  var NOT_A_SESSION_SOURCE = /* @__PURE__ */ new Set(["subagent", "internal", "mcp"]);
@@ -2637,7 +3223,7 @@ function isCodexSessionSource(source, meta = {}) {
2637
3223
  return false;
2638
3224
  }
2639
3225
  if (typeof source === "string") return !NOT_A_SESSION_SOURCE.has(source);
2640
- if (isRecord2(source)) return !Object.keys(source).some((kind) => NOT_A_SESSION_SOURCE.has(kind));
3226
+ if (isRecord3(source)) return !Object.keys(source).some((kind) => NOT_A_SESSION_SOURCE.has(kind));
2641
3227
  return true;
2642
3228
  }
2643
3229
 
@@ -2656,7 +3242,7 @@ function parseCodexTime(value) {
2656
3242
  var LINE_TIME = /^\{"timestamp":"([^"]{1,64})"/;
2657
3243
  var TURN_HINT = /"type":"(?:task|turn)_/;
2658
3244
  var TURN_TYPE = /^(?:task|turn)_/;
2659
- function isRecord3(value) {
3245
+ function isRecord4(value) {
2660
3246
  return typeof value === "object" && value !== null && !Array.isArray(value);
2661
3247
  }
2662
3248
  function text3(value) {
@@ -2667,7 +3253,7 @@ function label(value) {
2667
3253
  }
2668
3254
  function compactSource(value) {
2669
3255
  if (typeof value === "string") return text3(value);
2670
- if (!isRecord3(value)) return void 0;
3256
+ if (!isRecord4(value)) return void 0;
2671
3257
  return Object.fromEntries(
2672
3258
  Object.entries(value).map(([kind, detail]) => [
2673
3259
  kind,
@@ -2678,7 +3264,7 @@ function compactSource(value) {
2678
3264
  function parse(line) {
2679
3265
  try {
2680
3266
  const value = JSON.parse(line);
2681
- return isRecord3(value) ? value : null;
3267
+ return isRecord4(value) ? value : null;
2682
3268
  } catch {
2683
3269
  return null;
2684
3270
  }
@@ -2686,7 +3272,7 @@ function parse(line) {
2686
3272
  function readMetaLine(line) {
2687
3273
  if (!line.includes('"session_meta"')) return null;
2688
3274
  const value = parse(line);
2689
- if (!value || value.type !== "session_meta" || !isRecord3(value.payload)) return null;
3275
+ if (!value || value.type !== "session_meta" || !isRecord4(value.payload)) return null;
2690
3276
  const payload = value.payload;
2691
3277
  const meta = {
2692
3278
  id: text3(payload.id),
@@ -2706,7 +3292,7 @@ function readMetaLine(line) {
2706
3292
  function readTurnLine(line) {
2707
3293
  if (!line.includes('"event_msg"') || !TURN_HINT.test(line)) return null;
2708
3294
  const value = parse(line);
2709
- if (!value || value.type !== "event_msg" || !isRecord3(value.payload)) return null;
3295
+ if (!value || value.type !== "event_msg" || !isRecord4(value.payload)) return null;
2710
3296
  const turn = value.payload.type;
2711
3297
  if (typeof turn !== "string" || !TURN_TYPE.test(turn)) return null;
2712
3298
  return {
@@ -2988,7 +3574,7 @@ function createRolloutFinder(options) {
2988
3574
  // src/collector/adapters/codex/sessionIndex.ts
2989
3575
  var SESSION_INDEX_FILE = "session_index.jsonl";
2990
3576
  var SESSION_INDEX_LIMIT_BYTES = 4 * 1024 * 1024;
2991
- function isRecord4(value) {
3577
+ function isRecord5(value) {
2992
3578
  return typeof value === "object" && value !== null && !Array.isArray(value);
2993
3579
  }
2994
3580
  function parseSessionIndex(content) {
@@ -3001,7 +3587,7 @@ function parseSessionIndex(content) {
3001
3587
  } catch {
3002
3588
  continue;
3003
3589
  }
3004
- if (!isRecord4(value)) continue;
3590
+ if (!isRecord5(value)) continue;
3005
3591
  const { id, thread_name: name } = value;
3006
3592
  if (typeof id !== "string" || id === "" || typeof name !== "string") continue;
3007
3593
  const trimmed = name.trim();
@@ -3107,12 +3693,12 @@ async function readWriterLocks(dir, io) {
3107
3693
  } catch (error) {
3108
3694
  return { supported: false, missing: isMissing(error) };
3109
3695
  }
3110
- const open3 = /* @__PURE__ */ new Set();
3696
+ const open4 = /* @__PURE__ */ new Set();
3111
3697
  for (const name of names) {
3112
3698
  const threadId = threadIdOfLock(name);
3113
- if (threadId !== null) open3.add(threadId);
3699
+ if (threadId !== null) open4.add(threadId);
3114
3700
  }
3115
- return { supported: true, open: open3 };
3701
+ return { supported: true, open: open4 };
3116
3702
  }
3117
3703
 
3118
3704
  // src/collector/adapters/codex/index.ts
@@ -3276,7 +3862,7 @@ function createCodexAdapter(options = {}) {
3276
3862
  const chosen = /* @__PURE__ */ new Map();
3277
3863
  const subagentWrites = /* @__PURE__ */ new Map();
3278
3864
  for (const ref of [...candidates.values()].sort((a, b) => a.path < b.path ? -1 : 1)) {
3279
- const open3 = locked(ref.threadId);
3865
+ const open4 = locked(ref.threadId);
3280
3866
  let info;
3281
3867
  try {
3282
3868
  info = await io.lstat(ref.path);
@@ -3284,7 +3870,7 @@ function createCodexAdapter(options = {}) {
3284
3870
  tracked.delete(ref.path);
3285
3871
  continue;
3286
3872
  }
3287
- if (info.kind !== "file" || !open3 && info.mtimeMs < cutoff) {
3873
+ if (info.kind !== "file" || !open4 && info.mtimeMs < cutoff) {
3288
3874
  tracked.delete(ref.path);
3289
3875
  continue;
3290
3876
  }
@@ -3307,7 +3893,7 @@ function createCodexAdapter(options = {}) {
3307
3893
  }
3308
3894
  if (state.lastTurnImported) continue;
3309
3895
  const at = lastActivity(state, checkedAt);
3310
- if (!open3 && (at === null || !isWithinRetention(at, checkedAt))) {
3896
+ if (!open4 && (at === null || !isWithinRetention(at, checkedAt))) {
3311
3897
  tracked.delete(ref.path);
3312
3898
  continue;
3313
3899
  }
@@ -3496,8 +4082,8 @@ var STATUS_FILE_CAPABILITIES = {
3496
4082
  jump: { level: "no", reason: "Nothing in a status file is used to reach a session." },
3497
4083
  "quiet-for": { level: "partly", reason: "If the agent writes its file again as it works." }
3498
4084
  };
3499
- function counted(count, one, many) {
3500
- return `${count} ${count === 1 ? one : many}`;
4085
+ function counted(count2, one, many) {
4086
+ return `${count2} ${count2 === 1 ? one : many}`;
3501
4087
  }
3502
4088
  function createStatusFileAdapter(options = {}) {
3503
4089
  const env = options.env ?? process.env;
@@ -3540,9 +4126,9 @@ function createStatusFileAdapter(options = {}) {
3540
4126
  return { health, sessions: [] };
3541
4127
  }
3542
4128
  async function readOne(file) {
3543
- let open3;
4129
+ let open4;
3544
4130
  try {
3545
- open3 = await io.openRegular(file);
4131
+ open4 = await io.openRegular(file);
3546
4132
  } catch (error) {
3547
4133
  if (isMissing(error)) return { kind: "gone" };
3548
4134
  const code = error?.code;
@@ -3551,15 +4137,15 @@ function createStatusFileAdapter(options = {}) {
3551
4137
  }
3552
4138
  const tooLarge = { kind: "refused", why: "is over 16 KB" };
3553
4139
  try {
3554
- if (open3.info.size > MAX_FILE_BYTES) return tooLarge;
3555
- const bytes2 = await open3.read(0, MAX_FILE_BYTES + 1);
4140
+ if (open4.info.size > MAX_FILE_BYTES) return tooLarge;
4141
+ const bytes2 = await open4.read(0, MAX_FILE_BYTES + 1);
3556
4142
  if (bytes2.byteLength > MAX_FILE_BYTES) return tooLarge;
3557
4143
  const parsed = parseStatusFile(decoder2.decode(bytes2));
3558
- return parsed ? { kind: "read", file: parsed, mtimeMs: open3.info.mtimeMs } : { kind: "invalid" };
4144
+ return parsed ? { kind: "read", file: parsed, mtimeMs: open4.info.mtimeMs } : { kind: "invalid" };
3559
4145
  } catch {
3560
4146
  return { kind: "refused", why: "could not be read" };
3561
4147
  } finally {
3562
- await open3.close().catch(() => {
4148
+ await open4.close().catch(() => {
3563
4149
  });
3564
4150
  }
3565
4151
  }
@@ -3832,6 +4418,9 @@ function createEventStore(capacity = EVENT_CAPACITY) {
3832
4418
  }
3833
4419
  return newestFirst;
3834
4420
  },
4421
+ clear() {
4422
+ events = [];
4423
+ },
3835
4424
  get size() {
3836
4425
  return events.length;
3837
4426
  }
@@ -3900,21 +4489,21 @@ var MAX_LEVELS = 24;
3900
4489
  var READ_WAIT_MS = 1e3;
3901
4490
  var decoder = new TextDecoder("utf-8", { fatal: true });
3902
4491
  async function readSmall(io, file) {
3903
- let open3;
4492
+ let open4;
3904
4493
  try {
3905
- open3 = await io.openRegular(file);
4494
+ open4 = await io.openRegular(file);
3906
4495
  } catch (error) {
3907
4496
  return isMissing(error) ? void 0 : null;
3908
4497
  }
3909
4498
  try {
3910
- if (open3.info.size > MAX_GIT_FILE_BYTES) return null;
3911
- const bytes2 = await open3.read(0, MAX_GIT_FILE_BYTES + 1);
4499
+ if (open4.info.size > MAX_GIT_FILE_BYTES) return null;
4500
+ const bytes2 = await open4.read(0, MAX_GIT_FILE_BYTES + 1);
3912
4501
  if (bytes2.byteLength > MAX_GIT_FILE_BYTES) return null;
3913
4502
  return decoder.decode(bytes2);
3914
4503
  } catch {
3915
4504
  return null;
3916
4505
  } finally {
3917
- await open3.close().catch(() => {
4506
+ await open4.close().catch(() => {
3918
4507
  });
3919
4508
  }
3920
4509
  }
@@ -4016,52 +4605,916 @@ function createBranchFinder(options = {}) {
4016
4605
  };
4017
4606
  }
4018
4607
 
4019
- // src/collector/terminal/terminalTabs.ts
4608
+ // src/collector/history/clearRoute.ts
4609
+ var MAX_CLEAR_BODY_BYTES = 64;
4610
+ function isEmptyObject(body) {
4611
+ let value;
4612
+ try {
4613
+ value = JSON.parse(body);
4614
+ } catch {
4615
+ return false;
4616
+ }
4617
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 0;
4618
+ }
4619
+ var failed = (status, reason, error) => ({
4620
+ status,
4621
+ body: { error, reason }
4622
+ });
4623
+ function createClearHistoryRoute(options) {
4624
+ const { keeper, forget } = options;
4625
+ return async function answerClear(req) {
4626
+ const refused = actionRefusalFor(req, CLEAR_HISTORY_ACTION, MAX_CLEAR_BODY_BYTES);
4627
+ if (refused) {
4628
+ req.resume();
4629
+ return refused;
4630
+ }
4631
+ const body = await readRequestBody(req, MAX_CLEAR_BODY_BYTES);
4632
+ if (!body.ok) {
4633
+ req.resume();
4634
+ return body.tooLarge ? refusal(413, `The body must be no more than ${MAX_CLEAR_BODY_BYTES} bytes.`) : refusal(400, "The body could not be read.");
4635
+ }
4636
+ if (!isEmptyObject(body.text)) {
4637
+ return refusal(400, "The body must be an empty JSON object: {}.");
4638
+ }
4639
+ if (keeper === null) {
4640
+ return failed(
4641
+ 409,
4642
+ "memory-only",
4643
+ "History is kept in memory only, with AGENT_LOOKOUT_HISTORY=off, so there are no files to clear."
4644
+ );
4645
+ }
4646
+ const outcome = await keeper.clear(forget);
4647
+ if (outcome.ok) {
4648
+ return {
4649
+ status: 200,
4650
+ body: { ok: true, clearedAt: outcome.at }
4651
+ };
4652
+ }
4653
+ return failed(outcome.reason === "not-writing" ? 409 : 500, outcome.reason, outcome.error);
4654
+ };
4655
+ }
4656
+
4657
+ // src/collector/history/historyKeeper.ts
4020
4658
  import path13 from "node:path";
4021
- var TERMINAL_PATH = "/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal";
4022
- var ITERM_PATH = "/Applications/iTerm.app/Contents/MacOS/iTerm2";
4023
- var ITERM_SERVER_FOLDER = "Library/Application Support/iTerm2";
4024
- var ITERM_SERVER_NAME = /^iTermServer-[0-9A-Za-z._-]{1,40}$/;
4025
- var TTY_NAME = /^ttys\d{1,4}$/;
4026
- var TTY_PATH = /^\/dev\/ttys\d{1,4}$/;
4027
- function isTtyPath(value) {
4028
- return typeof value === "string" && TTY_PATH.test(value);
4659
+
4660
+ // src/collector/history/historyFiles.ts
4661
+ import {
4662
+ closeSync,
4663
+ constants as constants4,
4664
+ fstatSync,
4665
+ lstatSync,
4666
+ openSync,
4667
+ readSync,
4668
+ unlinkSync,
4669
+ writeSync
4670
+ } from "node:fs";
4671
+ import { lstat as lstat2, lutimes, mkdir, open as open3, readdir as readdir3, unlink } from "node:fs/promises";
4672
+ function infoOf2(info) {
4673
+ return { kind: info.isFile() ? "file" : "other", size: info.size, mtimeMs: info.mtimeMs };
4674
+ }
4675
+ var FILE_MODE = 384;
4676
+ var FOLDER_MODE = 448;
4677
+ var NO_FOLLOW = (constants4.O_NOFOLLOW ?? 0) | (constants4.O_NONBLOCK ?? 0);
4678
+ var READ = constants4.O_RDONLY | NO_FOLLOW;
4679
+ var APPEND = constants4.O_WRONLY | constants4.O_APPEND | constants4.O_CREAT | NO_FOLLOW;
4680
+ var CREATE = constants4.O_WRONLY | constants4.O_CREAT | constants4.O_EXCL | NO_FOLLOW;
4681
+ function notOrdinary() {
4682
+ return new Error("Not an ordinary file.");
4683
+ }
4684
+ function isMissing2(error) {
4685
+ return error?.code === "ENOENT";
4686
+ }
4687
+ async function readRegular(file, maxBytes) {
4688
+ const handle = await open3(file, READ);
4689
+ try {
4690
+ const info = await handle.stat();
4691
+ if (!info.isFile()) throw notOrdinary();
4692
+ if (info.size > maxBytes) throw new Error("Too large to be a history file.");
4693
+ const buffer = Buffer.alloc(info.size);
4694
+ let filled = 0;
4695
+ while (filled < buffer.byteLength) {
4696
+ const { bytesRead } = await handle.read(buffer, filled, buffer.byteLength - filled, filled);
4697
+ if (bytesRead === 0) break;
4698
+ filled += bytesRead;
4699
+ }
4700
+ return buffer.toString("utf8", 0, filled);
4701
+ } finally {
4702
+ await handle.close();
4703
+ }
4029
4704
  }
4030
- function ttyPathOf(name) {
4031
- return TTY_NAME.test(name) ? `/dev/${name}` : null;
4705
+ function readRegularNow(file, maxBytes) {
4706
+ const fd = openSync(file, READ);
4707
+ try {
4708
+ const info = fstatSync(fd);
4709
+ if (!info.isFile()) throw notOrdinary();
4710
+ if (info.size > maxBytes) throw new Error("Too large to be a history file.");
4711
+ const buffer = Buffer.alloc(info.size);
4712
+ let filled = 0;
4713
+ while (filled < buffer.byteLength) {
4714
+ const read = readSync(fd, buffer, filled, buffer.byteLength - filled, filled);
4715
+ if (read === 0) break;
4716
+ filled += read;
4717
+ }
4718
+ return buffer.toString("utf8", 0, filled);
4719
+ } finally {
4720
+ closeSync(fd);
4721
+ }
4032
4722
  }
4033
- function appOfProgram(program, homeDir) {
4034
- if (program === TERMINAL_PATH) return "Terminal";
4035
- if (program === ITERM_PATH) return "iTerm2";
4036
- const inServerFolder = path13.dirname(program) === path13.join(homeDir, ITERM_SERVER_FOLDER);
4037
- return inServerFolder && ITERM_SERVER_NAME.test(path13.basename(program)) ? "iTerm2" : null;
4723
+ function writeNow(file, flags, text4) {
4724
+ const fd = openSync(file, flags, FILE_MODE);
4725
+ try {
4726
+ if (!fstatSync(fd).isFile()) throw notOrdinary();
4727
+ writeSync(fd, text4);
4728
+ } finally {
4729
+ closeSync(fd);
4730
+ }
4038
4731
  }
4039
- function tabOfProcess(pid, table, homeDir) {
4040
- const own = table.get(pid);
4041
- const tty = own ? ttyPathOf(own.tty) : null;
4042
- if (!own || tty === null) return void 0;
4043
- const passed = /* @__PURE__ */ new Set([pid]);
4044
- let current = own.ppid;
4045
- while (current > 1 && !passed.has(current)) {
4046
- const parent = table.get(current);
4047
- if (!parent) return void 0;
4048
- const app = appOfProgram(parent.path, homeDir);
4049
- if (app) return { app, tty };
4050
- if (parent.tty !== own.tty) return void 0;
4051
- passed.add(current);
4052
- current = parent.ppid;
4732
+ async function writeTo(file, flags, text4) {
4733
+ const handle = await open3(file, flags, FILE_MODE);
4734
+ try {
4735
+ if (!(await handle.stat()).isFile()) throw notOrdinary();
4736
+ await handle.writeFile(text4, "utf8");
4737
+ } finally {
4738
+ await handle.close();
4053
4739
  }
4054
- return void 0;
4055
4740
  }
4741
+ var nodeHistoryFs = {
4742
+ async makeFolder(dir) {
4743
+ await mkdir(dir, { recursive: true, mode: FOLDER_MODE });
4744
+ },
4745
+ list: (dir) => readdir3(dir),
4746
+ lstat: async (file) => infoOf2(await lstat2(file)),
4747
+ lstatNow: (file) => infoOf2(lstatSync(file)),
4748
+ readRegular,
4749
+ readRegularNow,
4750
+ append: (file, text4) => writeTo(file, APPEND, text4),
4751
+ appendNow: (file, text4) => writeNow(file, APPEND, text4),
4752
+ create: (file, text4) => writeTo(file, CREATE, text4),
4753
+ createNow: (file, text4) => writeNow(file, CREATE, text4),
4754
+ async touch(file, at) {
4755
+ const when = new Date(at);
4756
+ await lutimes(file, when, when);
4757
+ },
4758
+ async remove(file) {
4759
+ try {
4760
+ await unlink(file);
4761
+ } catch (error) {
4762
+ if (!isMissing2(error)) throw error;
4763
+ }
4764
+ },
4765
+ removeNow(file) {
4766
+ try {
4767
+ unlinkSync(file);
4768
+ } catch (error) {
4769
+ if (!isMissing2(error)) throw error;
4770
+ }
4771
+ }
4772
+ };
4056
4773
 
4057
- // src/collector/terminal/focusTab.ts
4058
- var FOUND = "found";
4059
- var MISSING = "missing";
4060
- var TERMINAL_SCRIPT = [
4061
- "on run argv",
4062
- "set wanted to item 1 of argv",
4063
- `if not (application id "com.apple.Terminal" is running) then return "${MISSING}"`,
4064
- 'tell application id "com.apple.Terminal"',
4774
+ // src/collector/history/writerLock.ts
4775
+ import { randomBytes } from "node:crypto";
4776
+ var LOCK_FILE = "writer.lock";
4777
+ var LOCK_STALE_MS = 3e4;
4778
+ var MAX_LOCK_BYTES = 256;
4779
+ var MAX_MARK_LENGTH = 64;
4780
+ function mark(value) {
4781
+ return typeof value === "string" && value !== "" && value.length <= MAX_MARK_LENGTH ? value : null;
4782
+ }
4783
+ function holderIn(text4) {
4784
+ try {
4785
+ const value = JSON.parse(text4);
4786
+ if (typeof value !== "object" || value === null) return null;
4787
+ const fields = value;
4788
+ const pid = validPid(fields.pid);
4789
+ if (pid === void 0) return null;
4790
+ return { pid, token: mark(fields.token), sources: mark(fields.sources) };
4791
+ } catch {
4792
+ return null;
4793
+ }
4794
+ }
4795
+ function createWriterLock(options) {
4796
+ const { fs, file } = options;
4797
+ const pid = options.pid ?? process.pid;
4798
+ const now = options.now ?? Date.now;
4799
+ const isAlive = options.isAlive ?? isProcessAlive;
4800
+ const staleAfterMs = options.staleAfterMs ?? LOCK_STALE_MS;
4801
+ const token = options.token ?? randomBytes(8).toString("hex");
4802
+ const sources = options.sources ?? null;
4803
+ const content = `${JSON.stringify({ pid, token, ...sources !== null && { sources } })}
4804
+ `;
4805
+ let held = false;
4806
+ let heldByOtherSources = false;
4807
+ const isOwn = (holder2) => holder2?.pid === pid && holder2.token === token;
4808
+ function verdict(info, holder2) {
4809
+ heldByOtherSources = false;
4810
+ if (info.kind !== "file") return "leave";
4811
+ const fresh = now() - info.mtimeMs < staleAfterMs;
4812
+ if (!fresh || isOwn(holder2)) return "take-over";
4813
+ if (holder2 === null) return "leave";
4814
+ if (holder2.pid === pid || isAlive(holder2.pid)) {
4815
+ heldByOtherSources = sources !== null && holder2.sources !== null && holder2.sources !== sources;
4816
+ return "leave";
4817
+ }
4818
+ return "take-over";
4819
+ }
4820
+ async function make() {
4821
+ try {
4822
+ await fs.create(file, content);
4823
+ held = true;
4824
+ heldByOtherSources = false;
4825
+ return true;
4826
+ } catch {
4827
+ return false;
4828
+ }
4829
+ }
4830
+ function makeNow() {
4831
+ try {
4832
+ fs.createNow(file, content);
4833
+ held = true;
4834
+ heldByOtherSources = false;
4835
+ return true;
4836
+ } catch {
4837
+ return false;
4838
+ }
4839
+ }
4840
+ async function holder() {
4841
+ try {
4842
+ return holderIn(await fs.readRegular(file, MAX_LOCK_BYTES));
4843
+ } catch {
4844
+ return null;
4845
+ }
4846
+ }
4847
+ function holderNow() {
4848
+ try {
4849
+ return holderIn(fs.readRegularNow(file, MAX_LOCK_BYTES));
4850
+ } catch {
4851
+ return null;
4852
+ }
4853
+ }
4854
+ return {
4855
+ async take() {
4856
+ if (await make()) return true;
4857
+ let info;
4858
+ try {
4859
+ info = await fs.lstat(file);
4860
+ } catch {
4861
+ return make();
4862
+ }
4863
+ if (verdict(info, await holder()) === "leave") return false;
4864
+ try {
4865
+ await fs.remove(file);
4866
+ } catch {
4867
+ return false;
4868
+ }
4869
+ return make();
4870
+ },
4871
+ takeNow() {
4872
+ if (makeNow()) return true;
4873
+ let info;
4874
+ try {
4875
+ info = fs.lstatNow(file);
4876
+ } catch {
4877
+ return makeNow();
4878
+ }
4879
+ if (verdict(info, holderNow()) === "leave") return false;
4880
+ try {
4881
+ fs.removeNow(file);
4882
+ } catch {
4883
+ return false;
4884
+ }
4885
+ return makeNow();
4886
+ },
4887
+ async keep() {
4888
+ if (!held) return false;
4889
+ if (!isOwn(await holder())) {
4890
+ held = false;
4891
+ return false;
4892
+ }
4893
+ try {
4894
+ await fs.touch(file, now());
4895
+ } catch {
4896
+ }
4897
+ return true;
4898
+ },
4899
+ stillHeldNow() {
4900
+ if (!held) return false;
4901
+ if (isOwn(holderNow())) return true;
4902
+ held = false;
4903
+ return false;
4904
+ },
4905
+ releaseNow() {
4906
+ if (!held) return;
4907
+ held = false;
4908
+ if (!isOwn(holderNow())) return;
4909
+ try {
4910
+ fs.removeNow(file);
4911
+ } catch {
4912
+ }
4913
+ },
4914
+ get held() {
4915
+ return held;
4916
+ },
4917
+ get heldByOtherSources() {
4918
+ return heldByOtherSources;
4919
+ }
4920
+ };
4921
+ }
4922
+
4923
+ // src/collector/history/historyKeeper.ts
4924
+ var FLUSH_INTERVAL_MS = 5e3;
4925
+ var RESTORE_DEADLINE_MS = 1e4;
4926
+ var MAX_QUEUED_LINES = 5e4;
4927
+ var MAX_PART = 9999;
4928
+ var FUTURE_SLACK_MS = 6e4;
4929
+ var MAX_RESTARTS = 500;
4930
+ var BEGINNING = {
4931
+ start: "started",
4932
+ cleared: "cleared",
4933
+ point: "trimmed",
4934
+ event: "trimmed"
4935
+ };
4936
+ function byteLength(text4) {
4937
+ return Buffer.byteLength(text4, "utf8");
4938
+ }
4939
+ function isNotOrdinary(error) {
4940
+ const code = error?.code;
4941
+ return code === "ELOOP" || code === "EISDIR" || code === "EMLINK" || code === "ENXIO" || code === "EOPNOTSUPP" || error instanceof Error && error.message === "Not an ordinary file.";
4942
+ }
4943
+ function newest(list, limit) {
4944
+ return list.length > limit ? list.slice(list.length - Math.max(0, limit)) : list;
4945
+ }
4946
+ function createHistoryKeeper(options) {
4947
+ const { dir, folder } = options;
4948
+ const fs = options.fs ?? nodeHistoryFs;
4949
+ const now = options.now ?? Date.now;
4950
+ const flushIntervalMs = options.flushIntervalMs ?? FLUSH_INTERVAL_MS;
4951
+ const restoreDeadlineMs = options.restoreDeadlineMs ?? RESTORE_DEADLINE_MS;
4952
+ const maxBytes = options.maxBytes ?? HISTORY_MAX_BYTES;
4953
+ const maxFileBytes = options.maxFileBytes ?? HISTORY_MAX_FILE_BYTES;
4954
+ const readLimit = Math.floor(maxFileBytes * 1.05);
4955
+ const maxAgeMs = options.maxAgeMs ?? HISTORY_MAX_AGE_MS;
4956
+ const warn = options.warn ?? (() => {
4957
+ });
4958
+ const lock = createWriterLock({
4959
+ fs,
4960
+ file: path13.join(dir, LOCK_FILE),
4961
+ pid: options.pid,
4962
+ now,
4963
+ isAlive: options.isAlive,
4964
+ sources: options.sources ?? null
4965
+ });
4966
+ const files = /* @__PURE__ */ new Map();
4967
+ const blocked = /* @__PURE__ */ new Set();
4968
+ const touched = /* @__PURE__ */ new Set();
4969
+ let queue = [];
4970
+ let startedAt = null;
4971
+ let target = null;
4972
+ let restored = false;
4973
+ let unusable = null;
4974
+ let failing = null;
4975
+ let clearedAt = null;
4976
+ let writer = null;
4977
+ let running = false;
4978
+ let timer = null;
4979
+ let chain = Promise.resolve();
4980
+ function serially(work) {
4981
+ const next = chain.then(work, work);
4982
+ chain = next.catch(() => {
4983
+ });
4984
+ return next;
4985
+ }
4986
+ const full = (name) => path13.join(dir, name);
4987
+ function hold(record) {
4988
+ if (unusable !== null || !running) return;
4989
+ const line = encodeRecord(record);
4990
+ if (line.length > MAX_LINE_LENGTH || byteLength(line) > maxFileBytes) return;
4991
+ queue.push(line);
4992
+ if (queue.length > MAX_QUEUED_LINES) queue = queue.slice(queue.length - MAX_QUEUED_LINES);
4993
+ }
4994
+ function recordsIn(text4, at) {
4995
+ return parseHistoryText(text4).records.filter(
4996
+ (record) => recordAt(record) <= at + FUTURE_SLACK_MS
4997
+ );
4998
+ }
4999
+ async function readAll(limits) {
5000
+ try {
5001
+ await fs.makeFolder(dir);
5002
+ } catch {
5003
+ return `Agent Lookout could not make its history folder, ${folder}, so this run keeps history in memory only.`;
5004
+ }
5005
+ let names;
5006
+ try {
5007
+ names = await fs.list(dir);
5008
+ } catch {
5009
+ return `Agent Lookout could not read its history folder, ${folder}, so this run keeps history in memory only.`;
5010
+ }
5011
+ const ours = names.map(readHistoryFileName).filter((file) => file?.version === HISTORY_FORMAT_VERSION).sort(byFileOrder);
5012
+ const found = /* @__PURE__ */ new Map();
5013
+ const notFiles = /* @__PURE__ */ new Set();
5014
+ const events = /* @__PURE__ */ new Map();
5015
+ const points = [];
5016
+ const starts = [];
5017
+ const times = [];
5018
+ let lastAt = null;
5019
+ const at = now();
5020
+ for (const file of ours) {
5021
+ let info;
5022
+ try {
5023
+ info = await fs.lstat(full(file.name));
5024
+ } catch {
5025
+ continue;
5026
+ }
5027
+ if (info.kind !== "file") {
5028
+ notFiles.add(file.name);
5029
+ continue;
5030
+ }
5031
+ const known = { ...file, size: info.size, first: null };
5032
+ found.set(file.name, known);
5033
+ if (dayEnd(file) + maxAgeMs < at || info.size > readLimit) continue;
5034
+ let text4;
5035
+ try {
5036
+ text4 = await fs.readRegular(full(file.name), readLimit);
5037
+ } catch {
5038
+ continue;
5039
+ }
5040
+ const records = recordsIn(text4, at);
5041
+ const first = records[0];
5042
+ if (first) known.first = { at: recordAt(first), kind: first.kind };
5043
+ for (const record of records) {
5044
+ const recorded = recordAt(record);
5045
+ times.push(recorded);
5046
+ if (lastAt === null || recorded > lastAt) lastAt = recorded;
5047
+ if (record.kind === "event") events.set(record.event.id, record.event);
5048
+ else if (record.kind === "point") points.push(record.point);
5049
+ else if (record.kind === "start") starts.push(record.at);
5050
+ }
5051
+ }
5052
+ const allEvents = [...events.values()].sort((a, b) => a.at - b.at);
5053
+ points.sort((a, b) => a.at - b.at);
5054
+ const allPoints = points.filter((point, index) => point.at !== points[index - 1]?.at);
5055
+ return {
5056
+ history: {
5057
+ events: newest(allEvents, limits.events),
5058
+ points: newest(allPoints, limits.points),
5059
+ restarts: newest(restartsAmong(starts, times), MAX_RESTARTS),
5060
+ lastAt,
5061
+ whole: {
5062
+ waitEvents: allEvents.filter(isWaitEvent),
5063
+ measured: pollRuns(
5064
+ allPoints.map((point) => point.at),
5065
+ starts
5066
+ )
5067
+ }
5068
+ },
5069
+ found,
5070
+ notFiles
5071
+ };
5072
+ }
5073
+ async function firstRecordOf(name, size) {
5074
+ if (size === 0 || size > readLimit) return null;
5075
+ try {
5076
+ const first = recordsIn(await fs.readRegular(full(name), readLimit), now())[0];
5077
+ return first ? { at: recordAt(first), kind: first.kind } : null;
5078
+ } catch {
5079
+ return null;
5080
+ }
5081
+ }
5082
+ async function rescan() {
5083
+ let names;
5084
+ try {
5085
+ names = await fs.list(dir);
5086
+ } catch {
5087
+ return;
5088
+ }
5089
+ const there = /* @__PURE__ */ new Set();
5090
+ for (const name of names) {
5091
+ const file = readHistoryFileName(name);
5092
+ if (file?.version !== HISTORY_FORMAT_VERSION) continue;
5093
+ let info;
5094
+ try {
5095
+ info = await fs.lstat(full(name));
5096
+ } catch {
5097
+ continue;
5098
+ }
5099
+ if (info.kind !== "file") {
5100
+ blocked.add(name);
5101
+ continue;
5102
+ }
5103
+ there.add(name);
5104
+ blocked.delete(name);
5105
+ const known = files.get(name);
5106
+ if (known?.size === info.size) continue;
5107
+ const fresh = known === void 0 || known.first === null || info.size < known.size;
5108
+ const first = fresh ? await firstRecordOf(name, info.size) : known.first;
5109
+ files.set(name, { ...file, size: info.size, first });
5110
+ }
5111
+ for (const name of [...files.keys()]) {
5112
+ if (!there.has(name)) files.delete(name);
5113
+ }
5114
+ }
5115
+ function aimAtNewest() {
5116
+ const day = dayOf(now());
5117
+ let part = 0;
5118
+ for (const file of files.values()) {
5119
+ if (file.day === day && file.part > part) part = file.part;
5120
+ }
5121
+ target = part > 0 ? { day, part } : null;
5122
+ }
5123
+ async function prune(writingTo, incoming) {
5124
+ const doomed = filesToDelete([...files.values()], writingTo, incoming, {
5125
+ now: now(),
5126
+ maxBytes,
5127
+ maxAgeMs
5128
+ });
5129
+ for (const name of doomed) {
5130
+ try {
5131
+ await fs.remove(full(name));
5132
+ } catch {
5133
+ }
5134
+ files.delete(name);
5135
+ }
5136
+ }
5137
+ function* chunks(lines) {
5138
+ const day = dayOf(now());
5139
+ let part = target?.day === day ? target.part : 1;
5140
+ let index = 0;
5141
+ while (index < lines.length && part <= MAX_PART) {
5142
+ const name = historyFileName(day, part);
5143
+ const known = files.get(name);
5144
+ const size = known?.size ?? 0;
5145
+ const lead = size > 0 && !touched.has(name) ? "\n" : "";
5146
+ const room = maxFileBytes - size - lead.length;
5147
+ const firstBytes = byteLength(lines[index]);
5148
+ if (blocked.has(name) || firstBytes > room) {
5149
+ part += 1;
5150
+ continue;
5151
+ }
5152
+ let text4 = lead;
5153
+ let bytes2 = lead.length;
5154
+ let end = index;
5155
+ while (end < lines.length) {
5156
+ const lineBytes = byteLength(lines[end]);
5157
+ if (bytes2 - lead.length + lineBytes > room) break;
5158
+ text4 += lines[end];
5159
+ bytes2 += lineBytes;
5160
+ end += 1;
5161
+ }
5162
+ const outcome = yield { name, text: text4 };
5163
+ if (outcome === "blocked") {
5164
+ blocked.add(name);
5165
+ part += 1;
5166
+ continue;
5167
+ }
5168
+ const record = parseRecord(lines[index].trim());
5169
+ const parsed = readHistoryFileName(name);
5170
+ files.set(name, {
5171
+ ...parsed,
5172
+ size: size + bytes2,
5173
+ first: known?.first ?? (record && { at: recordAt(record), kind: record.kind })
5174
+ });
5175
+ touched.add(name);
5176
+ target = { day, part };
5177
+ index = end;
5178
+ }
5179
+ }
5180
+ function wrote() {
5181
+ failing = null;
5182
+ }
5183
+ function writeFailed() {
5184
+ touched.clear();
5185
+ if (failing === null) {
5186
+ failing = `Agent Lookout could not write to its history folder, ${folder}, so new history is kept in memory until it can.`;
5187
+ warn(failing);
5188
+ }
5189
+ }
5190
+ async function writeLines(lines) {
5191
+ if (lines.length === 0) return true;
5192
+ const steps = chunks(lines);
5193
+ let step = steps.next("written");
5194
+ while (!step.done) {
5195
+ const { name, text: text4 } = step.value;
5196
+ await prune(name, byteLength(text4));
5197
+ try {
5198
+ await fs.append(full(name), text4);
5199
+ } catch (error) {
5200
+ if (isNotOrdinary(error)) {
5201
+ step = steps.next("blocked");
5202
+ continue;
5203
+ }
5204
+ writeFailed();
5205
+ return false;
5206
+ }
5207
+ step = steps.next("written");
5208
+ }
5209
+ wrote();
5210
+ return true;
5211
+ }
5212
+ function writeLinesNow(lines) {
5213
+ if (lines.length === 0) return;
5214
+ const steps = chunks(lines);
5215
+ let step = steps.next("written");
5216
+ while (!step.done) {
5217
+ const { name, text: text4 } = step.value;
5218
+ try {
5219
+ fs.appendNow(full(name), text4);
5220
+ } catch (error) {
5221
+ if (isNotOrdinary(error)) {
5222
+ step = steps.next("blocked");
5223
+ continue;
5224
+ }
5225
+ return;
5226
+ }
5227
+ step = steps.next("written");
5228
+ }
5229
+ }
5230
+ function takeQueued() {
5231
+ const lines = startedAt === null ? queue : [encodeRecord({ kind: "start", at: startedAt }), ...queue];
5232
+ queue = [];
5233
+ startedAt = null;
5234
+ return lines;
5235
+ }
5236
+ let pruned = false;
5237
+ let beating = null;
5238
+ function beat() {
5239
+ beating ??= writeBeat().finally(() => {
5240
+ beating = null;
5241
+ });
5242
+ return beating;
5243
+ }
5244
+ function writeBeat() {
5245
+ return serially(async () => {
5246
+ if (!running || unusable !== null) return;
5247
+ const kept = lock.held && await lock.keep();
5248
+ const holds = kept || running && await lock.take();
5249
+ if (!running) {
5250
+ lock.releaseNow();
5251
+ return;
5252
+ }
5253
+ if (!holds) {
5254
+ writer = false;
5255
+ queue = [];
5256
+ startedAt = null;
5257
+ await rescan();
5258
+ return;
5259
+ }
5260
+ if (!kept) {
5261
+ await rescan();
5262
+ touched.clear();
5263
+ aimAtNewest();
5264
+ pruned = false;
5265
+ }
5266
+ writer = true;
5267
+ if (!pruned) {
5268
+ pruned = true;
5269
+ await prune(null, 0);
5270
+ }
5271
+ await writeLines(takeQueued());
5272
+ }).catch(() => {
5273
+ });
5274
+ }
5275
+ return {
5276
+ async restore(limits) {
5277
+ const none = {
5278
+ events: [],
5279
+ points: [],
5280
+ restarts: [],
5281
+ lastAt: null,
5282
+ whole: { waitEvents: [], measured: [] }
5283
+ };
5284
+ if (restored) return none;
5285
+ restored = true;
5286
+ let deadline;
5287
+ const late = new Promise((resolve) => {
5288
+ deadline = setTimeout(() => resolve("late"), restoreDeadlineMs);
5289
+ deadline.unref?.();
5290
+ });
5291
+ const reading = serially(() => readAll(limits)).catch(
5292
+ () => `Agent Lookout could not read its history folder, ${folder}, so this run keeps history in memory only.`
5293
+ );
5294
+ const outcome = await Promise.race([reading, late]);
5295
+ clearTimeout(deadline);
5296
+ if (outcome === "late") {
5297
+ unusable = `Agent Lookout could not read its history folder, ${folder}, in time, so this run keeps history in memory only.`;
5298
+ warn(unusable);
5299
+ return none;
5300
+ }
5301
+ if (typeof outcome === "string") {
5302
+ unusable = outcome;
5303
+ warn(unusable);
5304
+ return none;
5305
+ }
5306
+ for (const [name, file] of outcome.found) files.set(name, file);
5307
+ for (const name of outcome.notFiles) blocked.add(name);
5308
+ aimAtNewest();
5309
+ return outcome.history;
5310
+ },
5311
+ start() {
5312
+ if (running) return;
5313
+ running = true;
5314
+ if (unusable !== null) return;
5315
+ writer = lock.takeNow();
5316
+ pruned = false;
5317
+ startedAt = writer ? now() : null;
5318
+ void beat();
5319
+ timer = setInterval(() => void beat(), flushIntervalMs);
5320
+ timer.unref?.();
5321
+ },
5322
+ addEvents(events) {
5323
+ for (const event of events) hold({ kind: "event", event });
5324
+ },
5325
+ addPoint(point) {
5326
+ hold({ kind: "point", point });
5327
+ },
5328
+ async flush() {
5329
+ if (beating) await beating;
5330
+ await beat();
5331
+ },
5332
+ stop() {
5333
+ if (!running) return;
5334
+ running = false;
5335
+ if (timer) clearInterval(timer);
5336
+ timer = null;
5337
+ if (writer === true && unusable === null && lock.stillHeldNow()) {
5338
+ writeLinesNow(takeQueued());
5339
+ }
5340
+ queue = [];
5341
+ startedAt = null;
5342
+ writer = null;
5343
+ lock.releaseNow();
5344
+ },
5345
+ clear(forget) {
5346
+ return serially(async () => {
5347
+ if (unusable !== null || writer !== true || !lock.held) {
5348
+ return {
5349
+ ok: false,
5350
+ reason: "not-writing",
5351
+ error: "Another copy of Agent Lookout keeps the history, or it cannot be written here."
5352
+ };
5353
+ }
5354
+ let names;
5355
+ try {
5356
+ names = await fs.list(dir);
5357
+ } catch {
5358
+ return { ok: false, reason: "failed", error: "The history folder could not be read." };
5359
+ }
5360
+ let left = 0;
5361
+ for (const name of names) {
5362
+ if (readHistoryFileName(name)?.version !== HISTORY_FORMAT_VERSION) continue;
5363
+ try {
5364
+ const info = await fs.lstat(full(name));
5365
+ if (info.kind !== "file") continue;
5366
+ await fs.remove(full(name));
5367
+ files.delete(name);
5368
+ } catch {
5369
+ left += 1;
5370
+ }
5371
+ }
5372
+ const listed = new Set(names);
5373
+ for (const name of [...files.keys()]) {
5374
+ if (!listed.has(name)) files.delete(name);
5375
+ }
5376
+ touched.clear();
5377
+ aimAtNewest();
5378
+ if (left > 0) {
5379
+ return {
5380
+ ok: false,
5381
+ reason: "failed",
5382
+ error: `${left === 1 ? "One history file" : `${left} history files`} could not be deleted.`
5383
+ };
5384
+ }
5385
+ const at = now();
5386
+ queue = [];
5387
+ startedAt = null;
5388
+ clearedAt = at;
5389
+ forget();
5390
+ await writeLines([encodeRecord({ kind: "cleared", at })]);
5391
+ return { ok: true, at };
5392
+ });
5393
+ },
5394
+ since() {
5395
+ const oldest = [...files.values()].sort(byFileOrder).find((file) => file.first !== null);
5396
+ if (!oldest?.first) return clearedAt === null ? null : { at: clearedAt, by: "cleared" };
5397
+ return { at: oldest.first.at, by: BEGINNING[oldest.first.kind] };
5398
+ },
5399
+ status() {
5400
+ let bytes2 = 0;
5401
+ for (const file of files.values()) bytes2 += file.size;
5402
+ let problem = unusable ?? failing;
5403
+ if (problem === null && writer === false) {
5404
+ problem = lock.heldByOtherSources ? "Another copy of Agent Lookout on this computer is writing the history, and it reads sessions from other folders than this one. This one keeps what it sees in memory, and takes over when that one stops." : "Another copy of Agent Lookout on this computer is writing the history. This one keeps what it sees in memory, and takes over when that one stops.";
5405
+ }
5406
+ return {
5407
+ where: "disk",
5408
+ folder,
5409
+ bytes: bytes2,
5410
+ maxBytes,
5411
+ maxAgeMs,
5412
+ canClear: unusable === null && writer === true && lock.held,
5413
+ problem
5414
+ };
5415
+ }
5416
+ };
5417
+ }
5418
+ function keptEventStore(store, keeper) {
5419
+ return {
5420
+ add(events) {
5421
+ store.add(events);
5422
+ keeper.addEvents(events);
5423
+ },
5424
+ list: (options) => store.list(options),
5425
+ clear: () => store.clear(),
5426
+ get size() {
5427
+ return store.size;
5428
+ }
5429
+ };
5430
+ }
5431
+ function keptHistoryStore(store, keeper) {
5432
+ return {
5433
+ add(point) {
5434
+ store.add(point);
5435
+ keeper.addPoint(point);
5436
+ },
5437
+ list: (windowMs, at) => store.list(windowMs, at),
5438
+ clear: () => store.clear(),
5439
+ get size() {
5440
+ return store.size;
5441
+ }
5442
+ };
5443
+ }
5444
+
5445
+ // src/collector/history/historySettings.ts
5446
+ import { createHash as createHash3 } from "node:crypto";
5447
+ import os5 from "node:os";
5448
+ import path14 from "node:path";
5449
+ var HISTORY_ENV = "AGENT_LOOKOUT_HISTORY";
5450
+ var HISTORY_DIR_ENV = "AGENT_LOOKOUT_HISTORY_DIR";
5451
+ function readHistorySetup(env, homeDir = os5.homedir()) {
5452
+ if (env[HISTORY_ENV]?.trim().toLowerCase() === "off") return { on: false };
5453
+ const override = env[HISTORY_DIR_ENV]?.trim() || void 0;
5454
+ const dir = path14.resolve(override ?? path14.join(homeDir, ".agent-lookout", "history"));
5455
+ return {
5456
+ on: true,
5457
+ dir,
5458
+ folder: tildify(dir, homeDir),
5459
+ sources: sourcesFingerprint(env, homeDir)
5460
+ };
5461
+ }
5462
+ function sourcesFingerprint(env, homeDir = os5.homedir()) {
5463
+ const set = (name) => env[name]?.trim() || void 0;
5464
+ const folders = [
5465
+ path14.resolve(set(CLAUDE_HOME_ENV) ?? path14.join(homeDir, ".claude")),
5466
+ path14.resolve(set(CODEX_HOME_ENV) ?? set(CODEX_OWN_HOME_ENV) ?? path14.join(homeDir, ".codex")),
5467
+ path14.resolve(set(STATUS_DIR_ENV) ?? path14.join(homeDir, ".agent-lookout", "sessions"))
5468
+ ];
5469
+ return createHash3("sha256").update(JSON.stringify(folders)).digest("hex").slice(0, 32);
5470
+ }
5471
+
5472
+ // src/collector/terminal/terminalTabs.ts
5473
+ import path15 from "node:path";
5474
+ var TERMINAL_PATH = "/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal";
5475
+ var ITERM_PATH = "/Applications/iTerm.app/Contents/MacOS/iTerm2";
5476
+ var ITERM_SERVER_FOLDER = "Library/Application Support/iTerm2";
5477
+ var ITERM_SERVER_NAME = /^iTermServer-[0-9A-Za-z._-]{1,40}$/;
5478
+ var TTY_NAME = /^ttys\d{1,4}$/;
5479
+ var TTY_PATH = /^\/dev\/ttys\d{1,4}$/;
5480
+ function isTtyPath(value) {
5481
+ return typeof value === "string" && TTY_PATH.test(value);
5482
+ }
5483
+ function ttyPathOf(name) {
5484
+ return TTY_NAME.test(name) ? `/dev/${name}` : null;
5485
+ }
5486
+ function appOfProgram(program, homeDir) {
5487
+ if (program === TERMINAL_PATH) return "Terminal";
5488
+ if (program === ITERM_PATH) return "iTerm2";
5489
+ const inServerFolder = path15.dirname(program) === path15.join(homeDir, ITERM_SERVER_FOLDER);
5490
+ return inServerFolder && ITERM_SERVER_NAME.test(path15.basename(program)) ? "iTerm2" : null;
5491
+ }
5492
+ function tabOfProcess(pid, table, homeDir) {
5493
+ const own = table.get(pid);
5494
+ const tty = own ? ttyPathOf(own.tty) : null;
5495
+ if (!own || tty === null) return void 0;
5496
+ const passed = /* @__PURE__ */ new Set([pid]);
5497
+ let current = own.ppid;
5498
+ while (current > 1 && !passed.has(current)) {
5499
+ const parent = table.get(current);
5500
+ if (!parent) return void 0;
5501
+ const app = appOfProgram(parent.path, homeDir);
5502
+ if (app) return { app, tty };
5503
+ if (parent.tty !== own.tty) return void 0;
5504
+ passed.add(current);
5505
+ current = parent.ppid;
5506
+ }
5507
+ return void 0;
5508
+ }
5509
+
5510
+ // src/collector/terminal/focusTab.ts
5511
+ var FOUND = "found";
5512
+ var MISSING = "missing";
5513
+ var TERMINAL_SCRIPT = [
5514
+ "on run argv",
5515
+ "set wanted to item 1 of argv",
5516
+ `if not (application id "com.apple.Terminal" is running) then return "${MISSING}"`,
5517
+ 'tell application id "com.apple.Terminal"',
4065
5518
  "repeat with w in windows",
4066
5519
  "repeat with t in tabs of w",
4067
5520
  "if tty of t is wanted then",
@@ -4237,49 +5690,11 @@ function foundFor(session, panes, tabs) {
4237
5690
  }
4238
5691
  return void 0;
4239
5692
  }
4240
- var refuse = (status, error, headers = {}) => ({
4241
- status,
4242
- body: { error },
4243
- // A refused request's body may not have been read, so its connection is not used again.
4244
- headers: { Connection: "close", ...headers }
4245
- });
4246
- var failed = (status, reason, error, headers = {}) => ({
5693
+ var failed2 = (status, reason, error, headers = {}) => ({
4247
5694
  status,
4248
5695
  body: { error, reason },
4249
5696
  headers
4250
5697
  });
4251
- function header(req, name) {
4252
- const value = req.headers[name.toLowerCase()];
4253
- return typeof value === "string" ? value : void 0;
4254
- }
4255
- function actionRefusalFor(req, action, maxBodyBytes) {
4256
- if (req.method !== "POST") {
4257
- return refuse(405, "This address only answers POST requests.", { Allow: "POST" });
4258
- }
4259
- const origin = header(req, "origin");
4260
- if (origin === void 0 || !isLoopbackOrigin(origin)) {
4261
- return refuse(403, "This address only acts for the dashboard page served from this machine.");
4262
- }
4263
- const site = req.headers["sec-fetch-site"];
4264
- if (site !== void 0 && site !== "same-origin") {
4265
- return refuse(403, "This address only acts for the dashboard page served from this machine.");
4266
- }
4267
- if (header(req, ACTION_HEADER) !== action) {
4268
- return refuse(
4269
- 403,
4270
- `A request to this address must carry the header ${ACTION_HEADER}: ${action}.`
4271
- );
4272
- }
4273
- const contentType = header(req, "content-type")?.split(";", 1)[0]?.trim().toLowerCase();
4274
- if (contentType !== "application/json") {
4275
- return refuse(415, "The body must be JSON, sent as application/json.");
4276
- }
4277
- const length = header(req, "content-length");
4278
- if (length !== void 0 && (!/^\d+$/.test(length) || Number(length) > maxBodyBytes)) {
4279
- return refuse(413, `The body must be no more than ${maxBodyBytes} bytes.`);
4280
- }
4281
- return null;
4282
- }
4283
5698
  function jumpRefusalFor(req) {
4284
5699
  return actionRefusalFor(req, ACTION, MAX_JUMP_BODY_BYTES);
4285
5700
  }
@@ -4297,27 +5712,6 @@ function sessionIdIn(body) {
4297
5712
  if (typeof sessionId !== "string" || sessionId === "") return null;
4298
5713
  return sessionId.length <= MAX_SESSION_ID_LENGTH ? sessionId : null;
4299
5714
  }
4300
- function readBody(req, limit) {
4301
- return new Promise((resolve) => {
4302
- const chunks = [];
4303
- let received = 0;
4304
- let done = false;
4305
- const finish = (body) => {
4306
- if (done) return;
4307
- done = true;
4308
- resolve(body);
4309
- };
4310
- req.on("data", (chunk) => {
4311
- if (done) return;
4312
- received += chunk.byteLength;
4313
- if (received > limit) finish({ ok: false, tooLarge: true });
4314
- else chunks.push(chunk);
4315
- });
4316
- req.on("end", () => finish({ ok: true, text: Buffer.concat(chunks).toString("utf8") }));
4317
- req.on("error", () => finish({ ok: false, tooLarge: false }));
4318
- req.on("close", () => finish({ ok: false, tooLarge: false }));
4319
- });
4320
- }
4321
5715
  function createJumpRoute(options) {
4322
5716
  const { poller, panes, run, tabs, osascript } = options;
4323
5717
  const now = options.now ?? Date.now;
@@ -4333,41 +5727,41 @@ function createJumpRoute(options) {
4333
5727
  }
4334
5728
  if (outcome.reason === "tab-gone") {
4335
5729
  tabs.forget(pid);
4336
- return failed(409, "tab-gone", "That tab has closed.");
5730
+ return failed2(409, "tab-gone", "That tab has closed.");
4337
5731
  }
4338
5732
  if (outcome.reason === "not-allowed") {
4339
- return failed(
5733
+ return failed2(
4340
5734
  403,
4341
5735
  "not-allowed",
4342
5736
  `macOS has not allowed Agent Lookout to control ${tab.app}. Allow it in System Settings, Privacy & Security, Automation.`
4343
5737
  );
4344
5738
  }
4345
- return failed(500, "failed", `${tab.app} could not be asked to bring the tab forward.`);
5739
+ return failed2(500, "failed", `${tab.app} could not be asked to bring the tab forward.`);
4346
5740
  }
4347
5741
  return async function answerJump(req) {
4348
- const refusal = jumpRefusalFor(req);
4349
- if (refusal) {
5742
+ const refused = jumpRefusalFor(req);
5743
+ if (refused) {
4350
5744
  req.resume();
4351
- return refusal;
5745
+ return refused;
4352
5746
  }
4353
- const body = await readBody(req, MAX_JUMP_BODY_BYTES);
5747
+ const body = await readRequestBody(req, MAX_JUMP_BODY_BYTES);
4354
5748
  if (!body.ok) {
4355
5749
  req.resume();
4356
- return body.tooLarge ? refuse(413, `The body must be no more than ${MAX_JUMP_BODY_BYTES} bytes.`) : refuse(400, "The body could not be read.");
5750
+ return body.tooLarge ? refusal(413, `The body must be no more than ${MAX_JUMP_BODY_BYTES} bytes.`) : refusal(400, "The body could not be read.");
4357
5751
  }
4358
5752
  const sessionId = sessionIdIn(body.text);
4359
5753
  if (sessionId === null) {
4360
- return refuse(400, 'The body must name one session and nothing else: {"sessionId": "..."}.');
5754
+ return refusal(400, 'The body must name one session and nothing else: {"sessionId": "..."}.');
4361
5755
  }
4362
5756
  const session = poller.getSnapshot().sessions.find((candidate) => candidate.id === sessionId);
4363
5757
  const found = foundFor(session, panes, tabs);
4364
5758
  if (!found) {
4365
- return failed(404, "no-pane", "No tmux pane or terminal tab is known for that session.");
5759
+ return failed2(404, "no-pane", "No tmux pane or terminal tab is known for that session.");
4366
5760
  }
4367
5761
  const at = now();
4368
5762
  const tooSoon = lastAt !== null && at >= lastAt && at - lastAt < JUMP_INTERVAL_MS;
4369
5763
  if (underWay || tooSoon) {
4370
- return failed(429, "too-soon", "One jump is made a second. Try again in a moment.", {
5764
+ return failed2(429, "too-soon", "One jump is made a second. Try again in a moment.", {
4371
5765
  "Retry-After": "1"
4372
5766
  });
4373
5767
  }
@@ -4388,10 +5782,10 @@ function createJumpRoute(options) {
4388
5782
  };
4389
5783
  }
4390
5784
  if (outcome.reason === "failed") {
4391
- return failed(500, "failed", "tmux could not be asked to select the pane.");
5785
+ return failed2(500, "failed", "tmux could not be asked to select the pane.");
4392
5786
  }
4393
5787
  panes.lookAgain();
4394
- return outcome.reason === "pane-gone" ? failed(409, "pane-gone", "That pane has closed.") : failed(409, "tmux-stopped", "tmux is not running.");
5788
+ return outcome.reason === "pane-gone" ? failed2(409, "pane-gone", "That pane has closed.") : failed2(409, "tmux-stopped", "tmux is not running.");
4395
5789
  } finally {
4396
5790
  underWay = false;
4397
5791
  }
@@ -4580,11 +5974,11 @@ function createOsascriptRunner(options) {
4580
5974
  }
4581
5975
 
4582
5976
  // src/collector/terminal/tabFinder.ts
4583
- import os5 from "node:os";
5977
+ import os6 from "node:os";
4584
5978
  var TAB_LOOK_SOONEST_MS = 5e3;
4585
5979
  function createTabFinder(options) {
4586
5980
  const off = terminalJumpOff(options.env);
4587
- const homeDir = options.homeDir ?? os5.homedir();
5981
+ const homeDir = options.homeDir ?? os6.homedir();
4588
5982
  const readProcesses = options.readProcesses ?? readProcessTableWithPs;
4589
5983
  const now = options.now ?? Date.now;
4590
5984
  const soonestMs = options.soonestMs ?? TAB_LOOK_SOONEST_MS;
@@ -4746,6 +6140,89 @@ function createTmuxRunner(options) {
4746
6140
  };
4747
6141
  }
4748
6142
 
6143
+ // src/collector/waits/waitLedger.ts
6144
+ var LEDGER_KEPT_MS = 9 * 24 * 60 * 60 * 1e3;
6145
+ var MAX_EVENTS = 1e5;
6146
+ var MAX_SPANS = 5e4;
6147
+ function createWaitLedger() {
6148
+ let events = [];
6149
+ let spans = [];
6150
+ let fresh = true;
6151
+ function prune(at) {
6152
+ const cutoff = at - LEDGER_KEPT_MS;
6153
+ let firstEvent = 0;
6154
+ while (firstEvent < events.length && events[firstEvent].at < cutoff) {
6155
+ firstEvent += 1;
6156
+ }
6157
+ firstEvent = Math.max(firstEvent, events.length - MAX_EVENTS);
6158
+ if (firstEvent > 0) events = events.slice(firstEvent);
6159
+ let firstSpan = 0;
6160
+ while (firstSpan < spans.length && spans[firstSpan].to < cutoff) firstSpan += 1;
6161
+ firstSpan = Math.max(firstSpan, spans.length - MAX_SPANS);
6162
+ if (firstSpan > 0) spans = spans.slice(firstSpan);
6163
+ }
6164
+ return {
6165
+ restore(seed) {
6166
+ events = seed.waitEvents.filter(isWaitEvent).concat(events);
6167
+ spans = seed.measured.map((span) => ({ ...span })).concat(spans);
6168
+ },
6169
+ beginRun() {
6170
+ fresh = true;
6171
+ },
6172
+ addEvents(added) {
6173
+ for (const event of added) if (isWaitEvent(event)) events.push(event);
6174
+ },
6175
+ addPoint(point) {
6176
+ addPoll(spans, point.at, fresh);
6177
+ fresh = false;
6178
+ prune(point.at);
6179
+ },
6180
+ clear() {
6181
+ events = [];
6182
+ spans = [];
6183
+ fresh = true;
6184
+ },
6185
+ answer({ now, snapshot, since, runStarts, where }) {
6186
+ const totals = waitTotals({
6187
+ events,
6188
+ measured: spans,
6189
+ runStarts,
6190
+ waitingNow: snapshot.sessions.filter((session) => session.status === "needs-you").map((session) => ({ id: session.id, name: session.name })),
6191
+ names: new Map(snapshot.sessions.map((session) => [session.id, session.name])),
6192
+ since: since.at,
6193
+ now
6194
+ });
6195
+ return { at: now, ...totals, since, where };
6196
+ }
6197
+ };
6198
+ }
6199
+ function ledgerEventStore(store, ledger) {
6200
+ return {
6201
+ add(added) {
6202
+ store.add(added);
6203
+ ledger.addEvents(added);
6204
+ },
6205
+ list: (options) => store.list(options),
6206
+ clear: () => store.clear(),
6207
+ get size() {
6208
+ return store.size;
6209
+ }
6210
+ };
6211
+ }
6212
+ function ledgerHistoryStore(store, ledger) {
6213
+ return {
6214
+ add(point) {
6215
+ store.add(point);
6216
+ ledger.addPoint(point);
6217
+ },
6218
+ list: (windowMs, at) => store.list(windowMs, at),
6219
+ clear: () => store.clear(),
6220
+ get size() {
6221
+ return store.size;
6222
+ }
6223
+ };
6224
+ }
6225
+
4749
6226
  // src/collector/webhook/webhookSender.ts
4750
6227
  var WEBHOOK_TIMEOUT_MS = 1e4;
4751
6228
  function postHeaders(body, version) {
@@ -4845,6 +6322,7 @@ function createHttpSender(settings, options) {
4845
6322
  var WEBHOOK_URL_ENV = "AGENT_LOOKOUT_WEBHOOK_URL";
4846
6323
  var WEBHOOK_EVENTS_ENV = "AGENT_LOOKOUT_WEBHOOK_EVENTS";
4847
6324
  var WEBHOOK_AFTER_ENV = "AGENT_LOOKOUT_WEBHOOK_AFTER";
6325
+ var WEBHOOK_ASKING_ENV = "AGENT_LOOKOUT_WEBHOOK_ASKING";
4848
6326
  var MAX_URL_LENGTH = 2048;
4849
6327
  var URL_UNREADABLE2 = `${WEBHOOK_URL_ENV} could not be read. Copy the whole address, beginning https://.`;
4850
6328
  function readUrl(value) {
@@ -4881,7 +6359,8 @@ function readWebhookSetup(env) {
4881
6359
  settings: {
4882
6360
  url: readUrl(url),
4883
6361
  events: readEvents(WEBHOOK_EVENTS_ENV, valueOf(env, WEBHOOK_EVENTS_ENV)),
4884
- afterMs: readAfter(WEBHOOK_AFTER_ENV, valueOf(env, WEBHOOK_AFTER_ENV))
6362
+ afterMs: readAfter(WEBHOOK_AFTER_ENV, valueOf(env, WEBHOOK_AFTER_ENV)),
6363
+ asking: readAsking(WEBHOOK_ASKING_ENV, valueOf(env, WEBHOOK_ASKING_ENV))
4885
6364
  }
4886
6365
  };
4887
6366
  } catch (error) {
@@ -4898,8 +6377,28 @@ function createCollector(options) {
4898
6377
  const now = options.now ?? Date.now;
4899
6378
  const env = options.env ?? process.env;
4900
6379
  const intervalMs = options.intervalMs ?? POLL_INTERVAL_MS;
4901
- const events = createEventStore();
4902
- const history = createHistoryStore();
6380
+ const warn = options.warn ?? console.error;
6381
+ const memoryEvents = createEventStore();
6382
+ const memoryHistory = createHistoryStore();
6383
+ const historySetup = readHistorySetup(env);
6384
+ const keeper = historySetup.on ? createHistoryKeeper({
6385
+ dir: historySetup.dir,
6386
+ folder: historySetup.folder,
6387
+ fs: options.historyFs,
6388
+ flushIntervalMs: options.historyFlushMs,
6389
+ sources: historySetup.sources,
6390
+ now,
6391
+ warn
6392
+ }) : null;
6393
+ const ledger = createWaitLedger();
6394
+ const events = ledgerEventStore(
6395
+ keeper ? keptEventStore(memoryEvents, keeper) : memoryEvents,
6396
+ ledger
6397
+ );
6398
+ const history = ledgerHistoryStore(
6399
+ keeper ? keptHistoryStore(memoryHistory, keeper) : memoryHistory,
6400
+ ledger
6401
+ );
4903
6402
  const tmux = options.tmux ?? createTmuxRunner({ env });
4904
6403
  const { readProcesses } = options;
4905
6404
  const panes = createPaneFinder({
@@ -4909,7 +6408,6 @@ function createCollector(options) {
4909
6408
  });
4910
6409
  const tabs = createTabFinder({ env, readProcesses: options.readProcesses, now });
4911
6410
  const osascript = options.osascript ?? createOsascriptRunner({ env });
4912
- const warn = options.warn ?? console.error;
4913
6411
  const platform = options.platform ?? process.platform;
4914
6412
  const notifications = createServerNotifications({
4915
6413
  notifier: options.notifier ?? createSystemNotifier({ platform }),
@@ -4981,15 +6479,84 @@ function createCollector(options) {
4981
6479
  email: () => email?.status() ?? emailOff,
4982
6480
  webhook: () => webhook?.status() ?? webhookOff,
4983
6481
  jump: createJumpRoute({ poller, panes, run: tmux, tabs, osascript, now }),
6482
+ clearHistory: createClearHistoryRoute({
6483
+ keeper,
6484
+ forget: () => {
6485
+ memoryEvents.clear();
6486
+ memoryHistory.clear();
6487
+ ledger.clear();
6488
+ restarts = [];
6489
+ lastKept = null;
6490
+ }
6491
+ }),
6492
+ historyKept: () => ({
6493
+ since: keeper?.since() ?? null,
6494
+ kept: keeper?.status() ?? memoryOnlyStatus(),
6495
+ restarts: restartsHeld()
6496
+ }),
6497
+ waits: () => {
6498
+ const since = keeper?.since() ?? { at: poller.startedAt, by: "started" };
6499
+ return ledger.answer({
6500
+ now: now(),
6501
+ snapshot: poller.getSnapshot(),
6502
+ since,
6503
+ runStarts: [since.at, ...restartsHeld().map((restart) => restart.at), poller.startedAt],
6504
+ where: keeper ? "disk" : "memory"
6505
+ });
6506
+ },
6507
+ ready: () => restoring,
4984
6508
  now
4985
6509
  });
6510
+ let running = false;
6511
+ let restored = keeper === null;
6512
+ let restoring = null;
6513
+ let restarts = [];
6514
+ let lastKept = null;
6515
+ function restartsHeld() {
6516
+ const startedAt = poller.startedAt;
6517
+ if (lastKept === null || lastKept >= startedAt) return restarts;
6518
+ return [...restarts, { at: startedAt, lastBefore: lastKept }];
6519
+ }
6520
+ function begin() {
6521
+ ledger.beginRun();
6522
+ keeper?.start();
6523
+ poller.start();
6524
+ }
4986
6525
  return {
4987
- start: () => poller.start(),
4988
- stop: () => poller.stop(),
6526
+ start() {
6527
+ if (running) return;
6528
+ running = true;
6529
+ if (restored || keeper === null) {
6530
+ begin();
6531
+ return;
6532
+ }
6533
+ restoring = keeper.restore({ events: EVENT_CAPACITY, points: HISTORY_CAPACITY }).then((kept) => {
6534
+ restored = true;
6535
+ memoryEvents.add(kept.events);
6536
+ for (const point of kept.points) memoryHistory.add(point);
6537
+ restarts = kept.restarts;
6538
+ lastKept = kept.lastAt;
6539
+ ledger.restore(kept.whole);
6540
+ poller.resume([...unendedWaits(kept.whole.waitEvents), ...kept.events]);
6541
+ if (running) begin();
6542
+ }).catch(() => {
6543
+ restored = true;
6544
+ if (running) begin();
6545
+ }).finally(() => {
6546
+ restoring = null;
6547
+ });
6548
+ },
6549
+ whenStarted: () => restoring ?? Promise.resolve(),
6550
+ stop() {
6551
+ running = false;
6552
+ poller.stop();
6553
+ keeper?.stop();
6554
+ },
4989
6555
  handler,
4990
6556
  poller,
4991
6557
  email,
4992
- webhook
6558
+ webhook,
6559
+ history: keeper
4993
6560
  };
4994
6561
  }
4995
6562
 
@@ -5010,7 +6577,7 @@ async function runStandalone(options) {
5010
6577
  } catch (error) {
5011
6578
  return stopWith(error.message);
5012
6579
  }
5013
- if (!existsSync(path14.join(distDir, "index.html"))) {
6580
+ if (!existsSync(path16.join(distDir, "index.html"))) {
5014
6581
  return stopWith(
5015
6582
  "The dashboard has not been built yet. Run `npm run build`, then start it again."
5016
6583
  );