agent-lookout 0.2.5 → 0.2.7

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,21 +1,24 @@
1
1
  import {
2
2
  sortSessions
3
- } from "./chunk-Q73JDXXW.js";
3
+ } from "./chunk-JSBQMY3Q.js";
4
4
  import {
5
5
  ANSWER_DECISIONS,
6
6
  CAPABILITIES,
7
+ EDIT_TOOLS,
7
8
  EVENT_ACTORS,
8
9
  EVENT_KINDS,
9
10
  EVENT_SEVERITIES,
10
11
  MAX_MACHINE_NAME_LENGTH,
11
12
  MAX_NAME_LENGTH,
12
13
  MAX_NOTICE_TEXT_LENGTH,
14
+ NOT_YES_OR_NO_TOOLS,
13
15
  SESSION_STATUSES,
14
16
  SOURCE_STATES,
15
17
  SOURCE_STATE_LABEL,
16
18
  SURFACES,
17
19
  WAITING_REASONS,
18
20
  agentName,
21
+ answeredNow,
19
22
  changeNotice,
20
23
  checksStateOf,
21
24
  clean,
@@ -24,12 +27,14 @@ import {
24
27
  durationInWords,
25
28
  durationParts,
26
29
  formatDuration,
30
+ inAnsweredWait,
27
31
  isGitHubRepository,
28
32
  isMachineName,
29
33
  isPullRequestNumber,
30
34
  isRemoteSource,
31
35
  isStatusEvent,
32
36
  loopbackAddress,
37
+ needsYou,
33
38
  noticeTitle,
34
39
  oneLine,
35
40
  overPhrase,
@@ -43,7 +48,7 @@ import {
43
48
  waitingPhrase,
44
49
  waitingText,
45
50
  withoutWaitingText
46
- } from "./chunk-OT3EGOTY.js";
51
+ } from "./chunk-I6Q35DHC.js";
47
52
 
48
53
  // src/collector/hosts/server.ts
49
54
  import { createServer } from "node:http";
@@ -66,7 +71,7 @@ function waitChanges(memory, snapshot) {
66
71
  const started = [];
67
72
  const stopped = [];
68
73
  for (const session of snapshot.sessions) {
69
- if (session.status !== "needs-you") continue;
74
+ if (!needsYou(session)) continue;
70
75
  const waiting = waitingNow.get(session.source);
71
76
  if (!waiting || waiting.has(session.id)) continue;
72
77
  const remembered = memory.get(session.source);
@@ -192,6 +197,9 @@ var ANSWER_ACTION = "answer";
192
197
  var SETTINGS_PATH = "/api/settings";
193
198
  var TIME_RULES_PATH = "/api/settings/time-rules";
194
199
  var TIME_RULES_ACTION = "time-rules";
200
+ var PERMISSION_RULES_PATH = "/api/settings/permission-rules";
201
+ var PERMISSION_RULES_ACTION = "permission-rules";
202
+ var MAX_RULE_ANSWERS = 100;
195
203
  var ACTION_HEADER = "X-Agent-Lookout-Action";
196
204
  var MAX_EVENTS_PER_RESPONSE = 200;
197
205
  var NOTIFICATIONS_HEADER = "X-Agent-Lookout-Notifications";
@@ -213,15 +221,15 @@ function isWebhookHost(host) {
213
221
  // src/core/history.ts
214
222
  var DEFAULT_HISTORY_WINDOW_MS = 15 * 60 * 1e3;
215
223
  function historyPointFor(sessions, at) {
216
- let needsYou = 0;
224
+ let needsYou2 = 0;
217
225
  let working = 0;
218
226
  let idle = 0;
219
227
  for (const session of sessions) {
220
- if (session.status === "needs-you") needsYou += 1;
221
- else if (session.status === "working") working += 1;
228
+ if (needsYou(session)) needsYou2 += 1;
229
+ else if (session.status === "working" || session.status === "needs-you") working += 1;
222
230
  else if (session.status === "idle" && !session.stale) idle += 1;
223
231
  }
224
- return { at, needsYou, working, idle, total: sessions.length };
232
+ return { at, needsYou: needsYou2, working, idle, total: sessions.length };
225
233
  }
226
234
 
227
235
  // src/core/time-rules/quietHold.ts
@@ -246,10 +254,30 @@ function createQuietHold() {
246
254
  const last = lastWaitOf(session.id);
247
255
  if (last?.begunAt === begunAt) {
248
256
  last.endedAt = null;
257
+ last.reminderOnly = false;
249
258
  return;
250
259
  }
251
260
  if (last && last.endedAt === null) last.endedAt = begunAt;
252
- waits.push({ session: withoutWaitingText(session), begunAt, endedAt: null });
261
+ waits.push({
262
+ session: withoutWaitingText(session),
263
+ begunAt,
264
+ endedAt: null,
265
+ reminderOnly: false
266
+ });
267
+ },
268
+ holdReminder(session, begunAt) {
269
+ const last = lastWaitOf(session.id);
270
+ if (last?.begunAt === begunAt) {
271
+ last.endedAt = null;
272
+ return;
273
+ }
274
+ if (last && last.endedAt === null) last.endedAt = begunAt;
275
+ waits.push({
276
+ session: withoutWaitingText(session),
277
+ begunAt,
278
+ endedAt: null,
279
+ reminderOnly: true
280
+ });
253
281
  },
254
282
  holdOver(event, session, at) {
255
283
  over.push({ event, session: withoutWaitingText(session), at });
@@ -264,14 +292,15 @@ function createQuietHold() {
264
292
  const stillOpen = /* @__PURE__ */ new Map();
265
293
  for (const wait of waits) {
266
294
  const now = listed.get(wait.session.id);
267
- if (now?.status !== "needs-you") continue;
295
+ if (now === void 0 || !needsYou(now)) continue;
268
296
  if (wait.endedAt === null || now.statusSince === wait.begunAt) {
269
297
  stillOpen.set(wait.session.id, wait);
270
298
  }
271
299
  }
272
- const open4 = [];
300
+ const open2 = [];
273
301
  for (const [id, wait] of stillOpen) {
274
- open4.push({ session: listed.get(id), begunAt: wait.begunAt });
302
+ if (wait.reminderOnly) continue;
303
+ open2.push({ session: listed.get(id), begunAt: wait.begunAt });
275
304
  }
276
305
  const answered = /* @__PURE__ */ new Map();
277
306
  const lastBegun = /* @__PURE__ */ new Map();
@@ -309,7 +338,7 @@ function createQuietHold() {
309
338
  since = null;
310
339
  waits = [];
311
340
  over = [];
312
- return { summary: items.length > 0 ? { from, to: at, items } : null, open: open4 };
341
+ return { summary: items.length > 0 ? { from, to: at, items } : null, open: open2 };
313
342
  }
314
343
  };
315
344
  }
@@ -328,6 +357,7 @@ var DAY_MS = 24 * HOUR_MS;
328
357
  var WEEKDAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
329
358
  var TIME_RULE_NAMES = ["longWait", "idle", "quietHours"];
330
359
  var LONG_WAIT_MINUTES = { min: 1, max: 1440, default: 10 };
360
+ var REPEAT_MINUTES = { min: 5, max: 1440, default: 30 };
331
361
  var IDLE_HOURS = { min: 1, max: 720, default: 48 };
332
362
  var DEFAULT_TIME_RULES = {
333
363
  longWait: { on: false, minutes: LONG_WAIT_MINUTES.default },
@@ -357,12 +387,22 @@ function holds(value, keys, exact2) {
357
387
  function wholeNumberIn(value, range) {
358
388
  return Number.isSafeInteger(value) && value >= range.min && value <= range.max;
359
389
  }
360
- function readLongWait(value, exact2) {
390
+ function readRepeat(value, exact2) {
361
391
  if (!isRecord(value) || !holds(value, ["on", "minutes"], exact2)) return null;
362
392
  const { on, minutes } = value;
363
- if (typeof on !== "boolean" || !wholeNumberIn(minutes, LONG_WAIT_MINUTES)) return null;
393
+ if (typeof on !== "boolean" || !wholeNumberIn(minutes, REPEAT_MINUTES)) return null;
364
394
  return { on, minutes };
365
395
  }
396
+ function readLongWait(value, exact2) {
397
+ if (!isRecord(value)) return null;
398
+ const keys = value.repeat === void 0 ? ["on", "minutes"] : ["on", "minutes", "repeat"];
399
+ if (!holds(value, keys, exact2)) return null;
400
+ const { on, minutes } = value;
401
+ if (typeof on !== "boolean" || !wholeNumberIn(minutes, LONG_WAIT_MINUTES)) return null;
402
+ if (value.repeat === void 0) return { on, minutes };
403
+ const repeat = readRepeat(value.repeat, exact2);
404
+ return repeat === null ? null : { on, minutes, repeat };
405
+ }
366
406
  function readIdle(value, exact2) {
367
407
  if (!isRecord(value) || !holds(value, ["on", "hours"], exact2)) return null;
368
408
  const { on, hours } = value;
@@ -394,7 +434,7 @@ var READERS = {
394
434
  quietHours: readQuietHours
395
435
  };
396
436
  var SHAPES = {
397
- longWait: `longWait must be {"on", "minutes"}, with minutes a whole number from ${LONG_WAIT_MINUTES.min} to ${LONG_WAIT_MINUTES.max}.`,
437
+ longWait: `longWait must be {"on", "minutes"}, with minutes a whole number from ${LONG_WAIT_MINUTES.min} to ${LONG_WAIT_MINUTES.max}, and may have "repeat", {"on", "minutes"} with minutes a whole number from ${REPEAT_MINUTES.min} to ${REPEAT_MINUTES.max}.`,
398
438
  idle: `idle must be {"on", "hours"}, with hours a whole number from ${IDLE_HOURS.min} to ${IDLE_HOURS.max}.`,
399
439
  quietHours: 'quietHours must be {"on", "from", "to", "days", "leaveOutAnswered"}, with from and to two different times written as 22:00 is, and days any of mon, tue, wed, thu, fri, sat and sun, each once.'
400
440
  };
@@ -459,6 +499,18 @@ function quietOf(snapshot) {
459
499
  }
460
500
 
461
501
  // src/core/time-rules/reminders.ts
502
+ var MINUTE_MS = 6e4;
503
+ function reminderSchedule(rule) {
504
+ return {
505
+ thresholdMs: rule.minutes * MINUTE_MS,
506
+ everyMs: rule.repeat?.on ? rule.repeat.minutes * MINUTE_MS : null
507
+ };
508
+ }
509
+ function reminderNumber(waitedMs, schedule) {
510
+ if (waitedMs < schedule.thresholdMs) return -1;
511
+ if (schedule.everyMs === null) return 0;
512
+ return Math.floor((waitedMs - schedule.thresholdMs) / schedule.everyMs);
513
+ }
462
514
  function readable(snapshot) {
463
515
  return new Set(
464
516
  snapshot.sources.filter((source) => source.state === "ok" || source.state === "not-set-up").map((source) => source.id)
@@ -471,35 +523,39 @@ function createReminderWatch() {
471
523
  for (const id of stopped) watched.delete(id);
472
524
  const sources = readable(snapshot);
473
525
  for (const session of snapshot.sessions) {
474
- if (session.status !== "needs-you" || !sources.has(session.source)) continue;
526
+ if (!needsYou(session) || !sources.has(session.source)) continue;
475
527
  const since = session.statusSince !== null && session.statusSince <= at ? session.statusSince : null;
476
528
  const known = watched.get(session.id);
477
529
  if (known && (since === null || since === known.begunAt)) continue;
478
530
  watched.set(session.id, {
479
531
  begunAt: since ?? at,
480
- firstSeenAfterMs: since === null ? 0 : at - since,
481
- reminded: /* @__PURE__ */ new Set()
532
+ seenAfterMs: since === null ? 0 : at - since,
533
+ toldAfterMs: null
482
534
  });
483
535
  }
484
536
  },
485
- due(snapshot, at, thresholdMs, eligible = () => true) {
537
+ due(snapshot, at, schedule, eligible = () => true) {
486
538
  const found = [];
487
539
  for (const session of snapshot.sessions) {
488
- if (session.status !== "needs-you") continue;
540
+ if (!needsYou(session)) continue;
489
541
  const wait = watched.get(session.id);
490
- if (!wait || wait.reminded.has(thresholdMs) || !eligible(session.id)) continue;
542
+ if (!wait || !eligible(session.id)) continue;
491
543
  const waitedMs = at - wait.begunAt;
492
- if (waitedMs < thresholdMs || wait.firstSeenAfterMs >= thresholdMs) continue;
493
- found.push({ session, begunAt: wait.begunAt, waitedMs });
544
+ const repeat = reminderNumber(waitedMs, schedule);
545
+ if (repeat < 0 || repeat <= reminderNumber(wait.seenAfterMs, schedule)) continue;
546
+ const told = wait.toldAfterMs;
547
+ if (told !== null && repeat <= reminderNumber(told, schedule)) continue;
548
+ const { thresholdMs, everyMs } = schedule;
549
+ if (told !== null && everyMs !== null && repeat > 0) {
550
+ if (thresholdMs + repeat * everyMs - told < everyMs / 2) continue;
551
+ }
552
+ found.push({ session, begunAt: wait.begunAt, waitedMs, repeat });
494
553
  }
495
554
  return found;
496
555
  },
497
- reminded(sessionId, thresholdMs) {
498
- watched.get(sessionId)?.reminded.add(thresholdMs);
499
- },
500
- toldLate(sessionId, at, thresholdMs) {
556
+ told(sessionId, at) {
501
557
  const wait = watched.get(sessionId);
502
- if (wait && at - wait.begunAt >= thresholdMs) wait.reminded.add(thresholdMs);
558
+ if (wait) wait.toldAfterMs = Math.max(wait.toldAfterMs ?? 0, at - wait.begunAt);
503
559
  },
504
560
  clear() {
505
561
  watched.clear();
@@ -532,7 +588,7 @@ function createOutboundChannel(options) {
532
588
  const wanted = new Set(options.events);
533
589
  let memory = EMPTY_CHANGE_MEMORY;
534
590
  const ready = [];
535
- const open4 = /* @__PURE__ */ new Map();
591
+ const open2 = /* @__PURE__ */ new Map();
536
592
  const done = /* @__PURE__ */ new Map();
537
593
  let sentAt = [];
538
594
  const reminders = createReminderWatch();
@@ -558,7 +614,7 @@ function createOutboundChannel(options) {
558
614
  const result = sessionChanges(memory, snapshot);
559
615
  memory = result.memory;
560
616
  for (const id of result.stopped) {
561
- open4.delete(id);
617
+ open2.delete(id);
562
618
  hold.waitEnded(id, at);
563
619
  }
564
620
  reminders.observe(snapshot, result.stopped, at);
@@ -572,7 +628,7 @@ function createOutboundChannel(options) {
572
628
  }));
573
629
  ready.push((sentNow) => summaryMessage({ ...summary, items, now: sentNow }));
574
630
  }
575
- for (const { session, begunAt } of end.open) open4.set(session.id, begunAt);
631
+ for (const { session, begunAt } of end.open) open2.set(session.id, begunAt);
576
632
  }
577
633
  if (quiet) hold.begin(at);
578
634
  for (const { event, session } of result.changes) {
@@ -593,7 +649,7 @@ function createOutboundChannel(options) {
593
649
  }
594
650
  const since = session.statusSince;
595
651
  if (since !== null && done.get(session.id) === since) continue;
596
- open4.set(session.id, waitBegan(since, at));
652
+ open2.set(session.id, waitBegan(since, at));
597
653
  }
598
654
  sentAt = sendsInLastHour(sentAt, at);
599
655
  while (!quiet && ready.length > 0 && limitLiftsAt(sentAt, at) === null) {
@@ -601,21 +657,21 @@ function createOutboundChannel(options) {
601
657
  sentAt.push(at);
602
658
  send2(write(at));
603
659
  }
604
- const thresholdMs = rules.longWait.minutes * 6e4;
605
- for (const [id, begunAt] of open4) {
660
+ const schedule = reminderSchedule(rules.longWait);
661
+ for (const [id, begunAt] of open2) {
606
662
  const session = snapshot.sessions.find(
607
- (candidate) => candidate.id === id && candidate.status === "needs-you"
663
+ (candidate) => candidate.id === id && needsYou(candidate)
608
664
  );
609
665
  if (!session) continue;
610
666
  const timing = sendTiming(begunAt, afterMs, sentAt, at);
611
667
  if (timing === "wait") continue;
612
668
  if (quiet) {
613
- open4.delete(id);
669
+ open2.delete(id);
614
670
  hold.holdWait(session, begunAt);
615
671
  continue;
616
672
  }
617
673
  if (timing === "limited") continue;
618
- open4.delete(id);
674
+ open2.delete(id);
619
675
  sentAt.push(at);
620
676
  send2(
621
677
  waitMessage({
@@ -627,13 +683,17 @@ function createOutboundChannel(options) {
627
683
  now: at
628
684
  })
629
685
  );
630
- if (rules.longWait.on) reminders.toldLate(id, at, thresholdMs);
686
+ if (rules.longWait.on) reminders.told(id, at);
631
687
  }
632
- if (!quiet && rules.longWait.on && wanted.has("needs-you")) {
633
- for (const due of reminders.due(snapshot, at, thresholdMs, (id) => !open4.has(id))) {
688
+ if (rules.longWait.on && wanted.has("needs-you")) {
689
+ for (const due of reminders.due(snapshot, at, schedule, (id) => !open2.has(id))) {
690
+ if (quiet) {
691
+ hold.holdReminder(due.session, due.begunAt);
692
+ continue;
693
+ }
634
694
  if (limitLiftsAt(sentAt, at) !== null) break;
635
695
  sentAt.push(at);
636
- reminders.reminded(due.session.id, thresholdMs);
696
+ reminders.told(due.session.id, at);
637
697
  send2(
638
698
  reminderMessage({
639
699
  session: withoutWaitingText(due.session),
@@ -641,13 +701,15 @@ function createOutboundChannel(options) {
641
701
  asking: asking ? waitingText(due.session.waitingText) ?? null : null,
642
702
  begunAt: due.begunAt,
643
703
  now: at,
644
- thresholdMs
704
+ thresholdMs: schedule.thresholdMs,
705
+ everyMs: schedule.everyMs,
706
+ repeat: due.repeat
645
707
  })
646
708
  );
647
709
  }
648
710
  }
649
711
  for (const session of snapshot.sessions) {
650
- if (session.status === "needs-you" && session.statusSince !== null && !open4.has(session.id)) {
712
+ if (needsYou(session) && session.statusSince !== null && !open2.has(session.id)) {
651
713
  done.set(session.id, session.statusSince);
652
714
  }
653
715
  }
@@ -661,9 +723,9 @@ function createOutboundChannel(options) {
661
723
  }
662
724
 
663
725
  // src/core/time-rules/timeRulesWords.ts
664
- var MINUTE_MS = 6e4;
726
+ var MINUTE_MS2 = 6e4;
665
727
  function waitedInWords(ms) {
666
- if (ms < MINUTE_MS) return "under a minute";
728
+ if (ms < MINUTE_MS2) return "under a minute";
667
729
  return partsInWords(durationParts(ms).filter((part) => part.unit !== "s"));
668
730
  }
669
731
  var WAITED_FOR = {
@@ -865,9 +927,10 @@ function waitEmail(facts) {
865
927
  }
866
928
  function reminderEmail(facts) {
867
929
  const { session, begunAt, now } = facts;
930
+ const again = facts.everyMs === null ? "" : `, and again every ${waitedInWords(facts.everyMs)} while it goes on`;
868
931
  return emailOf(
869
932
  reminderSentence(session, now - begunAt),
870
- `It has waited since ${clockAt(begunAt, now)}. Agent Lookout reminds you once a wait lasts ${waitedInWords(facts.thresholdMs)}, as Time rules in Settings says.`,
933
+ `It has waited since ${clockAt(begunAt, now)}. Agent Lookout reminds you once a wait lasts ${waitedInWords(facts.thresholdMs)}${again}, as Time rules in Settings says.`,
871
934
  session,
872
935
  facts.agent,
873
936
  facts.asking
@@ -998,15 +1061,15 @@ function recordValue(record) {
998
1061
  case "cleared":
999
1062
  return { cleared: record.at };
1000
1063
  case "point": {
1001
- const { at, needsYou, working, idle, total } = record.point;
1002
- return { point: [at, needsYou, working, idle, total] };
1064
+ const { at, needsYou: needsYou2, working, idle, total } = record.point;
1065
+ return { point: [at, needsYou2, working, idle, total] };
1003
1066
  }
1004
1067
  case "event":
1005
1068
  return { event: eventFields(record.event) };
1006
1069
  }
1007
1070
  }
1008
1071
  function eventFields(event) {
1009
- const { id, at, sessionId, sessionName: sessionName2, kind, from, to, severity, by, decision } = event;
1072
+ const { id, at, sessionId, sessionName: sessionName2, kind, from, to, severity, by, decision, tool, rule } = event;
1010
1073
  return {
1011
1074
  id,
1012
1075
  at,
@@ -1017,7 +1080,12 @@ function eventFields(event) {
1017
1080
  ...to !== void 0 && { to },
1018
1081
  severity,
1019
1082
  ...by !== void 0 && { by },
1020
- ...decision !== void 0 && { decision }
1083
+ ...decision !== void 0 && { decision },
1084
+ ...tool !== void 0 && { tool },
1085
+ // The rule's own two fields, and nothing else it might carry.
1086
+ ...rule !== void 0 && {
1087
+ rule: { tool: rule.tool, ...rule.command !== void 0 && { command: rule.command } }
1088
+ }
1021
1089
  };
1022
1090
  }
1023
1091
  function isRecord2(value) {
@@ -1038,11 +1106,11 @@ function oneOf(list, value) {
1038
1106
  function readPoint(value) {
1039
1107
  if (!Array.isArray(value) || value.length !== 5) return null;
1040
1108
  const at = moment(value[0]);
1041
- const [needsYou, working, idle, total] = value.slice(1).map(count);
1042
- if (at === null || needsYou == null || working == null || idle == null || total == null) {
1109
+ const [needsYou2, working, idle, total] = value.slice(1).map(count);
1110
+ if (at === null || needsYou2 == null || working == null || idle == null || total == null) {
1043
1111
  return null;
1044
1112
  }
1045
- return { at, needsYou, working, idle, total };
1113
+ return { at, needsYou: needsYou2, working, idle, total };
1046
1114
  }
1047
1115
  function readEvent(value) {
1048
1116
  if (!isRecord2(value)) return null;
@@ -1060,6 +1128,10 @@ function readEvent(value) {
1060
1128
  if (value.by !== void 0 && !by) return null;
1061
1129
  const decision = oneOf(ANSWER_DECISIONS, value.decision);
1062
1130
  if (value.decision !== void 0 && !decision) return null;
1131
+ const tool = field(value.tool);
1132
+ if (value.tool !== void 0 && !tool) return null;
1133
+ const rule = value.rule === void 0 ? void 0 : readRule(value.rule);
1134
+ if (rule === null) return null;
1063
1135
  return {
1064
1136
  id,
1065
1137
  at,
@@ -1070,9 +1142,20 @@ function readEvent(value) {
1070
1142
  ...to && { to },
1071
1143
  severity,
1072
1144
  ...by && { by },
1073
- ...decision && { decision }
1145
+ ...decision && { decision },
1146
+ ...tool && { tool },
1147
+ ...rule && { rule }
1074
1148
  };
1075
1149
  }
1150
+ function readRule(value) {
1151
+ if (!isRecord2(value)) return null;
1152
+ const keys = Object.keys(value);
1153
+ if (!keys.every((key) => key === "tool" || key === "command")) return null;
1154
+ const tool = field(value.tool);
1155
+ const command = value.command === void 0 ? void 0 : field(value.command);
1156
+ if (!tool || command === null) return null;
1157
+ return { tool, ...command !== void 0 && { command } };
1158
+ }
1076
1159
  function parseRecord(line) {
1077
1160
  if (line === "" || line.length > MAX_LINE_LENGTH) return null;
1078
1161
  let value;
@@ -1291,6 +1374,34 @@ function staleBy(sessions, at, thresholdMs) {
1291
1374
  return stale === session.stale ? session : { ...session, stale };
1292
1375
  });
1293
1376
  }
1377
+ function forEvents(sessions, previous, answered) {
1378
+ if (answered.size === 0) return sessions;
1379
+ const before = new Map(previous.map((session) => [session.id, session]));
1380
+ const taken = [];
1381
+ for (const session of sessions) {
1382
+ if (!answered.has(session.id)) {
1383
+ taken.push(session);
1384
+ continue;
1385
+ }
1386
+ const was = before.get(session.id);
1387
+ if (was) taken.push(was);
1388
+ }
1389
+ return taken;
1390
+ }
1391
+ function markAnswered(sessions, answered) {
1392
+ if (answered.size === 0) return sessions;
1393
+ return sessions.map(
1394
+ (session) => answered.has(session.id) ? { ...session, answered: true } : session
1395
+ );
1396
+ }
1397
+ var NONE_ANSWERED = /* @__PURE__ */ new Set();
1398
+ function withAnsweredThere(answered, sessions) {
1399
+ const there = sessions.filter(
1400
+ (session) => session.machine !== void 0 && session.answered === true
1401
+ );
1402
+ if (there.length === 0) return answered;
1403
+ return /* @__PURE__ */ new Set([...answered, ...there.map((session) => session.id)]);
1404
+ }
1294
1405
  function declared(adapter) {
1295
1406
  return adapter.capabilities === void 0 ? {} : { capabilities: adapter.capabilities };
1296
1407
  }
@@ -1345,11 +1456,11 @@ function createPoller(options) {
1345
1456
  };
1346
1457
  }
1347
1458
  function ask(adapter) {
1348
- const open4 = overdue.get(adapter);
1349
- if (open4) {
1350
- if (open4.late === void 0) return Promise.resolve(stillWaiting(adapter, open4.since));
1459
+ const open2 = overdue.get(adapter);
1460
+ if (open2) {
1461
+ if (open2.late === void 0) return Promise.resolve(stillWaiting(adapter, open2.since));
1351
1462
  overdue.delete(adapter);
1352
- return Promise.resolve(open4.late);
1463
+ return Promise.resolve(open2.late);
1353
1464
  }
1354
1465
  const since = now();
1355
1466
  return new Promise((resolve) => {
@@ -1393,10 +1504,24 @@ function createPoller(options) {
1393
1504
  return sessions;
1394
1505
  }
1395
1506
  }
1507
+ function answeredIn(snapshot) {
1508
+ if (!options.answered) return NONE_ANSWERED;
1509
+ try {
1510
+ return options.answered(snapshot);
1511
+ } catch {
1512
+ return NONE_ANSWERED;
1513
+ }
1514
+ }
1396
1515
  async function poll() {
1397
1516
  const results = await Promise.all(adapters.map((adapter) => ask(adapter)));
1398
1517
  const found = await annotated(results.flatMap((result) => result.sessions));
1399
1518
  const at = now();
1519
+ const sources = results.map((result, index) => ({
1520
+ ...result.health,
1521
+ ...result.basis !== void 0 && { basis: result.basis },
1522
+ ...declared(adapters[index])
1523
+ }));
1524
+ const answered = withAnsweredThere(answeredIn({ sources, sessions: found }), found);
1400
1525
  const changes = [];
1401
1526
  results.forEach((result, index) => {
1402
1527
  const { state } = result.health;
@@ -1413,35 +1538,29 @@ function createPoller(options) {
1413
1538
  const { baselines, reported } = remembered;
1414
1539
  const basis = result.basis ?? "";
1415
1540
  const previous = baselines.get(basis);
1541
+ const sessions = previous ? forEvents(result.sessions, previous, answered) : result.sessions;
1416
1542
  if (previous) {
1417
- changes.push(...withoutRepeats(diffSessions(previous, result.sessions, at), reported));
1543
+ changes.push(...withoutRepeats(diffSessions(previous, sessions, at), reported));
1418
1544
  } else {
1419
1545
  if (lastSeen) {
1420
1546
  const known = new Set(lastSeen.map((session) => session.id));
1421
1547
  const before = lastSeen.filter(
1422
1548
  (session) => session.status !== null
1423
1549
  );
1424
- const after = result.sessions.filter((session) => known.has(session.id));
1550
+ const after = sessions.filter((session) => known.has(session.id));
1425
1551
  changes.push(...diffSessions(before, after, at));
1426
1552
  }
1427
- for (const session of result.sessions) {
1553
+ for (const session of sessions) {
1428
1554
  if (!reported.has(session.id)) reported.set(session.id, session.status);
1429
1555
  }
1430
1556
  }
1431
- baselines.set(basis, result.sessions.map(withoutWaitingText));
1557
+ baselines.set(basis, sessions.map(withoutWaitingText));
1432
1558
  });
1433
1559
  const rules = options.timeRules?.();
1434
1560
  const snapshot = {
1435
1561
  generatedAt: at,
1436
- // The way each was read goes with it, so what follows the snapshots
1437
- // compares answers the way the event log does. So does what its agent
1438
- // can report, whatever the answer was.
1439
- sources: results.map((result, index) => ({
1440
- ...result.health,
1441
- ...result.basis !== void 0 && { basis: result.basis },
1442
- ...declared(adapters[index])
1443
- })),
1444
- sessions: sortSessions(staleBy(found, at, staleAfterMs(rules))),
1562
+ sources,
1563
+ sessions: sortSessions(staleBy(markAnswered(found, answered), at, staleAfterMs(rules))),
1445
1564
  ...rules && { timeRules: rules, quiet: isQuietAt(rules.quietHours, at) }
1446
1565
  };
1447
1566
  latest = snapshot;
@@ -1570,7 +1689,9 @@ function reminderPost(facts) {
1570
1689
  post.session.app,
1571
1690
  post.session.agent
1572
1691
  ]),
1573
- reminder: true
1692
+ reminder: true,
1693
+ // Left out on the first, so its post is the post it always was.
1694
+ ...facts.repeat > 0 && { repeat: facts.repeat }
1574
1695
  };
1575
1696
  }
1576
1697
  function summaryPost(facts) {
@@ -1791,11 +1912,12 @@ function createApiHandler(options) {
1791
1912
  if (options.cleanUp) actions.set(CLEAN_UP_PATH, options.cleanUp);
1792
1913
  if (options.answer) actions.set(ANSWER_PATH, options.answer);
1793
1914
  if (options.timeRules) actions.set(TIME_RULES_PATH, options.timeRules);
1915
+ if (options.permissionRules) actions.set(PERMISSION_RULES_PATH, options.permissionRules);
1794
1916
  const serveSnapshot = options.serveSnapshot ?? ((snapshot) => snapshot);
1795
1917
  function route(req, res) {
1796
- const refusal2 = refusalFor(req);
1797
- if (refusal2) {
1798
- fail(res, 403, refusal2);
1918
+ const refusal3 = refusalFor(req);
1919
+ if (refusal3) {
1920
+ fail(res, 403, refusal3);
1799
1921
  return;
1800
1922
  }
1801
1923
  let url;
@@ -2136,7 +2258,7 @@ function urlFor(address) {
2136
2258
 
2137
2259
  // src/collector/hosts/standalone.ts
2138
2260
  import { existsSync } from "node:fs";
2139
- import path23 from "node:path";
2261
+ import path21 from "node:path";
2140
2262
 
2141
2263
  // src/core/mapping/claudeCodeMapping.ts
2142
2264
  var LIVE_STATUS = /* @__PURE__ */ new Map([
@@ -2195,7 +2317,7 @@ function claudeCodeOpenLink(surface, sessionId) {
2195
2317
  }
2196
2318
 
2197
2319
  // src/collector/terminal/terminalTabs.ts
2198
- import path2 from "node:path";
2320
+ import path2 from "node:path/posix";
2199
2321
  var TERMINAL_PATH = "/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal";
2200
2322
  var ITERM_PATH = "/Applications/iTerm.app/Contents/MacOS/iTerm2";
2201
2323
  var ITERM_SERVER_FOLDER = "Library/Application Support/iTerm2";
@@ -2785,7 +2907,15 @@ import path4 from "node:path";
2785
2907
  import { constants } from "node:fs";
2786
2908
  import { access, stat as stat2 } from "node:fs/promises";
2787
2909
  import path3 from "node:path";
2788
- async function isExecutableFile(candidate) {
2910
+ function pathsOf(platform) {
2911
+ return platform === "win32" ? path3.win32 : path3.posix;
2912
+ }
2913
+ var WINDOWS_PROGRAM_ENDINGS = [".com", ".exe"];
2914
+ function isWindowsProgram(file) {
2915
+ return WINDOWS_PROGRAM_ENDINGS.includes(path3.win32.extname(file).toLowerCase());
2916
+ }
2917
+ async function isExecutableFile(candidate, platform = process.platform) {
2918
+ if (platform === "win32" && !isWindowsProgram(candidate)) return false;
2789
2919
  try {
2790
2920
  const info = await stat2(candidate);
2791
2921
  if (!info.isFile()) return false;
@@ -2795,44 +2925,103 @@ async function isExecutableFile(candidate) {
2795
2925
  return false;
2796
2926
  }
2797
2927
  }
2798
- function pathCandidates(env, program) {
2799
- return (env.PATH ?? "").split(path3.delimiter).filter((dir) => dir !== "" && path3.isAbsolute(dir)).map((dir) => path3.join(dir, program));
2928
+ function variable(env, name, platform = process.platform) {
2929
+ if (platform !== "win32" || env[name] !== void 0) return env[name];
2930
+ const key = Object.keys(env).find((each) => each.toUpperCase() === name);
2931
+ return key === void 0 ? void 0 : env[key];
2800
2932
  }
2801
- function tildify(target, homeDir) {
2802
- if (homeDir === "" || homeDir === path3.sep) return target;
2803
- if (target === homeDir) return "~";
2804
- if (target.startsWith(homeDir + path3.sep)) return `~${target.slice(homeDir.length)}`;
2805
- return target;
2933
+ function programEndings(env) {
2934
+ const listed = (variable(env, "PATHEXT", "win32") ?? "").split(";").map((ending) => ending.trim().toLowerCase()).filter((ending) => WINDOWS_PROGRAM_ENDINGS.includes(ending));
2935
+ return listed.length > 0 ? [...new Set(listed)] : WINDOWS_PROGRAM_ENDINGS;
2936
+ }
2937
+ function pathFolders(env, platform = process.platform) {
2938
+ const paths = pathsOf(platform);
2939
+ return (variable(env, "PATH", platform) ?? "").split(paths.delimiter).map((dir) => platform === "win32" ? dir.trim().replace(/^"(.*)"$/, "$1") : dir).filter((dir) => dir !== "" && paths.isAbsolute(dir));
2940
+ }
2941
+ function programNames(env, program, platform = process.platform) {
2942
+ if (platform !== "win32" || isWindowsProgram(program)) return [program];
2943
+ return programEndings(env).map((ending) => `${program}${ending}`);
2944
+ }
2945
+ function pathCandidates(env, program, platform = process.platform) {
2946
+ const paths = pathsOf(platform);
2947
+ const names = programNames(env, program, platform);
2948
+ return pathFolders(env, platform).flatMap((dir) => names.map((name) => paths.join(dir, name)));
2949
+ }
2950
+ function tildify(target, homeDir, platform = process.platform) {
2951
+ if (homeDir === "") return target;
2952
+ if (platform !== "win32") {
2953
+ if (homeDir === "/") return target;
2954
+ if (target === homeDir) return "~";
2955
+ if (target.startsWith(`${homeDir}/`)) return `~${target.slice(homeDir.length)}`;
2956
+ return target;
2957
+ }
2958
+ const home = path3.win32.resolve(homeDir);
2959
+ if (path3.win32.parse(home).root === home) return target;
2960
+ const inside = path3.win32.relative(home, path3.win32.resolve(target));
2961
+ if (inside === "") return "~";
2962
+ if (inside === ".." || inside.startsWith("..\\") || path3.win32.isAbsolute(inside)) return target;
2963
+ return `~/${inside.replaceAll("\\", "/")}`;
2806
2964
  }
2807
2965
 
2808
2966
  // src/collector/adapters/claude-code/findBinary.ts
2809
2967
  var CLAUDE_BIN_ENV = "AGENT_LOOKOUT_CLAUDE_BIN";
2810
- function fixedLocations(homeDir) {
2968
+ var NPM_PROGRAM = ["node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"];
2969
+ function npmFolder(env, homeDir) {
2970
+ const appData = variable(env, "APPDATA", "win32")?.trim();
2971
+ return appData && path4.win32.isAbsolute(appData) ? path4.win32.join(appData, "npm") : path4.win32.join(homeDir, "AppData", "Roaming", "npm");
2972
+ }
2973
+ function fixedLocations(homeDir, platform = process.platform, env = {}) {
2974
+ if (platform === "win32") {
2975
+ return [
2976
+ path4.win32.join(homeDir, ".local", "bin", "claude.exe"),
2977
+ path4.win32.join(npmFolder(env, homeDir), ...NPM_PROGRAM)
2978
+ ];
2979
+ }
2811
2980
  return [
2812
- path4.join(homeDir, ".local", "bin", "claude"),
2981
+ path4.posix.join(homeDir, ".local", "bin", "claude"),
2813
2982
  "/opt/homebrew/bin/claude",
2814
2983
  "/usr/local/bin/claude",
2815
- path4.join(homeDir, ".npm-global", "bin", "claude"),
2984
+ path4.posix.join(homeDir, ".npm-global", "bin", "claude"),
2816
2985
  "/usr/bin/claude"
2817
2986
  ];
2818
2987
  }
2988
+ function onPath(env, platform) {
2989
+ if (platform !== "win32") return pathCandidates(env, "claude", platform);
2990
+ const names = programNames(env, "claude", platform);
2991
+ return pathFolders(env, platform).flatMap((dir) => [
2992
+ ...names.map((name) => path4.win32.join(dir, name)),
2993
+ path4.win32.join(dir, ...NPM_PROGRAM)
2994
+ ]);
2995
+ }
2996
+ var WINDOWS_SCRIPT = /\.(bat|cmd|ps1)$/i;
2819
2997
  async function findClaudeBinary(options) {
2820
- const isExecutable = options.isExecutable ?? isExecutableFile;
2998
+ const platform = options.platform ?? process.platform;
2999
+ const paths = pathsOf(platform);
3000
+ const isExecutable = options.isExecutable ?? ((candidate) => isExecutableFile(candidate, platform));
2821
3001
  const named = options.env[CLAUDE_BIN_ENV]?.trim();
2822
3002
  if (named) {
2823
- const resolved = path4.resolve(named);
3003
+ const resolved = paths.resolve(named);
2824
3004
  if (await isExecutable(resolved)) return { found: true, path: resolved };
2825
- return {
2826
- found: false,
2827
- looked: `${CLAUDE_BIN_ENV} is set to ${named}, which is not a program this user can run`
2828
- };
3005
+ const why = platform === "win32" && WINDOWS_SCRIPT.test(resolved) ? "a script that needs a shell to run, and Agent Lookout runs no shell" : "not a program this user can run";
3006
+ return { found: false, looked: `${CLAUDE_BIN_ENV} is set to ${named}, which is ${why}` };
2829
3007
  }
2830
- const fixed = fixedLocations(options.homeDir);
2831
- const candidates = [.../* @__PURE__ */ new Set([...pathCandidates(options.env, "claude"), ...fixed])];
3008
+ const fixed = fixedLocations(options.homeDir, platform, options.env);
3009
+ const candidates = [.../* @__PURE__ */ new Set([...onPath(options.env, platform), ...fixed])];
2832
3010
  for (const candidate of candidates) {
2833
3011
  if (await isExecutable(candidate)) return { found: true, path: candidate };
2834
3012
  }
2835
- const fixedDirs = fixed.map((candidate) => tildify(path4.dirname(candidate), options.homeDir));
3013
+ if (platform === "win32") {
3014
+ const places = [paths.dirname(fixed[0]), npmFolder(options.env, options.homeDir)].map(
3015
+ (place) => tildify(place, options.homeDir, platform)
3016
+ );
3017
+ return {
3018
+ found: false,
3019
+ looked: `The claude command, claude.exe, was not found on PATH or in ${listWithOr(places)}`
3020
+ };
3021
+ }
3022
+ const fixedDirs = fixed.map(
3023
+ (candidate) => tildify(paths.dirname(candidate), options.homeDir, platform)
3024
+ );
2836
3025
  return {
2837
3026
  found: false,
2838
3027
  looked: `The claude command was not found on PATH or in ${listWithOr(fixedDirs)}`
@@ -2845,7 +3034,6 @@ function listWithOr(items) {
2845
3034
 
2846
3035
  // src/collector/adapters/claude-code/index.ts
2847
3036
  import os from "node:os";
2848
- import path9 from "node:path";
2849
3037
 
2850
3038
  // src/core/time.ts
2851
3039
  var EARLIEST_PLAUSIBLE_TIME_MS = Date.UTC(2020, 0, 1);
@@ -3044,13 +3232,73 @@ function createFinishedTracker(retentionMs = FINISHED_RETENTION_MS) {
3044
3232
  }
3045
3233
 
3046
3234
  // src/collector/adapters/claude-code/registry.ts
3047
- import { constants as constants2 } from "node:fs";
3048
- import { open, readdir } from "node:fs/promises";
3235
+ import { constants as constants3 } from "node:fs";
3236
+ import { readdir } from "node:fs/promises";
3049
3237
  import path5 from "node:path";
3238
+
3239
+ // src/collector/files/noFollow.ts
3240
+ import { closeSync, constants as constants2, fstatSync, lstatSync, openSync } from "node:fs";
3241
+ import { lstat, open } from "node:fs/promises";
3242
+ var OPEN_FOLLOWS_LINKS = constants2.O_NOFOLLOW === void 0;
3243
+ var NO_FOLLOW = (constants2.O_NOFOLLOW ?? 0) | (constants2.O_NONBLOCK ?? 0);
3244
+ function notOrdinary() {
3245
+ return new Error("Not an ordinary file.");
3246
+ }
3247
+ function refusal2(named) {
3248
+ if (!named.isSymbolicLink()) return notOrdinary();
3249
+ return Object.assign(new Error("A symbolic link, which is not followed."), { code: "ELOOP" });
3250
+ }
3251
+ function sameFile(named, opened) {
3252
+ return named.isFile() && named.dev === opened.dev && named.ino === opened.ino;
3253
+ }
3254
+ function isMissing(error) {
3255
+ return error?.code === "ENOENT";
3256
+ }
3257
+ var creates = (flags) => (flags & constants2.O_CREAT) !== 0;
3258
+ async function openWithoutFollowing(file, flags, mode, follows = OPEN_FOLLOWS_LINKS) {
3259
+ if (!follows) return open(file, flags | NO_FOLLOW, mode);
3260
+ let before = null;
3261
+ try {
3262
+ before = await lstat(file, { bigint: true });
3263
+ } catch (error) {
3264
+ if (!(isMissing(error) && creates(flags))) throw error;
3265
+ }
3266
+ if (before !== null && !before.isFile()) throw refusal2(before);
3267
+ const handle = await open(file, flags, mode);
3268
+ try {
3269
+ const opened = await handle.stat({ bigint: true });
3270
+ const named = before ?? await lstat(file, { bigint: true });
3271
+ if (!sameFile(named, opened)) throw notOrdinary();
3272
+ return handle;
3273
+ } catch (error) {
3274
+ await handle.close();
3275
+ throw error;
3276
+ }
3277
+ }
3278
+ function openWithoutFollowingNow(file, flags, mode, follows = OPEN_FOLLOWS_LINKS) {
3279
+ if (!follows) return openSync(file, flags | NO_FOLLOW, mode);
3280
+ let before = null;
3281
+ try {
3282
+ before = lstatSync(file, { bigint: true });
3283
+ } catch (error) {
3284
+ if (!(isMissing(error) && creates(flags))) throw error;
3285
+ }
3286
+ if (before !== null && !before.isFile()) throw refusal2(before);
3287
+ const fd = openSync(file, flags, mode);
3288
+ try {
3289
+ const named = before ?? lstatSync(file, { bigint: true });
3290
+ if (!sameFile(named, fstatSync(fd, { bigint: true }))) throw notOrdinary();
3291
+ return fd;
3292
+ } catch (error) {
3293
+ closeSync(fd);
3294
+ throw error;
3295
+ }
3296
+ }
3297
+
3298
+ // src/collector/adapters/claude-code/registry.ts
3050
3299
  var MAX_ENTRY_BYTES = 256 * 1024;
3051
3300
  async function readRegularFile(file) {
3052
- const flags = constants2.O_RDONLY | (constants2.O_NOFOLLOW ?? 0) | (constants2.O_NONBLOCK ?? 0);
3053
- const handle = await open(file, flags);
3301
+ const handle = await openWithoutFollowing(file, constants3.O_RDONLY);
3054
3302
  try {
3055
3303
  const info = await handle.stat();
3056
3304
  if (!info.isFile()) throw new Error("Not an ordinary file.");
@@ -3108,7 +3356,7 @@ function parseRegistryEntry(content) {
3108
3356
  procStart: text2(raw.procStart)
3109
3357
  };
3110
3358
  }
3111
- async function readRegistry(sessionsDir, io = nodeIo) {
3359
+ async function readRegistry(sessionsDir, io = nodeIo, previous) {
3112
3360
  let names;
3113
3361
  try {
3114
3362
  names = await io.readdir(sessionsDir);
@@ -3117,22 +3365,28 @@ async function readRegistry(sessionsDir, io = nodeIo) {
3117
3365
  return { readable: false, missing: code === "ENOENT" || code === "ENOTDIR" };
3118
3366
  }
3119
3367
  const files = names.filter(isRegistryFileName).sort();
3120
- const parsed = await Promise.all(
3368
+ const parsed = /* @__PURE__ */ new Map();
3369
+ const found = await Promise.all(
3121
3370
  files.map(async (name) => {
3371
+ let content;
3122
3372
  try {
3123
- return parseRegistryEntry(await io.readFile(path5.join(sessionsDir, name)));
3373
+ content = await io.readFile(path5.join(sessionsDir, name));
3124
3374
  } catch {
3125
3375
  return null;
3126
3376
  }
3377
+ const entry = parseRegistryEntry(content);
3378
+ if (entry === null) return previous?.get(name) ?? null;
3379
+ parsed.set(name, entry);
3380
+ return entry;
3127
3381
  })
3128
3382
  );
3129
3383
  const entries = /* @__PURE__ */ new Map();
3130
- parsed.forEach((entry, index) => {
3384
+ found.forEach((entry, index) => {
3131
3385
  if (!entry) return;
3132
3386
  const isNamedForPid = files[index] === `${entry.pid}.json`;
3133
3387
  if (isNamedForPid || !entries.has(entry.pid)) entries.set(entry.pid, entry);
3134
3388
  });
3135
- return { readable: true, entries };
3389
+ return { readable: true, entries, parsed };
3136
3390
  }
3137
3391
 
3138
3392
  // src/collector/adapters/claude-code/stopOffers.ts
@@ -3153,7 +3407,7 @@ function build(fields, origin, context) {
3153
3407
  const project = projectOf(fields.cwd);
3154
3408
  const surface = mapClaudeCodeSurface(fields.entrypoint);
3155
3409
  const mapped = mapClaudeCodeStatus(fields, origin);
3156
- const open4 = claudeCodeOpenLink(surface, fields.sessionId);
3410
+ const open2 = claudeCodeOpenLink(surface, fields.sessionId);
3157
3411
  const startedAt = plausibleTime(fields.startedAt, context.now);
3158
3412
  let statusSince = plausibleTime(fields.statusUpdatedAt, context.now);
3159
3413
  if (statusSince !== null && startedAt !== null && statusSince < startedAt - WRITE_ORDER_SLACK_MS) {
@@ -3170,7 +3424,7 @@ function build(fields, origin, context) {
3170
3424
  ...mapped,
3171
3425
  startedAt,
3172
3426
  statusSince,
3173
- links: open4 ? { open: open4 } : {},
3427
+ links: open2 ? { open: open2 } : {},
3174
3428
  stale: isStale({ status: mapped.status, statusSince }, context.now)
3175
3429
  };
3176
3430
  if (fields.pid !== void 0) {
@@ -3273,8 +3527,8 @@ function withStopOffers(sessions, live2, answer, options) {
3273
3527
  import path8 from "node:path";
3274
3528
 
3275
3529
  // src/collector/files/readOnlyIo.ts
3276
- import { constants as constants3 } from "node:fs";
3277
- import { lstat, open as open2, readdir as readdir2, stat as stat3 } from "node:fs/promises";
3530
+ import { constants as constants4 } from "node:fs";
3531
+ import { lstat as lstat2, readdir as readdir2, stat as stat3 } from "node:fs/promises";
3278
3532
  function infoOf(stats) {
3279
3533
  return {
3280
3534
  kind: stats.isFile() ? "file" : stats.isDirectory() ? "directory" : "other",
@@ -3284,8 +3538,7 @@ function infoOf(stats) {
3284
3538
  };
3285
3539
  }
3286
3540
  async function openRegularFile(file) {
3287
- const flags = constants3.O_RDONLY | (constants3.O_NOFOLLOW ?? 0) | (constants3.O_NONBLOCK ?? 0);
3288
- const handle = await open2(file, flags);
3541
+ const handle = await openWithoutFollowing(file, constants4.O_RDONLY);
3289
3542
  let info;
3290
3543
  try {
3291
3544
  info = infoOf(await handle.stat());
@@ -3317,10 +3570,10 @@ async function openRegularFile(file) {
3317
3570
  var nodeIo2 = {
3318
3571
  readdir: (dir) => readdir2(dir),
3319
3572
  stat: async (target) => infoOf(await stat3(target)),
3320
- lstat: async (target) => infoOf(await lstat(target)),
3573
+ lstat: async (target) => infoOf(await lstat2(target)),
3321
3574
  openRegular: openRegularFile
3322
3575
  };
3323
- function isMissing(error) {
3576
+ function isMissing2(error) {
3324
3577
  const code = error?.code;
3325
3578
  return code === "ENOENT" || code === "ENOTDIR";
3326
3579
  }
@@ -3493,12 +3746,12 @@ function sameStamp(a, b) {
3493
3746
  return a.size === b.size && a.mtimeMs === b.mtimeMs && a.ino === b.ino;
3494
3747
  }
3495
3748
  async function readTranscriptTail(file, io, known, maxBytes = TRANSCRIPT_TAIL_BYTES) {
3496
- const open4 = await io.openRegular(file);
3749
+ const open2 = await io.openRegular(file);
3497
3750
  try {
3498
- const stamp = stampOf(open4.info);
3751
+ const stamp = stampOf(open2.info);
3499
3752
  if (known !== null && sameStamp(known, stamp)) return { unchanged: true, stamp };
3500
3753
  const start = Math.max(0, stamp.size - maxBytes);
3501
- const bytes2 = await open4.read(start, stamp.size - start);
3754
+ const bytes2 = await open2.read(start, stamp.size - start);
3502
3755
  return {
3503
3756
  unchanged: false,
3504
3757
  stamp,
@@ -3506,7 +3759,7 @@ async function readTranscriptTail(file, io, known, maxBytes = TRANSCRIPT_TAIL_BY
3506
3759
  fromStart: start === 0
3507
3760
  };
3508
3761
  } finally {
3509
- await open4.close();
3762
+ await open2.close();
3510
3763
  }
3511
3764
  }
3512
3765
 
@@ -3544,7 +3797,7 @@ function createWaitingTextReader(options) {
3544
3797
  }
3545
3798
  return wait.text;
3546
3799
  } catch (error) {
3547
- if (isMissing(error)) wait.file = null;
3800
+ if (isMissing2(error)) wait.file = null;
3548
3801
  wait.lookedAt = at;
3549
3802
  wait.stamp = null;
3550
3803
  wait.text = void 0;
@@ -3613,7 +3866,22 @@ var CLAUDE_CODE_CAPABILITIES = {
3613
3866
  },
3614
3867
  answer: {
3615
3868
  level: "partly",
3616
- reason: "With the Agent Lookout plugin installed. Allow only when all it allows is shown, so edits, plans and questions can only be denied."
3869
+ reason: "With the Agent Lookout plugin, by Allow, Deny or a permission rule. Allow only if all is shown: never edits, plans or questions."
3870
+ }
3871
+ };
3872
+ var CLAUDE_CODE_WINDOWS_CAPABILITIES = {
3873
+ ...CLAUDE_CODE_CAPABILITIES,
3874
+ jump: {
3875
+ level: "partly",
3876
+ reason: "Only in VS Code. On Windows there is no tmux pane or Terminal tab to reach, and the desktop app has no link."
3877
+ },
3878
+ stop: {
3879
+ level: "no",
3880
+ reason: "Not on Windows, where there is no ps to confirm a session's process by its start time, and no POSIX signal to stop it."
3881
+ },
3882
+ answer: {
3883
+ level: "no",
3884
+ reason: "Not on Windows: the plugin's hook is a POSIX sh script, and it reaches Agent Lookout through a Unix socket."
3617
3885
  }
3618
3886
  };
3619
3887
  var STOP_TURNED_OFF = {
@@ -3649,21 +3917,25 @@ function createClaudeCodeAdapter(options = {}) {
3649
3917
  const transcriptsOff = waitingTextOff(env);
3650
3918
  const feedWithheld = homeOverride !== void 0 && !binaryNamed;
3651
3919
  const neverRun = feedOff || feedWithheld;
3652
- const stops = stopOff(env) ? void 0 : options.stops;
3920
+ const platform = options.platform ?? process.platform;
3921
+ const onWindows = platform === "win32";
3922
+ const paths = pathsOf(platform);
3923
+ const stops = stopOff(env) || onWindows ? void 0 : options.stops;
3653
3924
  const answersOff = env.AGENT_LOOKOUT_ANSWER?.trim().toLowerCase() === "off";
3654
- const capabilities = stopOff(env) || answersOff ? {
3925
+ const capabilities = onWindows ? CLAUDE_CODE_WINDOWS_CAPABILITIES : stopOff(env) || answersOff ? {
3655
3926
  ...CLAUDE_CODE_CAPABILITIES,
3656
3927
  ...stopOff(env) && { stop: STOP_TURNED_OFF },
3657
3928
  ...answersOff && { answer: ANSWER_TURNED_OFF }
3658
3929
  } : CLAUDE_CODE_CAPABILITIES;
3659
- const claudeHome = path9.resolve(homeOverride ?? path9.join(homeDir, ".claude"));
3660
- const sessionsDir = path9.join(claudeHome, "sessions");
3661
- const homeName = tildify(claudeHome, homeDir);
3662
- const registryName = tildify(sessionsDir, homeDir);
3930
+ const claudeHome = paths.resolve(homeOverride ?? paths.join(homeDir, ".claude"));
3931
+ const sessionsDir = paths.join(claudeHome, "sessions");
3932
+ const homeName = tildify(claudeHome, homeDir, platform);
3933
+ const registryName = tildify(sessionsDir, homeDir, platform);
3663
3934
  const waitingTexts = transcriptsOff ? null : createWaitingTextReader({ claudeHome, io: options.transcriptIo, now });
3664
3935
  let lastDue = null;
3665
3936
  let lastRead = null;
3666
3937
  let feedSoon = false;
3938
+ let lastParsed;
3667
3939
  stops?.onAskFeedSoon(() => {
3668
3940
  feedSoon = true;
3669
3941
  });
@@ -3686,7 +3958,8 @@ function createClaudeCodeAdapter(options = {}) {
3686
3958
  ];
3687
3959
  }
3688
3960
  async function readRegistryNow(at) {
3689
- const registry = await readRegistry(sessionsDir, options.registryIo);
3961
+ const registry = await readRegistry(sessionsDir, options.registryIo, lastParsed);
3962
+ lastParsed = registry.readable ? registry.parsed : void 0;
3690
3963
  if (!registry.readable) return { registry, live: [], unknownStatus: false };
3691
3964
  const candidates = [...registry.entries.values()].filter(
3692
3965
  (entry) => entry.sessionId !== void 0 && isClaudeCodeSessionKind(entry.kind) && isAlive(entry.pid)
@@ -3720,7 +3993,12 @@ function createClaudeCodeAdapter(options = {}) {
3720
3993
  return at - due < intervalMs ? due : at;
3721
3994
  }
3722
3995
  async function runCommand(askedAt) {
3723
- const binary = await findClaudeBinary({ env, homeDir, isExecutable: options.isExecutable });
3996
+ const binary = await findClaudeBinary({
3997
+ env,
3998
+ homeDir,
3999
+ isExecutable: options.isExecutable,
4000
+ platform
4001
+ });
3724
4002
  if (!binary.found) {
3725
4003
  return {
3726
4004
  askedAt,
@@ -4119,8 +4397,10 @@ function stopFailed(reason) {
4119
4397
  function stopRefusalFor(req) {
4120
4398
  return actionRefusalFor(req, STOP_ACTION, MAX_STOP_BODY_BYTES);
4121
4399
  }
4122
- function stillOffered(snapshot, sessionId) {
4123
- return snapshot.sessions.some((session) => session.id === sessionId && session.stop);
4400
+ function notSeenToStop(snapshot, stopped) {
4401
+ return snapshot.sessions.some(
4402
+ (session) => session.id === stopped.id && (session.stop !== void 0 || stopped.stop?.how === "background" && session.status !== "finished" && session.status !== "failed")
4403
+ );
4124
4404
  }
4125
4405
  async function lookAgain(options, stopped) {
4126
4406
  if (stopped.length === 0) return;
@@ -4129,7 +4409,7 @@ async function lookAgain(options, stopped) {
4129
4409
  }
4130
4410
  try {
4131
4411
  const first = await options.poller.pollOnce();
4132
- if (stopped.some((session) => stillOffered(first, session.id))) {
4412
+ if (stopped.some((session) => notSeenToStop(first, session))) {
4133
4413
  await options.poller.pollOnce();
4134
4414
  }
4135
4415
  } catch {
@@ -4148,7 +4428,9 @@ function createStopRoute(options) {
4148
4428
  if (acted === "signalled") {
4149
4429
  const running = await stopper.waitForEnd([target.pid]);
4150
4430
  if (running.size > 0) return stopFailed("still-running");
4151
- } else if (acted !== "stopped") {
4431
+ } else if (acted === "stopped") {
4432
+ await stopper.waitForEnd([target.pid]);
4433
+ } else {
4152
4434
  return stopFailed(acted);
4153
4435
  }
4154
4436
  events.add([stoppedEvent(session, stopper.now())]);
@@ -4266,9 +4548,7 @@ function createCleanUpRoute(options) {
4266
4548
  outcomes[index] = acted;
4267
4549
  }
4268
4550
  }
4269
- const running = await stopper.waitForEnd(
4270
- sent.filter((one) => one.signalled).map((one) => one.target.pid)
4271
- );
4551
+ const running = await stopper.waitForEnd(sent.map((one) => one.target.pid));
4272
4552
  const ended = [];
4273
4553
  for (const one of sent) {
4274
4554
  if (one.signalled && running.has(one.target.pid)) outcomes[one.index] = "still-running";
@@ -4367,6 +4647,15 @@ function answeredEvent(session, decision, at) {
4367
4647
  decision
4368
4648
  };
4369
4649
  }
4650
+ async function pollAfterAnswer(poller, sessionId) {
4651
+ try {
4652
+ const first = await poller.pollOnce();
4653
+ if (first.sessions.some((session) => session.id === sessionId && needsYou(session))) {
4654
+ await poller.pollOnce();
4655
+ }
4656
+ } catch {
4657
+ }
4658
+ }
4370
4659
  function answerRefusalFor(req) {
4371
4660
  return actionRefusalFor(req, ANSWER_ACTION, MAX_ANSWER_BODY_BYTES);
4372
4661
  }
@@ -4396,8 +4685,7 @@ function createAnswerRoute(options) {
4396
4685
  const outcome = await asks.answer(asked.sessionId, asked.requestId, asked.decision);
4397
4686
  if (outcome !== "answered") return answerFailed(outcome);
4398
4687
  events.add([answeredEvent(session, asked.decision, now())]);
4399
- void poller.pollOnce().catch(() => {
4400
- });
4688
+ await pollAfterAnswer(poller, asked.sessionId);
4401
4689
  return {
4402
4690
  status: 200,
4403
4691
  body: { ok: true, decision: asked.decision }
@@ -4407,7 +4695,6 @@ function createAnswerRoute(options) {
4407
4695
 
4408
4696
  // src/collector/answers/answerSettings.ts
4409
4697
  import os3 from "node:os";
4410
- import path10 from "node:path";
4411
4698
  var ANSWER_ENV = "AGENT_LOOKOUT_ANSWER";
4412
4699
  var ANSWER_SOCKET_ENV = "AGENT_LOOKOUT_ANSWER_SOCKET";
4413
4700
  var ANSWER_WAIT_ENV = "AGENT_LOOKOUT_ANSWER_WAIT";
@@ -4415,6 +4702,7 @@ var DEFAULT_HOLD_MS = 3e5;
4415
4702
  var MIN_HOLD_MS = 5e3;
4416
4703
  var MAX_HOLD_MS = 54e4;
4417
4704
  var MAX_SOCKET_PATH_BYTES = 103;
4705
+ var WINDOWS_PROBLEM = "Permission prompts are not answered from Agent Lookout on Windows: the plugin's hook is a POSIX sh script, and it reaches Agent Lookout through a Unix socket.";
4418
4706
  function answerOff(env) {
4419
4707
  return env[ANSWER_ENV]?.trim().toLowerCase() === "off";
4420
4708
  }
@@ -4435,7 +4723,8 @@ function readAnswerSetup(env, homeDir = os3.homedir(), platform = process.platfo
4435
4723
  on: false,
4436
4724
  socketPath: null,
4437
4725
  holdMs: holdMs ?? DEFAULT_HOLD_MS,
4438
- problem: "Answering permission prompts is not offered on Windows."
4726
+ problem: WINDOWS_PROBLEM,
4727
+ quiet: true
4439
4728
  };
4440
4729
  }
4441
4730
  if (holdMs === null) {
@@ -4456,8 +4745,9 @@ function readAnswerSetup(env, homeDir = os3.homedir(), platform = process.platfo
4456
4745
  quiet: true
4457
4746
  };
4458
4747
  }
4459
- const ownDir = path10.resolve(homeDir, ".agent-lookout");
4460
- const socketPath = path10.resolve(named || path10.join(ownDir, "answer.sock"));
4748
+ const paths = pathsOf(platform);
4749
+ const ownDir = paths.resolve(homeDir, ".agent-lookout");
4750
+ const socketPath = paths.resolve(named || paths.join(ownDir, "answer.sock"));
4461
4751
  if (Buffer.byteLength(socketPath) > MAX_SOCKET_PATH_BYTES) {
4462
4752
  return {
4463
4753
  on: false,
@@ -4471,7 +4761,7 @@ function readAnswerSetup(env, homeDir = os3.homedir(), platform = process.platfo
4471
4761
  socketPath,
4472
4762
  holdMs,
4473
4763
  problem: null,
4474
- ownFolder: path10.dirname(socketPath) === ownDir
4764
+ ownFolder: paths.dirname(socketPath) === ownDir
4475
4765
  };
4476
4766
  }
4477
4767
 
@@ -4485,8 +4775,6 @@ var MAX_BLANK_RUN = 2;
4485
4775
  var MAX_INPUTS = 30;
4486
4776
  var MAX_TOOL_NAME_CHARS = 200;
4487
4777
  var MAX_DESCRIPTION_CHARS = 200;
4488
- var EDIT_TOOLS = ["Edit", "Write", "MultiEdit", "NotebookEdit"];
4489
- var NOT_YES_OR_NO_TOOLS = ["ExitPlanMode", "AskUserQuestion"];
4490
4778
  var EDIT_INPUTS = ["file_path", "notebook_path"];
4491
4779
  var HIDDEN = /[\p{Cc}\p{Cf}\p{Co}\p{Cn}\p{Cs}\p{Default_Ignorable_Code_Point}\u2028\u2029]/gu;
4492
4780
  var RIGHT_TO_LEFT = /[\p{Script=Hebrew}\p{Script=Arabic}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}\p{Script=Samaritan}\p{Script=Mandaic}\p{Script=Adlam}\p{Script=Hanifi_Rohingya}\p{Script=Mende_Kikakui}\p{Script=Old_Hungarian}\p{Script=Kharoshthi}\p{Script=Phoenician}\p{Script=Imperial_Aramaic}\p{Script=Avestan}\p{Script=Old_South_Arabian}\p{Script=Old_North_Arabian}\p{Script=Nabataean}\p{Script=Palmyrene}\p{Script=Manichaean}\p{Script=Inscriptional_Pahlavi}\p{Script=Inscriptional_Parthian}\p{Script=Psalter_Pahlavi}\p{Script=Sogdian}\p{Script=Old_Sogdian}\p{Script=Elymaic}\p{Script=Lydian}\p{Script=Cypriot}]/u;
@@ -4642,6 +4930,7 @@ function createHeldAsks(options) {
4642
4930
  const newId = options.newId ?? (() => randomBytes(16).toString("hex"));
4643
4931
  const startedAt = now();
4644
4932
  const held = /* @__PURE__ */ new Map();
4933
+ let answers2 = /* @__PURE__ */ new Map();
4645
4934
  const lastRequest = /* @__PURE__ */ new Map();
4646
4935
  let seenAt = null;
4647
4936
  let missedAt = null;
@@ -4673,6 +4962,8 @@ function createHeldAsks(options) {
4673
4962
  if (!entry.confirmed) {
4674
4963
  entry.confirmed = true;
4675
4964
  entry.wait = reading.wait;
4965
+ entry.since = reading.since ?? null;
4966
+ answerByRule(entry);
4676
4967
  return;
4677
4968
  }
4678
4969
  if (reading.wait !== entry.wait) drop(entry, "left-waiting");
@@ -4692,6 +4983,55 @@ function createHeldAsks(options) {
4692
4983
  if (inFlight === run) inFlight = null;
4693
4984
  }
4694
4985
  }
4986
+ async function answer(sessionId, requestId, decision) {
4987
+ const entry = held.get(sessionId);
4988
+ if (!entry || entry.requestId !== requestId || !entry.confirmed) return "no-ask";
4989
+ if (decision === "allow" && !entry.shown.allow) return "not-allowable";
4990
+ if (entry.answering) return "too-soon";
4991
+ entry.answering = true;
4992
+ const reading = await read(sessionId);
4993
+ if (held.get(sessionId) !== entry || !entry.reply.isOpen()) {
4994
+ if (held.get(sessionId) === entry) drop(entry, "closed");
4995
+ return "gone";
4996
+ }
4997
+ const sameWait = reading.status === "waiting" && reading.wait === entry.wait;
4998
+ if (!sameWait || now() >= entry.until) {
4999
+ drop(entry, sameWait ? "limit" : "left-waiting");
5000
+ return "gone";
5001
+ }
5002
+ held.delete(sessionId);
5003
+ entry.reply.send(decisionOutput(decision));
5004
+ answers2.set(sessionId, { source: SOURCE, since: entry.since, at: now() });
5005
+ if (held.size === 0 && stopTimer !== null) {
5006
+ stopTimer();
5007
+ stopTimer = null;
5008
+ }
5009
+ return "answered";
5010
+ }
5011
+ function answerByRule(entry) {
5012
+ const rules = options.rules;
5013
+ if (rules === void 0) return;
5014
+ const request2 = { sessionId: entry.sessionId, shown: entry.shown };
5015
+ const verdict = verdictOf(entry);
5016
+ if (verdict === null) return;
5017
+ entry.byRule = true;
5018
+ void answer(entry.sessionId, entry.requestId, verdict.decision).then((outcome) => {
5019
+ if (outcome === "answered") rules.answered(request2, verdict);
5020
+ });
5021
+ }
5022
+ function verdictOf(entry) {
5023
+ if (options.rules === void 0) return null;
5024
+ try {
5025
+ return options.rules.verdictFor({ sessionId: entry.sessionId, shown: entry.shown });
5026
+ } catch {
5027
+ return null;
5028
+ }
5029
+ }
5030
+ function answeringByRule(entry) {
5031
+ if (!entry.reply.isOpen()) return false;
5032
+ if (entry.confirmed) return entry.answering && entry.byRule;
5033
+ return verdictOf(entry) !== null;
5034
+ }
4695
5035
  function status() {
4696
5036
  const plugin = seenAt !== null && (missedAt === null || seenAt > missedAt) ? "seen" : missedAt !== null ? "missed" : "unknown";
4697
5037
  return { state: "on", plugin, holdMs };
@@ -4704,6 +5044,7 @@ function createHeldAsks(options) {
4704
5044
  const at = now();
4705
5045
  const older = held.get(request2.sessionId);
4706
5046
  if (older) drop(older, "newer");
5047
+ answers2.delete(request2.sessionId);
4707
5048
  const entry = {
4708
5049
  requestId: newId(),
4709
5050
  sessionId: request2.sessionId,
@@ -4713,7 +5054,9 @@ function createHeldAsks(options) {
4713
5054
  reply,
4714
5055
  confirmed: false,
4715
5056
  wait: null,
4716
- answering: false
5057
+ since: null,
5058
+ answering: false,
5059
+ byRule: false
4717
5060
  };
4718
5061
  if (held.size >= MAX_HELD) {
4719
5062
  drop(entry, "full");
@@ -4729,30 +5072,7 @@ function createHeldAsks(options) {
4729
5072
  void check();
4730
5073
  },
4731
5074
  check,
4732
- async answer(sessionId, requestId, decision) {
4733
- const entry = held.get(sessionId);
4734
- if (!entry || entry.requestId !== requestId || !entry.confirmed) return "no-ask";
4735
- if (decision === "allow" && !entry.shown.allow) return "not-allowable";
4736
- if (entry.answering) return "too-soon";
4737
- entry.answering = true;
4738
- const reading = await read(sessionId);
4739
- if (held.get(sessionId) !== entry || !entry.reply.isOpen()) {
4740
- if (held.get(sessionId) === entry) drop(entry, "closed");
4741
- return "gone";
4742
- }
4743
- const sameWait = reading.status === "waiting" && reading.wait === entry.wait;
4744
- if (!sameWait || now() >= entry.until) {
4745
- drop(entry, sameWait ? "limit" : "left-waiting");
4746
- return "gone";
4747
- }
4748
- held.delete(sessionId);
4749
- entry.reply.send(decisionOutput(decision));
4750
- if (held.size === 0 && stopTimer !== null) {
4751
- stopTimer();
4752
- stopTimer = null;
4753
- }
4754
- return "answered";
4755
- },
5075
+ answer,
4756
5076
  withAsks(snapshot) {
4757
5077
  const answering = status();
4758
5078
  if (held.size === 0) return { ...snapshot, answering };
@@ -4765,6 +5085,17 @@ function createHeldAsks(options) {
4765
5085
  });
4766
5086
  return { ...snapshot, sessions, answering };
4767
5087
  },
5088
+ answeredIn(snapshot) {
5089
+ const { ids, kept } = answeredNow(answers2, snapshot, now());
5090
+ answers2 = kept;
5091
+ for (const entry of held.values()) {
5092
+ if (!answeringByRule(entry)) continue;
5093
+ const session = snapshot.sessions.find((listed) => listed.id === entry.sessionId);
5094
+ const since = entry.confirmed ? entry.since : null;
5095
+ if (session && inAnsweredWait(session, { since })) ids.add(entry.sessionId);
5096
+ }
5097
+ return ids;
5098
+ },
4768
5099
  noteRequest(sessionId) {
4769
5100
  const at = now();
4770
5101
  seenAt = at;
@@ -4801,10 +5132,10 @@ function createHeldAsks(options) {
4801
5132
  }
4802
5133
 
4803
5134
  // src/collector/answers/hookSocket.ts
4804
- import { chmod, lstat as lstat2, mkdir, stat as stat4, unlink } from "node:fs/promises";
5135
+ import { chmod, lstat as lstat3, mkdir, stat as stat4, unlink } from "node:fs/promises";
4805
5136
  import http from "node:http";
4806
5137
  import net from "node:net";
4807
- import path11 from "node:path";
5138
+ import path9 from "node:path";
4808
5139
  var HOOK_PATH = "/hooks/permission-request";
4809
5140
  var HOOK_HEADER = "x-agent-lookout-hook";
4810
5141
  var HOOK_HEADER_VALUE = "permission-request";
@@ -4877,7 +5208,7 @@ function createHookSocket(options) {
4877
5208
  const maxBody = options.maxBodyBytes ?? MAX_HOOK_BODY_BYTES;
4878
5209
  const bodyTimeoutMs = options.bodyTimeoutMs ?? HOOK_BODY_TIMEOUT_MS;
4879
5210
  const uid = options.uid ?? process.getuid?.() ?? -1;
4880
- const folder = path11.dirname(socketPath);
5211
+ const folder = path9.dirname(socketPath);
4881
5212
  let server = null;
4882
5213
  let made = null;
4883
5214
  async function handle(req, res) {
@@ -4911,7 +5242,7 @@ function createHookSocket(options) {
4911
5242
  async function readyFolder() {
4912
5243
  try {
4913
5244
  const madeHere = await mkdir(folder, { recursive: true, mode: 448 });
4914
- const info = await lstat2(folder);
5245
+ const info = await lstat3(folder);
4915
5246
  if (!info.isDirectory()) return `${folder} is not a folder, so answering is off.`;
4916
5247
  if (uid >= 0 && info.uid !== uid) {
4917
5248
  return `${folder} belongs to another user, so answering is off.`;
@@ -4930,7 +5261,7 @@ function createHookSocket(options) {
4930
5261
  async function freePath() {
4931
5262
  let info;
4932
5263
  try {
4933
- info = await lstat2(socketPath);
5264
+ info = await lstat3(socketPath);
4934
5265
  } catch (error) {
4935
5266
  return errorCode2(error) === "ENOENT" ? null : `${socketPath} could not be read.`;
4936
5267
  }
@@ -5000,12 +5331,12 @@ function createHookSocket(options) {
5000
5331
 
5001
5332
  // src/collector/answers/registryStatus.ts
5002
5333
  import os4 from "node:os";
5003
- import path12 from "node:path";
5334
+ import path10 from "node:path";
5004
5335
  function createRegistryStatus(options) {
5005
5336
  const homeDir = options.homeDir ?? os4.homedir();
5006
5337
  const readFile2 = options.readFile ?? readRegularFile;
5007
- const home = options.env[CLAUDE_HOME_ENV]?.trim() || path12.join(homeDir, ".claude");
5008
- const sessionsDir = path12.join(path12.resolve(home), "sessions");
5338
+ const home = options.env[CLAUDE_HOME_ENV]?.trim() || path10.join(homeDir, ".claude");
5339
+ const sessionsDir = path10.join(path10.resolve(home), "sessions");
5009
5340
  return {
5010
5341
  async statusOf(sessionId) {
5011
5342
  const unknown = { status: "unknown" };
@@ -5016,7 +5347,7 @@ function createRegistryStatus(options) {
5016
5347
  const ownId = sessionId.slice("claude-code:".length);
5017
5348
  let content;
5018
5349
  try {
5019
- content = await readFile2(path12.join(sessionsDir, `${pid}.json`));
5350
+ content = await readFile2(path10.join(sessionsDir, `${pid}.json`));
5020
5351
  } catch {
5021
5352
  return unknown;
5022
5353
  }
@@ -5026,12 +5357,618 @@ function createRegistryStatus(options) {
5026
5357
  if (!forPermission) return { status: "not-waiting" };
5027
5358
  return {
5028
5359
  status: "waiting",
5029
- wait: `${entry.statusUpdatedAt ?? ""}|${entry.waitingFor ?? ""}`
5360
+ wait: `${entry.statusUpdatedAt ?? ""}|${entry.waitingFor ?? ""}`,
5361
+ ...entry.statusUpdatedAt !== void 0 && { since: entry.statusUpdatedAt }
5030
5362
  };
5031
5363
  }
5032
5364
  };
5033
5365
  }
5034
5366
 
5367
+ // src/core/permission-rules/commandWords.ts
5368
+ var COMMAND_RUNNERS = [
5369
+ // The shell's own words.
5370
+ ".",
5371
+ "builtin",
5372
+ "command",
5373
+ "coproc",
5374
+ "eval",
5375
+ "exec",
5376
+ "nocorrect",
5377
+ "noglob",
5378
+ "source",
5379
+ "time",
5380
+ // Programs that run the command after their own words.
5381
+ "arch",
5382
+ "bunx",
5383
+ "caffeinate",
5384
+ "chroot",
5385
+ "doas",
5386
+ "env",
5387
+ "flock",
5388
+ "gtimeout",
5389
+ "ionice",
5390
+ "nice",
5391
+ "nix-shell",
5392
+ "nohup",
5393
+ "npx",
5394
+ "parallel",
5395
+ "pnpx",
5396
+ "sandbox-exec",
5397
+ "script",
5398
+ "setsid",
5399
+ "stdbuf",
5400
+ "su",
5401
+ "sudo",
5402
+ "taskset",
5403
+ "timeout",
5404
+ "unbuffer",
5405
+ "uvx",
5406
+ "watch",
5407
+ "xargs",
5408
+ "xcrun",
5409
+ // Shells, which run the command they are given or a file.
5410
+ "ash",
5411
+ "bash",
5412
+ "busybox",
5413
+ "cmd",
5414
+ "csh",
5415
+ "dash",
5416
+ "fish",
5417
+ "ksh",
5418
+ "powershell",
5419
+ "pwsh",
5420
+ "sh",
5421
+ "tcsh",
5422
+ "zsh"
5423
+ ];
5424
+ var NETWORK_CLIENTS_AND_INTERPRETERS = [
5425
+ // Programs that send requests, or open a connection, to an address.
5426
+ "aria2c",
5427
+ "curl",
5428
+ "elinks",
5429
+ "ftp",
5430
+ "grpcurl",
5431
+ "http",
5432
+ "https",
5433
+ "httpx",
5434
+ "links",
5435
+ "lynx",
5436
+ "nc",
5437
+ "ncat",
5438
+ "netcat",
5439
+ "scp",
5440
+ "sftp",
5441
+ "socat",
5442
+ "ssh",
5443
+ "telnet",
5444
+ "w3m",
5445
+ "websocat",
5446
+ "wget",
5447
+ "xh",
5448
+ "xhs",
5449
+ // Interpreters, which run code given in their words or in a file.
5450
+ "awk",
5451
+ "bpython",
5452
+ "bun",
5453
+ "clj",
5454
+ "clojure",
5455
+ "dart",
5456
+ "deno",
5457
+ "elixir",
5458
+ "erl",
5459
+ "escript",
5460
+ "esno",
5461
+ "expect",
5462
+ "gawk",
5463
+ "ghci",
5464
+ "groovy",
5465
+ "guile",
5466
+ "iex",
5467
+ "ipython",
5468
+ "irb",
5469
+ "java",
5470
+ "jshell",
5471
+ "jrunscript",
5472
+ "julia",
5473
+ "kotlin",
5474
+ "lua",
5475
+ "luajit",
5476
+ "mawk",
5477
+ "nawk",
5478
+ "node",
5479
+ "nodejs",
5480
+ "ocaml",
5481
+ "osascript",
5482
+ "perl",
5483
+ "php",
5484
+ "pypy",
5485
+ "python",
5486
+ "r",
5487
+ "racket",
5488
+ "raku",
5489
+ "rakudo",
5490
+ "rscript",
5491
+ "ruby",
5492
+ "runghc",
5493
+ "runhaskell",
5494
+ "sbcl",
5495
+ "scala",
5496
+ "swift",
5497
+ "tclsh",
5498
+ "ts-node",
5499
+ "tsx",
5500
+ "vite-node",
5501
+ "wish",
5502
+ "zx"
5503
+ ];
5504
+ var WINDOWS_PROGRAM_END = /\.(?:exe|com)$/;
5505
+ var LETTER = /[a-z]/;
5506
+ function programOf(word2) {
5507
+ return word2.slice(word2.lastIndexOf("/") + 1).toLowerCase().replace(WINDOWS_PROGRAM_END, "");
5508
+ }
5509
+ function nameIn(program, names) {
5510
+ let found;
5511
+ for (const name of names) {
5512
+ if (program === name) return name;
5513
+ const fits = program.startsWith(name) && !LETTER.test(program.charAt(name.length));
5514
+ if (fits && name.length > (found?.length ?? 0)) found = name;
5515
+ }
5516
+ return found;
5517
+ }
5518
+ function runsAnotherCommand(word2) {
5519
+ return nameIn(programOf(word2), COMMAND_RUNNERS) !== void 0;
5520
+ }
5521
+ function sendsOrRunsCode(word2) {
5522
+ return nameIn(programOf(word2), NETWORK_CLIENTS_AND_INTERPRETERS) !== void 0;
5523
+ }
5524
+ var SUBCOMMANDS_THAT_RUN_CODE = {
5525
+ bundle: ["e", "ex", "exe", "exec"],
5526
+ bundler: ["e", "ex", "exe", "exec"],
5527
+ cargo: ["r", "run"],
5528
+ docker: ["build", "buildx", "compose", "container", "create", "exec", "run", "start"],
5529
+ "docker-compose": ["create", "exec", "run", "start", "up"],
5530
+ dotnet: ["exec", "run", "tool"],
5531
+ go: ["generate", "run", "tool"],
5532
+ nix: ["develop", "run", "shell"],
5533
+ npm: ["create", "exec", "explore", "init", "innit", "x"],
5534
+ pipx: ["run"],
5535
+ pnpm: ["create", "dlx", "exec", "x"],
5536
+ podman: ["build", "compose", "container", "create", "exec", "run", "start"],
5537
+ poetry: ["run"],
5538
+ uv: ["run", "tool"],
5539
+ yarn: ["create", "dlx", "exec", "node"],
5540
+ yarnpkg: ["create", "dlx", "exec", "node"]
5541
+ };
5542
+ var TAKES_A_SHORTENED_SUBCOMMAND = ["npm"];
5543
+ function subcommandToolOf(word2) {
5544
+ return nameIn(programOf(word2), Object.keys(SUBCOMMANDS_THAT_RUN_CODE));
5545
+ }
5546
+ function needsItsSubcommand(words) {
5547
+ const [first, second] = words;
5548
+ if (first === void 0 || subcommandToolOf(first) === void 0) return false;
5549
+ return second === void 0 || second.startsWith("-");
5550
+ }
5551
+ function runsCodeBySubcommand(words) {
5552
+ const [first, ...rest] = words;
5553
+ if (first === void 0) return false;
5554
+ const tool = subcommandToolOf(first);
5555
+ if (tool === void 0) return false;
5556
+ const listed = SUBCOMMANDS_THAT_RUN_CODE[tool] ?? [];
5557
+ const shortened = TAKES_A_SHORTENED_SUBCOMMAND.includes(tool);
5558
+ let pastOptions = false;
5559
+ for (const word2 of rest) {
5560
+ if (word2 === "--") {
5561
+ if (pastOptions) return false;
5562
+ continue;
5563
+ }
5564
+ if (!word2.startsWith("-")) pastOptions = true;
5565
+ const lower = word2.toLowerCase();
5566
+ if (listed.includes(lower)) return true;
5567
+ if (shortened && lower.length >= 2 && listed.some((name) => name.startsWith(lower)))
5568
+ return true;
5569
+ }
5570
+ return false;
5571
+ }
5572
+ var OPTIONS_THAT_RUN_A_PROGRAM = {
5573
+ git: ["c", "config-env", "exec-path", "receive-pack", "upload-pack"],
5574
+ go: ["exec", "toolexec", "vettool"],
5575
+ npm: ["call", "node-options", "script-shell"]
5576
+ };
5577
+ function runsAProgramByOption(words) {
5578
+ const [first, ...rest] = words;
5579
+ if (first === void 0) return false;
5580
+ const program = nameIn(programOf(first), Object.keys(OPTIONS_THAT_RUN_A_PROGRAM));
5581
+ if (program === void 0) return false;
5582
+ const options = OPTIONS_THAT_RUN_A_PROGRAM[program] ?? [];
5583
+ return rest.some((word2) => {
5584
+ if (!word2.startsWith("-")) return false;
5585
+ const [name = ""] = word2.replace(/^-+/, "").split("=");
5586
+ return options.includes(name);
5587
+ });
5588
+ }
5589
+ var OWN_FOLDER_NAME = ".agent-lookout";
5590
+ var BY_NUMBER = "/.vol";
5591
+ function plainPath(word2) {
5592
+ const lower = word2.toLowerCase();
5593
+ if (!lower.startsWith("/")) return lower;
5594
+ const parts = [];
5595
+ for (const part of lower.split("/")) {
5596
+ if (part === "" || part === ".") continue;
5597
+ if (part === "..") parts.pop();
5598
+ else parts.push(part);
5599
+ }
5600
+ return `/${parts.join("/")}`;
5601
+ }
5602
+ function foldersAbove(file) {
5603
+ const folders = [];
5604
+ let at = file;
5605
+ while (at !== "/" && at !== "") {
5606
+ at = at.slice(0, at.lastIndexOf("/")) || "/";
5607
+ folders.push(at);
5608
+ }
5609
+ return folders;
5610
+ }
5611
+ function namesOwnFiles(words, ownPaths = [], folder) {
5612
+ const own = ownPaths.filter((name) => name.startsWith("/")).map(plainPath);
5613
+ const above = new Set(own.flatMap(foldersAbove));
5614
+ const ownNames = own.filter((name) => !name.includes(OWN_FOLDER_NAME)).map((name) => name.slice(name.lastIndexOf("/") + 1));
5615
+ const from = folder?.startsWith("/") ? folder : void 0;
5616
+ return words.some((word2) => {
5617
+ const lower = word2.toLowerCase();
5618
+ if (lower.includes(OWN_FOLDER_NAME)) return true;
5619
+ const parts = [lower, ...lower.split("=").slice(1)];
5620
+ const read = parts.flatMap(
5621
+ (part) => from !== void 0 && !part.startsWith("/") && !part.startsWith("-") ? [part, `${from}/${part}`] : [part]
5622
+ );
5623
+ return read.some((part) => {
5624
+ const path23 = plainPath(part);
5625
+ if (path23 === BY_NUMBER || path23.startsWith(`${BY_NUMBER}/`)) return true;
5626
+ if (own.some((name) => path23 === name || path23.startsWith(`${name}/`))) return true;
5627
+ if (above.has(path23)) return true;
5628
+ return ownNames.some((name) => path23 === name || path23.endsWith(`/${name}`));
5629
+ });
5630
+ });
5631
+ }
5632
+ function folderReachesOwnFiles(folder, ownPaths = []) {
5633
+ const own = ownPaths.filter((name) => name.startsWith("/")).map(plainPath);
5634
+ const path23 = plainPath(folder);
5635
+ return own.some((name) => name === path23 || name.startsWith(`${path23 === "/" ? "" : path23}/`));
5636
+ }
5637
+ var PLAIN_WORD = /^[A-Za-z0-9_\-.,/:=+@%]+$/;
5638
+ var ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
5639
+ function plainCommandWords(command) {
5640
+ if (command === "") return null;
5641
+ const words = command.split(" ");
5642
+ for (const word2 of words) {
5643
+ if (word2 === "" || !PLAIN_WORD.test(word2)) return null;
5644
+ if (word2.startsWith("=")) return null;
5645
+ }
5646
+ if (ASSIGNMENT.test(words[0])) return null;
5647
+ return words;
5648
+ }
5649
+ var SEPARATORS = /[;&|\n\r()`{}<>]/;
5650
+ var DOLLAR_QUOTE = /\$(?=['"])/g;
5651
+ var QUOTING = /['"\\]/g;
5652
+ var CLAUSE_WORDS = ["!", "if", "then", "else", "elif", "do", "while", "until"];
5653
+ function looseCommandWords(command) {
5654
+ const commands = [];
5655
+ for (const part of command.split(SEPARATORS)) {
5656
+ const words = part.split(/\s+/u).map((word2) => word2.replace(DOLLAR_QUOTE, "").replace(QUOTING, "")).filter((word2) => word2 !== "");
5657
+ let first = 0;
5658
+ for (; ; ) {
5659
+ const word2 = words[first];
5660
+ if (word2 === void 0) break;
5661
+ if (!ASSIGNMENT.test(word2) && !CLAUSE_WORDS.includes(word2)) break;
5662
+ first += 1;
5663
+ }
5664
+ if (first < words.length) commands.push(words.slice(first));
5665
+ }
5666
+ return commands;
5667
+ }
5668
+ var PREFIX_MARK = ":*";
5669
+ function ruleCommandOf(command) {
5670
+ const prefix = command.endsWith(PREFIX_MARK);
5671
+ const words = (prefix ? command.slice(0, -PREFIX_MARK.length) : command).split(" ");
5672
+ return { words, prefix };
5673
+ }
5674
+ function wordsMatch(rule, words) {
5675
+ if (rule.prefix ? words.length < rule.words.length : words.length !== rule.words.length) {
5676
+ return false;
5677
+ }
5678
+ return rule.words.every((word2, index) => words[index] === word2);
5679
+ }
5680
+ function inOrder(wanted, words, from, to) {
5681
+ let at = from;
5682
+ for (const word2 of wanted) {
5683
+ while (at < to && words[at] !== word2) at += 1;
5684
+ if (at >= to) return -1;
5685
+ at += 1;
5686
+ }
5687
+ return at;
5688
+ }
5689
+ function looselyFrom(rule, words, start) {
5690
+ const [program, ...rest] = rule.words;
5691
+ if (program === void 0 || programOf(words[start]) !== programOf(program)) {
5692
+ return false;
5693
+ }
5694
+ if (rule.prefix) return inOrder(rest, words, start + 1, words.length) >= 0;
5695
+ const last = rest.at(-1);
5696
+ if (last === void 0) return words.length === start + 1;
5697
+ const end = words.length - 1;
5698
+ return end > start && words[end] === last && inOrder(rest.slice(0, -1), words, start + 1, end) >= 0;
5699
+ }
5700
+ function looselyMatches(rule, words) {
5701
+ const first = words[0];
5702
+ if (first === void 0) return false;
5703
+ const starts = runsAnotherCommand(first) ? words.length : 1;
5704
+ for (let start = 0; start < starts; start += 1) {
5705
+ if (looselyFrom(rule, words, start)) return true;
5706
+ }
5707
+ return false;
5708
+ }
5709
+
5710
+ // src/core/permission-rules/permissionRules.ts
5711
+ var RULE_DECISIONS = ["allow", "ask", "deny"];
5712
+ var EVERY_TOOL = "*";
5713
+ var COMMAND_TOOL = "Bash";
5714
+ var MCP_PREFIX = "mcp__";
5715
+ var MCP_SEPARATOR = "__";
5716
+ function isServerName(tool) {
5717
+ if (!tool.startsWith(MCP_PREFIX)) return false;
5718
+ const server = tool.slice(MCP_PREFIX.length);
5719
+ return server !== "" && !server.includes(MCP_SEPARATOR);
5720
+ }
5721
+ var MAX_RULES = 100;
5722
+ var MAX_RULE_TOOL_CHARS = 128;
5723
+ var MAX_RULE_COMMAND_CHARS = 200;
5724
+ var TOOL_NAME = /^[A-Za-z][A-Za-z0-9_-]*$/;
5725
+ var RULE_ID = /^[A-Za-z0-9-]{1,40}$/;
5726
+ var ASCII_WORDS = /^[\x21-\x7e]+(?: [\x21-\x7e]+)*$/;
5727
+ var NOT_IN_A_RULE = /[;&|<>()`'"\\{}$*]/;
5728
+ function isRecord7(value) {
5729
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5730
+ }
5731
+ function holdsOnly(value, keys, optional = []) {
5732
+ const there = Object.keys(value);
5733
+ return keys.every((key) => there.includes(key)) && there.every((key) => keys.includes(key) || optional.includes(key));
5734
+ }
5735
+ function toolProblem(tool) {
5736
+ if (tool === EVERY_TOOL) return null;
5737
+ if (tool === "") return "Name a tool, such as Bash or WebFetch, or * for every tool.";
5738
+ if (tool.includes("(")) {
5739
+ return "Write the tool's name alone, such as Bash, and the command in its own field.";
5740
+ }
5741
+ if (tool !== COMMAND_TOOL && tool.toLowerCase() === COMMAND_TOOL.toLowerCase()) {
5742
+ return `Claude Code writes this tool's name ${COMMAND_TOOL}.`;
5743
+ }
5744
+ if (tool.length > MAX_RULE_TOOL_CHARS || !TOOL_NAME.test(tool) || tool === MCP_PREFIX || tool.startsWith(MCP_PREFIX) && tool.endsWith(MCP_SEPARATOR)) {
5745
+ return "A tool's name is written as Claude Code writes it: a letter, then letters, digits, _ and -, such as Bash, WebFetch or mcp__docs__search.";
5746
+ }
5747
+ return null;
5748
+ }
5749
+ function commandProblem(command, decision) {
5750
+ if (command.length > MAX_RULE_COMMAND_CHARS) {
5751
+ return `A command in a rule is ${MAX_RULE_COMMAND_CHARS} characters at most.`;
5752
+ }
5753
+ if (!ASCII_WORDS.test(command)) {
5754
+ return "Write the command in plain ASCII letters, digits and marks, with one space between words and none at either end.";
5755
+ }
5756
+ const prefix = command.endsWith(PREFIX_MARK);
5757
+ const words = prefix ? command.slice(0, -PREFIX_MARK.length) : command;
5758
+ if (words === "" || words.endsWith(" ")) {
5759
+ return "Write :* straight after the words a command begins with, as in npm test:*.";
5760
+ }
5761
+ if (command.endsWith(" *")) {
5762
+ return "Write a prefix with :* straight after its words, as in npm test:*.";
5763
+ }
5764
+ if (NOT_IN_A_RULE.test(words)) {
5765
+ return "A rule names one command by its words: no ;, &, |, <, >, ( ), quotes, backslashes, $, { }, or * other than the :* that ends a prefix.";
5766
+ }
5767
+ if (decision === "allow" && plainCommandWords(words) === null) {
5768
+ return "An allow rule names one plain command: no ~, !, ^, #, ?, [ ], no word starting with =, and no variable set before it.";
5769
+ }
5770
+ const [program = ""] = words.split(" ");
5771
+ if (ASSIGNMENT.test(program)) {
5772
+ return "Begin the command with the program's name: the variables set before a command are passed over when it is matched.";
5773
+ }
5774
+ if (decision === "allow" && runsAnotherCommand(program)) {
5775
+ return `${program} runs another command, so an allow rule for it would let Claude Code run anything without asking you. Name the command it would run instead.`;
5776
+ }
5777
+ if (decision === "allow" && sendsOrRunsCode(program)) {
5778
+ return `${program} is, or is named like, a program that can send requests or run code that does, so an allow rule for it would let Claude Code reach Agent Lookout on this computer and add a rule or answer its own prompts without asking you. Answer such commands by hand, or allow a script the project owns, such as ./scripts/test.sh, knowing Claude can edit it.`;
5779
+ }
5780
+ if (decision === "allow" && needsItsSubcommand(words.split(" "))) {
5781
+ return `${program} does something different for each subcommand, and some run any code they are pointed at, so an allow rule for it names the subcommand too, such as ${program} test:*.`;
5782
+ }
5783
+ if (decision === "allow" && runsAProgramByOption(words.split(" "))) {
5784
+ return `${words} names an option through which ${program} runs another program, so an allow rule for it would let Claude Code run anything without asking you.`;
5785
+ }
5786
+ if (decision === "allow" && runsCodeBySubcommand(words.split(" "))) {
5787
+ return `${words} fetches or runs code that Claude Code can choose, so an allow rule for it would let it run anything without asking you. Name the script it should run instead, or answer such commands by hand.`;
5788
+ }
5789
+ if (decision === "allow" && namesOwnFiles(words.split(" "))) {
5790
+ return "An allow rule may not name Agent Lookout's own folder, ~/.agent-lookout, which holds its settings and its rules.";
5791
+ }
5792
+ return null;
5793
+ }
5794
+ function ruleProblem(words) {
5795
+ const { decision, tool, command } = words;
5796
+ const toolWrong = toolProblem(tool);
5797
+ if (toolWrong !== null) return toolWrong;
5798
+ if (command !== void 0) {
5799
+ if (tool !== COMMAND_TOOL)
5800
+ return "A command goes with Bash only. Leave it empty for any other tool.";
5801
+ const commandWrong = commandProblem(command, decision);
5802
+ if (commandWrong !== null) return commandWrong;
5803
+ }
5804
+ if (decision !== "allow") return null;
5805
+ if (tool === EVERY_TOOL) {
5806
+ return "An allow rule names one tool. Allowing every tool would let Claude Code run anything without asking you.";
5807
+ }
5808
+ if (tool === COMMAND_TOOL && command === void 0) {
5809
+ return "An allow rule for Bash names a command. Allowing every command would let Claude Code run anything without asking you.";
5810
+ }
5811
+ if (isServerName(tool)) {
5812
+ return `An allow rule names one tool of a server, such as ${tool}${MCP_SEPARATOR}search. Allowing every tool of ${tool} would let Claude Code use each without asking you.`;
5813
+ }
5814
+ if (EDIT_TOOLS.includes(tool)) {
5815
+ return `Agent Lookout does not show the change ${tool} makes, so it never allows one, by hand or by a rule.`;
5816
+ }
5817
+ if (NOT_YES_OR_NO_TOOLS.includes(tool)) {
5818
+ return `${tool} is answered with more than yes or no, so Agent Lookout never allows it, by hand or by a rule.`;
5819
+ }
5820
+ return null;
5821
+ }
5822
+ function ruleWordsIn(value) {
5823
+ const shape = 'A rule is {"decision": "allow", "ask" or "deny", "tool": "...", "command": "..."}, with the command for Bash only, and nothing else.';
5824
+ if (!isRecord7(value) || !holdsOnly(value, ["decision", "tool"], ["command"])) {
5825
+ return { ok: false, problem: shape };
5826
+ }
5827
+ const { decision, tool, command } = value;
5828
+ if (!RULE_DECISIONS.includes(decision))
5829
+ return { ok: false, problem: shape };
5830
+ if (typeof tool !== "string" || command !== void 0 && typeof command !== "string") {
5831
+ return { ok: false, problem: shape };
5832
+ }
5833
+ const words = {
5834
+ decision,
5835
+ tool,
5836
+ ...command !== void 0 && { command }
5837
+ };
5838
+ const problem = ruleProblem(words);
5839
+ return problem === null ? { ok: true, words } : { ok: false, problem };
5840
+ }
5841
+ function isRuleId(value) {
5842
+ return typeof value === "string" && RULE_ID.test(value);
5843
+ }
5844
+ function readRule2(value) {
5845
+ if (!isRecord7(value)) return null;
5846
+ const { id, ...rest } = value;
5847
+ if (!isRuleId(id)) return null;
5848
+ const read = ruleWordsIn(rest);
5849
+ return read.ok ? { id, ...read.words } : null;
5850
+ }
5851
+ function readPermissionRules(value) {
5852
+ if (value === void 0) return { ok: true, rules: [] };
5853
+ if (!Array.isArray(value) || value.length > MAX_RULES) return { ok: false };
5854
+ const rules = [];
5855
+ const ids = /* @__PURE__ */ new Set();
5856
+ for (const item of value) {
5857
+ const rule = readRule2(item);
5858
+ if (rule === null || ids.has(rule.id)) return { ok: false };
5859
+ ids.add(rule.id);
5860
+ rules.push(rule);
5861
+ }
5862
+ return { ok: true, rules };
5863
+ }
5864
+ function sameWords(a, b) {
5865
+ return a.decision === b.decision && a.tool === b.tool && a.command === b.command;
5866
+ }
5867
+
5868
+ // src/core/permission-rules/ruleDecision.ts
5869
+ var INPUTS_AN_ALLOW_RULE_TAKES = ["timeout", "run_in_background"];
5870
+ var ORDER = ["deny", "ask", "allow"];
5871
+ function toolMatches(rule, request2) {
5872
+ if (rule.tool === EVERY_TOOL || rule.tool === request2.tool) return true;
5873
+ return isServerName(rule.tool) && request2.tool.startsWith(`${rule.tool}${MCP_SEPARATOR}`);
5874
+ }
5875
+ function holdsBack(rule, request2) {
5876
+ if (!toolMatches(rule, request2)) return false;
5877
+ if (rule.command === void 0) return true;
5878
+ if (request2.tool !== COMMAND_TOOL || request2.command === void 0) return false;
5879
+ const wanted = ruleCommandOf(rule.command);
5880
+ return looseCommandWords(request2.command).some((words) => looselyMatches(wanted, words));
5881
+ }
5882
+ function lets(rule, request2) {
5883
+ if (!request2.allowable || rule.tool === EVERY_TOOL || isServerName(rule.tool)) return false;
5884
+ if (rule.tool !== request2.tool) return false;
5885
+ if (request2.tool !== COMMAND_TOOL) return rule.command === void 0;
5886
+ if (rule.command === void 0 || request2.command === void 0) return false;
5887
+ if (!request2.inputNames.every((name) => INPUTS_AN_ALLOW_RULE_TAKES.includes(name))) return false;
5888
+ const wanted = ruleCommandOf(rule.command);
5889
+ const [program = ""] = wanted.words;
5890
+ if (runsAnotherCommand(program) || sendsOrRunsCode(program)) return false;
5891
+ if (needsItsSubcommand(wanted.words)) return false;
5892
+ const words = plainCommandWords(request2.command);
5893
+ if (words === null || runsCodeBySubcommand(words) || runsAProgramByOption(words)) return false;
5894
+ const own = request2.ownPaths ?? [];
5895
+ if (request2.folder !== void 0 && folderReachesOwnFiles(request2.folder, own)) return false;
5896
+ if (namesOwnFiles(words, own, request2.folder)) return false;
5897
+ return wordsMatch(wanted, words);
5898
+ }
5899
+ function decideByRules(rules, request2) {
5900
+ for (const decision of ORDER) {
5901
+ const matches = decision === "allow" ? lets : holdsBack;
5902
+ const rule = rules.find(
5903
+ (candidate) => candidate.decision === decision && matches(candidate, request2)
5904
+ );
5905
+ if (rule !== void 0) return { decision, rule };
5906
+ }
5907
+ return null;
5908
+ }
5909
+
5910
+ // src/collector/answers/ruleAnswers.ts
5911
+ function ruleAnsweredEvent(session, sessionId, tool, verdict, at) {
5912
+ const { rule } = verdict;
5913
+ return {
5914
+ id: `${sessionId}@${at}:answered`,
5915
+ at,
5916
+ sessionId,
5917
+ sessionName: session?.name ?? sessionId,
5918
+ kind: "answered",
5919
+ ...session && { from: session.status },
5920
+ severity: "advisory",
5921
+ by: "agent-lookout",
5922
+ decision: verdict.decision,
5923
+ tool,
5924
+ rule: { tool: rule.tool, ...rule.command !== void 0 && { command: rule.command } }
5925
+ };
5926
+ }
5927
+ function createRuleAnswers(options) {
5928
+ const { poller, events } = options;
5929
+ const now = options.now ?? Date.now;
5930
+ let recent = [];
5931
+ return {
5932
+ verdictFor({ sessionId, shown }) {
5933
+ const rules = options.rules();
5934
+ if (rules.length === 0) return null;
5935
+ const folder = poller.getSnapshot().sessions.find((listed) => listed.id === sessionId)?.cwd;
5936
+ const verdict = decideByRules(rules, {
5937
+ tool: shown.tool,
5938
+ ...shown.command !== void 0 && { command: shown.command },
5939
+ inputNames: shown.command === void 0 ? [] : (shown.inputs ?? []).map((input) => input.name),
5940
+ // The one rule for what may be allowed: what the dashboard would offer Allow for.
5941
+ allowable: shown.allow,
5942
+ ownPaths: options.ownPaths ?? [],
5943
+ ...folder && { folder }
5944
+ });
5945
+ if (verdict === null || verdict.decision === "ask") return null;
5946
+ return { decision: verdict.decision, rule: verdict.rule };
5947
+ },
5948
+ answered({ sessionId, shown }, verdict) {
5949
+ const at = now();
5950
+ const session = poller.getSnapshot().sessions.find((listed) => listed.id === sessionId) ?? null;
5951
+ const event = ruleAnsweredEvent(session, sessionId, shown.tool, verdict, at);
5952
+ events.add([event]);
5953
+ const { id: _id, ...rule } = verdict.rule;
5954
+ recent = [
5955
+ {
5956
+ at,
5957
+ sessionId,
5958
+ sessionName: event.sessionName,
5959
+ tool: shown.tool,
5960
+ decision: verdict.decision,
5961
+ rule
5962
+ },
5963
+ ...recent
5964
+ ].slice(0, MAX_RULE_ANSWERS);
5965
+ void poller.pollOnce().catch(() => {
5966
+ });
5967
+ },
5968
+ recent: () => [...recent]
5969
+ };
5970
+ }
5971
+
5035
5972
  // src/collector/answers/answering.ts
5036
5973
  function createAnswering(options) {
5037
5974
  const { env, poller, events } = options;
@@ -5046,14 +5983,26 @@ function createAnswering(options) {
5046
5983
  },
5047
5984
  serve: (snapshot) => ({ ...snapshot, answering: off }),
5048
5985
  observe: () => {
5049
- }
5986
+ },
5987
+ answeredIn: () => /* @__PURE__ */ new Set(),
5988
+ ruleAnswers: () => []
5050
5989
  };
5051
5990
  }
5991
+ const ruleAnswers = createRuleAnswers({
5992
+ rules: options.rules ?? (() => []),
5993
+ // A command that names the settings file or the socket could change the
5994
+ // rules or answer prompts, so no rule allows it, wherever they are.
5995
+ ownPaths: [options.settingsFile ?? "", setup.socketPath],
5996
+ poller,
5997
+ events,
5998
+ now: options.now
5999
+ });
5052
6000
  const asks = createHeldAsks({
5053
6001
  status: options.status ?? createRegistryStatus({ snapshot: poller.getSnapshot, env }),
5054
6002
  holdMs: setup.holdMs,
5055
6003
  now: options.now,
5056
- every: options.every
6004
+ every: options.every,
6005
+ rules: ruleAnswers
5057
6006
  });
5058
6007
  const socket = createHookSocket({
5059
6008
  socketPath: setup.socketPath,
@@ -5062,6 +6011,7 @@ function createAnswering(options) {
5062
6011
  });
5063
6012
  let problem = null;
5064
6013
  let listening = false;
6014
+ const answerRoute = createAnswerRoute({ poller, events, asks, now: options.now });
5065
6015
  return {
5066
6016
  async start() {
5067
6017
  const started = await socket.start();
@@ -5085,7 +6035,16 @@ function createAnswering(options) {
5085
6035
  observe(snapshot) {
5086
6036
  if (listening) asks.observe(snapshot);
5087
6037
  },
5088
- route: createAnswerRoute({ poller, events, asks, now: options.now })
6038
+ answeredIn: (snapshot) => asks.answeredIn(snapshot),
6039
+ // A socket that could not be opened holds no request, so there is nothing
6040
+ // to answer. Past the route's own checks, a POST is told what it would be
6041
+ // told with answering off, where there is no such route.
6042
+ async route(req) {
6043
+ if (problem === null) return answerRoute(req);
6044
+ req.resume();
6045
+ return answerRefusalFor(req) ?? refusal(405, "This address only answers GET requests.", { Allow: "GET" });
6046
+ },
6047
+ ruleAnswers: () => ruleAnswers.recent()
5089
6048
  };
5090
6049
  }
5091
6050
 
@@ -5221,8 +6180,8 @@ function waitsOf(input) {
5221
6180
  names.set(sessionId, event.sessionName);
5222
6181
  }
5223
6182
  if (began !== null) {
5224
- const open4 = waitingNow.has(sessionId);
5225
- waits.push({ sessionId, from: began, to: open4 || began >= thisRun ? now : thisRun, open: open4 });
6183
+ const open2 = waitingNow.has(sessionId);
6184
+ waits.push({ sessionId, from: began, to: open2 || began >= thisRun ? now : thisRun, open: open2 });
5226
6185
  unended.add(sessionId);
5227
6186
  }
5228
6187
  }
@@ -5314,26 +6273,26 @@ function waitTotals(input) {
5314
6273
 
5315
6274
  // src/collector/adapters/codex/index.ts
5316
6275
  import os5 from "node:os";
5317
- import path14 from "node:path";
6276
+ import path12 from "node:path";
5318
6277
 
5319
6278
  // src/core/mapping/codexMapping.ts
5320
6279
  var TURN_UNDER_WAY = /* @__PURE__ */ new Set(["task_started", "turn_started"]);
5321
6280
  var TURN_OVER = /* @__PURE__ */ new Set(["task_complete", "turn_complete", "turn_aborted"]);
5322
6281
  function mapCodexStatus(fields) {
5323
6282
  const { lastTurn, live: live2 } = fields;
5324
- let open4;
6283
+ let open2;
5325
6284
  if (lastTurn === null) {
5326
- open4 = "idle";
6285
+ open2 = "idle";
5327
6286
  } else if (typeof lastTurn !== "string") {
5328
6287
  return "unknown";
5329
6288
  } else if (TURN_UNDER_WAY.has(lastTurn)) {
5330
- open4 = "working";
6289
+ open2 = "working";
5331
6290
  } else if (TURN_OVER.has(lastTurn)) {
5332
- open4 = "idle";
6291
+ open2 = "idle";
5333
6292
  } else {
5334
6293
  return "unknown";
5335
6294
  }
5336
- return live2 === false ? "finished" : open4;
6295
+ return live2 === false ? "finished" : open2;
5337
6296
  }
5338
6297
  var WRITER_LOCKS_SINCE = [0, 155, 0];
5339
6298
  var VERSION = /^(\d{1,6})\.(\d{1,6})\.(\d{1,6})(-[0-9A-Za-z.-]{1,64})?(?:\+[0-9A-Za-z.-]{1,64})?$/;
@@ -5357,7 +6316,7 @@ var IMPORTED_TURN_ID = /^external-import-turn-\d{1,9}$/;
5357
6316
  function isImportedCodexTurn(turnId) {
5358
6317
  return typeof turnId === "string" && IMPORTED_TURN_ID.test(turnId);
5359
6318
  }
5360
- function isRecord7(value) {
6319
+ function isRecord8(value) {
5361
6320
  return typeof value === "object" && value !== null && !Array.isArray(value);
5362
6321
  }
5363
6322
  var CODEX_DESKTOP_ORIGINATOR = "Codex Desktop";
@@ -5365,7 +6324,7 @@ function mapCodexSurface(source, originator) {
5365
6324
  if (originator === CODEX_DESKTOP_ORIGINATOR) return "desktop";
5366
6325
  if (source === "cli" || source === "exec") return "terminal";
5367
6326
  if (source === "vscode") return "vscode";
5368
- if (isRecord7(source) && source.custom === "chatgpt") return "desktop";
6327
+ if (isRecord8(source) && source.custom === "chatgpt") return "desktop";
5369
6328
  return "unknown";
5370
6329
  }
5371
6330
  var NOT_A_SESSION_SOURCE = /* @__PURE__ */ new Set(["subagent", "internal", "mcp"]);
@@ -5376,7 +6335,7 @@ function isCodexSessionSource(source, meta = {}) {
5376
6335
  return false;
5377
6336
  }
5378
6337
  if (typeof source === "string") return !NOT_A_SESSION_SOURCE.has(source);
5379
- if (isRecord7(source)) return !Object.keys(source).some((kind) => NOT_A_SESSION_SOURCE.has(kind));
6338
+ if (isRecord8(source)) return !Object.keys(source).some((kind) => NOT_A_SESSION_SOURCE.has(kind));
5380
6339
  return true;
5381
6340
  }
5382
6341
 
@@ -5395,7 +6354,7 @@ function parseCodexTime(value) {
5395
6354
  var LINE_TIME = /^\{"timestamp":"([^"]{1,64})"/;
5396
6355
  var TURN_HINT = /"type":"(?:task|turn)_/;
5397
6356
  var TURN_TYPE = /^(?:task|turn)_/;
5398
- function isRecord8(value) {
6357
+ function isRecord9(value) {
5399
6358
  return typeof value === "object" && value !== null && !Array.isArray(value);
5400
6359
  }
5401
6360
  function text3(value) {
@@ -5406,7 +6365,7 @@ function label(value) {
5406
6365
  }
5407
6366
  function compactSource(value) {
5408
6367
  if (typeof value === "string") return text3(value);
5409
- if (!isRecord8(value)) return void 0;
6368
+ if (!isRecord9(value)) return void 0;
5410
6369
  return Object.fromEntries(
5411
6370
  Object.entries(value).map(([kind, detail]) => [
5412
6371
  kind,
@@ -5417,7 +6376,7 @@ function compactSource(value) {
5417
6376
  function parse(line) {
5418
6377
  try {
5419
6378
  const value = JSON.parse(line);
5420
- return isRecord8(value) ? value : null;
6379
+ return isRecord9(value) ? value : null;
5421
6380
  } catch {
5422
6381
  return null;
5423
6382
  }
@@ -5425,7 +6384,7 @@ function parse(line) {
5425
6384
  function readMetaLine(line) {
5426
6385
  if (!line.includes('"session_meta"')) return null;
5427
6386
  const value = parse(line);
5428
- if (!value || value.type !== "session_meta" || !isRecord8(value.payload)) return null;
6387
+ if (!value || value.type !== "session_meta" || !isRecord9(value.payload)) return null;
5429
6388
  const payload = value.payload;
5430
6389
  const meta = {
5431
6390
  id: text3(payload.id),
@@ -5445,7 +6404,7 @@ function readMetaLine(line) {
5445
6404
  function readTurnLine(line) {
5446
6405
  if (!line.includes('"event_msg"') || !TURN_HINT.test(line)) return null;
5447
6406
  const value = parse(line);
5448
- if (!value || value.type !== "event_msg" || !isRecord8(value.payload)) return null;
6407
+ if (!value || value.type !== "event_msg" || !isRecord9(value.payload)) return null;
5449
6408
  const turn = value.payload.type;
5450
6409
  if (typeof turn !== "string" || !TURN_TYPE.test(turn)) return null;
5451
6410
  return {
@@ -5631,7 +6590,7 @@ function createRolloutReader(io) {
5631
6590
  }
5632
6591
 
5633
6592
  // src/collector/adapters/codex/rollouts.ts
5634
- import path13 from "node:path";
6593
+ import path11 from "node:path";
5635
6594
  var UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
5636
6595
  var ROLLOUT_FILE_NAME = new RegExp(
5637
6596
  `^rollout-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-(${UUID})(?:_(${UUID}))?\\.jsonl$`,
@@ -5649,7 +6608,7 @@ function twoDigits(value) {
5649
6608
  function recentDayFolders(sessionsDir, now) {
5650
6609
  const folders = [now, now - 24 * 60 * 60 * 1e3].map((at) => {
5651
6610
  const date = new Date(at);
5652
- return path13.join(
6611
+ return path11.join(
5653
6612
  sessionsDir,
5654
6613
  String(date.getFullYear()),
5655
6614
  twoDigits(date.getMonth() + 1),
@@ -5678,12 +6637,12 @@ function createRolloutFinder(options) {
5678
6637
  const refs = [];
5679
6638
  for (const name of (await names(dir)).sort()) {
5680
6639
  const threadId = threadIdOfRollout(name);
5681
- if (threadId !== null) refs.push({ path: path13.join(dir, name), threadId });
6640
+ if (threadId !== null) refs.push({ path: path11.join(dir, name), threadId });
5682
6641
  }
5683
6642
  return refs;
5684
6643
  }
5685
6644
  async function subfolders(dir, pattern) {
5686
- return (await names(dir)).filter((name) => pattern.test(name)).sort().map((name) => path13.join(dir, name));
6645
+ return (await names(dir)).filter((name) => pattern.test(name)).sort().map((name) => path11.join(dir, name));
5687
6646
  }
5688
6647
  async function buildIndex() {
5689
6648
  const years = await subfolders(sessionsDir, YEAR);
@@ -5727,7 +6686,7 @@ function createRolloutFinder(options) {
5727
6686
  // src/collector/adapters/codex/sessionIndex.ts
5728
6687
  var SESSION_INDEX_FILE = "session_index.jsonl";
5729
6688
  var SESSION_INDEX_LIMIT_BYTES = 4 * 1024 * 1024;
5730
- function isRecord9(value) {
6689
+ function isRecord10(value) {
5731
6690
  return typeof value === "object" && value !== null && !Array.isArray(value);
5732
6691
  }
5733
6692
  function parseSessionIndex(content) {
@@ -5740,7 +6699,7 @@ function parseSessionIndex(content) {
5740
6699
  } catch {
5741
6700
  continue;
5742
6701
  }
5743
- if (!isRecord9(value)) continue;
6702
+ if (!isRecord10(value)) continue;
5744
6703
  const { id, thread_name: name } = value;
5745
6704
  if (typeof id !== "string" || id === "" || typeof name !== "string") continue;
5746
6705
  const trimmed = name.trim();
@@ -5780,7 +6739,7 @@ function createSessionIndexReader(file, io) {
5780
6739
  }
5781
6740
  names = await readNames();
5782
6741
  } catch (error) {
5783
- if (isMissing(error)) {
6742
+ if (isMissing2(error)) {
5784
6743
  seen = null;
5785
6744
  names = /* @__PURE__ */ new Map();
5786
6745
  }
@@ -5844,14 +6803,14 @@ async function readWriterLocks(dir, io) {
5844
6803
  try {
5845
6804
  names = await io.readdir(dir);
5846
6805
  } catch (error) {
5847
- return { supported: false, missing: isMissing(error) };
6806
+ return { supported: false, missing: isMissing2(error) };
5848
6807
  }
5849
- const open4 = /* @__PURE__ */ new Set();
6808
+ const open2 = /* @__PURE__ */ new Set();
5850
6809
  for (const name of names) {
5851
6810
  const threadId = threadIdOfLock(name);
5852
- if (threadId !== null) open4.add(threadId);
6811
+ if (threadId !== null) open2.add(threadId);
5853
6812
  }
5854
- return { supported: true, open: open4 };
6813
+ return { supported: true, open: open2 };
5855
6814
  }
5856
6815
 
5857
6816
  // src/collector/adapters/codex/index.ts
@@ -5889,7 +6848,7 @@ var CODEX_CAPABILITIES = {
5889
6848
  },
5890
6849
  answer: {
5891
6850
  level: "no",
5892
- reason: "Codex records no approval waits, so there is nothing to answer from here."
6851
+ reason: "Codex records no approval waits, so there is nothing to answer from here, by hand or by a permission rule."
5893
6852
  }
5894
6853
  };
5895
6854
  function createCodexAdapter(options = {}) {
@@ -5902,10 +6861,10 @@ function createCodexAdapter(options = {}) {
5902
6861
  const ownOverride = env[CODEX_HOME_ENV]?.trim() || void 0;
5903
6862
  const codexOverride = env[CODEX_OWN_HOME_ENV]?.trim() || void 0;
5904
6863
  const homeFrom = ownOverride ? "agent-lookout" : codexOverride ? "codex" : "default";
5905
- const codexHome = path14.resolve(ownOverride ?? codexOverride ?? path14.join(homeDir, ".codex"));
5906
- const sessionsDir = path14.join(codexHome, "sessions");
5907
- const locksDir = path14.join(codexHome, WRITER_LOCK_DIR);
5908
- const indexFile = path14.join(codexHome, SESSION_INDEX_FILE);
6864
+ const codexHome = path12.resolve(ownOverride ?? codexOverride ?? path12.join(homeDir, ".codex"));
6865
+ const sessionsDir = path12.join(codexHome, "sessions");
6866
+ const locksDir = path12.join(codexHome, WRITER_LOCK_DIR);
6867
+ const indexFile = path12.join(codexHome, SESSION_INDEX_FILE);
5909
6868
  const homeName = tildify(codexHome, homeDir);
5910
6869
  const sessionsName = tildify(sessionsDir, homeDir);
5911
6870
  const locksName = tildify(locksDir, homeDir);
@@ -5953,7 +6912,7 @@ function createCodexAdapter(options = {}) {
5953
6912
  try {
5954
6913
  return (await io.stat(codexHome)).kind === "directory";
5955
6914
  } catch (error) {
5956
- return !isMissing(error);
6915
+ return !isMissing2(error);
5957
6916
  }
5958
6917
  }
5959
6918
  async function poll() {
@@ -5975,7 +6934,7 @@ function createCodexAdapter(options = {}) {
5975
6934
  try {
5976
6935
  await io.readdir(sessionsDir);
5977
6936
  } catch (error) {
5978
- if (!isMissing(error)) {
6937
+ if (!isMissing2(error)) {
5979
6938
  return result(
5980
6939
  "error",
5981
6940
  `Codex sessions could not be read: the folder ${sessionsName} could not be listed.`,
@@ -6023,7 +6982,7 @@ function createCodexAdapter(options = {}) {
6023
6982
  const chosen = /* @__PURE__ */ new Map();
6024
6983
  const subagentWrites = /* @__PURE__ */ new Map();
6025
6984
  for (const ref of [...candidates.values()].sort((a, b) => a.path < b.path ? -1 : 1)) {
6026
- const open4 = locked(ref.threadId);
6985
+ const open2 = locked(ref.threadId);
6027
6986
  let info;
6028
6987
  try {
6029
6988
  info = await io.lstat(ref.path);
@@ -6031,7 +6990,7 @@ function createCodexAdapter(options = {}) {
6031
6990
  tracked.delete(ref.path);
6032
6991
  continue;
6033
6992
  }
6034
- if (info.kind !== "file" || !open4 && info.mtimeMs < cutoff) {
6993
+ if (info.kind !== "file" || !open2 && info.mtimeMs < cutoff) {
6035
6994
  tracked.delete(ref.path);
6036
6995
  continue;
6037
6996
  }
@@ -6054,7 +7013,7 @@ function createCodexAdapter(options = {}) {
6054
7013
  }
6055
7014
  if (state.lastTurnImported) continue;
6056
7015
  const at = lastActivity(state, checkedAt);
6057
- if (!open4 && (at === null || !isWithinRetention(at, checkedAt))) {
7016
+ if (!open2 && (at === null || !isWithinRetention(at, checkedAt))) {
6058
7017
  tracked.delete(ref.path);
6059
7018
  continue;
6060
7019
  }
@@ -6113,7 +7072,7 @@ function createCodexAdapter(options = {}) {
6113
7072
 
6114
7073
  // src/collector/adapters/status-files/index.ts
6115
7074
  import os6 from "node:os";
6116
- import path15 from "node:path";
7075
+ import path13 from "node:path";
6117
7076
 
6118
7077
  // src/core/mapping/statusFileMapping.ts
6119
7078
  var STATUS = /* @__PURE__ */ new Map([
@@ -6248,7 +7207,7 @@ var STATUS_FILE_CAPABILITIES = {
6248
7207
  },
6249
7208
  answer: {
6250
7209
  level: "no",
6251
- reason: "A status file only says a session waits, and holds nothing to answer it through."
7210
+ reason: "A status file only says a session waits, and holds nothing to answer it through, by hand or by a permission rule."
6252
7211
  }
6253
7212
  };
6254
7213
  function counted(count2, one, many) {
@@ -6262,7 +7221,7 @@ function createStatusFileAdapter(options = {}) {
6262
7221
  const isAlive = options.isAlive ?? isProcessAlive;
6263
7222
  const pollIntervalMs = options.pollIntervalMs ?? POLL_INTERVAL_MS;
6264
7223
  const override = env[STATUS_DIR_ENV]?.trim() || void 0;
6265
- const dir = path15.resolve(override ?? path15.join(homeDir, ".agent-lookout", "sessions"));
7224
+ const dir = path13.resolve(override ?? path13.join(homeDir, ".agent-lookout", "sessions"));
6266
7225
  const dirName = tildify(dir, homeDir);
6267
7226
  const decoder2 = new TextDecoder();
6268
7227
  let lastRead = /* @__PURE__ */ new Map();
@@ -6295,26 +7254,26 @@ function createStatusFileAdapter(options = {}) {
6295
7254
  return { health, sessions: [] };
6296
7255
  }
6297
7256
  async function readOne(file) {
6298
- let open4;
7257
+ let open2;
6299
7258
  try {
6300
- open4 = await io.openRegular(file);
7259
+ open2 = await io.openRegular(file);
6301
7260
  } catch (error) {
6302
- if (isMissing(error)) return { kind: "gone" };
7261
+ if (isMissing2(error)) return { kind: "gone" };
6303
7262
  const code = error?.code;
6304
7263
  const notAFile = code === void 0 || code === "ELOOP" || code === "EISDIR";
6305
7264
  return { kind: "refused", why: notAFile ? "is not an ordinary file" : "could not be opened" };
6306
7265
  }
6307
7266
  const tooLarge = { kind: "refused", why: "is over 16 KB" };
6308
7267
  try {
6309
- if (open4.info.size > MAX_FILE_BYTES) return tooLarge;
6310
- const bytes2 = await open4.read(0, MAX_FILE_BYTES + 1);
7268
+ if (open2.info.size > MAX_FILE_BYTES) return tooLarge;
7269
+ const bytes2 = await open2.read(0, MAX_FILE_BYTES + 1);
6311
7270
  if (bytes2.byteLength > MAX_FILE_BYTES) return tooLarge;
6312
7271
  const parsed = parseStatusFile(decoder2.decode(bytes2));
6313
- return parsed ? { kind: "read", file: parsed, mtimeMs: open4.info.mtimeMs } : { kind: "invalid" };
7272
+ return parsed ? { kind: "read", file: parsed, mtimeMs: open2.info.mtimeMs } : { kind: "invalid" };
6314
7273
  } catch {
6315
7274
  return { kind: "refused", why: "could not be read" };
6316
7275
  } finally {
6317
- await open4.close().catch(() => {
7276
+ await open2.close().catch(() => {
6318
7277
  });
6319
7278
  }
6320
7279
  }
@@ -6322,7 +7281,7 @@ function createStatusFileAdapter(options = {}) {
6322
7281
  if (names.length <= MAX_FILES) return names;
6323
7282
  const written = await Promise.all(
6324
7283
  names.map(async (name) => {
6325
- const info = await io.lstat(path15.join(dir, name)).catch(() => null);
7284
+ const info = await io.lstat(path13.join(dir, name)).catch(() => null);
6326
7285
  return { name, mtimeMs: info?.mtimeMs ?? -Infinity };
6327
7286
  })
6328
7287
  );
@@ -6342,7 +7301,7 @@ function createStatusFileAdapter(options = {}) {
6342
7301
  try {
6343
7302
  names = await io.readdir(dir);
6344
7303
  } catch (error) {
6345
- if (!isMissing(error)) {
7304
+ if (!isMissing2(error)) {
6346
7305
  const answer = result(
6347
7306
  "error",
6348
7307
  `Status files could not be read: the folder ${dirName} could not be listed.`,
@@ -6372,7 +7331,7 @@ function createStatusFileAdapter(options = {}) {
6372
7331
  const nextRead = /* @__PURE__ */ new Map();
6373
7332
  const nextStatuses = /* @__PURE__ */ new Map();
6374
7333
  for (const fileName2 of await toRead(listed)) {
6375
- const reading = await readOne(path15.join(dir, fileName2));
7334
+ const reading = await readOne(path13.join(dir, fileName2));
6376
7335
  let kept;
6377
7336
  if (reading.kind === "read") {
6378
7337
  kept = reading;
@@ -6469,6 +7428,7 @@ function transportOptions(settings, timeoutMs = MAIL_TIMEOUT_MS) {
6469
7428
  secure: security === "tls",
6470
7429
  requireTLS: security === "starttls",
6471
7430
  ignoreTLS: security === "plain",
7431
+ tls: { rejectUnauthorized: true },
6472
7432
  ...auth ? { auth: { user: auth.user, pass: auth.pass } } : {},
6473
7433
  name: GREETING_NAME,
6474
7434
  connectionTimeout: timeoutMs,
@@ -6497,7 +7457,7 @@ function failureReason(error) {
6497
7457
  return "the mail server refused the email";
6498
7458
  case "ECONNECTION":
6499
7459
  case "ESOCKET":
6500
- if (errno === "ECONNREFUSED" || errno === -61 || errno === -111) {
7460
+ if (errno === "ECONNREFUSED" || errno === -61 || errno === -111 || errno === -4078) {
6501
7461
  return "nothing answered at the mail server's address";
6502
7462
  }
6503
7463
  return "the connection to the mail server failed";
@@ -6598,7 +7558,7 @@ function createEventStore(capacity = EVENT_CAPACITY) {
6598
7558
 
6599
7559
  // src/collector/git/branchFinder.ts
6600
7560
  import os7 from "node:os";
6601
- import path17 from "node:path";
7561
+ import path15 from "node:path";
6602
7562
 
6603
7563
  // src/collector/git/gitHead.ts
6604
7564
  var MAX_GIT_FILE_BYTES = 4 * 1024;
@@ -6638,18 +7598,18 @@ function parseCommonDir(content) {
6638
7598
 
6639
7599
  // src/collector/git/repository.ts
6640
7600
  import { createHash as createHash2 } from "node:crypto";
6641
- import path16 from "node:path";
7601
+ import path14 from "node:path";
6642
7602
  var REPOSITORY_ID_LENGTH = 16;
6643
7603
  function repositoryId(gitFolder) {
6644
7604
  return createHash2("sha256").update(gitFolder).digest("hex").slice(0, REPOSITORY_ID_LENGTH);
6645
7605
  }
6646
7606
  function sharedName(common) {
6647
- const base = path16.basename(common);
6648
- if (base.startsWith(".")) return path16.basename(path16.dirname(common));
7607
+ const base = path14.basename(common);
7608
+ if (base.startsWith(".")) return path14.basename(path14.dirname(common));
6649
7609
  return base.endsWith(".git") ? base.slice(0, -".git".length) : base;
6650
7610
  }
6651
7611
  function repositoryOf({ dir, gitDir, commonDir }) {
6652
- const name = sessionName(commonDir === null ? path16.basename(dir) : sharedName(commonDir));
7612
+ const name = sessionName(commonDir === null ? path14.basename(dir) : sharedName(commonDir));
6653
7613
  return name === void 0 ? null : { id: repositoryId(commonDir ?? gitDir), name };
6654
7614
  }
6655
7615
 
@@ -6659,32 +7619,32 @@ var MAX_LEVELS = 24;
6659
7619
  var READ_WAIT_MS = 1e3;
6660
7620
  var decoder = new TextDecoder("utf-8", { fatal: true });
6661
7621
  async function readGitFile(io, file, maxBytes = MAX_GIT_FILE_BYTES) {
6662
- let open4;
7622
+ let open2;
6663
7623
  try {
6664
- open4 = await io.openRegular(file);
7624
+ open2 = await io.openRegular(file);
6665
7625
  } catch (error) {
6666
- return isMissing(error) ? void 0 : null;
7626
+ return isMissing2(error) ? void 0 : null;
6667
7627
  }
6668
7628
  try {
6669
- if (open4.info.size > maxBytes) return null;
6670
- const bytes2 = await open4.read(0, maxBytes + 1);
7629
+ if (open2.info.size > maxBytes) return null;
7630
+ const bytes2 = await open2.read(0, maxBytes + 1);
6671
7631
  if (bytes2.byteLength > maxBytes) return null;
6672
7632
  return decoder.decode(bytes2);
6673
7633
  } catch {
6674
7634
  return null;
6675
7635
  } finally {
6676
- await open4.close().catch(() => {
7636
+ await open2.close().catch(() => {
6677
7637
  });
6678
7638
  }
6679
7639
  }
6680
7640
  async function commonDirOf(io, gitDir) {
6681
- const content = await readGitFile(io, path17.join(gitDir, "commondir"));
7641
+ const content = await readGitFile(io, path15.join(gitDir, "commondir"));
6682
7642
  if (content === void 0) return null;
6683
7643
  const target = content === null ? null : parseCommonDir(content);
6684
- return target === null ? void 0 : path17.resolve(gitDir, target);
7644
+ return target === null ? void 0 : path15.resolve(gitDir, target);
6685
7645
  }
6686
7646
  async function headIn(dir, kind, io) {
6687
- const marker = path17.join(dir, ".git");
7647
+ const marker = path15.join(dir, ".git");
6688
7648
  let gitDir;
6689
7649
  if (kind === "directory") {
6690
7650
  gitDir = marker;
@@ -6692,11 +7652,11 @@ async function headIn(dir, kind, io) {
6692
7652
  const content2 = await readGitFile(io, marker);
6693
7653
  const target = typeof content2 === "string" ? parseGitFile(content2) : null;
6694
7654
  if (target === null) return null;
6695
- gitDir = path17.resolve(dir, target);
7655
+ gitDir = path15.resolve(dir, target);
6696
7656
  } else {
6697
7657
  return null;
6698
7658
  }
6699
- const content = await readGitFile(io, path17.join(gitDir, "HEAD"));
7659
+ const content = await readGitFile(io, path15.join(gitDir, "HEAD"));
6700
7660
  const head = typeof content === "string" ? parseHead(content) : null;
6701
7661
  if (head === null) return null;
6702
7662
  const commonDir = kind === "file" ? await commonDirOf(io, gitDir) : null;
@@ -6705,22 +7665,22 @@ async function headIn(dir, kind, io) {
6705
7665
  return { head: { ...head, repository }, gitFolder: commonDir ?? gitDir };
6706
7666
  }
6707
7667
  async function findHead(cwd, { io, homeDir }) {
6708
- if (!path17.isAbsolute(cwd)) return null;
6709
- const home = path17.resolve(homeDir);
6710
- let dir = path17.resolve(cwd);
7668
+ if (!path15.isAbsolute(cwd)) return null;
7669
+ const home = path15.resolve(homeDir);
7670
+ let dir = path15.resolve(cwd);
6711
7671
  try {
6712
7672
  if ((await io.stat(dir)).kind !== "directory") return null;
6713
7673
  } catch {
6714
7674
  return null;
6715
7675
  }
6716
7676
  for (let level = 0; level < MAX_LEVELS; level += 1) {
6717
- const parent = path17.dirname(dir);
7677
+ const parent = path15.dirname(dir);
6718
7678
  if (dir === home || dir === parent) return null;
6719
7679
  let kind;
6720
7680
  try {
6721
- kind = (await io.lstat(path17.join(dir, ".git"))).kind;
7681
+ kind = (await io.lstat(path15.join(dir, ".git"))).kind;
6722
7682
  } catch (error) {
6723
- if (!isMissing(error)) return null;
7683
+ if (!isMissing2(error)) return null;
6724
7684
  dir = parent;
6725
7685
  continue;
6726
7686
  }
@@ -6801,14 +7761,14 @@ var STATUS_STATE = {
6801
7761
  PENDING: "pending",
6802
7762
  EXPECTED: "pending"
6803
7763
  };
6804
- function isRecord10(value) {
7764
+ function isRecord11(value) {
6805
7765
  return typeof value === "object" && value !== null && !Array.isArray(value);
6806
7766
  }
6807
7767
  function textOf(value) {
6808
7768
  return typeof value === "string" ? value : "";
6809
7769
  }
6810
7770
  function checkOf(value) {
6811
- if (!isRecord10(value)) return null;
7771
+ if (!isRecord11(value)) return null;
6812
7772
  const startedAt = Date.parse(textOf(value.startedAt));
6813
7773
  const at = Number.isNaN(startedAt) ? -Infinity : startedAt;
6814
7774
  if (value.__typename === "CheckRun") {
@@ -6857,7 +7817,7 @@ function readGhAnswer(stdout, repository) {
6857
7817
  } catch {
6858
7818
  return null;
6859
7819
  }
6860
- if (!isRecord10(data)) return null;
7820
+ if (!isRecord11(data)) return null;
6861
7821
  const { number } = data;
6862
7822
  const state = stateOf2(data.state, data.isDraft);
6863
7823
  const title = typeof data.title === "string" ? sessionName(data.title) : void 0;
@@ -6881,8 +7841,9 @@ var GH_LOCATIONS = [
6881
7841
  "/usr/bin/gh"
6882
7842
  ];
6883
7843
  var GH_FIELDS = "number,title,state,isDraft,statusCheckRollup";
6884
- async function findGhBinary(env, isExecutable = isExecutableFile, locations = GH_LOCATIONS) {
6885
- const candidates = [.../* @__PURE__ */ new Set([...pathCandidates(env, "gh"), ...locations])];
7844
+ async function findGhBinary(env, isExecutable, locations = GH_LOCATIONS, platform = process.platform) {
7845
+ isExecutable ??= (candidate) => isExecutableFile(candidate, platform);
7846
+ const candidates = [.../* @__PURE__ */ new Set([...pathCandidates(env, "gh", platform), ...locations])];
6886
7847
  for (const candidate of candidates) {
6887
7848
  if (await isExecutable(candidate)) return candidate;
6888
7849
  }
@@ -6995,7 +7956,7 @@ function createGhAsker(options) {
6995
7956
  }
6996
7957
 
6997
7958
  // src/collector/git/pullRequestTarget.ts
6998
- import path18 from "node:path";
7959
+ import path16 from "node:path";
6999
7960
 
7000
7961
  // src/collector/git/gitConfig.ts
7001
7962
  var MAX_CONFIG_BYTES = 64 * 1024;
@@ -7253,7 +8214,7 @@ function targetOf(config, branch, base, defaultBranch) {
7253
8214
  async function readPullRequestTarget(gitFolder, branch, io = nodeIo2) {
7254
8215
  try {
7255
8216
  if (!isAskableBranch(branch)) return null;
7256
- const text4 = await readGitFile(io, path18.join(gitFolder, "config"), MAX_CONFIG_BYTES);
8217
+ const text4 = await readGitFile(io, path16.join(gitFolder, "config"), MAX_CONFIG_BYTES);
7257
8218
  if (typeof text4 !== "string") return null;
7258
8219
  const config = parseGitConfig(text4);
7259
8220
  const base = config === null ? null : baseRemoteOf(config);
@@ -7262,7 +8223,7 @@ async function readPullRequestTarget(gitFolder, branch, io = nodeIo2) {
7262
8223
  if (base.name !== null && isSafeRemoteName(base.name)) {
7263
8224
  const head = await readGitFile(
7264
8225
  io,
7265
- path18.join(gitFolder, "refs", "remotes", base.name, "HEAD")
8226
+ path16.join(gitFolder, "refs", "remotes", base.name, "HEAD")
7266
8227
  );
7267
8228
  if (typeof head === "string") defaultBranch = parseRemoteHead(head, base.name);
7268
8229
  }
@@ -7433,40 +8394,38 @@ function createClearHistoryRoute(options) {
7433
8394
  }
7434
8395
 
7435
8396
  // src/collector/history/historyKeeper.ts
7436
- import path19 from "node:path";
8397
+ import path17 from "node:path";
7437
8398
 
7438
8399
  // src/collector/history/historyFiles.ts
7439
8400
  import {
7440
- closeSync,
7441
- constants as constants4,
7442
- fstatSync,
7443
- lstatSync,
7444
- openSync,
8401
+ closeSync as closeSync2,
8402
+ constants as constants5,
8403
+ fstatSync as fstatSync2,
8404
+ lstatSync as lstatSync2,
7445
8405
  readSync,
7446
8406
  unlinkSync,
7447
8407
  writeSync
7448
8408
  } from "node:fs";
7449
- import { lstat as lstat3, lutimes, mkdir as mkdir2, open as open3, readdir as readdir3, unlink as unlink2 } from "node:fs/promises";
8409
+ import { lstat as lstat4, lutimes, mkdir as mkdir2, readdir as readdir3, unlink as unlink2 } from "node:fs/promises";
7450
8410
  function infoOf2(info) {
7451
8411
  return { kind: info.isFile() ? "file" : "other", size: info.size, mtimeMs: info.mtimeMs };
7452
8412
  }
7453
8413
  var FILE_MODE = 384;
7454
8414
  var FOLDER_MODE = 448;
7455
- var NO_FOLLOW = (constants4.O_NOFOLLOW ?? 0) | (constants4.O_NONBLOCK ?? 0);
7456
- var READ = constants4.O_RDONLY | NO_FOLLOW;
7457
- var APPEND = constants4.O_WRONLY | constants4.O_APPEND | constants4.O_CREAT | NO_FOLLOW;
7458
- var CREATE = constants4.O_WRONLY | constants4.O_CREAT | constants4.O_EXCL | NO_FOLLOW;
7459
- function notOrdinary() {
8415
+ var READ = constants5.O_RDONLY;
8416
+ var APPEND = constants5.O_WRONLY | constants5.O_APPEND | constants5.O_CREAT;
8417
+ var CREATE = constants5.O_WRONLY | constants5.O_CREAT | constants5.O_EXCL;
8418
+ function notOrdinary2() {
7460
8419
  return new Error("Not an ordinary file.");
7461
8420
  }
7462
- function isMissing2(error) {
8421
+ function isMissing3(error) {
7463
8422
  return error?.code === "ENOENT";
7464
8423
  }
7465
8424
  async function readRegular(file, maxBytes) {
7466
- const handle = await open3(file, READ);
8425
+ const handle = await openWithoutFollowing(file, READ);
7467
8426
  try {
7468
8427
  const info = await handle.stat();
7469
- if (!info.isFile()) throw notOrdinary();
8428
+ if (!info.isFile()) throw notOrdinary2();
7470
8429
  if (info.size > maxBytes) throw new Error("Too large to be a history file.");
7471
8430
  const buffer = Buffer.alloc(info.size);
7472
8431
  let filled = 0;
@@ -7481,10 +8440,10 @@ async function readRegular(file, maxBytes) {
7481
8440
  }
7482
8441
  }
7483
8442
  function readRegularNow(file, maxBytes) {
7484
- const fd = openSync(file, READ);
8443
+ const fd = openWithoutFollowingNow(file, READ);
7485
8444
  try {
7486
- const info = fstatSync(fd);
7487
- if (!info.isFile()) throw notOrdinary();
8445
+ const info = fstatSync2(fd);
8446
+ if (!info.isFile()) throw notOrdinary2();
7488
8447
  if (info.size > maxBytes) throw new Error("Too large to be a history file.");
7489
8448
  const buffer = Buffer.alloc(info.size);
7490
8449
  let filled = 0;
@@ -7495,22 +8454,22 @@ function readRegularNow(file, maxBytes) {
7495
8454
  }
7496
8455
  return buffer.toString("utf8", 0, filled);
7497
8456
  } finally {
7498
- closeSync(fd);
8457
+ closeSync2(fd);
7499
8458
  }
7500
8459
  }
7501
8460
  function writeNow(file, flags, text4) {
7502
- const fd = openSync(file, flags, FILE_MODE);
8461
+ const fd = openWithoutFollowingNow(file, flags, FILE_MODE);
7503
8462
  try {
7504
- if (!fstatSync(fd).isFile()) throw notOrdinary();
8463
+ if (!fstatSync2(fd).isFile()) throw notOrdinary2();
7505
8464
  writeSync(fd, text4);
7506
8465
  } finally {
7507
- closeSync(fd);
8466
+ closeSync2(fd);
7508
8467
  }
7509
8468
  }
7510
8469
  async function writeTo(file, flags, text4) {
7511
- const handle = await open3(file, flags, FILE_MODE);
8470
+ const handle = await openWithoutFollowing(file, flags, FILE_MODE);
7512
8471
  try {
7513
- if (!(await handle.stat()).isFile()) throw notOrdinary();
8472
+ if (!(await handle.stat()).isFile()) throw notOrdinary2();
7514
8473
  await handle.writeFile(text4, "utf8");
7515
8474
  } finally {
7516
8475
  await handle.close();
@@ -7521,8 +8480,8 @@ var nodeHistoryFs = {
7521
8480
  await mkdir2(dir, { recursive: true, mode: FOLDER_MODE });
7522
8481
  },
7523
8482
  list: (dir) => readdir3(dir),
7524
- lstat: async (file) => infoOf2(await lstat3(file)),
7525
- lstatNow: (file) => infoOf2(lstatSync(file)),
8483
+ lstat: async (file) => infoOf2(await lstat4(file)),
8484
+ lstatNow: (file) => infoOf2(lstatSync2(file)),
7526
8485
  readRegular,
7527
8486
  readRegularNow,
7528
8487
  append: (file, text4) => writeTo(file, APPEND, text4),
@@ -7537,14 +8496,14 @@ var nodeHistoryFs = {
7537
8496
  try {
7538
8497
  await unlink2(file);
7539
8498
  } catch (error) {
7540
- if (!isMissing2(error)) throw error;
8499
+ if (!isMissing3(error)) throw error;
7541
8500
  }
7542
8501
  },
7543
8502
  removeNow(file) {
7544
8503
  try {
7545
8504
  unlinkSync(file);
7546
8505
  } catch (error) {
7547
- if (!isMissing2(error)) throw error;
8506
+ if (!isMissing3(error)) throw error;
7548
8507
  }
7549
8508
  }
7550
8509
  };
@@ -7735,7 +8694,7 @@ function createHistoryKeeper(options) {
7735
8694
  });
7736
8695
  const lock = createWriterLock({
7737
8696
  fs,
7738
- file: path19.join(dir, LOCK_FILE),
8697
+ file: path17.join(dir, LOCK_FILE),
7739
8698
  pid: options.pid,
7740
8699
  now,
7741
8700
  isAlive: options.isAlive,
@@ -7761,7 +8720,7 @@ function createHistoryKeeper(options) {
7761
8720
  });
7762
8721
  return next;
7763
8722
  }
7764
- const full = (name) => path19.join(dir, name);
8723
+ const full = (name) => path17.join(dir, name);
7765
8724
  function hold(record) {
7766
8725
  if (unusable !== null || !running) return;
7767
8726
  const line = encodeRecord(record);
@@ -8223,13 +9182,13 @@ function keptHistoryStore(store, keeper) {
8223
9182
  // src/collector/history/historySettings.ts
8224
9183
  import { createHash as createHash3 } from "node:crypto";
8225
9184
  import os8 from "node:os";
8226
- import path20 from "node:path";
9185
+ import path18 from "node:path";
8227
9186
  var HISTORY_ENV = "AGENT_LOOKOUT_HISTORY";
8228
9187
  var HISTORY_DIR_ENV = "AGENT_LOOKOUT_HISTORY_DIR";
8229
9188
  function readHistorySetup(env, homeDir = os8.homedir()) {
8230
9189
  if (env[HISTORY_ENV]?.trim().toLowerCase() === "off") return { on: false };
8231
9190
  const override = env[HISTORY_DIR_ENV]?.trim() || void 0;
8232
- const dir = path20.resolve(override ?? path20.join(homeDir, ".agent-lookout", "history"));
9191
+ const dir = path18.resolve(override ?? path18.join(homeDir, ".agent-lookout", "history"));
8233
9192
  return {
8234
9193
  on: true,
8235
9194
  dir,
@@ -8240,9 +9199,9 @@ function readHistorySetup(env, homeDir = os8.homedir()) {
8240
9199
  function sourcesFingerprint(env, homeDir = os8.homedir()) {
8241
9200
  const set = (name) => env[name]?.trim() || void 0;
8242
9201
  const folders = [
8243
- path20.resolve(set(CLAUDE_HOME_ENV) ?? path20.join(homeDir, ".claude")),
8244
- path20.resolve(set(CODEX_HOME_ENV) ?? set(CODEX_OWN_HOME_ENV) ?? path20.join(homeDir, ".codex")),
8245
- path20.resolve(set(STATUS_DIR_ENV) ?? path20.join(homeDir, ".agent-lookout", "sessions"))
9202
+ path18.resolve(set(CLAUDE_HOME_ENV) ?? path18.join(homeDir, ".claude")),
9203
+ path18.resolve(set(CODEX_HOME_ENV) ?? set(CODEX_OWN_HOME_ENV) ?? path18.join(homeDir, ".codex")),
9204
+ path18.resolve(set(STATUS_DIR_ENV) ?? path18.join(homeDir, ".agent-lookout", "sessions"))
8246
9205
  ];
8247
9206
  return createHash3("sha256").update(JSON.stringify(folders)).digest("hex").slice(0, 32);
8248
9207
  }
@@ -8315,7 +9274,7 @@ function createServerNotifications(options) {
8315
9274
  const at = now();
8316
9275
  const rules = rulesOf(snapshot);
8317
9276
  const quietNow = quietOf(snapshot);
8318
- const thresholdMs = rules.longWait.minutes * 6e4;
9277
+ const schedule = reminderSchedule(rules.longWait);
8319
9278
  const result = sessionChanges(memory, snapshot);
8320
9279
  memory = result.memory;
8321
9280
  for (const id of result.stopped) {
@@ -8341,7 +9300,7 @@ function createServerNotifications(options) {
8341
9300
  change: { event: "needs-you", session },
8342
9301
  since: at
8343
9302
  });
8344
- if (rules.longWait.on) reminders.toldLate(session.id, at, thresholdMs);
9303
+ if (rules.longWait.on) reminders.told(session.id, at);
8345
9304
  }
8346
9305
  }
8347
9306
  if (quietNow) quiet.begin(at);
@@ -8351,9 +9310,13 @@ function createServerNotifications(options) {
8351
9310
  else
8352
9311
  held.set(heldKey(change.event, change.session.id), { kind: "change", change, since: at });
8353
9312
  }
8354
- if (!quietNow && rules.longWait.on && wants("needs-you")) {
8355
- for (const reminder of reminders.due(snapshot, at, thresholdMs)) {
8356
- reminders.reminded(reminder.session.id, thresholdMs);
9313
+ if (rules.longWait.on && wants("needs-you")) {
9314
+ for (const reminder of reminders.due(snapshot, at, schedule)) {
9315
+ if (quietNow) {
9316
+ quiet.holdReminder(reminder.session, reminder.begunAt);
9317
+ continue;
9318
+ }
9319
+ reminders.told(reminder.session.id, at);
8357
9320
  held.set(reminderKey(reminder.session.id), { kind: "reminder", reminder, since: at });
8358
9321
  }
8359
9322
  }
@@ -8362,7 +9325,7 @@ function createServerNotifications(options) {
8362
9325
  if (entry.kind === "summary") continue;
8363
9326
  const session = entry.kind === "change" ? entry.change.session : entry.reminder.session;
8364
9327
  const current = listed.get(session.id);
8365
- if (current?.status !== "needs-you") continue;
9328
+ if (current === void 0 || !needsYou(current)) continue;
8366
9329
  if (entry.kind === "reminder") entry.reminder = { ...entry.reminder, session: current };
8367
9330
  else if (entry.change.event === "needs-you")
8368
9331
  entry.change = { ...entry.change, session: current };
@@ -8532,7 +9495,7 @@ var MAX_REMOTE_SOURCES = 20;
8532
9495
  var MAX_ID_LENGTH = 512;
8533
9496
  var MAX_PATH_LENGTH = 4096;
8534
9497
  var MAX_WORD_LENGTH2 = 200;
8535
- function isRecord11(value) {
9498
+ function isRecord12(value) {
8536
9499
  return typeof value === "object" && value !== null && !Array.isArray(value);
8537
9500
  }
8538
9501
  function oneOf2(allowed, value) {
@@ -8546,7 +9509,7 @@ function exact(value, max) {
8546
9509
  var COMMIT_ID2 = /^[0-9a-f]{7,64}$/;
8547
9510
  var REPOSITORY_ID = /^[0-9a-f]{16,64}$/;
8548
9511
  function readGit(value, machine) {
8549
- if (!isRecord11(value)) return void 0;
9512
+ if (!isRecord12(value)) return void 0;
8550
9513
  let head;
8551
9514
  if (value.commit !== void 0) {
8552
9515
  if (value.branch !== void 0 || typeof value.commit !== "string") return void 0;
@@ -8558,7 +9521,7 @@ function readGit(value, machine) {
8558
9521
  head = { branch };
8559
9522
  }
8560
9523
  const repository = value.repository;
8561
- if (isRecord11(repository) && typeof repository.id === "string") {
9524
+ if (isRecord12(repository) && typeof repository.id === "string") {
8562
9525
  const name = sessionName(typeof repository.name === "string" ? repository.name : "");
8563
9526
  if (name !== void 0 && REPOSITORY_ID.test(repository.id)) {
8564
9527
  head.repository = { id: repositoryId(`${machine}
@@ -8568,7 +9531,7 @@ ${repository.id}`), name };
8568
9531
  return head;
8569
9532
  }
8570
9533
  function readSession(value, machine, sources, now, keepWaitingText) {
8571
- if (!isRecord11(value)) return null;
9534
+ if (!isRecord12(value)) return null;
8572
9535
  const id = exact(value.id, MAX_ID_LENGTH);
8573
9536
  const source = exact(value.source, MAX_WORD_LENGTH2);
8574
9537
  if (id === null || source === null) return null;
@@ -8598,6 +9561,7 @@ function readSession(value, machine, sources, now, keepWaitingText) {
8598
9561
  if (detail !== void 0) session.waitingDetail = detail;
8599
9562
  const asking = keepWaitingText ? waitingText(value.waitingText) : void 0;
8600
9563
  if (asking !== void 0) session.waitingText = asking;
9564
+ if (value.answered === true) session.answered = true;
8601
9565
  }
8602
9566
  const lastWriteAt = plausibleTime(value.lastWriteAt, now);
8603
9567
  if (lastWriteAt !== null) session.lastWriteAt = lastWriteAt;
@@ -8607,7 +9571,7 @@ function readSession(value, machine, sources, now, keepWaitingText) {
8607
9571
  return session;
8608
9572
  }
8609
9573
  function readCell(value) {
8610
- if (!isRecord11(value)) return null;
9574
+ if (!isRecord12(value)) return null;
8611
9575
  if (value.level === "yes") return { level: "yes" };
8612
9576
  const level = oneOf2(["no", "partly"], value.level);
8613
9577
  const reason = cut(value.reason, MAX_WORD_LENGTH2);
@@ -8615,7 +9579,7 @@ function readCell(value) {
8615
9579
  }
8616
9580
  var ACTS_HERE = ["jump", "stop", "answer"];
8617
9581
  function readCapabilities(value, machine) {
8618
- if (!isRecord11(value)) return null;
9582
+ if (!isRecord12(value)) return null;
8619
9583
  const read = {};
8620
9584
  for (const capability of CAPABILITIES) {
8621
9585
  if (ACTS_HERE.includes(capability)) continue;
@@ -8631,7 +9595,7 @@ function readCapabilities(value, machine) {
8631
9595
  };
8632
9596
  }
8633
9597
  function readRemoteSnapshot(value, machine, now, options = {}) {
8634
- if (!isRecord11(value) || !Array.isArray(value.sessions) || !Array.isArray(value.sources)) {
9598
+ if (!isRecord12(value) || !Array.isArray(value.sessions) || !Array.isArray(value.sources)) {
8635
9599
  return null;
8636
9600
  }
8637
9601
  const named = /* @__PURE__ */ new Map();
@@ -8639,7 +9603,7 @@ function readRemoteSnapshot(value, machine, now, options = {}) {
8639
9603
  const agents = [];
8640
9604
  for (const item of value.sources) {
8641
9605
  if (sources.length >= MAX_REMOTE_SOURCES) break;
8642
- if (!isRecord11(item)) continue;
9606
+ if (!isRecord12(item)) continue;
8643
9607
  const id = exact(item.id, MAX_WORD_LENGTH2);
8644
9608
  if (id === null || isRemoteSource(id) || item.machine !== void 0) continue;
8645
9609
  const label2 = cut(item.label, MAX_WORD_LENGTH2) ?? id;
@@ -8664,14 +9628,15 @@ function readRemoteSnapshot(value, machine, now, options = {}) {
8664
9628
  }
8665
9629
 
8666
9630
  // src/collector/remotes/sshProgram.ts
8667
- import path21 from "node:path";
9631
+ import path19 from "node:path";
8668
9632
  var SSH_BIN_ENV = "AGENT_LOOKOUT_SSH_BIN";
8669
9633
  var SSH_LOCATIONS = ["/usr/bin/ssh", "/opt/homebrew/bin/ssh", "/usr/local/bin/ssh"];
8670
9634
  var SERVER_ALIVE_SECONDS = 15;
8671
- async function findSsh(env, isExecutable = isExecutableFile) {
9635
+ async function findSsh(env, isExecutable, platform = process.platform) {
9636
+ isExecutable ??= (candidate) => isExecutableFile(candidate, platform);
8672
9637
  const named = env[SSH_BIN_ENV]?.trim();
8673
9638
  if (named) {
8674
- const resolved = path21.resolve(named);
9639
+ const resolved = pathsOf(platform).resolve(named);
8675
9640
  if (await isExecutable(resolved)) return { found: true, path: resolved };
8676
9641
  return {
8677
9642
  found: false,
@@ -8679,13 +9644,13 @@ async function findSsh(env, isExecutable = isExecutableFile) {
8679
9644
  named: true
8680
9645
  };
8681
9646
  }
8682
- const candidates = [.../* @__PURE__ */ new Set([...pathCandidates(env, "ssh"), ...SSH_LOCATIONS])];
9647
+ const candidates = [.../* @__PURE__ */ new Set([...pathCandidates(env, "ssh", platform), ...SSH_LOCATIONS])];
8683
9648
  for (const candidate of candidates) {
8684
9649
  if (await isExecutable(candidate)) return { found: true, path: candidate };
8685
9650
  }
8686
9651
  return {
8687
9652
  found: false,
8688
- looked: `The ssh command was not found on PATH or in ${SSH_LOCATIONS.map((at) => path21.dirname(at)).join(", ")}`
9653
+ looked: `The ssh command was not found on PATH or in ${SSH_LOCATIONS.map((at) => path19.dirname(at)).join(", ")}`
8689
9654
  };
8690
9655
  }
8691
9656
  function sshArguments(forward) {
@@ -9268,11 +10233,11 @@ function createRemotes(options) {
9268
10233
  // src/collector/settings/settingsFile.ts
9269
10234
  import { randomBytes as randomBytes3 } from "node:crypto";
9270
10235
  import {
9271
- closeSync as closeSync2,
9272
- constants as constants5,
9273
- fstatSync as fstatSync2,
10236
+ closeSync as closeSync3,
10237
+ constants as constants6,
10238
+ fstatSync as fstatSync3,
9274
10239
  fsyncSync,
9275
- lstatSync as lstatSync2,
10240
+ lstatSync as lstatSync3,
9276
10241
  mkdirSync,
9277
10242
  openSync as openSync2,
9278
10243
  readSync as readSync2,
@@ -9281,23 +10246,22 @@ import {
9281
10246
  writeSync as writeSync2
9282
10247
  } from "node:fs";
9283
10248
  import os9 from "node:os";
9284
- import path22 from "node:path";
10249
+ import path20 from "node:path";
9285
10250
  var SETTINGS_FILE_ENV = "AGENT_LOOKOUT_SETTINGS_FILE";
9286
10251
  var SETTINGS_FILE_MODE = 384;
9287
10252
  var SETTINGS_FOLDER_MODE = 448;
9288
10253
  var MAX_SETTINGS_BYTES = 64 * 1024;
9289
10254
  function readSettingsSetup(env, homeDir = os9.homedir()) {
9290
10255
  const named = env[SETTINGS_FILE_ENV]?.trim() || void 0;
9291
- const file = path22.resolve(named ?? path22.join(homeDir, ".agent-lookout", "settings.json"));
10256
+ const file = path20.resolve(named ?? path20.join(homeDir, ".agent-lookout", "settings.json"));
9292
10257
  return { file, shown: tildify(file, homeDir) };
9293
10258
  }
9294
- var NO_FOLLOW2 = (constants5.O_NOFOLLOW ?? 0) | (constants5.O_NONBLOCK ?? 0);
9295
10259
  function codeOf(error) {
9296
10260
  return error?.code;
9297
10261
  }
9298
10262
  function kindAt(target) {
9299
10263
  try {
9300
- const info = lstatSync2(target);
10264
+ const info = lstatSync3(target);
9301
10265
  if (info.isSymbolicLink()) return "link";
9302
10266
  if (info.isFile()) return "file";
9303
10267
  return info.isDirectory() ? "folder" : "other";
@@ -9309,54 +10273,69 @@ function kindAt(target) {
9309
10273
  function syncFolder(folder) {
9310
10274
  let fd = null;
9311
10275
  try {
9312
- fd = openSync2(folder, constants5.O_RDONLY);
10276
+ fd = openSync2(folder, constants6.O_RDONLY);
9313
10277
  fsyncSync(fd);
9314
10278
  } catch {
9315
10279
  } finally {
9316
- if (fd !== null) closeSync2(fd);
10280
+ if (fd !== null) closeSync3(fd);
10281
+ }
10282
+ }
10283
+ var linkRefused = (shown) => ({
10284
+ kind: "refused",
10285
+ problem: `${shown} is a link, which Agent Lookout does not follow, so the time rules and the permission rules are off.`
10286
+ });
10287
+ var notOrdinary3 = (shown) => ({
10288
+ kind: "refused",
10289
+ problem: `${shown} is not an ordinary file, so the time rules and the permission rules are off.`
10290
+ });
10291
+ function readSettingsStamp(setup) {
10292
+ try {
10293
+ const info = lstatSync3(setup.file, { bigint: true });
10294
+ return `${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`;
10295
+ } catch {
10296
+ return null;
9317
10297
  }
9318
10298
  }
9319
10299
  function readSettingsText(setup) {
9320
10300
  const { file, shown } = setup;
9321
- const folder = path22.dirname(file);
10301
+ const folder = path20.dirname(file);
9322
10302
  try {
9323
10303
  const folderKind = kindAt(folder);
9324
10304
  if (folderKind === null) return { kind: "missing" };
9325
10305
  if (folderKind !== "folder") {
9326
10306
  return {
9327
10307
  kind: "refused",
9328
- problem: `The folder of ${shown} is a link or not a folder, which Agent Lookout does not follow, so the time rules are off.`
10308
+ problem: `The folder of ${shown} is a link or not a folder, which Agent Lookout does not follow, so the time rules and the permission rules are off.`
9329
10309
  };
9330
10310
  }
10311
+ const fileKind = kindAt(file);
10312
+ if (fileKind === "link") return linkRefused(shown);
10313
+ if (fileKind === "folder" || fileKind === "other") return notOrdinary3(shown);
9331
10314
  } catch {
9332
- return { kind: "refused", problem: `${shown} could not be read, so the time rules are off.` };
10315
+ return {
10316
+ kind: "refused",
10317
+ problem: `${shown} could not be read, so the time rules and the permission rules are off.`
10318
+ };
9333
10319
  }
9334
10320
  let fd;
9335
10321
  try {
9336
- fd = openSync2(file, constants5.O_RDONLY | NO_FOLLOW2);
10322
+ fd = openWithoutFollowingNow(file, constants6.O_RDONLY);
9337
10323
  } catch (error) {
9338
10324
  const code = codeOf(error);
9339
10325
  if (code === "ENOENT") return { kind: "missing" };
9340
- if (code === "ELOOP") {
9341
- return {
9342
- kind: "refused",
9343
- problem: `${shown} is a link, which Agent Lookout does not follow, so the time rules are off.`
9344
- };
9345
- }
9346
- return { kind: "refused", problem: `${shown} could not be read, so the time rules are off.` };
10326
+ if (code === "ELOOP") return linkRefused(shown);
10327
+ return {
10328
+ kind: "refused",
10329
+ problem: `${shown} could not be read, so the time rules and the permission rules are off.`
10330
+ };
9347
10331
  }
9348
10332
  try {
9349
- const info = fstatSync2(fd);
9350
- if (!info.isFile()) {
9351
- return {
9352
- kind: "refused",
9353
- problem: `${shown} is not an ordinary file, so the time rules are off.`
9354
- };
9355
- }
10333
+ const info = fstatSync3(fd);
10334
+ if (!info.isFile()) return notOrdinary3(shown);
9356
10335
  if (info.size > MAX_SETTINGS_BYTES) {
9357
10336
  return {
9358
10337
  kind: "refused",
9359
- problem: `${shown} is larger than 64 KB, so it was not read and the time rules are off.`
10338
+ problem: `${shown} is larger than 64 KB, so it was not read and the time rules and the permission rules are off.`
9360
10339
  };
9361
10340
  }
9362
10341
  const buffer = Buffer.alloc(info.size);
@@ -9368,14 +10347,17 @@ function readSettingsText(setup) {
9368
10347
  }
9369
10348
  return { kind: "read", text: buffer.toString("utf8", 0, filled) };
9370
10349
  } catch {
9371
- return { kind: "refused", problem: `${shown} could not be read, so the time rules are off.` };
10350
+ return {
10351
+ kind: "refused",
10352
+ problem: `${shown} could not be read, so the time rules and the permission rules are off.`
10353
+ };
9372
10354
  } finally {
9373
- closeSync2(fd);
10355
+ closeSync3(fd);
9374
10356
  }
9375
10357
  }
9376
10358
  function writeSettingsText(setup, text4) {
9377
10359
  const { file, shown } = setup;
9378
- const folder = path22.dirname(file);
10360
+ const folder = path20.dirname(file);
9379
10361
  const notSaved = {
9380
10362
  ok: false,
9381
10363
  problem: `${shown} could not be written, so the change was not saved.`
@@ -9396,20 +10378,20 @@ function writeSettingsText(setup, text4) {
9396
10378
  problem: `${shown} is a link or not an ordinary file, which Agent Lookout does not write through, so the change was not saved.`
9397
10379
  };
9398
10380
  }
9399
- temporary = path22.join(
10381
+ temporary = path20.join(
9400
10382
  folder,
9401
- `.${path22.basename(file)}.${process.pid}.${randomBytes3(6).toString("hex")}.tmp`
10383
+ `.${path20.basename(file)}.${process.pid}.${randomBytes3(6).toString("hex")}.tmp`
9402
10384
  );
9403
- const fd = openSync2(
10385
+ const fd = openWithoutFollowingNow(
9404
10386
  temporary,
9405
- constants5.O_WRONLY | constants5.O_CREAT | constants5.O_EXCL | NO_FOLLOW2,
10387
+ constants6.O_WRONLY | constants6.O_CREAT | constants6.O_EXCL,
9406
10388
  SETTINGS_FILE_MODE
9407
10389
  );
9408
10390
  try {
9409
10391
  writeSync2(fd, text4);
9410
10392
  fsyncSync(fd);
9411
10393
  } finally {
9412
- closeSync2(fd);
10394
+ closeSync3(fd);
9413
10395
  }
9414
10396
  renameSync(temporary, file);
9415
10397
  temporary = null;
@@ -9430,14 +10412,15 @@ function writeSettingsText(setup, text4) {
9430
10412
  // src/collector/settings/collectorSettings.ts
9431
10413
  var fileSettingsStore = {
9432
10414
  read: readSettingsText,
9433
- write: writeSettingsText
10415
+ write: writeSettingsText,
10416
+ stamp: readSettingsStamp
9434
10417
  };
9435
10418
  var RULE_WORDS = {
9436
10419
  longWait: "the long wait reminder",
9437
10420
  idle: "the idle rule",
9438
10421
  quietHours: "the quiet hours"
9439
10422
  };
9440
- function isRecord12(value) {
10423
+ function isRecord13(value) {
9441
10424
  return typeof value === "object" && value !== null && !Array.isArray(value);
9442
10425
  }
9443
10426
  function listOf(names) {
@@ -9446,11 +10429,28 @@ function listOf(names) {
9446
10429
  }
9447
10430
  function rulesIn(read, shown) {
9448
10431
  const off = readTimeRules(void 0).rules;
10432
+ const none = { permissionRules: [], heldRules: void 0 };
9449
10433
  if (read.kind === "missing") {
9450
- return { rules: off, rest: {}, otherRules: {}, problem: null, writable: true };
10434
+ return {
10435
+ rules: off,
10436
+ ...none,
10437
+ rest: {},
10438
+ otherRules: {},
10439
+ problem: null,
10440
+ rulesProblem: null,
10441
+ writable: true
10442
+ };
9451
10443
  }
9452
10444
  if (read.kind === "refused") {
9453
- return { rules: off, rest: {}, otherRules: {}, problem: read.problem, writable: false };
10445
+ return {
10446
+ rules: off,
10447
+ ...none,
10448
+ rest: {},
10449
+ otherRules: {},
10450
+ problem: read.problem,
10451
+ rulesProblem: read.problem,
10452
+ writable: false
10453
+ };
9454
10454
  }
9455
10455
  let value;
9456
10456
  try {
@@ -9458,60 +10458,252 @@ function rulesIn(read, shown) {
9458
10458
  } catch {
9459
10459
  value = null;
9460
10460
  }
9461
- if (!isRecord12(value)) {
10461
+ if (!isRecord13(value)) {
10462
+ const notJson = `${shown} is not JSON that Agent Lookout can read, so the time rules and the permission rules are off. Changing one in Settings writes the file again.`;
9462
10463
  return {
9463
10464
  rules: off,
10465
+ ...none,
9464
10466
  rest: {},
9465
10467
  otherRules: {},
9466
- problem: `${shown} is not JSON that Agent Lookout can read, so the time rules are off. Changing one in Settings writes the file again.`,
10468
+ problem: notJson,
10469
+ rulesProblem: notJson,
9467
10470
  writable: true
9468
10471
  };
9469
10472
  }
9470
- const { timeRules, ...rest } = value;
10473
+ const { timeRules, permissionRules: heldRules, ...rest } = value;
10474
+ const permissions = readPermissionRules(heldRules);
9471
10475
  const { rules, unread } = readTimeRules(timeRules);
9472
- const otherRules = isRecord12(timeRules) ? Object.fromEntries(
10476
+ const otherRules = isRecord13(timeRules) ? Object.fromEntries(
9473
10477
  Object.entries(timeRules).filter(
9474
10478
  ([name]) => !TIME_RULE_NAMES.includes(name)
9475
10479
  )
9476
10480
  ) : {};
9477
10481
  const problem = unread.length === 0 ? null : `${shown} holds ${listOf(unread)} in a form Agent Lookout cannot read, so ${unread.length === 1 ? "it is" : "they are"} off.`;
9478
- return { rules, rest, otherRules, problem, writable: true };
10482
+ return {
10483
+ rules,
10484
+ permissionRules: permissions.ok ? permissions.rules : null,
10485
+ heldRules,
10486
+ rest,
10487
+ otherRules,
10488
+ problem,
10489
+ rulesProblem: permissions.ok ? null : unreadRulesLine(shown),
10490
+ writable: true
10491
+ };
10492
+ }
10493
+ function unreadRulesLine(shown) {
10494
+ return `${shown} holds permission rules Agent Lookout cannot read, or a rule it refuses, so no rule is used. Changing a rule in Settings writes the list again with only the rules shown there.`;
9479
10495
  }
9480
10496
  function createCollectorSettings(options) {
9481
10497
  const { setup } = options;
9482
10498
  const store = options.store ?? fileSettingsStore;
9483
- const first = rulesIn(store.read(setup), setup.shown);
9484
- let rules = first.rules;
9485
- const { rest, otherRules } = first;
9486
- let problem = first.problem;
10499
+ let stamp = store.stamp(setup);
10500
+ let held = rulesIn(store.read(setup), setup.shown);
10501
+ const first = held;
10502
+ let rules = held.rules;
10503
+ let problem = held.problem;
10504
+ let permissionRules = held.permissionRules ?? [];
10505
+ let keepHeld = held.permissionRules === null;
10506
+ let rulesProblem = held.rulesProblem;
10507
+ function read() {
10508
+ stamp = store.stamp(setup);
10509
+ held = rulesIn(store.read(setup), setup.shown);
10510
+ rules = held.rules;
10511
+ problem = held.problem;
10512
+ permissionRules = held.permissionRules ?? [];
10513
+ keepHeld = held.permissionRules === null;
10514
+ rulesProblem = held.rulesProblem;
10515
+ }
10516
+ const notWritable = () => ({
10517
+ ok: false,
10518
+ problem: `${setup.shown} could not be read, so it is not written over and the change was not saved. Mend or remove the file, then make the change again.`
10519
+ });
10520
+ function textWith(time, permissions, keep) {
10521
+ const listed = keep ? { permissionRules: held.heldRules } : permissions.length > 0 || held.heldRules !== void 0 ? { permissionRules: permissions } : {};
10522
+ const whole = { ...held.rest, timeRules: { ...held.otherRules, ...time }, ...listed };
10523
+ return `${JSON.stringify(whole, null, 2)}
10524
+ `;
10525
+ }
9487
10526
  return {
9488
10527
  timeRules: () => rules,
9489
- status: () => ({ timeRules: rules, file: setup.shown, problem }),
10528
+ permissionRules: () => permissionRules,
10529
+ status: () => ({
10530
+ timeRules: rules,
10531
+ file: setup.shown,
10532
+ problem,
10533
+ permissionRules: [...permissionRules],
10534
+ permissionRulesProblem: rulesProblem
10535
+ }),
10536
+ readAgain() {
10537
+ if (store.stamp(setup) !== stamp) read();
10538
+ },
9490
10539
  changeTimeRules(next) {
9491
- if (!first.writable) {
9492
- return {
9493
- ok: false,
9494
- problem: `${setup.shown} could not be read when Agent Lookout started, so it is not written over and the change was not saved. Mend or remove the file, then start Agent Lookout again.`
9495
- };
9496
- }
9497
- const text4 = `${JSON.stringify({ ...rest, timeRules: { ...otherRules, ...next } }, null, 2)}
9498
- `;
9499
- const written = store.write(setup, text4);
10540
+ read();
10541
+ if (!held.writable) return notWritable();
10542
+ const written = store.write(setup, textWith(next, permissionRules, keepHeld));
9500
10543
  if (!written.ok) {
9501
10544
  problem = written.problem;
9502
10545
  return written;
9503
10546
  }
9504
10547
  rules = next;
9505
10548
  problem = null;
10549
+ if (!keepHeld) rulesProblem = null;
9506
10550
  return written;
9507
10551
  },
9508
- problemAtStart: first.problem
10552
+ changePermissionRules(change) {
10553
+ read();
10554
+ if (!held.writable) return { reason: "not-saved", ...notWritable() };
10555
+ const made = change(permissionRules);
10556
+ if (!made.ok || !made.changed) return made;
10557
+ const written = store.write(setup, textWith(rules, made.rules, false));
10558
+ if (!written.ok) {
10559
+ rulesProblem = written.problem;
10560
+ return { ok: false, reason: "not-saved", problem: written.problem };
10561
+ }
10562
+ permissionRules = [...made.rules];
10563
+ keepHeld = false;
10564
+ rulesProblem = null;
10565
+ problem = null;
10566
+ return made;
10567
+ },
10568
+ problemAtStart: first.problem,
10569
+ rulesProblemAtStart: first.rulesProblem === first.problem ? null : first.rulesProblem
10570
+ };
10571
+ }
10572
+
10573
+ // src/collector/settings/permissionRulesRoute.ts
10574
+ import { randomBytes as randomBytes4 } from "node:crypto";
10575
+
10576
+ // src/core/permission-rules/rulesChange.ts
10577
+ var CHANGE_SHAPE = 'The body must be one change: {"add": rule}, {"edit": rule with its "id"}, {"move": {"id", "to": "up" or "down"}} or {"remove": {"id"}}, and nothing else.';
10578
+ function isRecord14(value) {
10579
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10580
+ }
10581
+ function rulesChangeIn(value) {
10582
+ const refused = { ok: false, problem: CHANGE_SHAPE };
10583
+ if (!isRecord14(value) || Object.keys(value).length !== 1) return refused;
10584
+ const [kind] = Object.keys(value);
10585
+ const body = value[kind];
10586
+ if (!isRecord14(body)) return refused;
10587
+ switch (kind) {
10588
+ case "add": {
10589
+ const read = ruleWordsIn(body);
10590
+ return read.ok ? { ok: true, change: { kind, words: read.words } } : read;
10591
+ }
10592
+ case "edit": {
10593
+ const { id, ...words } = body;
10594
+ if (!isRuleId(id)) return refused;
10595
+ const read = ruleWordsIn(words);
10596
+ return read.ok ? { ok: true, change: { kind, id, words: read.words } } : read;
10597
+ }
10598
+ case "move": {
10599
+ const { id, to } = body;
10600
+ if (Object.keys(body).length !== 2 || !isRuleId(id)) return refused;
10601
+ if (to !== "up" && to !== "down") return refused;
10602
+ return { ok: true, change: { kind, id, to } };
10603
+ }
10604
+ case "remove": {
10605
+ const { id } = body;
10606
+ if (Object.keys(body).length !== 1 || !isRuleId(id)) return refused;
10607
+ return { ok: true, change: { kind, id } };
10608
+ }
10609
+ default:
10610
+ return refused;
10611
+ }
10612
+ }
10613
+ var NO_RULE = {
10614
+ ok: false,
10615
+ reason: "no-rule",
10616
+ problem: "The list holds no such rule. It may have been changed in another tab."
10617
+ };
10618
+ var DUPLICATE = {
10619
+ ok: false,
10620
+ reason: "duplicate",
10621
+ problem: "The list holds that rule already."
10622
+ };
10623
+ function applyRulesChange(rules, change, newId) {
10624
+ if (change.kind === "add") {
10625
+ if (rules.length >= MAX_RULES) {
10626
+ return {
10627
+ ok: false,
10628
+ reason: "full",
10629
+ problem: `The list holds ${MAX_RULES} rules, the most it can. Remove one to add another.`
10630
+ };
10631
+ }
10632
+ if (rules.some((rule2) => sameWords(rule2, change.words))) return DUPLICATE;
10633
+ let id = newId();
10634
+ while (rules.some((rule2) => rule2.id === id)) id = newId();
10635
+ return { ok: true, rules: [...rules, { id, ...change.words }], changed: true };
10636
+ }
10637
+ const at = rules.findIndex((rule2) => rule2.id === change.id);
10638
+ if (at < 0) return NO_RULE;
10639
+ const rule = rules[at];
10640
+ if (change.kind === "remove") {
10641
+ return { ok: true, rules: rules.filter((_, index) => index !== at), changed: true };
10642
+ }
10643
+ if (change.kind === "edit") {
10644
+ if (sameWords(rule, change.words)) return { ok: true, rules: [...rules], changed: false };
10645
+ if (rules.some((other) => other.id !== rule.id && sameWords(other, change.words))) {
10646
+ return DUPLICATE;
10647
+ }
10648
+ const next2 = [...rules];
10649
+ next2[at] = { id: rule.id, ...change.words };
10650
+ return { ok: true, rules: next2, changed: true };
10651
+ }
10652
+ const to = change.to === "up" ? at - 1 : at + 1;
10653
+ if (to < 0 || to >= rules.length) return { ok: true, rules: [...rules], changed: false };
10654
+ const next = [...rules];
10655
+ next[at] = next[to];
10656
+ next[to] = rule;
10657
+ return { ok: true, rules: next, changed: true };
10658
+ }
10659
+
10660
+ // src/collector/settings/permissionRulesRoute.ts
10661
+ var MAX_PERMISSION_RULES_BODY_BYTES = 1024;
10662
+ var failed3 = (status, reason, error) => ({
10663
+ status,
10664
+ body: { error, reason }
10665
+ });
10666
+ var STATUS_OF = { "no-rule": 404, full: 409, duplicate: 409, "not-saved": 500 };
10667
+ function permissionRulesRefusalFor(req) {
10668
+ return actionRefusalFor(req, PERMISSION_RULES_ACTION, MAX_PERMISSION_RULES_BODY_BYTES);
10669
+ }
10670
+ var newRuleId = () => randomBytes4(6).toString("hex");
10671
+ function createPermissionRulesRoute(options) {
10672
+ const { settings } = options;
10673
+ const newId = options.newId ?? newRuleId;
10674
+ return async function answerPermissionRules(req) {
10675
+ const refused = permissionRulesRefusalFor(req);
10676
+ if (refused) {
10677
+ req.resume();
10678
+ return refused;
10679
+ }
10680
+ const body = await readRequestBody(req, MAX_PERMISSION_RULES_BODY_BYTES);
10681
+ if (!body.ok) {
10682
+ req.resume();
10683
+ return body.tooLarge ? refusal(413, `The body must be no more than ${MAX_PERMISSION_RULES_BODY_BYTES} bytes.`) : refusal(400, "The body could not be read.");
10684
+ }
10685
+ let value;
10686
+ try {
10687
+ value = JSON.parse(body.text);
10688
+ } catch {
10689
+ return failed3(400, "invalid", "The body must be JSON: one change to the permission rules.");
10690
+ }
10691
+ const asked = rulesChangeIn(value);
10692
+ if (!asked.ok) return failed3(400, "invalid", asked.problem);
10693
+ const made = settings.changePermissionRules(
10694
+ (rules) => applyRulesChange(rules, asked.change, newId)
10695
+ );
10696
+ if (!made.ok) return failed3(STATUS_OF[made.reason], made.reason, made.problem);
10697
+ return {
10698
+ status: 200,
10699
+ body: { ok: true, permissionRules: made.rules }
10700
+ };
9509
10701
  };
9510
10702
  }
9511
10703
 
9512
10704
  // src/collector/settings/timeRulesRoute.ts
9513
10705
  var MAX_TIME_RULES_BODY_BYTES = 2048;
9514
- var failed3 = (status, reason, error) => ({
10706
+ var failed4 = (status, reason, error) => ({
9515
10707
  status,
9516
10708
  body: { error, reason }
9517
10709
  });
@@ -9535,12 +10727,12 @@ function createTimeRulesRoute(options) {
9535
10727
  try {
9536
10728
  value = JSON.parse(body.text);
9537
10729
  } catch {
9538
- return failed3(400, "invalid", "The body must be JSON: the three time rules.");
10730
+ return failed4(400, "invalid", "The body must be JSON: the three time rules.");
9539
10731
  }
9540
10732
  const asked = timeRulesIn(value);
9541
- if (!asked.ok) return failed3(400, "invalid", asked.problem);
10733
+ if (!asked.ok) return failed4(400, "invalid", asked.problem);
9542
10734
  const saved = settings.changeTimeRules(asked.rules);
9543
- if (!saved.ok) return failed3(500, "not-saved", saved.problem);
10735
+ if (!saved.ok) return failed4(500, "not-saved", saved.problem);
9544
10736
  return {
9545
10737
  status: 200,
9546
10738
  body: { ok: true, timeRules: asked.rules }
@@ -9692,8 +10884,9 @@ var TMUX_LOCATIONS = [
9692
10884
  "/opt/local/bin/tmux",
9693
10885
  "/usr/bin/tmux"
9694
10886
  ];
9695
- async function findTmuxBinary(env, isExecutable = isExecutableFile) {
9696
- const candidates = [.../* @__PURE__ */ new Set([...pathCandidates(env, "tmux"), ...TMUX_LOCATIONS])];
10887
+ async function findTmuxBinary(env, isExecutable, platform = process.platform) {
10888
+ isExecutable ??= (candidate) => isExecutableFile(candidate, platform);
10889
+ const candidates = [.../* @__PURE__ */ new Set([...pathCandidates(env, "tmux", platform), ...TMUX_LOCATIONS])];
9697
10890
  for (const candidate of candidates) {
9698
10891
  if (await isExecutable(candidate)) return candidate;
9699
10892
  }
@@ -9727,9 +10920,10 @@ function runTmuxBinary(binary, args, options) {
9727
10920
  function createTmuxRunner(options) {
9728
10921
  const { env } = options;
9729
10922
  const off = env[TMUX_ENV]?.trim().toLowerCase() === "off";
10923
+ const platform = options.platform ?? process.platform;
9730
10924
  return async (args) => {
9731
- if (off || process.platform === "win32") return NOT_RUN3;
9732
- const binary = await findTmuxBinary(env, options.isExecutable);
10925
+ if (off || platform === "win32") return NOT_RUN3;
10926
+ const binary = await findTmuxBinary(env, options.isExecutable, platform);
9733
10927
  if (binary === null) return NOT_RUN3;
9734
10928
  return runTmuxBinary(binary, args, { env, timeoutMs: options.timeoutMs });
9735
10929
  };
@@ -9782,7 +10976,8 @@ function createWaitLedger() {
9782
10976
  events,
9783
10977
  measured: spans,
9784
10978
  runStarts,
9785
- waitingNow: snapshot.sessions.filter((session) => session.status === "needs-you").map((session) => ({ id: session.id, name: session.name })),
10979
+ // Not a session in a wait Agent Lookout answered, whatever its source still says.
10980
+ waitingNow: snapshot.sessions.filter(needsYou).map((session) => ({ id: session.id, name: session.name })),
9786
10981
  names: new Map(snapshot.sessions.map((session) => [session.id, session.name])),
9787
10982
  since: since.at,
9788
10983
  now
@@ -9973,11 +11168,13 @@ function createCollector(options) {
9973
11168
  const env = options.env ?? process.env;
9974
11169
  const intervalMs = options.intervalMs ?? POLL_INTERVAL_MS;
9975
11170
  const warn = options.warn ?? console.error;
11171
+ const settingsSetup = readSettingsSetup(env);
9976
11172
  const settings = createCollectorSettings({
9977
- setup: readSettingsSetup(env),
11173
+ setup: settingsSetup,
9978
11174
  store: options.settingsStore
9979
11175
  });
9980
11176
  if (settings.problemAtStart !== null) warn(settings.problemAtStart);
11177
+ if (settings.rulesProblemAtStart !== null) warn(settings.rulesProblemAtStart);
9981
11178
  const memoryEvents = createEventStore();
9982
11179
  const memoryHistory = createHistoryStore();
9983
11180
  const historySetup = readHistorySetup(env);
@@ -10008,9 +11205,9 @@ function createCollector(options) {
10008
11205
  });
10009
11206
  const tabs = createTabFinder({ env, readProcesses: options.readProcesses, now });
10010
11207
  const osascript = options.osascript ?? createOsascriptRunner({ env });
10011
- const stopsOn = !stopOff(env);
10012
- const stopTargets = stopsOn ? createStopTargets() : void 0;
10013
11208
  const platform = options.platform ?? process.platform;
11209
+ const stopsOn = !stopOff(env) && platform !== "win32";
11210
+ const stopTargets = stopsOn ? createStopTargets() : void 0;
10014
11211
  const notifications = createServerNotifications({
10015
11212
  notifier: options.notifier ?? createSystemNotifier({ platform }),
10016
11213
  onAtStart: notificationsOnAtStart(env),
@@ -10071,7 +11268,8 @@ function createCollector(options) {
10071
11268
  panes,
10072
11269
  terminals: tabs,
10073
11270
  readProcessStarts: options.readProcessStarts,
10074
- stops: stopTargets
11271
+ stops: stopTargets,
11272
+ platform
10075
11273
  }),
10076
11274
  createCodexAdapter({ env: options.env, now, pollIntervalMs: intervalMs }),
10077
11275
  createStatusFileAdapter({ env: options.env, now, pollIntervalMs: intervalMs })
@@ -10087,7 +11285,17 @@ function createCollector(options) {
10087
11285
  // Every source's sessions alike are given the branch of their folder, and
10088
11286
  // with pull requests on, the branch's pull request, as last learnt.
10089
11287
  // Every snapshot carries the time rules in force, and is stale by them.
10090
- timeRules: () => settings.timeRules(),
11288
+ // The settings file is looked at again here, once a poll, so a change
11289
+ // another copy of Agent Lookout saved is in force by the next one.
11290
+ timeRules: () => {
11291
+ settings.readAgain();
11292
+ return settings.timeRules();
11293
+ },
11294
+ // And marks each session still in a wait whose permission request was
11295
+ // answered, so every channel, the page's too, takes that wait as over at
11296
+ // the same poll, and the Events log and the waits never count a wait a
11297
+ // rule answered before a poll saw it.
11298
+ answered: (snapshot) => answering?.answeredIn(snapshot) ?? /* @__PURE__ */ new Set(),
10091
11299
  annotate: async (sessions) => {
10092
11300
  const onBranches = await branches.annotate(sessions);
10093
11301
  return pullRequests ? pullRequests.annotate(onBranches) : onBranches;
@@ -10103,7 +11311,18 @@ function createCollector(options) {
10103
11311
  }
10104
11312
  });
10105
11313
  const answers2 = options.adapters === void 0 || options.answering !== void 0;
10106
- const answering = answers2 ? createAnswering({ env, poller, events, now, warn, ...options.answering }) : null;
11314
+ const answering = answers2 ? createAnswering({
11315
+ env,
11316
+ poller,
11317
+ events,
11318
+ now,
11319
+ warn,
11320
+ platform,
11321
+ // Read for each request, so a change in Settings holds for the next one.
11322
+ rules: () => settings.permissionRules(),
11323
+ settingsFile: settingsSetup.file,
11324
+ ...options.answering
11325
+ }) : null;
10107
11326
  const stopper = createStopper({
10108
11327
  env,
10109
11328
  now,
@@ -10133,8 +11352,13 @@ function createCollector(options) {
10133
11352
  jump: createJumpRoute({ poller, panes, run: tmux, tabs, osascript, now }),
10134
11353
  stop: stopRoutes && createStopRoute(stopRoutes),
10135
11354
  cleanUp: stopRoutes && createCleanUpRoute(stopRoutes),
10136
- settings: () => settings.status(),
11355
+ settings: () => ({
11356
+ ...settings.status(),
11357
+ ruleAnswers: answering?.ruleAnswers() ?? [],
11358
+ ruleAnswersSince: poller.startedAt
11359
+ }),
10137
11360
  timeRules: createTimeRulesRoute({ settings }),
11361
+ permissionRules: createPermissionRulesRoute({ settings }),
10138
11362
  clearHistory: createClearHistoryRoute({
10139
11363
  keeper,
10140
11364
  forget: () => {
@@ -10248,7 +11472,7 @@ async function runStandalone(options) {
10248
11472
  } catch (error) {
10249
11473
  return stopWith(error.message);
10250
11474
  }
10251
- if (!existsSync(path23.join(distDir, "index.html"))) {
11475
+ if (!existsSync(path21.join(distDir, "index.html"))) {
10252
11476
  return stopWith(
10253
11477
  "The dashboard has not been built yet. Run `npm run build`, then start it again."
10254
11478
  );
@@ -10291,12 +11515,24 @@ async function runStandalone(options) {
10291
11515
 
10292
11516
  // src/cli/start/openBrowser.ts
10293
11517
  import { spawn as spawn3 } from "node:child_process";
11518
+ import path22 from "node:path";
10294
11519
  var OPEN_WAIT_MS = 1e4;
10295
- function openCommand(platform, url) {
11520
+ var WINDOWS_FOLDER = "C:\\Windows";
11521
+ function windowsFolder(env) {
11522
+ const named = env.SystemRoot?.trim() || env.SYSTEMROOT?.trim();
11523
+ return named && path22.win32.isAbsolute(named) ? named : WINDOWS_FOLDER;
11524
+ }
11525
+ function openCommand(platform, url, env = process.env) {
10296
11526
  const address = loopbackAddress(url);
10297
11527
  if (address === null) return null;
10298
11528
  if (platform === "darwin") return { file: "/usr/bin/open", args: [address] };
10299
11529
  if (platform === "linux") return { file: "xdg-open", args: [address] };
11530
+ if (platform === "win32") {
11531
+ return {
11532
+ file: path22.win32.join(windowsFolder(env), "System32", "rundll32.exe"),
11533
+ args: ["url.dll,FileProtocolHandler", address]
11534
+ };
11535
+ }
10300
11536
  return null;
10301
11537
  }
10302
11538
  function runProgram2(file, args, waitMs = OPEN_WAIT_MS) {
@@ -10307,7 +11543,7 @@ function runProgram2(file, args, waitMs = OPEN_WAIT_MS) {
10307
11543
  resolve(opened);
10308
11544
  };
10309
11545
  try {
10310
- const child = spawn3(file, [...args], { stdio: "ignore", detached: true });
11546
+ const child = spawn3(file, [...args], { stdio: "ignore", detached: true, windowsHide: true });
10311
11547
  child.once("error", () => settle(false));
10312
11548
  child.once("exit", (code) => settle(code === 0));
10313
11549
  child.unref();
@@ -10318,7 +11554,7 @@ function runProgram2(file, args, waitMs = OPEN_WAIT_MS) {
10318
11554
  });
10319
11555
  }
10320
11556
  async function openInBrowser(url, options = {}) {
10321
- const command = openCommand(options.platform ?? process.platform, url);
11557
+ const command = openCommand(options.platform ?? process.platform, url, options.env);
10322
11558
  if (command === null) return false;
10323
11559
  return (options.run ?? runProgram2)(command.file, command.args);
10324
11560
  }