lobstah 0.1.2 → 0.2.1
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/README.md +8 -0
- package/dist/main.js +747 -227
- package/docs/lobsterman.md +16 -6
- package/docs/pickup.md +39 -0
- package/docs/vocabulary.md +21 -0
- package/package.json +1 -1
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
|
|
4
|
+
import * as fs25 from "node:fs";
|
|
5
5
|
import * as os6 from "node:os";
|
|
6
|
-
import * as
|
|
7
|
-
import { randomUUID as
|
|
8
|
-
import { spawnSync as
|
|
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`);
|
|
@@ -936,32 +937,210 @@ function onPath(bin, pathVar = process.env.PATH) {
|
|
|
936
937
|
}
|
|
937
938
|
return false;
|
|
938
939
|
}
|
|
939
|
-
function
|
|
940
|
+
function packagePresent(specifier, fromFile = import.meta.url) {
|
|
941
|
+
const require_2 = createRequire(fromFile);
|
|
940
942
|
try {
|
|
941
|
-
|
|
943
|
+
require_2.resolve(specifier);
|
|
942
944
|
return true;
|
|
943
945
|
} catch {
|
|
944
|
-
return false;
|
|
945
946
|
}
|
|
947
|
+
for (const dir of require_2.resolve.paths(specifier) ?? []) {
|
|
948
|
+
if (fs9.existsSync(path5.join(dir, specifier, "package.json")))
|
|
949
|
+
return true;
|
|
950
|
+
}
|
|
951
|
+
return false;
|
|
946
952
|
}
|
|
947
953
|
function detectHarnesses() {
|
|
948
954
|
const out = [];
|
|
949
|
-
if (onPath("claude") ||
|
|
955
|
+
if (onPath("claude") || packagePresent("@anthropic-ai/claude-agent-sdk"))
|
|
950
956
|
out.push("claude");
|
|
951
|
-
if (onPath("codex") ||
|
|
957
|
+
if (onPath("codex") || packagePresent("@openai/codex-sdk"))
|
|
952
958
|
out.push("codex");
|
|
953
959
|
return out;
|
|
954
960
|
}
|
|
955
961
|
function codexInvocation(argv) {
|
|
956
962
|
if (onPath("codex"))
|
|
957
963
|
return { file: "codex", argv };
|
|
964
|
+
const require_2 = createRequire(import.meta.url);
|
|
958
965
|
try {
|
|
959
|
-
const cli =
|
|
966
|
+
const cli = require_2.resolve("@openai/codex/bin/codex.js");
|
|
960
967
|
return { file: process.execPath, argv: [cli, ...argv] };
|
|
968
|
+
} catch {
|
|
969
|
+
}
|
|
970
|
+
for (const dir of require_2.resolve.paths("@openai/codex") ?? []) {
|
|
971
|
+
const cli = path5.join(dir, "@openai", "codex", "bin", "codex.js");
|
|
972
|
+
if (fs9.existsSync(cli))
|
|
973
|
+
return { file: process.execPath, argv: [cli, ...argv] };
|
|
974
|
+
}
|
|
975
|
+
return void 0;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// packages/core/dist/watch.js
|
|
979
|
+
import * as fs10 from "node:fs";
|
|
980
|
+
import * as path6 from "node:path";
|
|
981
|
+
import { spawnSync } from "node:child_process";
|
|
982
|
+
function watchesDir() {
|
|
983
|
+
return path6.join(lobstahHome(), "watches");
|
|
984
|
+
}
|
|
985
|
+
function slug(key) {
|
|
986
|
+
return key.replace(/[^A-Za-z0-9._-]+/g, "-");
|
|
987
|
+
}
|
|
988
|
+
function watchPath(key) {
|
|
989
|
+
return path6.join(watchesDir(), `${slug(key)}.json`);
|
|
990
|
+
}
|
|
991
|
+
function eventsPath2(key) {
|
|
992
|
+
return path6.join(watchesDir(), `${slug(key)}.events`);
|
|
993
|
+
}
|
|
994
|
+
function writeWatch(w) {
|
|
995
|
+
fs10.mkdirSync(watchesDir(), { recursive: true });
|
|
996
|
+
const file = watchPath(w.key);
|
|
997
|
+
const tmp = `${file}.tmp`;
|
|
998
|
+
fs10.writeFileSync(tmp, JSON.stringify(w, null, 2));
|
|
999
|
+
fs10.renameSync(tmp, file);
|
|
1000
|
+
}
|
|
1001
|
+
function readWatch(key) {
|
|
1002
|
+
try {
|
|
1003
|
+
return JSON.parse(fs10.readFileSync(watchPath(key), "utf8"));
|
|
961
1004
|
} catch {
|
|
962
1005
|
return void 0;
|
|
963
1006
|
}
|
|
964
1007
|
}
|
|
1008
|
+
function listWatches() {
|
|
1009
|
+
if (!fs10.existsSync(watchesDir()))
|
|
1010
|
+
return [];
|
|
1011
|
+
return fs10.readdirSync(watchesDir()).filter((f) => f.endsWith(".json")).map((f) => {
|
|
1012
|
+
try {
|
|
1013
|
+
return JSON.parse(fs10.readFileSync(path6.join(watchesDir(), f), "utf8"));
|
|
1014
|
+
} catch {
|
|
1015
|
+
return void 0;
|
|
1016
|
+
}
|
|
1017
|
+
}).filter((w) => w !== void 0);
|
|
1018
|
+
}
|
|
1019
|
+
function addWatch(key, check, opts = {}) {
|
|
1020
|
+
if (!key || !check)
|
|
1021
|
+
throw new Error("watch add requires a key and a --check command");
|
|
1022
|
+
const existing = readWatch(key);
|
|
1023
|
+
const w = {
|
|
1024
|
+
key,
|
|
1025
|
+
owner: opts.owner ?? existing?.owner ?? "man",
|
|
1026
|
+
check,
|
|
1027
|
+
stream: opts.stream ?? existing?.stream,
|
|
1028
|
+
cursor: existing?.cursor ?? opts.cursor ?? "0",
|
|
1029
|
+
everySecs: opts.everySecs ?? existing?.everySecs,
|
|
1030
|
+
brief: opts.brief ?? existing?.brief,
|
|
1031
|
+
createdAt: existing?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1032
|
+
lastCheckedAt: existing?.lastCheckedAt,
|
|
1033
|
+
seen: existing?.seen ?? 0,
|
|
1034
|
+
seenAt: existing?.seenAt ?? 0,
|
|
1035
|
+
lastFollowUpId: existing?.lastFollowUpId
|
|
1036
|
+
};
|
|
1037
|
+
writeWatch(w);
|
|
1038
|
+
return w;
|
|
1039
|
+
}
|
|
1040
|
+
function removeWatch(key) {
|
|
1041
|
+
const existed = fs10.existsSync(watchPath(key));
|
|
1042
|
+
fs10.rmSync(watchPath(key), { force: true });
|
|
1043
|
+
fs10.rmSync(eventsPath2(key), { force: true });
|
|
1044
|
+
return existed;
|
|
1045
|
+
}
|
|
1046
|
+
function readWatchEvents(key) {
|
|
1047
|
+
try {
|
|
1048
|
+
return fs10.readFileSync(eventsPath2(key), "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
1049
|
+
} catch {
|
|
1050
|
+
return [];
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
function appendWatchEvents(key, events) {
|
|
1054
|
+
const seen = new Set(readWatchEvents(key).map((e) => String(e.seq)));
|
|
1055
|
+
const fresh = events.filter((e) => !seen.has(String(e.seq)));
|
|
1056
|
+
if (fresh.length > 0) {
|
|
1057
|
+
fs10.appendFileSync(eventsPath2(key), fresh.map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
1058
|
+
}
|
|
1059
|
+
return fresh;
|
|
1060
|
+
}
|
|
1061
|
+
function setWatchCursor(key, cursor) {
|
|
1062
|
+
const w = readWatch(key);
|
|
1063
|
+
if (!w || w.cursor === cursor)
|
|
1064
|
+
return;
|
|
1065
|
+
w.cursor = cursor;
|
|
1066
|
+
writeWatch(w);
|
|
1067
|
+
}
|
|
1068
|
+
function watchDue(w, defaultEverySecs, now = Date.now()) {
|
|
1069
|
+
const every = (w.everySecs ?? defaultEverySecs) * 1e3;
|
|
1070
|
+
const last = w.lastCheckedAt ? Date.parse(w.lastCheckedAt) : 0;
|
|
1071
|
+
return now - last >= every;
|
|
1072
|
+
}
|
|
1073
|
+
var CHECK_TIMEOUT_MS = 9e4;
|
|
1074
|
+
function runWatchCheck(w, now = /* @__PURE__ */ new Date()) {
|
|
1075
|
+
w.lastCheckedAt = now.toISOString();
|
|
1076
|
+
writeWatch(w);
|
|
1077
|
+
const cmd = w.check.replaceAll("{cursor}", w.cursor);
|
|
1078
|
+
const res = spawnSync(cmd, { shell: true, encoding: "utf8", timeout: CHECK_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 });
|
|
1079
|
+
if (res.status !== 0 || res.error) {
|
|
1080
|
+
w.lastError = (res.error?.message || res.stderr?.trim() || `exit ${res.status}`).slice(0, 500);
|
|
1081
|
+
writeWatch(w);
|
|
1082
|
+
return { watch: w, fresh: [] };
|
|
1083
|
+
}
|
|
1084
|
+
let parsed;
|
|
1085
|
+
try {
|
|
1086
|
+
parsed = JSON.parse(res.stdout);
|
|
1087
|
+
} catch {
|
|
1088
|
+
w.lastError = `unparseable check output: ${res.stdout.slice(0, 200).trim()}`;
|
|
1089
|
+
writeWatch(w);
|
|
1090
|
+
return { watch: w, fresh: [] };
|
|
1091
|
+
}
|
|
1092
|
+
w.lastError = void 0;
|
|
1093
|
+
const events = Array.isArray(parsed.events) ? parsed.events.map((e) => ({
|
|
1094
|
+
seq: e.seq ?? w.cursor,
|
|
1095
|
+
summary: e.summary !== void 0 ? String(e.summary) : void 0,
|
|
1096
|
+
...e,
|
|
1097
|
+
at: now.toISOString()
|
|
1098
|
+
})) : [];
|
|
1099
|
+
const fresh = appendWatchEvents(w.key, events);
|
|
1100
|
+
if (parsed.cursor !== void 0)
|
|
1101
|
+
w.cursor = String(parsed.cursor);
|
|
1102
|
+
if (parsed.done === true)
|
|
1103
|
+
w.done = true;
|
|
1104
|
+
writeWatch(w);
|
|
1105
|
+
return { watch: w, fresh };
|
|
1106
|
+
}
|
|
1107
|
+
function pendingWatchEvents(consume, owner = "man", now = Date.now()) {
|
|
1108
|
+
const out = [];
|
|
1109
|
+
for (const w of listWatches()) {
|
|
1110
|
+
const isMan = w.owner === "man";
|
|
1111
|
+
if (owner === "man" !== isMan)
|
|
1112
|
+
continue;
|
|
1113
|
+
const events = readWatchEvents(w.key);
|
|
1114
|
+
const fresh = events.slice(w.seen);
|
|
1115
|
+
if (fresh.length === 0) {
|
|
1116
|
+
if (w.done && consume)
|
|
1117
|
+
removeWatch(w.key);
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
1120
|
+
out.push({ watch: w, events: fresh });
|
|
1121
|
+
if (consume) {
|
|
1122
|
+
w.seen = events.length;
|
|
1123
|
+
w.seenAt = now;
|
|
1124
|
+
if (w.done)
|
|
1125
|
+
removeWatch(w.key);
|
|
1126
|
+
else
|
|
1127
|
+
writeWatch(w);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
return out;
|
|
1131
|
+
}
|
|
1132
|
+
function markFollowUp(key, followUpId, deliveredThrough) {
|
|
1133
|
+
const w = readWatch(key);
|
|
1134
|
+
if (!w)
|
|
1135
|
+
return;
|
|
1136
|
+
w.lastFollowUpId = followUpId;
|
|
1137
|
+
w.seen = deliveredThrough;
|
|
1138
|
+
w.seenAt = Date.now();
|
|
1139
|
+
if (w.done)
|
|
1140
|
+
removeWatch(w.key);
|
|
1141
|
+
else
|
|
1142
|
+
writeWatch(w);
|
|
1143
|
+
}
|
|
965
1144
|
|
|
966
1145
|
// packages/supervisor/dist/liveness.js
|
|
967
1146
|
import { execFileSync } from "node:child_process";
|
|
@@ -1021,26 +1200,26 @@ function killGroup(pid, signal = "SIGTERM") {
|
|
|
1021
1200
|
}
|
|
1022
1201
|
|
|
1023
1202
|
// packages/supervisor/dist/daemon.js
|
|
1024
|
-
import * as
|
|
1203
|
+
import * as fs12 from "node:fs";
|
|
1025
1204
|
import * as os3 from "node:os";
|
|
1026
|
-
import * as
|
|
1205
|
+
import * as path8 from "node:path";
|
|
1027
1206
|
import { spawn as spawn2 } from "node:child_process";
|
|
1028
1207
|
import { createRequire as createRequire2 } from "node:module";
|
|
1029
1208
|
import { fileURLToPath } from "node:url";
|
|
1030
1209
|
|
|
1031
1210
|
// packages/supervisor/dist/notify.js
|
|
1032
|
-
import * as
|
|
1033
|
-
import * as
|
|
1211
|
+
import * as fs11 from "node:fs";
|
|
1212
|
+
import * as path7 from "node:path";
|
|
1034
1213
|
import { spawn } from "node:child_process";
|
|
1035
1214
|
var DEFAULT_NOTIFY_VERBS = ["needs-decision", "blocked", "done", "failed"];
|
|
1036
1215
|
function cursorPath(id, lane) {
|
|
1037
|
-
return
|
|
1216
|
+
return path7.join(laneDirs(lane).state, `${id}.notified`);
|
|
1038
1217
|
}
|
|
1039
1218
|
function pendingNotifications(id, lane, verbs = DEFAULT_NOTIFY_VERBS, sinceMs = 0) {
|
|
1040
1219
|
const log = readStatusLog(id, lane);
|
|
1041
1220
|
let cursor;
|
|
1042
1221
|
try {
|
|
1043
|
-
cursor = Number(
|
|
1222
|
+
cursor = Number(fs11.readFileSync(cursorPath(id, lane), "utf8")) || 0;
|
|
1044
1223
|
} catch {
|
|
1045
1224
|
cursor = void 0;
|
|
1046
1225
|
}
|
|
@@ -1050,13 +1229,13 @@ function pendingNotifications(id, lane, verbs = DEFAULT_NOTIFY_VERBS, sinceMs =
|
|
|
1050
1229
|
if (cursor === void 0 && sinceMs > 0) {
|
|
1051
1230
|
fresh = fresh.filter((e) => Date.parse(e.at) >= sinceMs);
|
|
1052
1231
|
}
|
|
1053
|
-
|
|
1232
|
+
fs11.writeFileSync(cursorPath(id, lane), String(log.length));
|
|
1054
1233
|
return fresh.filter((e) => verbs.includes(e.verb)).map((entry) => ({ id, lane, entry }));
|
|
1055
1234
|
}
|
|
1056
1235
|
function notifiableIds(lane, maxAgeMs = 48 * 36e5) {
|
|
1057
1236
|
const dir = laneDirs(lane).state;
|
|
1058
1237
|
const cutoff = Date.now() - maxAgeMs;
|
|
1059
|
-
return
|
|
1238
|
+
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
1239
|
}
|
|
1061
1240
|
function execNotify(command, ev, log = () => {
|
|
1062
1241
|
}) {
|
|
@@ -1086,11 +1265,11 @@ function captureWaitBaseline() {
|
|
|
1086
1265
|
return lengths;
|
|
1087
1266
|
}
|
|
1088
1267
|
function attnCursorPath(id, lane) {
|
|
1089
|
-
return
|
|
1268
|
+
return path7.join(laneDirs(lane).state, `${id}.attn`);
|
|
1090
1269
|
}
|
|
1091
1270
|
function readAttnCursor(file) {
|
|
1092
1271
|
try {
|
|
1093
|
-
const parsed = JSON.parse(
|
|
1272
|
+
const parsed = JSON.parse(fs11.readFileSync(file, "utf8"));
|
|
1094
1273
|
return { seen: parsed.seen ?? 0, at: parsed.at ?? 0 };
|
|
1095
1274
|
} catch {
|
|
1096
1275
|
return { seen: 0, at: 0 };
|
|
@@ -1116,7 +1295,7 @@ function attentionNow(consume = true, remindMs = 15 * 6e4, now = Date.now()) {
|
|
|
1116
1295
|
continue;
|
|
1117
1296
|
out.push({ id, lane, entry });
|
|
1118
1297
|
if (consume)
|
|
1119
|
-
|
|
1298
|
+
fs11.writeFileSync(file, JSON.stringify({ seen: Math.max(cursor.seen, index + 1), at: now }));
|
|
1120
1299
|
}
|
|
1121
1300
|
}
|
|
1122
1301
|
return out;
|
|
@@ -1151,33 +1330,33 @@ function runnerEntry() {
|
|
|
1151
1330
|
}
|
|
1152
1331
|
function readRunnerInfo(dir) {
|
|
1153
1332
|
try {
|
|
1154
|
-
return JSON.parse(
|
|
1333
|
+
return JSON.parse(fs12.readFileSync(path8.join(dir, "runner.json"), "utf8"));
|
|
1155
1334
|
} catch {
|
|
1156
1335
|
return void 0;
|
|
1157
1336
|
}
|
|
1158
1337
|
}
|
|
1159
1338
|
function listActive(lane) {
|
|
1160
1339
|
const dir = laneDirs(lane).active;
|
|
1161
|
-
return
|
|
1162
|
-
const d =
|
|
1340
|
+
return fs12.readdirSync(dir).filter((f) => !f.startsWith(".")).map((id) => {
|
|
1341
|
+
const d = path8.join(dir, id);
|
|
1163
1342
|
return { id, lane, dir: d, runner: readRunnerInfo(d) };
|
|
1164
1343
|
});
|
|
1165
1344
|
}
|
|
1166
1345
|
function finalize(st) {
|
|
1167
1346
|
try {
|
|
1168
|
-
|
|
1347
|
+
fs12.renameSync(st.dir, path8.join(laneDirs(st.lane).done, st.id));
|
|
1169
1348
|
} catch {
|
|
1170
1349
|
}
|
|
1171
1350
|
}
|
|
1172
1351
|
function spawnRunner(st, opts) {
|
|
1173
1352
|
let nudge = opts.nudge;
|
|
1174
|
-
const handoffFile =
|
|
1175
|
-
if (!nudge &&
|
|
1176
|
-
nudge =
|
|
1177
|
-
|
|
1353
|
+
const handoffFile = path8.join(st.dir, "handoff");
|
|
1354
|
+
if (!nudge && fs12.existsSync(handoffFile)) {
|
|
1355
|
+
nudge = fs12.readFileSync(handoffFile, "utf8");
|
|
1356
|
+
fs12.unlinkSync(handoffFile);
|
|
1178
1357
|
}
|
|
1179
|
-
const logPath =
|
|
1180
|
-
const log =
|
|
1358
|
+
const logPath = path8.join(laneDirs(st.lane).state, `${st.id}.runner.log`);
|
|
1359
|
+
const log = fs12.openSync(logPath, "a");
|
|
1181
1360
|
const child = spawn2(process.execPath, [runnerEntry(), st.dir, st.lane], {
|
|
1182
1361
|
detached: true,
|
|
1183
1362
|
stdio: ["ignore", log, log],
|
|
@@ -1188,7 +1367,7 @@ function spawnRunner(st, opts) {
|
|
|
1188
1367
|
...nudge ? { LOBSTAH_NUDGE: nudge } : {}
|
|
1189
1368
|
}
|
|
1190
1369
|
});
|
|
1191
|
-
|
|
1370
|
+
fs12.closeSync(log);
|
|
1192
1371
|
child.unref();
|
|
1193
1372
|
const info = {
|
|
1194
1373
|
pid: child.pid ?? -1,
|
|
@@ -1196,22 +1375,22 @@ function spawnRunner(st, opts) {
|
|
|
1196
1375
|
processStartTime: child.pid ? processStartTime(child.pid) : void 0,
|
|
1197
1376
|
attempts: opts.attempts
|
|
1198
1377
|
};
|
|
1199
|
-
|
|
1378
|
+
fs12.writeFileSync(path8.join(st.dir, "runner.json"), JSON.stringify(info, null, 2));
|
|
1200
1379
|
}
|
|
1201
1380
|
function sessionOf(st) {
|
|
1202
1381
|
try {
|
|
1203
|
-
const ev = JSON.parse(
|
|
1382
|
+
const ev = JSON.parse(fs12.readFileSync(path8.join(laneDirs(st.lane).state, `${st.id}.evidence`), "utf8"));
|
|
1204
1383
|
return ev.sessionId;
|
|
1205
1384
|
} catch {
|
|
1206
1385
|
return void 0;
|
|
1207
1386
|
}
|
|
1208
1387
|
}
|
|
1209
1388
|
function reconcileOne(st, cfg, log) {
|
|
1210
|
-
const hasDescriptor =
|
|
1389
|
+
const hasDescriptor = fs12.existsSync(path8.join(st.dir, "descriptor.json"));
|
|
1211
1390
|
if (!hasDescriptor) {
|
|
1212
|
-
const age = Date.now() -
|
|
1391
|
+
const age = Date.now() - fs12.statSync(st.dir).mtimeMs;
|
|
1213
1392
|
if (age > 5 * 6e4)
|
|
1214
|
-
|
|
1393
|
+
fs12.rmSync(st.dir, { recursive: true, force: true });
|
|
1215
1394
|
return;
|
|
1216
1395
|
}
|
|
1217
1396
|
const statusLog = readStatusLog(st.id, st.lane);
|
|
@@ -1295,16 +1474,16 @@ function writeHeartbeat(cfg) {
|
|
|
1295
1474
|
heartbeat: (/* @__PURE__ */ new Date()).toISOString()
|
|
1296
1475
|
};
|
|
1297
1476
|
const tmp = `${executorPath()}.tmp`;
|
|
1298
|
-
|
|
1299
|
-
|
|
1477
|
+
fs12.writeFileSync(tmp, JSON.stringify(payload, null, 2));
|
|
1478
|
+
fs12.renameSync(tmp, executorPath());
|
|
1300
1479
|
}
|
|
1301
1480
|
function pruneChores(cfg) {
|
|
1302
1481
|
const dir = laneDirs("chore").done;
|
|
1303
1482
|
const cutoff = Date.now() - cfg.limits.choreRetentionDays * 864e5;
|
|
1304
|
-
for (const id of
|
|
1305
|
-
const p =
|
|
1306
|
-
if (
|
|
1307
|
-
|
|
1483
|
+
for (const id of fs12.readdirSync(dir)) {
|
|
1484
|
+
const p = path8.join(dir, id);
|
|
1485
|
+
if (fs12.statSync(p).mtimeMs < cutoff)
|
|
1486
|
+
fs12.rmSync(p, { recursive: true, force: true });
|
|
1308
1487
|
}
|
|
1309
1488
|
}
|
|
1310
1489
|
var daemonStartedAt = Date.now();
|
|
@@ -1344,9 +1523,9 @@ function tick(log = () => {
|
|
|
1344
1523
|
pruneChores(loadConfig());
|
|
1345
1524
|
}
|
|
1346
1525
|
function acquireDaemonLock() {
|
|
1347
|
-
const lockPath =
|
|
1526
|
+
const lockPath = path8.join(lobstahHome(), "daemon.lock");
|
|
1348
1527
|
try {
|
|
1349
|
-
const prior = JSON.parse(
|
|
1528
|
+
const prior = JSON.parse(fs12.readFileSync(lockPath, "utf8"));
|
|
1350
1529
|
if (pidAlive(prior.pid, prior.processStartTime)) {
|
|
1351
1530
|
throw new Error(`another daemon (pid ${prior.pid}) already supervises ${lobstahHome()} \u2014 run a second instance against its own LOBSTAH_HOME instead`);
|
|
1352
1531
|
}
|
|
@@ -1354,10 +1533,10 @@ function acquireDaemonLock() {
|
|
|
1354
1533
|
if (err instanceof Error && err.message.includes("already supervises"))
|
|
1355
1534
|
throw err;
|
|
1356
1535
|
}
|
|
1357
|
-
|
|
1536
|
+
fs12.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, processStartTime: processStartTime(process.pid), startedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2));
|
|
1358
1537
|
const release = () => {
|
|
1359
1538
|
try {
|
|
1360
|
-
|
|
1539
|
+
fs12.unlinkSync(lockPath);
|
|
1361
1540
|
} catch {
|
|
1362
1541
|
}
|
|
1363
1542
|
};
|
|
@@ -1369,17 +1548,42 @@ function acquireDaemonLock() {
|
|
|
1369
1548
|
});
|
|
1370
1549
|
}
|
|
1371
1550
|
}
|
|
1551
|
+
function watchQueues(onChange) {
|
|
1552
|
+
const watchers = [];
|
|
1553
|
+
for (const lane of ["work", "chore"]) {
|
|
1554
|
+
try {
|
|
1555
|
+
watchers.push(fs12.watch(laneDirs(lane).queue, onChange));
|
|
1556
|
+
} catch {
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
return () => {
|
|
1560
|
+
for (const w of watchers)
|
|
1561
|
+
w.close();
|
|
1562
|
+
};
|
|
1563
|
+
}
|
|
1372
1564
|
async function daemon(intervalMs = 5e3, log = console.log) {
|
|
1373
1565
|
ensureLayout();
|
|
1374
1566
|
acquireDaemonLock();
|
|
1375
1567
|
log(`lobstah daemon: watching ${laneDirs("work").queue} every ${intervalMs}ms`);
|
|
1568
|
+
let wake;
|
|
1569
|
+
watchQueues(() => wake?.());
|
|
1376
1570
|
while (true) {
|
|
1377
1571
|
try {
|
|
1378
1572
|
tick(log);
|
|
1379
1573
|
} catch (err) {
|
|
1380
1574
|
log(`tick error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1381
1575
|
}
|
|
1382
|
-
await new Promise((
|
|
1576
|
+
await new Promise((resolve2) => {
|
|
1577
|
+
const timer = setTimeout(() => {
|
|
1578
|
+
wake = void 0;
|
|
1579
|
+
resolve2();
|
|
1580
|
+
}, intervalMs);
|
|
1581
|
+
wake = () => {
|
|
1582
|
+
wake = void 0;
|
|
1583
|
+
clearTimeout(timer);
|
|
1584
|
+
setTimeout(resolve2, 50);
|
|
1585
|
+
};
|
|
1586
|
+
});
|
|
1383
1587
|
}
|
|
1384
1588
|
}
|
|
1385
1589
|
|
|
@@ -1387,7 +1591,7 @@ async function daemon(intervalMs = 5e3, log = console.log) {
|
|
|
1387
1591
|
import { spawn as spawn3 } from "node:child_process";
|
|
1388
1592
|
|
|
1389
1593
|
// apps/pick/dist/config.js
|
|
1390
|
-
import * as
|
|
1594
|
+
import * as fs13 from "node:fs";
|
|
1391
1595
|
import { execSync } from "node:child_process";
|
|
1392
1596
|
import * as os4 from "node:os";
|
|
1393
1597
|
|
|
@@ -1429,7 +1633,7 @@ function resolveTokenSource(section, label) {
|
|
|
1429
1633
|
}
|
|
1430
1634
|
if (file) {
|
|
1431
1635
|
return () => {
|
|
1432
|
-
const token =
|
|
1636
|
+
const token = fs13.readFileSync(file.startsWith("~/") ? `${os4.homedir()}/${file.slice(2)}` : file, "utf8").trim();
|
|
1433
1637
|
if (!token)
|
|
1434
1638
|
throw new Error(`${label}: tokenFile ${file} is empty`);
|
|
1435
1639
|
return token;
|
|
@@ -1447,7 +1651,7 @@ function resolveTokenSource(section, label) {
|
|
|
1447
1651
|
}
|
|
1448
1652
|
function loadPickupConfig() {
|
|
1449
1653
|
const file = configPath();
|
|
1450
|
-
const raw =
|
|
1654
|
+
const raw = fs13.existsSync(file) ? parse(fs13.readFileSync(file, "utf8")) : {};
|
|
1451
1655
|
const p = raw.pickup ?? {};
|
|
1452
1656
|
const out = {
|
|
1453
1657
|
pollSecs: Number(p.pollSecs ?? 45),
|
|
@@ -1520,23 +1724,23 @@ function loadPickupConfig() {
|
|
|
1520
1724
|
}
|
|
1521
1725
|
|
|
1522
1726
|
// apps/pick/dist/state.js
|
|
1523
|
-
import * as
|
|
1524
|
-
import * as
|
|
1727
|
+
import * as fs14 from "node:fs";
|
|
1728
|
+
import * as path9 from "node:path";
|
|
1525
1729
|
var PickupState = class {
|
|
1526
1730
|
file;
|
|
1527
1731
|
data;
|
|
1528
|
-
constructor(dir =
|
|
1529
|
-
|
|
1530
|
-
this.file =
|
|
1531
|
-
this.data =
|
|
1732
|
+
constructor(dir = path9.join(lobstahHome(), "pickup")) {
|
|
1733
|
+
fs14.mkdirSync(dir, { recursive: true });
|
|
1734
|
+
this.file = path9.join(dir, "state.json");
|
|
1735
|
+
this.data = fs14.existsSync(this.file) ? JSON.parse(fs14.readFileSync(this.file, "utf8")) : { map: {}, consumedApprovals: {}, rebases: {} };
|
|
1532
1736
|
this.data.map ??= {};
|
|
1533
1737
|
this.data.consumedApprovals ??= {};
|
|
1534
1738
|
this.data.rebases ??= {};
|
|
1535
1739
|
}
|
|
1536
1740
|
save() {
|
|
1537
1741
|
const tmp = `${this.file}.tmp-${process.pid}`;
|
|
1538
|
-
|
|
1539
|
-
|
|
1742
|
+
fs14.writeFileSync(tmp, JSON.stringify(this.data, null, 2));
|
|
1743
|
+
fs14.renameSync(tmp, this.file);
|
|
1540
1744
|
}
|
|
1541
1745
|
get(key) {
|
|
1542
1746
|
return this.data.map[key];
|
|
@@ -1920,12 +2124,12 @@ async function dispatchLoop(source, state, log = () => {
|
|
|
1920
2124
|
}
|
|
1921
2125
|
|
|
1922
2126
|
// apps/pick/dist/loops/report.js
|
|
1923
|
-
import * as
|
|
1924
|
-
import * as
|
|
2127
|
+
import * as fs15 from "node:fs";
|
|
2128
|
+
import * as path10 from "node:path";
|
|
1925
2129
|
function dispatchLane(uuid) {
|
|
1926
2130
|
for (const lane of ["work", "chore"]) {
|
|
1927
2131
|
const d = laneDirs(lane);
|
|
1928
|
-
if (
|
|
2132
|
+
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
2133
|
return lane;
|
|
1930
2134
|
}
|
|
1931
2135
|
}
|
|
@@ -1966,12 +2170,12 @@ async function reportLoop(source, state, log = () => {
|
|
|
1966
2170
|
}
|
|
1967
2171
|
|
|
1968
2172
|
// apps/pick/dist/loops/reconcile.js
|
|
1969
|
-
import * as
|
|
1970
|
-
import * as
|
|
2173
|
+
import * as fs16 from "node:fs";
|
|
2174
|
+
import * as path11 from "node:path";
|
|
1971
2175
|
function dispatchActive(uuid) {
|
|
1972
2176
|
for (const lane of ["work", "chore"]) {
|
|
1973
2177
|
const d = laneDirs(lane);
|
|
1974
|
-
if (
|
|
2178
|
+
if (fs16.existsSync(path11.join(d.active, uuid)) || fs16.existsSync(path11.join(d.queue, `${uuid}.json`))) {
|
|
1975
2179
|
return lane;
|
|
1976
2180
|
}
|
|
1977
2181
|
}
|
|
@@ -2011,28 +2215,28 @@ async function reconcileLoop(source, state, log = () => {
|
|
|
2011
2215
|
|
|
2012
2216
|
// apps/pick/dist/loops/merge.js
|
|
2013
2217
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
2014
|
-
import * as
|
|
2015
|
-
import * as
|
|
2218
|
+
import * as fs18 from "node:fs";
|
|
2219
|
+
import * as path13 from "node:path";
|
|
2016
2220
|
|
|
2017
2221
|
// apps/pick/dist/merge-view.js
|
|
2018
|
-
import * as
|
|
2019
|
-
import * as
|
|
2222
|
+
import * as fs17 from "node:fs";
|
|
2223
|
+
import * as path12 from "node:path";
|
|
2020
2224
|
function viewPath() {
|
|
2021
|
-
return
|
|
2225
|
+
return path12.join(lobstahHome(), "pickup", "merge-view.json");
|
|
2022
2226
|
}
|
|
2023
2227
|
function readMergeView() {
|
|
2024
2228
|
try {
|
|
2025
|
-
return JSON.parse(
|
|
2229
|
+
return JSON.parse(fs17.readFileSync(viewPath(), "utf8"));
|
|
2026
2230
|
} catch {
|
|
2027
2231
|
return void 0;
|
|
2028
2232
|
}
|
|
2029
2233
|
}
|
|
2030
2234
|
function writeMergeView(view) {
|
|
2031
2235
|
const file = viewPath();
|
|
2032
|
-
|
|
2236
|
+
fs17.mkdirSync(path12.dirname(file), { recursive: true });
|
|
2033
2237
|
const tmp = `${file}.tmp-${process.pid}`;
|
|
2034
|
-
|
|
2035
|
-
|
|
2238
|
+
fs17.writeFileSync(tmp, JSON.stringify(view, null, 2));
|
|
2239
|
+
fs17.renameSync(tmp, file);
|
|
2036
2240
|
}
|
|
2037
2241
|
var RECENT_RETENTION_MS = 48 * 36e5;
|
|
2038
2242
|
async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ new Date()) {
|
|
@@ -2054,7 +2258,7 @@ async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ ne
|
|
|
2054
2258
|
}
|
|
2055
2259
|
function readPickupMap() {
|
|
2056
2260
|
try {
|
|
2057
|
-
const raw = JSON.parse(
|
|
2261
|
+
const raw = JSON.parse(fs17.readFileSync(path12.join(lobstahHome(), "pickup", "state.json"), "utf8"));
|
|
2058
2262
|
return raw.map ?? {};
|
|
2059
2263
|
} catch {
|
|
2060
2264
|
return {};
|
|
@@ -2088,7 +2292,7 @@ function rebaseBrief(pr) {
|
|
|
2088
2292
|
}
|
|
2089
2293
|
function choreVerdict(uuid) {
|
|
2090
2294
|
const d = laneDirs("chore");
|
|
2091
|
-
if (
|
|
2295
|
+
if (fs18.existsSync(path13.join(d.active, uuid)) || fs18.existsSync(path13.join(d.queue, `${uuid}.json`)))
|
|
2092
2296
|
return "active";
|
|
2093
2297
|
const last = readStatusLog(uuid, "chore").at(-1)?.verb;
|
|
2094
2298
|
return last === "done" ? "done" : "failed";
|
|
@@ -2181,6 +2385,126 @@ async function mergeLoop(ms, policy, state, log = () => {
|
|
|
2181
2385
|
await recordMergeView(ms.forgeRepo(), observed, (n) => ms.disposition(n));
|
|
2182
2386
|
}
|
|
2183
2387
|
|
|
2388
|
+
// apps/pick/dist/loops/watch.js
|
|
2389
|
+
import * as fs19 from "node:fs";
|
|
2390
|
+
import * as path14 from "node:path";
|
|
2391
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
2392
|
+
var DEFAULT_BRIEF = `You are resuming earlier work. The watched source {key} produced new events:
|
|
2393
|
+
|
|
2394
|
+
{events}
|
|
2395
|
+
|
|
2396
|
+
Read them, act on what they ask, and report the outcome.`;
|
|
2397
|
+
function laneOf(id) {
|
|
2398
|
+
for (const lane of ["work", "chore"]) {
|
|
2399
|
+
const d = laneDirs(lane);
|
|
2400
|
+
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`))) {
|
|
2401
|
+
return lane;
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
return void 0;
|
|
2405
|
+
}
|
|
2406
|
+
function descriptorOf(id, lane) {
|
|
2407
|
+
const d = laneDirs(lane);
|
|
2408
|
+
for (const bucket of ["active", "done"]) {
|
|
2409
|
+
const f = path14.join(d[bucket], id, "descriptor.json");
|
|
2410
|
+
if (fs19.existsSync(f))
|
|
2411
|
+
return JSON.parse(fs19.readFileSync(f, "utf8"));
|
|
2412
|
+
}
|
|
2413
|
+
const q = path14.join(d.queue, `${id}.json`);
|
|
2414
|
+
if (fs19.existsSync(q))
|
|
2415
|
+
return JSON.parse(fs19.readFileSync(q, "utf8"));
|
|
2416
|
+
return void 0;
|
|
2417
|
+
}
|
|
2418
|
+
function isTerminal(id) {
|
|
2419
|
+
const lane = laneOf(id);
|
|
2420
|
+
if (!lane)
|
|
2421
|
+
return true;
|
|
2422
|
+
const state = reconcile({ log: readStatusLog(id, lane), lastEventAt: lastEventAt(id, lane) });
|
|
2423
|
+
return state === "done" || state === "failed";
|
|
2424
|
+
}
|
|
2425
|
+
function spawnContinuation(w, pending, log) {
|
|
2426
|
+
const owner = w.owner.slice("dispatch:".length);
|
|
2427
|
+
const target = w.lastFollowUpId && !laneOf(w.lastFollowUpId) ? owner : w.lastFollowUpId ?? owner;
|
|
2428
|
+
const targetLane = laneOf(target);
|
|
2429
|
+
if (!targetLane) {
|
|
2430
|
+
log(`watch ${w.key}: owner dispatch ${target} is gone \u2014 dropping watch`);
|
|
2431
|
+
removeWatch(w.key);
|
|
2432
|
+
return;
|
|
2433
|
+
}
|
|
2434
|
+
const descriptor = descriptorOf(target, targetLane);
|
|
2435
|
+
if (!descriptor) {
|
|
2436
|
+
log(`watch ${w.key}: no descriptor for ${target} \u2014 dropping watch`);
|
|
2437
|
+
removeWatch(w.key);
|
|
2438
|
+
return;
|
|
2439
|
+
}
|
|
2440
|
+
const id = randomUUID3();
|
|
2441
|
+
const brief = (w.brief ?? DEFAULT_BRIEF).replaceAll("{key}", w.key).replaceAll("{events}", JSON.stringify(pending, null, 2));
|
|
2442
|
+
enqueue({ id, repo: descriptor.repo, brief, followUp: target }, "work");
|
|
2443
|
+
markFollowUp(w.key, id, readWatchEvents(w.key).length);
|
|
2444
|
+
log(`watch ${w.key}: ${pending.length} event(s) \u2192 continuation ${id} (forks ${target})`);
|
|
2445
|
+
}
|
|
2446
|
+
function notifyMan(watch2, fresh, notify) {
|
|
2447
|
+
if (fresh.length === 0 || watch2.owner !== "man")
|
|
2448
|
+
return;
|
|
2449
|
+
notify({
|
|
2450
|
+
key: watch2.key,
|
|
2451
|
+
uuid: "watch",
|
|
2452
|
+
verb: "watch",
|
|
2453
|
+
note: fresh[0]?.summary ?? `${fresh.length} new event(s)`
|
|
2454
|
+
});
|
|
2455
|
+
}
|
|
2456
|
+
function deliverDispatchOwned(log) {
|
|
2457
|
+
for (const { watch: watch2, events } of pendingWatchEvents(false, "dispatch")) {
|
|
2458
|
+
if (watch2.lastFollowUpId && !isTerminal(watch2.lastFollowUpId))
|
|
2459
|
+
continue;
|
|
2460
|
+
spawnContinuation(watch2, events, log);
|
|
2461
|
+
}
|
|
2462
|
+
for (const w of listWatches()) {
|
|
2463
|
+
if (w.done && w.owner !== "man" && readWatchEvents(w.key).length <= w.seen)
|
|
2464
|
+
removeWatch(w.key);
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
async function watchLoop(defaultEverySecs, log, notify = () => {
|
|
2468
|
+
}) {
|
|
2469
|
+
for (const w of listWatches()) {
|
|
2470
|
+
if (watchDue(w, defaultEverySecs)) {
|
|
2471
|
+
const { watch: watch2, fresh } = runWatchCheck(w);
|
|
2472
|
+
if (watch2.lastError)
|
|
2473
|
+
log(`watch ${w.key}: check failed \u2014 ${watch2.lastError}`);
|
|
2474
|
+
notifyMan(watch2, fresh, notify);
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
deliverDispatchOwned(log);
|
|
2478
|
+
}
|
|
2479
|
+
function handleStreamLine(key, line, log, notify) {
|
|
2480
|
+
const watch2 = readWatch(key);
|
|
2481
|
+
if (!watch2)
|
|
2482
|
+
return;
|
|
2483
|
+
let parsed;
|
|
2484
|
+
try {
|
|
2485
|
+
parsed = JSON.parse(line);
|
|
2486
|
+
} catch {
|
|
2487
|
+
log(`watch ${key}: unparseable stream line \u2014 ${line.slice(0, 120)}`);
|
|
2488
|
+
return;
|
|
2489
|
+
}
|
|
2490
|
+
if (parsed.seq === void 0) {
|
|
2491
|
+
if (parsed.cursor !== void 0)
|
|
2492
|
+
setWatchCursor(key, String(parsed.cursor));
|
|
2493
|
+
return;
|
|
2494
|
+
}
|
|
2495
|
+
const event = {
|
|
2496
|
+
seq: parsed.seq,
|
|
2497
|
+
summary: parsed.summary !== void 0 ? String(parsed.summary) : void 0,
|
|
2498
|
+
...parsed,
|
|
2499
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2500
|
+
};
|
|
2501
|
+
const fresh = appendWatchEvents(key, event.seq !== void 0 ? [event] : []);
|
|
2502
|
+
setWatchCursor(key, String(parsed.cursor ?? event.seq));
|
|
2503
|
+
notifyMan(watch2, fresh, notify);
|
|
2504
|
+
if (fresh.length > 0)
|
|
2505
|
+
deliverDispatchOwned(log);
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2184
2508
|
// apps/pick/dist/run.js
|
|
2185
2509
|
function makeNotifier(command, log) {
|
|
2186
2510
|
if (!command)
|
|
@@ -2204,7 +2528,49 @@ function makeNotifier(command, log) {
|
|
|
2204
2528
|
child.unref();
|
|
2205
2529
|
};
|
|
2206
2530
|
}
|
|
2207
|
-
|
|
2531
|
+
var SingleFlight = class {
|
|
2532
|
+
chain = Promise.resolve();
|
|
2533
|
+
run(job) {
|
|
2534
|
+
const next = this.chain.then(job, job);
|
|
2535
|
+
this.chain = next.then(() => {
|
|
2536
|
+
}, () => {
|
|
2537
|
+
});
|
|
2538
|
+
return next;
|
|
2539
|
+
}
|
|
2540
|
+
};
|
|
2541
|
+
function syncStreams(children, flight, log, notify) {
|
|
2542
|
+
for (const w of listWatches()) {
|
|
2543
|
+
if (!w.stream || children.has(w.key))
|
|
2544
|
+
continue;
|
|
2545
|
+
const cmd = w.stream.replaceAll("{cursor}", w.cursor);
|
|
2546
|
+
const child = spawn3(cmd, { shell: true, stdio: ["ignore", "pipe", "ignore"] });
|
|
2547
|
+
children.set(w.key, child);
|
|
2548
|
+
log(`watch ${w.key}: stream attached`);
|
|
2549
|
+
let buffer = "";
|
|
2550
|
+
child.stdout.on("data", (chunk) => {
|
|
2551
|
+
buffer += chunk.toString("utf8");
|
|
2552
|
+
const lines = buffer.split("\n");
|
|
2553
|
+
buffer = lines.pop() ?? "";
|
|
2554
|
+
for (const line of lines) {
|
|
2555
|
+
if (!line.trim())
|
|
2556
|
+
continue;
|
|
2557
|
+
void flight.run(() => handleStreamLine(w.key, line, log, notify));
|
|
2558
|
+
}
|
|
2559
|
+
});
|
|
2560
|
+
child.on("exit", () => {
|
|
2561
|
+
children.delete(w.key);
|
|
2562
|
+
log(`watch ${w.key}: stream ended \u2014 cadence covers until resync`);
|
|
2563
|
+
});
|
|
2564
|
+
child.on("error", () => children.delete(w.key));
|
|
2565
|
+
}
|
|
2566
|
+
for (const [key, child] of children) {
|
|
2567
|
+
if (!readWatch(key)?.stream) {
|
|
2568
|
+
child.kill();
|
|
2569
|
+
children.delete(key);
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
async function cycle(sources, merges, state, pollSecs, log, notify) {
|
|
2208
2574
|
for (const source of sources) {
|
|
2209
2575
|
try {
|
|
2210
2576
|
await dispatchLoop(source, state, log);
|
|
@@ -2221,6 +2587,11 @@ async function cycle(sources, merges, state, log, notify) {
|
|
|
2221
2587
|
log(`${source.name} merge: ${err instanceof Error ? err.message : String(err)}`);
|
|
2222
2588
|
}
|
|
2223
2589
|
}
|
|
2590
|
+
try {
|
|
2591
|
+
await watchLoop(pollSecs, log, notify);
|
|
2592
|
+
} catch (err) {
|
|
2593
|
+
log(`watches: ${err instanceof Error ? err.message : String(err)}`);
|
|
2594
|
+
}
|
|
2224
2595
|
}
|
|
2225
2596
|
async function runPickup(mode = "daemon") {
|
|
2226
2597
|
ensureLayout();
|
|
@@ -2237,16 +2608,22 @@ async function runPickup(mode = "daemon") {
|
|
|
2237
2608
|
if (cfg.linear)
|
|
2238
2609
|
sources.push(new LinearSource(cfg.linear));
|
|
2239
2610
|
if (sources.length === 0) {
|
|
2240
|
-
|
|
2611
|
+
if (listWatches().length === 0) {
|
|
2612
|
+
throw new Error("no [pickup.github], [pickup.linear], or registered watches \u2014 nothing to poll");
|
|
2613
|
+
}
|
|
2614
|
+
log("no tracker sources configured \u2014 watch-only mode");
|
|
2241
2615
|
}
|
|
2242
2616
|
const notify = makeNotifier(cfg.notifyCommand, log);
|
|
2243
2617
|
if (mode === "once") {
|
|
2244
|
-
await cycle(sources, merges, state, log, notify);
|
|
2618
|
+
await cycle(sources, merges, state, cfg.pollSecs, log, notify);
|
|
2245
2619
|
return;
|
|
2246
2620
|
}
|
|
2247
2621
|
log(`polling every ${cfg.pollSecs}s \u2014 no webhooks, no inbound surface`);
|
|
2622
|
+
const flight = new SingleFlight();
|
|
2623
|
+
const streamChildren = /* @__PURE__ */ new Map();
|
|
2248
2624
|
while (true) {
|
|
2249
|
-
await cycle(sources, merges, state, log, notify);
|
|
2625
|
+
await flight.run(() => cycle(sources, merges, state, cfg.pollSecs, log, notify));
|
|
2626
|
+
syncStreams(streamChildren, flight, log, notify);
|
|
2250
2627
|
await new Promise((r) => setTimeout(r, cfg.pollSecs * 1e3));
|
|
2251
2628
|
}
|
|
2252
2629
|
}
|
|
@@ -2265,21 +2642,21 @@ function mergeHaulHook(existing) {
|
|
|
2265
2642
|
}
|
|
2266
2643
|
|
|
2267
2644
|
// apps/cli/src/tend.ts
|
|
2268
|
-
import * as
|
|
2269
|
-
import * as
|
|
2645
|
+
import * as fs20 from "node:fs";
|
|
2646
|
+
import * as path15 from "node:path";
|
|
2270
2647
|
var HEARTBEAT_STALE_MS = 9e4;
|
|
2271
2648
|
var CLAIM_STALE_MS = 12e4;
|
|
2272
2649
|
var DAY_MS = 24 * 36e5;
|
|
2273
2650
|
function readJson(file) {
|
|
2274
2651
|
try {
|
|
2275
|
-
return JSON.parse(
|
|
2652
|
+
return JSON.parse(fs20.readFileSync(file, "utf8"));
|
|
2276
2653
|
} catch {
|
|
2277
2654
|
return void 0;
|
|
2278
2655
|
}
|
|
2279
2656
|
}
|
|
2280
2657
|
function doneIds(lane) {
|
|
2281
2658
|
try {
|
|
2282
|
-
return
|
|
2659
|
+
return fs20.readdirSync(laneDirs(lane).done).filter((f) => !f.startsWith("."));
|
|
2283
2660
|
} catch {
|
|
2284
2661
|
return [];
|
|
2285
2662
|
}
|
|
@@ -2298,16 +2675,16 @@ function followUps(uuid) {
|
|
|
2298
2675
|
const desc = readJson(file);
|
|
2299
2676
|
if (desc?.followUp === uuid) out.push({ id, bucket });
|
|
2300
2677
|
};
|
|
2301
|
-
for (const id of pendingIds("work")) scan(id, "queued",
|
|
2302
|
-
for (const id of activeIds("work")) scan(id, "active",
|
|
2303
|
-
for (const id of doneIds("work")) scan(id, "done",
|
|
2678
|
+
for (const id of pendingIds("work")) scan(id, "queued", path15.join(d.queue, `${id}.json`));
|
|
2679
|
+
for (const id of activeIds("work")) scan(id, "active", path15.join(d.active, id, "descriptor.json"));
|
|
2680
|
+
for (const id of doneIds("work")) scan(id, "done", path15.join(d.done, id, "descriptor.json"));
|
|
2304
2681
|
return out;
|
|
2305
2682
|
}
|
|
2306
2683
|
function bucketOf(uuid) {
|
|
2307
2684
|
const d = laneDirs("work");
|
|
2308
|
-
if (
|
|
2309
|
-
if (
|
|
2310
|
-
if (
|
|
2685
|
+
if (fs20.existsSync(path15.join(d.active, uuid))) return "active";
|
|
2686
|
+
if (fs20.existsSync(path15.join(d.done, uuid))) return "done";
|
|
2687
|
+
if (fs20.existsSync(path15.join(d.queue, `${uuid}.json`))) return "queued";
|
|
2311
2688
|
return void 0;
|
|
2312
2689
|
}
|
|
2313
2690
|
function buildTendReport(now = Date.now()) {
|
|
@@ -2335,8 +2712,39 @@ function buildTendReport(now = Date.now()) {
|
|
|
2335
2712
|
ageSecs: Math.max(0, Math.round((now - Date.parse(ev.entry.at)) / 1e3)),
|
|
2336
2713
|
note: ev.entry.note
|
|
2337
2714
|
}));
|
|
2715
|
+
const watches = [];
|
|
2716
|
+
const watchByDispatch = /* @__PURE__ */ new Map();
|
|
2717
|
+
for (const w of listWatches()) {
|
|
2718
|
+
const events = readWatchEvents(w.key);
|
|
2719
|
+
const pending = events.slice(w.seen);
|
|
2720
|
+
const last = events.at(-1);
|
|
2721
|
+
watches.push({
|
|
2722
|
+
key: w.key,
|
|
2723
|
+
owner: w.owner,
|
|
2724
|
+
cursor: w.cursor,
|
|
2725
|
+
pendingEvents: pending.length,
|
|
2726
|
+
lastSummary: last?.summary,
|
|
2727
|
+
lastAt: last?.at,
|
|
2728
|
+
error: w.lastError
|
|
2729
|
+
});
|
|
2730
|
+
if (w.owner.startsWith("dispatch:")) {
|
|
2731
|
+
const label = `${w.key}${pending.length > 0 ? ` (${pending.length} pending)` : ""}`;
|
|
2732
|
+
watchByDispatch.set(w.owner.slice("dispatch:".length), label);
|
|
2733
|
+
if (w.lastFollowUpId) watchByDispatch.set(w.lastFollowUpId, label);
|
|
2734
|
+
}
|
|
2735
|
+
if (w.owner === "man" && pending.length > 0) {
|
|
2736
|
+
const oldest = pending[0];
|
|
2737
|
+
attention.push({
|
|
2738
|
+
id: w.key,
|
|
2739
|
+
lane: "work",
|
|
2740
|
+
verb: "watch",
|
|
2741
|
+
ageSecs: oldest?.at ? Math.max(0, Math.round((now - Date.parse(oldest.at)) / 1e3)) : 0,
|
|
2742
|
+
note: pending.at(-1)?.summary
|
|
2743
|
+
});
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2338
2746
|
const oldestQueuedAge = queued.reduce((max, id) => {
|
|
2339
|
-
const st =
|
|
2747
|
+
const st = fs20.statSync(path15.join(laneDirs("work").queue, `${id}.json`), { throwIfNoEntry: false });
|
|
2340
2748
|
return st ? Math.max(max, now - st.mtimeMs) : max;
|
|
2341
2749
|
}, 0);
|
|
2342
2750
|
const stalled = daemonUp && queued.length > 0 && active.length < cfg.limits.maxConcurrent && oldestQueuedAge > CLAIM_STALE_MS;
|
|
@@ -2358,12 +2766,13 @@ function buildTendReport(now = Date.now()) {
|
|
|
2358
2766
|
const fresh = chain.some((d) => d.bucket !== "done" || d.at !== void 0 && now - Date.parse(d.at) < DAY_MS);
|
|
2359
2767
|
if (!fresh) continue;
|
|
2360
2768
|
const prUrl = chain.map((d) => d.prUrl).find((u) => u !== void 0);
|
|
2361
|
-
|
|
2769
|
+
const watch2 = chain.map((d) => watchByDispatch.get(d.id)).find((w) => w !== void 0);
|
|
2770
|
+
stories.push({ key, dispatches: chain, prUrl, gate: gateFor(entry.uuid, prUrl), watch: watch2 });
|
|
2362
2771
|
}
|
|
2363
2772
|
for (const id of [...active, ...queued]) {
|
|
2364
2773
|
if (storied.has(id)) continue;
|
|
2365
2774
|
const d = describeDispatch(id, "work", bucketOf(id) ?? "active");
|
|
2366
|
-
stories.push({ key: "(direct)", dispatches: [d], prUrl: d.prUrl, gate: gateFor(id, d.prUrl) });
|
|
2775
|
+
stories.push({ key: "(direct)", dispatches: [d], prUrl: d.prUrl, gate: gateFor(id, d.prUrl), watch: watchByDispatch.get(id) });
|
|
2367
2776
|
}
|
|
2368
2777
|
const verdict = !daemonUp ? "daemon-down" : stalled ? "stalled" : attention.length > 0 ? "needs-attention" : active.length + queued.length > 0 ? "working" : "idle";
|
|
2369
2778
|
return {
|
|
@@ -2372,6 +2781,7 @@ function buildTendReport(now = Date.now()) {
|
|
|
2372
2781
|
counts: { queued: queued.length, active: active.length, choresActive, done24h, failed24h },
|
|
2373
2782
|
attention,
|
|
2374
2783
|
stories,
|
|
2784
|
+
watches,
|
|
2375
2785
|
merge
|
|
2376
2786
|
};
|
|
2377
2787
|
}
|
|
@@ -2407,9 +2817,26 @@ function renderTend(r) {
|
|
|
2407
2817
|
key: s.key,
|
|
2408
2818
|
dispatches: s.dispatches.map((d) => `${d.id.slice(0, 8)}:${d.state}`).join(" \u2192 "),
|
|
2409
2819
|
pr: s.prUrl ?? "",
|
|
2410
|
-
gate: s.gate ?? ""
|
|
2820
|
+
gate: s.gate ?? "",
|
|
2821
|
+
watch: s.watch ?? ""
|
|
2411
2822
|
})),
|
|
2412
|
-
["key", "dispatches", "pr", "gate"]
|
|
2823
|
+
["key", "dispatches", "pr", "gate", "watch"]
|
|
2824
|
+
)
|
|
2825
|
+
);
|
|
2826
|
+
}
|
|
2827
|
+
if (r.watches.length > 0) {
|
|
2828
|
+
lines.push("");
|
|
2829
|
+
lines.push(
|
|
2830
|
+
toonTable(
|
|
2831
|
+
"watches",
|
|
2832
|
+
r.watches.map((w) => ({
|
|
2833
|
+
key: w.key,
|
|
2834
|
+
owner: w.owner,
|
|
2835
|
+
pending: w.pendingEvents,
|
|
2836
|
+
last: w.lastSummary ?? "",
|
|
2837
|
+
error: w.error ?? ""
|
|
2838
|
+
})),
|
|
2839
|
+
["key", "owner", "pending", "last", "error"]
|
|
2413
2840
|
)
|
|
2414
2841
|
);
|
|
2415
2842
|
}
|
|
@@ -2430,13 +2857,13 @@ function renderTend(r) {
|
|
|
2430
2857
|
}
|
|
2431
2858
|
|
|
2432
2859
|
// apps/cli/src/cull.ts
|
|
2433
|
-
import * as
|
|
2434
|
-
import * as
|
|
2860
|
+
import * as fs21 from "node:fs";
|
|
2861
|
+
import * as path16 from "node:path";
|
|
2435
2862
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
2436
2863
|
var DAY = 864e5;
|
|
2437
2864
|
function idsIn(dir) {
|
|
2438
2865
|
try {
|
|
2439
|
-
return new Set(
|
|
2866
|
+
return new Set(fs21.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => f.replace(/\.json$/, "")));
|
|
2440
2867
|
} catch {
|
|
2441
2868
|
return /* @__PURE__ */ new Set();
|
|
2442
2869
|
}
|
|
@@ -2451,39 +2878,39 @@ function planCull(olderThanDays, now = Date.now()) {
|
|
|
2451
2878
|
for (const id of idsIn(d.queue)) live.add(id);
|
|
2452
2879
|
for (const id of idsIn(d.active)) live.add(id);
|
|
2453
2880
|
for (const id of idsIn(d.done)) {
|
|
2454
|
-
const p =
|
|
2455
|
-
const m =
|
|
2881
|
+
const p = path16.join(d.done, id);
|
|
2882
|
+
const m = fs21.statSync(p).mtimeMs;
|
|
2456
2883
|
doneMtimes.set(id, m);
|
|
2457
2884
|
if (m < cutoff) items.push({ kind: "done", id, target: p, ageDays: Math.floor((now - m) / DAY) });
|
|
2458
2885
|
}
|
|
2459
2886
|
}
|
|
2460
|
-
const wtRoot =
|
|
2887
|
+
const wtRoot = path16.join(lobstahHome(), "worktrees");
|
|
2461
2888
|
for (const id of idsIn(wtRoot)) {
|
|
2462
2889
|
if (live.has(id)) continue;
|
|
2463
2890
|
const doneAt = doneMtimes.get(id);
|
|
2464
2891
|
if (doneAt !== void 0 && doneAt >= cutoff) continue;
|
|
2465
|
-
const p =
|
|
2466
|
-
items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now -
|
|
2892
|
+
const p = path16.join(wtRoot, id);
|
|
2893
|
+
items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now - fs21.statSync(p).mtimeMs) / DAY) });
|
|
2467
2894
|
}
|
|
2468
2895
|
for (const lane of ["work", "chore"]) {
|
|
2469
2896
|
const d = laneDirs(lane);
|
|
2470
2897
|
const seen = /* @__PURE__ */ new Set();
|
|
2471
|
-
for (const f of
|
|
2898
|
+
for (const f of fs21.readdirSync(d.state)) {
|
|
2472
2899
|
const id = f.replace(/\.(status|events|evidence|attn|notified|runner\.log)$/, "");
|
|
2473
2900
|
if (id === f || seen.has(id) || live.has(id) || doneMtimes.has(id)) continue;
|
|
2474
|
-
const m =
|
|
2901
|
+
const m = fs21.statSync(path16.join(d.state, f)).mtimeMs;
|
|
2475
2902
|
if (m >= cutoff) continue;
|
|
2476
2903
|
seen.add(id);
|
|
2477
|
-
items.push({ kind: "state", id, target:
|
|
2904
|
+
items.push({ kind: "state", id, target: path16.join(d.state, `${id}.*`), ageDays: Math.floor((now - m) / DAY) });
|
|
2478
2905
|
}
|
|
2479
2906
|
}
|
|
2480
2907
|
return items;
|
|
2481
2908
|
}
|
|
2482
2909
|
function removeWorktree(id, dir) {
|
|
2483
2910
|
for (const lane of ["work", "chore"]) {
|
|
2484
|
-
const descFile =
|
|
2911
|
+
const descFile = path16.join(laneDirs(lane).done, id, "descriptor.json");
|
|
2485
2912
|
try {
|
|
2486
|
-
const desc = JSON.parse(
|
|
2913
|
+
const desc = JSON.parse(fs21.readFileSync(descFile, "utf8"));
|
|
2487
2914
|
const repo = loadConfig().repos[desc.repo];
|
|
2488
2915
|
if (repo) {
|
|
2489
2916
|
execFileSync2("git", ["worktree", "remove", "--force", dir], { cwd: repo.path, stdio: "ignore" });
|
|
@@ -2492,15 +2919,15 @@ function removeWorktree(id, dir) {
|
|
|
2492
2919
|
} catch {
|
|
2493
2920
|
}
|
|
2494
2921
|
}
|
|
2495
|
-
|
|
2922
|
+
fs21.rmSync(dir, { recursive: true, force: true });
|
|
2496
2923
|
}
|
|
2497
2924
|
function applyCull(items) {
|
|
2498
2925
|
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"))
|
|
2926
|
+
for (const item of items.filter((i) => i.kind === "done")) fs21.rmSync(item.target, { recursive: true, force: true });
|
|
2500
2927
|
for (const item of items.filter((i) => i.kind === "state")) {
|
|
2501
|
-
const dir =
|
|
2502
|
-
for (const f of
|
|
2503
|
-
if (f.startsWith(`${item.id}.`))
|
|
2928
|
+
const dir = path16.dirname(item.target);
|
|
2929
|
+
for (const f of fs21.readdirSync(dir)) {
|
|
2930
|
+
if (f.startsWith(`${item.id}.`)) fs21.rmSync(path16.join(dir, f), { force: true });
|
|
2504
2931
|
}
|
|
2505
2932
|
}
|
|
2506
2933
|
}
|
|
@@ -2526,6 +2953,10 @@ your working set:
|
|
|
2526
2953
|
lobstah man tend [--json] tend the string: fleet verdict,
|
|
2527
2954
|
unanswered questions, each work
|
|
2528
2955
|
item's chain + PR + merge gate
|
|
2956
|
+
lobstah watch add <key> --check <cmd> stand watch on something
|
|
2957
|
+
external (a review session, a
|
|
2958
|
+
CI run) \u2014 its events wake you
|
|
2959
|
+
like any dispatch would
|
|
2529
2960
|
|
|
2530
2961
|
getting woken instead of asking:
|
|
2531
2962
|
lobstah man wait block until a dispatch needs attention (arm it as a
|
|
@@ -2545,12 +2976,11 @@ paste into your liaison instructions:
|
|
|
2545
2976
|
full pattern and trade-offs: docs/lobsterman.md \xB7 config: docs/configuration.md`;
|
|
2546
2977
|
|
|
2547
2978
|
// apps/cli/src/doctor.ts
|
|
2548
|
-
import * as
|
|
2549
|
-
import {
|
|
2550
|
-
import { spawnSync } from "node:child_process";
|
|
2979
|
+
import * as fs22 from "node:fs";
|
|
2980
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
2551
2981
|
var HEARTBEAT_STALE_MS2 = 9e4;
|
|
2552
2982
|
function git(repoPath, ...args) {
|
|
2553
|
-
const res =
|
|
2983
|
+
const res = spawnSync2("git", ["-C", repoPath, ...args], { encoding: "utf8" });
|
|
2554
2984
|
return { ok: res.status === 0, out: (res.stdout ?? "").trim() || (res.stderr ?? "").trim() };
|
|
2555
2985
|
}
|
|
2556
2986
|
function runDoctor(now = Date.now()) {
|
|
@@ -2562,25 +2992,20 @@ function runDoctor(now = Date.now()) {
|
|
|
2562
2992
|
const claude = onPath("claude");
|
|
2563
2993
|
push("harness claude", claude ? "ok" : "warn", claude ? "on PATH" : "claude not on PATH \u2014 claude dispatches will fail");
|
|
2564
2994
|
const codexPath = onPath("codex");
|
|
2565
|
-
|
|
2566
|
-
try {
|
|
2567
|
-
createRequire3(import.meta.url).resolve("@openai/codex-sdk");
|
|
2568
|
-
codexSdk = true;
|
|
2569
|
-
} catch {
|
|
2570
|
-
}
|
|
2995
|
+
const codexSdk = packagePresent("@openai/codex-sdk");
|
|
2571
2996
|
push(
|
|
2572
2997
|
"harness codex",
|
|
2573
2998
|
codexPath || codexSdk ? "ok" : "warn",
|
|
2574
2999
|
codexPath ? "on PATH" : codexSdk ? "vendored SDK (attach uses it too)" : "neither codex nor the SDK \u2014 codex dispatches will fail"
|
|
2575
3000
|
);
|
|
2576
3001
|
const cfgFile = configPath();
|
|
2577
|
-
if (!
|
|
3002
|
+
if (!fs22.existsSync(cfgFile)) {
|
|
2578
3003
|
push("config", "fail", `${cfgFile} missing \u2014 run \`lobstah init\``);
|
|
2579
3004
|
return rows;
|
|
2580
3005
|
}
|
|
2581
3006
|
let parsed;
|
|
2582
3007
|
try {
|
|
2583
|
-
parsed = parse(
|
|
3008
|
+
parsed = parse(fs22.readFileSync(cfgFile, "utf8"));
|
|
2584
3009
|
push("config", "ok", cfgFile);
|
|
2585
3010
|
} catch (err) {
|
|
2586
3011
|
push("config", "fail", `${cfgFile}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -2591,7 +3016,7 @@ function runDoctor(now = Date.now()) {
|
|
|
2591
3016
|
if (repoKeys.length === 0) push("repos", "warn", "none configured \u2014 add one with `lobstah repos add <path>`");
|
|
2592
3017
|
for (const [key, repo] of Object.entries(cfg.repos)) {
|
|
2593
3018
|
const label = `repo ${key}`;
|
|
2594
|
-
if (!
|
|
3019
|
+
if (!fs22.existsSync(repo.path)) {
|
|
2595
3020
|
push(label, "fail", `path ${repo.path} does not exist`);
|
|
2596
3021
|
continue;
|
|
2597
3022
|
}
|
|
@@ -2619,11 +3044,11 @@ function runDoctor(now = Date.now()) {
|
|
|
2619
3044
|
}
|
|
2620
3045
|
}
|
|
2621
3046
|
const hb = executorPath();
|
|
2622
|
-
if (!
|
|
3047
|
+
if (!fs22.existsSync(hb)) {
|
|
2623
3048
|
push("daemon", "warn", "no heartbeat \u2014 daemon not running (`lobstah daemon install`)");
|
|
2624
3049
|
} else {
|
|
2625
3050
|
try {
|
|
2626
|
-
const payload = JSON.parse(
|
|
3051
|
+
const payload = JSON.parse(fs22.readFileSync(hb, "utf8"));
|
|
2627
3052
|
const age = now - new Date(payload.heartbeat ?? 0).getTime();
|
|
2628
3053
|
push(
|
|
2629
3054
|
"daemon",
|
|
@@ -2639,10 +3064,10 @@ function runDoctor(now = Date.now()) {
|
|
|
2639
3064
|
}
|
|
2640
3065
|
|
|
2641
3066
|
// apps/cli/src/service.ts
|
|
2642
|
-
import * as
|
|
3067
|
+
import * as fs23 from "node:fs";
|
|
2643
3068
|
import * as os5 from "node:os";
|
|
2644
|
-
import * as
|
|
2645
|
-
import { spawnSync as
|
|
3069
|
+
import * as path17 from "node:path";
|
|
3070
|
+
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
2646
3071
|
function xml(s) {
|
|
2647
3072
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
2648
3073
|
}
|
|
@@ -2665,8 +3090,8 @@ function renderLaunchdPlist(spec) {
|
|
|
2665
3090
|
</dict>
|
|
2666
3091
|
<key>RunAtLoad</key><true/>
|
|
2667
3092
|
<key>KeepAlive</key><true/>
|
|
2668
|
-
<key>StandardOutPath</key><string>${xml(
|
|
2669
|
-
<key>StandardErrorPath</key><string>${xml(
|
|
3093
|
+
<key>StandardOutPath</key><string>${xml(path17.posix.join(spec.logDir, `${spec.kind}.log`))}</string>
|
|
3094
|
+
<key>StandardErrorPath</key><string>${xml(path17.posix.join(spec.logDir, `${spec.kind}.err`))}</string>
|
|
2670
3095
|
</dict>
|
|
2671
3096
|
</plist>
|
|
2672
3097
|
`;
|
|
@@ -2681,8 +3106,8 @@ Restart=always
|
|
|
2681
3106
|
RestartSec=5
|
|
2682
3107
|
Environment=PATH=${spec.pathEnv}
|
|
2683
3108
|
Environment=LOBSTAH_HOME=${spec.home}
|
|
2684
|
-
StandardOutput=append:${
|
|
2685
|
-
StandardError=append:${
|
|
3109
|
+
StandardOutput=append:${path17.posix.join(spec.logDir, `${spec.kind}.log`)}
|
|
3110
|
+
StandardError=append:${path17.posix.join(spec.logDir, `${spec.kind}.err`)}
|
|
2686
3111
|
|
|
2687
3112
|
[Install]
|
|
2688
3113
|
WantedBy=default.target
|
|
@@ -2690,45 +3115,48 @@ WantedBy=default.target
|
|
|
2690
3115
|
}
|
|
2691
3116
|
function servicePathEnv(nodePath, home = os5.homedir()) {
|
|
2692
3117
|
const dirs = [
|
|
2693
|
-
|
|
2694
|
-
|
|
3118
|
+
path17.posix.dirname(nodePath),
|
|
3119
|
+
path17.posix.join(home, ".local", "bin"),
|
|
2695
3120
|
"/opt/homebrew/bin",
|
|
2696
3121
|
"/usr/local/bin",
|
|
2697
3122
|
"/usr/bin",
|
|
2698
3123
|
"/bin"
|
|
2699
3124
|
];
|
|
2700
|
-
return [...new Set(dirs)].join(
|
|
3125
|
+
return [...new Set(dirs)].join(":");
|
|
2701
3126
|
}
|
|
2702
3127
|
function serviceFile(kind) {
|
|
2703
|
-
return process.platform === "darwin" ?
|
|
3128
|
+
return process.platform === "darwin" ? path17.join(os5.homedir(), "Library", "LaunchAgents", `lobstah.${kind}.plist`) : path17.join(os5.homedir(), ".config", "systemd", "user", `lobstah-${kind}.service`);
|
|
2704
3129
|
}
|
|
2705
3130
|
function run(cmd, args) {
|
|
2706
|
-
const res =
|
|
3131
|
+
const res = spawnSync3(cmd, args, { encoding: "utf8" });
|
|
2707
3132
|
return { ok: res.status === 0, out: `${res.stdout ?? ""}${res.stderr ?? ""}`.trim() };
|
|
2708
3133
|
}
|
|
2709
3134
|
function installService(kind) {
|
|
2710
|
-
|
|
3135
|
+
if (process.platform === "win32") {
|
|
3136
|
+
throw new Error(`no service manager support on Windows \u2014 run \`lobstah ${kind}\` under your process manager of choice`);
|
|
3137
|
+
}
|
|
3138
|
+
const entry = fs23.realpathSync(process.argv[1]);
|
|
2711
3139
|
const spec = {
|
|
2712
3140
|
kind,
|
|
2713
3141
|
nodePath: process.execPath,
|
|
2714
3142
|
entry,
|
|
2715
3143
|
pathEnv: servicePathEnv(process.execPath),
|
|
2716
3144
|
home: lobstahHome(),
|
|
2717
|
-
logDir:
|
|
3145
|
+
logDir: path17.join(lobstahHome(), "logs")
|
|
2718
3146
|
};
|
|
2719
3147
|
if (!onPath("git", spec.pathEnv)) {
|
|
2720
3148
|
throw new Error(`git is not reachable on the service PATH (${spec.pathEnv}) \u2014 the daemon cannot allocate worktrees`);
|
|
2721
3149
|
}
|
|
2722
|
-
|
|
3150
|
+
fs23.mkdirSync(spec.logDir, { recursive: true });
|
|
2723
3151
|
const file = serviceFile(kind);
|
|
2724
|
-
|
|
3152
|
+
fs23.mkdirSync(path17.dirname(file), { recursive: true });
|
|
2725
3153
|
if (process.platform === "darwin") {
|
|
2726
|
-
if (
|
|
2727
|
-
|
|
3154
|
+
if (fs23.existsSync(file)) run("launchctl", ["unload", file]);
|
|
3155
|
+
fs23.writeFileSync(file, renderLaunchdPlist(spec));
|
|
2728
3156
|
const load = run("launchctl", ["load", file]);
|
|
2729
3157
|
return { file, loaded: load.ok, detail: load.ok ? `label lobstah.${kind}` : load.out };
|
|
2730
3158
|
}
|
|
2731
|
-
|
|
3159
|
+
fs23.writeFileSync(file, renderSystemdUnit(spec));
|
|
2732
3160
|
run("systemctl", ["--user", "daemon-reload"]);
|
|
2733
3161
|
const enable = run("systemctl", ["--user", "enable", "--now", `lobstah-${kind}.service`]);
|
|
2734
3162
|
return { file, loaded: enable.ok, detail: enable.ok ? `unit lobstah-${kind}.service` : enable.out };
|
|
@@ -2736,22 +3164,22 @@ function installService(kind) {
|
|
|
2736
3164
|
function uninstallService(kind) {
|
|
2737
3165
|
const file = serviceFile(kind);
|
|
2738
3166
|
if (process.platform === "darwin") {
|
|
2739
|
-
if (
|
|
3167
|
+
if (fs23.existsSync(file)) run("launchctl", ["unload", file]);
|
|
2740
3168
|
} else {
|
|
2741
3169
|
run("systemctl", ["--user", "disable", "--now", `lobstah-${kind}.service`]);
|
|
2742
3170
|
}
|
|
2743
|
-
const removed =
|
|
2744
|
-
if (removed)
|
|
3171
|
+
const removed = fs23.existsSync(file);
|
|
3172
|
+
if (removed) fs23.unlinkSync(file);
|
|
2745
3173
|
if (process.platform !== "darwin") run("systemctl", ["--user", "daemon-reload"]);
|
|
2746
3174
|
return { file, removed };
|
|
2747
3175
|
}
|
|
2748
3176
|
|
|
2749
3177
|
// apps/cli/src/repos.ts
|
|
2750
|
-
import * as
|
|
2751
|
-
import * as
|
|
2752
|
-
import { spawnSync as
|
|
3178
|
+
import * as fs24 from "node:fs";
|
|
3179
|
+
import * as path18 from "node:path";
|
|
3180
|
+
import { spawnSync as spawnSync4 } from "node:child_process";
|
|
2753
3181
|
function git2(dir, ...args) {
|
|
2754
|
-
const res =
|
|
3182
|
+
const res = spawnSync4("git", ["-C", dir, ...args], { encoding: "utf8" });
|
|
2755
3183
|
return res.status === 0 ? res.stdout.trim() : void 0;
|
|
2756
3184
|
}
|
|
2757
3185
|
var LOCKFILE_SETUP = [
|
|
@@ -2764,37 +3192,41 @@ var LOCKFILE_SETUP = [
|
|
|
2764
3192
|
["poetry.lock", ["poetry install"]]
|
|
2765
3193
|
];
|
|
2766
3194
|
function detectRepo(dir) {
|
|
2767
|
-
const abs =
|
|
3195
|
+
const abs = path18.resolve(dir);
|
|
2768
3196
|
if (git2(abs, "rev-parse", "--is-inside-work-tree") !== "true") return void 0;
|
|
2769
3197
|
const top = git2(abs, "rev-parse", "--show-toplevel");
|
|
2770
|
-
|
|
3198
|
+
const canon = (p) => {
|
|
3199
|
+
const real = path18.resolve(fs24.realpathSync.native(p));
|
|
3200
|
+
return process.platform === "win32" ? real.toLowerCase() : real;
|
|
3201
|
+
};
|
|
3202
|
+
if (!top || canon(top) !== canon(abs)) return void 0;
|
|
2771
3203
|
const origin = git2(abs, "remote", "get-url", "origin");
|
|
2772
3204
|
const headRef = git2(abs, "symbolic-ref", "--short", "refs/remotes/origin/HEAD");
|
|
2773
3205
|
const trunk = headRef?.replace(/^origin\//, "") ?? git2(abs, "branch", "--show-current") ?? "main";
|
|
2774
|
-
const setup = LOCKFILE_SETUP.find(([f]) =>
|
|
2775
|
-
return { key:
|
|
3206
|
+
const setup = LOCKFILE_SETUP.find(([f]) => fs24.existsSync(path18.join(abs, f)))?.[1];
|
|
3207
|
+
return { key: path18.basename(abs), path: abs, trunk, origin, setup };
|
|
2776
3208
|
}
|
|
2777
3209
|
function scanForRepos(roots) {
|
|
2778
3210
|
const found = [];
|
|
2779
3211
|
for (const root of roots) {
|
|
2780
|
-
const abs =
|
|
2781
|
-
if (!
|
|
3212
|
+
const abs = path18.resolve(root);
|
|
3213
|
+
if (!fs24.existsSync(abs)) continue;
|
|
2782
3214
|
const self = detectRepo(abs);
|
|
2783
3215
|
if (self) {
|
|
2784
3216
|
found.push(self);
|
|
2785
3217
|
continue;
|
|
2786
3218
|
}
|
|
2787
|
-
for (const entry of
|
|
3219
|
+
for (const entry of fs24.readdirSync(abs, { withFileTypes: true })) {
|
|
2788
3220
|
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
2789
|
-
const child = detectRepo(
|
|
3221
|
+
const child = detectRepo(path18.join(abs, entry.name));
|
|
2790
3222
|
if (child) found.push(child);
|
|
2791
3223
|
}
|
|
2792
3224
|
}
|
|
2793
3225
|
return found;
|
|
2794
3226
|
}
|
|
2795
3227
|
function configuredRepoKeys(file = configPath()) {
|
|
2796
|
-
if (!
|
|
2797
|
-
const raw = parse(
|
|
3228
|
+
if (!fs24.existsSync(file)) return /* @__PURE__ */ new Set();
|
|
3229
|
+
const raw = parse(fs24.readFileSync(file, "utf8"));
|
|
2798
3230
|
return new Set(Object.keys(raw.repos ?? {}));
|
|
2799
3231
|
}
|
|
2800
3232
|
function appendRepoBlock(repo, opts = {}, file = configPath()) {
|
|
@@ -2802,10 +3234,18 @@ function appendRepoBlock(repo, opts = {}, file = configPath()) {
|
|
|
2802
3234
|
if (repo.origin) lines.push(`origin = ${JSON.stringify(repo.origin)}`);
|
|
2803
3235
|
if (repo.setup) lines.push(`setup = [${repo.setup.map((s) => JSON.stringify(s)).join(", ")}]`);
|
|
2804
3236
|
if (opts.pickup) lines.push(`pickup = true`);
|
|
2805
|
-
|
|
3237
|
+
fs24.appendFileSync(file, `${lines.join("\n")}
|
|
2806
3238
|
`);
|
|
2807
3239
|
}
|
|
2808
3240
|
|
|
3241
|
+
// apps/cli/src/report-args.ts
|
|
3242
|
+
function parseReportArgs(rest) {
|
|
3243
|
+
const prIndex = rest.indexOf("--pr");
|
|
3244
|
+
const prUrl = prIndex >= 0 ? rest[prIndex + 1] : void 0;
|
|
3245
|
+
const note = rest.filter((_, i) => prIndex < 0 || i !== prIndex && i !== prIndex + 1).join(" ") || void 0;
|
|
3246
|
+
return { note, prUrl };
|
|
3247
|
+
}
|
|
3248
|
+
|
|
2809
3249
|
// apps/cli/src/main.ts
|
|
2810
3250
|
var HELP = `lobstah \u2014 supervision framework for coding agents
|
|
2811
3251
|
|
|
@@ -2833,6 +3273,16 @@ work (humans and agents):
|
|
|
2833
3273
|
entries, orphaned worktrees, stale state.
|
|
2834
3274
|
Dry run by default (14 days).
|
|
2835
3275
|
cancel <uuid> request cancellation
|
|
3276
|
+
watch [add <key> --check <cmd> [--for <uuid>] [--cursor <c>] [--every <s>]
|
|
3277
|
+
[--brief <template>] [--stream <cmd>] | rm <key>]
|
|
3278
|
+
stand watch on something external: the check
|
|
3279
|
+
command answers "anything since {cursor}?"
|
|
3280
|
+
in JSON. Events wake man wait/haul (default)
|
|
3281
|
+
or fork a continuation of --for's dispatch
|
|
3282
|
+
chain. --stream holds a long-lived NDJSON
|
|
3283
|
+
child under pick for ms-latency delivery
|
|
3284
|
+
(the check stays the guarantee). Bare
|
|
3285
|
+
\`watch\` lists.
|
|
2836
3286
|
|
|
2837
3287
|
host processes:
|
|
2838
3288
|
daemon [--interval <ms>] the supervisor: claims, worktrees, liveness,
|
|
@@ -2850,10 +3300,12 @@ lobsterman (orchestrator sessions \u2014 bare \`lobstah man\` prints the manual)
|
|
|
2850
3300
|
PR, and merge-gate status from pick's last
|
|
2851
3301
|
observation. Pure disk read \u2014 no forge calls.
|
|
2852
3302
|
man wait [--timeout <secs>] [--peek]
|
|
2853
|
-
block until a dispatch
|
|
2854
|
-
print the event and
|
|
2855
|
-
on timeout.
|
|
2856
|
-
|
|
3303
|
+
block until a dispatch or watched source
|
|
3304
|
+
needs attention, then print the event and
|
|
3305
|
+
what to do next; exit 2 on timeout. Runs due
|
|
3306
|
+
watch checks itself when no pick process is.
|
|
3307
|
+
Unanswered questions re-fire every
|
|
3308
|
+
remindSecs until answered.
|
|
2857
3309
|
man init [--shared|--global] [--marker]
|
|
2858
3310
|
install the haul Stop hook: this project's
|
|
2859
3311
|
.claude/settings.local.json by default,
|
|
@@ -2890,10 +3342,26 @@ function arg(args, flag) {
|
|
|
2890
3342
|
const i = args.indexOf(flag);
|
|
2891
3343
|
return i >= 0 ? args[i + 1] : void 0;
|
|
2892
3344
|
}
|
|
3345
|
+
var WATCH_EVERY_SECS = 45;
|
|
3346
|
+
function runDueManWatches() {
|
|
3347
|
+
for (const w of listWatches()) {
|
|
3348
|
+
if (w.owner === "man" && watchDue(w, WATCH_EVERY_SECS)) runWatchCheck(w);
|
|
3349
|
+
}
|
|
3350
|
+
}
|
|
3351
|
+
function emitWatchAttention(attns) {
|
|
3352
|
+
for (const a of attns) {
|
|
3353
|
+
for (const e of a.events) {
|
|
3354
|
+
console.log(toonKV({ watch: a.watch.key, seq: e.seq, summary: e.summary }));
|
|
3355
|
+
}
|
|
3356
|
+
}
|
|
3357
|
+
console.log(
|
|
3358
|
+
"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`."
|
|
3359
|
+
);
|
|
3360
|
+
}
|
|
2893
3361
|
function findLane(id) {
|
|
2894
3362
|
for (const lane of ["work", "chore"]) {
|
|
2895
3363
|
const d = laneDirs(lane);
|
|
2896
|
-
if (
|
|
3364
|
+
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
3365
|
return lane;
|
|
2898
3366
|
}
|
|
2899
3367
|
}
|
|
@@ -2901,7 +3369,7 @@ function findLane(id) {
|
|
|
2901
3369
|
}
|
|
2902
3370
|
function rowsFor(lane, bucket) {
|
|
2903
3371
|
const dir = laneDirs(lane)[bucket];
|
|
2904
|
-
const entries =
|
|
3372
|
+
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
3373
|
const sliced = bucket === "done" ? entries.slice(0, 10) : entries;
|
|
2906
3374
|
return sliced.map(({ f, m }) => {
|
|
2907
3375
|
const id = f.replace(/\.json$/, "");
|
|
@@ -2927,9 +3395,9 @@ async function mainCli() {
|
|
|
2927
3395
|
throw new Error("dispatch requires --repo and --brief <file> (or --brief-text)");
|
|
2928
3396
|
}
|
|
2929
3397
|
const d = {
|
|
2930
|
-
id: arg(args, "--id") ??
|
|
3398
|
+
id: arg(args, "--id") ?? randomUUID4(),
|
|
2931
3399
|
repo,
|
|
2932
|
-
brief: briefText ??
|
|
3400
|
+
brief: briefText ?? fs25.readFileSync(briefFile, "utf8"),
|
|
2933
3401
|
harness: arg(args, "--harness"),
|
|
2934
3402
|
model: arg(args, "--model"),
|
|
2935
3403
|
effort: arg(args, "--effort"),
|
|
@@ -2966,17 +3434,17 @@ async function mainCli() {
|
|
|
2966
3434
|
if (!id) throw new Error("logs requires a dispatch id");
|
|
2967
3435
|
const lane = findLane(id);
|
|
2968
3436
|
const file = eventsPath(id, lane);
|
|
2969
|
-
if (
|
|
3437
|
+
if (fs25.existsSync(file)) process.stdout.write(fs25.readFileSync(file, "utf8"));
|
|
2970
3438
|
if (args.includes("--follow")) {
|
|
2971
|
-
let size =
|
|
3439
|
+
let size = fs25.existsSync(file) ? fs25.statSync(file).size : 0;
|
|
2972
3440
|
setInterval(() => {
|
|
2973
|
-
if (!
|
|
2974
|
-
const now =
|
|
3441
|
+
if (!fs25.existsSync(file)) return;
|
|
3442
|
+
const now = fs25.statSync(file).size;
|
|
2975
3443
|
if (now > size) {
|
|
2976
|
-
const fd =
|
|
3444
|
+
const fd = fs25.openSync(file, "r");
|
|
2977
3445
|
const buf = Buffer.alloc(now - size);
|
|
2978
|
-
|
|
2979
|
-
|
|
3446
|
+
fs25.readSync(fd, buf, 0, buf.length, size);
|
|
3447
|
+
fs25.closeSync(fd);
|
|
2980
3448
|
process.stdout.write(buf.toString("utf8"));
|
|
2981
3449
|
size = now;
|
|
2982
3450
|
}
|
|
@@ -2997,9 +3465,7 @@ async function mainCli() {
|
|
|
2997
3465
|
const [id, verb, ...rest] = args;
|
|
2998
3466
|
if (!id || !verb) throw new Error(`report requires an id and a verb (${VERBS.join("|")})`);
|
|
2999
3467
|
const lane = findLane(id);
|
|
3000
|
-
const
|
|
3001
|
-
const prUrl = prIndex >= 0 ? rest[prIndex + 1] : void 0;
|
|
3002
|
-
const note = rest.filter((_, i) => i !== prIndex && i !== prIndex + 1).join(" ") || void 0;
|
|
3468
|
+
const { note, prUrl } = parseReportArgs(rest);
|
|
3003
3469
|
const entry = appendStatus(id, lane, verb, note);
|
|
3004
3470
|
if (prUrl) mergeEvidence(id, lane, { prUrl });
|
|
3005
3471
|
console.log(toonKV({ id, verb: entry.verb, at: entry.at, ...prUrl ? { prUrl } : {} }));
|
|
@@ -3032,23 +3498,23 @@ async function mainCli() {
|
|
|
3032
3498
|
);
|
|
3033
3499
|
}
|
|
3034
3500
|
const dirs = laneDirs(lane);
|
|
3035
|
-
const descriptorFile = ["active", "done"].map((b) =>
|
|
3036
|
-
const harness = descriptorFile ? JSON.parse(
|
|
3501
|
+
const descriptorFile = ["active", "done"].map((b) => path19.join(dirs[b], id, "descriptor.json")).find((f) => fs25.existsSync(f));
|
|
3502
|
+
const harness = descriptorFile ? JSON.parse(fs25.readFileSync(descriptorFile, "utf8")).harness ?? "claude" : "claude";
|
|
3037
3503
|
let sessionId;
|
|
3038
3504
|
try {
|
|
3039
|
-
sessionId = JSON.parse(
|
|
3505
|
+
sessionId = JSON.parse(fs25.readFileSync(path19.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
|
|
3040
3506
|
} catch {
|
|
3041
3507
|
}
|
|
3042
3508
|
if (!sessionId) throw new Error(`${id} has no recorded harness session to attach to`);
|
|
3043
|
-
const worktree =
|
|
3044
|
-
const cwd =
|
|
3509
|
+
const worktree = path19.join(lobstahHome(), "worktrees", id);
|
|
3510
|
+
const cwd = fs25.existsSync(worktree) ? worktree : process.cwd();
|
|
3045
3511
|
const invocation = harness === "codex" ? codexInvocation(["resume", sessionId]) : { file: "claude", argv: ["--resume", sessionId] };
|
|
3046
3512
|
if (!invocation) throw new Error("no codex CLI found \u2014 neither on PATH nor vendored by @openai/codex-sdk");
|
|
3047
3513
|
if (args.includes("--print")) {
|
|
3048
3514
|
console.log(toonKV({ id, harness, sessionId, cwd, command: `${invocation.file} ${invocation.argv.join(" ")}` }));
|
|
3049
3515
|
break;
|
|
3050
3516
|
}
|
|
3051
|
-
const res =
|
|
3517
|
+
const res = spawnSync5(invocation.file, invocation.argv, { cwd, stdio: "inherit" });
|
|
3052
3518
|
if (res.error) throw new Error(`could not launch ${invocation.file}: ${res.error.message}`);
|
|
3053
3519
|
break;
|
|
3054
3520
|
}
|
|
@@ -3056,20 +3522,20 @@ async function mainCli() {
|
|
|
3056
3522
|
const id = args[0];
|
|
3057
3523
|
if (!id) throw new Error("swap requires a dispatch id");
|
|
3058
3524
|
const lane = findLane(id);
|
|
3059
|
-
const activeDir =
|
|
3060
|
-
if (!
|
|
3061
|
-
const descFile =
|
|
3062
|
-
const descriptor = JSON.parse(
|
|
3525
|
+
const activeDir = path19.join(laneDirs(lane).active, id);
|
|
3526
|
+
if (!fs25.existsSync(activeDir)) throw new Error(`${id} is not active \u2014 swap only applies to in-flight dispatches`);
|
|
3527
|
+
const descFile = path19.join(activeDir, "descriptor.json");
|
|
3528
|
+
const descriptor = JSON.parse(fs25.readFileSync(descFile, "utf8"));
|
|
3063
3529
|
const fromHarness = descriptor.harness ?? "default";
|
|
3064
3530
|
for (const key of ["harness", "model", "effort"]) {
|
|
3065
3531
|
const v = arg(args, `--${key}`);
|
|
3066
3532
|
if (v) descriptor[key] = v;
|
|
3067
3533
|
}
|
|
3068
|
-
|
|
3069
|
-
const worktree =
|
|
3534
|
+
fs25.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
|
|
3535
|
+
const worktree = path19.join(lobstahHome(), "worktrees", id);
|
|
3070
3536
|
let progress = "No worktree progress recorded.";
|
|
3071
|
-
if (
|
|
3072
|
-
const git3 = (...a) =>
|
|
3537
|
+
if (fs25.existsSync(worktree)) {
|
|
3538
|
+
const git3 = (...a) => spawnSync5("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
|
|
3073
3539
|
const commits = git3("log", "--oneline", "-15");
|
|
3074
3540
|
const status = git3("status", "--short");
|
|
3075
3541
|
progress = `Commits so far:
|
|
@@ -3078,17 +3544,17 @@ ${commits || "(none)"}
|
|
|
3078
3544
|
Uncommitted changes:
|
|
3079
3545
|
${status || "(clean)"}`;
|
|
3080
3546
|
}
|
|
3081
|
-
|
|
3082
|
-
|
|
3547
|
+
fs25.writeFileSync(
|
|
3548
|
+
path19.join(activeDir, "handoff"),
|
|
3083
3549
|
`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
3550
|
|
|
3085
3551
|
${progress}`
|
|
3086
3552
|
);
|
|
3087
|
-
const runnerFile =
|
|
3088
|
-
if (
|
|
3089
|
-
const runner = JSON.parse(
|
|
3553
|
+
const runnerFile = path19.join(activeDir, "runner.json");
|
|
3554
|
+
if (fs25.existsSync(runnerFile)) {
|
|
3555
|
+
const runner = JSON.parse(fs25.readFileSync(runnerFile, "utf8"));
|
|
3090
3556
|
if (pidAlive(runner.pid, runner.processStartTime)) killGroup(runner.pid, "SIGKILL");
|
|
3091
|
-
|
|
3557
|
+
fs25.unlinkSync(runnerFile);
|
|
3092
3558
|
}
|
|
3093
3559
|
appendStatus(id, lane, "working", `swapped to ${descriptor.harness ?? "default"} \u2014 awaiting respawn`);
|
|
3094
3560
|
console.log(toonKV({ id, harness: descriptor.harness ?? "default", model: descriptor.model, swapped: true }));
|
|
@@ -3164,9 +3630,13 @@ ${progress}`
|
|
|
3164
3630
|
);
|
|
3165
3631
|
};
|
|
3166
3632
|
const remindMs = (loadConfig().remindSecs ?? 900) * 1e3;
|
|
3167
|
-
const
|
|
3168
|
-
|
|
3169
|
-
|
|
3633
|
+
const consume = !args.includes("--peek");
|
|
3634
|
+
runDueManWatches();
|
|
3635
|
+
const standing = attentionNow(consume, remindMs);
|
|
3636
|
+
const standingWatches = pendingWatchEvents(consume);
|
|
3637
|
+
if (standing.length > 0 || standingWatches.length > 0) {
|
|
3638
|
+
if (standing.length > 0) emit(standing);
|
|
3639
|
+
if (standingWatches.length > 0) emitWatchAttention(standingWatches);
|
|
3170
3640
|
break;
|
|
3171
3641
|
}
|
|
3172
3642
|
const baseline = captureWaitBaseline();
|
|
@@ -3177,26 +3647,32 @@ ${progress}`
|
|
|
3177
3647
|
emit(fresh);
|
|
3178
3648
|
return;
|
|
3179
3649
|
}
|
|
3650
|
+
runDueManWatches();
|
|
3651
|
+
const watched = pendingWatchEvents(true);
|
|
3652
|
+
if (watched.length > 0) {
|
|
3653
|
+
emitWatchAttention(watched);
|
|
3654
|
+
return;
|
|
3655
|
+
}
|
|
3180
3656
|
}
|
|
3181
3657
|
console.log(toonKV({ timeout: true, waitedSecs: timeoutSecs }));
|
|
3182
3658
|
process.exitCode = 2;
|
|
3183
3659
|
break;
|
|
3184
3660
|
}
|
|
3185
3661
|
case "man:init": {
|
|
3186
|
-
const file = args.includes("--global") ?
|
|
3662
|
+
const file = args.includes("--global") ? path19.join(os6.homedir(), ".claude", "settings.json") : path19.join(".claude", args.includes("--shared") ? "settings.json" : "settings.local.json");
|
|
3187
3663
|
let existing;
|
|
3188
3664
|
try {
|
|
3189
|
-
existing = JSON.parse(
|
|
3665
|
+
existing = JSON.parse(fs25.readFileSync(file, "utf8"));
|
|
3190
3666
|
} catch {
|
|
3191
3667
|
existing = void 0;
|
|
3192
3668
|
}
|
|
3193
3669
|
const { settings, changed } = mergeHaulHook(existing);
|
|
3194
3670
|
if (changed) {
|
|
3195
|
-
|
|
3196
|
-
|
|
3671
|
+
fs25.mkdirSync(path19.dirname(file), { recursive: true });
|
|
3672
|
+
fs25.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
|
|
3197
3673
|
`);
|
|
3198
3674
|
}
|
|
3199
|
-
if (args.includes("--marker"))
|
|
3675
|
+
if (args.includes("--marker")) fs25.writeFileSync(".lobstah-man", "");
|
|
3200
3676
|
console.log(
|
|
3201
3677
|
toonKV({
|
|
3202
3678
|
hook: "lobstah man haul",
|
|
@@ -3209,32 +3685,41 @@ ${progress}`
|
|
|
3209
3685
|
}
|
|
3210
3686
|
case "man:haul": {
|
|
3211
3687
|
try {
|
|
3212
|
-
if (process.env.LOBSTAH_MAN !== "1" && !
|
|
3688
|
+
if (process.env.LOBSTAH_MAN !== "1" && !fs25.existsSync(".lobstah-man")) break;
|
|
3213
3689
|
const anyActive = ["work", "chore"].some(
|
|
3214
|
-
(l) =>
|
|
3690
|
+
(l) => fs25.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
|
|
3215
3691
|
);
|
|
3216
|
-
|
|
3692
|
+
const anyWatch = listWatches().some((w) => w.owner === "man");
|
|
3693
|
+
if (!anyActive && !anyWatch) break;
|
|
3217
3694
|
const timeoutSecs = Number(arg(args, "--timeout") ?? "14000");
|
|
3218
3695
|
const deadline = Date.now() + timeoutSecs * 1e3;
|
|
3219
3696
|
const remindMs = (loadConfig().remindSecs ?? 900) * 1e3;
|
|
3697
|
+
runDueManWatches();
|
|
3220
3698
|
let evs = attentionNow(true, remindMs);
|
|
3221
|
-
|
|
3699
|
+
let watched = pendingWatchEvents(true);
|
|
3700
|
+
if (evs.length === 0 && watched.length === 0) {
|
|
3222
3701
|
const baseline = captureWaitBaseline();
|
|
3223
3702
|
while (Date.now() < deadline) {
|
|
3224
3703
|
await new Promise((r) => setTimeout(r, 1500));
|
|
3225
3704
|
evs = freshWakeEvents(baseline);
|
|
3226
3705
|
if (evs.length === 0) evs = attentionNow(true, remindMs);
|
|
3227
|
-
|
|
3706
|
+
runDueManWatches();
|
|
3707
|
+
watched = pendingWatchEvents(true);
|
|
3708
|
+
if (evs.length > 0 || watched.length > 0) break;
|
|
3228
3709
|
}
|
|
3229
3710
|
}
|
|
3230
|
-
if (evs.length === 0) break;
|
|
3231
|
-
const lines =
|
|
3232
|
-
(ev) => `- ${ev.entry.verb} ${ev.id}${ev.entry.note ? ` \u2014 ${ev.entry.note}` : ""}`
|
|
3233
|
-
|
|
3711
|
+
if (evs.length === 0 && watched.length === 0) break;
|
|
3712
|
+
const lines = [
|
|
3713
|
+
...evs.map((ev) => `- ${ev.entry.verb} ${ev.id}${ev.entry.note ? ` \u2014 ${ev.entry.note}` : ""}`),
|
|
3714
|
+
...watched.flatMap(
|
|
3715
|
+
(a) => a.events.map((e) => `- watch ${a.watch.key}${e.summary ? ` \u2014 ${e.summary}` : ` (seq ${e.seq})`}`)
|
|
3716
|
+
)
|
|
3717
|
+
];
|
|
3234
3718
|
const reason = [
|
|
3235
|
-
"A lobstah dispatch needs attention:",
|
|
3719
|
+
"A lobstah dispatch or watched source needs attention:",
|
|
3236
3720
|
...lines,
|
|
3237
|
-
'Check with `lobstah status <id>`; answer a needs-decision with `lobstah send <id> "<answer>"`.',
|
|
3721
|
+
'Check a dispatch with `lobstah status <id>`; answer a needs-decision with `lobstah send <id> "<answer>"`.',
|
|
3722
|
+
"A watch line means an external source updated (e.g. a review round) \u2014 handle it directly.",
|
|
3238
3723
|
"Handle it now. This session re-parks automatically at turn end \u2014 do not arm any watcher."
|
|
3239
3724
|
].join("\n");
|
|
3240
3725
|
console.log(JSON.stringify({ decision: "block", reason }));
|
|
@@ -3283,7 +3768,7 @@ ${progress}`
|
|
|
3283
3768
|
path: r.path,
|
|
3284
3769
|
trunk: r.trunk,
|
|
3285
3770
|
pickup: r.pickup ?? false,
|
|
3286
|
-
exists:
|
|
3771
|
+
exists: fs25.existsSync(r.path)
|
|
3287
3772
|
}));
|
|
3288
3773
|
console.log(toonTable("repos", rows, ["key", "path", "trunk", "pickup", "exists"]));
|
|
3289
3774
|
break;
|
|
@@ -3292,8 +3777,43 @@ ${progress}`
|
|
|
3292
3777
|
console.log(lobstahVersion());
|
|
3293
3778
|
break;
|
|
3294
3779
|
}
|
|
3780
|
+
case "watch": {
|
|
3781
|
+
const sub = args[0];
|
|
3782
|
+
if (sub === "add") {
|
|
3783
|
+
const key = args[1];
|
|
3784
|
+
const check = arg(args, "--check");
|
|
3785
|
+
if (!key || key.startsWith("--") || !check) throw new Error("watch add requires a key and --check <command>");
|
|
3786
|
+
const forId = arg(args, "--for");
|
|
3787
|
+
const every = arg(args, "--every");
|
|
3788
|
+
const w = addWatch(key, check, {
|
|
3789
|
+
owner: forId ? `dispatch:${forId}` : "man",
|
|
3790
|
+
cursor: arg(args, "--cursor"),
|
|
3791
|
+
everySecs: every ? Number(every) : void 0,
|
|
3792
|
+
brief: arg(args, "--brief"),
|
|
3793
|
+
stream: arg(args, "--stream")
|
|
3794
|
+
});
|
|
3795
|
+
console.log(toonKV({ key: w.key, owner: w.owner, cursor: w.cursor, registered: true }));
|
|
3796
|
+
break;
|
|
3797
|
+
}
|
|
3798
|
+
if (sub === "rm") {
|
|
3799
|
+
const key = args[1];
|
|
3800
|
+
if (!key) throw new Error("watch rm requires a key");
|
|
3801
|
+
console.log(toonKV({ key, removed: removeWatch(key) }));
|
|
3802
|
+
break;
|
|
3803
|
+
}
|
|
3804
|
+
const rows = listWatches().map((w) => ({
|
|
3805
|
+
key: w.key,
|
|
3806
|
+
owner: w.owner,
|
|
3807
|
+
cursor: w.cursor,
|
|
3808
|
+
pending: readWatchEvents(w.key).length - w.seen,
|
|
3809
|
+
lastChecked: w.lastCheckedAt ?? "-",
|
|
3810
|
+
error: w.lastError ?? ""
|
|
3811
|
+
}));
|
|
3812
|
+
console.log(toonTable("watches", rows, ["key", "owner", "cursor", "pending", "lastChecked", "error"]));
|
|
3813
|
+
break;
|
|
3814
|
+
}
|
|
3295
3815
|
case "init": {
|
|
3296
|
-
if (!
|
|
3816
|
+
if (!fs25.existsSync(configPath())) {
|
|
3297
3817
|
const exampleRepo = args.includes("--scan") ? "" : `[repos.example]
|
|
3298
3818
|
path = "~/src/example"
|
|
3299
3819
|
trunk = "main"
|
|
@@ -3302,7 +3822,7 @@ trunk = "main"
|
|
|
3302
3822
|
# pickup = true # opt into [pickup.github] multi-repo tracker pickup
|
|
3303
3823
|
|
|
3304
3824
|
`;
|
|
3305
|
-
|
|
3825
|
+
fs25.writeFileSync(
|
|
3306
3826
|
configPath(),
|
|
3307
3827
|
`# lobstah workspace definitions \u2014 the descriptor's repo key resolves here.
|
|
3308
3828
|
# Top-level keys must come before any [section].
|
|
@@ -3340,12 +3860,12 @@ wallClockSecs = 3600
|
|
|
3340
3860
|
let reference = "https://github.com/aequitas-labs/lobstah/blob/main/docs/configuration.md";
|
|
3341
3861
|
try {
|
|
3342
3862
|
const local = fileURLToPath2(new URL("../docs/configuration.md", import.meta.url));
|
|
3343
|
-
if (
|
|
3863
|
+
if (fs25.existsSync(local)) reference = local;
|
|
3344
3864
|
} catch {
|
|
3345
3865
|
}
|
|
3346
3866
|
console.log(
|
|
3347
3867
|
toonKV({
|
|
3348
|
-
home:
|
|
3868
|
+
home: path19.dirname(configPath()),
|
|
3349
3869
|
config: configPath(),
|
|
3350
3870
|
...added.length > 0 ? { added: added.join(", ") } : {},
|
|
3351
3871
|
...unmarked.length > 0 ? { note: `none marked pickable \u2014 set pickup = true per [repos.*] (or rerun with --pickup)` } : {},
|