lobstah 0.1.1 → 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,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // apps/cli/src/main.ts
4
- import * as fs18 from "node:fs";
5
- import * as path14 from "node:path";
6
- import { randomUUID as randomUUID3 } from "node:crypto";
7
- import { spawnSync } from "node:child_process";
4
+ import * as fs25 from "node:fs";
5
+ import * as os6 from "node:os";
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
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
8
10
 
9
11
  // packages/core/dist/types.js
10
12
  var VERBS = ["working", "needs-decision", "blocked", "paused", "done", "failed"];
@@ -36,6 +38,7 @@ function ensureLayout() {
36
38
  fs.mkdirSync(dir, { recursive: true });
37
39
  }
38
40
  fs.mkdirSync(path.join(lobstahHome(), "worktrees"), { recursive: true });
41
+ fs.mkdirSync(path.join(lobstahHome(), "watches"), { recursive: true });
39
42
  }
40
43
  function statusPath(id, lane) {
41
44
  return path.join(laneDirs(lane).state, `${id}.status`);
@@ -868,7 +871,8 @@ function loadConfig() {
868
871
  trunk: String(r.trunk ?? "main"),
869
872
  setup: Array.isArray(r.setup) ? r.setup.map(String) : void 0,
870
873
  env: r.env ?? void 0,
871
- harness: r.harness ?? void 0
874
+ harness: r.harness ?? void 0,
875
+ pickup: r.pickup === void 0 ? void 0 : Boolean(r.pickup)
872
876
  };
873
877
  }
874
878
  return {
@@ -898,6 +902,235 @@ function toonTable(name, rows, cols) {
898
902
  return [header, ...rows.map((r) => ` ${cols.map((c) => cell(r[c])).join(",")}`)].join("\n");
899
903
  }
900
904
 
905
+ // packages/core/dist/version.js
906
+ import * as fs8 from "node:fs";
907
+ function lobstahVersion() {
908
+ try {
909
+ const pkg = JSON.parse(fs8.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
910
+ if (pkg.name === "lobstah" && typeof pkg.version === "string")
911
+ return pkg.version;
912
+ } catch {
913
+ }
914
+ return "0.0.0-dev";
915
+ }
916
+
917
+ // packages/core/dist/harness.js
918
+ import * as fs9 from "node:fs";
919
+ import * as path5 from "node:path";
920
+ import { createRequire } from "node:module";
921
+ function onPath(bin, pathVar = process.env.PATH) {
922
+ if (!pathVar)
923
+ return false;
924
+ const names = process.platform === "win32" ? [bin, `${bin}.exe`, `${bin}.cmd`, `${bin}.bat`] : [bin];
925
+ for (const dir of pathVar.split(path5.delimiter)) {
926
+ if (!dir)
927
+ continue;
928
+ for (const name of names) {
929
+ const candidate = path5.join(dir, name);
930
+ try {
931
+ fs9.accessSync(candidate, fs9.constants.X_OK);
932
+ if (fs9.statSync(candidate).isFile())
933
+ return true;
934
+ } catch {
935
+ }
936
+ }
937
+ }
938
+ return false;
939
+ }
940
+ function resolvable(specifier) {
941
+ try {
942
+ createRequire(import.meta.url).resolve(specifier);
943
+ return true;
944
+ } catch {
945
+ return false;
946
+ }
947
+ }
948
+ function detectHarnesses() {
949
+ const out = [];
950
+ if (onPath("claude") || resolvable("@anthropic-ai/claude-agent-sdk"))
951
+ out.push("claude");
952
+ if (onPath("codex") || resolvable("@openai/codex-sdk"))
953
+ out.push("codex");
954
+ return out;
955
+ }
956
+ function codexInvocation(argv) {
957
+ if (onPath("codex"))
958
+ return { file: "codex", argv };
959
+ try {
960
+ const cli = createRequire(import.meta.url).resolve("@openai/codex/bin/codex.js");
961
+ return { file: process.execPath, argv: [cli, ...argv] };
962
+ } catch {
963
+ return void 0;
964
+ }
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
+
901
1134
  // packages/supervisor/dist/liveness.js
902
1135
  import { execFileSync } from "node:child_process";
903
1136
  function classify(input) {
@@ -956,26 +1189,26 @@ function killGroup(pid, signal = "SIGTERM") {
956
1189
  }
957
1190
 
958
1191
  // packages/supervisor/dist/daemon.js
959
- import * as fs9 from "node:fs";
1192
+ import * as fs12 from "node:fs";
960
1193
  import * as os3 from "node:os";
961
- import * as path6 from "node:path";
1194
+ import * as path8 from "node:path";
962
1195
  import { spawn as spawn2 } from "node:child_process";
963
- import { createRequire } from "node:module";
1196
+ import { createRequire as createRequire2 } from "node:module";
964
1197
  import { fileURLToPath } from "node:url";
965
1198
 
966
1199
  // packages/supervisor/dist/notify.js
967
- import * as fs8 from "node:fs";
968
- import * as path5 from "node:path";
1200
+ import * as fs11 from "node:fs";
1201
+ import * as path7 from "node:path";
969
1202
  import { spawn } from "node:child_process";
970
1203
  var DEFAULT_NOTIFY_VERBS = ["needs-decision", "blocked", "done", "failed"];
971
1204
  function cursorPath(id, lane) {
972
- return path5.join(laneDirs(lane).state, `${id}.notified`);
1205
+ return path7.join(laneDirs(lane).state, `${id}.notified`);
973
1206
  }
974
1207
  function pendingNotifications(id, lane, verbs = DEFAULT_NOTIFY_VERBS, sinceMs = 0) {
975
1208
  const log = readStatusLog(id, lane);
976
1209
  let cursor;
977
1210
  try {
978
- cursor = Number(fs8.readFileSync(cursorPath(id, lane), "utf8")) || 0;
1211
+ cursor = Number(fs11.readFileSync(cursorPath(id, lane), "utf8")) || 0;
979
1212
  } catch {
980
1213
  cursor = void 0;
981
1214
  }
@@ -985,13 +1218,13 @@ function pendingNotifications(id, lane, verbs = DEFAULT_NOTIFY_VERBS, sinceMs =
985
1218
  if (cursor === void 0 && sinceMs > 0) {
986
1219
  fresh = fresh.filter((e) => Date.parse(e.at) >= sinceMs);
987
1220
  }
988
- fs8.writeFileSync(cursorPath(id, lane), String(log.length));
1221
+ fs11.writeFileSync(cursorPath(id, lane), String(log.length));
989
1222
  return fresh.filter((e) => verbs.includes(e.verb)).map((entry) => ({ id, lane, entry }));
990
1223
  }
991
1224
  function notifiableIds(lane, maxAgeMs = 48 * 36e5) {
992
1225
  const dir = laneDirs(lane).state;
993
1226
  const cutoff = Date.now() - maxAgeMs;
994
- return fs8.readdirSync(dir).filter((f) => f.endsWith(".status")).filter((f) => fs8.statSync(path5.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));
995
1228
  }
996
1229
  function execNotify(command, ev, log = () => {
997
1230
  }) {
@@ -1021,11 +1254,11 @@ function captureWaitBaseline() {
1021
1254
  return lengths;
1022
1255
  }
1023
1256
  function attnCursorPath(id, lane) {
1024
- return path5.join(laneDirs(lane).state, `${id}.attn`);
1257
+ return path7.join(laneDirs(lane).state, `${id}.attn`);
1025
1258
  }
1026
1259
  function readAttnCursor(file) {
1027
1260
  try {
1028
- const parsed = JSON.parse(fs8.readFileSync(file, "utf8"));
1261
+ const parsed = JSON.parse(fs11.readFileSync(file, "utf8"));
1029
1262
  return { seen: parsed.seen ?? 0, at: parsed.at ?? 0 };
1030
1263
  } catch {
1031
1264
  return { seen: 0, at: 0 };
@@ -1051,7 +1284,7 @@ function attentionNow(consume = true, remindMs = 15 * 6e4, now = Date.now()) {
1051
1284
  continue;
1052
1285
  out.push({ id, lane, entry });
1053
1286
  if (consume)
1054
- fs8.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 }));
1055
1288
  }
1056
1289
  }
1057
1290
  return out;
@@ -1076,7 +1309,7 @@ function freshWakeEvents(baseline, verbs = DEFAULT_NOTIFY_VERBS) {
1076
1309
  }
1077
1310
 
1078
1311
  // packages/supervisor/dist/daemon.js
1079
- var require_ = createRequire(import.meta.url);
1312
+ var require_ = createRequire2(import.meta.url);
1080
1313
  function runnerEntry() {
1081
1314
  try {
1082
1315
  return require_.resolve("@lobstah/runner");
@@ -1086,33 +1319,33 @@ function runnerEntry() {
1086
1319
  }
1087
1320
  function readRunnerInfo(dir) {
1088
1321
  try {
1089
- return JSON.parse(fs9.readFileSync(path6.join(dir, "runner.json"), "utf8"));
1322
+ return JSON.parse(fs12.readFileSync(path8.join(dir, "runner.json"), "utf8"));
1090
1323
  } catch {
1091
1324
  return void 0;
1092
1325
  }
1093
1326
  }
1094
1327
  function listActive(lane) {
1095
1328
  const dir = laneDirs(lane).active;
1096
- return fs9.readdirSync(dir).filter((f) => !f.startsWith(".")).map((id) => {
1097
- const d = path6.join(dir, id);
1329
+ return fs12.readdirSync(dir).filter((f) => !f.startsWith(".")).map((id) => {
1330
+ const d = path8.join(dir, id);
1098
1331
  return { id, lane, dir: d, runner: readRunnerInfo(d) };
1099
1332
  });
1100
1333
  }
1101
1334
  function finalize(st) {
1102
1335
  try {
1103
- fs9.renameSync(st.dir, path6.join(laneDirs(st.lane).done, st.id));
1336
+ fs12.renameSync(st.dir, path8.join(laneDirs(st.lane).done, st.id));
1104
1337
  } catch {
1105
1338
  }
1106
1339
  }
1107
1340
  function spawnRunner(st, opts) {
1108
1341
  let nudge = opts.nudge;
1109
- const handoffFile = path6.join(st.dir, "handoff");
1110
- if (!nudge && fs9.existsSync(handoffFile)) {
1111
- nudge = fs9.readFileSync(handoffFile, "utf8");
1112
- fs9.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);
1113
1346
  }
1114
- const logPath = path6.join(laneDirs(st.lane).state, `${st.id}.runner.log`);
1115
- const log = fs9.openSync(logPath, "a");
1347
+ const logPath = path8.join(laneDirs(st.lane).state, `${st.id}.runner.log`);
1348
+ const log = fs12.openSync(logPath, "a");
1116
1349
  const child = spawn2(process.execPath, [runnerEntry(), st.dir, st.lane], {
1117
1350
  detached: true,
1118
1351
  stdio: ["ignore", log, log],
@@ -1123,7 +1356,7 @@ function spawnRunner(st, opts) {
1123
1356
  ...nudge ? { LOBSTAH_NUDGE: nudge } : {}
1124
1357
  }
1125
1358
  });
1126
- fs9.closeSync(log);
1359
+ fs12.closeSync(log);
1127
1360
  child.unref();
1128
1361
  const info = {
1129
1362
  pid: child.pid ?? -1,
@@ -1131,22 +1364,22 @@ function spawnRunner(st, opts) {
1131
1364
  processStartTime: child.pid ? processStartTime(child.pid) : void 0,
1132
1365
  attempts: opts.attempts
1133
1366
  };
1134
- fs9.writeFileSync(path6.join(st.dir, "runner.json"), JSON.stringify(info, null, 2));
1367
+ fs12.writeFileSync(path8.join(st.dir, "runner.json"), JSON.stringify(info, null, 2));
1135
1368
  }
1136
1369
  function sessionOf(st) {
1137
1370
  try {
1138
- const ev = JSON.parse(fs9.readFileSync(path6.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"));
1139
1372
  return ev.sessionId;
1140
1373
  } catch {
1141
1374
  return void 0;
1142
1375
  }
1143
1376
  }
1144
1377
  function reconcileOne(st, cfg, log) {
1145
- const hasDescriptor = fs9.existsSync(path6.join(st.dir, "descriptor.json"));
1378
+ const hasDescriptor = fs12.existsSync(path8.join(st.dir, "descriptor.json"));
1146
1379
  if (!hasDescriptor) {
1147
- const age = Date.now() - fs9.statSync(st.dir).mtimeMs;
1380
+ const age = Date.now() - fs12.statSync(st.dir).mtimeMs;
1148
1381
  if (age > 5 * 6e4)
1149
- fs9.rmSync(st.dir, { recursive: true, force: true });
1382
+ fs12.rmSync(st.dir, { recursive: true, force: true });
1150
1383
  return;
1151
1384
  }
1152
1385
  const statusLog = readStatusLog(st.id, st.lane);
@@ -1224,22 +1457,22 @@ function writeHeartbeat(cfg) {
1224
1457
  const payload = {
1225
1458
  machineId: os3.hostname(),
1226
1459
  repos: Object.keys(cfg.repos),
1227
- harnesses: ["claude", "codex"],
1460
+ harnesses: detectHarnesses(),
1228
1461
  maxConcurrent: cfg.limits.maxConcurrent,
1229
- version: "0.1.0",
1462
+ version: lobstahVersion(),
1230
1463
  heartbeat: (/* @__PURE__ */ new Date()).toISOString()
1231
1464
  };
1232
1465
  const tmp = `${executorPath()}.tmp`;
1233
- fs9.writeFileSync(tmp, JSON.stringify(payload, null, 2));
1234
- fs9.renameSync(tmp, executorPath());
1466
+ fs12.writeFileSync(tmp, JSON.stringify(payload, null, 2));
1467
+ fs12.renameSync(tmp, executorPath());
1235
1468
  }
1236
1469
  function pruneChores(cfg) {
1237
1470
  const dir = laneDirs("chore").done;
1238
1471
  const cutoff = Date.now() - cfg.limits.choreRetentionDays * 864e5;
1239
- for (const id of fs9.readdirSync(dir)) {
1240
- const p = path6.join(dir, id);
1241
- if (fs9.statSync(p).mtimeMs < cutoff)
1242
- fs9.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 });
1243
1476
  }
1244
1477
  }
1245
1478
  var daemonStartedAt = Date.now();
@@ -1279,9 +1512,9 @@ function tick(log = () => {
1279
1512
  pruneChores(loadConfig());
1280
1513
  }
1281
1514
  function acquireDaemonLock() {
1282
- const lockPath = path6.join(lobstahHome(), "daemon.lock");
1515
+ const lockPath = path8.join(lobstahHome(), "daemon.lock");
1283
1516
  try {
1284
- const prior = JSON.parse(fs9.readFileSync(lockPath, "utf8"));
1517
+ const prior = JSON.parse(fs12.readFileSync(lockPath, "utf8"));
1285
1518
  if (pidAlive(prior.pid, prior.processStartTime)) {
1286
1519
  throw new Error(`another daemon (pid ${prior.pid}) already supervises ${lobstahHome()} \u2014 run a second instance against its own LOBSTAH_HOME instead`);
1287
1520
  }
@@ -1289,10 +1522,10 @@ function acquireDaemonLock() {
1289
1522
  if (err instanceof Error && err.message.includes("already supervises"))
1290
1523
  throw err;
1291
1524
  }
1292
- fs9.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));
1293
1526
  const release = () => {
1294
1527
  try {
1295
- fs9.unlinkSync(lockPath);
1528
+ fs12.unlinkSync(lockPath);
1296
1529
  } catch {
1297
1530
  }
1298
1531
  };
@@ -1304,17 +1537,42 @@ function acquireDaemonLock() {
1304
1537
  });
1305
1538
  }
1306
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
+ }
1307
1553
  async function daemon(intervalMs = 5e3, log = console.log) {
1308
1554
  ensureLayout();
1309
1555
  acquireDaemonLock();
1310
1556
  log(`lobstah daemon: watching ${laneDirs("work").queue} every ${intervalMs}ms`);
1557
+ let wake;
1558
+ watchQueues(() => wake?.());
1311
1559
  while (true) {
1312
1560
  try {
1313
1561
  tick(log);
1314
1562
  } catch (err) {
1315
1563
  log(`tick error: ${err instanceof Error ? err.message : String(err)}`);
1316
1564
  }
1317
- 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
+ });
1318
1576
  }
1319
1577
  }
1320
1578
 
@@ -1322,7 +1580,7 @@ async function daemon(intervalMs = 5e3, log = console.log) {
1322
1580
  import { spawn as spawn3 } from "node:child_process";
1323
1581
 
1324
1582
  // apps/pick/dist/config.js
1325
- import * as fs10 from "node:fs";
1583
+ import * as fs13 from "node:fs";
1326
1584
  import { execSync } from "node:child_process";
1327
1585
  import * as os4 from "node:os";
1328
1586
 
@@ -1341,6 +1599,11 @@ function marker(uuid) {
1341
1599
  }
1342
1600
 
1343
1601
  // apps/pick/dist/config.js
1602
+ function githubRepoFromOrigin(origin) {
1603
+ const m = /^git@github\.com:(.+?)(?:\.git)?$/.exec(origin) ?? /^ssh:\/\/git@github\.com\/(.+?)(?:\.git)?$/.exec(origin) ?? /^https:\/\/github\.com\/(.+?)(?:\.git)?\/?$/.exec(origin);
1604
+ const repo = m?.[1];
1605
+ return repo && repo.split("/").length === 2 ? repo : void 0;
1606
+ }
1344
1607
  function resolveTokenSource(section, label) {
1345
1608
  const command = section.tokenCommand ? String(section.tokenCommand) : void 0;
1346
1609
  const file = section.tokenFile ? String(section.tokenFile) : void 0;
@@ -1359,7 +1622,7 @@ function resolveTokenSource(section, label) {
1359
1622
  }
1360
1623
  if (file) {
1361
1624
  return () => {
1362
- const token = fs10.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();
1363
1626
  if (!token)
1364
1627
  throw new Error(`${label}: tokenFile ${file} is empty`);
1365
1628
  return token;
@@ -1377,27 +1640,55 @@ function resolveTokenSource(section, label) {
1377
1640
  }
1378
1641
  function loadPickupConfig() {
1379
1642
  const file = configPath();
1380
- const raw = fs10.existsSync(file) ? parse(fs10.readFileSync(file, "utf8")) : {};
1643
+ const raw = fs13.existsSync(file) ? parse(fs13.readFileSync(file, "utf8")) : {};
1381
1644
  const p = raw.pickup ?? {};
1382
1645
  const out = {
1383
1646
  pollSecs: Number(p.pollSecs ?? 45),
1384
- notifyCommand: p.notifyCommand ? String(p.notifyCommand) : void 0
1647
+ notifyCommand: p.notifyCommand ? String(p.notifyCommand) : void 0,
1648
+ github: []
1385
1649
  };
1386
1650
  const gh = p.github;
1387
1651
  if (gh) {
1388
1652
  const token = resolveTokenSource({ tokenEnv: "GITHUB_TOKEN", ...gh }, "pickup.github");
1389
1653
  token();
1390
- out.github = {
1391
- repo: String(gh.repo ?? ""),
1392
- key: String(gh.key ?? ""),
1393
- identity: String(gh.identity ?? ""),
1654
+ const identity = String(gh.identity ?? "");
1655
+ if (!identity)
1656
+ throw new Error("pickup.github requires identity");
1657
+ const shared = {
1658
+ identity,
1394
1659
  token,
1395
1660
  startLabel: String(gh.startLabel ?? "lobstah"),
1396
- claimedLabel: String(gh.claimedLabel ?? "lobstah:claimed"),
1397
- merge: { ...DEFAULT_MERGE_POLICY, ...gh.merge ?? {} }
1661
+ claimedLabel: String(gh.claimedLabel ?? "lobstah:claimed")
1398
1662
  };
1399
- if (!out.github.repo || !out.github.key || !out.github.identity) {
1400
- throw new Error("pickup.github requires repo, key, and identity");
1663
+ const mergeBase = { ...DEFAULT_MERGE_POLICY, ...gh.merge ?? {} };
1664
+ const overrides = gh.overrides ?? {};
1665
+ if (gh.repo || gh.key) {
1666
+ const single = { ...shared, repo: String(gh.repo ?? ""), key: String(gh.key ?? ""), merge: mergeBase };
1667
+ if (!single.repo || !single.key)
1668
+ throw new Error("pickup.github requires repo, key, and identity");
1669
+ out.github.push(single);
1670
+ } else {
1671
+ for (const [key, repo] of Object.entries(loadConfig().repos)) {
1672
+ if (!repo.pickup)
1673
+ continue;
1674
+ if (!repo.origin)
1675
+ throw new Error(`repos.${key} has pickup = true but no origin to derive the GitHub repo from`);
1676
+ const forgeRepo = githubRepoFromOrigin(repo.origin);
1677
+ if (!forgeRepo)
1678
+ throw new Error(`repos.${key} has pickup = true but origin "${repo.origin}" is not a GitHub URL`);
1679
+ const o = overrides[key] ?? {};
1680
+ out.github.push({
1681
+ ...shared,
1682
+ repo: forgeRepo,
1683
+ key,
1684
+ startLabel: o.startLabel ? String(o.startLabel) : shared.startLabel,
1685
+ claimedLabel: o.claimedLabel ? String(o.claimedLabel) : shared.claimedLabel,
1686
+ merge: { ...mergeBase, ...o.merge ?? {} }
1687
+ });
1688
+ }
1689
+ if (out.github.length === 0) {
1690
+ throw new Error("pickup.github has no repo \u2014 multi-repo mode needs at least one [repos.<key>] with pickup = true and a GitHub origin");
1691
+ }
1401
1692
  }
1402
1693
  }
1403
1694
  const ln = p.linear;
@@ -1422,23 +1713,23 @@ function loadPickupConfig() {
1422
1713
  }
1423
1714
 
1424
1715
  // apps/pick/dist/state.js
1425
- import * as fs11 from "node:fs";
1426
- import * as path7 from "node:path";
1716
+ import * as fs14 from "node:fs";
1717
+ import * as path9 from "node:path";
1427
1718
  var PickupState = class {
1428
1719
  file;
1429
1720
  data;
1430
- constructor(dir = path7.join(lobstahHome(), "pickup")) {
1431
- fs11.mkdirSync(dir, { recursive: true });
1432
- this.file = path7.join(dir, "state.json");
1433
- this.data = fs11.existsSync(this.file) ? JSON.parse(fs11.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: {} };
1434
1725
  this.data.map ??= {};
1435
1726
  this.data.consumedApprovals ??= {};
1436
1727
  this.data.rebases ??= {};
1437
1728
  }
1438
1729
  save() {
1439
1730
  const tmp = `${this.file}.tmp-${process.pid}`;
1440
- fs11.writeFileSync(tmp, JSON.stringify(this.data, null, 2));
1441
- fs11.renameSync(tmp, this.file);
1731
+ fs14.writeFileSync(tmp, JSON.stringify(this.data, null, 2));
1732
+ fs14.renameSync(tmp, this.file);
1442
1733
  }
1443
1734
  get(key) {
1444
1735
  return this.data.map[key];
@@ -1822,12 +2113,12 @@ async function dispatchLoop(source, state, log = () => {
1822
2113
  }
1823
2114
 
1824
2115
  // apps/pick/dist/loops/report.js
1825
- import * as fs12 from "node:fs";
1826
- import * as path8 from "node:path";
2116
+ import * as fs15 from "node:fs";
2117
+ import * as path10 from "node:path";
1827
2118
  function dispatchLane(uuid) {
1828
2119
  for (const lane of ["work", "chore"]) {
1829
2120
  const d = laneDirs(lane);
1830
- if (fs12.existsSync(path8.join(d.active, uuid)) || fs12.existsSync(path8.join(d.queue, `${uuid}.json`)) || fs12.existsSync(path8.join(d.done, uuid)) || fs12.existsSync(path8.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`))) {
1831
2122
  return lane;
1832
2123
  }
1833
2124
  }
@@ -1868,12 +2159,12 @@ async function reportLoop(source, state, log = () => {
1868
2159
  }
1869
2160
 
1870
2161
  // apps/pick/dist/loops/reconcile.js
1871
- import * as fs13 from "node:fs";
1872
- import * as path9 from "node:path";
2162
+ import * as fs16 from "node:fs";
2163
+ import * as path11 from "node:path";
1873
2164
  function dispatchActive(uuid) {
1874
2165
  for (const lane of ["work", "chore"]) {
1875
2166
  const d = laneDirs(lane);
1876
- if (fs13.existsSync(path9.join(d.active, uuid)) || fs13.existsSync(path9.join(d.queue, `${uuid}.json`))) {
2167
+ if (fs16.existsSync(path11.join(d.active, uuid)) || fs16.existsSync(path11.join(d.queue, `${uuid}.json`))) {
1877
2168
  return lane;
1878
2169
  }
1879
2170
  }
@@ -1913,28 +2204,28 @@ async function reconcileLoop(source, state, log = () => {
1913
2204
 
1914
2205
  // apps/pick/dist/loops/merge.js
1915
2206
  import { randomUUID as randomUUID2 } from "node:crypto";
1916
- import * as fs15 from "node:fs";
1917
- import * as path11 from "node:path";
2207
+ import * as fs18 from "node:fs";
2208
+ import * as path13 from "node:path";
1918
2209
 
1919
2210
  // apps/pick/dist/merge-view.js
1920
- import * as fs14 from "node:fs";
1921
- import * as path10 from "node:path";
2211
+ import * as fs17 from "node:fs";
2212
+ import * as path12 from "node:path";
1922
2213
  function viewPath() {
1923
- return path10.join(lobstahHome(), "pickup", "merge-view.json");
2214
+ return path12.join(lobstahHome(), "pickup", "merge-view.json");
1924
2215
  }
1925
2216
  function readMergeView() {
1926
2217
  try {
1927
- return JSON.parse(fs14.readFileSync(viewPath(), "utf8"));
2218
+ return JSON.parse(fs17.readFileSync(viewPath(), "utf8"));
1928
2219
  } catch {
1929
2220
  return void 0;
1930
2221
  }
1931
2222
  }
1932
2223
  function writeMergeView(view) {
1933
2224
  const file = viewPath();
1934
- fs14.mkdirSync(path10.dirname(file), { recursive: true });
2225
+ fs17.mkdirSync(path12.dirname(file), { recursive: true });
1935
2226
  const tmp = `${file}.tmp-${process.pid}`;
1936
- fs14.writeFileSync(tmp, JSON.stringify(view, null, 2));
1937
- fs14.renameSync(tmp, file);
2227
+ fs17.writeFileSync(tmp, JSON.stringify(view, null, 2));
2228
+ fs17.renameSync(tmp, file);
1938
2229
  }
1939
2230
  var RECENT_RETENTION_MS = 48 * 36e5;
1940
2231
  async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ new Date()) {
@@ -1956,7 +2247,7 @@ async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ ne
1956
2247
  }
1957
2248
  function readPickupMap() {
1958
2249
  try {
1959
- const raw = JSON.parse(fs14.readFileSync(path10.join(lobstahHome(), "pickup", "state.json"), "utf8"));
2250
+ const raw = JSON.parse(fs17.readFileSync(path12.join(lobstahHome(), "pickup", "state.json"), "utf8"));
1960
2251
  return raw.map ?? {};
1961
2252
  } catch {
1962
2253
  return {};
@@ -1990,7 +2281,7 @@ function rebaseBrief(pr) {
1990
2281
  }
1991
2282
  function choreVerdict(uuid) {
1992
2283
  const d = laneDirs("chore");
1993
- if (fs15.existsSync(path11.join(d.active, uuid)) || fs15.existsSync(path11.join(d.queue, `${uuid}.json`)))
2284
+ if (fs18.existsSync(path13.join(d.active, uuid)) || fs18.existsSync(path13.join(d.queue, `${uuid}.json`)))
1994
2285
  return "active";
1995
2286
  const last = readStatusLog(uuid, "chore").at(-1)?.verb;
1996
2287
  return last === "done" ? "done" : "failed";
@@ -2083,6 +2374,126 @@ async function mergeLoop(ms, policy, state, log = () => {
2083
2374
  await recordMergeView(ms.forgeRepo(), observed, (n) => ms.disposition(n));
2084
2375
  }
2085
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
+
2086
2497
  // apps/pick/dist/run.js
2087
2498
  function makeNotifier(command, log) {
2088
2499
  if (!command)
@@ -2106,7 +2517,49 @@ function makeNotifier(command, log) {
2106
2517
  child.unref();
2107
2518
  };
2108
2519
  }
2109
- 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) {
2110
2563
  for (const source of sources) {
2111
2564
  try {
2112
2565
  await dispatchLoop(source, state, log);
@@ -2123,6 +2576,11 @@ async function cycle(sources, merges, state, log, notify) {
2123
2576
  log(`${source.name} merge: ${err instanceof Error ? err.message : String(err)}`);
2124
2577
  }
2125
2578
  }
2579
+ try {
2580
+ await watchLoop(pollSecs, log, notify);
2581
+ } catch (err) {
2582
+ log(`watches: ${err instanceof Error ? err.message : String(err)}`);
2583
+ }
2126
2584
  }
2127
2585
  async function runPickup(mode = "daemon") {
2128
2586
  ensureLayout();
@@ -2131,24 +2589,30 @@ async function runPickup(mode = "daemon") {
2131
2589
  const log = (m) => console.log(`[pickup] ${m}`);
2132
2590
  const sources = [];
2133
2591
  const merges = [];
2134
- if (cfg.github) {
2135
- const gh = new GithubSource(cfg.github);
2592
+ for (const ghCfg of cfg.github) {
2593
+ const gh = new GithubSource(ghCfg);
2136
2594
  sources.push(gh);
2137
- merges.push({ source: gh, policy: cfg.github.merge });
2595
+ merges.push({ source: gh, policy: ghCfg.merge });
2138
2596
  }
2139
2597
  if (cfg.linear)
2140
2598
  sources.push(new LinearSource(cfg.linear));
2141
2599
  if (sources.length === 0) {
2142
- 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");
2143
2604
  }
2144
2605
  const notify = makeNotifier(cfg.notifyCommand, log);
2145
2606
  if (mode === "once") {
2146
- await cycle(sources, merges, state, log, notify);
2607
+ await cycle(sources, merges, state, cfg.pollSecs, log, notify);
2147
2608
  return;
2148
2609
  }
2149
2610
  log(`polling every ${cfg.pollSecs}s \u2014 no webhooks, no inbound surface`);
2611
+ const flight = new SingleFlight();
2612
+ const streamChildren = /* @__PURE__ */ new Map();
2150
2613
  while (true) {
2151
- 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);
2152
2616
  await new Promise((r) => setTimeout(r, cfg.pollSecs * 1e3));
2153
2617
  }
2154
2618
  }
@@ -2167,21 +2631,21 @@ function mergeHaulHook(existing) {
2167
2631
  }
2168
2632
 
2169
2633
  // apps/cli/src/tend.ts
2170
- import * as fs16 from "node:fs";
2171
- import * as path12 from "node:path";
2634
+ import * as fs20 from "node:fs";
2635
+ import * as path15 from "node:path";
2172
2636
  var HEARTBEAT_STALE_MS = 9e4;
2173
2637
  var CLAIM_STALE_MS = 12e4;
2174
2638
  var DAY_MS = 24 * 36e5;
2175
2639
  function readJson(file) {
2176
2640
  try {
2177
- return JSON.parse(fs16.readFileSync(file, "utf8"));
2641
+ return JSON.parse(fs20.readFileSync(file, "utf8"));
2178
2642
  } catch {
2179
2643
  return void 0;
2180
2644
  }
2181
2645
  }
2182
2646
  function doneIds(lane) {
2183
2647
  try {
2184
- return fs16.readdirSync(laneDirs(lane).done).filter((f) => !f.startsWith("."));
2648
+ return fs20.readdirSync(laneDirs(lane).done).filter((f) => !f.startsWith("."));
2185
2649
  } catch {
2186
2650
  return [];
2187
2651
  }
@@ -2200,16 +2664,16 @@ function followUps(uuid) {
2200
2664
  const desc = readJson(file);
2201
2665
  if (desc?.followUp === uuid) out.push({ id, bucket });
2202
2666
  };
2203
- for (const id of pendingIds("work")) scan(id, "queued", path12.join(d.queue, `${id}.json`));
2204
- for (const id of activeIds("work")) scan(id, "active", path12.join(d.active, id, "descriptor.json"));
2205
- for (const id of doneIds("work")) scan(id, "done", path12.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"));
2206
2670
  return out;
2207
2671
  }
2208
2672
  function bucketOf(uuid) {
2209
2673
  const d = laneDirs("work");
2210
- if (fs16.existsSync(path12.join(d.active, uuid))) return "active";
2211
- if (fs16.existsSync(path12.join(d.done, uuid))) return "done";
2212
- if (fs16.existsSync(path12.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";
2213
2677
  return void 0;
2214
2678
  }
2215
2679
  function buildTendReport(now = Date.now()) {
@@ -2237,8 +2701,39 @@ function buildTendReport(now = Date.now()) {
2237
2701
  ageSecs: Math.max(0, Math.round((now - Date.parse(ev.entry.at)) / 1e3)),
2238
2702
  note: ev.entry.note
2239
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
+ }
2240
2735
  const oldestQueuedAge = queued.reduce((max, id) => {
2241
- const st = fs16.statSync(path12.join(laneDirs("work").queue, `${id}.json`), { throwIfNoEntry: false });
2736
+ const st = fs20.statSync(path15.join(laneDirs("work").queue, `${id}.json`), { throwIfNoEntry: false });
2242
2737
  return st ? Math.max(max, now - st.mtimeMs) : max;
2243
2738
  }, 0);
2244
2739
  const stalled = daemonUp && queued.length > 0 && active.length < cfg.limits.maxConcurrent && oldestQueuedAge > CLAIM_STALE_MS;
@@ -2260,12 +2755,13 @@ function buildTendReport(now = Date.now()) {
2260
2755
  const fresh = chain.some((d) => d.bucket !== "done" || d.at !== void 0 && now - Date.parse(d.at) < DAY_MS);
2261
2756
  if (!fresh) continue;
2262
2757
  const prUrl = chain.map((d) => d.prUrl).find((u) => u !== void 0);
2263
- 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 });
2264
2760
  }
2265
2761
  for (const id of [...active, ...queued]) {
2266
2762
  if (storied.has(id)) continue;
2267
2763
  const d = describeDispatch(id, "work", bucketOf(id) ?? "active");
2268
- 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) });
2269
2765
  }
2270
2766
  const verdict = !daemonUp ? "daemon-down" : stalled ? "stalled" : attention.length > 0 ? "needs-attention" : active.length + queued.length > 0 ? "working" : "idle";
2271
2767
  return {
@@ -2274,6 +2770,7 @@ function buildTendReport(now = Date.now()) {
2274
2770
  counts: { queued: queued.length, active: active.length, choresActive, done24h, failed24h },
2275
2771
  attention,
2276
2772
  stories,
2773
+ watches,
2277
2774
  merge
2278
2775
  };
2279
2776
  }
@@ -2309,9 +2806,26 @@ function renderTend(r) {
2309
2806
  key: s.key,
2310
2807
  dispatches: s.dispatches.map((d) => `${d.id.slice(0, 8)}:${d.state}`).join(" \u2192 "),
2311
2808
  pr: s.prUrl ?? "",
2312
- gate: s.gate ?? ""
2809
+ gate: s.gate ?? "",
2810
+ watch: s.watch ?? ""
2811
+ })),
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 ?? ""
2313
2827
  })),
2314
- ["key", "dispatches", "pr", "gate"]
2828
+ ["key", "owner", "pending", "last", "error"]
2315
2829
  )
2316
2830
  );
2317
2831
  }
@@ -2332,13 +2846,13 @@ function renderTend(r) {
2332
2846
  }
2333
2847
 
2334
2848
  // apps/cli/src/cull.ts
2335
- import * as fs17 from "node:fs";
2336
- import * as path13 from "node:path";
2849
+ import * as fs21 from "node:fs";
2850
+ import * as path16 from "node:path";
2337
2851
  import { execFileSync as execFileSync2 } from "node:child_process";
2338
2852
  var DAY = 864e5;
2339
2853
  function idsIn(dir) {
2340
2854
  try {
2341
- return new Set(fs17.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$/, "")));
2342
2856
  } catch {
2343
2857
  return /* @__PURE__ */ new Set();
2344
2858
  }
@@ -2353,39 +2867,39 @@ function planCull(olderThanDays, now = Date.now()) {
2353
2867
  for (const id of idsIn(d.queue)) live.add(id);
2354
2868
  for (const id of idsIn(d.active)) live.add(id);
2355
2869
  for (const id of idsIn(d.done)) {
2356
- const p = path13.join(d.done, id);
2357
- const m = fs17.statSync(p).mtimeMs;
2870
+ const p = path16.join(d.done, id);
2871
+ const m = fs21.statSync(p).mtimeMs;
2358
2872
  doneMtimes.set(id, m);
2359
2873
  if (m < cutoff) items.push({ kind: "done", id, target: p, ageDays: Math.floor((now - m) / DAY) });
2360
2874
  }
2361
2875
  }
2362
- const wtRoot = path13.join(lobstahHome(), "worktrees");
2876
+ const wtRoot = path16.join(lobstahHome(), "worktrees");
2363
2877
  for (const id of idsIn(wtRoot)) {
2364
2878
  if (live.has(id)) continue;
2365
2879
  const doneAt = doneMtimes.get(id);
2366
2880
  if (doneAt !== void 0 && doneAt >= cutoff) continue;
2367
- const p = path13.join(wtRoot, id);
2368
- items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now - fs17.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) });
2369
2883
  }
2370
2884
  for (const lane of ["work", "chore"]) {
2371
2885
  const d = laneDirs(lane);
2372
2886
  const seen = /* @__PURE__ */ new Set();
2373
- for (const f of fs17.readdirSync(d.state)) {
2887
+ for (const f of fs21.readdirSync(d.state)) {
2374
2888
  const id = f.replace(/\.(status|events|evidence|attn|notified|runner\.log)$/, "");
2375
2889
  if (id === f || seen.has(id) || live.has(id) || doneMtimes.has(id)) continue;
2376
- const m = fs17.statSync(path13.join(d.state, f)).mtimeMs;
2890
+ const m = fs21.statSync(path16.join(d.state, f)).mtimeMs;
2377
2891
  if (m >= cutoff) continue;
2378
2892
  seen.add(id);
2379
- items.push({ kind: "state", id, target: path13.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) });
2380
2894
  }
2381
2895
  }
2382
2896
  return items;
2383
2897
  }
2384
2898
  function removeWorktree(id, dir) {
2385
2899
  for (const lane of ["work", "chore"]) {
2386
- const descFile = path13.join(laneDirs(lane).done, id, "descriptor.json");
2900
+ const descFile = path16.join(laneDirs(lane).done, id, "descriptor.json");
2387
2901
  try {
2388
- const desc = JSON.parse(fs17.readFileSync(descFile, "utf8"));
2902
+ const desc = JSON.parse(fs21.readFileSync(descFile, "utf8"));
2389
2903
  const repo = loadConfig().repos[desc.repo];
2390
2904
  if (repo) {
2391
2905
  execFileSync2("git", ["worktree", "remove", "--force", dir], { cwd: repo.path, stdio: "ignore" });
@@ -2394,15 +2908,15 @@ function removeWorktree(id, dir) {
2394
2908
  } catch {
2395
2909
  }
2396
2910
  }
2397
- fs17.rmSync(dir, { recursive: true, force: true });
2911
+ fs21.rmSync(dir, { recursive: true, force: true });
2398
2912
  }
2399
2913
  function applyCull(items) {
2400
2914
  for (const item of items.filter((i) => i.kind === "worktree")) removeWorktree(item.id, item.target);
2401
- for (const item of items.filter((i) => i.kind === "done")) fs17.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 });
2402
2916
  for (const item of items.filter((i) => i.kind === "state")) {
2403
- const dir = path13.dirname(item.target);
2404
- for (const f of fs17.readdirSync(dir)) {
2405
- if (f.startsWith(`${item.id}.`)) fs17.rmSync(path13.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 });
2406
2920
  }
2407
2921
  }
2408
2922
  }
@@ -2428,6 +2942,10 @@ your working set:
2428
2942
  lobstah man tend [--json] tend the string: fleet verdict,
2429
2943
  unanswered questions, each work
2430
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
2431
2949
 
2432
2950
  getting woken instead of asking:
2433
2951
  lobstah man wait block until a dispatch needs attention (arm it as a
@@ -2446,6 +2964,275 @@ paste into your liaison instructions:
2446
2964
 
2447
2965
  full pattern and trade-offs: docs/lobsterman.md \xB7 config: docs/configuration.md`;
2448
2966
 
2967
+ // apps/cli/src/doctor.ts
2968
+ import * as fs22 from "node:fs";
2969
+ import { createRequire as createRequire3 } from "node:module";
2970
+ import { spawnSync as spawnSync2 } from "node:child_process";
2971
+ var HEARTBEAT_STALE_MS2 = 9e4;
2972
+ function git(repoPath, ...args) {
2973
+ const res = spawnSync2("git", ["-C", repoPath, ...args], { encoding: "utf8" });
2974
+ return { ok: res.status === 0, out: (res.stdout ?? "").trim() || (res.stderr ?? "").trim() };
2975
+ }
2976
+ function runDoctor(now = Date.now()) {
2977
+ const rows = [];
2978
+ const push = (check, status, detail) => rows.push({ check, status, detail });
2979
+ const major = Number(process.versions.node.split(".")[0]);
2980
+ push("node", major >= 20 ? "ok" : "fail", `v${process.versions.node}${major >= 20 ? "" : " \u2014 lobstah needs >=20"}`);
2981
+ push("git", onPath("git") ? "ok" : "fail", onPath("git") ? "on PATH" : "not on PATH");
2982
+ const claude = onPath("claude");
2983
+ push("harness claude", claude ? "ok" : "warn", claude ? "on PATH" : "claude not on PATH \u2014 claude dispatches will fail");
2984
+ const codexPath = onPath("codex");
2985
+ let codexSdk = false;
2986
+ try {
2987
+ createRequire3(import.meta.url).resolve("@openai/codex-sdk");
2988
+ codexSdk = true;
2989
+ } catch {
2990
+ }
2991
+ push(
2992
+ "harness codex",
2993
+ codexPath || codexSdk ? "ok" : "warn",
2994
+ codexPath ? "on PATH" : codexSdk ? "vendored SDK (attach uses it too)" : "neither codex nor the SDK \u2014 codex dispatches will fail"
2995
+ );
2996
+ const cfgFile = configPath();
2997
+ if (!fs22.existsSync(cfgFile)) {
2998
+ push("config", "fail", `${cfgFile} missing \u2014 run \`lobstah init\``);
2999
+ return rows;
3000
+ }
3001
+ let parsed;
3002
+ try {
3003
+ parsed = parse(fs22.readFileSync(cfgFile, "utf8"));
3004
+ push("config", "ok", cfgFile);
3005
+ } catch (err) {
3006
+ push("config", "fail", `${cfgFile}: ${err instanceof Error ? err.message : String(err)}`);
3007
+ return rows;
3008
+ }
3009
+ const cfg = loadConfig();
3010
+ const repoKeys = Object.keys(cfg.repos);
3011
+ if (repoKeys.length === 0) push("repos", "warn", "none configured \u2014 add one with `lobstah repos add <path>`");
3012
+ for (const [key, repo] of Object.entries(cfg.repos)) {
3013
+ const label = `repo ${key}`;
3014
+ if (!fs22.existsSync(repo.path)) {
3015
+ push(label, "fail", `path ${repo.path} does not exist`);
3016
+ continue;
3017
+ }
3018
+ if (!git(repo.path, "rev-parse", "--is-inside-work-tree").ok) {
3019
+ push(label, "fail", `${repo.path} is not a git repository`);
3020
+ continue;
3021
+ }
3022
+ const trunk = git(repo.path, "rev-parse", "--verify", "--quiet", `origin/${repo.trunk}`);
3023
+ push(
3024
+ label,
3025
+ trunk.ok ? "ok" : "warn",
3026
+ trunk.ok ? `${repo.path} (trunk origin/${repo.trunk})` : `origin/${repo.trunk} not found \u2014 check trunk or \`git fetch\``
3027
+ );
3028
+ }
3029
+ if (parsed.pickup) {
3030
+ try {
3031
+ const pk = loadPickupConfig();
3032
+ const sources = [
3033
+ ...pk.github.map((g) => `gh:${g.repo}\u2192${g.key}`),
3034
+ ...pk.linear ? ["linear"] : []
3035
+ ];
3036
+ push("pickup", sources.length > 0 ? "ok" : "warn", sources.join(", ") || "section present but no sources");
3037
+ } catch (err) {
3038
+ push("pickup", "fail", err instanceof Error ? err.message : String(err));
3039
+ }
3040
+ }
3041
+ const hb = executorPath();
3042
+ if (!fs22.existsSync(hb)) {
3043
+ push("daemon", "warn", "no heartbeat \u2014 daemon not running (`lobstah daemon install`)");
3044
+ } else {
3045
+ try {
3046
+ const payload = JSON.parse(fs22.readFileSync(hb, "utf8"));
3047
+ const age = now - new Date(payload.heartbeat ?? 0).getTime();
3048
+ push(
3049
+ "daemon",
3050
+ age < HEARTBEAT_STALE_MS2 ? "ok" : "warn",
3051
+ age < HEARTBEAT_STALE_MS2 ? `heartbeat ${Math.round(age / 1e3)}s ago (v${payload.version ?? "?"})` : `heartbeat stale (${Math.round(age / 1e3)}s) \u2014 daemon down or wedged`
3052
+ );
3053
+ } catch {
3054
+ push("daemon", "warn", "heartbeat unreadable");
3055
+ }
3056
+ }
3057
+ push("lobstah", "ok", `v${lobstahVersion()} at ${process.argv[1] ?? "?"}`);
3058
+ return rows;
3059
+ }
3060
+
3061
+ // apps/cli/src/service.ts
3062
+ import * as fs23 from "node:fs";
3063
+ import * as os5 from "node:os";
3064
+ import * as path17 from "node:path";
3065
+ import { spawnSync as spawnSync3 } from "node:child_process";
3066
+ function xml(s) {
3067
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
3068
+ }
3069
+ function renderLaunchdPlist(spec) {
3070
+ return `<?xml version="1.0" encoding="UTF-8"?>
3071
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3072
+ <plist version="1.0">
3073
+ <dict>
3074
+ <key>Label</key><string>lobstah.${spec.kind}</string>
3075
+ <key>ProgramArguments</key>
3076
+ <array>
3077
+ <string>${xml(spec.nodePath)}</string>
3078
+ <string>${xml(spec.entry)}</string>
3079
+ <string>${spec.kind}</string>
3080
+ </array>
3081
+ <key>EnvironmentVariables</key>
3082
+ <dict>
3083
+ <key>PATH</key><string>${xml(spec.pathEnv)}</string>
3084
+ <key>LOBSTAH_HOME</key><string>${xml(spec.home)}</string>
3085
+ </dict>
3086
+ <key>RunAtLoad</key><true/>
3087
+ <key>KeepAlive</key><true/>
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>
3090
+ </dict>
3091
+ </plist>
3092
+ `;
3093
+ }
3094
+ function renderSystemdUnit(spec) {
3095
+ return `[Unit]
3096
+ Description=lobstah ${spec.kind}
3097
+
3098
+ [Service]
3099
+ ExecStart=${spec.nodePath} ${spec.entry} ${spec.kind}
3100
+ Restart=always
3101
+ RestartSec=5
3102
+ Environment=PATH=${spec.pathEnv}
3103
+ Environment=LOBSTAH_HOME=${spec.home}
3104
+ StandardOutput=append:${path17.posix.join(spec.logDir, `${spec.kind}.log`)}
3105
+ StandardError=append:${path17.posix.join(spec.logDir, `${spec.kind}.err`)}
3106
+
3107
+ [Install]
3108
+ WantedBy=default.target
3109
+ `;
3110
+ }
3111
+ function servicePathEnv(nodePath, home = os5.homedir()) {
3112
+ const dirs = [
3113
+ path17.posix.dirname(nodePath),
3114
+ path17.posix.join(home, ".local", "bin"),
3115
+ "/opt/homebrew/bin",
3116
+ "/usr/local/bin",
3117
+ "/usr/bin",
3118
+ "/bin"
3119
+ ];
3120
+ return [...new Set(dirs)].join(":");
3121
+ }
3122
+ function serviceFile(kind) {
3123
+ return process.platform === "darwin" ? path17.join(os5.homedir(), "Library", "LaunchAgents", `lobstah.${kind}.plist`) : path17.join(os5.homedir(), ".config", "systemd", "user", `lobstah-${kind}.service`);
3124
+ }
3125
+ function run(cmd, args) {
3126
+ const res = spawnSync3(cmd, args, { encoding: "utf8" });
3127
+ return { ok: res.status === 0, out: `${res.stdout ?? ""}${res.stderr ?? ""}`.trim() };
3128
+ }
3129
+ function installService(kind) {
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]);
3134
+ const spec = {
3135
+ kind,
3136
+ nodePath: process.execPath,
3137
+ entry,
3138
+ pathEnv: servicePathEnv(process.execPath),
3139
+ home: lobstahHome(),
3140
+ logDir: path17.join(lobstahHome(), "logs")
3141
+ };
3142
+ if (!onPath("git", spec.pathEnv)) {
3143
+ throw new Error(`git is not reachable on the service PATH (${spec.pathEnv}) \u2014 the daemon cannot allocate worktrees`);
3144
+ }
3145
+ fs23.mkdirSync(spec.logDir, { recursive: true });
3146
+ const file = serviceFile(kind);
3147
+ fs23.mkdirSync(path17.dirname(file), { recursive: true });
3148
+ if (process.platform === "darwin") {
3149
+ if (fs23.existsSync(file)) run("launchctl", ["unload", file]);
3150
+ fs23.writeFileSync(file, renderLaunchdPlist(spec));
3151
+ const load = run("launchctl", ["load", file]);
3152
+ return { file, loaded: load.ok, detail: load.ok ? `label lobstah.${kind}` : load.out };
3153
+ }
3154
+ fs23.writeFileSync(file, renderSystemdUnit(spec));
3155
+ run("systemctl", ["--user", "daemon-reload"]);
3156
+ const enable = run("systemctl", ["--user", "enable", "--now", `lobstah-${kind}.service`]);
3157
+ return { file, loaded: enable.ok, detail: enable.ok ? `unit lobstah-${kind}.service` : enable.out };
3158
+ }
3159
+ function uninstallService(kind) {
3160
+ const file = serviceFile(kind);
3161
+ if (process.platform === "darwin") {
3162
+ if (fs23.existsSync(file)) run("launchctl", ["unload", file]);
3163
+ } else {
3164
+ run("systemctl", ["--user", "disable", "--now", `lobstah-${kind}.service`]);
3165
+ }
3166
+ const removed = fs23.existsSync(file);
3167
+ if (removed) fs23.unlinkSync(file);
3168
+ if (process.platform !== "darwin") run("systemctl", ["--user", "daemon-reload"]);
3169
+ return { file, removed };
3170
+ }
3171
+
3172
+ // apps/cli/src/repos.ts
3173
+ import * as fs24 from "node:fs";
3174
+ import * as path18 from "node:path";
3175
+ import { spawnSync as spawnSync4 } from "node:child_process";
3176
+ function git2(dir, ...args) {
3177
+ const res = spawnSync4("git", ["-C", dir, ...args], { encoding: "utf8" });
3178
+ return res.status === 0 ? res.stdout.trim() : void 0;
3179
+ }
3180
+ var LOCKFILE_SETUP = [
3181
+ ["pnpm-lock.yaml", ["pnpm install"]],
3182
+ ["package-lock.json", ["npm install"]],
3183
+ ["yarn.lock", ["yarn install"]],
3184
+ ["bun.lockb", ["bun install"]],
3185
+ ["bun.lock", ["bun install"]],
3186
+ ["uv.lock", ["uv sync"]],
3187
+ ["poetry.lock", ["poetry install"]]
3188
+ ];
3189
+ function detectRepo(dir) {
3190
+ const abs = path18.resolve(dir);
3191
+ if (git2(abs, "rev-parse", "--is-inside-work-tree") !== "true") return void 0;
3192
+ const top = git2(abs, "rev-parse", "--show-toplevel");
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;
3198
+ const origin = git2(abs, "remote", "get-url", "origin");
3199
+ const headRef = git2(abs, "symbolic-ref", "--short", "refs/remotes/origin/HEAD");
3200
+ const trunk = headRef?.replace(/^origin\//, "") ?? git2(abs, "branch", "--show-current") ?? "main";
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 };
3203
+ }
3204
+ function scanForRepos(roots) {
3205
+ const found = [];
3206
+ for (const root of roots) {
3207
+ const abs = path18.resolve(root);
3208
+ if (!fs24.existsSync(abs)) continue;
3209
+ const self = detectRepo(abs);
3210
+ if (self) {
3211
+ found.push(self);
3212
+ continue;
3213
+ }
3214
+ for (const entry of fs24.readdirSync(abs, { withFileTypes: true })) {
3215
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
3216
+ const child = detectRepo(path18.join(abs, entry.name));
3217
+ if (child) found.push(child);
3218
+ }
3219
+ }
3220
+ return found;
3221
+ }
3222
+ function configuredRepoKeys(file = configPath()) {
3223
+ if (!fs24.existsSync(file)) return /* @__PURE__ */ new Set();
3224
+ const raw = parse(fs24.readFileSync(file, "utf8"));
3225
+ return new Set(Object.keys(raw.repos ?? {}));
3226
+ }
3227
+ function appendRepoBlock(repo, opts = {}, file = configPath()) {
3228
+ const lines = [``, `[repos.${repo.key}]`, `path = ${JSON.stringify(repo.path)}`, `trunk = ${JSON.stringify(repo.trunk)}`];
3229
+ if (repo.origin) lines.push(`origin = ${JSON.stringify(repo.origin)}`);
3230
+ if (repo.setup) lines.push(`setup = [${repo.setup.map((s) => JSON.stringify(s)).join(", ")}]`);
3231
+ if (opts.pickup) lines.push(`pickup = true`);
3232
+ fs24.appendFileSync(file, `${lines.join("\n")}
3233
+ `);
3234
+ }
3235
+
2449
3236
  // apps/cli/src/main.ts
2450
3237
  var HELP = `lobstah \u2014 supervision framework for coding agents
2451
3238
 
@@ -2473,12 +3260,25 @@ work (humans and agents):
2473
3260
  entries, orphaned worktrees, stale state.
2474
3261
  Dry run by default (14 days).
2475
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.
2476
3273
 
2477
- host processes (run under launchd/systemd/pm2):
3274
+ host processes:
2478
3275
  daemon [--interval <ms>] the supervisor: claims, worktrees, liveness,
2479
3276
  restart ladder, notifyCommand. One per home.
2480
3277
  pick [once] tracker loops: poll Linear/GitHub, dispatch
2481
3278
  assigned work, report back, reconcile, merge
3279
+ daemon install|uninstall write + load the launchd agent / systemd user
3280
+ pick install|uninstall unit for this host, with resolved node and
3281
+ lobstah paths (launchd gets no shell env)
2482
3282
 
2483
3283
  lobsterman (orchestrator sessions \u2014 bare \`lobstah man\` prints the manual):
2484
3284
  man tend [--json] tend the whole string: fleet verdict (daemon
@@ -2487,14 +3287,19 @@ lobsterman (orchestrator sessions \u2014 bare \`lobstah man\` prints the manual)
2487
3287
  PR, and merge-gate status from pick's last
2488
3288
  observation. Pure disk read \u2014 no forge calls.
2489
3289
  man wait [--timeout <secs>] [--peek]
2490
- block until a dispatch needs attention, then
2491
- print the event and what to do next; exit 2
2492
- on timeout. Unanswered questions re-fire
2493
- every remindSecs until answered.
2494
- man init [--shared] [--marker] install the haul Stop hook into this
2495
- project's .claude/settings.local.json
2496
- (--shared: settings.json); --marker touches
2497
- .lobstah-man. Idempotent.
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.
3296
+ man init [--shared|--global] [--marker]
3297
+ install the haul Stop hook: this project's
3298
+ .claude/settings.local.json by default,
3299
+ settings.json with --shared, or once into
3300
+ ~/.claude/settings.json with --global (any
3301
+ directory with a .lobstah-man file then
3302
+ parks); --marker touches .lobstah-man.
2498
3303
  man haul [--timeout <secs>] Stop-hook entry point: park the session while
2499
3304
  work is in flight; prints hook-decision JSON
2500
3305
  on an event, silent exit 0 otherwise. Gate:
@@ -2506,7 +3311,16 @@ workers (dispatched agents; injected into every brief):
2506
3311
  (${VERBS.join(" | ")})
2507
3312
 
2508
3313
  setup:
2509
- init create ~/.lobstah + example config
3314
+ init [--scan <dir>... [--pickup]]
3315
+ create ~/.lobstah + example config; --scan
3316
+ detects git repos under the given roots and
3317
+ appends a [repos.*] block per repo (--pickup
3318
+ marks them pickable by [pickup.github])
3319
+ repos [add <path> [--pickup] [--key <k>]]
3320
+ list configured repos, or detect + append one
3321
+ doctor check binaries, config, repos, harnesses, and
3322
+ the daemon heartbeat; exit 1 on failures
3323
+ version | --version the installed lobstah version
2510
3324
 
2511
3325
  Everything except daemon and pick works with both stopped: writes are files,
2512
3326
  reads are files. Output is TOON; agents can drive this CLI directly.
@@ -2515,10 +3329,26 @@ function arg(args, flag) {
2515
3329
  const i = args.indexOf(flag);
2516
3330
  return i >= 0 ? args[i + 1] : void 0;
2517
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
+ }
2518
3348
  function findLane(id) {
2519
3349
  for (const lane of ["work", "chore"]) {
2520
3350
  const d = laneDirs(lane);
2521
- if (fs18.existsSync(path14.join(d.active, id)) || fs18.existsSync(path14.join(d.queue, `${id}.json`)) || fs18.existsSync(path14.join(d.done, id)) || fs18.existsSync(path14.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`))) {
2522
3352
  return lane;
2523
3353
  }
2524
3354
  }
@@ -2526,7 +3356,7 @@ function findLane(id) {
2526
3356
  }
2527
3357
  function rowsFor(lane, bucket) {
2528
3358
  const dir = laneDirs(lane)[bucket];
2529
- const entries = fs18.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => ({ f, m: fs18.statSync(path14.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);
2530
3360
  const sliced = bucket === "done" ? entries.slice(0, 10) : entries;
2531
3361
  return sliced.map(({ f, m }) => {
2532
3362
  const id = f.replace(/\.json$/, "");
@@ -2552,9 +3382,9 @@ async function mainCli() {
2552
3382
  throw new Error("dispatch requires --repo and --brief <file> (or --brief-text)");
2553
3383
  }
2554
3384
  const d = {
2555
- id: arg(args, "--id") ?? randomUUID3(),
3385
+ id: arg(args, "--id") ?? randomUUID4(),
2556
3386
  repo,
2557
- brief: briefText ?? fs18.readFileSync(briefFile, "utf8"),
3387
+ brief: briefText ?? fs25.readFileSync(briefFile, "utf8"),
2558
3388
  harness: arg(args, "--harness"),
2559
3389
  model: arg(args, "--model"),
2560
3390
  effort: arg(args, "--effort"),
@@ -2591,17 +3421,17 @@ async function mainCli() {
2591
3421
  if (!id) throw new Error("logs requires a dispatch id");
2592
3422
  const lane = findLane(id);
2593
3423
  const file = eventsPath(id, lane);
2594
- if (fs18.existsSync(file)) process.stdout.write(fs18.readFileSync(file, "utf8"));
3424
+ if (fs25.existsSync(file)) process.stdout.write(fs25.readFileSync(file, "utf8"));
2595
3425
  if (args.includes("--follow")) {
2596
- let size = fs18.existsSync(file) ? fs18.statSync(file).size : 0;
3426
+ let size = fs25.existsSync(file) ? fs25.statSync(file).size : 0;
2597
3427
  setInterval(() => {
2598
- if (!fs18.existsSync(file)) return;
2599
- const now = fs18.statSync(file).size;
3428
+ if (!fs25.existsSync(file)) return;
3429
+ const now = fs25.statSync(file).size;
2600
3430
  if (now > size) {
2601
- const fd = fs18.openSync(file, "r");
3431
+ const fd = fs25.openSync(file, "r");
2602
3432
  const buf = Buffer.alloc(now - size);
2603
- fs18.readSync(fd, buf, 0, buf.length, size);
2604
- fs18.closeSync(fd);
3433
+ fs25.readSync(fd, buf, 0, buf.length, size);
3434
+ fs25.closeSync(fd);
2605
3435
  process.stdout.write(buf.toString("utf8"));
2606
3436
  size = now;
2607
3437
  }
@@ -2657,22 +3487,23 @@ async function mainCli() {
2657
3487
  );
2658
3488
  }
2659
3489
  const dirs = laneDirs(lane);
2660
- const descriptorFile = ["active", "done"].map((b) => path14.join(dirs[b], id, "descriptor.json")).find((f) => fs18.existsSync(f));
2661
- const harness = descriptorFile ? JSON.parse(fs18.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";
2662
3492
  let sessionId;
2663
3493
  try {
2664
- sessionId = JSON.parse(fs18.readFileSync(path14.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
3494
+ sessionId = JSON.parse(fs25.readFileSync(path19.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
2665
3495
  } catch {
2666
3496
  }
2667
3497
  if (!sessionId) throw new Error(`${id} has no recorded harness session to attach to`);
2668
- const worktree = path14.join(lobstahHome(), "worktrees", id);
2669
- const cwd = fs18.existsSync(worktree) ? worktree : process.cwd();
2670
- const invocation = harness === "codex" ? { file: "codex", argv: ["resume", sessionId] } : { file: "claude", argv: ["--resume", sessionId] };
3498
+ const worktree = path19.join(lobstahHome(), "worktrees", id);
3499
+ const cwd = fs25.existsSync(worktree) ? worktree : process.cwd();
3500
+ const invocation = harness === "codex" ? codexInvocation(["resume", sessionId]) : { file: "claude", argv: ["--resume", sessionId] };
3501
+ if (!invocation) throw new Error("no codex CLI found \u2014 neither on PATH nor vendored by @openai/codex-sdk");
2671
3502
  if (args.includes("--print")) {
2672
3503
  console.log(toonKV({ id, harness, sessionId, cwd, command: `${invocation.file} ${invocation.argv.join(" ")}` }));
2673
3504
  break;
2674
3505
  }
2675
- const res = spawnSync(invocation.file, invocation.argv, { cwd, stdio: "inherit" });
3506
+ const res = spawnSync5(invocation.file, invocation.argv, { cwd, stdio: "inherit" });
2676
3507
  if (res.error) throw new Error(`could not launch ${invocation.file}: ${res.error.message}`);
2677
3508
  break;
2678
3509
  }
@@ -2680,39 +3511,39 @@ async function mainCli() {
2680
3511
  const id = args[0];
2681
3512
  if (!id) throw new Error("swap requires a dispatch id");
2682
3513
  const lane = findLane(id);
2683
- const activeDir = path14.join(laneDirs(lane).active, id);
2684
- if (!fs18.existsSync(activeDir)) throw new Error(`${id} is not active \u2014 swap only applies to in-flight dispatches`);
2685
- const descFile = path14.join(activeDir, "descriptor.json");
2686
- const descriptor = JSON.parse(fs18.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"));
2687
3518
  const fromHarness = descriptor.harness ?? "default";
2688
3519
  for (const key of ["harness", "model", "effort"]) {
2689
3520
  const v = arg(args, `--${key}`);
2690
3521
  if (v) descriptor[key] = v;
2691
3522
  }
2692
- fs18.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
2693
- const worktree = path14.join(lobstahHome(), "worktrees", id);
3523
+ fs25.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
3524
+ const worktree = path19.join(lobstahHome(), "worktrees", id);
2694
3525
  let progress = "No worktree progress recorded.";
2695
- if (fs18.existsSync(worktree)) {
2696
- const git = (...a) => spawnSync("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
2697
- const commits = git("log", "--oneline", "-15");
2698
- const status = git("status", "--short");
3526
+ if (fs25.existsSync(worktree)) {
3527
+ const git3 = (...a) => spawnSync5("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
3528
+ const commits = git3("log", "--oneline", "-15");
3529
+ const status = git3("status", "--short");
2699
3530
  progress = `Commits so far:
2700
3531
  ${commits || "(none)"}
2701
3532
 
2702
3533
  Uncommitted changes:
2703
3534
  ${status || "(clean)"}`;
2704
3535
  }
2705
- fs18.writeFileSync(
2706
- path14.join(activeDir, "handoff"),
3536
+ fs25.writeFileSync(
3537
+ path19.join(activeDir, "handoff"),
2707
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.
2708
3539
 
2709
3540
  ${progress}`
2710
3541
  );
2711
- const runnerFile = path14.join(activeDir, "runner.json");
2712
- if (fs18.existsSync(runnerFile)) {
2713
- const runner = JSON.parse(fs18.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"));
2714
3545
  if (pidAlive(runner.pid, runner.processStartTime)) killGroup(runner.pid, "SIGKILL");
2715
- fs18.unlinkSync(runnerFile);
3546
+ fs25.unlinkSync(runnerFile);
2716
3547
  }
2717
3548
  appendStatus(id, lane, "working", `swapped to ${descriptor.harness ?? "default"} \u2014 awaiting respawn`);
2718
3549
  console.log(toonKV({ id, harness: descriptor.harness ?? "default", model: descriptor.model, swapped: true }));
@@ -2788,9 +3619,13 @@ ${progress}`
2788
3619
  );
2789
3620
  };
2790
3621
  const remindMs = (loadConfig().remindSecs ?? 900) * 1e3;
2791
- const standing = attentionNow(!args.includes("--peek"), remindMs);
2792
- if (standing.length > 0) {
2793
- 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);
2794
3629
  break;
2795
3630
  }
2796
3631
  const baseline = captureWaitBaseline();
@@ -2801,64 +3636,79 @@ ${progress}`
2801
3636
  emit(fresh);
2802
3637
  return;
2803
3638
  }
3639
+ runDueManWatches();
3640
+ const watched = pendingWatchEvents(true);
3641
+ if (watched.length > 0) {
3642
+ emitWatchAttention(watched);
3643
+ return;
3644
+ }
2804
3645
  }
2805
3646
  console.log(toonKV({ timeout: true, waitedSecs: timeoutSecs }));
2806
3647
  process.exitCode = 2;
2807
3648
  break;
2808
3649
  }
2809
3650
  case "man:init": {
2810
- const file = path14.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");
2811
3652
  let existing;
2812
3653
  try {
2813
- existing = JSON.parse(fs18.readFileSync(file, "utf8"));
3654
+ existing = JSON.parse(fs25.readFileSync(file, "utf8"));
2814
3655
  } catch {
2815
3656
  existing = void 0;
2816
3657
  }
2817
3658
  const { settings, changed } = mergeHaulHook(existing);
2818
3659
  if (changed) {
2819
- fs18.mkdirSync(".claude", { recursive: true });
2820
- fs18.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
3660
+ fs25.mkdirSync(path19.dirname(file), { recursive: true });
3661
+ fs25.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
2821
3662
  `);
2822
3663
  }
2823
- if (args.includes("--marker")) fs18.writeFileSync(".lobstah-man", "");
3664
+ if (args.includes("--marker")) fs25.writeFileSync(".lobstah-man", "");
2824
3665
  console.log(
2825
3666
  toonKV({
2826
3667
  hook: "lobstah man haul",
2827
3668
  file,
2828
3669
  installed: changed || "already present",
2829
- gate: args.includes("--marker") ? ".lobstah-man (this directory)" : "launch with LOBSTAH_MAN=1 claude"
3670
+ gate: args.includes("--marker") ? ".lobstah-man (this directory)" : args.includes("--global") ? "touch .lobstah-man in a project (or LOBSTAH_MAN=1) to arm it there" : "launch with LOBSTAH_MAN=1 claude"
2830
3671
  })
2831
3672
  );
2832
3673
  break;
2833
3674
  }
2834
3675
  case "man:haul": {
2835
3676
  try {
2836
- if (process.env.LOBSTAH_MAN !== "1" && !fs18.existsSync(".lobstah-man")) break;
3677
+ if (process.env.LOBSTAH_MAN !== "1" && !fs25.existsSync(".lobstah-man")) break;
2837
3678
  const anyActive = ["work", "chore"].some(
2838
- (l) => fs18.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
3679
+ (l) => fs25.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
2839
3680
  );
2840
- if (!anyActive) break;
3681
+ const anyWatch = listWatches().some((w) => w.owner === "man");
3682
+ if (!anyActive && !anyWatch) break;
2841
3683
  const timeoutSecs = Number(arg(args, "--timeout") ?? "14000");
2842
3684
  const deadline = Date.now() + timeoutSecs * 1e3;
2843
3685
  const remindMs = (loadConfig().remindSecs ?? 900) * 1e3;
3686
+ runDueManWatches();
2844
3687
  let evs = attentionNow(true, remindMs);
2845
- if (evs.length === 0) {
3688
+ let watched = pendingWatchEvents(true);
3689
+ if (evs.length === 0 && watched.length === 0) {
2846
3690
  const baseline = captureWaitBaseline();
2847
3691
  while (Date.now() < deadline) {
2848
3692
  await new Promise((r) => setTimeout(r, 1500));
2849
3693
  evs = freshWakeEvents(baseline);
2850
3694
  if (evs.length === 0) evs = attentionNow(true, remindMs);
2851
- if (evs.length > 0) break;
3695
+ runDueManWatches();
3696
+ watched = pendingWatchEvents(true);
3697
+ if (evs.length > 0 || watched.length > 0) break;
2852
3698
  }
2853
3699
  }
2854
- if (evs.length === 0) break;
2855
- const lines = evs.map(
2856
- (ev) => `- ${ev.entry.verb} ${ev.id}${ev.entry.note ? ` \u2014 ${ev.entry.note}` : ""}`
2857
- );
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
+ ];
2858
3707
  const reason = [
2859
- "A lobstah dispatch needs attention:",
3708
+ "A lobstah dispatch or watched source needs attention:",
2860
3709
  ...lines,
2861
- '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.",
2862
3712
  "Handle it now. This session re-parks automatically at turn end \u2014 do not arm any watcher."
2863
3713
  ].join("\n");
2864
3714
  console.log(JSON.stringify({ decision: "block", reason }));
@@ -2866,31 +3716,109 @@ ${progress}`
2866
3716
  }
2867
3717
  break;
2868
3718
  }
2869
- case "daemon": {
2870
- const interval = Number(arg(args, "--interval") ?? "5000");
2871
- await daemon(interval);
3719
+ case "daemon":
3720
+ case "pick": {
3721
+ const kind = cmd;
3722
+ if (args[0] === "install") {
3723
+ const res = installService(kind);
3724
+ console.log(toonKV({ service: kind, file: res.file, loaded: res.loaded, detail: res.detail }));
3725
+ break;
3726
+ }
3727
+ if (args[0] === "uninstall") {
3728
+ const res = uninstallService(kind);
3729
+ console.log(toonKV({ service: kind, file: res.file, removed: res.removed }));
3730
+ break;
3731
+ }
3732
+ if (kind === "daemon") await daemon(Number(arg(args, "--interval") ?? "5000"));
3733
+ else await runPickup(args[0] === "once" ? "once" : "daemon");
3734
+ break;
3735
+ }
3736
+ case "doctor": {
3737
+ const rows = runDoctor();
3738
+ console.log(toonTable("doctor", rows, ["check", "status", "detail"]));
3739
+ if (rows.some((r) => r.status === "fail")) process.exitCode = 1;
2872
3740
  break;
2873
3741
  }
2874
- case "pick": {
2875
- await runPickup(args[0] === "once" ? "once" : "daemon");
3742
+ case "repos": {
3743
+ if (args[0] === "add") {
3744
+ const target = args[1];
3745
+ if (!target) throw new Error("repos add requires a path");
3746
+ const detected = detectRepo(target);
3747
+ if (!detected) throw new Error(`${target} is not the root of a git repository`);
3748
+ const key = arg(args, "--key") ?? detected.key;
3749
+ if (configuredRepoKeys().has(key)) throw new Error(`repos.${key} already configured \u2014 edit ${configPath()} directly`);
3750
+ appendRepoBlock({ ...detected, key }, { pickup: args.includes("--pickup") });
3751
+ console.log(toonKV({ key, path: detected.path, trunk: detected.trunk, origin: detected.origin, pickup: args.includes("--pickup") }));
3752
+ break;
3753
+ }
3754
+ const repos = loadConfig().repos;
3755
+ const rows = Object.entries(repos).map(([key, r]) => ({
3756
+ key,
3757
+ path: r.path,
3758
+ trunk: r.trunk,
3759
+ pickup: r.pickup ?? false,
3760
+ exists: fs25.existsSync(r.path)
3761
+ }));
3762
+ console.log(toonTable("repos", rows, ["key", "path", "trunk", "pickup", "exists"]));
3763
+ break;
3764
+ }
3765
+ case "version": {
3766
+ console.log(lobstahVersion());
3767
+ break;
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"]));
2876
3802
  break;
2877
3803
  }
2878
3804
  case "init": {
2879
- if (!fs18.existsSync(configPath())) {
2880
- fs18.writeFileSync(
3805
+ if (!fs25.existsSync(configPath())) {
3806
+ const exampleRepo = args.includes("--scan") ? "" : `[repos.example]
3807
+ path = "~/src/example"
3808
+ trunk = "main"
3809
+ # origin = "git@github.com:you/example.git"
3810
+ # setup = ["pnpm install"]
3811
+ # pickup = true # opt into [pickup.github] multi-repo tracker pickup
3812
+
3813
+ `;
3814
+ fs25.writeFileSync(
2881
3815
  configPath(),
2882
3816
  `# lobstah workspace definitions \u2014 the descriptor's repo key resolves here.
2883
3817
  # Top-level keys must come before any [section].
2884
3818
  # notifyCommand = "ntfy pub my-topic "$LOBSTAH_VERB $LOBSTAH_ID: $LOBSTAH_NOTE""
2885
3819
  # notifyVerbs = ["needs-decision", "blocked", "done", "failed"] # the default
2886
3820
 
2887
- [repos.example]
2888
- path = "~/src/example"
2889
- trunk = "main"
2890
- # origin = "git@github.com:you/example.git"
2891
- # setup = ["pnpm install"]
2892
-
2893
- [harness]
3821
+ ${exampleRepo}[harness]
2894
3822
  default = "claude"
2895
3823
 
2896
3824
  [limits]
@@ -2902,16 +3830,44 @@ wallClockSecs = 3600
2902
3830
  `
2903
3831
  );
2904
3832
  }
3833
+ const scanIdx = args.indexOf("--scan");
3834
+ const added = [];
3835
+ const unmarked = [];
3836
+ if (scanIdx >= 0) {
3837
+ const roots = args.slice(scanIdx + 1).filter((a) => !a.startsWith("--"));
3838
+ if (roots.length === 0) throw new Error("--scan requires at least one directory");
3839
+ const known = configuredRepoKeys();
3840
+ const pickup = args.includes("--pickup");
3841
+ for (const repo of scanForRepos(roots)) {
3842
+ if (known.has(repo.key)) continue;
3843
+ known.add(repo.key);
3844
+ appendRepoBlock(repo, { pickup });
3845
+ added.push(repo.key);
3846
+ if (!pickup) unmarked.push(repo.key);
3847
+ }
3848
+ }
3849
+ let reference = "https://github.com/aequitas-labs/lobstah/blob/main/docs/configuration.md";
3850
+ try {
3851
+ const local = fileURLToPath2(new URL("../docs/configuration.md", import.meta.url));
3852
+ if (fs25.existsSync(local)) reference = local;
3853
+ } catch {
3854
+ }
2905
3855
  console.log(
2906
3856
  toonKV({
2907
- home: path14.dirname(configPath()),
3857
+ home: path19.dirname(configPath()),
2908
3858
  config: configPath(),
2909
- reference: "docs/configuration.md",
3859
+ ...added.length > 0 ? { added: added.join(", ") } : {},
3860
+ ...unmarked.length > 0 ? { note: `none marked pickable \u2014 set pickup = true per [repos.*] (or rerun with --pickup)` } : {},
3861
+ reference,
2910
3862
  initialized: true
2911
3863
  })
2912
3864
  );
2913
3865
  break;
2914
3866
  }
3867
+ case "--version":
3868
+ case "-v":
3869
+ console.log(lobstahVersion());
3870
+ break;
2915
3871
  case void 0:
2916
3872
  case "--help":
2917
3873
  case "help":