lobstah 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // apps/cli/src/main.ts
4
- import * as fs23 from "node:fs";
4
+ import * as fs25 from "node:fs";
5
5
  import * as os6 from "node:os";
6
- import * as path17 from "node:path";
7
- import { randomUUID as randomUUID3 } from "node:crypto";
8
- import { spawnSync as spawnSync4 } from "node:child_process";
6
+ import * as path19 from "node:path";
7
+ import { randomUUID as randomUUID4 } from "node:crypto";
8
+ import { spawnSync as spawnSync5 } from "node:child_process";
9
9
  import { fileURLToPath as fileURLToPath2 } from "node:url";
10
10
 
11
11
  // packages/core/dist/types.js
@@ -38,6 +38,7 @@ function ensureLayout() {
38
38
  fs.mkdirSync(dir, { recursive: true });
39
39
  }
40
40
  fs.mkdirSync(path.join(lobstahHome(), "worktrees"), { recursive: true });
41
+ fs.mkdirSync(path.join(lobstahHome(), "watches"), { recursive: true });
41
42
  }
42
43
  function statusPath(id, lane) {
43
44
  return path.join(laneDirs(lane).state, `${id}.status`);
@@ -963,6 +964,173 @@ function codexInvocation(argv) {
963
964
  }
964
965
  }
965
966
 
967
+ // packages/core/dist/watch.js
968
+ import * as fs10 from "node:fs";
969
+ import * as path6 from "node:path";
970
+ import { spawnSync } from "node:child_process";
971
+ function watchesDir() {
972
+ return path6.join(lobstahHome(), "watches");
973
+ }
974
+ function slug(key) {
975
+ return key.replace(/[^A-Za-z0-9._-]+/g, "-");
976
+ }
977
+ function watchPath(key) {
978
+ return path6.join(watchesDir(), `${slug(key)}.json`);
979
+ }
980
+ function eventsPath2(key) {
981
+ return path6.join(watchesDir(), `${slug(key)}.events`);
982
+ }
983
+ function writeWatch(w) {
984
+ fs10.mkdirSync(watchesDir(), { recursive: true });
985
+ const file = watchPath(w.key);
986
+ const tmp = `${file}.tmp`;
987
+ fs10.writeFileSync(tmp, JSON.stringify(w, null, 2));
988
+ fs10.renameSync(tmp, file);
989
+ }
990
+ function readWatch(key) {
991
+ try {
992
+ return JSON.parse(fs10.readFileSync(watchPath(key), "utf8"));
993
+ } catch {
994
+ return void 0;
995
+ }
996
+ }
997
+ function listWatches() {
998
+ if (!fs10.existsSync(watchesDir()))
999
+ return [];
1000
+ return fs10.readdirSync(watchesDir()).filter((f) => f.endsWith(".json")).map((f) => {
1001
+ try {
1002
+ return JSON.parse(fs10.readFileSync(path6.join(watchesDir(), f), "utf8"));
1003
+ } catch {
1004
+ return void 0;
1005
+ }
1006
+ }).filter((w) => w !== void 0);
1007
+ }
1008
+ function addWatch(key, check, opts = {}) {
1009
+ if (!key || !check)
1010
+ throw new Error("watch add requires a key and a --check command");
1011
+ const existing = readWatch(key);
1012
+ const w = {
1013
+ key,
1014
+ owner: opts.owner ?? existing?.owner ?? "man",
1015
+ check,
1016
+ stream: opts.stream ?? existing?.stream,
1017
+ cursor: existing?.cursor ?? opts.cursor ?? "0",
1018
+ everySecs: opts.everySecs ?? existing?.everySecs,
1019
+ brief: opts.brief ?? existing?.brief,
1020
+ createdAt: existing?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
1021
+ lastCheckedAt: existing?.lastCheckedAt,
1022
+ seen: existing?.seen ?? 0,
1023
+ seenAt: existing?.seenAt ?? 0,
1024
+ lastFollowUpId: existing?.lastFollowUpId
1025
+ };
1026
+ writeWatch(w);
1027
+ return w;
1028
+ }
1029
+ function removeWatch(key) {
1030
+ const existed = fs10.existsSync(watchPath(key));
1031
+ fs10.rmSync(watchPath(key), { force: true });
1032
+ fs10.rmSync(eventsPath2(key), { force: true });
1033
+ return existed;
1034
+ }
1035
+ function readWatchEvents(key) {
1036
+ try {
1037
+ return fs10.readFileSync(eventsPath2(key), "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
1038
+ } catch {
1039
+ return [];
1040
+ }
1041
+ }
1042
+ function appendWatchEvents(key, events) {
1043
+ const seen = new Set(readWatchEvents(key).map((e) => String(e.seq)));
1044
+ const fresh = events.filter((e) => !seen.has(String(e.seq)));
1045
+ if (fresh.length > 0) {
1046
+ fs10.appendFileSync(eventsPath2(key), fresh.map((e) => JSON.stringify(e)).join("\n") + "\n");
1047
+ }
1048
+ return fresh;
1049
+ }
1050
+ function setWatchCursor(key, cursor) {
1051
+ const w = readWatch(key);
1052
+ if (!w || w.cursor === cursor)
1053
+ return;
1054
+ w.cursor = cursor;
1055
+ writeWatch(w);
1056
+ }
1057
+ function watchDue(w, defaultEverySecs, now = Date.now()) {
1058
+ const every = (w.everySecs ?? defaultEverySecs) * 1e3;
1059
+ const last = w.lastCheckedAt ? Date.parse(w.lastCheckedAt) : 0;
1060
+ return now - last >= every;
1061
+ }
1062
+ var CHECK_TIMEOUT_MS = 9e4;
1063
+ function runWatchCheck(w, now = /* @__PURE__ */ new Date()) {
1064
+ w.lastCheckedAt = now.toISOString();
1065
+ writeWatch(w);
1066
+ const cmd = w.check.replaceAll("{cursor}", w.cursor);
1067
+ const res = spawnSync(cmd, { shell: true, encoding: "utf8", timeout: CHECK_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 });
1068
+ if (res.status !== 0 || res.error) {
1069
+ w.lastError = (res.error?.message || res.stderr?.trim() || `exit ${res.status}`).slice(0, 500);
1070
+ writeWatch(w);
1071
+ return { watch: w, fresh: [] };
1072
+ }
1073
+ let parsed;
1074
+ try {
1075
+ parsed = JSON.parse(res.stdout);
1076
+ } catch {
1077
+ w.lastError = `unparseable check output: ${res.stdout.slice(0, 200).trim()}`;
1078
+ writeWatch(w);
1079
+ return { watch: w, fresh: [] };
1080
+ }
1081
+ w.lastError = void 0;
1082
+ const events = Array.isArray(parsed.events) ? parsed.events.map((e) => ({
1083
+ seq: e.seq ?? w.cursor,
1084
+ summary: e.summary !== void 0 ? String(e.summary) : void 0,
1085
+ ...e,
1086
+ at: now.toISOString()
1087
+ })) : [];
1088
+ const fresh = appendWatchEvents(w.key, events);
1089
+ if (parsed.cursor !== void 0)
1090
+ w.cursor = String(parsed.cursor);
1091
+ if (parsed.done === true)
1092
+ w.done = true;
1093
+ writeWatch(w);
1094
+ return { watch: w, fresh };
1095
+ }
1096
+ function pendingWatchEvents(consume, owner = "man", now = Date.now()) {
1097
+ const out = [];
1098
+ for (const w of listWatches()) {
1099
+ const isMan = w.owner === "man";
1100
+ if (owner === "man" !== isMan)
1101
+ continue;
1102
+ const events = readWatchEvents(w.key);
1103
+ const fresh = events.slice(w.seen);
1104
+ if (fresh.length === 0) {
1105
+ if (w.done && consume)
1106
+ removeWatch(w.key);
1107
+ continue;
1108
+ }
1109
+ out.push({ watch: w, events: fresh });
1110
+ if (consume) {
1111
+ w.seen = events.length;
1112
+ w.seenAt = now;
1113
+ if (w.done)
1114
+ removeWatch(w.key);
1115
+ else
1116
+ writeWatch(w);
1117
+ }
1118
+ }
1119
+ return out;
1120
+ }
1121
+ function markFollowUp(key, followUpId, deliveredThrough) {
1122
+ const w = readWatch(key);
1123
+ if (!w)
1124
+ return;
1125
+ w.lastFollowUpId = followUpId;
1126
+ w.seen = deliveredThrough;
1127
+ w.seenAt = Date.now();
1128
+ if (w.done)
1129
+ removeWatch(w.key);
1130
+ else
1131
+ writeWatch(w);
1132
+ }
1133
+
966
1134
  // packages/supervisor/dist/liveness.js
967
1135
  import { execFileSync } from "node:child_process";
968
1136
  function classify(input) {
@@ -1021,26 +1189,26 @@ function killGroup(pid, signal = "SIGTERM") {
1021
1189
  }
1022
1190
 
1023
1191
  // packages/supervisor/dist/daemon.js
1024
- import * as fs11 from "node:fs";
1192
+ import * as fs12 from "node:fs";
1025
1193
  import * as os3 from "node:os";
1026
- import * as path7 from "node:path";
1194
+ import * as path8 from "node:path";
1027
1195
  import { spawn as spawn2 } from "node:child_process";
1028
1196
  import { createRequire as createRequire2 } from "node:module";
1029
1197
  import { fileURLToPath } from "node:url";
1030
1198
 
1031
1199
  // packages/supervisor/dist/notify.js
1032
- import * as fs10 from "node:fs";
1033
- import * as path6 from "node:path";
1200
+ import * as fs11 from "node:fs";
1201
+ import * as path7 from "node:path";
1034
1202
  import { spawn } from "node:child_process";
1035
1203
  var DEFAULT_NOTIFY_VERBS = ["needs-decision", "blocked", "done", "failed"];
1036
1204
  function cursorPath(id, lane) {
1037
- return path6.join(laneDirs(lane).state, `${id}.notified`);
1205
+ return path7.join(laneDirs(lane).state, `${id}.notified`);
1038
1206
  }
1039
1207
  function pendingNotifications(id, lane, verbs = DEFAULT_NOTIFY_VERBS, sinceMs = 0) {
1040
1208
  const log = readStatusLog(id, lane);
1041
1209
  let cursor;
1042
1210
  try {
1043
- cursor = Number(fs10.readFileSync(cursorPath(id, lane), "utf8")) || 0;
1211
+ cursor = Number(fs11.readFileSync(cursorPath(id, lane), "utf8")) || 0;
1044
1212
  } catch {
1045
1213
  cursor = void 0;
1046
1214
  }
@@ -1050,13 +1218,13 @@ function pendingNotifications(id, lane, verbs = DEFAULT_NOTIFY_VERBS, sinceMs =
1050
1218
  if (cursor === void 0 && sinceMs > 0) {
1051
1219
  fresh = fresh.filter((e) => Date.parse(e.at) >= sinceMs);
1052
1220
  }
1053
- fs10.writeFileSync(cursorPath(id, lane), String(log.length));
1221
+ fs11.writeFileSync(cursorPath(id, lane), String(log.length));
1054
1222
  return fresh.filter((e) => verbs.includes(e.verb)).map((entry) => ({ id, lane, entry }));
1055
1223
  }
1056
1224
  function notifiableIds(lane, maxAgeMs = 48 * 36e5) {
1057
1225
  const dir = laneDirs(lane).state;
1058
1226
  const cutoff = Date.now() - maxAgeMs;
1059
- return fs10.readdirSync(dir).filter((f) => f.endsWith(".status")).filter((f) => fs10.statSync(path6.join(dir, f)).mtimeMs > cutoff).map((f) => f.slice(0, -".status".length));
1227
+ return fs11.readdirSync(dir).filter((f) => f.endsWith(".status")).filter((f) => fs11.statSync(path7.join(dir, f)).mtimeMs > cutoff).map((f) => f.slice(0, -".status".length));
1060
1228
  }
1061
1229
  function execNotify(command, ev, log = () => {
1062
1230
  }) {
@@ -1086,11 +1254,11 @@ function captureWaitBaseline() {
1086
1254
  return lengths;
1087
1255
  }
1088
1256
  function attnCursorPath(id, lane) {
1089
- return path6.join(laneDirs(lane).state, `${id}.attn`);
1257
+ return path7.join(laneDirs(lane).state, `${id}.attn`);
1090
1258
  }
1091
1259
  function readAttnCursor(file) {
1092
1260
  try {
1093
- const parsed = JSON.parse(fs10.readFileSync(file, "utf8"));
1261
+ const parsed = JSON.parse(fs11.readFileSync(file, "utf8"));
1094
1262
  return { seen: parsed.seen ?? 0, at: parsed.at ?? 0 };
1095
1263
  } catch {
1096
1264
  return { seen: 0, at: 0 };
@@ -1116,7 +1284,7 @@ function attentionNow(consume = true, remindMs = 15 * 6e4, now = Date.now()) {
1116
1284
  continue;
1117
1285
  out.push({ id, lane, entry });
1118
1286
  if (consume)
1119
- fs10.writeFileSync(file, JSON.stringify({ seen: Math.max(cursor.seen, index + 1), at: now }));
1287
+ fs11.writeFileSync(file, JSON.stringify({ seen: Math.max(cursor.seen, index + 1), at: now }));
1120
1288
  }
1121
1289
  }
1122
1290
  return out;
@@ -1151,33 +1319,33 @@ function runnerEntry() {
1151
1319
  }
1152
1320
  function readRunnerInfo(dir) {
1153
1321
  try {
1154
- return JSON.parse(fs11.readFileSync(path7.join(dir, "runner.json"), "utf8"));
1322
+ return JSON.parse(fs12.readFileSync(path8.join(dir, "runner.json"), "utf8"));
1155
1323
  } catch {
1156
1324
  return void 0;
1157
1325
  }
1158
1326
  }
1159
1327
  function listActive(lane) {
1160
1328
  const dir = laneDirs(lane).active;
1161
- return fs11.readdirSync(dir).filter((f) => !f.startsWith(".")).map((id) => {
1162
- const d = path7.join(dir, id);
1329
+ return fs12.readdirSync(dir).filter((f) => !f.startsWith(".")).map((id) => {
1330
+ const d = path8.join(dir, id);
1163
1331
  return { id, lane, dir: d, runner: readRunnerInfo(d) };
1164
1332
  });
1165
1333
  }
1166
1334
  function finalize(st) {
1167
1335
  try {
1168
- fs11.renameSync(st.dir, path7.join(laneDirs(st.lane).done, st.id));
1336
+ fs12.renameSync(st.dir, path8.join(laneDirs(st.lane).done, st.id));
1169
1337
  } catch {
1170
1338
  }
1171
1339
  }
1172
1340
  function spawnRunner(st, opts) {
1173
1341
  let nudge = opts.nudge;
1174
- const handoffFile = path7.join(st.dir, "handoff");
1175
- if (!nudge && fs11.existsSync(handoffFile)) {
1176
- nudge = fs11.readFileSync(handoffFile, "utf8");
1177
- fs11.unlinkSync(handoffFile);
1342
+ const handoffFile = path8.join(st.dir, "handoff");
1343
+ if (!nudge && fs12.existsSync(handoffFile)) {
1344
+ nudge = fs12.readFileSync(handoffFile, "utf8");
1345
+ fs12.unlinkSync(handoffFile);
1178
1346
  }
1179
- const logPath = path7.join(laneDirs(st.lane).state, `${st.id}.runner.log`);
1180
- const log = fs11.openSync(logPath, "a");
1347
+ const logPath = path8.join(laneDirs(st.lane).state, `${st.id}.runner.log`);
1348
+ const log = fs12.openSync(logPath, "a");
1181
1349
  const child = spawn2(process.execPath, [runnerEntry(), st.dir, st.lane], {
1182
1350
  detached: true,
1183
1351
  stdio: ["ignore", log, log],
@@ -1188,7 +1356,7 @@ function spawnRunner(st, opts) {
1188
1356
  ...nudge ? { LOBSTAH_NUDGE: nudge } : {}
1189
1357
  }
1190
1358
  });
1191
- fs11.closeSync(log);
1359
+ fs12.closeSync(log);
1192
1360
  child.unref();
1193
1361
  const info = {
1194
1362
  pid: child.pid ?? -1,
@@ -1196,22 +1364,22 @@ function spawnRunner(st, opts) {
1196
1364
  processStartTime: child.pid ? processStartTime(child.pid) : void 0,
1197
1365
  attempts: opts.attempts
1198
1366
  };
1199
- fs11.writeFileSync(path7.join(st.dir, "runner.json"), JSON.stringify(info, null, 2));
1367
+ fs12.writeFileSync(path8.join(st.dir, "runner.json"), JSON.stringify(info, null, 2));
1200
1368
  }
1201
1369
  function sessionOf(st) {
1202
1370
  try {
1203
- const ev = JSON.parse(fs11.readFileSync(path7.join(laneDirs(st.lane).state, `${st.id}.evidence`), "utf8"));
1371
+ const ev = JSON.parse(fs12.readFileSync(path8.join(laneDirs(st.lane).state, `${st.id}.evidence`), "utf8"));
1204
1372
  return ev.sessionId;
1205
1373
  } catch {
1206
1374
  return void 0;
1207
1375
  }
1208
1376
  }
1209
1377
  function reconcileOne(st, cfg, log) {
1210
- const hasDescriptor = fs11.existsSync(path7.join(st.dir, "descriptor.json"));
1378
+ const hasDescriptor = fs12.existsSync(path8.join(st.dir, "descriptor.json"));
1211
1379
  if (!hasDescriptor) {
1212
- const age = Date.now() - fs11.statSync(st.dir).mtimeMs;
1380
+ const age = Date.now() - fs12.statSync(st.dir).mtimeMs;
1213
1381
  if (age > 5 * 6e4)
1214
- fs11.rmSync(st.dir, { recursive: true, force: true });
1382
+ fs12.rmSync(st.dir, { recursive: true, force: true });
1215
1383
  return;
1216
1384
  }
1217
1385
  const statusLog = readStatusLog(st.id, st.lane);
@@ -1295,16 +1463,16 @@ function writeHeartbeat(cfg) {
1295
1463
  heartbeat: (/* @__PURE__ */ new Date()).toISOString()
1296
1464
  };
1297
1465
  const tmp = `${executorPath()}.tmp`;
1298
- fs11.writeFileSync(tmp, JSON.stringify(payload, null, 2));
1299
- fs11.renameSync(tmp, executorPath());
1466
+ fs12.writeFileSync(tmp, JSON.stringify(payload, null, 2));
1467
+ fs12.renameSync(tmp, executorPath());
1300
1468
  }
1301
1469
  function pruneChores(cfg) {
1302
1470
  const dir = laneDirs("chore").done;
1303
1471
  const cutoff = Date.now() - cfg.limits.choreRetentionDays * 864e5;
1304
- for (const id of fs11.readdirSync(dir)) {
1305
- const p = path7.join(dir, id);
1306
- if (fs11.statSync(p).mtimeMs < cutoff)
1307
- fs11.rmSync(p, { recursive: true, force: true });
1472
+ for (const id of fs12.readdirSync(dir)) {
1473
+ const p = path8.join(dir, id);
1474
+ if (fs12.statSync(p).mtimeMs < cutoff)
1475
+ fs12.rmSync(p, { recursive: true, force: true });
1308
1476
  }
1309
1477
  }
1310
1478
  var daemonStartedAt = Date.now();
@@ -1344,9 +1512,9 @@ function tick(log = () => {
1344
1512
  pruneChores(loadConfig());
1345
1513
  }
1346
1514
  function acquireDaemonLock() {
1347
- const lockPath = path7.join(lobstahHome(), "daemon.lock");
1515
+ const lockPath = path8.join(lobstahHome(), "daemon.lock");
1348
1516
  try {
1349
- const prior = JSON.parse(fs11.readFileSync(lockPath, "utf8"));
1517
+ const prior = JSON.parse(fs12.readFileSync(lockPath, "utf8"));
1350
1518
  if (pidAlive(prior.pid, prior.processStartTime)) {
1351
1519
  throw new Error(`another daemon (pid ${prior.pid}) already supervises ${lobstahHome()} \u2014 run a second instance against its own LOBSTAH_HOME instead`);
1352
1520
  }
@@ -1354,10 +1522,10 @@ function acquireDaemonLock() {
1354
1522
  if (err instanceof Error && err.message.includes("already supervises"))
1355
1523
  throw err;
1356
1524
  }
1357
- fs11.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, processStartTime: processStartTime(process.pid), startedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2));
1525
+ fs12.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, processStartTime: processStartTime(process.pid), startedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2));
1358
1526
  const release = () => {
1359
1527
  try {
1360
- fs11.unlinkSync(lockPath);
1528
+ fs12.unlinkSync(lockPath);
1361
1529
  } catch {
1362
1530
  }
1363
1531
  };
@@ -1369,17 +1537,42 @@ function acquireDaemonLock() {
1369
1537
  });
1370
1538
  }
1371
1539
  }
1540
+ function watchQueues(onChange) {
1541
+ const watchers = [];
1542
+ for (const lane of ["work", "chore"]) {
1543
+ try {
1544
+ watchers.push(fs12.watch(laneDirs(lane).queue, onChange));
1545
+ } catch {
1546
+ }
1547
+ }
1548
+ return () => {
1549
+ for (const w of watchers)
1550
+ w.close();
1551
+ };
1552
+ }
1372
1553
  async function daemon(intervalMs = 5e3, log = console.log) {
1373
1554
  ensureLayout();
1374
1555
  acquireDaemonLock();
1375
1556
  log(`lobstah daemon: watching ${laneDirs("work").queue} every ${intervalMs}ms`);
1557
+ let wake;
1558
+ watchQueues(() => wake?.());
1376
1559
  while (true) {
1377
1560
  try {
1378
1561
  tick(log);
1379
1562
  } catch (err) {
1380
1563
  log(`tick error: ${err instanceof Error ? err.message : String(err)}`);
1381
1564
  }
1382
- await new Promise((r) => setTimeout(r, intervalMs));
1565
+ await new Promise((resolve2) => {
1566
+ const timer = setTimeout(() => {
1567
+ wake = void 0;
1568
+ resolve2();
1569
+ }, intervalMs);
1570
+ wake = () => {
1571
+ wake = void 0;
1572
+ clearTimeout(timer);
1573
+ setTimeout(resolve2, 50);
1574
+ };
1575
+ });
1383
1576
  }
1384
1577
  }
1385
1578
 
@@ -1387,7 +1580,7 @@ async function daemon(intervalMs = 5e3, log = console.log) {
1387
1580
  import { spawn as spawn3 } from "node:child_process";
1388
1581
 
1389
1582
  // apps/pick/dist/config.js
1390
- import * as fs12 from "node:fs";
1583
+ import * as fs13 from "node:fs";
1391
1584
  import { execSync } from "node:child_process";
1392
1585
  import * as os4 from "node:os";
1393
1586
 
@@ -1429,7 +1622,7 @@ function resolveTokenSource(section, label) {
1429
1622
  }
1430
1623
  if (file) {
1431
1624
  return () => {
1432
- const token = fs12.readFileSync(file.startsWith("~/") ? `${os4.homedir()}/${file.slice(2)}` : file, "utf8").trim();
1625
+ const token = fs13.readFileSync(file.startsWith("~/") ? `${os4.homedir()}/${file.slice(2)}` : file, "utf8").trim();
1433
1626
  if (!token)
1434
1627
  throw new Error(`${label}: tokenFile ${file} is empty`);
1435
1628
  return token;
@@ -1447,7 +1640,7 @@ function resolveTokenSource(section, label) {
1447
1640
  }
1448
1641
  function loadPickupConfig() {
1449
1642
  const file = configPath();
1450
- const raw = fs12.existsSync(file) ? parse(fs12.readFileSync(file, "utf8")) : {};
1643
+ const raw = fs13.existsSync(file) ? parse(fs13.readFileSync(file, "utf8")) : {};
1451
1644
  const p = raw.pickup ?? {};
1452
1645
  const out = {
1453
1646
  pollSecs: Number(p.pollSecs ?? 45),
@@ -1520,23 +1713,23 @@ function loadPickupConfig() {
1520
1713
  }
1521
1714
 
1522
1715
  // apps/pick/dist/state.js
1523
- import * as fs13 from "node:fs";
1524
- import * as path8 from "node:path";
1716
+ import * as fs14 from "node:fs";
1717
+ import * as path9 from "node:path";
1525
1718
  var PickupState = class {
1526
1719
  file;
1527
1720
  data;
1528
- constructor(dir = path8.join(lobstahHome(), "pickup")) {
1529
- fs13.mkdirSync(dir, { recursive: true });
1530
- this.file = path8.join(dir, "state.json");
1531
- this.data = fs13.existsSync(this.file) ? JSON.parse(fs13.readFileSync(this.file, "utf8")) : { map: {}, consumedApprovals: {}, rebases: {} };
1721
+ constructor(dir = path9.join(lobstahHome(), "pickup")) {
1722
+ fs14.mkdirSync(dir, { recursive: true });
1723
+ this.file = path9.join(dir, "state.json");
1724
+ this.data = fs14.existsSync(this.file) ? JSON.parse(fs14.readFileSync(this.file, "utf8")) : { map: {}, consumedApprovals: {}, rebases: {} };
1532
1725
  this.data.map ??= {};
1533
1726
  this.data.consumedApprovals ??= {};
1534
1727
  this.data.rebases ??= {};
1535
1728
  }
1536
1729
  save() {
1537
1730
  const tmp = `${this.file}.tmp-${process.pid}`;
1538
- fs13.writeFileSync(tmp, JSON.stringify(this.data, null, 2));
1539
- fs13.renameSync(tmp, this.file);
1731
+ fs14.writeFileSync(tmp, JSON.stringify(this.data, null, 2));
1732
+ fs14.renameSync(tmp, this.file);
1540
1733
  }
1541
1734
  get(key) {
1542
1735
  return this.data.map[key];
@@ -1920,12 +2113,12 @@ async function dispatchLoop(source, state, log = () => {
1920
2113
  }
1921
2114
 
1922
2115
  // apps/pick/dist/loops/report.js
1923
- import * as fs14 from "node:fs";
1924
- import * as path9 from "node:path";
2116
+ import * as fs15 from "node:fs";
2117
+ import * as path10 from "node:path";
1925
2118
  function dispatchLane(uuid) {
1926
2119
  for (const lane of ["work", "chore"]) {
1927
2120
  const d = laneDirs(lane);
1928
- if (fs14.existsSync(path9.join(d.active, uuid)) || fs14.existsSync(path9.join(d.queue, `${uuid}.json`)) || fs14.existsSync(path9.join(d.done, uuid)) || fs14.existsSync(path9.join(d.state, `${uuid}.status`))) {
2121
+ if (fs15.existsSync(path10.join(d.active, uuid)) || fs15.existsSync(path10.join(d.queue, `${uuid}.json`)) || fs15.existsSync(path10.join(d.done, uuid)) || fs15.existsSync(path10.join(d.state, `${uuid}.status`))) {
1929
2122
  return lane;
1930
2123
  }
1931
2124
  }
@@ -1966,12 +2159,12 @@ async function reportLoop(source, state, log = () => {
1966
2159
  }
1967
2160
 
1968
2161
  // apps/pick/dist/loops/reconcile.js
1969
- import * as fs15 from "node:fs";
1970
- import * as path10 from "node:path";
2162
+ import * as fs16 from "node:fs";
2163
+ import * as path11 from "node:path";
1971
2164
  function dispatchActive(uuid) {
1972
2165
  for (const lane of ["work", "chore"]) {
1973
2166
  const d = laneDirs(lane);
1974
- if (fs15.existsSync(path10.join(d.active, uuid)) || fs15.existsSync(path10.join(d.queue, `${uuid}.json`))) {
2167
+ if (fs16.existsSync(path11.join(d.active, uuid)) || fs16.existsSync(path11.join(d.queue, `${uuid}.json`))) {
1975
2168
  return lane;
1976
2169
  }
1977
2170
  }
@@ -2011,28 +2204,28 @@ async function reconcileLoop(source, state, log = () => {
2011
2204
 
2012
2205
  // apps/pick/dist/loops/merge.js
2013
2206
  import { randomUUID as randomUUID2 } from "node:crypto";
2014
- import * as fs17 from "node:fs";
2015
- import * as path12 from "node:path";
2207
+ import * as fs18 from "node:fs";
2208
+ import * as path13 from "node:path";
2016
2209
 
2017
2210
  // apps/pick/dist/merge-view.js
2018
- import * as fs16 from "node:fs";
2019
- import * as path11 from "node:path";
2211
+ import * as fs17 from "node:fs";
2212
+ import * as path12 from "node:path";
2020
2213
  function viewPath() {
2021
- return path11.join(lobstahHome(), "pickup", "merge-view.json");
2214
+ return path12.join(lobstahHome(), "pickup", "merge-view.json");
2022
2215
  }
2023
2216
  function readMergeView() {
2024
2217
  try {
2025
- return JSON.parse(fs16.readFileSync(viewPath(), "utf8"));
2218
+ return JSON.parse(fs17.readFileSync(viewPath(), "utf8"));
2026
2219
  } catch {
2027
2220
  return void 0;
2028
2221
  }
2029
2222
  }
2030
2223
  function writeMergeView(view) {
2031
2224
  const file = viewPath();
2032
- fs16.mkdirSync(path11.dirname(file), { recursive: true });
2225
+ fs17.mkdirSync(path12.dirname(file), { recursive: true });
2033
2226
  const tmp = `${file}.tmp-${process.pid}`;
2034
- fs16.writeFileSync(tmp, JSON.stringify(view, null, 2));
2035
- fs16.renameSync(tmp, file);
2227
+ fs17.writeFileSync(tmp, JSON.stringify(view, null, 2));
2228
+ fs17.renameSync(tmp, file);
2036
2229
  }
2037
2230
  var RECENT_RETENTION_MS = 48 * 36e5;
2038
2231
  async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ new Date()) {
@@ -2054,7 +2247,7 @@ async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ ne
2054
2247
  }
2055
2248
  function readPickupMap() {
2056
2249
  try {
2057
- const raw = JSON.parse(fs16.readFileSync(path11.join(lobstahHome(), "pickup", "state.json"), "utf8"));
2250
+ const raw = JSON.parse(fs17.readFileSync(path12.join(lobstahHome(), "pickup", "state.json"), "utf8"));
2058
2251
  return raw.map ?? {};
2059
2252
  } catch {
2060
2253
  return {};
@@ -2088,7 +2281,7 @@ function rebaseBrief(pr) {
2088
2281
  }
2089
2282
  function choreVerdict(uuid) {
2090
2283
  const d = laneDirs("chore");
2091
- if (fs17.existsSync(path12.join(d.active, uuid)) || fs17.existsSync(path12.join(d.queue, `${uuid}.json`)))
2284
+ if (fs18.existsSync(path13.join(d.active, uuid)) || fs18.existsSync(path13.join(d.queue, `${uuid}.json`)))
2092
2285
  return "active";
2093
2286
  const last = readStatusLog(uuid, "chore").at(-1)?.verb;
2094
2287
  return last === "done" ? "done" : "failed";
@@ -2181,6 +2374,126 @@ async function mergeLoop(ms, policy, state, log = () => {
2181
2374
  await recordMergeView(ms.forgeRepo(), observed, (n) => ms.disposition(n));
2182
2375
  }
2183
2376
 
2377
+ // apps/pick/dist/loops/watch.js
2378
+ import * as fs19 from "node:fs";
2379
+ import * as path14 from "node:path";
2380
+ import { randomUUID as randomUUID3 } from "node:crypto";
2381
+ var DEFAULT_BRIEF = `You are resuming earlier work. The watched source {key} produced new events:
2382
+
2383
+ {events}
2384
+
2385
+ Read them, act on what they ask, and report the outcome.`;
2386
+ function laneOf(id) {
2387
+ for (const lane of ["work", "chore"]) {
2388
+ const d = laneDirs(lane);
2389
+ if (fs19.existsSync(path14.join(d.active, id)) || fs19.existsSync(path14.join(d.queue, `${id}.json`)) || fs19.existsSync(path14.join(d.done, id)) || fs19.existsSync(path14.join(d.state, `${id}.status`))) {
2390
+ return lane;
2391
+ }
2392
+ }
2393
+ return void 0;
2394
+ }
2395
+ function descriptorOf(id, lane) {
2396
+ const d = laneDirs(lane);
2397
+ for (const bucket of ["active", "done"]) {
2398
+ const f = path14.join(d[bucket], id, "descriptor.json");
2399
+ if (fs19.existsSync(f))
2400
+ return JSON.parse(fs19.readFileSync(f, "utf8"));
2401
+ }
2402
+ const q = path14.join(d.queue, `${id}.json`);
2403
+ if (fs19.existsSync(q))
2404
+ return JSON.parse(fs19.readFileSync(q, "utf8"));
2405
+ return void 0;
2406
+ }
2407
+ function isTerminal(id) {
2408
+ const lane = laneOf(id);
2409
+ if (!lane)
2410
+ return true;
2411
+ const state = reconcile({ log: readStatusLog(id, lane), lastEventAt: lastEventAt(id, lane) });
2412
+ return state === "done" || state === "failed";
2413
+ }
2414
+ function spawnContinuation(w, pending, log) {
2415
+ const owner = w.owner.slice("dispatch:".length);
2416
+ const target = w.lastFollowUpId && !laneOf(w.lastFollowUpId) ? owner : w.lastFollowUpId ?? owner;
2417
+ const targetLane = laneOf(target);
2418
+ if (!targetLane) {
2419
+ log(`watch ${w.key}: owner dispatch ${target} is gone \u2014 dropping watch`);
2420
+ removeWatch(w.key);
2421
+ return;
2422
+ }
2423
+ const descriptor = descriptorOf(target, targetLane);
2424
+ if (!descriptor) {
2425
+ log(`watch ${w.key}: no descriptor for ${target} \u2014 dropping watch`);
2426
+ removeWatch(w.key);
2427
+ return;
2428
+ }
2429
+ const id = randomUUID3();
2430
+ const brief = (w.brief ?? DEFAULT_BRIEF).replaceAll("{key}", w.key).replaceAll("{events}", JSON.stringify(pending, null, 2));
2431
+ enqueue({ id, repo: descriptor.repo, brief, followUp: target }, "work");
2432
+ markFollowUp(w.key, id, readWatchEvents(w.key).length);
2433
+ log(`watch ${w.key}: ${pending.length} event(s) \u2192 continuation ${id} (forks ${target})`);
2434
+ }
2435
+ function notifyMan(watch2, fresh, notify) {
2436
+ if (fresh.length === 0 || watch2.owner !== "man")
2437
+ return;
2438
+ notify({
2439
+ key: watch2.key,
2440
+ uuid: "watch",
2441
+ verb: "watch",
2442
+ note: fresh[0]?.summary ?? `${fresh.length} new event(s)`
2443
+ });
2444
+ }
2445
+ function deliverDispatchOwned(log) {
2446
+ for (const { watch: watch2, events } of pendingWatchEvents(false, "dispatch")) {
2447
+ if (watch2.lastFollowUpId && !isTerminal(watch2.lastFollowUpId))
2448
+ continue;
2449
+ spawnContinuation(watch2, events, log);
2450
+ }
2451
+ for (const w of listWatches()) {
2452
+ if (w.done && w.owner !== "man" && readWatchEvents(w.key).length <= w.seen)
2453
+ removeWatch(w.key);
2454
+ }
2455
+ }
2456
+ async function watchLoop(defaultEverySecs, log, notify = () => {
2457
+ }) {
2458
+ for (const w of listWatches()) {
2459
+ if (watchDue(w, defaultEverySecs)) {
2460
+ const { watch: watch2, fresh } = runWatchCheck(w);
2461
+ if (watch2.lastError)
2462
+ log(`watch ${w.key}: check failed \u2014 ${watch2.lastError}`);
2463
+ notifyMan(watch2, fresh, notify);
2464
+ }
2465
+ }
2466
+ deliverDispatchOwned(log);
2467
+ }
2468
+ function handleStreamLine(key, line, log, notify) {
2469
+ const watch2 = readWatch(key);
2470
+ if (!watch2)
2471
+ return;
2472
+ let parsed;
2473
+ try {
2474
+ parsed = JSON.parse(line);
2475
+ } catch {
2476
+ log(`watch ${key}: unparseable stream line \u2014 ${line.slice(0, 120)}`);
2477
+ return;
2478
+ }
2479
+ if (parsed.seq === void 0) {
2480
+ if (parsed.cursor !== void 0)
2481
+ setWatchCursor(key, String(parsed.cursor));
2482
+ return;
2483
+ }
2484
+ const event = {
2485
+ seq: parsed.seq,
2486
+ summary: parsed.summary !== void 0 ? String(parsed.summary) : void 0,
2487
+ ...parsed,
2488
+ at: (/* @__PURE__ */ new Date()).toISOString()
2489
+ };
2490
+ const fresh = appendWatchEvents(key, event.seq !== void 0 ? [event] : []);
2491
+ setWatchCursor(key, String(parsed.cursor ?? event.seq));
2492
+ notifyMan(watch2, fresh, notify);
2493
+ if (fresh.length > 0)
2494
+ deliverDispatchOwned(log);
2495
+ }
2496
+
2184
2497
  // apps/pick/dist/run.js
2185
2498
  function makeNotifier(command, log) {
2186
2499
  if (!command)
@@ -2204,7 +2517,49 @@ function makeNotifier(command, log) {
2204
2517
  child.unref();
2205
2518
  };
2206
2519
  }
2207
- async function cycle(sources, merges, state, log, notify) {
2520
+ var SingleFlight = class {
2521
+ chain = Promise.resolve();
2522
+ run(job) {
2523
+ const next = this.chain.then(job, job);
2524
+ this.chain = next.then(() => {
2525
+ }, () => {
2526
+ });
2527
+ return next;
2528
+ }
2529
+ };
2530
+ function syncStreams(children, flight, log, notify) {
2531
+ for (const w of listWatches()) {
2532
+ if (!w.stream || children.has(w.key))
2533
+ continue;
2534
+ const cmd = w.stream.replaceAll("{cursor}", w.cursor);
2535
+ const child = spawn3(cmd, { shell: true, stdio: ["ignore", "pipe", "ignore"] });
2536
+ children.set(w.key, child);
2537
+ log(`watch ${w.key}: stream attached`);
2538
+ let buffer = "";
2539
+ child.stdout.on("data", (chunk) => {
2540
+ buffer += chunk.toString("utf8");
2541
+ const lines = buffer.split("\n");
2542
+ buffer = lines.pop() ?? "";
2543
+ for (const line of lines) {
2544
+ if (!line.trim())
2545
+ continue;
2546
+ void flight.run(() => handleStreamLine(w.key, line, log, notify));
2547
+ }
2548
+ });
2549
+ child.on("exit", () => {
2550
+ children.delete(w.key);
2551
+ log(`watch ${w.key}: stream ended \u2014 cadence covers until resync`);
2552
+ });
2553
+ child.on("error", () => children.delete(w.key));
2554
+ }
2555
+ for (const [key, child] of children) {
2556
+ if (!readWatch(key)?.stream) {
2557
+ child.kill();
2558
+ children.delete(key);
2559
+ }
2560
+ }
2561
+ }
2562
+ async function cycle(sources, merges, state, pollSecs, log, notify) {
2208
2563
  for (const source of sources) {
2209
2564
  try {
2210
2565
  await dispatchLoop(source, state, log);
@@ -2221,6 +2576,11 @@ async function cycle(sources, merges, state, log, notify) {
2221
2576
  log(`${source.name} merge: ${err instanceof Error ? err.message : String(err)}`);
2222
2577
  }
2223
2578
  }
2579
+ try {
2580
+ await watchLoop(pollSecs, log, notify);
2581
+ } catch (err) {
2582
+ log(`watches: ${err instanceof Error ? err.message : String(err)}`);
2583
+ }
2224
2584
  }
2225
2585
  async function runPickup(mode = "daemon") {
2226
2586
  ensureLayout();
@@ -2237,16 +2597,22 @@ async function runPickup(mode = "daemon") {
2237
2597
  if (cfg.linear)
2238
2598
  sources.push(new LinearSource(cfg.linear));
2239
2599
  if (sources.length === 0) {
2240
- throw new Error("no [pickup.github] or [pickup.linear] configured \u2014 nothing to poll");
2600
+ if (listWatches().length === 0) {
2601
+ throw new Error("no [pickup.github], [pickup.linear], or registered watches \u2014 nothing to poll");
2602
+ }
2603
+ log("no tracker sources configured \u2014 watch-only mode");
2241
2604
  }
2242
2605
  const notify = makeNotifier(cfg.notifyCommand, log);
2243
2606
  if (mode === "once") {
2244
- await cycle(sources, merges, state, log, notify);
2607
+ await cycle(sources, merges, state, cfg.pollSecs, log, notify);
2245
2608
  return;
2246
2609
  }
2247
2610
  log(`polling every ${cfg.pollSecs}s \u2014 no webhooks, no inbound surface`);
2611
+ const flight = new SingleFlight();
2612
+ const streamChildren = /* @__PURE__ */ new Map();
2248
2613
  while (true) {
2249
- await cycle(sources, merges, state, log, notify);
2614
+ await flight.run(() => cycle(sources, merges, state, cfg.pollSecs, log, notify));
2615
+ syncStreams(streamChildren, flight, log, notify);
2250
2616
  await new Promise((r) => setTimeout(r, cfg.pollSecs * 1e3));
2251
2617
  }
2252
2618
  }
@@ -2265,21 +2631,21 @@ function mergeHaulHook(existing) {
2265
2631
  }
2266
2632
 
2267
2633
  // apps/cli/src/tend.ts
2268
- import * as fs18 from "node:fs";
2269
- import * as path13 from "node:path";
2634
+ import * as fs20 from "node:fs";
2635
+ import * as path15 from "node:path";
2270
2636
  var HEARTBEAT_STALE_MS = 9e4;
2271
2637
  var CLAIM_STALE_MS = 12e4;
2272
2638
  var DAY_MS = 24 * 36e5;
2273
2639
  function readJson(file) {
2274
2640
  try {
2275
- return JSON.parse(fs18.readFileSync(file, "utf8"));
2641
+ return JSON.parse(fs20.readFileSync(file, "utf8"));
2276
2642
  } catch {
2277
2643
  return void 0;
2278
2644
  }
2279
2645
  }
2280
2646
  function doneIds(lane) {
2281
2647
  try {
2282
- return fs18.readdirSync(laneDirs(lane).done).filter((f) => !f.startsWith("."));
2648
+ return fs20.readdirSync(laneDirs(lane).done).filter((f) => !f.startsWith("."));
2283
2649
  } catch {
2284
2650
  return [];
2285
2651
  }
@@ -2298,16 +2664,16 @@ function followUps(uuid) {
2298
2664
  const desc = readJson(file);
2299
2665
  if (desc?.followUp === uuid) out.push({ id, bucket });
2300
2666
  };
2301
- for (const id of pendingIds("work")) scan(id, "queued", path13.join(d.queue, `${id}.json`));
2302
- for (const id of activeIds("work")) scan(id, "active", path13.join(d.active, id, "descriptor.json"));
2303
- for (const id of doneIds("work")) scan(id, "done", path13.join(d.done, id, "descriptor.json"));
2667
+ for (const id of pendingIds("work")) scan(id, "queued", path15.join(d.queue, `${id}.json`));
2668
+ for (const id of activeIds("work")) scan(id, "active", path15.join(d.active, id, "descriptor.json"));
2669
+ for (const id of doneIds("work")) scan(id, "done", path15.join(d.done, id, "descriptor.json"));
2304
2670
  return out;
2305
2671
  }
2306
2672
  function bucketOf(uuid) {
2307
2673
  const d = laneDirs("work");
2308
- if (fs18.existsSync(path13.join(d.active, uuid))) return "active";
2309
- if (fs18.existsSync(path13.join(d.done, uuid))) return "done";
2310
- if (fs18.existsSync(path13.join(d.queue, `${uuid}.json`))) return "queued";
2674
+ if (fs20.existsSync(path15.join(d.active, uuid))) return "active";
2675
+ if (fs20.existsSync(path15.join(d.done, uuid))) return "done";
2676
+ if (fs20.existsSync(path15.join(d.queue, `${uuid}.json`))) return "queued";
2311
2677
  return void 0;
2312
2678
  }
2313
2679
  function buildTendReport(now = Date.now()) {
@@ -2335,8 +2701,39 @@ function buildTendReport(now = Date.now()) {
2335
2701
  ageSecs: Math.max(0, Math.round((now - Date.parse(ev.entry.at)) / 1e3)),
2336
2702
  note: ev.entry.note
2337
2703
  }));
2704
+ const watches = [];
2705
+ const watchByDispatch = /* @__PURE__ */ new Map();
2706
+ for (const w of listWatches()) {
2707
+ const events = readWatchEvents(w.key);
2708
+ const pending = events.slice(w.seen);
2709
+ const last = events.at(-1);
2710
+ watches.push({
2711
+ key: w.key,
2712
+ owner: w.owner,
2713
+ cursor: w.cursor,
2714
+ pendingEvents: pending.length,
2715
+ lastSummary: last?.summary,
2716
+ lastAt: last?.at,
2717
+ error: w.lastError
2718
+ });
2719
+ if (w.owner.startsWith("dispatch:")) {
2720
+ const label = `${w.key}${pending.length > 0 ? ` (${pending.length} pending)` : ""}`;
2721
+ watchByDispatch.set(w.owner.slice("dispatch:".length), label);
2722
+ if (w.lastFollowUpId) watchByDispatch.set(w.lastFollowUpId, label);
2723
+ }
2724
+ if (w.owner === "man" && pending.length > 0) {
2725
+ const oldest = pending[0];
2726
+ attention.push({
2727
+ id: w.key,
2728
+ lane: "work",
2729
+ verb: "watch",
2730
+ ageSecs: oldest?.at ? Math.max(0, Math.round((now - Date.parse(oldest.at)) / 1e3)) : 0,
2731
+ note: pending.at(-1)?.summary
2732
+ });
2733
+ }
2734
+ }
2338
2735
  const oldestQueuedAge = queued.reduce((max, id) => {
2339
- const st = fs18.statSync(path13.join(laneDirs("work").queue, `${id}.json`), { throwIfNoEntry: false });
2736
+ const st = fs20.statSync(path15.join(laneDirs("work").queue, `${id}.json`), { throwIfNoEntry: false });
2340
2737
  return st ? Math.max(max, now - st.mtimeMs) : max;
2341
2738
  }, 0);
2342
2739
  const stalled = daemonUp && queued.length > 0 && active.length < cfg.limits.maxConcurrent && oldestQueuedAge > CLAIM_STALE_MS;
@@ -2358,12 +2755,13 @@ function buildTendReport(now = Date.now()) {
2358
2755
  const fresh = chain.some((d) => d.bucket !== "done" || d.at !== void 0 && now - Date.parse(d.at) < DAY_MS);
2359
2756
  if (!fresh) continue;
2360
2757
  const prUrl = chain.map((d) => d.prUrl).find((u) => u !== void 0);
2361
- stories.push({ key, dispatches: chain, prUrl, gate: gateFor(entry.uuid, prUrl) });
2758
+ const watch2 = chain.map((d) => watchByDispatch.get(d.id)).find((w) => w !== void 0);
2759
+ stories.push({ key, dispatches: chain, prUrl, gate: gateFor(entry.uuid, prUrl), watch: watch2 });
2362
2760
  }
2363
2761
  for (const id of [...active, ...queued]) {
2364
2762
  if (storied.has(id)) continue;
2365
2763
  const d = describeDispatch(id, "work", bucketOf(id) ?? "active");
2366
- stories.push({ key: "(direct)", dispatches: [d], prUrl: d.prUrl, gate: gateFor(id, d.prUrl) });
2764
+ stories.push({ key: "(direct)", dispatches: [d], prUrl: d.prUrl, gate: gateFor(id, d.prUrl), watch: watchByDispatch.get(id) });
2367
2765
  }
2368
2766
  const verdict = !daemonUp ? "daemon-down" : stalled ? "stalled" : attention.length > 0 ? "needs-attention" : active.length + queued.length > 0 ? "working" : "idle";
2369
2767
  return {
@@ -2372,6 +2770,7 @@ function buildTendReport(now = Date.now()) {
2372
2770
  counts: { queued: queued.length, active: active.length, choresActive, done24h, failed24h },
2373
2771
  attention,
2374
2772
  stories,
2773
+ watches,
2375
2774
  merge
2376
2775
  };
2377
2776
  }
@@ -2407,9 +2806,26 @@ function renderTend(r) {
2407
2806
  key: s.key,
2408
2807
  dispatches: s.dispatches.map((d) => `${d.id.slice(0, 8)}:${d.state}`).join(" \u2192 "),
2409
2808
  pr: s.prUrl ?? "",
2410
- gate: s.gate ?? ""
2809
+ gate: s.gate ?? "",
2810
+ watch: s.watch ?? ""
2411
2811
  })),
2412
- ["key", "dispatches", "pr", "gate"]
2812
+ ["key", "dispatches", "pr", "gate", "watch"]
2813
+ )
2814
+ );
2815
+ }
2816
+ if (r.watches.length > 0) {
2817
+ lines.push("");
2818
+ lines.push(
2819
+ toonTable(
2820
+ "watches",
2821
+ r.watches.map((w) => ({
2822
+ key: w.key,
2823
+ owner: w.owner,
2824
+ pending: w.pendingEvents,
2825
+ last: w.lastSummary ?? "",
2826
+ error: w.error ?? ""
2827
+ })),
2828
+ ["key", "owner", "pending", "last", "error"]
2413
2829
  )
2414
2830
  );
2415
2831
  }
@@ -2430,13 +2846,13 @@ function renderTend(r) {
2430
2846
  }
2431
2847
 
2432
2848
  // apps/cli/src/cull.ts
2433
- import * as fs19 from "node:fs";
2434
- import * as path14 from "node:path";
2849
+ import * as fs21 from "node:fs";
2850
+ import * as path16 from "node:path";
2435
2851
  import { execFileSync as execFileSync2 } from "node:child_process";
2436
2852
  var DAY = 864e5;
2437
2853
  function idsIn(dir) {
2438
2854
  try {
2439
- return new Set(fs19.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => f.replace(/\.json$/, "")));
2855
+ return new Set(fs21.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => f.replace(/\.json$/, "")));
2440
2856
  } catch {
2441
2857
  return /* @__PURE__ */ new Set();
2442
2858
  }
@@ -2451,39 +2867,39 @@ function planCull(olderThanDays, now = Date.now()) {
2451
2867
  for (const id of idsIn(d.queue)) live.add(id);
2452
2868
  for (const id of idsIn(d.active)) live.add(id);
2453
2869
  for (const id of idsIn(d.done)) {
2454
- const p = path14.join(d.done, id);
2455
- const m = fs19.statSync(p).mtimeMs;
2870
+ const p = path16.join(d.done, id);
2871
+ const m = fs21.statSync(p).mtimeMs;
2456
2872
  doneMtimes.set(id, m);
2457
2873
  if (m < cutoff) items.push({ kind: "done", id, target: p, ageDays: Math.floor((now - m) / DAY) });
2458
2874
  }
2459
2875
  }
2460
- const wtRoot = path14.join(lobstahHome(), "worktrees");
2876
+ const wtRoot = path16.join(lobstahHome(), "worktrees");
2461
2877
  for (const id of idsIn(wtRoot)) {
2462
2878
  if (live.has(id)) continue;
2463
2879
  const doneAt = doneMtimes.get(id);
2464
2880
  if (doneAt !== void 0 && doneAt >= cutoff) continue;
2465
- const p = path14.join(wtRoot, id);
2466
- items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now - fs19.statSync(p).mtimeMs) / DAY) });
2881
+ const p = path16.join(wtRoot, id);
2882
+ items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now - fs21.statSync(p).mtimeMs) / DAY) });
2467
2883
  }
2468
2884
  for (const lane of ["work", "chore"]) {
2469
2885
  const d = laneDirs(lane);
2470
2886
  const seen = /* @__PURE__ */ new Set();
2471
- for (const f of fs19.readdirSync(d.state)) {
2887
+ for (const f of fs21.readdirSync(d.state)) {
2472
2888
  const id = f.replace(/\.(status|events|evidence|attn|notified|runner\.log)$/, "");
2473
2889
  if (id === f || seen.has(id) || live.has(id) || doneMtimes.has(id)) continue;
2474
- const m = fs19.statSync(path14.join(d.state, f)).mtimeMs;
2890
+ const m = fs21.statSync(path16.join(d.state, f)).mtimeMs;
2475
2891
  if (m >= cutoff) continue;
2476
2892
  seen.add(id);
2477
- items.push({ kind: "state", id, target: path14.join(d.state, `${id}.*`), ageDays: Math.floor((now - m) / DAY) });
2893
+ items.push({ kind: "state", id, target: path16.join(d.state, `${id}.*`), ageDays: Math.floor((now - m) / DAY) });
2478
2894
  }
2479
2895
  }
2480
2896
  return items;
2481
2897
  }
2482
2898
  function removeWorktree(id, dir) {
2483
2899
  for (const lane of ["work", "chore"]) {
2484
- const descFile = path14.join(laneDirs(lane).done, id, "descriptor.json");
2900
+ const descFile = path16.join(laneDirs(lane).done, id, "descriptor.json");
2485
2901
  try {
2486
- const desc = JSON.parse(fs19.readFileSync(descFile, "utf8"));
2902
+ const desc = JSON.parse(fs21.readFileSync(descFile, "utf8"));
2487
2903
  const repo = loadConfig().repos[desc.repo];
2488
2904
  if (repo) {
2489
2905
  execFileSync2("git", ["worktree", "remove", "--force", dir], { cwd: repo.path, stdio: "ignore" });
@@ -2492,15 +2908,15 @@ function removeWorktree(id, dir) {
2492
2908
  } catch {
2493
2909
  }
2494
2910
  }
2495
- fs19.rmSync(dir, { recursive: true, force: true });
2911
+ fs21.rmSync(dir, { recursive: true, force: true });
2496
2912
  }
2497
2913
  function applyCull(items) {
2498
2914
  for (const item of items.filter((i) => i.kind === "worktree")) removeWorktree(item.id, item.target);
2499
- for (const item of items.filter((i) => i.kind === "done")) fs19.rmSync(item.target, { recursive: true, force: true });
2915
+ for (const item of items.filter((i) => i.kind === "done")) fs21.rmSync(item.target, { recursive: true, force: true });
2500
2916
  for (const item of items.filter((i) => i.kind === "state")) {
2501
- const dir = path14.dirname(item.target);
2502
- for (const f of fs19.readdirSync(dir)) {
2503
- if (f.startsWith(`${item.id}.`)) fs19.rmSync(path14.join(dir, f), { force: true });
2917
+ const dir = path16.dirname(item.target);
2918
+ for (const f of fs21.readdirSync(dir)) {
2919
+ if (f.startsWith(`${item.id}.`)) fs21.rmSync(path16.join(dir, f), { force: true });
2504
2920
  }
2505
2921
  }
2506
2922
  }
@@ -2526,6 +2942,10 @@ your working set:
2526
2942
  lobstah man tend [--json] tend the string: fleet verdict,
2527
2943
  unanswered questions, each work
2528
2944
  item's chain + PR + merge gate
2945
+ lobstah watch add <key> --check <cmd> stand watch on something
2946
+ external (a review session, a
2947
+ CI run) \u2014 its events wake you
2948
+ like any dispatch would
2529
2949
 
2530
2950
  getting woken instead of asking:
2531
2951
  lobstah man wait block until a dispatch needs attention (arm it as a
@@ -2545,12 +2965,12 @@ paste into your liaison instructions:
2545
2965
  full pattern and trade-offs: docs/lobsterman.md \xB7 config: docs/configuration.md`;
2546
2966
 
2547
2967
  // apps/cli/src/doctor.ts
2548
- import * as fs20 from "node:fs";
2968
+ import * as fs22 from "node:fs";
2549
2969
  import { createRequire as createRequire3 } from "node:module";
2550
- import { spawnSync } from "node:child_process";
2970
+ import { spawnSync as spawnSync2 } from "node:child_process";
2551
2971
  var HEARTBEAT_STALE_MS2 = 9e4;
2552
2972
  function git(repoPath, ...args) {
2553
- const res = spawnSync("git", ["-C", repoPath, ...args], { encoding: "utf8" });
2973
+ const res = spawnSync2("git", ["-C", repoPath, ...args], { encoding: "utf8" });
2554
2974
  return { ok: res.status === 0, out: (res.stdout ?? "").trim() || (res.stderr ?? "").trim() };
2555
2975
  }
2556
2976
  function runDoctor(now = Date.now()) {
@@ -2574,13 +2994,13 @@ function runDoctor(now = Date.now()) {
2574
2994
  codexPath ? "on PATH" : codexSdk ? "vendored SDK (attach uses it too)" : "neither codex nor the SDK \u2014 codex dispatches will fail"
2575
2995
  );
2576
2996
  const cfgFile = configPath();
2577
- if (!fs20.existsSync(cfgFile)) {
2997
+ if (!fs22.existsSync(cfgFile)) {
2578
2998
  push("config", "fail", `${cfgFile} missing \u2014 run \`lobstah init\``);
2579
2999
  return rows;
2580
3000
  }
2581
3001
  let parsed;
2582
3002
  try {
2583
- parsed = parse(fs20.readFileSync(cfgFile, "utf8"));
3003
+ parsed = parse(fs22.readFileSync(cfgFile, "utf8"));
2584
3004
  push("config", "ok", cfgFile);
2585
3005
  } catch (err) {
2586
3006
  push("config", "fail", `${cfgFile}: ${err instanceof Error ? err.message : String(err)}`);
@@ -2591,7 +3011,7 @@ function runDoctor(now = Date.now()) {
2591
3011
  if (repoKeys.length === 0) push("repos", "warn", "none configured \u2014 add one with `lobstah repos add <path>`");
2592
3012
  for (const [key, repo] of Object.entries(cfg.repos)) {
2593
3013
  const label = `repo ${key}`;
2594
- if (!fs20.existsSync(repo.path)) {
3014
+ if (!fs22.existsSync(repo.path)) {
2595
3015
  push(label, "fail", `path ${repo.path} does not exist`);
2596
3016
  continue;
2597
3017
  }
@@ -2619,11 +3039,11 @@ function runDoctor(now = Date.now()) {
2619
3039
  }
2620
3040
  }
2621
3041
  const hb = executorPath();
2622
- if (!fs20.existsSync(hb)) {
3042
+ if (!fs22.existsSync(hb)) {
2623
3043
  push("daemon", "warn", "no heartbeat \u2014 daemon not running (`lobstah daemon install`)");
2624
3044
  } else {
2625
3045
  try {
2626
- const payload = JSON.parse(fs20.readFileSync(hb, "utf8"));
3046
+ const payload = JSON.parse(fs22.readFileSync(hb, "utf8"));
2627
3047
  const age = now - new Date(payload.heartbeat ?? 0).getTime();
2628
3048
  push(
2629
3049
  "daemon",
@@ -2639,10 +3059,10 @@ function runDoctor(now = Date.now()) {
2639
3059
  }
2640
3060
 
2641
3061
  // apps/cli/src/service.ts
2642
- import * as fs21 from "node:fs";
3062
+ import * as fs23 from "node:fs";
2643
3063
  import * as os5 from "node:os";
2644
- import * as path15 from "node:path";
2645
- import { spawnSync as spawnSync2 } from "node:child_process";
3064
+ import * as path17 from "node:path";
3065
+ import { spawnSync as spawnSync3 } from "node:child_process";
2646
3066
  function xml(s) {
2647
3067
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2648
3068
  }
@@ -2665,8 +3085,8 @@ function renderLaunchdPlist(spec) {
2665
3085
  </dict>
2666
3086
  <key>RunAtLoad</key><true/>
2667
3087
  <key>KeepAlive</key><true/>
2668
- <key>StandardOutPath</key><string>${xml(path15.join(spec.logDir, `${spec.kind}.log`))}</string>
2669
- <key>StandardErrorPath</key><string>${xml(path15.join(spec.logDir, `${spec.kind}.err`))}</string>
3088
+ <key>StandardOutPath</key><string>${xml(path17.posix.join(spec.logDir, `${spec.kind}.log`))}</string>
3089
+ <key>StandardErrorPath</key><string>${xml(path17.posix.join(spec.logDir, `${spec.kind}.err`))}</string>
2670
3090
  </dict>
2671
3091
  </plist>
2672
3092
  `;
@@ -2681,8 +3101,8 @@ Restart=always
2681
3101
  RestartSec=5
2682
3102
  Environment=PATH=${spec.pathEnv}
2683
3103
  Environment=LOBSTAH_HOME=${spec.home}
2684
- StandardOutput=append:${path15.join(spec.logDir, `${spec.kind}.log`)}
2685
- StandardError=append:${path15.join(spec.logDir, `${spec.kind}.err`)}
3104
+ StandardOutput=append:${path17.posix.join(spec.logDir, `${spec.kind}.log`)}
3105
+ StandardError=append:${path17.posix.join(spec.logDir, `${spec.kind}.err`)}
2686
3106
 
2687
3107
  [Install]
2688
3108
  WantedBy=default.target
@@ -2690,45 +3110,48 @@ WantedBy=default.target
2690
3110
  }
2691
3111
  function servicePathEnv(nodePath, home = os5.homedir()) {
2692
3112
  const dirs = [
2693
- path15.dirname(nodePath),
2694
- path15.join(home, ".local", "bin"),
3113
+ path17.posix.dirname(nodePath),
3114
+ path17.posix.join(home, ".local", "bin"),
2695
3115
  "/opt/homebrew/bin",
2696
3116
  "/usr/local/bin",
2697
3117
  "/usr/bin",
2698
3118
  "/bin"
2699
3119
  ];
2700
- return [...new Set(dirs)].join(path15.delimiter);
3120
+ return [...new Set(dirs)].join(":");
2701
3121
  }
2702
3122
  function serviceFile(kind) {
2703
- return process.platform === "darwin" ? path15.join(os5.homedir(), "Library", "LaunchAgents", `lobstah.${kind}.plist`) : path15.join(os5.homedir(), ".config", "systemd", "user", `lobstah-${kind}.service`);
3123
+ return process.platform === "darwin" ? path17.join(os5.homedir(), "Library", "LaunchAgents", `lobstah.${kind}.plist`) : path17.join(os5.homedir(), ".config", "systemd", "user", `lobstah-${kind}.service`);
2704
3124
  }
2705
3125
  function run(cmd, args) {
2706
- const res = spawnSync2(cmd, args, { encoding: "utf8" });
3126
+ const res = spawnSync3(cmd, args, { encoding: "utf8" });
2707
3127
  return { ok: res.status === 0, out: `${res.stdout ?? ""}${res.stderr ?? ""}`.trim() };
2708
3128
  }
2709
3129
  function installService(kind) {
2710
- const entry = fs21.realpathSync(process.argv[1]);
3130
+ if (process.platform === "win32") {
3131
+ throw new Error(`no service manager support on Windows \u2014 run \`lobstah ${kind}\` under your process manager of choice`);
3132
+ }
3133
+ const entry = fs23.realpathSync(process.argv[1]);
2711
3134
  const spec = {
2712
3135
  kind,
2713
3136
  nodePath: process.execPath,
2714
3137
  entry,
2715
3138
  pathEnv: servicePathEnv(process.execPath),
2716
3139
  home: lobstahHome(),
2717
- logDir: path15.join(lobstahHome(), "logs")
3140
+ logDir: path17.join(lobstahHome(), "logs")
2718
3141
  };
2719
3142
  if (!onPath("git", spec.pathEnv)) {
2720
3143
  throw new Error(`git is not reachable on the service PATH (${spec.pathEnv}) \u2014 the daemon cannot allocate worktrees`);
2721
3144
  }
2722
- fs21.mkdirSync(spec.logDir, { recursive: true });
3145
+ fs23.mkdirSync(spec.logDir, { recursive: true });
2723
3146
  const file = serviceFile(kind);
2724
- fs21.mkdirSync(path15.dirname(file), { recursive: true });
3147
+ fs23.mkdirSync(path17.dirname(file), { recursive: true });
2725
3148
  if (process.platform === "darwin") {
2726
- if (fs21.existsSync(file)) run("launchctl", ["unload", file]);
2727
- fs21.writeFileSync(file, renderLaunchdPlist(spec));
3149
+ if (fs23.existsSync(file)) run("launchctl", ["unload", file]);
3150
+ fs23.writeFileSync(file, renderLaunchdPlist(spec));
2728
3151
  const load = run("launchctl", ["load", file]);
2729
3152
  return { file, loaded: load.ok, detail: load.ok ? `label lobstah.${kind}` : load.out };
2730
3153
  }
2731
- fs21.writeFileSync(file, renderSystemdUnit(spec));
3154
+ fs23.writeFileSync(file, renderSystemdUnit(spec));
2732
3155
  run("systemctl", ["--user", "daemon-reload"]);
2733
3156
  const enable = run("systemctl", ["--user", "enable", "--now", `lobstah-${kind}.service`]);
2734
3157
  return { file, loaded: enable.ok, detail: enable.ok ? `unit lobstah-${kind}.service` : enable.out };
@@ -2736,22 +3159,22 @@ function installService(kind) {
2736
3159
  function uninstallService(kind) {
2737
3160
  const file = serviceFile(kind);
2738
3161
  if (process.platform === "darwin") {
2739
- if (fs21.existsSync(file)) run("launchctl", ["unload", file]);
3162
+ if (fs23.existsSync(file)) run("launchctl", ["unload", file]);
2740
3163
  } else {
2741
3164
  run("systemctl", ["--user", "disable", "--now", `lobstah-${kind}.service`]);
2742
3165
  }
2743
- const removed = fs21.existsSync(file);
2744
- if (removed) fs21.unlinkSync(file);
3166
+ const removed = fs23.existsSync(file);
3167
+ if (removed) fs23.unlinkSync(file);
2745
3168
  if (process.platform !== "darwin") run("systemctl", ["--user", "daemon-reload"]);
2746
3169
  return { file, removed };
2747
3170
  }
2748
3171
 
2749
3172
  // apps/cli/src/repos.ts
2750
- import * as fs22 from "node:fs";
2751
- import * as path16 from "node:path";
2752
- import { spawnSync as spawnSync3 } from "node:child_process";
3173
+ import * as fs24 from "node:fs";
3174
+ import * as path18 from "node:path";
3175
+ import { spawnSync as spawnSync4 } from "node:child_process";
2753
3176
  function git2(dir, ...args) {
2754
- const res = spawnSync3("git", ["-C", dir, ...args], { encoding: "utf8" });
3177
+ const res = spawnSync4("git", ["-C", dir, ...args], { encoding: "utf8" });
2755
3178
  return res.status === 0 ? res.stdout.trim() : void 0;
2756
3179
  }
2757
3180
  var LOCKFILE_SETUP = [
@@ -2764,37 +3187,41 @@ var LOCKFILE_SETUP = [
2764
3187
  ["poetry.lock", ["poetry install"]]
2765
3188
  ];
2766
3189
  function detectRepo(dir) {
2767
- const abs = path16.resolve(dir);
3190
+ const abs = path18.resolve(dir);
2768
3191
  if (git2(abs, "rev-parse", "--is-inside-work-tree") !== "true") return void 0;
2769
3192
  const top = git2(abs, "rev-parse", "--show-toplevel");
2770
- if (!top || fs22.realpathSync(top) !== fs22.realpathSync(abs)) return void 0;
3193
+ const canon = (p) => {
3194
+ const real = path18.resolve(fs24.realpathSync.native(p));
3195
+ return process.platform === "win32" ? real.toLowerCase() : real;
3196
+ };
3197
+ if (!top || canon(top) !== canon(abs)) return void 0;
2771
3198
  const origin = git2(abs, "remote", "get-url", "origin");
2772
3199
  const headRef = git2(abs, "symbolic-ref", "--short", "refs/remotes/origin/HEAD");
2773
3200
  const trunk = headRef?.replace(/^origin\//, "") ?? git2(abs, "branch", "--show-current") ?? "main";
2774
- const setup = LOCKFILE_SETUP.find(([f]) => fs22.existsSync(path16.join(abs, f)))?.[1];
2775
- return { key: path16.basename(abs), path: abs, trunk, origin, setup };
3201
+ const setup = LOCKFILE_SETUP.find(([f]) => fs24.existsSync(path18.join(abs, f)))?.[1];
3202
+ return { key: path18.basename(abs), path: abs, trunk, origin, setup };
2776
3203
  }
2777
3204
  function scanForRepos(roots) {
2778
3205
  const found = [];
2779
3206
  for (const root of roots) {
2780
- const abs = path16.resolve(root);
2781
- if (!fs22.existsSync(abs)) continue;
3207
+ const abs = path18.resolve(root);
3208
+ if (!fs24.existsSync(abs)) continue;
2782
3209
  const self = detectRepo(abs);
2783
3210
  if (self) {
2784
3211
  found.push(self);
2785
3212
  continue;
2786
3213
  }
2787
- for (const entry of fs22.readdirSync(abs, { withFileTypes: true })) {
3214
+ for (const entry of fs24.readdirSync(abs, { withFileTypes: true })) {
2788
3215
  if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
2789
- const child = detectRepo(path16.join(abs, entry.name));
3216
+ const child = detectRepo(path18.join(abs, entry.name));
2790
3217
  if (child) found.push(child);
2791
3218
  }
2792
3219
  }
2793
3220
  return found;
2794
3221
  }
2795
3222
  function configuredRepoKeys(file = configPath()) {
2796
- if (!fs22.existsSync(file)) return /* @__PURE__ */ new Set();
2797
- const raw = parse(fs22.readFileSync(file, "utf8"));
3223
+ if (!fs24.existsSync(file)) return /* @__PURE__ */ new Set();
3224
+ const raw = parse(fs24.readFileSync(file, "utf8"));
2798
3225
  return new Set(Object.keys(raw.repos ?? {}));
2799
3226
  }
2800
3227
  function appendRepoBlock(repo, opts = {}, file = configPath()) {
@@ -2802,7 +3229,7 @@ function appendRepoBlock(repo, opts = {}, file = configPath()) {
2802
3229
  if (repo.origin) lines.push(`origin = ${JSON.stringify(repo.origin)}`);
2803
3230
  if (repo.setup) lines.push(`setup = [${repo.setup.map((s) => JSON.stringify(s)).join(", ")}]`);
2804
3231
  if (opts.pickup) lines.push(`pickup = true`);
2805
- fs22.appendFileSync(file, `${lines.join("\n")}
3232
+ fs24.appendFileSync(file, `${lines.join("\n")}
2806
3233
  `);
2807
3234
  }
2808
3235
 
@@ -2833,6 +3260,16 @@ work (humans and agents):
2833
3260
  entries, orphaned worktrees, stale state.
2834
3261
  Dry run by default (14 days).
2835
3262
  cancel <uuid> request cancellation
3263
+ watch [add <key> --check <cmd> [--for <uuid>] [--cursor <c>] [--every <s>]
3264
+ [--brief <template>] [--stream <cmd>] | rm <key>]
3265
+ stand watch on something external: the check
3266
+ command answers "anything since {cursor}?"
3267
+ in JSON. Events wake man wait/haul (default)
3268
+ or fork a continuation of --for's dispatch
3269
+ chain. --stream holds a long-lived NDJSON
3270
+ child under pick for ms-latency delivery
3271
+ (the check stays the guarantee). Bare
3272
+ \`watch\` lists.
2836
3273
 
2837
3274
  host processes:
2838
3275
  daemon [--interval <ms>] the supervisor: claims, worktrees, liveness,
@@ -2850,10 +3287,12 @@ lobsterman (orchestrator sessions \u2014 bare \`lobstah man\` prints the manual)
2850
3287
  PR, and merge-gate status from pick's last
2851
3288
  observation. Pure disk read \u2014 no forge calls.
2852
3289
  man wait [--timeout <secs>] [--peek]
2853
- block until a dispatch needs attention, then
2854
- print the event and what to do next; exit 2
2855
- on timeout. Unanswered questions re-fire
2856
- every remindSecs until answered.
3290
+ block until a dispatch or watched source
3291
+ needs attention, then print the event and
3292
+ what to do next; exit 2 on timeout. Runs due
3293
+ watch checks itself when no pick process is.
3294
+ Unanswered questions re-fire every
3295
+ remindSecs until answered.
2857
3296
  man init [--shared|--global] [--marker]
2858
3297
  install the haul Stop hook: this project's
2859
3298
  .claude/settings.local.json by default,
@@ -2890,10 +3329,26 @@ function arg(args, flag) {
2890
3329
  const i = args.indexOf(flag);
2891
3330
  return i >= 0 ? args[i + 1] : void 0;
2892
3331
  }
3332
+ var WATCH_EVERY_SECS = 45;
3333
+ function runDueManWatches() {
3334
+ for (const w of listWatches()) {
3335
+ if (w.owner === "man" && watchDue(w, WATCH_EVERY_SECS)) runWatchCheck(w);
3336
+ }
3337
+ }
3338
+ function emitWatchAttention(attns) {
3339
+ for (const a of attns) {
3340
+ for (const e of a.events) {
3341
+ console.log(toonKV({ watch: a.watch.key, seq: e.seq, summary: e.summary }));
3342
+ }
3343
+ }
3344
+ console.log(
3345
+ "next: handle the watched update now (a review round, a finished run \u2014 `lobstah watch ls` for context), then re-arm a background `lobstah man wait`."
3346
+ );
3347
+ }
2893
3348
  function findLane(id) {
2894
3349
  for (const lane of ["work", "chore"]) {
2895
3350
  const d = laneDirs(lane);
2896
- if (fs23.existsSync(path17.join(d.active, id)) || fs23.existsSync(path17.join(d.queue, `${id}.json`)) || fs23.existsSync(path17.join(d.done, id)) || fs23.existsSync(path17.join(d.state, `${id}.status`))) {
3351
+ if (fs25.existsSync(path19.join(d.active, id)) || fs25.existsSync(path19.join(d.queue, `${id}.json`)) || fs25.existsSync(path19.join(d.done, id)) || fs25.existsSync(path19.join(d.state, `${id}.status`))) {
2897
3352
  return lane;
2898
3353
  }
2899
3354
  }
@@ -2901,7 +3356,7 @@ function findLane(id) {
2901
3356
  }
2902
3357
  function rowsFor(lane, bucket) {
2903
3358
  const dir = laneDirs(lane)[bucket];
2904
- const entries = fs23.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => ({ f, m: fs23.statSync(path17.join(dir, f)).mtimeMs })).sort((a, b) => b.m - a.m);
3359
+ const entries = fs25.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => ({ f, m: fs25.statSync(path19.join(dir, f)).mtimeMs })).sort((a, b) => b.m - a.m);
2905
3360
  const sliced = bucket === "done" ? entries.slice(0, 10) : entries;
2906
3361
  return sliced.map(({ f, m }) => {
2907
3362
  const id = f.replace(/\.json$/, "");
@@ -2927,9 +3382,9 @@ async function mainCli() {
2927
3382
  throw new Error("dispatch requires --repo and --brief <file> (or --brief-text)");
2928
3383
  }
2929
3384
  const d = {
2930
- id: arg(args, "--id") ?? randomUUID3(),
3385
+ id: arg(args, "--id") ?? randomUUID4(),
2931
3386
  repo,
2932
- brief: briefText ?? fs23.readFileSync(briefFile, "utf8"),
3387
+ brief: briefText ?? fs25.readFileSync(briefFile, "utf8"),
2933
3388
  harness: arg(args, "--harness"),
2934
3389
  model: arg(args, "--model"),
2935
3390
  effort: arg(args, "--effort"),
@@ -2966,17 +3421,17 @@ async function mainCli() {
2966
3421
  if (!id) throw new Error("logs requires a dispatch id");
2967
3422
  const lane = findLane(id);
2968
3423
  const file = eventsPath(id, lane);
2969
- if (fs23.existsSync(file)) process.stdout.write(fs23.readFileSync(file, "utf8"));
3424
+ if (fs25.existsSync(file)) process.stdout.write(fs25.readFileSync(file, "utf8"));
2970
3425
  if (args.includes("--follow")) {
2971
- let size = fs23.existsSync(file) ? fs23.statSync(file).size : 0;
3426
+ let size = fs25.existsSync(file) ? fs25.statSync(file).size : 0;
2972
3427
  setInterval(() => {
2973
- if (!fs23.existsSync(file)) return;
2974
- const now = fs23.statSync(file).size;
3428
+ if (!fs25.existsSync(file)) return;
3429
+ const now = fs25.statSync(file).size;
2975
3430
  if (now > size) {
2976
- const fd = fs23.openSync(file, "r");
3431
+ const fd = fs25.openSync(file, "r");
2977
3432
  const buf = Buffer.alloc(now - size);
2978
- fs23.readSync(fd, buf, 0, buf.length, size);
2979
- fs23.closeSync(fd);
3433
+ fs25.readSync(fd, buf, 0, buf.length, size);
3434
+ fs25.closeSync(fd);
2980
3435
  process.stdout.write(buf.toString("utf8"));
2981
3436
  size = now;
2982
3437
  }
@@ -3032,23 +3487,23 @@ async function mainCli() {
3032
3487
  );
3033
3488
  }
3034
3489
  const dirs = laneDirs(lane);
3035
- const descriptorFile = ["active", "done"].map((b) => path17.join(dirs[b], id, "descriptor.json")).find((f) => fs23.existsSync(f));
3036
- const harness = descriptorFile ? JSON.parse(fs23.readFileSync(descriptorFile, "utf8")).harness ?? "claude" : "claude";
3490
+ const descriptorFile = ["active", "done"].map((b) => path19.join(dirs[b], id, "descriptor.json")).find((f) => fs25.existsSync(f));
3491
+ const harness = descriptorFile ? JSON.parse(fs25.readFileSync(descriptorFile, "utf8")).harness ?? "claude" : "claude";
3037
3492
  let sessionId;
3038
3493
  try {
3039
- sessionId = JSON.parse(fs23.readFileSync(path17.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
3494
+ sessionId = JSON.parse(fs25.readFileSync(path19.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
3040
3495
  } catch {
3041
3496
  }
3042
3497
  if (!sessionId) throw new Error(`${id} has no recorded harness session to attach to`);
3043
- const worktree = path17.join(lobstahHome(), "worktrees", id);
3044
- const cwd = fs23.existsSync(worktree) ? worktree : process.cwd();
3498
+ const worktree = path19.join(lobstahHome(), "worktrees", id);
3499
+ const cwd = fs25.existsSync(worktree) ? worktree : process.cwd();
3045
3500
  const invocation = harness === "codex" ? codexInvocation(["resume", sessionId]) : { file: "claude", argv: ["--resume", sessionId] };
3046
3501
  if (!invocation) throw new Error("no codex CLI found \u2014 neither on PATH nor vendored by @openai/codex-sdk");
3047
3502
  if (args.includes("--print")) {
3048
3503
  console.log(toonKV({ id, harness, sessionId, cwd, command: `${invocation.file} ${invocation.argv.join(" ")}` }));
3049
3504
  break;
3050
3505
  }
3051
- const res = spawnSync4(invocation.file, invocation.argv, { cwd, stdio: "inherit" });
3506
+ const res = spawnSync5(invocation.file, invocation.argv, { cwd, stdio: "inherit" });
3052
3507
  if (res.error) throw new Error(`could not launch ${invocation.file}: ${res.error.message}`);
3053
3508
  break;
3054
3509
  }
@@ -3056,20 +3511,20 @@ async function mainCli() {
3056
3511
  const id = args[0];
3057
3512
  if (!id) throw new Error("swap requires a dispatch id");
3058
3513
  const lane = findLane(id);
3059
- const activeDir = path17.join(laneDirs(lane).active, id);
3060
- if (!fs23.existsSync(activeDir)) throw new Error(`${id} is not active \u2014 swap only applies to in-flight dispatches`);
3061
- const descFile = path17.join(activeDir, "descriptor.json");
3062
- const descriptor = JSON.parse(fs23.readFileSync(descFile, "utf8"));
3514
+ const activeDir = path19.join(laneDirs(lane).active, id);
3515
+ if (!fs25.existsSync(activeDir)) throw new Error(`${id} is not active \u2014 swap only applies to in-flight dispatches`);
3516
+ const descFile = path19.join(activeDir, "descriptor.json");
3517
+ const descriptor = JSON.parse(fs25.readFileSync(descFile, "utf8"));
3063
3518
  const fromHarness = descriptor.harness ?? "default";
3064
3519
  for (const key of ["harness", "model", "effort"]) {
3065
3520
  const v = arg(args, `--${key}`);
3066
3521
  if (v) descriptor[key] = v;
3067
3522
  }
3068
- fs23.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
3069
- const worktree = path17.join(lobstahHome(), "worktrees", id);
3523
+ fs25.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
3524
+ const worktree = path19.join(lobstahHome(), "worktrees", id);
3070
3525
  let progress = "No worktree progress recorded.";
3071
- if (fs23.existsSync(worktree)) {
3072
- const git3 = (...a) => spawnSync4("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
3526
+ if (fs25.existsSync(worktree)) {
3527
+ const git3 = (...a) => spawnSync5("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
3073
3528
  const commits = git3("log", "--oneline", "-15");
3074
3529
  const status = git3("status", "--short");
3075
3530
  progress = `Commits so far:
@@ -3078,17 +3533,17 @@ ${commits || "(none)"}
3078
3533
  Uncommitted changes:
3079
3534
  ${status || "(clean)"}`;
3080
3535
  }
3081
- fs23.writeFileSync(
3082
- path17.join(activeDir, "handoff"),
3536
+ fs25.writeFileSync(
3537
+ path19.join(activeDir, "handoff"),
3083
3538
  `You are taking over this dispatch from a previous agent session (harness: ${fromHarness}). Its conversation is not available \u2014 the worktree state below is the ground truth. Review it, then continue the brief from where it stops.
3084
3539
 
3085
3540
  ${progress}`
3086
3541
  );
3087
- const runnerFile = path17.join(activeDir, "runner.json");
3088
- if (fs23.existsSync(runnerFile)) {
3089
- const runner = JSON.parse(fs23.readFileSync(runnerFile, "utf8"));
3542
+ const runnerFile = path19.join(activeDir, "runner.json");
3543
+ if (fs25.existsSync(runnerFile)) {
3544
+ const runner = JSON.parse(fs25.readFileSync(runnerFile, "utf8"));
3090
3545
  if (pidAlive(runner.pid, runner.processStartTime)) killGroup(runner.pid, "SIGKILL");
3091
- fs23.unlinkSync(runnerFile);
3546
+ fs25.unlinkSync(runnerFile);
3092
3547
  }
3093
3548
  appendStatus(id, lane, "working", `swapped to ${descriptor.harness ?? "default"} \u2014 awaiting respawn`);
3094
3549
  console.log(toonKV({ id, harness: descriptor.harness ?? "default", model: descriptor.model, swapped: true }));
@@ -3164,9 +3619,13 @@ ${progress}`
3164
3619
  );
3165
3620
  };
3166
3621
  const remindMs = (loadConfig().remindSecs ?? 900) * 1e3;
3167
- const standing = attentionNow(!args.includes("--peek"), remindMs);
3168
- if (standing.length > 0) {
3169
- emit(standing);
3622
+ const consume = !args.includes("--peek");
3623
+ runDueManWatches();
3624
+ const standing = attentionNow(consume, remindMs);
3625
+ const standingWatches = pendingWatchEvents(consume);
3626
+ if (standing.length > 0 || standingWatches.length > 0) {
3627
+ if (standing.length > 0) emit(standing);
3628
+ if (standingWatches.length > 0) emitWatchAttention(standingWatches);
3170
3629
  break;
3171
3630
  }
3172
3631
  const baseline = captureWaitBaseline();
@@ -3177,26 +3636,32 @@ ${progress}`
3177
3636
  emit(fresh);
3178
3637
  return;
3179
3638
  }
3639
+ runDueManWatches();
3640
+ const watched = pendingWatchEvents(true);
3641
+ if (watched.length > 0) {
3642
+ emitWatchAttention(watched);
3643
+ return;
3644
+ }
3180
3645
  }
3181
3646
  console.log(toonKV({ timeout: true, waitedSecs: timeoutSecs }));
3182
3647
  process.exitCode = 2;
3183
3648
  break;
3184
3649
  }
3185
3650
  case "man:init": {
3186
- const file = args.includes("--global") ? path17.join(os6.homedir(), ".claude", "settings.json") : path17.join(".claude", args.includes("--shared") ? "settings.json" : "settings.local.json");
3651
+ const file = args.includes("--global") ? path19.join(os6.homedir(), ".claude", "settings.json") : path19.join(".claude", args.includes("--shared") ? "settings.json" : "settings.local.json");
3187
3652
  let existing;
3188
3653
  try {
3189
- existing = JSON.parse(fs23.readFileSync(file, "utf8"));
3654
+ existing = JSON.parse(fs25.readFileSync(file, "utf8"));
3190
3655
  } catch {
3191
3656
  existing = void 0;
3192
3657
  }
3193
3658
  const { settings, changed } = mergeHaulHook(existing);
3194
3659
  if (changed) {
3195
- fs23.mkdirSync(path17.dirname(file), { recursive: true });
3196
- fs23.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
3660
+ fs25.mkdirSync(path19.dirname(file), { recursive: true });
3661
+ fs25.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
3197
3662
  `);
3198
3663
  }
3199
- if (args.includes("--marker")) fs23.writeFileSync(".lobstah-man", "");
3664
+ if (args.includes("--marker")) fs25.writeFileSync(".lobstah-man", "");
3200
3665
  console.log(
3201
3666
  toonKV({
3202
3667
  hook: "lobstah man haul",
@@ -3209,32 +3674,41 @@ ${progress}`
3209
3674
  }
3210
3675
  case "man:haul": {
3211
3676
  try {
3212
- if (process.env.LOBSTAH_MAN !== "1" && !fs23.existsSync(".lobstah-man")) break;
3677
+ if (process.env.LOBSTAH_MAN !== "1" && !fs25.existsSync(".lobstah-man")) break;
3213
3678
  const anyActive = ["work", "chore"].some(
3214
- (l) => fs23.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
3679
+ (l) => fs25.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
3215
3680
  );
3216
- if (!anyActive) break;
3681
+ const anyWatch = listWatches().some((w) => w.owner === "man");
3682
+ if (!anyActive && !anyWatch) break;
3217
3683
  const timeoutSecs = Number(arg(args, "--timeout") ?? "14000");
3218
3684
  const deadline = Date.now() + timeoutSecs * 1e3;
3219
3685
  const remindMs = (loadConfig().remindSecs ?? 900) * 1e3;
3686
+ runDueManWatches();
3220
3687
  let evs = attentionNow(true, remindMs);
3221
- if (evs.length === 0) {
3688
+ let watched = pendingWatchEvents(true);
3689
+ if (evs.length === 0 && watched.length === 0) {
3222
3690
  const baseline = captureWaitBaseline();
3223
3691
  while (Date.now() < deadline) {
3224
3692
  await new Promise((r) => setTimeout(r, 1500));
3225
3693
  evs = freshWakeEvents(baseline);
3226
3694
  if (evs.length === 0) evs = attentionNow(true, remindMs);
3227
- if (evs.length > 0) break;
3695
+ runDueManWatches();
3696
+ watched = pendingWatchEvents(true);
3697
+ if (evs.length > 0 || watched.length > 0) break;
3228
3698
  }
3229
3699
  }
3230
- if (evs.length === 0) break;
3231
- const lines = evs.map(
3232
- (ev) => `- ${ev.entry.verb} ${ev.id}${ev.entry.note ? ` \u2014 ${ev.entry.note}` : ""}`
3233
- );
3700
+ if (evs.length === 0 && watched.length === 0) break;
3701
+ const lines = [
3702
+ ...evs.map((ev) => `- ${ev.entry.verb} ${ev.id}${ev.entry.note ? ` \u2014 ${ev.entry.note}` : ""}`),
3703
+ ...watched.flatMap(
3704
+ (a) => a.events.map((e) => `- watch ${a.watch.key}${e.summary ? ` \u2014 ${e.summary}` : ` (seq ${e.seq})`}`)
3705
+ )
3706
+ ];
3234
3707
  const reason = [
3235
- "A lobstah dispatch needs attention:",
3708
+ "A lobstah dispatch or watched source needs attention:",
3236
3709
  ...lines,
3237
- 'Check with `lobstah status <id>`; answer a needs-decision with `lobstah send <id> "<answer>"`.',
3710
+ 'Check a dispatch with `lobstah status <id>`; answer a needs-decision with `lobstah send <id> "<answer>"`.',
3711
+ "A watch line means an external source updated (e.g. a review round) \u2014 handle it directly.",
3238
3712
  "Handle it now. This session re-parks automatically at turn end \u2014 do not arm any watcher."
3239
3713
  ].join("\n");
3240
3714
  console.log(JSON.stringify({ decision: "block", reason }));
@@ -3283,7 +3757,7 @@ ${progress}`
3283
3757
  path: r.path,
3284
3758
  trunk: r.trunk,
3285
3759
  pickup: r.pickup ?? false,
3286
- exists: fs23.existsSync(r.path)
3760
+ exists: fs25.existsSync(r.path)
3287
3761
  }));
3288
3762
  console.log(toonTable("repos", rows, ["key", "path", "trunk", "pickup", "exists"]));
3289
3763
  break;
@@ -3292,8 +3766,43 @@ ${progress}`
3292
3766
  console.log(lobstahVersion());
3293
3767
  break;
3294
3768
  }
3769
+ case "watch": {
3770
+ const sub = args[0];
3771
+ if (sub === "add") {
3772
+ const key = args[1];
3773
+ const check = arg(args, "--check");
3774
+ if (!key || key.startsWith("--") || !check) throw new Error("watch add requires a key and --check <command>");
3775
+ const forId = arg(args, "--for");
3776
+ const every = arg(args, "--every");
3777
+ const w = addWatch(key, check, {
3778
+ owner: forId ? `dispatch:${forId}` : "man",
3779
+ cursor: arg(args, "--cursor"),
3780
+ everySecs: every ? Number(every) : void 0,
3781
+ brief: arg(args, "--brief"),
3782
+ stream: arg(args, "--stream")
3783
+ });
3784
+ console.log(toonKV({ key: w.key, owner: w.owner, cursor: w.cursor, registered: true }));
3785
+ break;
3786
+ }
3787
+ if (sub === "rm") {
3788
+ const key = args[1];
3789
+ if (!key) throw new Error("watch rm requires a key");
3790
+ console.log(toonKV({ key, removed: removeWatch(key) }));
3791
+ break;
3792
+ }
3793
+ const rows = listWatches().map((w) => ({
3794
+ key: w.key,
3795
+ owner: w.owner,
3796
+ cursor: w.cursor,
3797
+ pending: readWatchEvents(w.key).length - w.seen,
3798
+ lastChecked: w.lastCheckedAt ?? "-",
3799
+ error: w.lastError ?? ""
3800
+ }));
3801
+ console.log(toonTable("watches", rows, ["key", "owner", "cursor", "pending", "lastChecked", "error"]));
3802
+ break;
3803
+ }
3295
3804
  case "init": {
3296
- if (!fs23.existsSync(configPath())) {
3805
+ if (!fs25.existsSync(configPath())) {
3297
3806
  const exampleRepo = args.includes("--scan") ? "" : `[repos.example]
3298
3807
  path = "~/src/example"
3299
3808
  trunk = "main"
@@ -3302,7 +3811,7 @@ trunk = "main"
3302
3811
  # pickup = true # opt into [pickup.github] multi-repo tracker pickup
3303
3812
 
3304
3813
  `;
3305
- fs23.writeFileSync(
3814
+ fs25.writeFileSync(
3306
3815
  configPath(),
3307
3816
  `# lobstah workspace definitions \u2014 the descriptor's repo key resolves here.
3308
3817
  # Top-level keys must come before any [section].
@@ -3340,12 +3849,12 @@ wallClockSecs = 3600
3340
3849
  let reference = "https://github.com/aequitas-labs/lobstah/blob/main/docs/configuration.md";
3341
3850
  try {
3342
3851
  const local = fileURLToPath2(new URL("../docs/configuration.md", import.meta.url));
3343
- if (fs23.existsSync(local)) reference = local;
3852
+ if (fs25.existsSync(local)) reference = local;
3344
3853
  } catch {
3345
3854
  }
3346
3855
  console.log(
3347
3856
  toonKV({
3348
- home: path17.dirname(configPath()),
3857
+ home: path19.dirname(configPath()),
3349
3858
  config: configPath(),
3350
3859
  ...added.length > 0 ? { added: added.join(", ") } : {},
3351
3860
  ...unmarked.length > 0 ? { note: `none marked pickable \u2014 set pickup = true per [repos.*] (or rerun with --pickup)` } : {},