agent-lookout 0.2.4 → 0.2.5

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,13 +1,20 @@
1
1
  import {
2
2
  sortSessions
3
- } from "./chunk-KNOLJ3IC.js";
3
+ } from "./chunk-Q73JDXXW.js";
4
4
  import {
5
+ ANSWER_DECISIONS,
6
+ CAPABILITIES,
5
7
  EVENT_ACTORS,
6
8
  EVENT_KINDS,
7
9
  EVENT_SEVERITIES,
10
+ MAX_MACHINE_NAME_LENGTH,
8
11
  MAX_NAME_LENGTH,
9
12
  MAX_NOTICE_TEXT_LENGTH,
10
13
  SESSION_STATUSES,
14
+ SOURCE_STATES,
15
+ SOURCE_STATE_LABEL,
16
+ SURFACES,
17
+ WAITING_REASONS,
11
18
  agentName,
12
19
  changeNotice,
13
20
  checksStateOf,
@@ -15,22 +22,28 @@ import {
15
22
  clockAt,
16
23
  cut,
17
24
  durationInWords,
25
+ durationParts,
18
26
  formatDuration,
19
27
  isGitHubRepository,
28
+ isMachineName,
20
29
  isPullRequestNumber,
30
+ isRemoteSource,
21
31
  isStatusEvent,
22
32
  loopbackAddress,
33
+ noticeTitle,
23
34
  oneLine,
24
35
  overPhrase,
36
+ partsInWords,
25
37
  pullRequestUrl,
26
38
  readAppVersion,
39
+ remoteSourceId,
27
40
  sessionName,
28
41
  sessionTitle,
29
42
  surfaceLabel,
30
43
  waitingPhrase,
31
44
  waitingText,
32
45
  withoutWaitingText
33
- } from "./chunk-63XPRBHW.js";
46
+ } from "./chunk-OT3EGOTY.js";
34
47
 
35
48
  // src/collector/hosts/server.ts
36
49
  import { createServer } from "node:http";
@@ -106,15 +119,15 @@ function toldAlready(event, known) {
106
119
  function sessionChanges(memory, snapshot) {
107
120
  const waits = waitChanges(memory.waits, snapshot);
108
121
  const startedWaiting = new Set(waits.started);
109
- const answers = /* @__PURE__ */ new Map();
122
+ const answers2 = /* @__PURE__ */ new Map();
110
123
  for (const source of snapshot.sources) {
111
124
  if (source.state === "ok" || source.state === "not-set-up") {
112
- answers.set(source.id, { basis: source.basis ?? "", sessions: /* @__PURE__ */ new Map() });
125
+ answers2.set(source.id, { basis: source.basis ?? "", sessions: /* @__PURE__ */ new Map() });
113
126
  }
114
127
  }
115
128
  const found = [];
116
129
  for (const session of snapshot.sessions) {
117
- const answer = answers.get(session.source);
130
+ const answer = answers2.get(session.source);
118
131
  if (!answer || answer.sessions.has(session.id)) continue;
119
132
  answer.sessions.set(session.id, withoutWaitingText(session));
120
133
  if (startedWaiting.has(session)) {
@@ -126,7 +139,7 @@ function sessionChanges(memory, snapshot) {
126
139
  found.push({ event: session.status, session });
127
140
  }
128
141
  }
129
- for (const [source, { basis, sessions }] of answers) {
142
+ for (const [source, { basis, sessions }] of answers2) {
130
143
  for (const [id, before] of memory.seen.get(source)?.get(basis) ?? []) {
131
144
  if (!sessions.has(id) && !isOverStatus(before.status)) {
132
145
  found.push({ event: "ended", session: before });
@@ -134,7 +147,7 @@ function sessionChanges(memory, snapshot) {
134
147
  }
135
148
  }
136
149
  const overNow = /* @__PURE__ */ new Map();
137
- for (const [source, { sessions }] of answers) {
150
+ for (const [source, { sessions }] of answers2) {
138
151
  const over2 = new Map(memory.over.get(source));
139
152
  for (const [id, session] of sessions) {
140
153
  if (!isOverStatus(session.status)) over2.delete(id);
@@ -150,7 +163,7 @@ function sessionChanges(memory, snapshot) {
150
163
  });
151
164
  const seen = new Map(memory.seen);
152
165
  const over = new Map(memory.over);
153
- for (const [source, { basis, sessions }] of answers) {
166
+ for (const [source, { basis, sessions }] of answers2) {
154
167
  const ways = new Map(memory.seen.get(source));
155
168
  ways.set(basis, sessions);
156
169
  seen.set(source, ways);
@@ -175,6 +188,10 @@ var CLEAN_UP_ACTION = "clean-up";
175
188
  var STOP_WAIT_MS = 1e4;
176
189
  var STOP_INTERVAL_MS = 1e3;
177
190
  var MAX_CLEAN_UP_SESSIONS = 20;
191
+ var ANSWER_ACTION = "answer";
192
+ var SETTINGS_PATH = "/api/settings";
193
+ var TIME_RULES_PATH = "/api/settings/time-rules";
194
+ var TIME_RULES_ACTION = "time-rules";
178
195
  var ACTION_HEADER = "X-Agent-Lookout-Action";
179
196
  var MAX_EVENTS_PER_RESPONSE = 200;
180
197
  var NOTIFICATIONS_HEADER = "X-Agent-Lookout-Notifications";
@@ -207,15 +224,298 @@ function historyPointFor(sessions, at) {
207
224
  return { at, needsYou, working, idle, total: sessions.length };
208
225
  }
209
226
 
227
+ // src/core/time-rules/quietHold.ts
228
+ function createQuietHold() {
229
+ let since = null;
230
+ let waits = [];
231
+ let over = [];
232
+ const openWaitOf = (sessionId) => waits.find((wait) => wait.session.id === sessionId && wait.endedAt === null);
233
+ const lastWaitOf = (sessionId) => {
234
+ for (let index = waits.length - 1; index >= 0; index -= 1) {
235
+ const wait = waits[index];
236
+ if (wait.session.id === sessionId) return wait;
237
+ }
238
+ return void 0;
239
+ };
240
+ return {
241
+ holding: () => since !== null,
242
+ begin(at) {
243
+ since ??= at;
244
+ },
245
+ holdWait(session, begunAt) {
246
+ const last = lastWaitOf(session.id);
247
+ if (last?.begunAt === begunAt) {
248
+ last.endedAt = null;
249
+ return;
250
+ }
251
+ if (last && last.endedAt === null) last.endedAt = begunAt;
252
+ waits.push({ session: withoutWaitingText(session), begunAt, endedAt: null });
253
+ },
254
+ holdOver(event, session, at) {
255
+ over.push({ event, session: withoutWaitingText(session), at });
256
+ },
257
+ waitEnded(sessionId, at) {
258
+ const held = openWaitOf(sessionId);
259
+ if (held) held.endedAt = at;
260
+ },
261
+ holdsWait: (sessionId) => openWaitOf(sessionId) !== void 0,
262
+ end(snapshot, at, leaveOutAnswered) {
263
+ const listed = new Map(snapshot.sessions.map((session) => [session.id, session]));
264
+ const stillOpen = /* @__PURE__ */ new Map();
265
+ for (const wait of waits) {
266
+ const now = listed.get(wait.session.id);
267
+ if (now?.status !== "needs-you") continue;
268
+ if (wait.endedAt === null || now.statusSince === wait.begunAt) {
269
+ stillOpen.set(wait.session.id, wait);
270
+ }
271
+ }
272
+ const open4 = [];
273
+ for (const [id, wait] of stillOpen) {
274
+ open4.push({ session: listed.get(id), begunAt: wait.begunAt });
275
+ }
276
+ const answered = /* @__PURE__ */ new Map();
277
+ const lastBegun = /* @__PURE__ */ new Map();
278
+ for (const wait of waits) {
279
+ if (stillOpen.get(wait.session.id)?.begunAt === wait.begunAt) continue;
280
+ if (leaveOutAnswered) continue;
281
+ const waitedMs = Math.max(0, (wait.endedAt ?? at) - wait.begunAt);
282
+ lastBegun.set(wait.session.id, wait.begunAt);
283
+ const item = answered.get(wait.session.id);
284
+ if (item) {
285
+ item.waitedMs += waitedMs;
286
+ item.times += 1;
287
+ } else {
288
+ answered.set(wait.session.id, {
289
+ event: "needs-you",
290
+ session: wait.session,
291
+ at: wait.begunAt,
292
+ waitedMs,
293
+ times: 1
294
+ });
295
+ }
296
+ }
297
+ const rest = [];
298
+ for (const item of over) {
299
+ const waited = answered.get(item.session.id);
300
+ const begun = lastBegun.get(item.session.id);
301
+ if (waited && waited.then === void 0 && begun !== void 0 && item.at >= begun) {
302
+ waited.then = { event: item.event, at: item.at };
303
+ } else {
304
+ rest.push(item);
305
+ }
306
+ }
307
+ const items = [...answered.values(), ...rest].sort((a, b) => a.at - b.at);
308
+ const from = since ?? at;
309
+ since = null;
310
+ waits = [];
311
+ over = [];
312
+ return { summary: items.length > 0 ? { from, to: at, items } : null, open: open4 };
313
+ }
314
+ };
315
+ }
316
+
317
+ // src/core/sessions/staleness.ts
318
+ var STALE_THRESHOLD_MS = 24 * 60 * 60 * 1e3;
319
+ function isStale(session, now, thresholdMs = STALE_THRESHOLD_MS) {
320
+ if (session.status !== "idle") return false;
321
+ if (session.statusSince === null) return false;
322
+ return now - session.statusSince >= thresholdMs;
323
+ }
324
+ var HOUR_MS = 60 * 60 * 1e3;
325
+ var DAY_MS = 24 * HOUR_MS;
326
+
327
+ // src/core/time-rules/timeRules.ts
328
+ var WEEKDAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
329
+ var TIME_RULE_NAMES = ["longWait", "idle", "quietHours"];
330
+ var LONG_WAIT_MINUTES = { min: 1, max: 1440, default: 10 };
331
+ var IDLE_HOURS = { min: 1, max: 720, default: 48 };
332
+ var DEFAULT_TIME_RULES = {
333
+ longWait: { on: false, minutes: LONG_WAIT_MINUTES.default },
334
+ idle: { on: false, hours: IDLE_HOURS.default },
335
+ quietHours: {
336
+ on: false,
337
+ from: "22:00",
338
+ to: "08:00",
339
+ days: [...WEEKDAYS],
340
+ leaveOutAnswered: false
341
+ }
342
+ };
343
+ var HOUR_MS2 = 36e5;
344
+ var CLOCK = /^([01]\d|2[0-3]):([0-5]\d)$/;
345
+ function clockMinutes(clock) {
346
+ const match = CLOCK.exec(clock);
347
+ if (!match) return null;
348
+ return Number(match[1]) * 60 + Number(match[2]);
349
+ }
350
+ function isRecord(value) {
351
+ return typeof value === "object" && value !== null && !Array.isArray(value);
352
+ }
353
+ function holds(value, keys, exact2) {
354
+ if (!keys.every((key) => key in value)) return false;
355
+ return !exact2 || Object.keys(value).length === keys.length;
356
+ }
357
+ function wholeNumberIn(value, range) {
358
+ return Number.isSafeInteger(value) && value >= range.min && value <= range.max;
359
+ }
360
+ function readLongWait(value, exact2) {
361
+ if (!isRecord(value) || !holds(value, ["on", "minutes"], exact2)) return null;
362
+ const { on, minutes } = value;
363
+ if (typeof on !== "boolean" || !wholeNumberIn(minutes, LONG_WAIT_MINUTES)) return null;
364
+ return { on, minutes };
365
+ }
366
+ function readIdle(value, exact2) {
367
+ if (!isRecord(value) || !holds(value, ["on", "hours"], exact2)) return null;
368
+ const { on, hours } = value;
369
+ if (typeof on !== "boolean" || !wholeNumberIn(hours, IDLE_HOURS)) return null;
370
+ return { on, hours };
371
+ }
372
+ function readDays(value) {
373
+ if (!Array.isArray(value)) return null;
374
+ if (!value.every((day) => WEEKDAYS.includes(day))) return null;
375
+ if (new Set(value).size !== value.length) return null;
376
+ return WEEKDAYS.filter((day) => value.includes(day));
377
+ }
378
+ function readQuietHours(value, exact2) {
379
+ const keys = ["on", "from", "to", "days", "leaveOutAnswered"];
380
+ if (!isRecord(value) || !holds(value, keys, exact2)) return null;
381
+ const { on, from, to, leaveOutAnswered } = value;
382
+ if (typeof on !== "boolean" || typeof leaveOutAnswered !== "boolean") return null;
383
+ if (typeof from !== "string" || typeof to !== "string") return null;
384
+ const begins = clockMinutes(from);
385
+ const ends = clockMinutes(to);
386
+ if (begins === null || ends === null || begins === ends) return null;
387
+ const days = readDays(value.days);
388
+ if (days === null) return null;
389
+ return { on, from, to, days, leaveOutAnswered };
390
+ }
391
+ var READERS = {
392
+ longWait: readLongWait,
393
+ idle: readIdle,
394
+ quietHours: readQuietHours
395
+ };
396
+ var SHAPES = {
397
+ longWait: `longWait must be {"on", "minutes"}, with minutes a whole number from ${LONG_WAIT_MINUTES.min} to ${LONG_WAIT_MINUTES.max}.`,
398
+ idle: `idle must be {"on", "hours"}, with hours a whole number from ${IDLE_HOURS.min} to ${IDLE_HOURS.max}.`,
399
+ 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
+ };
401
+ function readTimeRules(value) {
402
+ const rules = {
403
+ longWait: { ...DEFAULT_TIME_RULES.longWait },
404
+ idle: { ...DEFAULT_TIME_RULES.idle },
405
+ quietHours: { ...DEFAULT_TIME_RULES.quietHours, days: [...WEEKDAYS] }
406
+ };
407
+ const unread = [];
408
+ if (!isRecord(value)) return { rules, unread: value === void 0 ? [] : [...TIME_RULE_NAMES] };
409
+ for (const name of TIME_RULE_NAMES) {
410
+ if (value[name] === void 0) continue;
411
+ const read = READERS[name](value[name], false);
412
+ if (read === null) unread.push(name);
413
+ else Object.assign(rules, { [name]: read });
414
+ }
415
+ return { rules, unread };
416
+ }
417
+ function timeRulesIn(value) {
418
+ if (!isRecord(value) || !holds(value, TIME_RULE_NAMES, true)) {
419
+ return {
420
+ ok: false,
421
+ problem: "The body must be the three time rules, longWait, idle and quietHours, and nothing else."
422
+ };
423
+ }
424
+ const rules = {};
425
+ for (const name of TIME_RULE_NAMES) {
426
+ const read = READERS[name](value[name], true);
427
+ if (read === null) return { ok: false, problem: SHAPES[name] };
428
+ Object.assign(rules, { [name]: read });
429
+ }
430
+ return { ok: true, rules };
431
+ }
432
+ function staleAfterMs(rules) {
433
+ return rules?.idle.on ? rules.idle.hours * HOUR_MS2 : STALE_THRESHOLD_MS;
434
+ }
435
+ function rulesOf(snapshot) {
436
+ return snapshot.timeRules ?? DEFAULT_TIME_RULES;
437
+ }
438
+
439
+ // src/core/time-rules/quietHours.ts
440
+ var DAY_OF_WEEK = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
441
+ function isQuietAt(rule, at) {
442
+ if (!rule.on) return false;
443
+ const from = clockMinutes(rule.from);
444
+ const to = clockMinutes(rule.to);
445
+ if (from === null || to === null || from === to) return false;
446
+ const date = new Date(at);
447
+ const minute = date.getHours() * 60 + date.getMinutes();
448
+ const today = DAY_OF_WEEK[date.getDay()];
449
+ if (from < to) return minute >= from && minute < to && rule.days.includes(today);
450
+ if (minute >= from) return rule.days.includes(today);
451
+ if (minute < to) {
452
+ const yesterday = DAY_OF_WEEK[(date.getDay() + 6) % 7];
453
+ return rule.days.includes(yesterday);
454
+ }
455
+ return false;
456
+ }
457
+ function quietOf(snapshot) {
458
+ return snapshot.quiet ?? isQuietAt(rulesOf(snapshot).quietHours, snapshot.generatedAt);
459
+ }
460
+
461
+ // src/core/time-rules/reminders.ts
462
+ function readable(snapshot) {
463
+ return new Set(
464
+ snapshot.sources.filter((source) => source.state === "ok" || source.state === "not-set-up").map((source) => source.id)
465
+ );
466
+ }
467
+ function createReminderWatch() {
468
+ const watched = /* @__PURE__ */ new Map();
469
+ return {
470
+ observe(snapshot, stopped, at) {
471
+ for (const id of stopped) watched.delete(id);
472
+ const sources = readable(snapshot);
473
+ for (const session of snapshot.sessions) {
474
+ if (session.status !== "needs-you" || !sources.has(session.source)) continue;
475
+ const since = session.statusSince !== null && session.statusSince <= at ? session.statusSince : null;
476
+ const known = watched.get(session.id);
477
+ if (known && (since === null || since === known.begunAt)) continue;
478
+ watched.set(session.id, {
479
+ begunAt: since ?? at,
480
+ firstSeenAfterMs: since === null ? 0 : at - since,
481
+ reminded: /* @__PURE__ */ new Set()
482
+ });
483
+ }
484
+ },
485
+ due(snapshot, at, thresholdMs, eligible = () => true) {
486
+ const found = [];
487
+ for (const session of snapshot.sessions) {
488
+ if (session.status !== "needs-you") continue;
489
+ const wait = watched.get(session.id);
490
+ if (!wait || wait.reminded.has(thresholdMs) || !eligible(session.id)) continue;
491
+ const waitedMs = at - wait.begunAt;
492
+ if (waitedMs < thresholdMs || wait.firstSeenAfterMs >= thresholdMs) continue;
493
+ found.push({ session, begunAt: wait.begunAt, waitedMs });
494
+ }
495
+ return found;
496
+ },
497
+ reminded(sessionId, thresholdMs) {
498
+ watched.get(sessionId)?.reminded.add(thresholdMs);
499
+ },
500
+ toldLate(sessionId, at, thresholdMs) {
501
+ const wait = watched.get(sessionId);
502
+ if (wait && at - wait.begunAt >= thresholdMs) wait.reminded.add(thresholdMs);
503
+ },
504
+ clear() {
505
+ watched.clear();
506
+ }
507
+ };
508
+ }
509
+
210
510
  // src/collector/outbound/outboundTiming.ts
211
- var HOUR_MS = 36e5;
511
+ var HOUR_MS3 = 36e5;
212
512
  function sendsInLastHour(sentAt, now) {
213
- return sentAt.filter((at) => now - at < HOUR_MS).sort((a, b) => a - b);
513
+ return sentAt.filter((at) => now - at < HOUR_MS3).sort((a, b) => a - b);
214
514
  }
215
515
  function limitLiftsAt(sentAt, now) {
216
516
  const recent = sendsInLastHour(sentAt, now);
217
517
  if (recent.length < SENDS_PER_HOUR) return null;
218
- return recent[recent.length - SENDS_PER_HOUR] + HOUR_MS;
518
+ return recent[recent.length - SENDS_PER_HOUR] + HOUR_MS3;
219
519
  }
220
520
  function sendTiming(begunAt, afterMs, sentAt, now) {
221
521
  if (now - begunAt < afterMs) return "wait";
@@ -227,14 +527,16 @@ function waitBegan(statusSince, seenAt) {
227
527
 
228
528
  // src/collector/outbound/outboundChannel.ts
229
529
  function createOutboundChannel(options) {
230
- const { afterMs, asking, waitMessage, overMessage, failure: failure2 } = options;
530
+ const { afterMs, asking, waitMessage, overMessage, reminderMessage, summaryMessage, failure: failure2 } = options;
231
531
  const now = options.now ?? Date.now;
232
532
  const wanted = new Set(options.events);
233
533
  let memory = EMPTY_CHANGE_MEMORY;
234
- const over = [];
534
+ const ready = [];
235
535
  const open4 = /* @__PURE__ */ new Map();
236
536
  const done = /* @__PURE__ */ new Map();
237
537
  let sentAt = [];
538
+ const reminders = createReminderWatch();
539
+ const hold = createQuietHold();
238
540
  let last = null;
239
541
  let queue = Promise.resolve();
240
542
  function send2(content) {
@@ -251,18 +553,42 @@ function createOutboundChannel(options) {
251
553
  handle(snapshot) {
252
554
  try {
253
555
  const at = now();
556
+ const rules = rulesOf(snapshot);
557
+ const quiet = quietOf(snapshot);
254
558
  const result = sessionChanges(memory, snapshot);
255
559
  memory = result.memory;
256
- for (const id of result.stopped) open4.delete(id);
560
+ for (const id of result.stopped) {
561
+ open4.delete(id);
562
+ hold.waitEnded(id, at);
563
+ }
564
+ reminders.observe(snapshot, result.stopped, at);
565
+ if (hold.holding() && !quiet) {
566
+ const end = hold.end(snapshot, at, rules.quietHours.leaveOutAnswered);
567
+ const { summary } = end;
568
+ if (summary) {
569
+ const items = summary.items.map((item) => ({
570
+ ...item,
571
+ agent: agentName(item.session, snapshot.sources)
572
+ }));
573
+ ready.push((sentNow) => summaryMessage({ ...summary, items, now: sentNow }));
574
+ }
575
+ for (const { session, begunAt } of end.open) open4.set(session.id, begunAt);
576
+ }
577
+ if (quiet) hold.begin(at);
257
578
  for (const { event, session } of result.changes) {
258
579
  if (!wanted.has(event)) continue;
259
580
  if (event !== "needs-you") {
260
- over.push({
581
+ if (quiet) {
582
+ hold.holdOver(event, session, at);
583
+ continue;
584
+ }
585
+ const facts = {
261
586
  event,
262
587
  session: withoutWaitingText(session),
263
588
  agent: agentName(session, snapshot.sources),
264
589
  seenAt: at
265
- });
590
+ };
591
+ ready.push((sentNow) => overMessage({ ...facts, now: sentNow }));
266
592
  continue;
267
593
  }
268
594
  const since = session.statusSince;
@@ -270,16 +596,25 @@ function createOutboundChannel(options) {
270
596
  open4.set(session.id, waitBegan(since, at));
271
597
  }
272
598
  sentAt = sendsInLastHour(sentAt, at);
273
- while (over.length > 0 && limitLiftsAt(sentAt, at) === null) {
274
- const facts = over.shift();
599
+ while (!quiet && ready.length > 0 && limitLiftsAt(sentAt, at) === null) {
600
+ const write = ready.shift();
275
601
  sentAt.push(at);
276
- send2(overMessage({ ...facts, now: at }));
602
+ send2(write(at));
277
603
  }
604
+ const thresholdMs = rules.longWait.minutes * 6e4;
278
605
  for (const [id, begunAt] of open4) {
279
606
  const session = snapshot.sessions.find(
280
607
  (candidate) => candidate.id === id && candidate.status === "needs-you"
281
608
  );
282
- if (!session || sendTiming(begunAt, afterMs, sentAt, at) !== "send") continue;
609
+ if (!session) continue;
610
+ const timing = sendTiming(begunAt, afterMs, sentAt, at);
611
+ if (timing === "wait") continue;
612
+ if (quiet) {
613
+ open4.delete(id);
614
+ hold.holdWait(session, begunAt);
615
+ continue;
616
+ }
617
+ if (timing === "limited") continue;
283
618
  open4.delete(id);
284
619
  sentAt.push(at);
285
620
  send2(
@@ -292,6 +627,24 @@ function createOutboundChannel(options) {
292
627
  now: at
293
628
  })
294
629
  );
630
+ if (rules.longWait.on) reminders.toldLate(id, at, thresholdMs);
631
+ }
632
+ if (!quiet && rules.longWait.on && wanted.has("needs-you")) {
633
+ for (const due of reminders.due(snapshot, at, thresholdMs, (id) => !open4.has(id))) {
634
+ if (limitLiftsAt(sentAt, at) !== null) break;
635
+ sentAt.push(at);
636
+ reminders.reminded(due.session.id, thresholdMs);
637
+ send2(
638
+ reminderMessage({
639
+ session: withoutWaitingText(due.session),
640
+ agent: agentName(due.session, snapshot.sources),
641
+ asking: asking ? waitingText(due.session.waitingText) ?? null : null,
642
+ begunAt: due.begunAt,
643
+ now: at,
644
+ thresholdMs
645
+ })
646
+ );
647
+ }
295
648
  }
296
649
  for (const session of snapshot.sessions) {
297
650
  if (session.status === "needs-you" && session.statusSince !== null && !open4.has(session.id)) {
@@ -307,6 +660,52 @@ function createOutboundChannel(options) {
307
660
  };
308
661
  }
309
662
 
663
+ // src/core/time-rules/timeRulesWords.ts
664
+ var MINUTE_MS = 6e4;
665
+ function waitedInWords(ms) {
666
+ if (ms < MINUTE_MS) return "under a minute";
667
+ return partsInWords(durationParts(ms).filter((part) => part.unit !== "s"));
668
+ }
669
+ var WAITED_FOR = {
670
+ permission: "for permission",
671
+ question: "for an answer",
672
+ other: "for you"
673
+ };
674
+ function reminderPhrase(session, waitedMs) {
675
+ return `has waited ${waitedInWords(waitedMs)} ${WAITED_FOR[session.waitingReason ?? "other"]}`;
676
+ }
677
+ function reminderSentence(session, waitedMs) {
678
+ return `${oneLine(sessionTitle(session))} ${reminderPhrase(session, waitedMs)}`;
679
+ }
680
+ function reminderNotice(session, waitedMs) {
681
+ const phrase = reminderPhrase(session, waitedMs);
682
+ const said = `${phrase.charAt(0).toUpperCase()}${phrase.slice(1)}`;
683
+ const asking = session.waitingText?.trim();
684
+ return { title: noticeTitle(session), body: asking ? `${said}: ${asking}` : said };
685
+ }
686
+ var WHILE_QUIET = "While quiet";
687
+ function summaryWaitedPhrase(item) {
688
+ const waited = waitedInWords(item.waitedMs);
689
+ return item.times === 1 ? `waited ${waited}` : `waited ${waited} over ${item.times} waits`;
690
+ }
691
+ function summaryItemPhrase(item, title = sessionTitle) {
692
+ const name = oneLine(title(item.session));
693
+ if (item.event !== "needs-you") return `${name} ${overPhrase(item.event)}`;
694
+ const waited = `${name} ${summaryWaitedPhrase(item)}`;
695
+ return item.then ? `${waited} then ${overPhrase(item.then.event)}` : waited;
696
+ }
697
+ function summaryLine(items, named = 4, title = sessionTitle) {
698
+ const shown = items.slice(0, named).map((item) => summaryItemPhrase(item, title));
699
+ const rest = items.length - shown.length;
700
+ if (rest === 0) {
701
+ return shown.length <= 1 ? shown[0] ?? "" : `${shown.slice(0, -1).join(", ")} and ${shown[shown.length - 1]}`;
702
+ }
703
+ return `${shown.join(", ")} and ${rest} more`;
704
+ }
705
+ function summaryNotice(items) {
706
+ return { title: WHILE_QUIET, body: summaryLine(items, void 0, noticeTitle) };
707
+ }
708
+
310
709
  // src/collector/outbound/outboundSettings.ts
311
710
  var DEFAULT_AFTER_SECONDS = 60;
312
711
  var MAX_AFTER_SECONDS = 86400;
@@ -439,6 +838,7 @@ function maskAddress(address) {
439
838
  }
440
839
 
441
840
  // src/collector/email/emailMessage.ts
841
+ var SENT_BY = `Sent by Agent Lookout on your computer. To stop these emails, start it again without ${EMAIL_TO_ENV}.`;
442
842
  function emailOf(happened, when, session, agentName2, asking = null) {
443
843
  const lines = [`${happened}.`, "", when, ""];
444
844
  const said = waitingText(asking);
@@ -449,10 +849,7 @@ function emailOf(happened, when, session, agentName2, asking = null) {
449
849
  if (app) lines.push(`App: ${app}`);
450
850
  const agent = agentName2 ? oneLine(agentName2) : "";
451
851
  if (agent) lines.push(`Agent: ${agent}`);
452
- lines.push(
453
- "",
454
- `Sent by Agent Lookout on your computer. To stop these emails, start it again without ${EMAIL_TO_ENV}.`
455
- );
852
+ lines.push("", SENT_BY);
456
853
  return { subject: happened, text: `${lines.join("\n")}
457
854
  ` };
458
855
  }
@@ -466,6 +863,42 @@ function waitEmail(facts) {
466
863
  facts.asking
467
864
  );
468
865
  }
866
+ function reminderEmail(facts) {
867
+ const { session, begunAt, now } = facts;
868
+ return emailOf(
869
+ 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.`,
871
+ session,
872
+ facts.agent,
873
+ facts.asking
874
+ );
875
+ }
876
+ var MAX_SUMMARY_LINES = 50;
877
+ function summaryItemLine(item, now) {
878
+ if (item.event !== "needs-you") return `${summaryItemPhrase(item)} at ${clockAt(item.at, now)}.`;
879
+ const waited = `${oneLine(sessionTitle(item.session))} ${summaryWaitedPhrase(item)}, from ${clockAt(item.at, now)}`;
880
+ return item.then ? `${waited}, then ${overPhrase(item.then.event)} at ${clockAt(item.then.at, now)}.` : `${waited}.`;
881
+ }
882
+ function summaryEmail(facts) {
883
+ const { items, now } = facts;
884
+ const lines = [
885
+ `During quiet hours, from ${clockAt(facts.from, now)} to ${clockAt(facts.to, now)}:`,
886
+ "",
887
+ ...items.slice(0, MAX_SUMMARY_LINES).map((item) => summaryItemLine(item, now))
888
+ ];
889
+ if (items.length > MAX_SUMMARY_LINES) lines.push(`And ${items.length - MAX_SUMMARY_LINES} more.`);
890
+ lines.push(
891
+ "",
892
+ "Quiet hours are set under Time rules in Settings. A session still waiting when they ended has an email of its own.",
893
+ "",
894
+ SENT_BY
895
+ );
896
+ return {
897
+ subject: oneLine(`${WHILE_QUIET}: ${summaryLine(items, 3)}`, 160),
898
+ text: `${lines.join("\n")}
899
+ `
900
+ };
901
+ }
469
902
  function overEmail(facts) {
470
903
  const { session, seenAt, now } = facts;
471
904
  return emailOf(
@@ -497,6 +930,8 @@ function createEmailNotifications(options) {
497
930
  asking: settings.asking,
498
931
  waitMessage: waitEmail,
499
932
  overMessage: overEmail,
933
+ reminderMessage: reminderEmail,
934
+ summaryMessage: summaryEmail,
500
935
  send: (content) => sender.send(content),
501
936
  failure: "the email could not be sent",
502
937
  now: options.now
@@ -523,7 +958,7 @@ function createEmailNotifications(options) {
523
958
  var HISTORY_FORMAT_VERSION = 1;
524
959
  var MAX_LINE_LENGTH = 8 * 1024;
525
960
  var MAX_FIELD_LENGTH = 1024;
526
- var DAY_MS = 24 * 60 * 60 * 1e3;
961
+ var DAY_MS2 = 24 * 60 * 60 * 1e3;
527
962
  var FILE_NAME = /^v([1-9]\d{0,3})-(\d{4})-(\d{2})-(\d{2})(?:-([1-9]\d{0,3}))?\.jsonl$/;
528
963
  function dayOf(at) {
529
964
  return new Date(at).toISOString().slice(0, 10);
@@ -545,7 +980,7 @@ function byFileOrder(a, b) {
545
980
  return a.dayStart - b.dayStart || a.part - b.part;
546
981
  }
547
982
  function dayEnd(file) {
548
- return file.dayStart + DAY_MS;
983
+ return file.dayStart + DAY_MS2;
549
984
  }
550
985
  function recordAt(record) {
551
986
  if (record.kind === "point") return record.point.at;
@@ -571,7 +1006,7 @@ function recordValue(record) {
571
1006
  }
572
1007
  }
573
1008
  function eventFields(event) {
574
- const { id, at, sessionId, sessionName: sessionName2, kind, from, to, severity, by } = event;
1009
+ const { id, at, sessionId, sessionName: sessionName2, kind, from, to, severity, by, decision } = event;
575
1010
  return {
576
1011
  id,
577
1012
  at,
@@ -581,10 +1016,11 @@ function eventFields(event) {
581
1016
  ...from !== void 0 && { from },
582
1017
  ...to !== void 0 && { to },
583
1018
  severity,
584
- ...by !== void 0 && { by }
1019
+ ...by !== void 0 && { by },
1020
+ ...decision !== void 0 && { decision }
585
1021
  };
586
1022
  }
587
- function isRecord(value) {
1023
+ function isRecord2(value) {
588
1024
  return typeof value === "object" && value !== null && !Array.isArray(value);
589
1025
  }
590
1026
  function moment(value) {
@@ -609,7 +1045,7 @@ function readPoint(value) {
609
1045
  return { at, needsYou, working, idle, total };
610
1046
  }
611
1047
  function readEvent(value) {
612
- if (!isRecord(value)) return null;
1048
+ if (!isRecord2(value)) return null;
613
1049
  const id = field(value.id);
614
1050
  const at = moment(value.at);
615
1051
  const sessionId = field(value.sessionId);
@@ -622,6 +1058,8 @@ function readEvent(value) {
622
1058
  if (value.from !== void 0 && !from || value.to !== void 0 && !to) return null;
623
1059
  const by = oneOf(EVENT_ACTORS, value.by);
624
1060
  if (value.by !== void 0 && !by) return null;
1061
+ const decision = oneOf(ANSWER_DECISIONS, value.decision);
1062
+ if (value.decision !== void 0 && !decision) return null;
625
1063
  return {
626
1064
  id,
627
1065
  at,
@@ -631,7 +1069,8 @@ function readEvent(value) {
631
1069
  ...from && { from },
632
1070
  ...to && { to },
633
1071
  severity,
634
- ...by && { by }
1072
+ ...by && { by },
1073
+ ...decision && { decision }
635
1074
  };
636
1075
  }
637
1076
  function parseRecord(line) {
@@ -642,7 +1081,7 @@ function parseRecord(line) {
642
1081
  } catch {
643
1082
  return null;
644
1083
  }
645
- if (!isRecord(value)) return null;
1084
+ if (!isRecord2(value)) return null;
646
1085
  if ("point" in value) {
647
1086
  const point = readPoint(value.point);
648
1087
  return point && { kind: "point", point };
@@ -846,6 +1285,12 @@ function withoutRepeats(events, reported) {
846
1285
  // src/collector/poller.ts
847
1286
  var POLL_INTERVAL_MS = 2e3;
848
1287
  var POLL_DEADLINE_MS = 15e3;
1288
+ function staleBy(sessions, at, thresholdMs) {
1289
+ return sessions.map((session) => {
1290
+ const stale = isStale(session, at, thresholdMs);
1291
+ return stale === session.stale ? session : { ...session, stale };
1292
+ });
1293
+ }
849
1294
  function declared(adapter) {
850
1295
  return adapter.capabilities === void 0 ? {} : { capabilities: adapter.capabilities };
851
1296
  }
@@ -875,6 +1320,14 @@ function createPoller(options) {
875
1320
  let inFlight = null;
876
1321
  let timer = null;
877
1322
  const memory = /* @__PURE__ */ new Map();
1323
+ function sourceOf(sessionId) {
1324
+ let found = null;
1325
+ for (const adapter of adapters) {
1326
+ if (!sessionId.startsWith(`${adapter.id}:`)) continue;
1327
+ if (found === null || adapter.id.length > found.length) found = adapter.id;
1328
+ }
1329
+ return found;
1330
+ }
878
1331
  const resumed = /* @__PURE__ */ new Map();
879
1332
  const overdue = /* @__PURE__ */ new Map();
880
1333
  const settled = /* @__PURE__ */ new Map();
@@ -977,6 +1430,7 @@ function createPoller(options) {
977
1430
  }
978
1431
  baselines.set(basis, result.sessions.map(withoutWaitingText));
979
1432
  });
1433
+ const rules = options.timeRules?.();
980
1434
  const snapshot = {
981
1435
  generatedAt: at,
982
1436
  // The way each was read goes with it, so what follows the snapshots
@@ -987,7 +1441,8 @@ function createPoller(options) {
987
1441
  ...result.basis !== void 0 && { basis: result.basis },
988
1442
  ...declared(adapters[index])
989
1443
  })),
990
- sessions: sortSessions(found)
1444
+ sessions: sortSessions(staleBy(found, at, staleAfterMs(rules))),
1445
+ ...rules && { timeRules: rules, quiet: isQuietAt(rules.quietHours, at) }
991
1446
  };
992
1447
  latest = snapshot;
993
1448
  events.add(changes);
@@ -1035,9 +1490,8 @@ function createPoller(options) {
1035
1490
  for (const event of newest2.values()) {
1036
1491
  const status = event.kind === "ended" ? null : event.to ?? null;
1037
1492
  if (event.kind !== "ended" && status === null) continue;
1038
- const colon = event.sessionId.indexOf(":");
1039
- if (colon <= 0) continue;
1040
- const source = event.sessionId.slice(0, colon);
1493
+ const source = sourceOf(event.sessionId);
1494
+ if (source === null) continue;
1041
1495
  const list = resumed.get(source) ?? [];
1042
1496
  list.push({ id: event.sessionId, name: event.sessionName, status });
1043
1497
  resumed.set(source, list);
@@ -1066,6 +1520,7 @@ function createPoller(options) {
1066
1520
  }
1067
1521
 
1068
1522
  // src/collector/webhook/webhookMessage.ts
1523
+ var MAX_SUMMARY_ITEMS = 100;
1069
1524
  function slackSafe(text4) {
1070
1525
  return text4.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/@/g, "@\u200B");
1071
1526
  }
@@ -1104,6 +1559,43 @@ function waitPost(facts) {
1104
1559
  waitedSeconds: Math.floor(waitedMs / 1e3)
1105
1560
  };
1106
1561
  }
1562
+ function reminderPost(facts) {
1563
+ const post = waitPost(facts);
1564
+ const asking = waitingText(facts.asking);
1565
+ const said = reminderSentence(facts.session, facts.now - facts.begunAt);
1566
+ return {
1567
+ ...post,
1568
+ text: lineOf(asking ? `${said}: ${asking}` : said, [
1569
+ post.session.folder,
1570
+ post.session.app,
1571
+ post.session.agent
1572
+ ]),
1573
+ reminder: true
1574
+ };
1575
+ }
1576
+ function summaryPost(facts) {
1577
+ const items = facts.items.slice(0, MAX_SUMMARY_ITEMS).map((item) => ({
1578
+ event: item.event,
1579
+ session: sessionOf(item.session, item.agent),
1580
+ at: new Date(item.at).toISOString(),
1581
+ ...item.event === "needs-you" && {
1582
+ waitedSeconds: Math.floor(item.waitedMs / 1e3),
1583
+ times: item.times,
1584
+ ...item.then && {
1585
+ then: { event: item.then.event, at: new Date(item.then.at).toISOString() }
1586
+ }
1587
+ }
1588
+ }));
1589
+ const more = facts.items.length - items.length;
1590
+ return {
1591
+ text: slackSafe(`${WHILE_QUIET}: ${summaryLine(facts.items)}`),
1592
+ event: "quiet-summary",
1593
+ from: new Date(facts.from).toISOString(),
1594
+ to: new Date(facts.to).toISOString(),
1595
+ items,
1596
+ ...more > 0 && { more }
1597
+ };
1598
+ }
1107
1599
  function overPost(facts) {
1108
1600
  const session = sessionOf(facts.session, facts.agent);
1109
1601
  return {
@@ -1139,6 +1631,8 @@ function createWebhookNotifications(options) {
1139
1631
  asking: settings.asking,
1140
1632
  waitMessage: waitPost,
1141
1633
  overMessage: overPost,
1634
+ reminderMessage: reminderPost,
1635
+ summaryMessage: summaryPost,
1142
1636
  send: (post) => sender.send(post),
1143
1637
  failure: "the post could not be sent",
1144
1638
  now: options.now
@@ -1167,6 +1661,7 @@ var JUMP_PATH = "/api/jump";
1167
1661
  var CLEAR_HISTORY_PATH = "/api/history/clear";
1168
1662
  var STOP_PATH = "/api/sessions/stop";
1169
1663
  var CLEAN_UP_PATH = "/api/sessions/clean-up";
1664
+ var ANSWER_PATH = "/api/permission/answer";
1170
1665
  var MAX_HISTORY_WINDOW_MS = HISTORY_CAPACITY * POLL_INTERVAL_MS;
1171
1666
  var LOOPBACK_HOST = /^(localhost|127\.0\.0\.1|\[::1\])(:\d{1,5})?$/i;
1172
1667
  var LOOPBACK_ORIGIN = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d{1,5})?$/i;
@@ -1294,6 +1789,9 @@ function createApiHandler(options) {
1294
1789
  if (options.clearHistory) actions.set(CLEAR_HISTORY_PATH, options.clearHistory);
1295
1790
  if (options.stop) actions.set(STOP_PATH, options.stop);
1296
1791
  if (options.cleanUp) actions.set(CLEAN_UP_PATH, options.cleanUp);
1792
+ if (options.answer) actions.set(ANSWER_PATH, options.answer);
1793
+ if (options.timeRules) actions.set(TIME_RULES_PATH, options.timeRules);
1794
+ const serveSnapshot = options.serveSnapshot ?? ((snapshot) => snapshot);
1297
1795
  function route(req, res) {
1298
1796
  const refusal2 = refusalFor(req);
1299
1797
  if (refusal2) {
@@ -1331,7 +1829,7 @@ function createApiHandler(options) {
1331
1829
  return;
1332
1830
  }
1333
1831
  case "/api/sessions": {
1334
- send(res, 200, poller.getSnapshot());
1832
+ send(res, 200, serveSnapshot(poller.getSnapshot()));
1335
1833
  return;
1336
1834
  }
1337
1835
  case "/api/events": {
@@ -1382,6 +1880,14 @@ function createApiHandler(options) {
1382
1880
  });
1383
1881
  return;
1384
1882
  }
1883
+ case SETTINGS_PATH: {
1884
+ if (options.settings) {
1885
+ send(res, 200, options.settings());
1886
+ return;
1887
+ }
1888
+ fail(res, 404, "There is nothing at that address.");
1889
+ return;
1890
+ }
1385
1891
  case "/api/waits": {
1386
1892
  if (options.waits) {
1387
1893
  send(res, 200, options.waits());
@@ -1599,9 +2105,9 @@ function createAppServer(options) {
1599
2105
  function portAnswers(host, port) {
1600
2106
  return new Promise((resolve) => {
1601
2107
  const socket = connect({ host, port });
1602
- const finish = (answers) => {
2108
+ const finish = (answers2) => {
1603
2109
  socket.destroy();
1604
- resolve(answers);
2110
+ resolve(answers2);
1605
2111
  };
1606
2112
  socket.setTimeout(1e3, () => finish(false));
1607
2113
  socket.once("connect", () => finish(true));
@@ -1630,7 +2136,7 @@ function urlFor(address) {
1630
2136
 
1631
2137
  // src/collector/hosts/standalone.ts
1632
2138
  import { existsSync } from "node:fs";
1633
- import path18 from "node:path";
2139
+ import path23 from "node:path";
1634
2140
 
1635
2141
  // src/core/mapping/claudeCodeMapping.ts
1636
2142
  var LIVE_STATUS = /* @__PURE__ */ new Map([
@@ -1657,10 +2163,10 @@ function liveStatusOf(status, origin) {
1657
2163
  return REGISTRY_LIVE_STATUS.get(status);
1658
2164
  }
1659
2165
  function mapClaudeCodeStatus(fields, origin = "feed") {
1660
- const live = liveStatusOf(fields.status, origin);
2166
+ const live2 = liveStatusOf(fields.status, origin);
1661
2167
  const background = typeof fields.state === "string" ? BACKGROUND_STATE.get(fields.state) : void 0;
1662
2168
  const backgroundSaysMore = background !== void 0 && background !== "working";
1663
- const status = live === "idle" && backgroundSaysMore ? background : live ?? background ?? "unknown";
2169
+ const status = live2 === "idle" && backgroundSaysMore ? background : live2 ?? background ?? "unknown";
1664
2170
  if (status !== "needs-you") return { status };
1665
2171
  return { status, ...mapClaudeCodeWaitingFor(fields.waitingFor) };
1666
2172
  }
@@ -1688,14 +2194,6 @@ function claudeCodeOpenLink(surface, sessionId) {
1688
2194
  return `vscode://anthropic.claude-code/open?session=${encodeURIComponent(sessionId)}`;
1689
2195
  }
1690
2196
 
1691
- // src/core/sessions/staleness.ts
1692
- var STALE_THRESHOLD_MS = 24 * 60 * 60 * 1e3;
1693
- function isStale(session, now, thresholdMs = STALE_THRESHOLD_MS) {
1694
- if (session.status !== "idle") return false;
1695
- if (session.statusSince === null) return false;
1696
- return now - session.statusSince >= thresholdMs;
1697
- }
1698
-
1699
2197
  // src/collector/terminal/terminalTabs.ts
1700
2198
  import path2 from "node:path";
1701
2199
  var TERMINAL_PATH = "/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal";
@@ -2752,9 +3250,9 @@ function targetFor(entry, answer, options) {
2752
3250
  }
2753
3251
  return null;
2754
3252
  }
2755
- function withStopOffers(sessions, live, answer, options) {
3253
+ function withStopOffers(sessions, live2, answer, options) {
2756
3254
  const byId = /* @__PURE__ */ new Map();
2757
- for (const entry of live) {
3255
+ for (const entry of live2) {
2758
3256
  if (entry.sessionId !== void 0) byId.set(`${SOURCE_ID}:${entry.sessionId}`, entry);
2759
3257
  }
2760
3258
  const targets = /* @__PURE__ */ new Map();
@@ -2828,7 +3326,7 @@ function isMissing(error) {
2828
3326
  }
2829
3327
 
2830
3328
  // src/collector/adapters/claude-code/transcript/lastAsk.ts
2831
- function isRecord2(value) {
3329
+ function isRecord3(value) {
2832
3330
  return typeof value === "object" && value !== null && !Array.isArray(value);
2833
3331
  }
2834
3332
  function nonEmpty(value) {
@@ -2836,8 +3334,8 @@ function nonEmpty(value) {
2836
3334
  }
2837
3335
  function blocksOf(line) {
2838
3336
  const message = line.message;
2839
- if (!isRecord2(message) || !Array.isArray(message.content)) return [];
2840
- return message.content.filter(isRecord2);
3337
+ if (!isRecord3(message) || !Array.isArray(message.content)) return [];
3338
+ return message.content.filter(isRecord3);
2841
3339
  }
2842
3340
  function linesOf(tail, fromStart) {
2843
3341
  const pieces = tail.split("\n");
@@ -2847,7 +3345,7 @@ function linesOf(tail, fromStart) {
2847
3345
  if (piece.trim() === "") continue;
2848
3346
  try {
2849
3347
  const value = JSON.parse(piece);
2850
- if (isRecord2(value)) lines.push(value);
3348
+ if (isRecord3(value)) lines.push(value);
2851
3349
  } catch {
2852
3350
  }
2853
3351
  }
@@ -2855,7 +3353,7 @@ function linesOf(tail, fromStart) {
2855
3353
  }
2856
3354
  function isPrompt(line) {
2857
3355
  if (line.isMeta === true) return false;
2858
- const message = isRecord2(line.message) ? line.message : null;
3356
+ const message = isRecord3(line.message) ? line.message : null;
2859
3357
  if (message === null) return false;
2860
3358
  if (typeof message.content === "string") return message.content.trim() !== "";
2861
3359
  return blocksOf(line).some((block) => block.type === "text");
@@ -2866,7 +3364,7 @@ function pendingToolUse(lines) {
2866
3364
  let movedOn = false;
2867
3365
  for (const line of lines) {
2868
3366
  if (line.isSidechain === true) continue;
2869
- const message = isRecord2(line.message) ? line.message : null;
3367
+ const message = isRecord3(line.message) ? line.message : null;
2870
3368
  if (line.type === "assistant") {
2871
3369
  const messageId = nonEmpty(message?.id);
2872
3370
  const newest3 = uses.at(-1);
@@ -2878,7 +3376,7 @@ function pendingToolUse(lines) {
2878
3376
  const id = nonEmpty(block.id);
2879
3377
  const name = nonEmpty(block.name);
2880
3378
  if (id === null || name === null) continue;
2881
- uses.push({ id, name, input: isRecord2(block.input) ? block.input : {}, messageId });
3379
+ uses.push({ id, name, input: isRecord3(block.input) ? block.input : {}, messageId });
2882
3380
  movedOn = false;
2883
3381
  }
2884
3382
  } else if (line.type === "user") {
@@ -2919,7 +3417,7 @@ function describeToolUse(name, input, cwd) {
2919
3417
  if (name === "AskUserQuestion") {
2920
3418
  const questions = Array.isArray(input.questions) ? input.questions : [];
2921
3419
  const first = questions[0];
2922
- const question = isRecord2(first) ? nonEmpty(first.question) : null;
3420
+ const question = isRecord3(first) ? nonEmpty(first.question) : null;
2923
3421
  if (question === null) return null;
2924
3422
  return questions.length > 1 ? `${question} (+${questions.length - 1} more)` : question;
2925
3423
  }
@@ -3112,15 +3610,23 @@ var CLAUDE_CODE_CAPABILITIES = {
3112
3610
  stop: {
3113
3611
  level: "partly",
3114
3612
  reason: "In a terminal, in VS Code and for background jobs. Not in the desktop app."
3613
+ },
3614
+ answer: {
3615
+ 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."
3115
3617
  }
3116
3618
  };
3117
3619
  var STOP_TURNED_OFF = {
3118
3620
  level: "no",
3119
3621
  reason: `${STOP_ENV} is off, so no session is stopped from Agent Lookout.`
3120
3622
  };
3623
+ var ANSWER_TURNED_OFF = {
3624
+ level: "no",
3625
+ reason: "AGENT_LOOKOUT_ANSWER is off, so no permission prompt is answered from Agent Lookout."
3626
+ };
3121
3627
  var COULD_NOT_RUN = "The claude agents --json command could not be run";
3122
- function capitalise(sentence) {
3123
- return sentence.charAt(0).toUpperCase() + sentence.slice(1);
3628
+ function capitalise(sentence2) {
3629
+ return sentence2.charAt(0).toUpperCase() + sentence2.slice(1);
3124
3630
  }
3125
3631
  function startedAfter(entry, since, now) {
3126
3632
  const startedAt = plausibleTime(entry.startedAt, now);
@@ -3144,7 +3650,12 @@ function createClaudeCodeAdapter(options = {}) {
3144
3650
  const feedWithheld = homeOverride !== void 0 && !binaryNamed;
3145
3651
  const neverRun = feedOff || feedWithheld;
3146
3652
  const stops = stopOff(env) ? void 0 : options.stops;
3147
- const capabilities = stopOff(env) ? { ...CLAUDE_CODE_CAPABILITIES, stop: STOP_TURNED_OFF } : CLAUDE_CODE_CAPABILITIES;
3653
+ const answersOff = env.AGENT_LOOKOUT_ANSWER?.trim().toLowerCase() === "off";
3654
+ const capabilities = stopOff(env) || answersOff ? {
3655
+ ...CLAUDE_CODE_CAPABILITIES,
3656
+ ...stopOff(env) && { stop: STOP_TURNED_OFF },
3657
+ ...answersOff && { answer: ANSWER_TURNED_OFF }
3658
+ } : CLAUDE_CODE_CAPABILITIES;
3148
3659
  const claudeHome = path9.resolve(homeOverride ?? path9.join(homeDir, ".claude"));
3149
3660
  const sessionsDir = path9.join(claudeHome, "sessions");
3150
3661
  const homeName = tildify(claudeHome, homeDir);
@@ -3181,17 +3692,17 @@ function createClaudeCodeAdapter(options = {}) {
3181
3692
  (entry) => entry.sessionId !== void 0 && isClaudeCodeSessionKind(entry.kind) && isAlive(entry.pid)
3182
3693
  );
3183
3694
  const reused = await startCheck.reused(candidates);
3184
- const live = candidates.filter((entry) => !reused.has(entry.pid));
3185
- const unknownStatus = live.some((entry) => {
3695
+ const live2 = candidates.filter((entry) => !reused.has(entry.pid));
3696
+ const unknownStatus = live2.some((entry) => {
3186
3697
  if (mapClaudeCodeStatus(entry, "registry").status !== "unknown") return false;
3187
3698
  const saysNothing = entry.status === void 0 && entry.state === void 0;
3188
3699
  return !(saysNothing && startedAfter(entry, at - NEW_SESSION_GRACE_MS, at));
3189
3700
  });
3190
- return { registry, live, unknownStatus };
3701
+ return { registry, live: live2, unknownStatus };
3191
3702
  }
3192
- function lacksOne(entries, live) {
3193
- const pids = new Set(live.map((entry) => entry.pid));
3194
- const sessionIds = new Set(live.map((entry) => entry.sessionId));
3703
+ function lacksOne(entries, live2) {
3704
+ const pids = new Set(live2.map((entry) => entry.pid));
3705
+ const sessionIds = new Set(live2.map((entry) => entry.sessionId));
3195
3706
  return entries.some(
3196
3707
  (entry) => entry.pid !== void 0 && !pids.has(entry.pid) && !(entry.sessionId !== void 0 && sessionIds.has(entry.sessionId)) && isAlive(entry.pid)
3197
3708
  );
@@ -3262,10 +3773,10 @@ function createClaudeCodeAdapter(options = {}) {
3262
3773
  const read = lastRead;
3263
3774
  const answer = read?.feed.ok ? read.feed.entries : null;
3264
3775
  const problem = problemWith(reading, read);
3265
- const { registry, live } = reading;
3776
+ const { registry, live: live2 } = reading;
3266
3777
  const offered = (result) => {
3267
3778
  if (!stops) return result;
3268
- const found = withStopOffers(result.sessions, live, answer, {
3779
+ const found = withStopOffers(result.sessions, live2, answer, {
3269
3780
  sessionsDir,
3270
3781
  background: !neverRun
3271
3782
  });
@@ -3273,7 +3784,7 @@ function createClaudeCodeAdapter(options = {}) {
3273
3784
  return { ...result, sessions: found.sessions };
3274
3785
  };
3275
3786
  if (options.panes || options.terminals) {
3276
- const pids = new Set(live.map((entry) => entry.pid));
3787
+ const pids = new Set(live2.map((entry) => entry.pid));
3277
3788
  for (const entry of answer ?? []) {
3278
3789
  if (entry.pid !== void 0 && isAlive(entry.pid)) pids.add(entry.pid);
3279
3790
  }
@@ -3298,11 +3809,11 @@ function createClaudeCodeAdapter(options = {}) {
3298
3809
  const listedPids = new Set(answer.map((entry) => entry.pid));
3299
3810
  const listedSessionIds = new Set(answer.map((entry) => entry.sessionId));
3300
3811
  const tooNewSince = read.askedAt - NEW_SESSION_GRACE_MS;
3301
- const fromRegistry = live.filter(
3812
+ const fromRegistry = live2.filter(
3302
3813
  (entry) => listedPids.has(entry.pid) || listedSessionIds.has(entry.sessionId) || startedAfter(entry, tooNewSince, checkedAt)
3303
3814
  );
3304
- const livePids = new Set(live.map((entry) => entry.pid));
3305
- const liveSessionIds = new Set(live.map((entry) => entry.sessionId));
3815
+ const livePids = new Set(live2.map((entry) => entry.pid));
3816
+ const liveSessionIds = new Set(live2.map((entry) => entry.sessionId));
3306
3817
  const jobs = answer.filter(
3307
3818
  (entry) => (entry.id !== void 0 || entry.state !== void 0) && !(entry.pid !== void 0 && livePids.has(entry.pid)) && !(entry.sessionId !== void 0 && liveSessionIds.has(entry.sessionId))
3308
3819
  ).map((entry) => {
@@ -3350,7 +3861,7 @@ function createClaudeCodeAdapter(options = {}) {
3350
3861
  const withheld = feedWithheld && !feedOff;
3351
3862
  const whyNoAnswer = feedOff ? offNote : withheld ? `${CLAUDE_HOME_ENV} is set, so the claude command is not run` : read !== null && !read.feed.ok ? read.feed.problem : COULD_NOT_RUN;
3352
3863
  if (registry.readable) {
3353
- const sessions = uniqueById(live.map((entry) => sessionFromRegistry(entry, context)));
3864
+ const sessions = uniqueById(live2.map((entry) => sessionFromRegistry(entry, context)));
3354
3865
  let detail2;
3355
3866
  if (problem === "unknown-status") {
3356
3867
  detail2 = `${whyNoAnswer}, and ${oddStatus}. Sessions are read from the registry alone, and that status is shown as unknown.`;
@@ -3671,7 +4182,7 @@ function createStopRoute(options) {
3671
4182
  // src/collector/actions/cleanUpRoute.ts
3672
4183
  var MAX_CLEAN_UP_BODY_BYTES = 8 * 1024;
3673
4184
  var MAX_SESSION_ID_LENGTH2 = 300;
3674
- function isRecord3(value) {
4185
+ function isRecord4(value) {
3675
4186
  return typeof value === "object" && value !== null && !Array.isArray(value);
3676
4187
  }
3677
4188
  function hasExactly(value, keys) {
@@ -3685,14 +4196,14 @@ function cleanUpEntriesIn(body) {
3685
4196
  } catch {
3686
4197
  return null;
3687
4198
  }
3688
- if (!isRecord3(value) || !hasExactly(value, ["sessions"])) return null;
4199
+ if (!isRecord4(value) || !hasExactly(value, ["sessions"])) return null;
3689
4200
  const { sessions } = value;
3690
4201
  if (!Array.isArray(sessions)) return null;
3691
4202
  if (sessions.length === 0 || sessions.length > MAX_CLEAN_UP_SESSIONS) return null;
3692
4203
  const entries = [];
3693
4204
  const seen = /* @__PURE__ */ new Set();
3694
4205
  for (const item of sessions) {
3695
- if (!isRecord3(item) || !hasExactly(item, ["sessionId", "statusSince"])) return null;
4206
+ if (!isRecord4(item) || !hasExactly(item, ["sessionId", "statusSince"])) return null;
3696
4207
  const { sessionId, statusSince } = item;
3697
4208
  if (typeof sessionId !== "string" || sessionId === "") return null;
3698
4209
  if (sessionId.length > MAX_SESSION_ID_LENGTH2 || seen.has(sessionId)) return null;
@@ -3704,12 +4215,12 @@ function cleanUpEntriesIn(body) {
3704
4215
  }
3705
4216
  return entries;
3706
4217
  }
3707
- function changedSince(entry, registry, listed, stopper) {
4218
+ function changedSince(entry, registry, listed, stopper, staleAfterMs2 = STALE_THRESHOLD_MS) {
3708
4219
  if (mapClaudeCodeStatus(registry, "registry").status !== "idle") return "became-active";
3709
4220
  if (registry.statusUpdatedAt !== entry.statusSince) return "became-active";
3710
4221
  if (listed.status !== "idle" || listed.statusSince !== entry.statusSince) return "became-active";
3711
4222
  if (listed.stale !== true) return "not-stale";
3712
- if (!isStale({ status: "idle", statusSince: entry.statusSince }, stopper.now())) {
4223
+ if (!isStale({ status: "idle", statusSince: entry.statusSince }, stopper.now(), staleAfterMs2)) {
3713
4224
  return "not-stale";
3714
4225
  }
3715
4226
  return null;
@@ -3736,7 +4247,13 @@ function createCleanUpRoute(options) {
3736
4247
  outcomes[index] = confirmed.reason;
3737
4248
  continue;
3738
4249
  }
3739
- const changed = changedSince(entry, confirmed.entry, session, stopper);
4250
+ const changed = changedSince(
4251
+ entry,
4252
+ confirmed.entry,
4253
+ session,
4254
+ stopper,
4255
+ options.staleAfterMs?.() ?? STALE_THRESHOLD_MS
4256
+ );
3740
4257
  if (changed !== null) {
3741
4258
  outcomes[index] = changed;
3742
4259
  continue;
@@ -3797,69 +4314,845 @@ function createCleanUpRoute(options) {
3797
4314
  };
3798
4315
  }
3799
4316
 
3800
- // src/core/waits/measured.ts
3801
- var POLL_GAP_MS = 8e3;
3802
- function pollRuns(times, starts, gapMs = POLL_GAP_MS) {
3803
- const ordered = [...times].sort((a, b) => a - b);
3804
- const breaks = [...starts].sort((a, b) => a - b);
3805
- const runs = [];
3806
- let next = 0;
3807
- for (const at of ordered) {
3808
- const run = runs[runs.length - 1];
3809
- let restarted = false;
3810
- while (next < breaks.length && breaks[next] <= at) {
3811
- if (run && breaks[next] > run.to) restarted = true;
3812
- next += 1;
3813
- }
3814
- if (run && !restarted && at - run.to <= gapMs) run.to = Math.max(run.to, at);
3815
- else runs.push({ from: at, to: at });
3816
- }
3817
- return runs;
4317
+ // src/collector/answers/answerRoute.ts
4318
+ var MAX_ANSWER_BODY_BYTES = 1024;
4319
+ var ANSWERS2 = {
4320
+ "no-ask": {
4321
+ status: 404,
4322
+ error: "Agent Lookout holds no such request of that session. It may have been answered."
4323
+ },
4324
+ "not-allowable": {
4325
+ status: 409,
4326
+ error: "Only Deny is offered for this request. Answer it in the session to allow it."
4327
+ },
4328
+ gone: {
4329
+ status: 409,
4330
+ error: "The session has moved on: it was answered there, or it is no longer waiting."
4331
+ },
4332
+ "too-soon": { status: 429, error: "That request is being answered." },
4333
+ failed: { status: 500, error: "The answer could not be handed to the session." }
4334
+ };
4335
+ function answerFailed(reason) {
4336
+ const { status, error } = ANSWERS2[reason];
4337
+ return { status, body: { error, reason } };
3818
4338
  }
3819
- function addPoll(runs, at, fresh = false, gapMs = POLL_GAP_MS) {
3820
- const run = runs[runs.length - 1];
3821
- if (run && !fresh && at >= run.to && at - run.to <= gapMs) {
3822
- run.to = at;
3823
- } else if (!run || fresh || at > run.to) {
3824
- runs.push({ from: at, to: at });
4339
+ var REQUEST_ID = /^[0-9a-f]{32}$/;
4340
+ function answerIn(text4) {
4341
+ let value;
4342
+ try {
4343
+ value = JSON.parse(text4);
4344
+ } catch {
4345
+ return null;
3825
4346
  }
4347
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
4348
+ const record = value;
4349
+ const keys = Object.keys(record).sort();
4350
+ if (keys.join() !== "decision,requestId,sessionId") return null;
4351
+ const { sessionId, requestId, decision } = record;
4352
+ if (typeof sessionId !== "string" || sessionId === "" || sessionId.length > 200) return null;
4353
+ if (typeof requestId !== "string" || !REQUEST_ID.test(requestId)) return null;
4354
+ if (!ANSWER_DECISIONS.includes(decision)) return null;
4355
+ return { sessionId, requestId, decision };
4356
+ }
4357
+ function answeredEvent(session, decision, at) {
4358
+ return {
4359
+ id: `${session.id}@${at}:answered`,
4360
+ at,
4361
+ sessionId: session.id,
4362
+ sessionName: session.name,
4363
+ kind: "answered",
4364
+ from: session.status,
4365
+ severity: "advisory",
4366
+ by: "agent-lookout",
4367
+ decision
4368
+ };
3826
4369
  }
3827
- function joined(spans) {
3828
- const ordered = spans.filter((span) => span.to >= span.from).sort((a, b) => a.from - b.from);
3829
- const out = [];
3830
- for (const span of ordered) {
3831
- const last = out[out.length - 1];
3832
- if (last && span.from <= last.to) last.to = Math.max(last.to, span.to);
3833
- else out.push({ ...span });
3834
- }
3835
- return out;
4370
+ function answerRefusalFor(req) {
4371
+ return actionRefusalFor(req, ANSWER_ACTION, MAX_ANSWER_BODY_BYTES);
3836
4372
  }
3837
- function coveredWithin(spans, from, to) {
3838
- if (to <= from) return 0;
3839
- let low = 0;
3840
- let high = spans.length;
3841
- while (low < high) {
3842
- const middle = low + high >> 1;
3843
- if (spans[middle].to <= from) low = middle + 1;
3844
- else high = middle;
4373
+ function createAnswerRoute(options) {
4374
+ const { poller, events, asks } = options;
4375
+ const now = options.now ?? Date.now;
4376
+ return async function answerPermission(req) {
4377
+ const refused = answerRefusalFor(req);
4378
+ if (refused) {
4379
+ req.resume();
4380
+ return refused;
4381
+ }
4382
+ const body = await readRequestBody(req, MAX_ANSWER_BODY_BYTES);
4383
+ if (!body.ok) {
4384
+ req.resume();
4385
+ return body.tooLarge ? refusal(413, `The body must be no more than ${MAX_ANSWER_BODY_BYTES} bytes.`) : refusal(400, "The body could not be read.");
4386
+ }
4387
+ const asked = answerIn(body.text);
4388
+ if (asked === null) {
4389
+ return refusal(
4390
+ 400,
4391
+ 'The body must be {"sessionId": "...", "requestId": "...", "decision": "allow" or "deny"} and nothing else.'
4392
+ );
4393
+ }
4394
+ const session = poller.getSnapshot().sessions.find((listed) => listed.id === asked.sessionId);
4395
+ if (!session) return answerFailed("no-ask");
4396
+ const outcome = await asks.answer(asked.sessionId, asked.requestId, asked.decision);
4397
+ if (outcome !== "answered") return answerFailed(outcome);
4398
+ events.add([answeredEvent(session, asked.decision, now())]);
4399
+ void poller.pollOnce().catch(() => {
4400
+ });
4401
+ return {
4402
+ status: 200,
4403
+ body: { ok: true, decision: asked.decision }
4404
+ };
4405
+ };
4406
+ }
4407
+
4408
+ // src/collector/answers/answerSettings.ts
4409
+ import os3 from "node:os";
4410
+ import path10 from "node:path";
4411
+ var ANSWER_ENV = "AGENT_LOOKOUT_ANSWER";
4412
+ var ANSWER_SOCKET_ENV = "AGENT_LOOKOUT_ANSWER_SOCKET";
4413
+ var ANSWER_WAIT_ENV = "AGENT_LOOKOUT_ANSWER_WAIT";
4414
+ var DEFAULT_HOLD_MS = 3e5;
4415
+ var MIN_HOLD_MS = 5e3;
4416
+ var MAX_HOLD_MS = 54e4;
4417
+ var MAX_SOCKET_PATH_BYTES = 103;
4418
+ function answerOff(env) {
4419
+ return env[ANSWER_ENV]?.trim().toLowerCase() === "off";
4420
+ }
4421
+ function holdMsFrom(value) {
4422
+ const text4 = value?.trim();
4423
+ if (text4 === void 0 || text4 === "") return DEFAULT_HOLD_MS;
4424
+ if (!/^\d{1,4}$/.test(text4)) return null;
4425
+ const ms = Number(text4) * 1e3;
4426
+ return ms < MIN_HOLD_MS || ms > MAX_HOLD_MS ? null : ms;
4427
+ }
4428
+ function readAnswerSetup(env, homeDir = os3.homedir(), platform = process.platform) {
4429
+ const holdMs = holdMsFrom(env[ANSWER_WAIT_ENV]);
4430
+ if (answerOff(env)) {
4431
+ return { on: false, socketPath: null, holdMs: holdMs ?? DEFAULT_HOLD_MS, problem: null };
4432
+ }
4433
+ if (platform === "win32") {
4434
+ return {
4435
+ on: false,
4436
+ socketPath: null,
4437
+ holdMs: holdMs ?? DEFAULT_HOLD_MS,
4438
+ problem: "Answering permission prompts is not offered on Windows."
4439
+ };
3845
4440
  }
3846
- let sum = 0;
3847
- for (let index = low; index < spans.length; index += 1) {
3848
- const span = spans[index];
3849
- if (span.from >= to) break;
3850
- sum += Math.min(span.to, to) - Math.max(span.from, from);
4441
+ if (holdMs === null) {
4442
+ return {
4443
+ on: false,
4444
+ socketPath: null,
4445
+ holdMs: DEFAULT_HOLD_MS,
4446
+ problem: `${ANSWER_WAIT_ENV} must be a number of seconds from ${MIN_HOLD_MS / 1e3} to ${MAX_HOLD_MS / 1e3}, so answering is off.`
4447
+ };
3851
4448
  }
3852
- return sum;
4449
+ const named = env[ANSWER_SOCKET_ENV]?.trim();
4450
+ if (!named && env[CLAUDE_HOME_ENV]?.trim()) {
4451
+ return {
4452
+ on: false,
4453
+ socketPath: null,
4454
+ holdMs,
4455
+ problem: `${CLAUDE_HOME_ENV} is set, so permission prompts are not answered. Set ${ANSWER_SOCKET_ENV} as well to answer them.`,
4456
+ quiet: true
4457
+ };
4458
+ }
4459
+ const ownDir = path10.resolve(homeDir, ".agent-lookout");
4460
+ const socketPath = path10.resolve(named || path10.join(ownDir, "answer.sock"));
4461
+ if (Buffer.byteLength(socketPath) > MAX_SOCKET_PATH_BYTES) {
4462
+ return {
4463
+ on: false,
4464
+ socketPath: null,
4465
+ holdMs,
4466
+ problem: `The path of the answer socket is longer than ${MAX_SOCKET_PATH_BYTES} bytes, which a socket cannot have, so answering is off. Set ${ANSWER_SOCKET_ENV} to a shorter path.`
4467
+ };
4468
+ }
4469
+ return {
4470
+ on: true,
4471
+ socketPath,
4472
+ holdMs,
4473
+ problem: null,
4474
+ ownFolder: path10.dirname(socketPath) === ownDir
4475
+ };
3853
4476
  }
3854
4477
 
3855
- // src/core/waits/waitTotals.ts
3856
- var MAX_WAIT_SESSIONS = 10;
3857
- var WAIT_DAYS = 7;
3858
- function isWaitEvent(event) {
3859
- return event.to === "needs-you" || event.from === "needs-you";
4478
+ // src/collector/answers/heldAsks.ts
4479
+ import { randomBytes } from "node:crypto";
4480
+
4481
+ // src/collector/answers/shownAsk.ts
4482
+ var MAX_SHOWN_CHARS = 4e3;
4483
+ var MAX_SHOWN_LINES = 40;
4484
+ var MAX_BLANK_RUN = 2;
4485
+ var MAX_INPUTS = 30;
4486
+ var MAX_TOOL_NAME_CHARS = 200;
4487
+ var MAX_DESCRIPTION_CHARS = 200;
4488
+ var EDIT_TOOLS = ["Edit", "Write", "MultiEdit", "NotebookEdit"];
4489
+ var NOT_YES_OR_NO_TOOLS = ["ExitPlanMode", "AskUserQuestion"];
4490
+ var EDIT_INPUTS = ["file_path", "notebook_path"];
4491
+ var HIDDEN = /[\p{Cc}\p{Cf}\p{Co}\p{Cn}\p{Cs}\p{Default_Ignorable_Code_Point}\u2028\u2029]/gu;
4492
+ 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;
4493
+ var BLANK_RUN = new RegExp(`\\n(?:[ \\t]*\\n){${MAX_BLANK_RUN + 1},}`);
4494
+ function isRecord5(value) {
4495
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3860
4496
  }
3861
- function unendedWaits(events) {
3862
- const newest2 = /* @__PURE__ */ new Map();
4497
+ function visible(text4) {
4498
+ let hidden = false;
4499
+ const shown = text4.replace(HIDDEN, (character) => {
4500
+ if (character === "\n" || character === " ") return character;
4501
+ hidden = true;
4502
+ return `\\u{${(character.codePointAt(0) ?? 0).toString(16)}}`;
4503
+ });
4504
+ return { text: shown, hidden };
4505
+ }
4506
+ function valueText(value) {
4507
+ if (typeof value === "string") return value;
4508
+ if (value === void 0) return "undefined";
4509
+ return JSON.stringify(value, null, 2) ?? String(value);
4510
+ }
4511
+ function budget() {
4512
+ let left = MAX_SHOWN_CHARS;
4513
+ let lines = 0;
4514
+ let cut2 = false;
4515
+ let hidden = false;
4516
+ let blankRun = false;
4517
+ return {
4518
+ /** Takes a text to show. A name, drawn on a line of its own beside its value, adds no lines. */
4519
+ take(text4, kind = "value") {
4520
+ const seen = visible(text4);
4521
+ if (seen.hidden) hidden = true;
4522
+ if (BLANK_RUN.test(`
4523
+ ${seen.text}
4524
+ `)) blankRun = true;
4525
+ if (kind === "value") lines += seen.text.split("\n").length;
4526
+ if (lines > MAX_SHOWN_LINES) cut2 = true;
4527
+ const characters = Array.from(seen.text);
4528
+ if (characters.length <= left) {
4529
+ left -= characters.length;
4530
+ return seen.text;
4531
+ }
4532
+ cut2 = true;
4533
+ const kept = characters.slice(0, Math.max(0, left)).join("");
4534
+ left = 0;
4535
+ return `${kept}\u2026`;
4536
+ },
4537
+ markCut() {
4538
+ cut2 = true;
4539
+ },
4540
+ get cut() {
4541
+ return cut2;
4542
+ },
4543
+ get hidden() {
4544
+ return hidden;
4545
+ },
4546
+ get blankRun() {
4547
+ return blankRun;
4548
+ }
4549
+ };
4550
+ }
4551
+ function inputsOf(input, keys, shown) {
4552
+ const listed = keys.slice(0, MAX_INPUTS);
4553
+ if (keys.length > MAX_INPUTS) shown.markCut();
4554
+ return listed.map((key) => ({
4555
+ name: shown.take(key, "name"),
4556
+ value: shown.take(valueText(input[key]))
4557
+ }));
4558
+ }
4559
+ function shownAsk(toolName, toolInput, agentId) {
4560
+ if (typeof toolName !== "string") return null;
4561
+ const tool = toolName.trim();
4562
+ if (tool === "" || Array.from(tool).length > MAX_TOOL_NAME_CHARS) return null;
4563
+ if (visible(tool).hidden || /\s/.test(tool)) return null;
4564
+ const input = toolInput === void 0 ? {} : toolInput;
4565
+ if (!isRecord5(input)) return null;
4566
+ const shown = budget();
4567
+ const subagent = typeof agentId === "string" && agentId !== "" ? { subagent: true } : {};
4568
+ let denyOnly;
4569
+ let rightToLeft = false;
4570
+ let ask;
4571
+ if (EDIT_TOOLS.includes(tool)) {
4572
+ const keys = EDIT_INPUTS.filter((key) => input[key] !== void 0);
4573
+ ask = { tool, inputs: inputsOf(input, keys, shown), allow: false };
4574
+ denyOnly = "edit";
4575
+ } else if (tool === "Bash" && typeof input.command === "string" && input.command.trim() !== "") {
4576
+ const command = shown.take(input.command);
4577
+ rightToLeft = RIGHT_TO_LEFT.test(input.command);
4578
+ const description = typeof input.description === "string" ? oneLine(input.description, MAX_DESCRIPTION_CHARS) || void 0 : void 0;
4579
+ const rest = Object.keys(input).filter((key) => key !== "command" && key !== "description");
4580
+ const inputs = inputsOf(input, rest, shown);
4581
+ ask = {
4582
+ tool,
4583
+ command,
4584
+ ...description !== void 0 && { description },
4585
+ ...inputs.length > 0 && { inputs },
4586
+ allow: false
4587
+ };
4588
+ } else {
4589
+ ask = { tool, inputs: inputsOf(input, Object.keys(input), shown), allow: false };
4590
+ if (NOT_YES_OR_NO_TOOLS.includes(tool)) denyOnly = "not-yes-or-no";
4591
+ }
4592
+ denyOnly ??= shown.hidden ? "hidden-characters" : rightToLeft ? "right-to-left" : shown.blankRun ? "blank-lines" : shown.cut ? "too-long" : void 0;
4593
+ return denyOnly === void 0 ? { ...ask, allow: true, ...subagent } : { ...ask, allow: false, denyOnly, ...subagent };
4594
+ }
4595
+
4596
+ // src/collector/answers/heldAsks.ts
4597
+ var ALLOW_OUTPUT = JSON.stringify({
4598
+ hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "allow" } }
4599
+ });
4600
+ var DENY_MESSAGE = "The person denied this from Agent Lookout. It was their choice, not a problem with a hook or a setting, so do not look for one. Do not try it again unless they ask.";
4601
+ var DENY_OUTPUT = JSON.stringify({
4602
+ hookSpecificOutput: {
4603
+ hookEventName: "PermissionRequest",
4604
+ decision: { behavior: "deny", message: DENY_MESSAGE }
4605
+ }
4606
+ });
4607
+ function decisionOutput(decision) {
4608
+ return decision === "allow" ? ALLOW_OUTPUT : DENY_OUTPUT;
4609
+ }
4610
+ var CHECK_EVERY_MS = 500;
4611
+ var GRACE_MS = 3e3;
4612
+ var MAX_HELD = 32;
4613
+ var MISSED_AFTER_MS = 5e3;
4614
+ var REMEMBER_REQUEST_MS = 6e4;
4615
+ var SOURCE = "claude-code";
4616
+ var SESSION_ID2 = /^[A-Za-z0-9][A-Za-z0-9-]{0,99}$/;
4617
+ function isRecord6(value) {
4618
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4619
+ }
4620
+ function readHookRequest(body) {
4621
+ let value;
4622
+ try {
4623
+ value = JSON.parse(body);
4624
+ } catch {
4625
+ return null;
4626
+ }
4627
+ if (!isRecord6(value) || value.hook_event_name !== "PermissionRequest") return null;
4628
+ const id = value.session_id;
4629
+ if (typeof id !== "string" || !SESSION_ID2.test(id)) return null;
4630
+ const shown = shownAsk(value.tool_name, value.tool_input, value.agent_id);
4631
+ return shown === null ? null : { sessionId: `${SOURCE}:${id}`, shown };
4632
+ }
4633
+ var realEvery = (run, ms) => {
4634
+ const timer = setInterval(run, ms);
4635
+ timer.unref?.();
4636
+ return () => clearInterval(timer);
4637
+ };
4638
+ function createHeldAsks(options) {
4639
+ const { status: reader, holdMs } = options;
4640
+ const now = options.now ?? Date.now;
4641
+ const every2 = options.every ?? realEvery;
4642
+ const newId = options.newId ?? (() => randomBytes(16).toString("hex"));
4643
+ const startedAt = now();
4644
+ const held = /* @__PURE__ */ new Map();
4645
+ const lastRequest = /* @__PURE__ */ new Map();
4646
+ let seenAt = null;
4647
+ let missedAt = null;
4648
+ let stopTimer = null;
4649
+ let inFlight = null;
4650
+ function drop(entry, reason) {
4651
+ if (held.get(entry.sessionId) === entry) held.delete(entry.sessionId);
4652
+ if (entry.reply.isOpen()) entry.reply.send("");
4653
+ options.onDrop?.(reason, entry.sessionId);
4654
+ if (held.size === 0 && stopTimer !== null) {
4655
+ stopTimer();
4656
+ stopTimer = null;
4657
+ }
4658
+ }
4659
+ async function read(sessionId) {
4660
+ try {
4661
+ return await reader.statusOf(sessionId);
4662
+ } catch {
4663
+ return { status: "unknown" };
4664
+ }
4665
+ }
4666
+ async function checkOne(entry, at) {
4667
+ if (entry.answering) return;
4668
+ if (!entry.reply.isOpen()) return drop(entry, "closed");
4669
+ if (at >= entry.until) return drop(entry, "limit");
4670
+ const reading = await read(entry.sessionId);
4671
+ if (held.get(entry.sessionId) !== entry || entry.answering) return;
4672
+ if (reading.status === "waiting") {
4673
+ if (!entry.confirmed) {
4674
+ entry.confirmed = true;
4675
+ entry.wait = reading.wait;
4676
+ return;
4677
+ }
4678
+ if (reading.wait !== entry.wait) drop(entry, "left-waiting");
4679
+ return;
4680
+ }
4681
+ if (entry.confirmed || at - entry.arrivedAt >= GRACE_MS) drop(entry, "left-waiting");
4682
+ }
4683
+ async function check() {
4684
+ while (inFlight !== null) await inFlight;
4685
+ const at = now();
4686
+ const run = Promise.all([...held.values()].map((entry) => checkOne(entry, at))).then(() => {
4687
+ });
4688
+ inFlight = run;
4689
+ try {
4690
+ await run;
4691
+ } finally {
4692
+ if (inFlight === run) inFlight = null;
4693
+ }
4694
+ }
4695
+ function status() {
4696
+ const plugin = seenAt !== null && (missedAt === null || seenAt > missedAt) ? "seen" : missedAt !== null ? "missed" : "unknown";
4697
+ return { state: "on", plugin, holdMs };
4698
+ }
4699
+ function toAsk(entry) {
4700
+ return { requestId: entry.requestId, ...entry.shown, until: entry.until };
4701
+ }
4702
+ return {
4703
+ receive(request2, reply) {
4704
+ const at = now();
4705
+ const older = held.get(request2.sessionId);
4706
+ if (older) drop(older, "newer");
4707
+ const entry = {
4708
+ requestId: newId(),
4709
+ sessionId: request2.sessionId,
4710
+ shown: request2.shown,
4711
+ arrivedAt: at,
4712
+ until: at + holdMs,
4713
+ reply,
4714
+ confirmed: false,
4715
+ wait: null,
4716
+ answering: false
4717
+ };
4718
+ if (held.size >= MAX_HELD) {
4719
+ drop(entry, "full");
4720
+ return;
4721
+ }
4722
+ held.set(request2.sessionId, entry);
4723
+ reply.onClose(() => {
4724
+ if (held.get(entry.sessionId) === entry && !entry.answering) drop(entry, "closed");
4725
+ });
4726
+ stopTimer ??= every2(() => {
4727
+ if (inFlight === null) void check();
4728
+ }, CHECK_EVERY_MS);
4729
+ void check();
4730
+ },
4731
+ 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
+ },
4756
+ withAsks(snapshot) {
4757
+ const answering = status();
4758
+ if (held.size === 0) return { ...snapshot, answering };
4759
+ const sessions = snapshot.sessions.map((session) => {
4760
+ const entry = held.get(session.id);
4761
+ if (!entry || !entry.confirmed || entry.answering || session.status !== "needs-you") {
4762
+ return session;
4763
+ }
4764
+ return { ...session, ask: toAsk(entry) };
4765
+ });
4766
+ return { ...snapshot, sessions, answering };
4767
+ },
4768
+ noteRequest(sessionId) {
4769
+ const at = now();
4770
+ seenAt = at;
4771
+ if (sessionId !== null) lastRequest.set(sessionId, at);
4772
+ },
4773
+ observe(snapshot) {
4774
+ const at = snapshot.generatedAt;
4775
+ const waitingIds = /* @__PURE__ */ new Set();
4776
+ for (const session of snapshot.sessions) {
4777
+ if (session.source !== SOURCE || session.status !== "needs-you") continue;
4778
+ if (session.waitingReason !== "permission" || session.alive === false) continue;
4779
+ waitingIds.add(session.id);
4780
+ const since = session.statusSince;
4781
+ if (since === null || since < startedAt + 1e3) continue;
4782
+ if (at - since < MISSED_AFTER_MS) continue;
4783
+ const last = lastRequest.get(session.id);
4784
+ if (last !== void 0 && last >= since - MISSED_AFTER_MS) continue;
4785
+ if (missedAt === null || since > missedAt) missedAt = since;
4786
+ }
4787
+ for (const [id, last] of lastRequest) {
4788
+ if (!waitingIds.has(id) && !held.has(id) && at - last > REMEMBER_REQUEST_MS) {
4789
+ lastRequest.delete(id);
4790
+ }
4791
+ }
4792
+ },
4793
+ status,
4794
+ dropAll() {
4795
+ for (const entry of [...held.values()]) drop(entry, "stopped");
4796
+ },
4797
+ get size() {
4798
+ return held.size;
4799
+ }
4800
+ };
4801
+ }
4802
+
4803
+ // src/collector/answers/hookSocket.ts
4804
+ import { chmod, lstat as lstat2, mkdir, stat as stat4, unlink } from "node:fs/promises";
4805
+ import http from "node:http";
4806
+ import net from "node:net";
4807
+ import path11 from "node:path";
4808
+ var HOOK_PATH = "/hooks/permission-request";
4809
+ var HOOK_HEADER = "x-agent-lookout-hook";
4810
+ var HOOK_HEADER_VALUE = "permission-request";
4811
+ var MAX_HOOK_BODY_BYTES = 1024 * 1024;
4812
+ var HOOK_BODY_TIMEOUT_MS = 1e4;
4813
+ var MAX_CONNECTIONS = 64;
4814
+ function errorCode2(error) {
4815
+ return error?.code;
4816
+ }
4817
+ function answers(socketPath) {
4818
+ return new Promise((resolve) => {
4819
+ const client = net.connect(socketPath);
4820
+ const done = (result) => {
4821
+ client.destroy();
4822
+ resolve(result);
4823
+ };
4824
+ client.setTimeout(1e3, () => done(true));
4825
+ client.once("connect", () => done(true));
4826
+ client.once("error", () => done(false));
4827
+ });
4828
+ }
4829
+ function sendNothing(res) {
4830
+ if (res.headersSent) {
4831
+ res.end();
4832
+ return;
4833
+ }
4834
+ res.writeHead(200, { "Content-Length": 0, Connection: "close" });
4835
+ res.end();
4836
+ }
4837
+ function header2(req, name) {
4838
+ const value = req.headers[name];
4839
+ return typeof value === "string" ? value : void 0;
4840
+ }
4841
+ function isHookRequest(req) {
4842
+ if (req.method !== "POST" || req.url !== HOOK_PATH) return false;
4843
+ if (req.headers.origin !== void 0) return false;
4844
+ if (header2(req, HOOK_HEADER) !== HOOK_HEADER_VALUE) return false;
4845
+ const type = header2(req, "content-type")?.split(";", 1)[0]?.trim().toLowerCase();
4846
+ return type === "application/json";
4847
+ }
4848
+ function replyTo(res) {
4849
+ let closed = false;
4850
+ res.once("close", () => {
4851
+ closed = true;
4852
+ });
4853
+ return {
4854
+ send(body) {
4855
+ if (closed || res.writableEnded) return;
4856
+ if (body === "") {
4857
+ sendNothing(res);
4858
+ return;
4859
+ }
4860
+ res.writeHead(200, {
4861
+ "Content-Type": "application/json",
4862
+ "Content-Length": Buffer.byteLength(body),
4863
+ Connection: "close"
4864
+ });
4865
+ res.end(body);
4866
+ },
4867
+ isOpen: () => !closed && !res.writableEnded && !res.destroyed,
4868
+ onClose(listener) {
4869
+ res.once("close", () => {
4870
+ if (!res.writableFinished) listener();
4871
+ });
4872
+ }
4873
+ };
4874
+ }
4875
+ function createHookSocket(options) {
4876
+ const { socketPath, asks } = options;
4877
+ const maxBody = options.maxBodyBytes ?? MAX_HOOK_BODY_BYTES;
4878
+ const bodyTimeoutMs = options.bodyTimeoutMs ?? HOOK_BODY_TIMEOUT_MS;
4879
+ const uid = options.uid ?? process.getuid?.() ?? -1;
4880
+ const folder = path11.dirname(socketPath);
4881
+ let server = null;
4882
+ let made = null;
4883
+ async function handle(req, res) {
4884
+ if (!isHookRequest(req)) {
4885
+ req.resume();
4886
+ sendNothing(res);
4887
+ return;
4888
+ }
4889
+ const length = header2(req, "content-length");
4890
+ if (length !== void 0 && (!/^\d+$/.test(length) || Number(length) > maxBody)) {
4891
+ req.resume();
4892
+ sendNothing(res);
4893
+ return;
4894
+ }
4895
+ const timer = setTimeout(() => req.destroy(), bodyTimeoutMs);
4896
+ const body = await readRequestBody(req, maxBody);
4897
+ clearTimeout(timer);
4898
+ if (!body.ok) {
4899
+ req.resume();
4900
+ sendNothing(res);
4901
+ return;
4902
+ }
4903
+ const request2 = readHookRequest(body.text);
4904
+ asks.noteRequest(request2?.sessionId ?? null);
4905
+ if (request2 === null) {
4906
+ sendNothing(res);
4907
+ return;
4908
+ }
4909
+ asks.receive(request2, replyTo(res));
4910
+ }
4911
+ async function readyFolder() {
4912
+ try {
4913
+ const madeHere = await mkdir(folder, { recursive: true, mode: 448 });
4914
+ const info = await lstat2(folder);
4915
+ if (!info.isDirectory()) return `${folder} is not a folder, so answering is off.`;
4916
+ if (uid >= 0 && info.uid !== uid) {
4917
+ return `${folder} belongs to another user, so answering is off.`;
4918
+ }
4919
+ if ((info.mode & 63) !== 0) {
4920
+ if (madeHere === void 0 && !options.ownFolder) {
4921
+ return `Other users can open ${folder}, so answering is off. Name a socket in a folder only you can open.`;
4922
+ }
4923
+ await chmod(folder, 448);
4924
+ }
4925
+ return null;
4926
+ } catch {
4927
+ return `The folder ${folder} could not be made ready, so answering is off.`;
4928
+ }
4929
+ }
4930
+ async function freePath() {
4931
+ let info;
4932
+ try {
4933
+ info = await lstat2(socketPath);
4934
+ } catch (error) {
4935
+ return errorCode2(error) === "ENOENT" ? null : `${socketPath} could not be read.`;
4936
+ }
4937
+ if (!info.isSocket())
4938
+ return `Something other than a socket is at ${socketPath}, so answering is off.`;
4939
+ if (await answers(socketPath)) {
4940
+ return "Another copy of Agent Lookout answers permission prompts on this computer.";
4941
+ }
4942
+ try {
4943
+ await unlink(socketPath);
4944
+ return null;
4945
+ } catch {
4946
+ return `The leftover socket at ${socketPath} could not be removed, so answering is off.`;
4947
+ }
4948
+ }
4949
+ return {
4950
+ async start() {
4951
+ if (server !== null) return { ok: true };
4952
+ const problem = await readyFolder() ?? await freePath();
4953
+ if (problem !== null) return { ok: false, problem };
4954
+ const listening = http.createServer(
4955
+ { requestTimeout: 0, headersTimeout: bodyTimeoutMs, keepAliveTimeout: 1e3 },
4956
+ (req, res) => {
4957
+ handle(req, res).catch(() => sendNothing(res));
4958
+ }
4959
+ );
4960
+ listening.maxConnections = MAX_CONNECTIONS;
4961
+ const listened = await new Promise((resolve) => {
4962
+ listening.once(
4963
+ "error",
4964
+ (error) => resolve(
4965
+ errorCode2(error) === "EADDRINUSE" ? "Another copy of Agent Lookout answers permission prompts on this computer." : `Agent Lookout could not listen at ${socketPath}, so answering is off.`
4966
+ )
4967
+ );
4968
+ listening.listen(socketPath, () => resolve(null));
4969
+ });
4970
+ if (listened !== null) return { ok: false, problem: listened };
4971
+ listening.on("error", () => {
4972
+ });
4973
+ try {
4974
+ await chmod(socketPath, 384);
4975
+ made = (await stat4(socketPath)).ino;
4976
+ } catch {
4977
+ listening.close();
4978
+ return { ok: false, problem: `The socket at ${socketPath} could not be made private.` };
4979
+ }
4980
+ server = listening;
4981
+ return { ok: true };
4982
+ },
4983
+ async close() {
4984
+ asks.dropAll();
4985
+ const closing = server;
4986
+ server = null;
4987
+ if (closing === null) return;
4988
+ await new Promise((resolve) => {
4989
+ closing.close(() => resolve());
4990
+ closing.closeAllConnections();
4991
+ });
4992
+ try {
4993
+ if (made !== null && (await stat4(socketPath)).ino === made) await unlink(socketPath);
4994
+ } catch {
4995
+ }
4996
+ made = null;
4997
+ }
4998
+ };
4999
+ }
5000
+
5001
+ // src/collector/answers/registryStatus.ts
5002
+ import os4 from "node:os";
5003
+ import path12 from "node:path";
5004
+ function createRegistryStatus(options) {
5005
+ const homeDir = options.homeDir ?? os4.homedir();
5006
+ 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");
5009
+ return {
5010
+ async statusOf(sessionId) {
5011
+ const unknown = { status: "unknown" };
5012
+ const session = options.snapshot().sessions.find((candidate) => candidate.id === sessionId);
5013
+ if (!session || session.source !== "claude-code" || session.alive === false) return unknown;
5014
+ const pid = session.pid;
5015
+ if (pid === void 0 || !Number.isSafeInteger(pid) || pid <= 1) return unknown;
5016
+ const ownId = sessionId.slice("claude-code:".length);
5017
+ let content;
5018
+ try {
5019
+ content = await readFile2(path12.join(sessionsDir, `${pid}.json`));
5020
+ } catch {
5021
+ return unknown;
5022
+ }
5023
+ const entry = parseRegistryEntry(content);
5024
+ if (entry === null || entry.pid !== pid || entry.sessionId !== ownId) return unknown;
5025
+ const forPermission = entry.status === "waiting" && mapClaudeCodeWaitingFor(entry.waitingFor).waitingReason === "permission";
5026
+ if (!forPermission) return { status: "not-waiting" };
5027
+ return {
5028
+ status: "waiting",
5029
+ wait: `${entry.statusUpdatedAt ?? ""}|${entry.waitingFor ?? ""}`
5030
+ };
5031
+ }
5032
+ };
5033
+ }
5034
+
5035
+ // src/collector/answers/answering.ts
5036
+ function createAnswering(options) {
5037
+ const { env, poller, events } = options;
5038
+ const setup = readAnswerSetup(env, options.homeDir, options.platform);
5039
+ if (!setup.on) {
5040
+ if (setup.problem !== null && !setup.quiet) options.warn?.(setup.problem);
5041
+ const off = setup.problem === null ? { state: "off", plugin: "unknown", holdMs: setup.holdMs } : { state: "unavailable", plugin: "unknown", problem: setup.problem, holdMs: setup.holdMs };
5042
+ return {
5043
+ start: async () => {
5044
+ },
5045
+ stop: async () => {
5046
+ },
5047
+ serve: (snapshot) => ({ ...snapshot, answering: off }),
5048
+ observe: () => {
5049
+ }
5050
+ };
5051
+ }
5052
+ const asks = createHeldAsks({
5053
+ status: options.status ?? createRegistryStatus({ snapshot: poller.getSnapshot, env }),
5054
+ holdMs: setup.holdMs,
5055
+ now: options.now,
5056
+ every: options.every
5057
+ });
5058
+ const socket = createHookSocket({
5059
+ socketPath: setup.socketPath,
5060
+ asks,
5061
+ ownFolder: setup.ownFolder
5062
+ });
5063
+ let problem = null;
5064
+ let listening = false;
5065
+ return {
5066
+ async start() {
5067
+ const started = await socket.start();
5068
+ listening = started.ok;
5069
+ problem = started.ok ? null : started.problem;
5070
+ if (problem !== null) options.warn?.(problem);
5071
+ },
5072
+ async stop() {
5073
+ listening = false;
5074
+ await socket.close();
5075
+ },
5076
+ serve(snapshot) {
5077
+ if (problem !== null) {
5078
+ return {
5079
+ ...snapshot,
5080
+ answering: { state: "unavailable", plugin: "unknown", holdMs: setup.holdMs, problem }
5081
+ };
5082
+ }
5083
+ return asks.withAsks(snapshot);
5084
+ },
5085
+ observe(snapshot) {
5086
+ if (listening) asks.observe(snapshot);
5087
+ },
5088
+ route: createAnswerRoute({ poller, events, asks, now: options.now })
5089
+ };
5090
+ }
5091
+
5092
+ // src/core/waits/measured.ts
5093
+ var POLL_GAP_MS = 8e3;
5094
+ function pollRuns(times, starts, gapMs = POLL_GAP_MS) {
5095
+ const ordered = [...times].sort((a, b) => a - b);
5096
+ const breaks = [...starts].sort((a, b) => a - b);
5097
+ const runs = [];
5098
+ let next = 0;
5099
+ for (const at of ordered) {
5100
+ const run = runs[runs.length - 1];
5101
+ let restarted = false;
5102
+ while (next < breaks.length && breaks[next] <= at) {
5103
+ if (run && breaks[next] > run.to) restarted = true;
5104
+ next += 1;
5105
+ }
5106
+ if (run && !restarted && at - run.to <= gapMs) run.to = Math.max(run.to, at);
5107
+ else runs.push({ from: at, to: at });
5108
+ }
5109
+ return runs;
5110
+ }
5111
+ function addPoll(runs, at, fresh = false, gapMs = POLL_GAP_MS) {
5112
+ const run = runs[runs.length - 1];
5113
+ if (run && !fresh && at >= run.to && at - run.to <= gapMs) {
5114
+ run.to = at;
5115
+ } else if (!run || fresh || at > run.to) {
5116
+ runs.push({ from: at, to: at });
5117
+ }
5118
+ }
5119
+ function joined(spans) {
5120
+ const ordered = spans.filter((span) => span.to >= span.from).sort((a, b) => a.from - b.from);
5121
+ const out = [];
5122
+ for (const span of ordered) {
5123
+ const last = out[out.length - 1];
5124
+ if (last && span.from <= last.to) last.to = Math.max(last.to, span.to);
5125
+ else out.push({ ...span });
5126
+ }
5127
+ return out;
5128
+ }
5129
+ function coveredWithin(spans, from, to) {
5130
+ if (to <= from) return 0;
5131
+ let low = 0;
5132
+ let high = spans.length;
5133
+ while (low < high) {
5134
+ const middle = low + high >> 1;
5135
+ if (spans[middle].to <= from) low = middle + 1;
5136
+ else high = middle;
5137
+ }
5138
+ let sum = 0;
5139
+ for (let index = low; index < spans.length; index += 1) {
5140
+ const span = spans[index];
5141
+ if (span.from >= to) break;
5142
+ sum += Math.min(span.to, to) - Math.max(span.from, from);
5143
+ }
5144
+ return sum;
5145
+ }
5146
+
5147
+ // src/core/waits/waitTotals.ts
5148
+ var MAX_WAIT_SESSIONS = 10;
5149
+ var WAIT_DAYS = 7;
5150
+ function isWaitEvent(event) {
5151
+ if (!isStatusEvent(event)) return false;
5152
+ return event.to === "needs-you" || event.from === "needs-you";
5153
+ }
5154
+ function unendedWaits(events) {
5155
+ const newest2 = /* @__PURE__ */ new Map();
3863
5156
  for (const event of events) {
3864
5157
  if (!isWaitEvent(event)) continue;
3865
5158
  const held = newest2.get(event.sessionId);
@@ -4020,14 +5313,14 @@ function waitTotals(input) {
4020
5313
  }
4021
5314
 
4022
5315
  // src/collector/adapters/codex/index.ts
4023
- import os3 from "node:os";
4024
- import path11 from "node:path";
5316
+ import os5 from "node:os";
5317
+ import path14 from "node:path";
4025
5318
 
4026
5319
  // src/core/mapping/codexMapping.ts
4027
5320
  var TURN_UNDER_WAY = /* @__PURE__ */ new Set(["task_started", "turn_started"]);
4028
5321
  var TURN_OVER = /* @__PURE__ */ new Set(["task_complete", "turn_complete", "turn_aborted"]);
4029
5322
  function mapCodexStatus(fields) {
4030
- const { lastTurn, live } = fields;
5323
+ const { lastTurn, live: live2 } = fields;
4031
5324
  let open4;
4032
5325
  if (lastTurn === null) {
4033
5326
  open4 = "idle";
@@ -4040,7 +5333,7 @@ function mapCodexStatus(fields) {
4040
5333
  } else {
4041
5334
  return "unknown";
4042
5335
  }
4043
- return live === false ? "finished" : open4;
5336
+ return live2 === false ? "finished" : open4;
4044
5337
  }
4045
5338
  var WRITER_LOCKS_SINCE = [0, 155, 0];
4046
5339
  var VERSION = /^(\d{1,6})\.(\d{1,6})\.(\d{1,6})(-[0-9A-Za-z.-]{1,64})?(?:\+[0-9A-Za-z.-]{1,64})?$/;
@@ -4064,7 +5357,7 @@ var IMPORTED_TURN_ID = /^external-import-turn-\d{1,9}$/;
4064
5357
  function isImportedCodexTurn(turnId) {
4065
5358
  return typeof turnId === "string" && IMPORTED_TURN_ID.test(turnId);
4066
5359
  }
4067
- function isRecord4(value) {
5360
+ function isRecord7(value) {
4068
5361
  return typeof value === "object" && value !== null && !Array.isArray(value);
4069
5362
  }
4070
5363
  var CODEX_DESKTOP_ORIGINATOR = "Codex Desktop";
@@ -4072,7 +5365,7 @@ function mapCodexSurface(source, originator) {
4072
5365
  if (originator === CODEX_DESKTOP_ORIGINATOR) return "desktop";
4073
5366
  if (source === "cli" || source === "exec") return "terminal";
4074
5367
  if (source === "vscode") return "vscode";
4075
- if (isRecord4(source) && source.custom === "chatgpt") return "desktop";
5368
+ if (isRecord7(source) && source.custom === "chatgpt") return "desktop";
4076
5369
  return "unknown";
4077
5370
  }
4078
5371
  var NOT_A_SESSION_SOURCE = /* @__PURE__ */ new Set(["subagent", "internal", "mcp"]);
@@ -4083,7 +5376,7 @@ function isCodexSessionSource(source, meta = {}) {
4083
5376
  return false;
4084
5377
  }
4085
5378
  if (typeof source === "string") return !NOT_A_SESSION_SOURCE.has(source);
4086
- if (isRecord4(source)) return !Object.keys(source).some((kind) => NOT_A_SESSION_SOURCE.has(kind));
5379
+ if (isRecord7(source)) return !Object.keys(source).some((kind) => NOT_A_SESSION_SOURCE.has(kind));
4087
5380
  return true;
4088
5381
  }
4089
5382
 
@@ -4102,7 +5395,7 @@ function parseCodexTime(value) {
4102
5395
  var LINE_TIME = /^\{"timestamp":"([^"]{1,64})"/;
4103
5396
  var TURN_HINT = /"type":"(?:task|turn)_/;
4104
5397
  var TURN_TYPE = /^(?:task|turn)_/;
4105
- function isRecord5(value) {
5398
+ function isRecord8(value) {
4106
5399
  return typeof value === "object" && value !== null && !Array.isArray(value);
4107
5400
  }
4108
5401
  function text3(value) {
@@ -4113,7 +5406,7 @@ function label(value) {
4113
5406
  }
4114
5407
  function compactSource(value) {
4115
5408
  if (typeof value === "string") return text3(value);
4116
- if (!isRecord5(value)) return void 0;
5409
+ if (!isRecord8(value)) return void 0;
4117
5410
  return Object.fromEntries(
4118
5411
  Object.entries(value).map(([kind, detail]) => [
4119
5412
  kind,
@@ -4124,7 +5417,7 @@ function compactSource(value) {
4124
5417
  function parse(line) {
4125
5418
  try {
4126
5419
  const value = JSON.parse(line);
4127
- return isRecord5(value) ? value : null;
5420
+ return isRecord8(value) ? value : null;
4128
5421
  } catch {
4129
5422
  return null;
4130
5423
  }
@@ -4132,7 +5425,7 @@ function parse(line) {
4132
5425
  function readMetaLine(line) {
4133
5426
  if (!line.includes('"session_meta"')) return null;
4134
5427
  const value = parse(line);
4135
- if (!value || value.type !== "session_meta" || !isRecord5(value.payload)) return null;
5428
+ if (!value || value.type !== "session_meta" || !isRecord8(value.payload)) return null;
4136
5429
  const payload = value.payload;
4137
5430
  const meta = {
4138
5431
  id: text3(payload.id),
@@ -4152,7 +5445,7 @@ function readMetaLine(line) {
4152
5445
  function readTurnLine(line) {
4153
5446
  if (!line.includes('"event_msg"') || !TURN_HINT.test(line)) return null;
4154
5447
  const value = parse(line);
4155
- if (!value || value.type !== "event_msg" || !isRecord5(value.payload)) return null;
5448
+ if (!value || value.type !== "event_msg" || !isRecord8(value.payload)) return null;
4156
5449
  const turn = value.payload.type;
4157
5450
  if (typeof turn !== "string" || !TURN_TYPE.test(turn)) return null;
4158
5451
  return {
@@ -4192,7 +5485,7 @@ async function readHead(file, size) {
4192
5485
  }
4193
5486
  async function scanTail(file, size) {
4194
5487
  let position = size;
4195
- let budget = TAIL_LIMIT_BYTES;
5488
+ let budget2 = TAIL_LIMIT_BYTES;
4196
5489
  let fragment = Buffer.alloc(0);
4197
5490
  let end = null;
4198
5491
  let lastLineAt = null;
@@ -4209,11 +5502,11 @@ async function scanTail(file, size) {
4209
5502
  lastLineAt,
4210
5503
  end
4211
5504
  });
4212
- while (position > 0 && budget > 0) {
4213
- const length = Math.min(TAIL_CHUNK_BYTES, position, budget);
5505
+ while (position > 0 && budget2 > 0) {
5506
+ const length = Math.min(TAIL_CHUNK_BYTES, position, budget2);
4214
5507
  const start = position - length;
4215
5508
  const chunk = await readExactly(file, start, length);
4216
- budget -= length;
5509
+ budget2 -= length;
4217
5510
  position = start;
4218
5511
  const buffer = fragment.byteLength > 0 ? Buffer.concat([chunk, fragment]) : chunk;
4219
5512
  let cut2 = buffer.byteLength;
@@ -4338,7 +5631,7 @@ function createRolloutReader(io) {
4338
5631
  }
4339
5632
 
4340
5633
  // src/collector/adapters/codex/rollouts.ts
4341
- import path10 from "node:path";
5634
+ import path13 from "node:path";
4342
5635
  var UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
4343
5636
  var ROLLOUT_FILE_NAME = new RegExp(
4344
5637
  `^rollout-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-(${UUID})(?:_(${UUID}))?\\.jsonl$`,
@@ -4356,7 +5649,7 @@ function twoDigits(value) {
4356
5649
  function recentDayFolders(sessionsDir, now) {
4357
5650
  const folders = [now, now - 24 * 60 * 60 * 1e3].map((at) => {
4358
5651
  const date = new Date(at);
4359
- return path10.join(
5652
+ return path13.join(
4360
5653
  sessionsDir,
4361
5654
  String(date.getFullYear()),
4362
5655
  twoDigits(date.getMonth() + 1),
@@ -4385,12 +5678,12 @@ function createRolloutFinder(options) {
4385
5678
  const refs = [];
4386
5679
  for (const name of (await names(dir)).sort()) {
4387
5680
  const threadId = threadIdOfRollout(name);
4388
- if (threadId !== null) refs.push({ path: path10.join(dir, name), threadId });
5681
+ if (threadId !== null) refs.push({ path: path13.join(dir, name), threadId });
4389
5682
  }
4390
5683
  return refs;
4391
5684
  }
4392
5685
  async function subfolders(dir, pattern) {
4393
- return (await names(dir)).filter((name) => pattern.test(name)).sort().map((name) => path10.join(dir, name));
5686
+ return (await names(dir)).filter((name) => pattern.test(name)).sort().map((name) => path13.join(dir, name));
4394
5687
  }
4395
5688
  async function buildIndex() {
4396
5689
  const years = await subfolders(sessionsDir, YEAR);
@@ -4434,7 +5727,7 @@ function createRolloutFinder(options) {
4434
5727
  // src/collector/adapters/codex/sessionIndex.ts
4435
5728
  var SESSION_INDEX_FILE = "session_index.jsonl";
4436
5729
  var SESSION_INDEX_LIMIT_BYTES = 4 * 1024 * 1024;
4437
- function isRecord6(value) {
5730
+ function isRecord9(value) {
4438
5731
  return typeof value === "object" && value !== null && !Array.isArray(value);
4439
5732
  }
4440
5733
  function parseSessionIndex(content) {
@@ -4447,7 +5740,7 @@ function parseSessionIndex(content) {
4447
5740
  } catch {
4448
5741
  continue;
4449
5742
  }
4450
- if (!isRecord6(value)) continue;
5743
+ if (!isRecord9(value)) continue;
4451
5744
  const { id, thread_name: name } = value;
4452
5745
  if (typeof id !== "string" || id === "" || typeof name !== "string") continue;
4453
5746
  const trimmed = name.trim();
@@ -4504,11 +5797,11 @@ function lastActivity(state, now) {
4504
5797
  return plausibleTime(state.lastLineAt, now) ?? plausibleTime(state.lastTurnAt, now) ?? plausibleTime(parseCodexTime(state.meta?.timestamp), now);
4505
5798
  }
4506
5799
  function codexSession(input) {
4507
- const { threadId, state, live, now } = input;
5800
+ const { threadId, state, live: live2, now } = input;
4508
5801
  const id = `${SOURCE_ID2}:${threadId}`;
4509
5802
  const cwd = state.meta?.cwd ?? null;
4510
5803
  const project = projectOf(cwd);
4511
- const status = mapCodexStatus({ lastTurn: state.lastTurn, live });
5804
+ const status = mapCodexStatus({ lastTurn: state.lastTurn, live: live2 });
4512
5805
  const startedAt = plausibleTime(parseCodexTime(state.meta?.timestamp), now);
4513
5806
  let statusSince = null;
4514
5807
  if (status === "working" || status === "idle" && state.lastTurn !== null) {
@@ -4593,11 +5886,15 @@ var CODEX_CAPABILITIES = {
4593
5886
  stop: {
4594
5887
  level: "no",
4595
5888
  reason: "Codex's files name no process that Agent Lookout could confirm and stop."
4596
- }
5889
+ },
5890
+ answer: {
5891
+ level: "no",
5892
+ reason: "Codex records no approval waits, so there is nothing to answer from here."
5893
+ }
4597
5894
  };
4598
5895
  function createCodexAdapter(options = {}) {
4599
5896
  const env = options.env ?? process.env;
4600
- const homeDir = options.homeDir ?? os3.homedir();
5897
+ const homeDir = options.homeDir ?? os5.homedir();
4601
5898
  const now = options.now ?? Date.now;
4602
5899
  const io = options.io ?? nodeIo2;
4603
5900
  const pollIntervalMs = options.pollIntervalMs ?? POLL_INTERVAL_MS;
@@ -4605,10 +5902,10 @@ function createCodexAdapter(options = {}) {
4605
5902
  const ownOverride = env[CODEX_HOME_ENV]?.trim() || void 0;
4606
5903
  const codexOverride = env[CODEX_OWN_HOME_ENV]?.trim() || void 0;
4607
5904
  const homeFrom = ownOverride ? "agent-lookout" : codexOverride ? "codex" : "default";
4608
- const codexHome = path11.resolve(ownOverride ?? codexOverride ?? path11.join(homeDir, ".codex"));
4609
- const sessionsDir = path11.join(codexHome, "sessions");
4610
- const locksDir = path11.join(codexHome, WRITER_LOCK_DIR);
4611
- const indexFile = path11.join(codexHome, SESSION_INDEX_FILE);
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);
4612
5909
  const homeName = tildify(codexHome, homeDir);
4613
5910
  const sessionsName = tildify(sessionsDir, homeDir);
4614
5911
  const locksName = tildify(locksDir, homeDir);
@@ -4769,17 +6066,17 @@ function createCodexAdapter(options = {}) {
4769
6066
  reader.keepOnly(read);
4770
6067
  let olderCodex = false;
4771
6068
  const sessions = [...chosen.entries()].map(([threadId, { state, writtenAt }]) => {
4772
- const live = codexLiveness({
6069
+ const live2 = codexLiveness({
4773
6070
  lockFolder: locks.supported,
4774
6071
  locked: locked(threadId),
4775
6072
  cliVersion: state.meta?.cliVersion
4776
6073
  });
4777
- if (locks.supported && live === "unknown") olderCodex = true;
6074
+ if (locks.supported && live2 === "unknown") olderCodex = true;
4778
6075
  return codexSession({
4779
6076
  threadId,
4780
6077
  state,
4781
6078
  name: names.get(threadId),
4782
- live,
6079
+ live: live2,
4783
6080
  writtenAt: Math.max(writtenAt, subagentWrites.get(threadId) ?? -Infinity),
4784
6081
  now: checkedAt
4785
6082
  });
@@ -4815,8 +6112,8 @@ function createCodexAdapter(options = {}) {
4815
6112
  }
4816
6113
 
4817
6114
  // src/collector/adapters/status-files/index.ts
4818
- import os4 from "node:os";
4819
- import path12 from "node:path";
6115
+ import os6 from "node:os";
6116
+ import path15 from "node:path";
4820
6117
 
4821
6118
  // src/core/mapping/statusFileMapping.ts
4822
6119
  var STATUS = /* @__PURE__ */ new Map([
@@ -4948,6 +6245,10 @@ var STATUS_FILE_CAPABILITIES = {
4948
6245
  stop: {
4949
6246
  level: "no",
4950
6247
  reason: "Any program can write a status file, so nothing in one is used to stop a session."
6248
+ },
6249
+ answer: {
6250
+ level: "no",
6251
+ reason: "A status file only says a session waits, and holds nothing to answer it through."
4951
6252
  }
4952
6253
  };
4953
6254
  function counted(count2, one, many) {
@@ -4955,13 +6256,13 @@ function counted(count2, one, many) {
4955
6256
  }
4956
6257
  function createStatusFileAdapter(options = {}) {
4957
6258
  const env = options.env ?? process.env;
4958
- const homeDir = options.homeDir ?? os4.homedir();
6259
+ const homeDir = options.homeDir ?? os6.homedir();
4959
6260
  const now = options.now ?? Date.now;
4960
6261
  const io = options.io ?? nodeIo2;
4961
6262
  const isAlive = options.isAlive ?? isProcessAlive;
4962
6263
  const pollIntervalMs = options.pollIntervalMs ?? POLL_INTERVAL_MS;
4963
6264
  const override = env[STATUS_DIR_ENV]?.trim() || void 0;
4964
- const dir = path12.resolve(override ?? path12.join(homeDir, ".agent-lookout", "sessions"));
6265
+ const dir = path15.resolve(override ?? path15.join(homeDir, ".agent-lookout", "sessions"));
4965
6266
  const dirName = tildify(dir, homeDir);
4966
6267
  const decoder2 = new TextDecoder();
4967
6268
  let lastRead = /* @__PURE__ */ new Map();
@@ -5021,7 +6322,7 @@ function createStatusFileAdapter(options = {}) {
5021
6322
  if (names.length <= MAX_FILES) return names;
5022
6323
  const written = await Promise.all(
5023
6324
  names.map(async (name) => {
5024
- const info = await io.lstat(path12.join(dir, name)).catch(() => null);
6325
+ const info = await io.lstat(path15.join(dir, name)).catch(() => null);
5025
6326
  return { name, mtimeMs: info?.mtimeMs ?? -Infinity };
5026
6327
  })
5027
6328
  );
@@ -5071,7 +6372,7 @@ function createStatusFileAdapter(options = {}) {
5071
6372
  const nextRead = /* @__PURE__ */ new Map();
5072
6373
  const nextStatuses = /* @__PURE__ */ new Map();
5073
6374
  for (const fileName2 of await toRead(listed)) {
5074
- const reading = await readOne(path12.join(dir, fileName2));
6375
+ const reading = await readOne(path15.join(dir, fileName2));
5075
6376
  let kept;
5076
6377
  if (reading.kind === "read") {
5077
6378
  kept = reading;
@@ -5296,8 +6597,8 @@ function createEventStore(capacity = EVENT_CAPACITY) {
5296
6597
  }
5297
6598
 
5298
6599
  // src/collector/git/branchFinder.ts
5299
- import os5 from "node:os";
5300
- import path14 from "node:path";
6600
+ import os7 from "node:os";
6601
+ import path17 from "node:path";
5301
6602
 
5302
6603
  // src/collector/git/gitHead.ts
5303
6604
  var MAX_GIT_FILE_BYTES = 4 * 1024;
@@ -5337,18 +6638,18 @@ function parseCommonDir(content) {
5337
6638
 
5338
6639
  // src/collector/git/repository.ts
5339
6640
  import { createHash as createHash2 } from "node:crypto";
5340
- import path13 from "node:path";
6641
+ import path16 from "node:path";
5341
6642
  var REPOSITORY_ID_LENGTH = 16;
5342
6643
  function repositoryId(gitFolder) {
5343
6644
  return createHash2("sha256").update(gitFolder).digest("hex").slice(0, REPOSITORY_ID_LENGTH);
5344
6645
  }
5345
6646
  function sharedName(common) {
5346
- const base = path13.basename(common);
5347
- if (base.startsWith(".")) return path13.basename(path13.dirname(common));
6647
+ const base = path16.basename(common);
6648
+ if (base.startsWith(".")) return path16.basename(path16.dirname(common));
5348
6649
  return base.endsWith(".git") ? base.slice(0, -".git".length) : base;
5349
6650
  }
5350
6651
  function repositoryOf({ dir, gitDir, commonDir }) {
5351
- const name = sessionName(commonDir === null ? path13.basename(dir) : sharedName(commonDir));
6652
+ const name = sessionName(commonDir === null ? path16.basename(dir) : sharedName(commonDir));
5352
6653
  return name === void 0 ? null : { id: repositoryId(commonDir ?? gitDir), name };
5353
6654
  }
5354
6655
 
@@ -5377,13 +6678,13 @@ async function readGitFile(io, file, maxBytes = MAX_GIT_FILE_BYTES) {
5377
6678
  }
5378
6679
  }
5379
6680
  async function commonDirOf(io, gitDir) {
5380
- const content = await readGitFile(io, path14.join(gitDir, "commondir"));
6681
+ const content = await readGitFile(io, path17.join(gitDir, "commondir"));
5381
6682
  if (content === void 0) return null;
5382
6683
  const target = content === null ? null : parseCommonDir(content);
5383
- return target === null ? void 0 : path14.resolve(gitDir, target);
6684
+ return target === null ? void 0 : path17.resolve(gitDir, target);
5384
6685
  }
5385
6686
  async function headIn(dir, kind, io) {
5386
- const marker = path14.join(dir, ".git");
6687
+ const marker = path17.join(dir, ".git");
5387
6688
  let gitDir;
5388
6689
  if (kind === "directory") {
5389
6690
  gitDir = marker;
@@ -5391,11 +6692,11 @@ async function headIn(dir, kind, io) {
5391
6692
  const content2 = await readGitFile(io, marker);
5392
6693
  const target = typeof content2 === "string" ? parseGitFile(content2) : null;
5393
6694
  if (target === null) return null;
5394
- gitDir = path14.resolve(dir, target);
6695
+ gitDir = path17.resolve(dir, target);
5395
6696
  } else {
5396
6697
  return null;
5397
6698
  }
5398
- const content = await readGitFile(io, path14.join(gitDir, "HEAD"));
6699
+ const content = await readGitFile(io, path17.join(gitDir, "HEAD"));
5399
6700
  const head = typeof content === "string" ? parseHead(content) : null;
5400
6701
  if (head === null) return null;
5401
6702
  const commonDir = kind === "file" ? await commonDirOf(io, gitDir) : null;
@@ -5404,20 +6705,20 @@ async function headIn(dir, kind, io) {
5404
6705
  return { head: { ...head, repository }, gitFolder: commonDir ?? gitDir };
5405
6706
  }
5406
6707
  async function findHead(cwd, { io, homeDir }) {
5407
- if (!path14.isAbsolute(cwd)) return null;
5408
- const home = path14.resolve(homeDir);
5409
- let dir = path14.resolve(cwd);
6708
+ if (!path17.isAbsolute(cwd)) return null;
6709
+ const home = path17.resolve(homeDir);
6710
+ let dir = path17.resolve(cwd);
5410
6711
  try {
5411
6712
  if ((await io.stat(dir)).kind !== "directory") return null;
5412
6713
  } catch {
5413
6714
  return null;
5414
6715
  }
5415
6716
  for (let level = 0; level < MAX_LEVELS; level += 1) {
5416
- const parent = path14.dirname(dir);
6717
+ const parent = path17.dirname(dir);
5417
6718
  if (dir === home || dir === parent) return null;
5418
6719
  let kind;
5419
6720
  try {
5420
- kind = (await io.lstat(path14.join(dir, ".git"))).kind;
6721
+ kind = (await io.lstat(path17.join(dir, ".git"))).kind;
5421
6722
  } catch (error) {
5422
6723
  if (!isMissing(error)) return null;
5423
6724
  dir = parent;
@@ -5438,7 +6739,7 @@ function within(work, ms) {
5438
6739
  });
5439
6740
  }
5440
6741
  function createBranchFinder(options = {}) {
5441
- const where = { io: options.io ?? nodeIo2, homeDir: options.homeDir ?? os5.homedir() };
6742
+ const where = { io: options.io ?? nodeIo2, homeDir: options.homeDir ?? os7.homedir() };
5442
6743
  const now = options.now ?? Date.now;
5443
6744
  const waitMs = options.waitMs ?? READ_WAIT_MS;
5444
6745
  const known = /* @__PURE__ */ new Map();
@@ -5452,7 +6753,9 @@ function createBranchFinder(options = {}) {
5452
6753
  return {
5453
6754
  async annotate(sessions) {
5454
6755
  const folders = /* @__PURE__ */ new Set();
5455
- for (const session of sessions) if (session.cwd) folders.add(session.cwd);
6756
+ for (const session of sessions) {
6757
+ if (session.cwd && session.machine === void 0) folders.add(session.cwd);
6758
+ }
5456
6759
  for (const folder of known.keys()) if (!folders.has(folder)) known.delete(folder);
5457
6760
  if (!running) {
5458
6761
  const at = now();
@@ -5468,6 +6771,7 @@ function createBranchFinder(options = {}) {
5468
6771
  }
5469
6772
  }
5470
6773
  return sessions.map((session) => {
6774
+ if (session.machine !== void 0) return session;
5471
6775
  const head = session.cwd ? known.get(session.cwd)?.found?.head : null;
5472
6776
  return head ? { ...session, git: head } : session;
5473
6777
  });
@@ -5497,14 +6801,14 @@ var STATUS_STATE = {
5497
6801
  PENDING: "pending",
5498
6802
  EXPECTED: "pending"
5499
6803
  };
5500
- function isRecord7(value) {
6804
+ function isRecord10(value) {
5501
6805
  return typeof value === "object" && value !== null && !Array.isArray(value);
5502
6806
  }
5503
6807
  function textOf(value) {
5504
6808
  return typeof value === "string" ? value : "";
5505
6809
  }
5506
6810
  function checkOf(value) {
5507
- if (!isRecord7(value)) return null;
6811
+ if (!isRecord10(value)) return null;
5508
6812
  const startedAt = Date.parse(textOf(value.startedAt));
5509
6813
  const at = Number.isNaN(startedAt) ? -Infinity : startedAt;
5510
6814
  if (value.__typename === "CheckRun") {
@@ -5553,7 +6857,7 @@ function readGhAnswer(stdout, repository) {
5553
6857
  } catch {
5554
6858
  return null;
5555
6859
  }
5556
- if (!isRecord7(data)) return null;
6860
+ if (!isRecord10(data)) return null;
5557
6861
  const { number } = data;
5558
6862
  const state = stateOf2(data.state, data.isDraft);
5559
6863
  const title = typeof data.title === "string" ? sessionName(data.title) : void 0;
@@ -5691,7 +6995,7 @@ function createGhAsker(options) {
5691
6995
  }
5692
6996
 
5693
6997
  // src/collector/git/pullRequestTarget.ts
5694
- import path15 from "node:path";
6998
+ import path18 from "node:path";
5695
6999
 
5696
7000
  // src/collector/git/gitConfig.ts
5697
7001
  var MAX_CONFIG_BYTES = 64 * 1024;
@@ -5949,7 +7253,7 @@ function targetOf(config, branch, base, defaultBranch) {
5949
7253
  async function readPullRequestTarget(gitFolder, branch, io = nodeIo2) {
5950
7254
  try {
5951
7255
  if (!isAskableBranch(branch)) return null;
5952
- const text4 = await readGitFile(io, path15.join(gitFolder, "config"), MAX_CONFIG_BYTES);
7256
+ const text4 = await readGitFile(io, path18.join(gitFolder, "config"), MAX_CONFIG_BYTES);
5953
7257
  if (typeof text4 !== "string") return null;
5954
7258
  const config = parseGitConfig(text4);
5955
7259
  const base = config === null ? null : baseRemoteOf(config);
@@ -5958,7 +7262,7 @@ async function readPullRequestTarget(gitFolder, branch, io = nodeIo2) {
5958
7262
  if (base.name !== null && isSafeRemoteName(base.name)) {
5959
7263
  const head = await readGitFile(
5960
7264
  io,
5961
- path15.join(gitFolder, "refs", "remotes", base.name, "HEAD")
7265
+ path18.join(gitFolder, "refs", "remotes", base.name, "HEAD")
5962
7266
  );
5963
7267
  if (typeof head === "string") defaultBranch = parseRemoteHead(head, base.name);
5964
7268
  }
@@ -5972,9 +7276,8 @@ async function readPullRequestTarget(gitFolder, branch, io = nodeIo2) {
5972
7276
  var PULL_REQUEST_CHECK_MS = 12e4;
5973
7277
  function placeOf2(session, gitFolderOf) {
5974
7278
  const branch = session.git?.branch;
5975
- if (session.cwd === null || branch === void 0 || session.git?.repository === void 0) {
5976
- return null;
5977
- }
7279
+ if (session.machine !== void 0 || session.cwd === null || branch === void 0) return null;
7280
+ if (session.git?.repository === void 0) return null;
5978
7281
  const gitFolder = gitFolderOf(session.cwd);
5979
7282
  return gitFolder === null ? null : { key: `${gitFolder}\0${branch}`, gitFolder, branch };
5980
7283
  }
@@ -5988,7 +7291,7 @@ function createPullRequestFinder(options) {
5988
7291
  const now = options.now ?? Date.now;
5989
7292
  const intervalMs = options.intervalMs ?? PULL_REQUEST_CHECK_MS;
5990
7293
  const reads = /* @__PURE__ */ new Map();
5991
- const answers = /* @__PURE__ */ new Map();
7294
+ const answers2 = /* @__PURE__ */ new Map();
5992
7295
  let running = null;
5993
7296
  let pausedUntil = -Infinity;
5994
7297
  let gh = "unknown";
@@ -6000,22 +7303,22 @@ function createPullRequestFinder(options) {
6000
7303
  case "none":
6001
7304
  gh = "ready";
6002
7305
  last = { at, ok: true };
6003
- answers.set(question, {
7306
+ answers2.set(question, {
6004
7307
  pullRequest: answer.kind === "found" ? answer.pullRequest : null,
6005
7308
  askedAt: at
6006
7309
  });
6007
7310
  return true;
6008
7311
  case "failed":
6009
7312
  last = { at, ok: false, reason: answer.reason };
6010
- answers.set(question, {
6011
- pullRequest: answers.get(question)?.pullRequest ?? null,
7313
+ answers2.set(question, {
7314
+ pullRequest: answers2.get(question)?.pullRequest ?? null,
6012
7315
  askedAt: at
6013
7316
  });
6014
7317
  return true;
6015
7318
  case "not-found":
6016
7319
  case "signed-out":
6017
7320
  gh = answer.kind;
6018
- answers.clear();
7321
+ answers2.clear();
6019
7322
  pausedUntil = at + intervalMs;
6020
7323
  return false;
6021
7324
  }
@@ -6033,7 +7336,7 @@ function createPullRequestFinder(options) {
6033
7336
  asked.add(question);
6034
7337
  const at = now();
6035
7338
  if (at < pausedUntil) return;
6036
- if (!due(at, answers.get(question)?.askedAt)) continue;
7339
+ if (!due(at, answers2.get(question)?.askedAt)) continue;
6037
7340
  const answer = await ask(target).catch(() => ({
6038
7341
  kind: "failed",
6039
7342
  reason: "gh could not be run"
@@ -6054,13 +7357,13 @@ function createPullRequestFinder(options) {
6054
7357
  for (const key of reads.keys()) if (!places.has(key)) reads.delete(key);
6055
7358
  const wanted = /* @__PURE__ */ new Set();
6056
7359
  for (const { target } of reads.values()) if (target) wanted.add(questionOf(target));
6057
- for (const question of answers.keys()) if (!wanted.has(question)) answers.delete(question);
7360
+ for (const question of answers2.keys()) if (!wanted.has(question)) answers2.delete(question);
6058
7361
  if (!running) {
6059
7362
  const at = now();
6060
7363
  const toRead = [...places.values()].filter(
6061
7364
  (place) => due(at, reads.get(place.key)?.readAt)
6062
7365
  );
6063
- const toAsk = at >= pausedUntil && [...wanted].some((question) => due(at, answers.get(question)?.askedAt));
7366
+ const toAsk = at >= pausedUntil && [...wanted].some((question) => due(at, answers2.get(question)?.askedAt));
6064
7367
  if (toRead.length > 0 || toAsk) {
6065
7368
  running = round(toRead).catch(() => {
6066
7369
  }).finally(() => {
@@ -6071,7 +7374,7 @@ function createPullRequestFinder(options) {
6071
7374
  return sessions.map((session) => {
6072
7375
  const place = placeOf2(session, gitFolderOf);
6073
7376
  const target = place === null ? null : reads.get(place.key)?.target;
6074
- const pullRequest = target ? answers.get(questionOf(target))?.pullRequest : null;
7377
+ const pullRequest = target ? answers2.get(questionOf(target))?.pullRequest : null;
6075
7378
  return pullRequest && session.git ? { ...session, git: { ...session.git, pullRequest } } : session;
6076
7379
  });
6077
7380
  },
@@ -6130,7 +7433,7 @@ function createClearHistoryRoute(options) {
6130
7433
  }
6131
7434
 
6132
7435
  // src/collector/history/historyKeeper.ts
6133
- import path16 from "node:path";
7436
+ import path19 from "node:path";
6134
7437
 
6135
7438
  // src/collector/history/historyFiles.ts
6136
7439
  import {
@@ -6143,7 +7446,7 @@ import {
6143
7446
  unlinkSync,
6144
7447
  writeSync
6145
7448
  } from "node:fs";
6146
- import { lstat as lstat2, lutimes, mkdir, open as open3, readdir as readdir3, unlink } from "node:fs/promises";
7449
+ import { lstat as lstat3, lutimes, mkdir as mkdir2, open as open3, readdir as readdir3, unlink as unlink2 } from "node:fs/promises";
6147
7450
  function infoOf2(info) {
6148
7451
  return { kind: info.isFile() ? "file" : "other", size: info.size, mtimeMs: info.mtimeMs };
6149
7452
  }
@@ -6215,10 +7518,10 @@ async function writeTo(file, flags, text4) {
6215
7518
  }
6216
7519
  var nodeHistoryFs = {
6217
7520
  async makeFolder(dir) {
6218
- await mkdir(dir, { recursive: true, mode: FOLDER_MODE });
7521
+ await mkdir2(dir, { recursive: true, mode: FOLDER_MODE });
6219
7522
  },
6220
7523
  list: (dir) => readdir3(dir),
6221
- lstat: async (file) => infoOf2(await lstat2(file)),
7524
+ lstat: async (file) => infoOf2(await lstat3(file)),
6222
7525
  lstatNow: (file) => infoOf2(lstatSync(file)),
6223
7526
  readRegular,
6224
7527
  readRegularNow,
@@ -6232,7 +7535,7 @@ var nodeHistoryFs = {
6232
7535
  },
6233
7536
  async remove(file) {
6234
7537
  try {
6235
- await unlink(file);
7538
+ await unlink2(file);
6236
7539
  } catch (error) {
6237
7540
  if (!isMissing2(error)) throw error;
6238
7541
  }
@@ -6247,7 +7550,7 @@ var nodeHistoryFs = {
6247
7550
  };
6248
7551
 
6249
7552
  // src/collector/history/writerLock.ts
6250
- import { randomBytes } from "node:crypto";
7553
+ import { randomBytes as randomBytes2 } from "node:crypto";
6251
7554
  var LOCK_FILE = "writer.lock";
6252
7555
  var LOCK_STALE_MS = 3e4;
6253
7556
  var MAX_LOCK_BYTES = 256;
@@ -6272,8 +7575,8 @@ function createWriterLock(options) {
6272
7575
  const pid = options.pid ?? process.pid;
6273
7576
  const now = options.now ?? Date.now;
6274
7577
  const isAlive = options.isAlive ?? isProcessAlive;
6275
- const staleAfterMs = options.staleAfterMs ?? LOCK_STALE_MS;
6276
- const token = options.token ?? randomBytes(8).toString("hex");
7578
+ const staleAfterMs2 = options.staleAfterMs ?? LOCK_STALE_MS;
7579
+ const token = options.token ?? randomBytes2(8).toString("hex");
6277
7580
  const sources = options.sources ?? null;
6278
7581
  const content = `${JSON.stringify({ pid, token, ...sources !== null && { sources } })}
6279
7582
  `;
@@ -6283,7 +7586,7 @@ function createWriterLock(options) {
6283
7586
  function verdict(info, holder2) {
6284
7587
  heldByOtherSources = false;
6285
7588
  if (info.kind !== "file") return "leave";
6286
- const fresh = now() - info.mtimeMs < staleAfterMs;
7589
+ const fresh = now() - info.mtimeMs < staleAfterMs2;
6287
7590
  if (!fresh || isOwn(holder2)) return "take-over";
6288
7591
  if (holder2 === null) return "leave";
6289
7592
  if (holder2.pid === pid || isAlive(holder2.pid)) {
@@ -6432,7 +7735,7 @@ function createHistoryKeeper(options) {
6432
7735
  });
6433
7736
  const lock = createWriterLock({
6434
7737
  fs,
6435
- file: path16.join(dir, LOCK_FILE),
7738
+ file: path19.join(dir, LOCK_FILE),
6436
7739
  pid: options.pid,
6437
7740
  now,
6438
7741
  isAlive: options.isAlive,
@@ -6458,7 +7761,7 @@ function createHistoryKeeper(options) {
6458
7761
  });
6459
7762
  return next;
6460
7763
  }
6461
- const full = (name) => path16.join(dir, name);
7764
+ const full = (name) => path19.join(dir, name);
6462
7765
  function hold(record) {
6463
7766
  if (unusable !== null || !running) return;
6464
7767
  const line = encodeRecord(record);
@@ -6720,12 +8023,12 @@ function createHistoryKeeper(options) {
6720
8023
  return serially(async () => {
6721
8024
  if (!running || unusable !== null) return;
6722
8025
  const kept = lock.held && await lock.keep();
6723
- const holds = kept || running && await lock.take();
8026
+ const holds2 = kept || running && await lock.take();
6724
8027
  if (!running) {
6725
8028
  lock.releaseNow();
6726
8029
  return;
6727
8030
  }
6728
- if (!holds) {
8031
+ if (!holds2) {
6729
8032
  writer = false;
6730
8033
  queue = [];
6731
8034
  startedAt = null;
@@ -6909,184 +8212,1339 @@ function keptHistoryStore(store, keeper) {
6909
8212
  store.add(point);
6910
8213
  keeper.addPoint(point);
6911
8214
  },
6912
- list: (windowMs, at) => store.list(windowMs, at),
6913
- clear: () => store.clear(),
6914
- get size() {
6915
- return store.size;
8215
+ list: (windowMs, at) => store.list(windowMs, at),
8216
+ clear: () => store.clear(),
8217
+ get size() {
8218
+ return store.size;
8219
+ }
8220
+ };
8221
+ }
8222
+
8223
+ // src/collector/history/historySettings.ts
8224
+ import { createHash as createHash3 } from "node:crypto";
8225
+ import os8 from "node:os";
8226
+ import path20 from "node:path";
8227
+ var HISTORY_ENV = "AGENT_LOOKOUT_HISTORY";
8228
+ var HISTORY_DIR_ENV = "AGENT_LOOKOUT_HISTORY_DIR";
8229
+ function readHistorySetup(env, homeDir = os8.homedir()) {
8230
+ if (env[HISTORY_ENV]?.trim().toLowerCase() === "off") return { on: false };
8231
+ const override = env[HISTORY_DIR_ENV]?.trim() || void 0;
8232
+ const dir = path20.resolve(override ?? path20.join(homeDir, ".agent-lookout", "history"));
8233
+ return {
8234
+ on: true,
8235
+ dir,
8236
+ folder: tildify(dir, homeDir),
8237
+ sources: sourcesFingerprint(env, homeDir)
8238
+ };
8239
+ }
8240
+ function sourcesFingerprint(env, homeDir = os8.homedir()) {
8241
+ const set = (name) => env[name]?.trim() || void 0;
8242
+ 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"))
8246
+ ];
8247
+ return createHash3("sha256").update(JSON.stringify(folders)).digest("hex").slice(0, 32);
8248
+ }
8249
+
8250
+ // src/collector/notifications/heldWait.ts
8251
+ var HANDOVER_GRACE_MS = 3e3;
8252
+ var PAGE_GONE_AFTER_MS = 5e3;
8253
+ function noPageReports(on) {
8254
+ return { on, lastOnAt: null, lastOnSessionsAt: null };
8255
+ }
8256
+ function heldWaitOutcome(since, pages, now) {
8257
+ if (!pages.on) return "drop";
8258
+ if (pages.lastOnSessionsAt !== null && pages.lastOnSessionsAt > since) return "drop";
8259
+ if (pages.lastOnAt === null || now - pages.lastOnAt > PAGE_GONE_AFTER_MS) return "show";
8260
+ return now - since >= HANDOVER_GRACE_MS ? "show" : "hold";
8261
+ }
8262
+
8263
+ // src/collector/notifications/serverNotifications.ts
8264
+ var NOTIFICATIONS_ENV = "AGENT_LOOKOUT_NOTIFICATIONS";
8265
+ function notificationsOnAtStart(env) {
8266
+ return env[NOTIFICATIONS_ENV]?.trim().toLowerCase() === "on";
8267
+ }
8268
+ var NOTIFICATIONS_NOT_SHOWN_LINE = `${NOTIFICATIONS_ENV} is on, but Agent Lookout shows notifications itself on macOS only, so it shows none here. A dashboard tab with notifications on still shows them.`;
8269
+ function notificationsAtStartLine(env, notifierShows) {
8270
+ return notificationsOnAtStart(env) && !notifierShows ? NOTIFICATIONS_NOT_SHOWN_LINE : null;
8271
+ }
8272
+ var SUMMARY_KEY = "summary";
8273
+ function reminderKey(sessionId) {
8274
+ return `reminder ${sessionId}`;
8275
+ }
8276
+ function heldKey(event, sessionId) {
8277
+ return `${event} ${sessionId}`;
8278
+ }
8279
+ function createServerNotifications(options) {
8280
+ const { notifier } = options;
8281
+ const now = options.now ?? Date.now;
8282
+ let memory = EMPTY_CHANGE_MEMORY;
8283
+ let pages = noPageReports(options.onAtStart);
8284
+ let chosen = new Set(options.onAtStart ? DEFAULT_NOTICE_EVENTS : []);
8285
+ const held = /* @__PURE__ */ new Map();
8286
+ const reminders = createReminderWatch();
8287
+ const quiet = createQuietHold();
8288
+ function show(notice) {
8289
+ try {
8290
+ notifier.show(notice);
8291
+ } catch {
8292
+ }
8293
+ }
8294
+ const wants = (event) => pages.on && chosen.has(event);
8295
+ function holdQuietly(change, at) {
8296
+ const { event, session } = change;
8297
+ if (event === "needs-you") quiet.holdWait(session, waitBegan(session.statusSince, at));
8298
+ else quiet.holdOver(event, withoutWaitingText(session), at);
8299
+ }
8300
+ return {
8301
+ pageSaid(said, fetchedSessions) {
8302
+ const at = now();
8303
+ chosen = new Set(said);
8304
+ if (said.length === 0) {
8305
+ pages = { ...pages, on: false };
8306
+ return;
8307
+ }
8308
+ pages = {
8309
+ on: true,
8310
+ lastOnAt: at,
8311
+ lastOnSessionsAt: fetchedSessions ? at : pages.lastOnSessionsAt
8312
+ };
8313
+ },
8314
+ handle(snapshot) {
8315
+ const at = now();
8316
+ const rules = rulesOf(snapshot);
8317
+ const quietNow = quietOf(snapshot);
8318
+ const thresholdMs = rules.longWait.minutes * 6e4;
8319
+ const result = sessionChanges(memory, snapshot);
8320
+ memory = result.memory;
8321
+ for (const id of result.stopped) {
8322
+ held.delete(heldKey("needs-you", id));
8323
+ held.delete(reminderKey(id));
8324
+ quiet.waitEnded(id, at);
8325
+ }
8326
+ reminders.observe(snapshot, result.stopped, at);
8327
+ if (quiet.holding() && !quietNow) {
8328
+ const end = quiet.end(snapshot, at, rules.quietHours.leaveOutAnswered);
8329
+ const items = end.summary?.items.filter((item) => wants(item.event)) ?? [];
8330
+ if (end.summary && items.length > 0) {
8331
+ held.set(SUMMARY_KEY, {
8332
+ kind: "summary",
8333
+ summary: { ...end.summary, items },
8334
+ since: at
8335
+ });
8336
+ }
8337
+ for (const { session } of end.open) {
8338
+ if (!wants("needs-you")) continue;
8339
+ held.set(heldKey("needs-you", session.id), {
8340
+ kind: "change",
8341
+ change: { event: "needs-you", session },
8342
+ since: at
8343
+ });
8344
+ if (rules.longWait.on) reminders.toldLate(session.id, at, thresholdMs);
8345
+ }
8346
+ }
8347
+ if (quietNow) quiet.begin(at);
8348
+ for (const change of result.changes) {
8349
+ if (!wants(change.event)) continue;
8350
+ if (quietNow) holdQuietly(change, at);
8351
+ else
8352
+ held.set(heldKey(change.event, change.session.id), { kind: "change", change, since: at });
8353
+ }
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);
8357
+ held.set(reminderKey(reminder.session.id), { kind: "reminder", reminder, since: at });
8358
+ }
8359
+ }
8360
+ const listed = new Map(snapshot.sessions.map((session) => [session.id, session]));
8361
+ for (const entry of held.values()) {
8362
+ if (entry.kind === "summary") continue;
8363
+ const session = entry.kind === "change" ? entry.change.session : entry.reminder.session;
8364
+ const current = listed.get(session.id);
8365
+ if (current?.status !== "needs-you") continue;
8366
+ if (entry.kind === "reminder") entry.reminder = { ...entry.reminder, session: current };
8367
+ else if (entry.change.event === "needs-you")
8368
+ entry.change = { ...entry.change, session: current };
8369
+ }
8370
+ for (const [key, entry] of held) {
8371
+ const event = entry.kind === "change" ? entry.change.event : "needs-you";
8372
+ const outcome = entry.kind === "summary" || chosen.has(event) ? heldWaitOutcome(entry.since, pages, at) : "drop";
8373
+ if (outcome === "hold") continue;
8374
+ held.delete(key);
8375
+ if (outcome !== "show") continue;
8376
+ if (quietNow) {
8377
+ if (entry.kind === "change") holdQuietly(entry.change, at);
8378
+ else if (entry.kind === "reminder") {
8379
+ quiet.holdWait(entry.reminder.session, entry.reminder.begunAt);
8380
+ }
8381
+ continue;
8382
+ }
8383
+ if (entry.kind === "change") show(changeNotice(entry.change));
8384
+ else if (entry.kind === "reminder") {
8385
+ show(reminderNotice(entry.reminder.session, at - entry.reminder.begunAt));
8386
+ } else show(summaryNotice(entry.summary.items));
8387
+ }
8388
+ }
8389
+ };
8390
+ }
8391
+
8392
+ // src/collector/processes/osascript.ts
8393
+ import { execFile as execFile3 } from "node:child_process";
8394
+ var OSASCRIPT = "/usr/bin/osascript";
8395
+ var MAX_OUTPUT_BYTES4 = 64 * 1024;
8396
+ var NOT_RUN2 = { ok: false, stderr: "", timedOut: false };
8397
+ function runOsascript(args, options = {}) {
8398
+ return new Promise((resolve) => {
8399
+ try {
8400
+ const child = execFile3(
8401
+ OSASCRIPT,
8402
+ [...args],
8403
+ {
8404
+ timeout: options.timeoutMs ?? TERMINAL_JUMP_TIMEOUT_MS,
8405
+ maxBuffer: MAX_OUTPUT_BYTES4,
8406
+ encoding: "utf8",
8407
+ windowsHide: true
8408
+ },
8409
+ (error, stdout, stderr) => {
8410
+ if (!error) {
8411
+ resolve({ ok: true, stdout: String(stdout ?? "") });
8412
+ return;
8413
+ }
8414
+ const timedOut = error.killed === true;
8415
+ resolve({ ok: false, stderr: String(stderr ?? "").trim(), timedOut });
8416
+ }
8417
+ );
8418
+ child.stdin?.end();
8419
+ } catch {
8420
+ resolve(NOT_RUN2);
8421
+ }
8422
+ });
8423
+ }
8424
+
8425
+ // src/collector/notifications/systemNotifier.ts
8426
+ var OSASCRIPT_TIMEOUT_MS = 5e3;
8427
+ var SCRIPT = [
8428
+ "on run argv",
8429
+ "display notification (item 2 of argv) with title (item 1 of argv)",
8430
+ "end run"
8431
+ ];
8432
+ function osascriptArgs(notice) {
8433
+ return [
8434
+ ...SCRIPT.flatMap((line) => ["-e", line]),
8435
+ "--",
8436
+ oneLine(notice.title),
8437
+ oneLine(notice.body, MAX_NOTICE_TEXT_LENGTH)
8438
+ ];
8439
+ }
8440
+ function systemNotificationsShownOn(platform) {
8441
+ return platform === "darwin";
8442
+ }
8443
+ var runForNotification = (args) => runOsascript(args, { timeoutMs: OSASCRIPT_TIMEOUT_MS });
8444
+ function createSystemNotifier(options = {}) {
8445
+ const platform = options.platform ?? process.platform;
8446
+ const run = options.run ?? runForNotification;
8447
+ return {
8448
+ show(notice) {
8449
+ if (!systemNotificationsShownOn(platform)) return;
8450
+ try {
8451
+ run(osascriptArgs(notice)).catch(() => {
8452
+ });
8453
+ } catch {
8454
+ }
8455
+ }
8456
+ };
8457
+ }
8458
+
8459
+ // src/collector/remotes/remoteReader.ts
8460
+ import { request } from "node:http";
8461
+ var REMOTE_ANSWER_TIMEOUT_MS = 6e3;
8462
+ var MAX_ANSWER_BYTES = 8 * 1024 * 1024;
8463
+ function askRemote(port, route, timeoutMs = REMOTE_ANSWER_TIMEOUT_MS) {
8464
+ return new Promise((resolve) => {
8465
+ let connected = false;
8466
+ let settled = false;
8467
+ const finish = (answer) => {
8468
+ if (settled) return;
8469
+ settled = true;
8470
+ clearTimeout(timer);
8471
+ req.destroy();
8472
+ resolve(answer);
8473
+ };
8474
+ const req = request({
8475
+ host: "127.0.0.1",
8476
+ port,
8477
+ path: route,
8478
+ method: "GET",
8479
+ // A connection of its own, closed after the answer.
8480
+ agent: false,
8481
+ headers: { Accept: "application/json" }
8482
+ });
8483
+ const timer = setTimeout(() => finish({ kind: "timeout" }), timeoutMs);
8484
+ timer.unref?.();
8485
+ req.on("socket", (socket) => socket.once("connect", () => connected = true));
8486
+ req.on("error", () => finish(connected ? { kind: "closed" } : { kind: "refused" }));
8487
+ req.on("response", (res) => {
8488
+ if (res.statusCode !== 200) {
8489
+ finish({ kind: "unreadable", why: `answered ${route} with status ${res.statusCode}` });
8490
+ return;
8491
+ }
8492
+ const chunks = [];
8493
+ let size = 0;
8494
+ res.on("data", (chunk) => {
8495
+ size += chunk.byteLength;
8496
+ if (size > MAX_ANSWER_BYTES) finish({ kind: "unreadable", why: `sent too long an answer` });
8497
+ else chunks.push(chunk);
8498
+ });
8499
+ res.on("error", () => finish({ kind: "closed" }));
8500
+ res.on("end", () => {
8501
+ try {
8502
+ finish({ kind: "json", value: JSON.parse(Buffer.concat(chunks).toString("utf8")) });
8503
+ } catch {
8504
+ finish({ kind: "unreadable", why: `answered ${route} with something that is not JSON` });
8505
+ }
8506
+ });
8507
+ });
8508
+ req.end();
8509
+ });
8510
+ }
8511
+ function versionIn(value) {
8512
+ if (typeof value !== "object" || value === null) return null;
8513
+ const { ok, version } = value;
8514
+ if (ok !== true || typeof version !== "string") return null;
8515
+ return /^\d{1,4}\.\d{1,5}\.\d{1,6}(?:-[0-9A-Za-z.-]{1,40})?$/.test(version) ? version : null;
8516
+ }
8517
+ async function readRemote(port, timeoutMs = REMOTE_ANSWER_TIMEOUT_MS) {
8518
+ const health = await askRemote(port, "/api/health", timeoutMs);
8519
+ if (health.kind !== "json") return health;
8520
+ const version = versionIn(health.value);
8521
+ if (version === null) {
8522
+ return { kind: "unreadable", why: "answered /api/health, but not as Agent Lookout does" };
8523
+ }
8524
+ const sessions = await askRemote(port, "/api/sessions", timeoutMs);
8525
+ if (sessions.kind !== "json") return sessions;
8526
+ return { kind: "read", version, snapshot: sessions.value };
8527
+ }
8528
+
8529
+ // src/collector/remotes/remoteSessions.ts
8530
+ var MAX_REMOTE_SESSIONS = 500;
8531
+ var MAX_REMOTE_SOURCES = 20;
8532
+ var MAX_ID_LENGTH = 512;
8533
+ var MAX_PATH_LENGTH = 4096;
8534
+ var MAX_WORD_LENGTH2 = 200;
8535
+ function isRecord11(value) {
8536
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8537
+ }
8538
+ function oneOf2(allowed, value) {
8539
+ return allowed.includes(value) ? value : null;
8540
+ }
8541
+ function exact(value, max) {
8542
+ if (typeof value !== "string") return null;
8543
+ const cleaned = cut(value, max);
8544
+ return cleaned === value ? value : null;
8545
+ }
8546
+ var COMMIT_ID2 = /^[0-9a-f]{7,64}$/;
8547
+ var REPOSITORY_ID = /^[0-9a-f]{16,64}$/;
8548
+ function readGit(value, machine) {
8549
+ if (!isRecord11(value)) return void 0;
8550
+ let head;
8551
+ if (value.commit !== void 0) {
8552
+ if (value.branch !== void 0 || typeof value.commit !== "string") return void 0;
8553
+ if (!COMMIT_ID2.test(value.commit)) return void 0;
8554
+ head = { commit: value.commit };
8555
+ } else {
8556
+ const branch = cut(value.branch, MAX_NAME_LENGTH);
8557
+ if (branch === void 0) return void 0;
8558
+ head = { branch };
8559
+ }
8560
+ const repository = value.repository;
8561
+ if (isRecord11(repository) && typeof repository.id === "string") {
8562
+ const name = sessionName(typeof repository.name === "string" ? repository.name : "");
8563
+ if (name !== void 0 && REPOSITORY_ID.test(repository.id)) {
8564
+ head.repository = { id: repositoryId(`${machine}
8565
+ ${repository.id}`), name };
8566
+ }
8567
+ }
8568
+ return head;
8569
+ }
8570
+ function readSession(value, machine, sources, now, keepWaitingText) {
8571
+ if (!isRecord11(value)) return null;
8572
+ const id = exact(value.id, MAX_ID_LENGTH);
8573
+ const source = exact(value.source, MAX_WORD_LENGTH2);
8574
+ if (id === null || source === null) return null;
8575
+ if (isRemoteSource(source) || value.machine !== void 0) return null;
8576
+ const project = sessionName(typeof value.project === "string" ? value.project : "") ?? null;
8577
+ const status = oneOf2(SESSION_STATUSES, value.status) ?? "unknown";
8578
+ const agent = cut(value.agent, MAX_WORD_LENGTH2) ?? sources.get(source) ?? source;
8579
+ const session = {
8580
+ id: `${remoteSourceId(machine)}:${id}`,
8581
+ source: remoteSourceId(machine),
8582
+ agent,
8583
+ machine,
8584
+ surface: oneOf2(SURFACES, value.surface) ?? "unknown",
8585
+ name: sessionName(typeof value.name === "string" ? value.name : "") ?? project ?? id,
8586
+ cwd: cut(value.cwd, MAX_PATH_LENGTH) ?? null,
8587
+ project,
8588
+ status,
8589
+ startedAt: plausibleTime(value.startedAt, now),
8590
+ statusSince: plausibleTime(value.statusSince, now),
8591
+ links: {},
8592
+ stale: value.stale === true
8593
+ };
8594
+ if (status === "needs-you") {
8595
+ const reason = oneOf2(WAITING_REASONS, value.waitingReason);
8596
+ if (reason) session.waitingReason = reason;
8597
+ const detail = cut(value.waitingDetail, MAX_WORD_LENGTH2);
8598
+ if (detail !== void 0) session.waitingDetail = detail;
8599
+ const asking = keepWaitingText ? waitingText(value.waitingText) : void 0;
8600
+ if (asking !== void 0) session.waitingText = asking;
8601
+ }
8602
+ const lastWriteAt = plausibleTime(value.lastWriteAt, now);
8603
+ if (lastWriteAt !== null) session.lastWriteAt = lastWriteAt;
8604
+ if (typeof value.alive === "boolean") session.alive = value.alive;
8605
+ const git = readGit(value.git, machine);
8606
+ if (git) session.git = git;
8607
+ return session;
8608
+ }
8609
+ function readCell(value) {
8610
+ if (!isRecord11(value)) return null;
8611
+ if (value.level === "yes") return { level: "yes" };
8612
+ const level = oneOf2(["no", "partly"], value.level);
8613
+ const reason = cut(value.reason, MAX_WORD_LENGTH2);
8614
+ return level && reason !== void 0 ? { level, reason } : null;
8615
+ }
8616
+ var ACTS_HERE = ["jump", "stop", "answer"];
8617
+ function readCapabilities(value, machine) {
8618
+ if (!isRecord11(value)) return null;
8619
+ const read = {};
8620
+ for (const capability of CAPABILITIES) {
8621
+ if (ACTS_HERE.includes(capability)) continue;
8622
+ const cell = readCell(value[capability]);
8623
+ if (!cell) return null;
8624
+ read[capability] = cell;
8625
+ }
8626
+ return {
8627
+ ...read,
8628
+ jump: { level: "no", reason: `Jump acts on this computer only, not on ${machine}.` },
8629
+ stop: { level: "no", reason: `Stop acts on this computer only, not on ${machine}.` },
8630
+ answer: { level: "no", reason: `Answer acts on this computer only, not on ${machine}.` }
8631
+ };
8632
+ }
8633
+ function readRemoteSnapshot(value, machine, now, options = {}) {
8634
+ if (!isRecord11(value) || !Array.isArray(value.sessions) || !Array.isArray(value.sources)) {
8635
+ return null;
8636
+ }
8637
+ const named = /* @__PURE__ */ new Map();
8638
+ const sources = [];
8639
+ const agents = [];
8640
+ for (const item of value.sources) {
8641
+ if (sources.length >= MAX_REMOTE_SOURCES) break;
8642
+ if (!isRecord11(item)) continue;
8643
+ const id = exact(item.id, MAX_WORD_LENGTH2);
8644
+ if (id === null || isRemoteSource(id) || item.machine !== void 0) continue;
8645
+ const label2 = cut(item.label, MAX_WORD_LENGTH2) ?? id;
8646
+ const state = oneOf2(SOURCE_STATES, item.state) ?? "error";
8647
+ if (named.has(id)) continue;
8648
+ named.set(id, label2);
8649
+ sources.push({ label: label2, state });
8650
+ if (state === "unavailable" || state === "not-set-up") continue;
8651
+ const capabilities = readCapabilities(item.capabilities, machine);
8652
+ if (capabilities) agents.push({ label: label2, capabilities });
8653
+ }
8654
+ const sessions = [];
8655
+ const seen = /* @__PURE__ */ new Set();
8656
+ for (const item of value.sessions) {
8657
+ if (sessions.length >= MAX_REMOTE_SESSIONS) break;
8658
+ const session = readSession(item, machine, named, now, options.waitingText !== false);
8659
+ if (!session || seen.has(session.id)) continue;
8660
+ seen.add(session.id);
8661
+ sessions.push(session);
8662
+ }
8663
+ return { sessions, sources, agents };
8664
+ }
8665
+
8666
+ // src/collector/remotes/sshProgram.ts
8667
+ import path21 from "node:path";
8668
+ var SSH_BIN_ENV = "AGENT_LOOKOUT_SSH_BIN";
8669
+ var SSH_LOCATIONS = ["/usr/bin/ssh", "/opt/homebrew/bin/ssh", "/usr/local/bin/ssh"];
8670
+ var SERVER_ALIVE_SECONDS = 15;
8671
+ async function findSsh(env, isExecutable = isExecutableFile) {
8672
+ const named = env[SSH_BIN_ENV]?.trim();
8673
+ if (named) {
8674
+ const resolved = path21.resolve(named);
8675
+ if (await isExecutable(resolved)) return { found: true, path: resolved };
8676
+ return {
8677
+ found: false,
8678
+ looked: `${SSH_BIN_ENV} is set to ${named}, which is not a program this user can run`,
8679
+ named: true
8680
+ };
8681
+ }
8682
+ const candidates = [.../* @__PURE__ */ new Set([...pathCandidates(env, "ssh"), ...SSH_LOCATIONS])];
8683
+ for (const candidate of candidates) {
8684
+ if (await isExecutable(candidate)) return { found: true, path: candidate };
8685
+ }
8686
+ return {
8687
+ found: false,
8688
+ looked: `The ssh command was not found on PATH or in ${SSH_LOCATIONS.map((at) => path21.dirname(at)).join(", ")}`
8689
+ };
8690
+ }
8691
+ function sshArguments(forward) {
8692
+ return [
8693
+ "-N",
8694
+ "-o",
8695
+ "BatchMode=yes",
8696
+ "-o",
8697
+ "ExitOnForwardFailure=yes",
8698
+ "-o",
8699
+ `ServerAliveInterval=${SERVER_ALIVE_SECONDS}`,
8700
+ "-o",
8701
+ "ControlMaster=no",
8702
+ "-o",
8703
+ "ControlPath=none",
8704
+ "-L",
8705
+ `127.0.0.1:${forward.localPort}:127.0.0.1:${forward.remotePort}`,
8706
+ "--",
8707
+ forward.target
8708
+ ];
8709
+ }
8710
+
8711
+ // src/collector/remotes/remoteAdapter.ts
8712
+ var BASIS3 = "api";
8713
+ var NOT_CONNECTED = "unavailable";
8714
+ var ANSWER_WAIT_MS = 500;
8715
+ var STILL_CONNECTING_MS = 1e4;
8716
+ function within2(work, ms) {
8717
+ return new Promise((resolve) => {
8718
+ const timer = setTimeout(resolve, ms);
8719
+ timer.unref?.();
8720
+ void work.then(() => {
8721
+ clearTimeout(timer);
8722
+ resolve();
8723
+ });
8724
+ });
8725
+ }
8726
+ function sentence(text4) {
8727
+ return /[.!?]$/.test(text4) ? text4 : `${text4}.`;
8728
+ }
8729
+ function inSeconds(at, now) {
8730
+ const seconds = Math.max(1, Math.ceil((at - now) / 1e3));
8731
+ return `in ${seconds} ${seconds === 1 ? "second" : "seconds"}`;
8732
+ }
8733
+ function createRemoteAdapter(options) {
8734
+ const { remote, tunnel, version } = options;
8735
+ const read = options.read ?? ((port) => readRemote(port));
8736
+ const now = options.now ?? Date.now;
8737
+ const pollIntervalMs = options.pollIntervalMs ?? POLL_INTERVAL_MS;
8738
+ const answerWaitMs = options.answerWaitMs ?? ANSWER_WAIT_MS;
8739
+ const stillConnectingMs = options.stillConnectingMs ?? STILL_CONNECTING_MS;
8740
+ const waitingText2 = options.waitingText ?? true;
8741
+ const id = remoteSourceId(remote.name);
8742
+ const label2 = remote.name;
8743
+ const where = `port ${remote.port} of ${remote.name}`;
8744
+ let latest = null;
8745
+ let underWay = null;
8746
+ let problem = null;
8747
+ let agents = [];
8748
+ let command = null;
8749
+ let connectingSince = null;
8750
+ function facts(snapshot, remoteVersion) {
8751
+ const watching = [{ label: "Connects to", value: remote.target }];
8752
+ if (command !== null) watching.push({ label: "Command", value: command });
8753
+ watching.push(
8754
+ { label: "Asks for", value: "/api/health and /api/sessions" },
8755
+ { label: "Read", value: every(pollIntervalMs) }
8756
+ );
8757
+ if (remoteVersion !== null) {
8758
+ watching.push({ label: "Agent Lookout there", value: remoteVersion });
8759
+ }
8760
+ for (const source of snapshot?.sources ?? []) {
8761
+ watching.push({ label: `${source.label} there`, value: SOURCE_STATE_LABEL[source.state] });
8762
+ }
8763
+ return watching;
8764
+ }
8765
+ function result(state, found, checkedAt, snapshot = null, remoteVersion = null) {
8766
+ const health = {
8767
+ id,
8768
+ label: label2,
8769
+ machine: remote.name,
8770
+ state,
8771
+ detail: found.detail,
8772
+ watching: facts(snapshot, remoteVersion),
8773
+ checkedAt
8774
+ };
8775
+ if (found.advice) health.advice = found.advice;
8776
+ if (agents.length > 0) health.agents = agents;
8777
+ const sessions = state === "ok" && snapshot ? snapshot.sessions : [];
8778
+ return state === "ok" ? { health, sessions, basis: BASIS3 } : { health, sessions };
8779
+ }
8780
+ function connecting(at) {
8781
+ if (problem === null) {
8782
+ connectingSince ??= at;
8783
+ if (at - connectingSince < stillConnectingMs) {
8784
+ return result(
8785
+ "searching",
8786
+ { detail: `Connecting to ${remote.name} over SSH, as ${remote.target}.` },
8787
+ at
8788
+ );
8789
+ }
8790
+ return result(
8791
+ NOT_CONNECTED,
8792
+ {
8793
+ detail: `Still connecting to ${remote.name} over SSH, as ${remote.target}.`,
8794
+ advice: `ssh has not connected yet. If this goes on, check that ssh ${remote.target} connects from a terminal on this computer, and that ${remote.name} is switched on and reachable.`
8795
+ },
8796
+ at
8797
+ );
8798
+ }
8799
+ return result(
8800
+ NOT_CONNECTED,
8801
+ { detail: `${problem.detail} Connecting again.`, advice: problem.advice },
8802
+ at
8803
+ );
8804
+ }
8805
+ function endProblem(end) {
8806
+ const said = end.said === null ? null : sentence(end.said);
8807
+ if (end.connected) {
8808
+ return {
8809
+ detail: `The SSH connection to ${remote.name} dropped${said ? `: ${said}` : "."}`,
8810
+ advice: "It is started again on its own. If it keeps dropping, check the network between the two machines."
8811
+ };
8812
+ }
8813
+ if (said !== null && /could not request local forwarding|cannot listen to port/i.test(said)) {
8814
+ return { detail: `ssh could not forward a port on this computer: ${said}` };
8815
+ }
8816
+ if (said !== null && /permission denied|host key verification failed|authentication|refused|closed by/i.test(said)) {
8817
+ return {
8818
+ detail: `${remote.name} turned the SSH connection away: ${said}`,
8819
+ advice: `Check that ssh ${remote.target} connects without asking for anything. Agent Lookout runs ssh with BatchMode, so it never types a password or answers a question.`
8820
+ };
8821
+ }
8822
+ return {
8823
+ detail: said ? `ssh could not connect to ${remote.target}: ${said}` : `ssh could not connect to ${remote.target}, and ended${end.code === null ? "" : ` with code ${end.code}`}.`,
8824
+ advice: `Check that ssh ${remote.target} connects from a terminal on this computer.`
8825
+ };
8826
+ }
8827
+ function fromRead(done) {
8828
+ const { reading } = done;
8829
+ switch (reading.kind) {
8830
+ case "refused":
8831
+ return connecting(done.at);
8832
+ case "closed":
8833
+ problem = {
8834
+ detail: `ssh is connected to ${remote.name}, but no Agent Lookout answers on its port ${remote.port}.`,
8835
+ advice: `Start it there with npx agent-lookout, and leave it running. If it listens on another port, give it as ${remote.name}=${remote.target}:<port>.`
8836
+ };
8837
+ return result(NOT_CONNECTED, problem, done.at);
8838
+ case "timeout":
8839
+ problem = {
8840
+ detail: `${remote.name} did not answer within ${REMOTE_ANSWER_TIMEOUT_MS / 1e3} seconds.`,
8841
+ advice: "It keeps trying. ssh notices within a minute when the connection has gone."
8842
+ };
8843
+ return result(NOT_CONNECTED, problem, done.at);
8844
+ case "unreadable":
8845
+ problem = {
8846
+ detail: `What listens on ${where} ${reading.why}, so it is not read as Agent Lookout.`,
8847
+ advice: `Check that Agent Lookout is what listens there, or give its port as ${remote.name}=${remote.target}:<port>.`
8848
+ };
8849
+ return result(NOT_CONNECTED, problem, done.at);
8850
+ case "read":
8851
+ if (done.snapshot === null) {
8852
+ problem = {
8853
+ detail: `${remote.name} runs Agent Lookout ${reading.version}, and this computer's, ${version}, cannot read its list of sessions.`,
8854
+ advice: "Run the same version of Agent Lookout on both machines."
8855
+ };
8856
+ return result(NOT_CONNECTED, problem, done.at, null, reading.version);
8857
+ }
8858
+ problem = null;
8859
+ connectingSince = null;
8860
+ return result(
8861
+ "ok",
8862
+ {
8863
+ detail: `Sessions are read from Agent Lookout ${reading.version} on ${remote.name}, through ssh to ${remote.target}. Jump, Stop, Allow and Deny act on this computer only.`
8864
+ },
8865
+ done.at,
8866
+ done.snapshot,
8867
+ reading.version
8868
+ );
8869
+ }
8870
+ }
8871
+ function readThrough(run, port) {
8872
+ if (underWay) return underWay;
8873
+ const current = read(port).catch(() => ({ kind: "closed" })).then((reading) => {
8874
+ const at = now();
8875
+ if (reading.kind !== "refused") tunnel.connected(run);
8876
+ const snapshot = reading.kind === "read" ? readRemoteSnapshot(reading.snapshot, remote.name, at, { waitingText: waitingText2 }) : null;
8877
+ if (snapshot) agents = snapshot.agents;
8878
+ latest = { run, at, reading, snapshot };
8879
+ }).finally(() => {
8880
+ underWay = null;
8881
+ });
8882
+ underWay = current;
8883
+ return current;
8884
+ }
8885
+ async function poll() {
8886
+ const at = now();
8887
+ const state = tunnel.state();
8888
+ switch (state.kind) {
8889
+ case "off":
8890
+ return connecting(at);
8891
+ case "no-ssh":
8892
+ latest = null;
8893
+ problem = {
8894
+ detail: `${state.looked}, so ${remote.name} cannot be reached. Looking again ${inSeconds(state.retryAt, at)}.`,
8895
+ advice: state.named ? `Correct ${SSH_BIN_ENV}, or unset it to use the ssh on your PATH.` : `Install OpenSSH, or name the ssh program with ${SSH_BIN_ENV}.`
8896
+ };
8897
+ return result(NOT_CONNECTED, problem, at);
8898
+ case "ended": {
8899
+ latest = null;
8900
+ problem = endProblem(state.end);
8901
+ return result(
8902
+ NOT_CONNECTED,
8903
+ {
8904
+ detail: `${problem.detail} Trying again ${inSeconds(state.retryAt, at)}.`,
8905
+ advice: problem.advice
8906
+ },
8907
+ at
8908
+ );
8909
+ }
8910
+ case "running": {
8911
+ command = state.command.join(" ");
8912
+ if (latest && latest.run !== state.run) latest = null;
8913
+ await within2(readThrough(state.run, state.port), answerWaitMs);
8914
+ return latest && latest.run === state.run ? fromRead(latest) : connecting(at);
8915
+ }
8916
+ }
8917
+ }
8918
+ return {
8919
+ id,
8920
+ label: label2,
8921
+ lookingIn: `Connecting to ${remote.name} over SSH, as ${remote.target}.`,
8922
+ async poll() {
8923
+ try {
8924
+ return await poll();
8925
+ } catch {
8926
+ return result(
8927
+ "error",
8928
+ { detail: `Something unexpected went wrong while reading ${remote.name}.` },
8929
+ now()
8930
+ );
8931
+ }
8932
+ }
8933
+ };
8934
+ }
8935
+
8936
+ // src/collector/remotes/remoteSettings.ts
8937
+ var REMOTES_ENV = "AGENT_LOOKOUT_REMOTES";
8938
+ var DEFAULT_REMOTE_PORT = 4777;
8939
+ var MAX_REMOTES = 8;
8940
+ var USER = "[A-Za-z0-9_][A-Za-z0-9._-]{0,63}";
8941
+ var HOST = "[A-Za-z0-9_][A-Za-z0-9._-]{0,252}";
8942
+ var TARGET = new RegExp(`^(?:${USER}@)?${HOST}$`);
8943
+ function isSshTarget(value) {
8944
+ return TARGET.test(value);
8945
+ }
8946
+ function portOf(text4) {
8947
+ if (!/^\d{1,5}$/.test(text4)) return null;
8948
+ const port = Number(text4);
8949
+ return port >= 1 && port <= 65535 ? port : null;
8950
+ }
8951
+ function ordinal(index) {
8952
+ const n = index + 1;
8953
+ const tens = n % 100;
8954
+ if (tens >= 11 && tens <= 13) return `${n}th`;
8955
+ return `${n}${n % 10 === 1 ? "st" : n % 10 === 2 ? "nd" : n % 10 === 3 ? "rd" : "th"}`;
8956
+ }
8957
+ var EXAMPLE = "such as devbox=dev@devbox.local";
8958
+ function readEntry(entry, index) {
8959
+ const equals = entry.indexOf("=");
8960
+ if (equals < 0) {
8961
+ return `${REMOTES_ENV} has an entry, the ${ordinal(index)}, with no = in it. Write each machine as name=target, ${EXAMPLE}.`;
8962
+ }
8963
+ const name = entry.slice(0, equals).trim();
8964
+ if (!isMachineName(name)) {
8965
+ return `${REMOTES_ENV} gives the ${ordinal(index)} machine a name that is not one: use letters, digits and dashes, up to ${MAX_MACHINE_NAME_LENGTH}, starting with a letter or a digit.`;
8966
+ }
8967
+ let target = entry.slice(equals + 1).trim();
8968
+ let port = DEFAULT_REMOTE_PORT;
8969
+ const colon = target.lastIndexOf(":");
8970
+ if (colon >= 0) {
8971
+ const given = portOf(target.slice(colon + 1));
8972
+ if (given === null) {
8973
+ return `${REMOTES_ENV} gives ${name} a port that is not a number from 1 to 65535.`;
8974
+ }
8975
+ port = given;
8976
+ target = target.slice(0, colon);
8977
+ }
8978
+ if (!isSshTarget(target)) {
8979
+ return `${REMOTES_ENV} gives ${name} a target that Agent Lookout does not hand to ssh. Give a host alias from your ssh config, a host name or user@host, with no spaces, no leading dash and no other punctuation.`;
8980
+ }
8981
+ return { name, target, port };
8982
+ }
8983
+ function readRemotesSetup(env) {
8984
+ const value = env[REMOTES_ENV]?.trim() ?? "";
8985
+ if (value === "") return { on: false, problem: null };
8986
+ const entries = value.split(",").map((entry) => entry.trim()).filter((entry) => entry !== "");
8987
+ if (entries.length === 0) return { on: false, problem: null };
8988
+ if (entries.length > MAX_REMOTES) {
8989
+ return {
8990
+ on: false,
8991
+ problem: `${REMOTES_ENV} names ${entries.length} machines, and Agent Lookout reads ${MAX_REMOTES} at most.`
8992
+ };
8993
+ }
8994
+ const remotes = [];
8995
+ const names = /* @__PURE__ */ new Set();
8996
+ for (const [index, entry] of entries.entries()) {
8997
+ const read = readEntry(entry, index);
8998
+ if (typeof read === "string") return { on: false, problem: read };
8999
+ const key = read.name.toLowerCase();
9000
+ if (names.has(key)) {
9001
+ return { on: false, problem: `${REMOTES_ENV} names ${read.name} twice.` };
9002
+ }
9003
+ names.add(key);
9004
+ remotes.push(read);
9005
+ }
9006
+ return { on: true, remotes };
9007
+ }
9008
+ function remotesProblemLine(problem) {
9009
+ return `Other machines are not read: ${problem}`;
9010
+ }
9011
+
9012
+ // src/collector/remotes/tunnel.ts
9013
+ import { spawn as spawn2 } from "node:child_process";
9014
+ import { createServer as createServer2 } from "node:net";
9015
+ var RESTART_DELAYS_MS = [1e3, 2e3, 5e3, 1e4, 3e4, 6e4];
9016
+ var MAX_SAID_BYTES = 4096;
9017
+ var MAX_SAID_LENGTH = 200;
9018
+ var STOP_WAIT_MS2 = 2e3;
9019
+ function freeLoopbackPort() {
9020
+ return new Promise((resolve, reject) => {
9021
+ const server = createServer2();
9022
+ server.unref();
9023
+ server.once("error", reject);
9024
+ server.listen(0, "127.0.0.1", () => {
9025
+ const { port } = server.address();
9026
+ server.close(() => resolve(port));
9027
+ });
9028
+ });
9029
+ }
9030
+ var spawnSsh = (binary, args, env) => spawn2(binary, [...args], { env, stdio: ["ignore", "ignore", "pipe"], windowsHide: true });
9031
+ var live = /* @__PURE__ */ new Set();
9032
+ var endedOnExit = false;
9033
+ function endOnExit(child) {
9034
+ live.add(child);
9035
+ child.once("exit", () => live.delete(child));
9036
+ if (endedOnExit) return;
9037
+ endedOnExit = true;
9038
+ process.once("exit", () => {
9039
+ for (const running of live) running.kill("SIGTERM");
9040
+ });
9041
+ }
9042
+ function lastLine(text4) {
9043
+ const lines = text4.split(/\r?\n/).map((line2) => clean(line2)).filter((line2) => line2 !== void 0);
9044
+ const reasons = lines.filter((line2) => !/^warning:/i.test(line2));
9045
+ const line = reasons[reasons.length - 1] ?? lines[lines.length - 1];
9046
+ return line === void 0 ? null : oneLine(line, MAX_SAID_LENGTH);
9047
+ }
9048
+ function createTunnel(options) {
9049
+ const { remote, env } = options;
9050
+ const find = options.findSsh ?? findSsh;
9051
+ const freePort = options.freePort ?? freeLoopbackPort;
9052
+ const start = options.spawnSsh ?? spawnSsh;
9053
+ const delays = options.restartDelaysMs ?? RESTART_DELAYS_MS;
9054
+ const now = options.now ?? Date.now;
9055
+ let state = { kind: "off" };
9056
+ let on = false;
9057
+ let generation = 0;
9058
+ let run = 0;
9059
+ let unanswered = 0;
9060
+ let connectedRun = null;
9061
+ let child = null;
9062
+ let timer = null;
9063
+ let exited = Promise.resolve();
9064
+ function notStarted(said) {
9065
+ const at = now();
9066
+ return {
9067
+ kind: "ended",
9068
+ run,
9069
+ end: { at, connected: false, code: null, said },
9070
+ retryAt: later(nextDelay(), at)
9071
+ };
9072
+ }
9073
+ function nextDelay() {
9074
+ const delay = delays[Math.min(unanswered, delays.length - 1)] ?? 1e3;
9075
+ unanswered += 1;
9076
+ return delay;
9077
+ }
9078
+ function later(delay, from = now()) {
9079
+ if (timer) clearTimeout(timer);
9080
+ const current = generation;
9081
+ timer = setTimeout(() => {
9082
+ timer = null;
9083
+ void launch(current);
9084
+ }, delay);
9085
+ timer.unref?.();
9086
+ return from + delay;
9087
+ }
9088
+ let launching = false;
9089
+ let again = false;
9090
+ async function launch(current) {
9091
+ if (launching) {
9092
+ again = true;
9093
+ return;
9094
+ }
9095
+ launching = true;
9096
+ try {
9097
+ await prepare(current);
9098
+ } finally {
9099
+ launching = false;
9100
+ }
9101
+ if (again) {
9102
+ again = false;
9103
+ void launch(generation);
9104
+ }
9105
+ }
9106
+ async function prepare(current) {
9107
+ const stale = () => !on || current !== generation;
9108
+ if (stale() || child) return;
9109
+ const search = await find(env);
9110
+ if (stale()) return;
9111
+ if (!search.found) {
9112
+ state = { kind: "no-ssh", looked: search.looked, retryAt: later(nextDelay()) };
9113
+ if (search.named) state.named = true;
9114
+ return;
9115
+ }
9116
+ let port;
9117
+ try {
9118
+ port = await freePort();
9119
+ } catch {
9120
+ state = notStarted("No free port on 127.0.0.1.");
9121
+ return;
9122
+ }
9123
+ if (stale() || child) return;
9124
+ run += 1;
9125
+ const thisRun = run;
9126
+ const args = sshArguments({ localPort: port, remotePort: remote.port, target: remote.target });
9127
+ let ssh;
9128
+ try {
9129
+ ssh = start(search.path, args, env);
9130
+ } catch {
9131
+ state = notStarted("ssh could not be started.");
9132
+ return;
9133
+ }
9134
+ child = ssh;
9135
+ endOnExit(ssh);
9136
+ state = { kind: "running", run: thisRun, port, since: now(), command: [search.path, ...args] };
9137
+ let said = "";
9138
+ ssh.stderr?.setEncoding("utf8");
9139
+ ssh.stderr?.on("data", (chunk) => {
9140
+ said = (said + chunk).slice(-MAX_SAID_BYTES);
9141
+ });
9142
+ ssh.unref();
9143
+ ssh.stderr?.unref?.();
9144
+ let gone = () => {
9145
+ };
9146
+ exited = new Promise((resolve) => gone = resolve);
9147
+ ssh.once("exit", () => {
9148
+ if (child === ssh) child = null;
9149
+ gone();
9150
+ if (current !== generation && on && child === null) {
9151
+ if (state.kind === "running" && state.run === thisRun) state = { kind: "off" };
9152
+ void launch(generation);
9153
+ }
9154
+ });
9155
+ let over = false;
9156
+ const finish = (code, failure2) => {
9157
+ if (over) return;
9158
+ over = true;
9159
+ if (child === ssh) child = null;
9160
+ gone();
9161
+ const connected = connectedRun === thisRun;
9162
+ if (connected) unanswered = 0;
9163
+ if (current !== generation) return;
9164
+ const at = now();
9165
+ const end = { at, connected, code, said: lastLine(said) ?? failure2 };
9166
+ state = { kind: "ended", run: thisRun, end, retryAt: later(nextDelay(), at) };
9167
+ };
9168
+ ssh.once("close", (code) => finish(typeof code === "number" ? code : null, null));
9169
+ ssh.once("error", () => {
9170
+ if (ssh.pid === void 0) finish(null, "ssh could not be started.");
9171
+ });
9172
+ }
9173
+ function ended(gone) {
9174
+ return new Promise((resolve) => {
9175
+ const timeout = setTimeout(() => resolve(false), STOP_WAIT_MS2);
9176
+ timeout.unref?.();
9177
+ void gone.then(() => {
9178
+ clearTimeout(timeout);
9179
+ resolve(true);
9180
+ });
9181
+ });
9182
+ }
9183
+ return {
9184
+ start() {
9185
+ if (on) return;
9186
+ on = true;
9187
+ generation += 1;
9188
+ unanswered = 0;
9189
+ void launch(generation);
9190
+ },
9191
+ async stop() {
9192
+ on = false;
9193
+ generation += 1;
9194
+ if (timer) clearTimeout(timer);
9195
+ timer = null;
9196
+ const running = child;
9197
+ const gone = exited;
9198
+ if (running) {
9199
+ running.kill("SIGTERM");
9200
+ if (!await ended(gone)) {
9201
+ running.kill("SIGKILL");
9202
+ await ended(gone);
9203
+ }
9204
+ }
9205
+ if (!on) state = { kind: "off" };
9206
+ },
9207
+ state: () => state,
9208
+ connected(which) {
9209
+ if (state.kind === "running" && state.run === which) connectedRun = which;
6916
9210
  }
6917
9211
  };
6918
9212
  }
6919
9213
 
6920
- // src/collector/history/historySettings.ts
6921
- import { createHash as createHash3 } from "node:crypto";
6922
- import os6 from "node:os";
6923
- import path17 from "node:path";
6924
- var HISTORY_ENV = "AGENT_LOOKOUT_HISTORY";
6925
- var HISTORY_DIR_ENV = "AGENT_LOOKOUT_HISTORY_DIR";
6926
- function readHistorySetup(env, homeDir = os6.homedir()) {
6927
- if (env[HISTORY_ENV]?.trim().toLowerCase() === "off") return { on: false };
6928
- const override = env[HISTORY_DIR_ENV]?.trim() || void 0;
6929
- const dir = path17.resolve(override ?? path17.join(homeDir, ".agent-lookout", "history"));
9214
+ // src/collector/remotes/remotes.ts
9215
+ var REMOTES_SETTING_SOURCE_ID = remoteSourceId("");
9216
+ function settingAdapter(problem, now) {
6930
9217
  return {
6931
- on: true,
6932
- dir,
6933
- folder: tildify(dir, homeDir),
6934
- sources: sourcesFingerprint(env, homeDir)
9218
+ id: REMOTES_SETTING_SOURCE_ID,
9219
+ label: "Other machines",
9220
+ async poll() {
9221
+ return {
9222
+ health: {
9223
+ id: REMOTES_SETTING_SOURCE_ID,
9224
+ label: "Other machines",
9225
+ state: "not-set-up",
9226
+ detail: problem,
9227
+ advice: `Correct ${REMOTES_ENV}, or unset it, and start Agent Lookout again. Until then no other machine is read and ssh is never run.`,
9228
+ watching: [{ label: "Setting", value: REMOTES_ENV }],
9229
+ checkedAt: now()
9230
+ },
9231
+ sessions: []
9232
+ };
9233
+ }
6935
9234
  };
6936
9235
  }
6937
- function sourcesFingerprint(env, homeDir = os6.homedir()) {
6938
- const set = (name) => env[name]?.trim() || void 0;
6939
- const folders = [
6940
- path17.resolve(set(CLAUDE_HOME_ENV) ?? path17.join(homeDir, ".claude")),
6941
- path17.resolve(set(CODEX_HOME_ENV) ?? set(CODEX_OWN_HOME_ENV) ?? path17.join(homeDir, ".codex")),
6942
- path17.resolve(set(STATUS_DIR_ENV) ?? path17.join(homeDir, ".agent-lookout", "sessions"))
6943
- ];
6944
- return createHash3("sha256").update(JSON.stringify(folders)).digest("hex").slice(0, 32);
6945
- }
6946
-
6947
- // src/collector/notifications/heldWait.ts
6948
- var HANDOVER_GRACE_MS = 3e3;
6949
- var PAGE_GONE_AFTER_MS = 5e3;
6950
- function noPageReports(on) {
6951
- return { on, lastOnAt: null, lastOnSessionsAt: null };
6952
- }
6953
- function heldWaitOutcome(since, pages, now) {
6954
- if (!pages.on) return "drop";
6955
- if (pages.lastOnSessionsAt !== null && pages.lastOnSessionsAt > since) return "drop";
6956
- if (pages.lastOnAt === null || now - pages.lastOnAt > PAGE_GONE_AFTER_MS) return "show";
6957
- return now - since >= HANDOVER_GRACE_MS ? "show" : "hold";
9236
+ function createRemotes(options) {
9237
+ const setup = readRemotesSetup(options.env);
9238
+ const remotes = setup.on ? setup.remotes : [];
9239
+ const waitingText2 = !waitingTextOff(options.env);
9240
+ const tunnels = remotes.map(
9241
+ (remote) => createTunnel({ remote, env: options.env, now: options.now, ...options.tunnels })
9242
+ );
9243
+ const adapters = remotes.map(
9244
+ (remote, index) => createRemoteAdapter({
9245
+ remote,
9246
+ tunnel: tunnels[index],
9247
+ version: options.version,
9248
+ read: options.read,
9249
+ now: options.now,
9250
+ pollIntervalMs: options.pollIntervalMs,
9251
+ waitingText: waitingText2
9252
+ })
9253
+ );
9254
+ const problem = setup.on ? null : setup.problem;
9255
+ return {
9256
+ remotes,
9257
+ adapters: problem === null ? adapters : [settingAdapter(problem, options.now ?? Date.now)],
9258
+ problem,
9259
+ start() {
9260
+ for (const tunnel of tunnels) tunnel.start();
9261
+ },
9262
+ async stop() {
9263
+ await Promise.all(tunnels.map((tunnel) => tunnel.stop()));
9264
+ }
9265
+ };
6958
9266
  }
6959
9267
 
6960
- // src/collector/notifications/serverNotifications.ts
6961
- var NOTIFICATIONS_ENV = "AGENT_LOOKOUT_NOTIFICATIONS";
6962
- function notificationsOnAtStart(env) {
6963
- return env[NOTIFICATIONS_ENV]?.trim().toLowerCase() === "on";
9268
+ // src/collector/settings/settingsFile.ts
9269
+ import { randomBytes as randomBytes3 } from "node:crypto";
9270
+ import {
9271
+ closeSync as closeSync2,
9272
+ constants as constants5,
9273
+ fstatSync as fstatSync2,
9274
+ fsyncSync,
9275
+ lstatSync as lstatSync2,
9276
+ mkdirSync,
9277
+ openSync as openSync2,
9278
+ readSync as readSync2,
9279
+ renameSync,
9280
+ unlinkSync as unlinkSync2,
9281
+ writeSync as writeSync2
9282
+ } from "node:fs";
9283
+ import os9 from "node:os";
9284
+ import path22 from "node:path";
9285
+ var SETTINGS_FILE_ENV = "AGENT_LOOKOUT_SETTINGS_FILE";
9286
+ var SETTINGS_FILE_MODE = 384;
9287
+ var SETTINGS_FOLDER_MODE = 448;
9288
+ var MAX_SETTINGS_BYTES = 64 * 1024;
9289
+ function readSettingsSetup(env, homeDir = os9.homedir()) {
9290
+ const named = env[SETTINGS_FILE_ENV]?.trim() || void 0;
9291
+ const file = path22.resolve(named ?? path22.join(homeDir, ".agent-lookout", "settings.json"));
9292
+ return { file, shown: tildify(file, homeDir) };
9293
+ }
9294
+ var NO_FOLLOW2 = (constants5.O_NOFOLLOW ?? 0) | (constants5.O_NONBLOCK ?? 0);
9295
+ function codeOf(error) {
9296
+ return error?.code;
6964
9297
  }
6965
- var NOTIFICATIONS_NOT_SHOWN_LINE = `${NOTIFICATIONS_ENV} is on, but Agent Lookout shows notifications itself on macOS only, so it shows none here. A dashboard tab with notifications on still shows them.`;
6966
- function notificationsAtStartLine(env, notifierShows) {
6967
- return notificationsOnAtStart(env) && !notifierShows ? NOTIFICATIONS_NOT_SHOWN_LINE : null;
9298
+ function kindAt(target) {
9299
+ try {
9300
+ const info = lstatSync2(target);
9301
+ if (info.isSymbolicLink()) return "link";
9302
+ if (info.isFile()) return "file";
9303
+ return info.isDirectory() ? "folder" : "other";
9304
+ } catch (error) {
9305
+ if (codeOf(error) === "ENOENT" || codeOf(error) === "ENOTDIR") return null;
9306
+ throw error;
9307
+ }
6968
9308
  }
6969
- function heldKey(event, sessionId) {
6970
- return `${event} ${sessionId}`;
9309
+ function syncFolder(folder) {
9310
+ let fd = null;
9311
+ try {
9312
+ fd = openSync2(folder, constants5.O_RDONLY);
9313
+ fsyncSync(fd);
9314
+ } catch {
9315
+ } finally {
9316
+ if (fd !== null) closeSync2(fd);
9317
+ }
6971
9318
  }
6972
- function createServerNotifications(options) {
6973
- const { notifier } = options;
6974
- const now = options.now ?? Date.now;
6975
- let memory = EMPTY_CHANGE_MEMORY;
6976
- let pages = noPageReports(options.onAtStart);
6977
- let chosen = new Set(options.onAtStart ? DEFAULT_NOTICE_EVENTS : []);
6978
- const held = /* @__PURE__ */ new Map();
6979
- function show(change) {
6980
- try {
6981
- notifier.show(changeNotice(change));
6982
- } catch {
9319
+ function readSettingsText(setup) {
9320
+ const { file, shown } = setup;
9321
+ const folder = path22.dirname(file);
9322
+ try {
9323
+ const folderKind = kindAt(folder);
9324
+ if (folderKind === null) return { kind: "missing" };
9325
+ if (folderKind !== "folder") {
9326
+ return {
9327
+ 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.`
9329
+ };
6983
9330
  }
9331
+ } catch {
9332
+ return { kind: "refused", problem: `${shown} could not be read, so the time rules are off.` };
6984
9333
  }
6985
- return {
6986
- pageSaid(said, fetchedSessions) {
6987
- const at = now();
6988
- chosen = new Set(said);
6989
- if (said.length === 0) {
6990
- pages = { ...pages, on: false };
6991
- return;
6992
- }
6993
- pages = {
6994
- on: true,
6995
- lastOnAt: at,
6996
- lastOnSessionsAt: fetchedSessions ? at : pages.lastOnSessionsAt
9334
+ let fd;
9335
+ try {
9336
+ fd = openSync2(file, constants5.O_RDONLY | NO_FOLLOW2);
9337
+ } catch (error) {
9338
+ const code = codeOf(error);
9339
+ 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.`
6997
9344
  };
6998
- },
6999
- handle(snapshot) {
7000
- const at = now();
7001
- const result = sessionChanges(memory, snapshot);
7002
- memory = result.memory;
7003
- for (const id of result.stopped) held.delete(heldKey("needs-you", id));
7004
- for (const change of result.changes) {
7005
- if (pages.on && chosen.has(change.event)) {
7006
- held.set(heldKey(change.event, change.session.id), { change, since: at });
7007
- }
7008
- }
7009
- const listed = new Map(snapshot.sessions.map((session) => [session.id, session]));
7010
- for (const entry of held.values()) {
7011
- const current = listed.get(entry.change.session.id);
7012
- if (entry.change.event === "needs-you" && current?.status === "needs-you") {
7013
- entry.change = { ...entry.change, session: current };
7014
- }
7015
- }
7016
- for (const [key, { change, since }] of held) {
7017
- const outcome = chosen.has(change.event) ? heldWaitOutcome(since, pages, at) : "drop";
7018
- if (outcome === "hold") continue;
7019
- held.delete(key);
7020
- if (outcome === "show") show(change);
7021
- }
7022
9345
  }
7023
- };
9346
+ return { kind: "refused", problem: `${shown} could not be read, so the time rules are off.` };
9347
+ }
9348
+ 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
+ }
9356
+ if (info.size > MAX_SETTINGS_BYTES) {
9357
+ return {
9358
+ kind: "refused",
9359
+ problem: `${shown} is larger than 64 KB, so it was not read and the time rules are off.`
9360
+ };
9361
+ }
9362
+ const buffer = Buffer.alloc(info.size);
9363
+ let filled = 0;
9364
+ while (filled < buffer.byteLength) {
9365
+ const read = readSync2(fd, buffer, filled, buffer.byteLength - filled, filled);
9366
+ if (read === 0) break;
9367
+ filled += read;
9368
+ }
9369
+ return { kind: "read", text: buffer.toString("utf8", 0, filled) };
9370
+ } catch {
9371
+ return { kind: "refused", problem: `${shown} could not be read, so the time rules are off.` };
9372
+ } finally {
9373
+ closeSync2(fd);
9374
+ }
7024
9375
  }
7025
-
7026
- // src/collector/processes/osascript.ts
7027
- import { execFile as execFile3 } from "node:child_process";
7028
- var OSASCRIPT = "/usr/bin/osascript";
7029
- var MAX_OUTPUT_BYTES4 = 64 * 1024;
7030
- var NOT_RUN2 = { ok: false, stderr: "", timedOut: false };
7031
- function runOsascript(args, options = {}) {
7032
- return new Promise((resolve) => {
9376
+ function writeSettingsText(setup, text4) {
9377
+ const { file, shown } = setup;
9378
+ const folder = path22.dirname(file);
9379
+ const notSaved = {
9380
+ ok: false,
9381
+ problem: `${shown} could not be written, so the change was not saved.`
9382
+ };
9383
+ let temporary = null;
9384
+ try {
9385
+ mkdirSync(folder, { recursive: true, mode: SETTINGS_FOLDER_MODE });
9386
+ if (kindAt(folder) !== "folder") {
9387
+ return {
9388
+ ok: false,
9389
+ problem: `The folder of ${shown} is a link or not a folder, which Agent Lookout does not write through, so the change was not saved.`
9390
+ };
9391
+ }
9392
+ const there = kindAt(file);
9393
+ if (there !== null && there !== "file") {
9394
+ return {
9395
+ ok: false,
9396
+ problem: `${shown} is a link or not an ordinary file, which Agent Lookout does not write through, so the change was not saved.`
9397
+ };
9398
+ }
9399
+ temporary = path22.join(
9400
+ folder,
9401
+ `.${path22.basename(file)}.${process.pid}.${randomBytes3(6).toString("hex")}.tmp`
9402
+ );
9403
+ const fd = openSync2(
9404
+ temporary,
9405
+ constants5.O_WRONLY | constants5.O_CREAT | constants5.O_EXCL | NO_FOLLOW2,
9406
+ SETTINGS_FILE_MODE
9407
+ );
7033
9408
  try {
7034
- const child = execFile3(
7035
- OSASCRIPT,
7036
- [...args],
7037
- {
7038
- timeout: options.timeoutMs ?? TERMINAL_JUMP_TIMEOUT_MS,
7039
- maxBuffer: MAX_OUTPUT_BYTES4,
7040
- encoding: "utf8",
7041
- windowsHide: true
7042
- },
7043
- (error, stdout, stderr) => {
7044
- if (!error) {
7045
- resolve({ ok: true, stdout: String(stdout ?? "") });
7046
- return;
7047
- }
7048
- const timedOut = error.killed === true;
7049
- resolve({ ok: false, stderr: String(stderr ?? "").trim(), timedOut });
7050
- }
7051
- );
7052
- child.stdin?.end();
7053
- } catch {
7054
- resolve(NOT_RUN2);
9409
+ writeSync2(fd, text4);
9410
+ fsyncSync(fd);
9411
+ } finally {
9412
+ closeSync2(fd);
7055
9413
  }
7056
- });
9414
+ renameSync(temporary, file);
9415
+ temporary = null;
9416
+ syncFolder(folder);
9417
+ return { ok: true };
9418
+ } catch {
9419
+ return notSaved;
9420
+ } finally {
9421
+ if (temporary !== null) {
9422
+ try {
9423
+ unlinkSync2(temporary);
9424
+ } catch {
9425
+ }
9426
+ }
9427
+ }
7057
9428
  }
7058
9429
 
7059
- // src/collector/notifications/systemNotifier.ts
7060
- var OSASCRIPT_TIMEOUT_MS = 5e3;
7061
- var SCRIPT = [
7062
- "on run argv",
7063
- "display notification (item 2 of argv) with title (item 1 of argv)",
7064
- "end run"
7065
- ];
7066
- function osascriptArgs(notice) {
7067
- return [
7068
- ...SCRIPT.flatMap((line) => ["-e", line]),
7069
- "--",
7070
- oneLine(notice.title),
7071
- oneLine(notice.body, MAX_NOTICE_TEXT_LENGTH)
7072
- ];
9430
+ // src/collector/settings/collectorSettings.ts
9431
+ var fileSettingsStore = {
9432
+ read: readSettingsText,
9433
+ write: writeSettingsText
9434
+ };
9435
+ var RULE_WORDS = {
9436
+ longWait: "the long wait reminder",
9437
+ idle: "the idle rule",
9438
+ quietHours: "the quiet hours"
9439
+ };
9440
+ function isRecord12(value) {
9441
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7073
9442
  }
7074
- function systemNotificationsShownOn(platform) {
7075
- return platform === "darwin";
9443
+ function listOf(names) {
9444
+ const words = names.map((name) => RULE_WORDS[name]);
9445
+ return words.length <= 1 ? words[0] ?? "" : `${words.slice(0, -1).join(", ")} and ${words[words.length - 1]}`;
7076
9446
  }
7077
- var runForNotification = (args) => runOsascript(args, { timeoutMs: OSASCRIPT_TIMEOUT_MS });
7078
- function createSystemNotifier(options = {}) {
7079
- const platform = options.platform ?? process.platform;
7080
- const run = options.run ?? runForNotification;
9447
+ function rulesIn(read, shown) {
9448
+ const off = readTimeRules(void 0).rules;
9449
+ if (read.kind === "missing") {
9450
+ return { rules: off, rest: {}, otherRules: {}, problem: null, writable: true };
9451
+ }
9452
+ if (read.kind === "refused") {
9453
+ return { rules: off, rest: {}, otherRules: {}, problem: read.problem, writable: false };
9454
+ }
9455
+ let value;
9456
+ try {
9457
+ value = JSON.parse(read.text);
9458
+ } catch {
9459
+ value = null;
9460
+ }
9461
+ if (!isRecord12(value)) {
9462
+ return {
9463
+ rules: off,
9464
+ rest: {},
9465
+ 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.`,
9467
+ writable: true
9468
+ };
9469
+ }
9470
+ const { timeRules, ...rest } = value;
9471
+ const { rules, unread } = readTimeRules(timeRules);
9472
+ const otherRules = isRecord12(timeRules) ? Object.fromEntries(
9473
+ Object.entries(timeRules).filter(
9474
+ ([name]) => !TIME_RULE_NAMES.includes(name)
9475
+ )
9476
+ ) : {};
9477
+ 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 };
9479
+ }
9480
+ function createCollectorSettings(options) {
9481
+ const { setup } = options;
9482
+ 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;
7081
9487
  return {
7082
- show(notice) {
7083
- if (!systemNotificationsShownOn(platform)) return;
7084
- try {
7085
- run(osascriptArgs(notice)).catch(() => {
7086
- });
7087
- } catch {
9488
+ timeRules: () => rules,
9489
+ status: () => ({ timeRules: rules, file: setup.shown, problem }),
9490
+ 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);
9500
+ if (!written.ok) {
9501
+ problem = written.problem;
9502
+ return written;
7088
9503
  }
9504
+ rules = next;
9505
+ problem = null;
9506
+ return written;
9507
+ },
9508
+ problemAtStart: first.problem
9509
+ };
9510
+ }
9511
+
9512
+ // src/collector/settings/timeRulesRoute.ts
9513
+ var MAX_TIME_RULES_BODY_BYTES = 2048;
9514
+ var failed3 = (status, reason, error) => ({
9515
+ status,
9516
+ body: { error, reason }
9517
+ });
9518
+ function timeRulesRefusalFor(req) {
9519
+ return actionRefusalFor(req, TIME_RULES_ACTION, MAX_TIME_RULES_BODY_BYTES);
9520
+ }
9521
+ function createTimeRulesRoute(options) {
9522
+ const { settings } = options;
9523
+ return async function answerTimeRules(req) {
9524
+ const refused = timeRulesRefusalFor(req);
9525
+ if (refused) {
9526
+ req.resume();
9527
+ return refused;
9528
+ }
9529
+ const body = await readRequestBody(req, MAX_TIME_RULES_BODY_BYTES);
9530
+ if (!body.ok) {
9531
+ req.resume();
9532
+ return body.tooLarge ? refusal(413, `The body must be no more than ${MAX_TIME_RULES_BODY_BYTES} bytes.`) : refusal(400, "The body could not be read.");
9533
+ }
9534
+ let value;
9535
+ try {
9536
+ value = JSON.parse(body.text);
9537
+ } catch {
9538
+ return failed3(400, "invalid", "The body must be JSON: the three time rules.");
7089
9539
  }
9540
+ const asked = timeRulesIn(value);
9541
+ if (!asked.ok) return failed3(400, "invalid", asked.problem);
9542
+ const saved = settings.changeTimeRules(asked.rules);
9543
+ if (!saved.ok) return failed3(500, "not-saved", saved.problem);
9544
+ return {
9545
+ status: 200,
9546
+ body: { ok: true, timeRules: asked.rules }
9547
+ };
7090
9548
  };
7091
9549
  }
7092
9550
 
@@ -7126,11 +9584,11 @@ function createOsascriptRunner(options) {
7126
9584
  }
7127
9585
 
7128
9586
  // src/collector/terminal/tabFinder.ts
7129
- import os7 from "node:os";
9587
+ import os10 from "node:os";
7130
9588
  var TAB_LOOK_SOONEST_MS = 5e3;
7131
9589
  function createTabFinder(options) {
7132
9590
  const off = terminalJumpOff(options.env);
7133
- const homeDir = options.homeDir ?? os7.homedir();
9591
+ const homeDir = options.homeDir ?? os10.homedir();
7134
9592
  const readProcesses = options.readProcesses ?? readProcessTableWithPs;
7135
9593
  const now = options.now ?? Date.now;
7136
9594
  const soonestMs = options.soonestMs ?? TAB_LOOK_SOONEST_MS;
@@ -7408,14 +9866,14 @@ function createHttpSender(settings, options) {
7408
9866
  const timeoutMs = options.timeoutMs ?? WEBHOOK_TIMEOUT_MS;
7409
9867
  let client = null;
7410
9868
  const load = () => {
7411
- client ??= url.protocol === "https:" ? import("node:https").then(({ request }) => request) : import("node:http").then(({ request }) => request);
9869
+ client ??= url.protocol === "https:" ? import("node:https").then(({ request: request2 }) => request2) : import("node:http").then(({ request: request2 }) => request2);
7412
9870
  return client;
7413
9871
  };
7414
9872
  return {
7415
9873
  async send(post) {
7416
- let request;
9874
+ let request2;
7417
9875
  try {
7418
- request = await load();
9876
+ request2 = await load();
7419
9877
  } catch {
7420
9878
  return { sent: false, reason: "the post could not be sent" };
7421
9879
  }
@@ -7430,7 +9888,7 @@ function createHttpSender(settings, options) {
7430
9888
  let req;
7431
9889
  try {
7432
9890
  body = JSON.stringify(post);
7433
- req = request(url, requestOptions(body, options.version));
9891
+ req = request2(url, requestOptions(body, options.version));
7434
9892
  } catch {
7435
9893
  finish({ sent: false, reason: "the post could not be sent" });
7436
9894
  return;
@@ -7515,6 +9973,11 @@ function createCollector(options) {
7515
9973
  const env = options.env ?? process.env;
7516
9974
  const intervalMs = options.intervalMs ?? POLL_INTERVAL_MS;
7517
9975
  const warn = options.warn ?? console.error;
9976
+ const settings = createCollectorSettings({
9977
+ setup: readSettingsSetup(env),
9978
+ store: options.settingsStore
9979
+ });
9980
+ if (settings.problemAtStart !== null) warn(settings.problemAtStart);
7518
9981
  const memoryEvents = createEventStore();
7519
9982
  const memoryHistory = createHistoryStore();
7520
9983
  const historySetup = readHistorySetup(env);
@@ -7585,34 +10048,46 @@ function createCollector(options) {
7585
10048
  if (!webhookSetup.on && webhookSetup.problem !== null) {
7586
10049
  warn(webhookProblemLine(webhookSetup.problem));
7587
10050
  }
7588
- const createWebhookSender = options.createWebhookSender ?? ((settings) => createHttpSender(settings, { version: options.version }));
10051
+ const createWebhookSender = options.createWebhookSender ?? ((settings2) => createHttpSender(settings2, { version: options.version }));
7589
10052
  const webhook = webhookSetup.on ? createWebhookNotifications({
7590
10053
  settings: webhookSetup.settings,
7591
10054
  sender: createWebhookSender(webhookSetup.settings),
7592
10055
  now
7593
10056
  }) : null;
7594
10057
  const webhookOff = webhookOffStatus(webhookSetup.on ? null : webhookSetup.problem);
10058
+ const remotes = createRemotes({
10059
+ env,
10060
+ version: options.version,
10061
+ now,
10062
+ pollIntervalMs: intervalMs,
10063
+ ...options.remotes
10064
+ });
10065
+ if (remotes.problem !== null) warn(remotesProblemLine(remotes.problem));
10066
+ const localAdapters = options.adapters ?? [
10067
+ createClaudeCodeAdapter({
10068
+ env: options.env,
10069
+ now,
10070
+ pollIntervalMs: intervalMs,
10071
+ panes,
10072
+ terminals: tabs,
10073
+ readProcessStarts: options.readProcessStarts,
10074
+ stops: stopTargets
10075
+ }),
10076
+ createCodexAdapter({ env: options.env, now, pollIntervalMs: intervalMs }),
10077
+ createStatusFileAdapter({ env: options.env, now, pollIntervalMs: intervalMs })
10078
+ ];
7595
10079
  const poller = createPoller({
7596
10080
  // Each adapter is told how often it will be polled so that it can say so.
7597
- adapters: options.adapters ?? [
7598
- createClaudeCodeAdapter({
7599
- env: options.env,
7600
- now,
7601
- pollIntervalMs: intervalMs,
7602
- panes,
7603
- terminals: tabs,
7604
- readProcessStarts: options.readProcessStarts,
7605
- stops: stopTargets
7606
- }),
7607
- createCodexAdapter({ env: options.env, now, pollIntervalMs: intervalMs }),
7608
- createStatusFileAdapter({ env: options.env, now, pollIntervalMs: intervalMs })
7609
- ],
10081
+ // The other machines come after this one's own sources.
10082
+ adapters: [...localAdapters, ...remotes.adapters],
7610
10083
  events,
7611
10084
  history,
7612
10085
  intervalMs,
7613
10086
  now,
7614
10087
  // Every source's sessions alike are given the branch of their folder, and
7615
10088
  // with pull requests on, the branch's pull request, as last learnt.
10089
+ // Every snapshot carries the time rules in force, and is stale by them.
10090
+ timeRules: () => settings.timeRules(),
7616
10091
  annotate: async (sessions) => {
7617
10092
  const onBranches = await branches.annotate(sessions);
7618
10093
  return pullRequests ? pullRequests.annotate(onBranches) : onBranches;
@@ -7620,12 +10095,15 @@ function createCollector(options) {
7620
10095
  // The poller runs for as long as the app does, with a dashboard open or
7621
10096
  // not, so a wait that begins with no page open is still seen here.
7622
10097
  onSnapshot: (snapshot) => {
10098
+ answering?.observe(snapshot);
7623
10099
  email?.handle(snapshot);
7624
10100
  webhook?.handle(snapshot);
7625
10101
  notifications.handle(snapshot);
7626
10102
  options.onSnapshot?.(snapshot);
7627
10103
  }
7628
10104
  });
10105
+ const answers2 = options.adapters === void 0 || options.answering !== void 0;
10106
+ const answering = answers2 ? createAnswering({ env, poller, events, now, warn, ...options.answering }) : null;
7629
10107
  const stopper = createStopper({
7630
10108
  env,
7631
10109
  now,
@@ -7638,11 +10116,14 @@ function createCollector(options) {
7638
10116
  events,
7639
10117
  targets: stopTargets,
7640
10118
  stopper,
7641
- limiter: createActionLimiter(now)
10119
+ limiter: createActionLimiter(now),
10120
+ staleAfterMs: () => staleAfterMs(settings.timeRules())
7642
10121
  };
7643
10122
  const handler = createApiHandler({
7644
10123
  version: options.version,
7645
10124
  poller,
10125
+ serveSnapshot: answering ? (snapshot) => answering.serve(snapshot) : void 0,
10126
+ answer: answering?.route,
7646
10127
  events,
7647
10128
  history,
7648
10129
  notifications,
@@ -7652,6 +10133,8 @@ function createCollector(options) {
7652
10133
  jump: createJumpRoute({ poller, panes, run: tmux, tabs, osascript, now }),
7653
10134
  stop: stopRoutes && createStopRoute(stopRoutes),
7654
10135
  cleanUp: stopRoutes && createCleanUpRoute(stopRoutes),
10136
+ settings: () => settings.status(),
10137
+ timeRules: createTimeRulesRoute({ settings }),
7655
10138
  clearHistory: createClearHistoryRoute({
7656
10139
  keeper,
7657
10140
  forget: () => {
@@ -7681,6 +10164,7 @@ function createCollector(options) {
7681
10164
  now
7682
10165
  });
7683
10166
  let running = false;
10167
+ let answerStart = null;
7684
10168
  let restored = keeper === null;
7685
10169
  let restoring = null;
7686
10170
  let restarts = [];
@@ -7699,6 +10183,8 @@ function createCollector(options) {
7699
10183
  start() {
7700
10184
  if (running) return;
7701
10185
  running = true;
10186
+ answerStart ??= answering?.start() ?? Promise.resolve();
10187
+ remotes.start();
7702
10188
  if (restored || keeper === null) {
7703
10189
  begin();
7704
10190
  return;
@@ -7724,13 +10210,24 @@ function createCollector(options) {
7724
10210
  running = false;
7725
10211
  poller.stop();
7726
10212
  keeper?.stop();
10213
+ answerStart = null;
10214
+ void answering?.stop();
10215
+ void remotes.stop();
7727
10216
  },
7728
10217
  handler,
7729
10218
  poller,
7730
10219
  email,
7731
10220
  webhook,
7732
10221
  pullRequests,
7733
- history: keeper
10222
+ history: keeper,
10223
+ answering: {
10224
+ start: () => answerStart ??= answering?.start() ?? Promise.resolve(),
10225
+ stop: async () => {
10226
+ await answering?.stop();
10227
+ }
10228
+ },
10229
+ remotes,
10230
+ settings
7734
10231
  };
7735
10232
  }
7736
10233
 
@@ -7751,7 +10248,7 @@ async function runStandalone(options) {
7751
10248
  } catch (error) {
7752
10249
  return stopWith(error.message);
7753
10250
  }
7754
- if (!existsSync(path18.join(distDir, "index.html"))) {
10251
+ if (!existsSync(path23.join(distDir, "index.html"))) {
7755
10252
  return stopWith(
7756
10253
  "The dashboard has not been built yet. Run `npm run build`, then start it again."
7757
10254
  );
@@ -7793,7 +10290,7 @@ async function runStandalone(options) {
7793
10290
  }
7794
10291
 
7795
10292
  // src/cli/start/openBrowser.ts
7796
- import { spawn as spawn2 } from "node:child_process";
10293
+ import { spawn as spawn3 } from "node:child_process";
7797
10294
  var OPEN_WAIT_MS = 1e4;
7798
10295
  function openCommand(platform, url) {
7799
10296
  const address = loopbackAddress(url);
@@ -7810,7 +10307,7 @@ function runProgram2(file, args, waitMs = OPEN_WAIT_MS) {
7810
10307
  resolve(opened);
7811
10308
  };
7812
10309
  try {
7813
- const child = spawn2(file, [...args], { stdio: "ignore", detached: true });
10310
+ const child = spawn3(file, [...args], { stdio: "ignore", detached: true });
7814
10311
  child.once("error", () => settle(false));
7815
10312
  child.once("exit", (code) => settle(code === 0));
7816
10313
  child.unref();