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.
- package/CHANGELOG.md +26 -0
- package/PRIVACY.md +92 -16
- package/README.md +11 -5
- package/SECURITY.md +27 -9
- package/dist/assets/HistoryPanel-TXa0oPH4.js +1 -0
- package/dist/assets/SessionPanel-BStoG7uq.js +1 -0
- package/dist/assets/index-BsTJp5-3.css +1 -0
- package/dist/assets/index-a7ubgAmE.js +3 -0
- package/dist/cli/agent-lookout.js +4 -4
- package/dist/cli/{chunk-XVXSGVEW.js → chunk-NWVEVCRT.js} +9 -2
- package/dist/cli/{chunk-63XPRBHW.js → chunk-OT3EGOTY.js} +38 -7
- package/dist/cli/{chunk-KNOLJ3IC.js → chunk-Q73JDXXW.js} +1 -1
- package/dist/cli/{mcpServer-HGH6PY4W.js → mcpServer-IFB2FEOZ.js} +9 -6
- package/dist/cli/{startCommand-KNXMVM2U.js → startCommand-J3QS26SZ.js} +2898 -401
- package/dist/index.html +2 -2
- package/docs/API.md +165 -61
- package/docs/GUIDE.md +289 -23
- package/package.json +1 -1
- package/dist/assets/HistoryPanel-CIjshvhu.js +0 -1
- package/dist/assets/SessionPanel-eBOdTLD9.js +0 -1
- package/dist/assets/index-B-myfVQS.js +0 -2
- package/dist/assets/index-Cceyz9yZ.css +0 -1
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import {
|
|
2
2
|
sortSessions
|
|
3
|
-
} from "./chunk-
|
|
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-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
511
|
+
var HOUR_MS3 = 36e5;
|
|
212
512
|
function sendsInLastHour(sentAt, now) {
|
|
213
|
-
return sentAt.filter((at) => now - at <
|
|
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] +
|
|
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
|
|
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)
|
|
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
|
-
|
|
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 (
|
|
274
|
-
const
|
|
599
|
+
while (!quiet && ready.length > 0 && limitLiftsAt(sentAt, at) === null) {
|
|
600
|
+
const write = ready.shift();
|
|
275
601
|
sentAt.push(at);
|
|
276
|
-
send2(
|
|
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
|
|
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
|
|
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 +
|
|
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
|
|
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 (!
|
|
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 (!
|
|
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
|
|
1039
|
-
if (
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").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 = (
|
|
2108
|
+
const finish = (answers2) => {
|
|
1603
2109
|
socket.destroy();
|
|
1604
|
-
resolve(
|
|
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
|
|
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
|
|
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 =
|
|
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,
|
|
3253
|
+
function withStopOffers(sessions, live2, answer, options) {
|
|
2756
3254
|
const byId = /* @__PURE__ */ new Map();
|
|
2757
|
-
for (const entry of
|
|
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
|
|
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 (!
|
|
2840
|
-
return message.content.filter(
|
|
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 (
|
|
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 =
|
|
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 =
|
|
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:
|
|
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 =
|
|
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(
|
|
3123
|
-
return
|
|
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
|
|
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
|
|
3185
|
-
const unknownStatus =
|
|
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,
|
|
3193
|
-
const pids = new Set(
|
|
3194
|
-
const sessionIds = new Set(
|
|
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,
|
|
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(
|
|
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 =
|
|
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(
|
|
3305
|
-
const liveSessionIds = new Set(
|
|
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(
|
|
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
|
|
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 (!
|
|
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 (!
|
|
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(
|
|
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/
|
|
3801
|
-
var
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
}
|
|
3817
|
-
|
|
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
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
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
|
|
3828
|
-
|
|
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
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
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
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
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
|
-
|
|
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/
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
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
|
|
3862
|
-
|
|
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
|
|
4024
|
-
import
|
|
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
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
|
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 (!
|
|
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
|
|
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" || !
|
|
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" || !
|
|
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
|
|
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 &&
|
|
4213
|
-
const length = Math.min(TAIL_CHUNK_BYTES, position,
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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) =>
|
|
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
|
|
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 (!
|
|
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 ??
|
|
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 =
|
|
4609
|
-
const sessionsDir =
|
|
4610
|
-
const locksDir =
|
|
4611
|
-
const indexFile =
|
|
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
|
|
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 &&
|
|
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
|
|
4819
|
-
import
|
|
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 ??
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
|
5300
|
-
import
|
|
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
|
|
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 =
|
|
5347
|
-
if (base.startsWith(".")) return
|
|
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 ?
|
|
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,
|
|
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 :
|
|
6684
|
+
return target === null ? void 0 : path17.resolve(gitDir, target);
|
|
5384
6685
|
}
|
|
5385
6686
|
async function headIn(dir, kind, io) {
|
|
5386
|
-
const marker =
|
|
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 =
|
|
6695
|
+
gitDir = path17.resolve(dir, target);
|
|
5395
6696
|
} else {
|
|
5396
6697
|
return null;
|
|
5397
6698
|
}
|
|
5398
|
-
const content = await readGitFile(io,
|
|
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 (!
|
|
5408
|
-
const home =
|
|
5409
|
-
let dir =
|
|
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 =
|
|
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(
|
|
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 ??
|
|
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)
|
|
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
|
|
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 (!
|
|
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 (!
|
|
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
|
|
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,
|
|
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
|
-
|
|
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.
|
|
5976
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
6011
|
-
pullRequest:
|
|
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
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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 ?
|
|
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
|
|
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
|
|
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
|
|
7521
|
+
await mkdir2(dir, { recursive: true, mode: FOLDER_MODE });
|
|
6219
7522
|
},
|
|
6220
7523
|
list: (dir) => readdir3(dir),
|
|
6221
|
-
lstat: async (file) => infoOf2(await
|
|
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
|
|
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
|
|
6276
|
-
const token = options.token ??
|
|
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 <
|
|
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:
|
|
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) =>
|
|
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
|
|
8026
|
+
const holds2 = kept || running && await lock.take();
|
|
6724
8027
|
if (!running) {
|
|
6725
8028
|
lock.releaseNow();
|
|
6726
8029
|
return;
|
|
6727
8030
|
}
|
|
6728
|
-
if (!
|
|
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/
|
|
6921
|
-
|
|
6922
|
-
|
|
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
|
-
|
|
6932
|
-
|
|
6933
|
-
|
|
6934
|
-
|
|
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
|
|
6938
|
-
const
|
|
6939
|
-
const
|
|
6940
|
-
|
|
6941
|
-
|
|
6942
|
-
|
|
6943
|
-
|
|
6944
|
-
|
|
6945
|
-
|
|
6946
|
-
|
|
6947
|
-
|
|
6948
|
-
|
|
6949
|
-
|
|
6950
|
-
|
|
6951
|
-
|
|
6952
|
-
|
|
6953
|
-
|
|
6954
|
-
|
|
6955
|
-
|
|
6956
|
-
|
|
6957
|
-
|
|
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/
|
|
6961
|
-
|
|
6962
|
-
|
|
6963
|
-
|
|
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
|
-
|
|
6966
|
-
|
|
6967
|
-
|
|
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
|
|
6970
|
-
|
|
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
|
|
6973
|
-
const {
|
|
6974
|
-
const
|
|
6975
|
-
|
|
6976
|
-
|
|
6977
|
-
|
|
6978
|
-
|
|
6979
|
-
|
|
6980
|
-
|
|
6981
|
-
|
|
6982
|
-
|
|
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
|
-
|
|
6986
|
-
|
|
6987
|
-
|
|
6988
|
-
|
|
6989
|
-
|
|
6990
|
-
|
|
6991
|
-
|
|
6992
|
-
|
|
6993
|
-
|
|
6994
|
-
|
|
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
|
-
|
|
7027
|
-
|
|
7028
|
-
|
|
7029
|
-
|
|
7030
|
-
|
|
7031
|
-
|
|
7032
|
-
|
|
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
|
-
|
|
7035
|
-
|
|
7036
|
-
|
|
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/
|
|
7060
|
-
var
|
|
7061
|
-
|
|
7062
|
-
|
|
7063
|
-
|
|
7064
|
-
|
|
7065
|
-
|
|
7066
|
-
|
|
7067
|
-
|
|
7068
|
-
|
|
7069
|
-
|
|
7070
|
-
|
|
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
|
|
7075
|
-
|
|
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
|
-
|
|
7078
|
-
|
|
7079
|
-
|
|
7080
|
-
|
|
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
|
-
|
|
7083
|
-
|
|
7084
|
-
|
|
7085
|
-
|
|
7086
|
-
|
|
7087
|
-
|
|
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
|
|
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 ??
|
|
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 }) =>
|
|
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
|
|
9874
|
+
let request2;
|
|
7417
9875
|
try {
|
|
7418
|
-
|
|
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 =
|
|
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 ?? ((
|
|
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
|
-
|
|
7598
|
-
|
|
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(
|
|
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
|
|
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 =
|
|
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();
|