lobstah 0.2.0 → 0.3.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/README.md +16 -5
- package/dist/main.js +866 -226
- package/dist/runner.js +5 -0
- package/docs/assets/lob-star.png +0 -0
- package/docs/assets/social-preview.png +0 -0
- package/docs/configuration.md +7 -0
- package/docs/lobsterman.md +38 -8
- package/docs/vocabulary.md +36 -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 fs27 from "node:fs";
|
|
5
5
|
import * as os6 from "node:os";
|
|
6
|
-
import * as
|
|
6
|
+
import * as path21 from "node:path";
|
|
7
7
|
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
8
|
-
import { spawnSync as
|
|
8
|
+
import { spawnSync as spawnSync6 } from "node:child_process";
|
|
9
9
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
10
10
|
|
|
11
11
|
// packages/core/dist/types.js
|
|
@@ -39,6 +39,10 @@ function ensureLayout() {
|
|
|
39
39
|
}
|
|
40
40
|
fs.mkdirSync(path.join(lobstahHome(), "worktrees"), { recursive: true });
|
|
41
41
|
fs.mkdirSync(path.join(lobstahHome(), "watches"), { recursive: true });
|
|
42
|
+
fs.mkdirSync(path.join(lobstahHome(), "soaking"), { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
function soakingDir() {
|
|
45
|
+
return path.join(lobstahHome(), "soaking");
|
|
42
46
|
}
|
|
43
47
|
function statusPath(id, lane) {
|
|
44
48
|
return path.join(laneDirs(lane).state, `${id}.status`);
|
|
@@ -71,9 +75,21 @@ function pendingIds(lane) {
|
|
|
71
75
|
const dir = laneDirs(lane).queue;
|
|
72
76
|
return fs2.readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => ({ f, m: fs2.statSync(path2.join(dir, f)).mtimeMs })).sort((a, b) => a.m - b.m).map(({ f }) => f.slice(0, -".json".length));
|
|
73
77
|
}
|
|
74
|
-
function
|
|
78
|
+
function queuedDescriptor(id, lane) {
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(fs2.readFileSync(path2.join(laneDirs(lane).queue, `${id}.json`), "utf8"));
|
|
81
|
+
} catch {
|
|
82
|
+
return void 0;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function claimNext(lane, skip) {
|
|
75
86
|
const dirs = laneDirs(lane);
|
|
76
87
|
for (const id of pendingIds(lane)) {
|
|
88
|
+
if (skip) {
|
|
89
|
+
const d = queuedDescriptor(id, lane);
|
|
90
|
+
if (!d || skip(d))
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
77
93
|
const activeDir = path2.join(dirs.active, id);
|
|
78
94
|
try {
|
|
79
95
|
fs2.mkdirSync(activeDir);
|
|
@@ -90,9 +106,19 @@ function claimNext(lane) {
|
|
|
90
106
|
}
|
|
91
107
|
return null;
|
|
92
108
|
}
|
|
109
|
+
function requeue(id, lane) {
|
|
110
|
+
const dirs = laneDirs(lane);
|
|
111
|
+
const activeDir = path2.join(dirs.active, id);
|
|
112
|
+
fs2.renameSync(path2.join(activeDir, "descriptor.json"), path2.join(dirs.queue, `${id}.json`));
|
|
113
|
+
fs2.rmSync(activeDir, { recursive: true, force: true });
|
|
114
|
+
}
|
|
93
115
|
function activeIds(lane) {
|
|
94
116
|
return fs2.readdirSync(laneDirs(lane).active).filter((f) => !f.startsWith("."));
|
|
95
117
|
}
|
|
118
|
+
function complete(id, lane) {
|
|
119
|
+
const dirs = laneDirs(lane);
|
|
120
|
+
fs2.renameSync(path2.join(dirs.active, id), path2.join(dirs.done, id));
|
|
121
|
+
}
|
|
96
122
|
function requestCancel(id, lane) {
|
|
97
123
|
fs2.writeFileSync(path2.join(laneDirs(lane).active, id, "cancel"), (/* @__PURE__ */ new Date()).toISOString());
|
|
98
124
|
}
|
|
@@ -845,6 +871,10 @@ function parse(toml, { maxDepth = 1e3, integersAsBigInt } = {}) {
|
|
|
845
871
|
}
|
|
846
872
|
|
|
847
873
|
// packages/core/dist/config.js
|
|
874
|
+
var DEFAULT_SOAK = {
|
|
875
|
+
deferSecs: 90,
|
|
876
|
+
ttlSecs: 1800
|
|
877
|
+
};
|
|
848
878
|
var DEFAULT_LIMITS = {
|
|
849
879
|
maxConcurrent: 2,
|
|
850
880
|
choreConcurrent: 1,
|
|
@@ -879,6 +909,7 @@ function loadConfig() {
|
|
|
879
909
|
repos,
|
|
880
910
|
harness: raw.harness ?? {},
|
|
881
911
|
limits: { ...DEFAULT_LIMITS, ...raw.limits ?? {} },
|
|
912
|
+
soak: { ...DEFAULT_SOAK, ...raw.soak ?? {} },
|
|
882
913
|
notifyCommand: raw.notifyCommand ? String(raw.notifyCommand) : void 0,
|
|
883
914
|
notifyVerbs: Array.isArray(raw.notifyVerbs) ? raw.notifyVerbs.map(String) : void 0,
|
|
884
915
|
remindSecs: raw.remindSecs !== void 0 ? Number(raw.remindSecs) : void 0
|
|
@@ -901,6 +932,9 @@ function toonTable(name, rows, cols) {
|
|
|
901
932
|
return header;
|
|
902
933
|
return [header, ...rows.map((r) => ` ${cols.map((c) => cell(r[c])).join(",")}`)].join("\n");
|
|
903
934
|
}
|
|
935
|
+
function toonHelp(lines) {
|
|
936
|
+
return [`help[${lines.length}]:`, ...lines.map((l) => ` ${l}`)].join("\n");
|
|
937
|
+
}
|
|
904
938
|
|
|
905
939
|
// packages/core/dist/version.js
|
|
906
940
|
import * as fs8 from "node:fs";
|
|
@@ -937,31 +971,42 @@ function onPath(bin, pathVar = process.env.PATH) {
|
|
|
937
971
|
}
|
|
938
972
|
return false;
|
|
939
973
|
}
|
|
940
|
-
function
|
|
974
|
+
function packagePresent(specifier, fromFile = import.meta.url) {
|
|
975
|
+
const require_2 = createRequire(fromFile);
|
|
941
976
|
try {
|
|
942
|
-
|
|
977
|
+
require_2.resolve(specifier);
|
|
943
978
|
return true;
|
|
944
979
|
} catch {
|
|
945
|
-
return false;
|
|
946
980
|
}
|
|
981
|
+
for (const dir of require_2.resolve.paths(specifier) ?? []) {
|
|
982
|
+
if (fs9.existsSync(path5.join(dir, specifier, "package.json")))
|
|
983
|
+
return true;
|
|
984
|
+
}
|
|
985
|
+
return false;
|
|
947
986
|
}
|
|
948
987
|
function detectHarnesses() {
|
|
949
988
|
const out = [];
|
|
950
|
-
if (onPath("claude") ||
|
|
989
|
+
if (onPath("claude") || packagePresent("@anthropic-ai/claude-agent-sdk"))
|
|
951
990
|
out.push("claude");
|
|
952
|
-
if (onPath("codex") ||
|
|
991
|
+
if (onPath("codex") || packagePresent("@openai/codex-sdk"))
|
|
953
992
|
out.push("codex");
|
|
954
993
|
return out;
|
|
955
994
|
}
|
|
956
995
|
function codexInvocation(argv) {
|
|
957
996
|
if (onPath("codex"))
|
|
958
997
|
return { file: "codex", argv };
|
|
998
|
+
const require_2 = createRequire(import.meta.url);
|
|
959
999
|
try {
|
|
960
|
-
const cli =
|
|
1000
|
+
const cli = require_2.resolve("@openai/codex/bin/codex.js");
|
|
961
1001
|
return { file: process.execPath, argv: [cli, ...argv] };
|
|
962
1002
|
} catch {
|
|
963
|
-
return void 0;
|
|
964
1003
|
}
|
|
1004
|
+
for (const dir of require_2.resolve.paths("@openai/codex") ?? []) {
|
|
1005
|
+
const cli = path5.join(dir, "@openai", "codex", "bin", "codex.js");
|
|
1006
|
+
if (fs9.existsSync(cli))
|
|
1007
|
+
return { file: process.execPath, argv: [cli, ...argv] };
|
|
1008
|
+
}
|
|
1009
|
+
return void 0;
|
|
965
1010
|
}
|
|
966
1011
|
|
|
967
1012
|
// packages/core/dist/watch.js
|
|
@@ -1131,6 +1176,158 @@ function markFollowUp(key, followUpId, deliveredThrough) {
|
|
|
1131
1176
|
writeWatch(w);
|
|
1132
1177
|
}
|
|
1133
1178
|
|
|
1179
|
+
// packages/core/dist/soak.js
|
|
1180
|
+
import * as fs11 from "node:fs";
|
|
1181
|
+
import * as path7 from "node:path";
|
|
1182
|
+
function regPath(sessionId) {
|
|
1183
|
+
return path7.join(soakingDir(), `${sessionId}.json`);
|
|
1184
|
+
}
|
|
1185
|
+
function atomicWrite2(file, content) {
|
|
1186
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
1187
|
+
fs11.writeFileSync(tmp, content);
|
|
1188
|
+
fs11.renameSync(tmp, file);
|
|
1189
|
+
}
|
|
1190
|
+
function signOnSoak(reg) {
|
|
1191
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1192
|
+
const prior = readSoak(reg.sessionId);
|
|
1193
|
+
const full = {
|
|
1194
|
+
...reg,
|
|
1195
|
+
signedOnAt: prior?.signedOnAt ?? now,
|
|
1196
|
+
heartbeatAt: now,
|
|
1197
|
+
claimed: prior?.claimed
|
|
1198
|
+
};
|
|
1199
|
+
atomicWrite2(regPath(reg.sessionId), JSON.stringify(full, null, 2));
|
|
1200
|
+
return full;
|
|
1201
|
+
}
|
|
1202
|
+
function readSoak(sessionId) {
|
|
1203
|
+
try {
|
|
1204
|
+
return JSON.parse(fs11.readFileSync(regPath(sessionId), "utf8"));
|
|
1205
|
+
} catch {
|
|
1206
|
+
return void 0;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
function listSoaking() {
|
|
1210
|
+
try {
|
|
1211
|
+
return fs11.readdirSync(soakingDir()).filter((f) => f.endsWith(".json")).map((f) => JSON.parse(fs11.readFileSync(path7.join(soakingDir(), f), "utf8")));
|
|
1212
|
+
} catch {
|
|
1213
|
+
return [];
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
function stowSoak(sessionId) {
|
|
1217
|
+
const reg = readSoak(sessionId);
|
|
1218
|
+
if (!reg)
|
|
1219
|
+
return void 0;
|
|
1220
|
+
fs11.rmSync(regPath(sessionId), { force: true });
|
|
1221
|
+
return reg;
|
|
1222
|
+
}
|
|
1223
|
+
function heartbeatSoak(sessionId, patch = {}) {
|
|
1224
|
+
const reg = readSoak(sessionId);
|
|
1225
|
+
if (!reg)
|
|
1226
|
+
return void 0;
|
|
1227
|
+
const next = { ...reg, ...patch, heartbeatAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1228
|
+
atomicWrite2(regPath(sessionId), JSON.stringify(next, null, 2));
|
|
1229
|
+
return next;
|
|
1230
|
+
}
|
|
1231
|
+
function claimPath(id, lane) {
|
|
1232
|
+
return path7.join(laneDirs(lane).active, id, "claim.json");
|
|
1233
|
+
}
|
|
1234
|
+
function readSessionClaim(id, lane) {
|
|
1235
|
+
try {
|
|
1236
|
+
return JSON.parse(fs11.readFileSync(claimPath(id, lane), "utf8"));
|
|
1237
|
+
} catch {
|
|
1238
|
+
return void 0;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
function addressedSession(d) {
|
|
1242
|
+
return d.for?.startsWith("session:") ? d.for.slice("session:".length) : void 0;
|
|
1243
|
+
}
|
|
1244
|
+
function hasOpenCatch(reg) {
|
|
1245
|
+
if (!reg.claimed)
|
|
1246
|
+
return false;
|
|
1247
|
+
if (!fs11.existsSync(path7.join(laneDirs("work").active, reg.claimed)))
|
|
1248
|
+
return false;
|
|
1249
|
+
const last = readStatusLog(reg.claimed, "work").at(-1)?.verb;
|
|
1250
|
+
return last === void 0 || !TERMINAL_VERBS.includes(last);
|
|
1251
|
+
}
|
|
1252
|
+
function releaseCatch(reg) {
|
|
1253
|
+
if (!hasOpenCatch(reg))
|
|
1254
|
+
return {};
|
|
1255
|
+
const id = reg.claimed;
|
|
1256
|
+
fs11.rmSync(path7.join(laneDirs("work").active, id, "claim.json"), { force: true });
|
|
1257
|
+
if (cancelRequested(id, "work")) {
|
|
1258
|
+
appendStatus(id, "work", "failed", "cancelled by request; claimant gone, work preserved");
|
|
1259
|
+
complete(id, "work");
|
|
1260
|
+
return { finalized: id };
|
|
1261
|
+
}
|
|
1262
|
+
requeue(id, "work");
|
|
1263
|
+
return { requeued: id };
|
|
1264
|
+
}
|
|
1265
|
+
function claimBait(reg) {
|
|
1266
|
+
if (hasOpenCatch(reg))
|
|
1267
|
+
return null;
|
|
1268
|
+
const mine = `session:${reg.sessionId}`;
|
|
1269
|
+
const takeable = (d) => {
|
|
1270
|
+
if (d.for === mine)
|
|
1271
|
+
return true;
|
|
1272
|
+
if (d.for !== void 0)
|
|
1273
|
+
return false;
|
|
1274
|
+
return reg.repo !== void 0 && d.repo === reg.repo;
|
|
1275
|
+
};
|
|
1276
|
+
for (const pass of [(d) => d.for === mine, takeable]) {
|
|
1277
|
+
const id = claimNext("work", (d) => !pass(d));
|
|
1278
|
+
if (!id)
|
|
1279
|
+
continue;
|
|
1280
|
+
const descriptor = JSON.parse(fs11.readFileSync(path7.join(laneDirs("work").active, id, "descriptor.json"), "utf8"));
|
|
1281
|
+
const claim = { by: mine, harness: reg.harness, worktree: reg.worktree, at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1282
|
+
atomicWrite2(claimPath(id, "work"), JSON.stringify(claim, null, 2));
|
|
1283
|
+
mergeEvidence(id, "work", { sessionId: reg.sessionId });
|
|
1284
|
+
heartbeatSoak(reg.sessionId, { claimed: id });
|
|
1285
|
+
return { id, descriptor };
|
|
1286
|
+
}
|
|
1287
|
+
return null;
|
|
1288
|
+
}
|
|
1289
|
+
var msOf = (iso) => Date.parse(iso) || 0;
|
|
1290
|
+
function soakReady(reg, deferMs, now = Date.now()) {
|
|
1291
|
+
return now - msOf(reg.heartbeatAt) <= deferMs && !hasOpenCatch(reg);
|
|
1292
|
+
}
|
|
1293
|
+
function soakSkip(soaking, deferMs, now = Date.now()) {
|
|
1294
|
+
return (d) => {
|
|
1295
|
+
const to = addressedSession(d);
|
|
1296
|
+
if (to !== void 0)
|
|
1297
|
+
return soaking.some((r) => r.sessionId === to);
|
|
1298
|
+
return soaking.some((r) => r.repo === d.repo && soakReady(r, deferMs, now));
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
function sweepGhostTraps(ttlMs, now = Date.now()) {
|
|
1302
|
+
const actions = [];
|
|
1303
|
+
for (const reg of listSoaking()) {
|
|
1304
|
+
if (now - msOf(reg.heartbeatAt) <= ttlMs)
|
|
1305
|
+
continue;
|
|
1306
|
+
if (hasOpenCatch(reg)) {
|
|
1307
|
+
const lastReport = readStatusLog(reg.claimed, "work").at(-1)?.at;
|
|
1308
|
+
if (lastReport && now - msOf(lastReport) <= ttlMs)
|
|
1309
|
+
continue;
|
|
1310
|
+
const released = releaseCatch(reg);
|
|
1311
|
+
actions.push({ sessionId: reg.sessionId, requeued: released.requeued ?? released.finalized });
|
|
1312
|
+
} else {
|
|
1313
|
+
actions.push({ sessionId: reg.sessionId });
|
|
1314
|
+
}
|
|
1315
|
+
fs11.rmSync(regPath(reg.sessionId), { force: true });
|
|
1316
|
+
}
|
|
1317
|
+
return actions;
|
|
1318
|
+
}
|
|
1319
|
+
function baitBrief(id, d) {
|
|
1320
|
+
return [
|
|
1321
|
+
`You are a soaking trap and caught bait: dispatch ${id}.`,
|
|
1322
|
+
"",
|
|
1323
|
+
"Work it in THIS worktree on a fresh branch (branch first, never on the checked-out state directly).",
|
|
1324
|
+
`Report progress with \`lobstah report ${id} working "<note>"\` at milestones, check \`lobstah inbox ${id}\` at natural checkpoints, and finish with \`lobstah report ${id} done "<note>" [--pr <url>]\` (or \`failed\`).`,
|
|
1325
|
+
"The brief:",
|
|
1326
|
+
"",
|
|
1327
|
+
d.brief
|
|
1328
|
+
].join("\n");
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1134
1331
|
// packages/supervisor/dist/liveness.js
|
|
1135
1332
|
import { execFileSync } from "node:child_process";
|
|
1136
1333
|
function classify(input) {
|
|
@@ -1189,26 +1386,26 @@ function killGroup(pid, signal = "SIGTERM") {
|
|
|
1189
1386
|
}
|
|
1190
1387
|
|
|
1191
1388
|
// packages/supervisor/dist/daemon.js
|
|
1192
|
-
import * as
|
|
1389
|
+
import * as fs13 from "node:fs";
|
|
1193
1390
|
import * as os3 from "node:os";
|
|
1194
|
-
import * as
|
|
1391
|
+
import * as path9 from "node:path";
|
|
1195
1392
|
import { spawn as spawn2 } from "node:child_process";
|
|
1196
1393
|
import { createRequire as createRequire2 } from "node:module";
|
|
1197
1394
|
import { fileURLToPath } from "node:url";
|
|
1198
1395
|
|
|
1199
1396
|
// packages/supervisor/dist/notify.js
|
|
1200
|
-
import * as
|
|
1201
|
-
import * as
|
|
1397
|
+
import * as fs12 from "node:fs";
|
|
1398
|
+
import * as path8 from "node:path";
|
|
1202
1399
|
import { spawn } from "node:child_process";
|
|
1203
1400
|
var DEFAULT_NOTIFY_VERBS = ["needs-decision", "blocked", "done", "failed"];
|
|
1204
1401
|
function cursorPath(id, lane) {
|
|
1205
|
-
return
|
|
1402
|
+
return path8.join(laneDirs(lane).state, `${id}.notified`);
|
|
1206
1403
|
}
|
|
1207
1404
|
function pendingNotifications(id, lane, verbs = DEFAULT_NOTIFY_VERBS, sinceMs = 0) {
|
|
1208
1405
|
const log = readStatusLog(id, lane);
|
|
1209
1406
|
let cursor;
|
|
1210
1407
|
try {
|
|
1211
|
-
cursor = Number(
|
|
1408
|
+
cursor = Number(fs12.readFileSync(cursorPath(id, lane), "utf8")) || 0;
|
|
1212
1409
|
} catch {
|
|
1213
1410
|
cursor = void 0;
|
|
1214
1411
|
}
|
|
@@ -1218,13 +1415,13 @@ function pendingNotifications(id, lane, verbs = DEFAULT_NOTIFY_VERBS, sinceMs =
|
|
|
1218
1415
|
if (cursor === void 0 && sinceMs > 0) {
|
|
1219
1416
|
fresh = fresh.filter((e) => Date.parse(e.at) >= sinceMs);
|
|
1220
1417
|
}
|
|
1221
|
-
|
|
1418
|
+
fs12.writeFileSync(cursorPath(id, lane), String(log.length));
|
|
1222
1419
|
return fresh.filter((e) => verbs.includes(e.verb)).map((entry) => ({ id, lane, entry }));
|
|
1223
1420
|
}
|
|
1224
1421
|
function notifiableIds(lane, maxAgeMs = 48 * 36e5) {
|
|
1225
1422
|
const dir = laneDirs(lane).state;
|
|
1226
1423
|
const cutoff = Date.now() - maxAgeMs;
|
|
1227
|
-
return
|
|
1424
|
+
return fs12.readdirSync(dir).filter((f) => f.endsWith(".status")).filter((f) => fs12.statSync(path8.join(dir, f)).mtimeMs > cutoff).map((f) => f.slice(0, -".status".length));
|
|
1228
1425
|
}
|
|
1229
1426
|
function execNotify(command, ev, log = () => {
|
|
1230
1427
|
}) {
|
|
@@ -1254,11 +1451,11 @@ function captureWaitBaseline() {
|
|
|
1254
1451
|
return lengths;
|
|
1255
1452
|
}
|
|
1256
1453
|
function attnCursorPath(id, lane) {
|
|
1257
|
-
return
|
|
1454
|
+
return path8.join(laneDirs(lane).state, `${id}.attn`);
|
|
1258
1455
|
}
|
|
1259
1456
|
function readAttnCursor(file) {
|
|
1260
1457
|
try {
|
|
1261
|
-
const parsed = JSON.parse(
|
|
1458
|
+
const parsed = JSON.parse(fs12.readFileSync(file, "utf8"));
|
|
1262
1459
|
return { seen: parsed.seen ?? 0, at: parsed.at ?? 0 };
|
|
1263
1460
|
} catch {
|
|
1264
1461
|
return { seen: 0, at: 0 };
|
|
@@ -1284,7 +1481,7 @@ function attentionNow(consume = true, remindMs = 15 * 6e4, now = Date.now()) {
|
|
|
1284
1481
|
continue;
|
|
1285
1482
|
out.push({ id, lane, entry });
|
|
1286
1483
|
if (consume)
|
|
1287
|
-
|
|
1484
|
+
fs12.writeFileSync(file, JSON.stringify({ seen: Math.max(cursor.seen, index + 1), at: now }));
|
|
1288
1485
|
}
|
|
1289
1486
|
}
|
|
1290
1487
|
return out;
|
|
@@ -1319,33 +1516,33 @@ function runnerEntry() {
|
|
|
1319
1516
|
}
|
|
1320
1517
|
function readRunnerInfo(dir) {
|
|
1321
1518
|
try {
|
|
1322
|
-
return JSON.parse(
|
|
1519
|
+
return JSON.parse(fs13.readFileSync(path9.join(dir, "runner.json"), "utf8"));
|
|
1323
1520
|
} catch {
|
|
1324
1521
|
return void 0;
|
|
1325
1522
|
}
|
|
1326
1523
|
}
|
|
1327
1524
|
function listActive(lane) {
|
|
1328
1525
|
const dir = laneDirs(lane).active;
|
|
1329
|
-
return
|
|
1330
|
-
const d =
|
|
1526
|
+
return fs13.readdirSync(dir).filter((f) => !f.startsWith(".")).map((id) => {
|
|
1527
|
+
const d = path9.join(dir, id);
|
|
1331
1528
|
return { id, lane, dir: d, runner: readRunnerInfo(d) };
|
|
1332
1529
|
});
|
|
1333
1530
|
}
|
|
1334
1531
|
function finalize(st) {
|
|
1335
1532
|
try {
|
|
1336
|
-
|
|
1533
|
+
fs13.renameSync(st.dir, path9.join(laneDirs(st.lane).done, st.id));
|
|
1337
1534
|
} catch {
|
|
1338
1535
|
}
|
|
1339
1536
|
}
|
|
1340
1537
|
function spawnRunner(st, opts) {
|
|
1341
1538
|
let nudge = opts.nudge;
|
|
1342
|
-
const handoffFile =
|
|
1343
|
-
if (!nudge &&
|
|
1344
|
-
nudge =
|
|
1345
|
-
|
|
1539
|
+
const handoffFile = path9.join(st.dir, "handoff");
|
|
1540
|
+
if (!nudge && fs13.existsSync(handoffFile)) {
|
|
1541
|
+
nudge = fs13.readFileSync(handoffFile, "utf8");
|
|
1542
|
+
fs13.unlinkSync(handoffFile);
|
|
1346
1543
|
}
|
|
1347
|
-
const logPath =
|
|
1348
|
-
const log =
|
|
1544
|
+
const logPath = path9.join(laneDirs(st.lane).state, `${st.id}.runner.log`);
|
|
1545
|
+
const log = fs13.openSync(logPath, "a");
|
|
1349
1546
|
const child = spawn2(process.execPath, [runnerEntry(), st.dir, st.lane], {
|
|
1350
1547
|
detached: true,
|
|
1351
1548
|
stdio: ["ignore", log, log],
|
|
@@ -1356,7 +1553,7 @@ function spawnRunner(st, opts) {
|
|
|
1356
1553
|
...nudge ? { LOBSTAH_NUDGE: nudge } : {}
|
|
1357
1554
|
}
|
|
1358
1555
|
});
|
|
1359
|
-
|
|
1556
|
+
fs13.closeSync(log);
|
|
1360
1557
|
child.unref();
|
|
1361
1558
|
const info = {
|
|
1362
1559
|
pid: child.pid ?? -1,
|
|
@@ -1364,27 +1561,35 @@ function spawnRunner(st, opts) {
|
|
|
1364
1561
|
processStartTime: child.pid ? processStartTime(child.pid) : void 0,
|
|
1365
1562
|
attempts: opts.attempts
|
|
1366
1563
|
};
|
|
1367
|
-
|
|
1564
|
+
fs13.writeFileSync(path9.join(st.dir, "runner.json"), JSON.stringify(info, null, 2));
|
|
1368
1565
|
}
|
|
1369
1566
|
function sessionOf(st) {
|
|
1370
1567
|
try {
|
|
1371
|
-
const ev = JSON.parse(
|
|
1568
|
+
const ev = JSON.parse(fs13.readFileSync(path9.join(laneDirs(st.lane).state, `${st.id}.evidence`), "utf8"));
|
|
1372
1569
|
return ev.sessionId;
|
|
1373
1570
|
} catch {
|
|
1374
1571
|
return void 0;
|
|
1375
1572
|
}
|
|
1376
1573
|
}
|
|
1377
1574
|
function reconcileOne(st, cfg, log) {
|
|
1378
|
-
const hasDescriptor =
|
|
1575
|
+
const hasDescriptor = fs13.existsSync(path9.join(st.dir, "descriptor.json"));
|
|
1379
1576
|
if (!hasDescriptor) {
|
|
1380
|
-
const age = Date.now() -
|
|
1577
|
+
const age = Date.now() - fs13.statSync(st.dir).mtimeMs;
|
|
1381
1578
|
if (age > 5 * 6e4)
|
|
1382
|
-
|
|
1579
|
+
fs13.rmSync(st.dir, { recursive: true, force: true });
|
|
1383
1580
|
return;
|
|
1384
1581
|
}
|
|
1385
1582
|
const statusLog = readStatusLog(st.id, st.lane);
|
|
1386
1583
|
const lastVerb = statusLog.at(-1)?.verb;
|
|
1387
1584
|
const alive = st.runner ? pidAlive(st.runner.pid, st.runner.processStartTime) : void 0;
|
|
1585
|
+
const sessionClaim = readSessionClaim(st.id, st.lane);
|
|
1586
|
+
if (sessionClaim) {
|
|
1587
|
+
if (lastVerb === "done" || lastVerb === "failed") {
|
|
1588
|
+
finalize(st);
|
|
1589
|
+
log(`${st.id}: ${lastVerb} (claimed by ${sessionClaim.by}), finalized`);
|
|
1590
|
+
}
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1388
1593
|
if (cancelRequested(st.id, st.lane)) {
|
|
1389
1594
|
if (st.runner && alive) {
|
|
1390
1595
|
log(`${st.id}: cancel requested, killing group ${st.runner.pid}`);
|
|
@@ -1463,16 +1668,16 @@ function writeHeartbeat(cfg) {
|
|
|
1463
1668
|
heartbeat: (/* @__PURE__ */ new Date()).toISOString()
|
|
1464
1669
|
};
|
|
1465
1670
|
const tmp = `${executorPath()}.tmp`;
|
|
1466
|
-
|
|
1467
|
-
|
|
1671
|
+
fs13.writeFileSync(tmp, JSON.stringify(payload, null, 2));
|
|
1672
|
+
fs13.renameSync(tmp, executorPath());
|
|
1468
1673
|
}
|
|
1469
1674
|
function pruneChores(cfg) {
|
|
1470
1675
|
const dir = laneDirs("chore").done;
|
|
1471
1676
|
const cutoff = Date.now() - cfg.limits.choreRetentionDays * 864e5;
|
|
1472
|
-
for (const id of
|
|
1473
|
-
const p =
|
|
1474
|
-
if (
|
|
1475
|
-
|
|
1677
|
+
for (const id of fs13.readdirSync(dir)) {
|
|
1678
|
+
const p = path9.join(dir, id);
|
|
1679
|
+
if (fs13.statSync(p).mtimeMs < cutoff)
|
|
1680
|
+
fs13.rmSync(p, { recursive: true, force: true });
|
|
1476
1681
|
}
|
|
1477
1682
|
}
|
|
1478
1683
|
var daemonStartedAt = Date.now();
|
|
@@ -1481,6 +1686,10 @@ function tick(log = () => {
|
|
|
1481
1686
|
const cfg = loadConfig();
|
|
1482
1687
|
ensureLayout();
|
|
1483
1688
|
writeHeartbeat(cfg);
|
|
1689
|
+
for (const action of sweepGhostTraps(cfg.soak.ttlSecs * 1e3)) {
|
|
1690
|
+
log(`ghost trap ${action.sessionId.slice(0, 8)} swept` + (action.requeued ? ` \u2014 bait ${action.requeued} back in the queue` : ""));
|
|
1691
|
+
}
|
|
1692
|
+
const workSkip = soakSkip(listSoaking(), cfg.soak.deferSecs * 1e3);
|
|
1484
1693
|
for (const lane of ["chore", "work"]) {
|
|
1485
1694
|
const active = listActive(lane);
|
|
1486
1695
|
for (const st of active)
|
|
@@ -1488,7 +1697,7 @@ function tick(log = () => {
|
|
|
1488
1697
|
const ceiling = lane === "work" ? cfg.limits.maxConcurrent : cfg.limits.choreConcurrent;
|
|
1489
1698
|
let inFlight = listActive(lane).length;
|
|
1490
1699
|
while (inFlight < ceiling) {
|
|
1491
|
-
const id = claimNext(lane);
|
|
1700
|
+
const id = claimNext(lane, lane === "work" ? workSkip : void 0);
|
|
1492
1701
|
if (!id)
|
|
1493
1702
|
break;
|
|
1494
1703
|
log(`${id}: claimed (${lane})`);
|
|
@@ -1512,9 +1721,9 @@ function tick(log = () => {
|
|
|
1512
1721
|
pruneChores(loadConfig());
|
|
1513
1722
|
}
|
|
1514
1723
|
function acquireDaemonLock() {
|
|
1515
|
-
const lockPath =
|
|
1724
|
+
const lockPath = path9.join(lobstahHome(), "daemon.lock");
|
|
1516
1725
|
try {
|
|
1517
|
-
const prior = JSON.parse(
|
|
1726
|
+
const prior = JSON.parse(fs13.readFileSync(lockPath, "utf8"));
|
|
1518
1727
|
if (pidAlive(prior.pid, prior.processStartTime)) {
|
|
1519
1728
|
throw new Error(`another daemon (pid ${prior.pid}) already supervises ${lobstahHome()} \u2014 run a second instance against its own LOBSTAH_HOME instead`);
|
|
1520
1729
|
}
|
|
@@ -1522,10 +1731,10 @@ function acquireDaemonLock() {
|
|
|
1522
1731
|
if (err instanceof Error && err.message.includes("already supervises"))
|
|
1523
1732
|
throw err;
|
|
1524
1733
|
}
|
|
1525
|
-
|
|
1734
|
+
fs13.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, processStartTime: processStartTime(process.pid), startedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2));
|
|
1526
1735
|
const release = () => {
|
|
1527
1736
|
try {
|
|
1528
|
-
|
|
1737
|
+
fs13.unlinkSync(lockPath);
|
|
1529
1738
|
} catch {
|
|
1530
1739
|
}
|
|
1531
1740
|
};
|
|
@@ -1541,7 +1750,7 @@ function watchQueues(onChange) {
|
|
|
1541
1750
|
const watchers = [];
|
|
1542
1751
|
for (const lane of ["work", "chore"]) {
|
|
1543
1752
|
try {
|
|
1544
|
-
watchers.push(
|
|
1753
|
+
watchers.push(fs13.watch(laneDirs(lane).queue, onChange));
|
|
1545
1754
|
} catch {
|
|
1546
1755
|
}
|
|
1547
1756
|
}
|
|
@@ -1562,15 +1771,15 @@ async function daemon(intervalMs = 5e3, log = console.log) {
|
|
|
1562
1771
|
} catch (err) {
|
|
1563
1772
|
log(`tick error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1564
1773
|
}
|
|
1565
|
-
await new Promise((
|
|
1774
|
+
await new Promise((resolve3) => {
|
|
1566
1775
|
const timer = setTimeout(() => {
|
|
1567
1776
|
wake = void 0;
|
|
1568
|
-
|
|
1777
|
+
resolve3();
|
|
1569
1778
|
}, intervalMs);
|
|
1570
1779
|
wake = () => {
|
|
1571
1780
|
wake = void 0;
|
|
1572
1781
|
clearTimeout(timer);
|
|
1573
|
-
setTimeout(
|
|
1782
|
+
setTimeout(resolve3, 50);
|
|
1574
1783
|
};
|
|
1575
1784
|
});
|
|
1576
1785
|
}
|
|
@@ -1580,7 +1789,7 @@ async function daemon(intervalMs = 5e3, log = console.log) {
|
|
|
1580
1789
|
import { spawn as spawn3 } from "node:child_process";
|
|
1581
1790
|
|
|
1582
1791
|
// apps/pick/dist/config.js
|
|
1583
|
-
import * as
|
|
1792
|
+
import * as fs14 from "node:fs";
|
|
1584
1793
|
import { execSync } from "node:child_process";
|
|
1585
1794
|
import * as os4 from "node:os";
|
|
1586
1795
|
|
|
@@ -1622,7 +1831,7 @@ function resolveTokenSource(section, label) {
|
|
|
1622
1831
|
}
|
|
1623
1832
|
if (file) {
|
|
1624
1833
|
return () => {
|
|
1625
|
-
const token =
|
|
1834
|
+
const token = fs14.readFileSync(file.startsWith("~/") ? `${os4.homedir()}/${file.slice(2)}` : file, "utf8").trim();
|
|
1626
1835
|
if (!token)
|
|
1627
1836
|
throw new Error(`${label}: tokenFile ${file} is empty`);
|
|
1628
1837
|
return token;
|
|
@@ -1640,7 +1849,7 @@ function resolveTokenSource(section, label) {
|
|
|
1640
1849
|
}
|
|
1641
1850
|
function loadPickupConfig() {
|
|
1642
1851
|
const file = configPath();
|
|
1643
|
-
const raw =
|
|
1852
|
+
const raw = fs14.existsSync(file) ? parse(fs14.readFileSync(file, "utf8")) : {};
|
|
1644
1853
|
const p = raw.pickup ?? {};
|
|
1645
1854
|
const out = {
|
|
1646
1855
|
pollSecs: Number(p.pollSecs ?? 45),
|
|
@@ -1713,23 +1922,23 @@ function loadPickupConfig() {
|
|
|
1713
1922
|
}
|
|
1714
1923
|
|
|
1715
1924
|
// apps/pick/dist/state.js
|
|
1716
|
-
import * as
|
|
1717
|
-
import * as
|
|
1925
|
+
import * as fs15 from "node:fs";
|
|
1926
|
+
import * as path10 from "node:path";
|
|
1718
1927
|
var PickupState = class {
|
|
1719
1928
|
file;
|
|
1720
1929
|
data;
|
|
1721
|
-
constructor(dir =
|
|
1722
|
-
|
|
1723
|
-
this.file =
|
|
1724
|
-
this.data =
|
|
1930
|
+
constructor(dir = path10.join(lobstahHome(), "pickup")) {
|
|
1931
|
+
fs15.mkdirSync(dir, { recursive: true });
|
|
1932
|
+
this.file = path10.join(dir, "state.json");
|
|
1933
|
+
this.data = fs15.existsSync(this.file) ? JSON.parse(fs15.readFileSync(this.file, "utf8")) : { map: {}, consumedApprovals: {}, rebases: {} };
|
|
1725
1934
|
this.data.map ??= {};
|
|
1726
1935
|
this.data.consumedApprovals ??= {};
|
|
1727
1936
|
this.data.rebases ??= {};
|
|
1728
1937
|
}
|
|
1729
1938
|
save() {
|
|
1730
1939
|
const tmp = `${this.file}.tmp-${process.pid}`;
|
|
1731
|
-
|
|
1732
|
-
|
|
1940
|
+
fs15.writeFileSync(tmp, JSON.stringify(this.data, null, 2));
|
|
1941
|
+
fs15.renameSync(tmp, this.file);
|
|
1733
1942
|
}
|
|
1734
1943
|
get(key) {
|
|
1735
1944
|
return this.data.map[key];
|
|
@@ -2113,12 +2322,12 @@ async function dispatchLoop(source, state, log = () => {
|
|
|
2113
2322
|
}
|
|
2114
2323
|
|
|
2115
2324
|
// apps/pick/dist/loops/report.js
|
|
2116
|
-
import * as
|
|
2117
|
-
import * as
|
|
2325
|
+
import * as fs16 from "node:fs";
|
|
2326
|
+
import * as path11 from "node:path";
|
|
2118
2327
|
function dispatchLane(uuid) {
|
|
2119
2328
|
for (const lane of ["work", "chore"]) {
|
|
2120
2329
|
const d = laneDirs(lane);
|
|
2121
|
-
if (
|
|
2330
|
+
if (fs16.existsSync(path11.join(d.active, uuid)) || fs16.existsSync(path11.join(d.queue, `${uuid}.json`)) || fs16.existsSync(path11.join(d.done, uuid)) || fs16.existsSync(path11.join(d.state, `${uuid}.status`))) {
|
|
2122
2331
|
return lane;
|
|
2123
2332
|
}
|
|
2124
2333
|
}
|
|
@@ -2159,12 +2368,12 @@ async function reportLoop(source, state, log = () => {
|
|
|
2159
2368
|
}
|
|
2160
2369
|
|
|
2161
2370
|
// apps/pick/dist/loops/reconcile.js
|
|
2162
|
-
import * as
|
|
2163
|
-
import * as
|
|
2371
|
+
import * as fs17 from "node:fs";
|
|
2372
|
+
import * as path12 from "node:path";
|
|
2164
2373
|
function dispatchActive(uuid) {
|
|
2165
2374
|
for (const lane of ["work", "chore"]) {
|
|
2166
2375
|
const d = laneDirs(lane);
|
|
2167
|
-
if (
|
|
2376
|
+
if (fs17.existsSync(path12.join(d.active, uuid)) || fs17.existsSync(path12.join(d.queue, `${uuid}.json`))) {
|
|
2168
2377
|
return lane;
|
|
2169
2378
|
}
|
|
2170
2379
|
}
|
|
@@ -2204,28 +2413,28 @@ async function reconcileLoop(source, state, log = () => {
|
|
|
2204
2413
|
|
|
2205
2414
|
// apps/pick/dist/loops/merge.js
|
|
2206
2415
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
2207
|
-
import * as
|
|
2208
|
-
import * as
|
|
2416
|
+
import * as fs19 from "node:fs";
|
|
2417
|
+
import * as path14 from "node:path";
|
|
2209
2418
|
|
|
2210
2419
|
// apps/pick/dist/merge-view.js
|
|
2211
|
-
import * as
|
|
2212
|
-
import * as
|
|
2420
|
+
import * as fs18 from "node:fs";
|
|
2421
|
+
import * as path13 from "node:path";
|
|
2213
2422
|
function viewPath() {
|
|
2214
|
-
return
|
|
2423
|
+
return path13.join(lobstahHome(), "pickup", "merge-view.json");
|
|
2215
2424
|
}
|
|
2216
2425
|
function readMergeView() {
|
|
2217
2426
|
try {
|
|
2218
|
-
return JSON.parse(
|
|
2427
|
+
return JSON.parse(fs18.readFileSync(viewPath(), "utf8"));
|
|
2219
2428
|
} catch {
|
|
2220
2429
|
return void 0;
|
|
2221
2430
|
}
|
|
2222
2431
|
}
|
|
2223
2432
|
function writeMergeView(view) {
|
|
2224
2433
|
const file = viewPath();
|
|
2225
|
-
|
|
2434
|
+
fs18.mkdirSync(path13.dirname(file), { recursive: true });
|
|
2226
2435
|
const tmp = `${file}.tmp-${process.pid}`;
|
|
2227
|
-
|
|
2228
|
-
|
|
2436
|
+
fs18.writeFileSync(tmp, JSON.stringify(view, null, 2));
|
|
2437
|
+
fs18.renameSync(tmp, file);
|
|
2229
2438
|
}
|
|
2230
2439
|
var RECENT_RETENTION_MS = 48 * 36e5;
|
|
2231
2440
|
async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ new Date()) {
|
|
@@ -2247,7 +2456,7 @@ async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ ne
|
|
|
2247
2456
|
}
|
|
2248
2457
|
function readPickupMap() {
|
|
2249
2458
|
try {
|
|
2250
|
-
const raw = JSON.parse(
|
|
2459
|
+
const raw = JSON.parse(fs18.readFileSync(path13.join(lobstahHome(), "pickup", "state.json"), "utf8"));
|
|
2251
2460
|
return raw.map ?? {};
|
|
2252
2461
|
} catch {
|
|
2253
2462
|
return {};
|
|
@@ -2281,7 +2490,7 @@ function rebaseBrief(pr) {
|
|
|
2281
2490
|
}
|
|
2282
2491
|
function choreVerdict(uuid) {
|
|
2283
2492
|
const d = laneDirs("chore");
|
|
2284
|
-
if (
|
|
2493
|
+
if (fs19.existsSync(path14.join(d.active, uuid)) || fs19.existsSync(path14.join(d.queue, `${uuid}.json`)))
|
|
2285
2494
|
return "active";
|
|
2286
2495
|
const last = readStatusLog(uuid, "chore").at(-1)?.verb;
|
|
2287
2496
|
return last === "done" ? "done" : "failed";
|
|
@@ -2375,8 +2584,8 @@ async function mergeLoop(ms, policy, state, log = () => {
|
|
|
2375
2584
|
}
|
|
2376
2585
|
|
|
2377
2586
|
// apps/pick/dist/loops/watch.js
|
|
2378
|
-
import * as
|
|
2379
|
-
import * as
|
|
2587
|
+
import * as fs20 from "node:fs";
|
|
2588
|
+
import * as path15 from "node:path";
|
|
2380
2589
|
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
2381
2590
|
var DEFAULT_BRIEF = `You are resuming earlier work. The watched source {key} produced new events:
|
|
2382
2591
|
|
|
@@ -2386,7 +2595,7 @@ Read them, act on what they ask, and report the outcome.`;
|
|
|
2386
2595
|
function laneOf(id) {
|
|
2387
2596
|
for (const lane of ["work", "chore"]) {
|
|
2388
2597
|
const d = laneDirs(lane);
|
|
2389
|
-
if (
|
|
2598
|
+
if (fs20.existsSync(path15.join(d.active, id)) || fs20.existsSync(path15.join(d.queue, `${id}.json`)) || fs20.existsSync(path15.join(d.done, id)) || fs20.existsSync(path15.join(d.state, `${id}.status`))) {
|
|
2390
2599
|
return lane;
|
|
2391
2600
|
}
|
|
2392
2601
|
}
|
|
@@ -2395,13 +2604,13 @@ function laneOf(id) {
|
|
|
2395
2604
|
function descriptorOf(id, lane) {
|
|
2396
2605
|
const d = laneDirs(lane);
|
|
2397
2606
|
for (const bucket of ["active", "done"]) {
|
|
2398
|
-
const f =
|
|
2399
|
-
if (
|
|
2400
|
-
return JSON.parse(
|
|
2607
|
+
const f = path15.join(d[bucket], id, "descriptor.json");
|
|
2608
|
+
if (fs20.existsSync(f))
|
|
2609
|
+
return JSON.parse(fs20.readFileSync(f, "utf8"));
|
|
2401
2610
|
}
|
|
2402
|
-
const q =
|
|
2403
|
-
if (
|
|
2404
|
-
return JSON.parse(
|
|
2611
|
+
const q = path15.join(d.queue, `${id}.json`);
|
|
2612
|
+
if (fs20.existsSync(q))
|
|
2613
|
+
return JSON.parse(fs20.readFileSync(q, "utf8"));
|
|
2405
2614
|
return void 0;
|
|
2406
2615
|
}
|
|
2407
2616
|
function isTerminal(id) {
|
|
@@ -2428,9 +2637,12 @@ function spawnContinuation(w, pending, log) {
|
|
|
2428
2637
|
}
|
|
2429
2638
|
const id = randomUUID3();
|
|
2430
2639
|
const brief = (w.brief ?? DEFAULT_BRIEF).replaceAll("{key}", w.key).replaceAll("{events}", JSON.stringify(pending, null, 2));
|
|
2431
|
-
|
|
2640
|
+
const claim = readSessionClaim(target, targetLane);
|
|
2641
|
+
const claimant = claim?.by.startsWith("session:") ? claim.by.slice("session:".length) : void 0;
|
|
2642
|
+
const live = claimant !== void 0 && readSoak(claimant) !== void 0;
|
|
2643
|
+
enqueue({ id, repo: descriptor.repo, brief, followUp: target, ...live ? { for: claim.by } : {} }, "work");
|
|
2432
2644
|
markFollowUp(w.key, id, readWatchEvents(w.key).length);
|
|
2433
|
-
log(`watch ${w.key}: ${pending.length} event(s) \u2192 continuation ${id} (forks ${target})`);
|
|
2645
|
+
log(`watch ${w.key}: ${pending.length} event(s) \u2192 continuation ${id} ` + (live ? `(addressed to soaking ${claimant.slice(0, 8)})` : `(forks ${target})`));
|
|
2434
2646
|
}
|
|
2435
2647
|
function notifyMan(watch2, fresh, notify) {
|
|
2436
2648
|
if (fresh.length === 0 || watch2.owner !== "man")
|
|
@@ -2631,21 +2843,21 @@ function mergeHaulHook(existing) {
|
|
|
2631
2843
|
}
|
|
2632
2844
|
|
|
2633
2845
|
// apps/cli/src/tend.ts
|
|
2634
|
-
import * as
|
|
2635
|
-
import * as
|
|
2846
|
+
import * as fs21 from "node:fs";
|
|
2847
|
+
import * as path16 from "node:path";
|
|
2636
2848
|
var HEARTBEAT_STALE_MS = 9e4;
|
|
2637
2849
|
var CLAIM_STALE_MS = 12e4;
|
|
2638
2850
|
var DAY_MS = 24 * 36e5;
|
|
2639
2851
|
function readJson(file) {
|
|
2640
2852
|
try {
|
|
2641
|
-
return JSON.parse(
|
|
2853
|
+
return JSON.parse(fs21.readFileSync(file, "utf8"));
|
|
2642
2854
|
} catch {
|
|
2643
2855
|
return void 0;
|
|
2644
2856
|
}
|
|
2645
2857
|
}
|
|
2646
2858
|
function doneIds(lane) {
|
|
2647
2859
|
try {
|
|
2648
|
-
return
|
|
2860
|
+
return fs21.readdirSync(laneDirs(lane).done).filter((f) => !f.startsWith("."));
|
|
2649
2861
|
} catch {
|
|
2650
2862
|
return [];
|
|
2651
2863
|
}
|
|
@@ -2664,16 +2876,16 @@ function followUps(uuid) {
|
|
|
2664
2876
|
const desc = readJson(file);
|
|
2665
2877
|
if (desc?.followUp === uuid) out.push({ id, bucket });
|
|
2666
2878
|
};
|
|
2667
|
-
for (const id of pendingIds("work")) scan(id, "queued",
|
|
2668
|
-
for (const id of activeIds("work")) scan(id, "active",
|
|
2669
|
-
for (const id of doneIds("work")) scan(id, "done",
|
|
2879
|
+
for (const id of pendingIds("work")) scan(id, "queued", path16.join(d.queue, `${id}.json`));
|
|
2880
|
+
for (const id of activeIds("work")) scan(id, "active", path16.join(d.active, id, "descriptor.json"));
|
|
2881
|
+
for (const id of doneIds("work")) scan(id, "done", path16.join(d.done, id, "descriptor.json"));
|
|
2670
2882
|
return out;
|
|
2671
2883
|
}
|
|
2672
2884
|
function bucketOf(uuid) {
|
|
2673
2885
|
const d = laneDirs("work");
|
|
2674
|
-
if (
|
|
2675
|
-
if (
|
|
2676
|
-
if (
|
|
2886
|
+
if (fs21.existsSync(path16.join(d.active, uuid))) return "active";
|
|
2887
|
+
if (fs21.existsSync(path16.join(d.done, uuid))) return "done";
|
|
2888
|
+
if (fs21.existsSync(path16.join(d.queue, `${uuid}.json`))) return "queued";
|
|
2677
2889
|
return void 0;
|
|
2678
2890
|
}
|
|
2679
2891
|
function buildTendReport(now = Date.now()) {
|
|
@@ -2733,7 +2945,7 @@ function buildTendReport(now = Date.now()) {
|
|
|
2733
2945
|
}
|
|
2734
2946
|
}
|
|
2735
2947
|
const oldestQueuedAge = queued.reduce((max, id) => {
|
|
2736
|
-
const st =
|
|
2948
|
+
const st = fs21.statSync(path16.join(laneDirs("work").queue, `${id}.json`), { throwIfNoEntry: false });
|
|
2737
2949
|
return st ? Math.max(max, now - st.mtimeMs) : max;
|
|
2738
2950
|
}, 0);
|
|
2739
2951
|
const stalled = daemonUp && queued.length > 0 && active.length < cfg.limits.maxConcurrent && oldestQueuedAge > CLAIM_STALE_MS;
|
|
@@ -2764,6 +2976,13 @@ function buildTendReport(now = Date.now()) {
|
|
|
2764
2976
|
stories.push({ key: "(direct)", dispatches: [d], prUrl: d.prUrl, gate: gateFor(id, d.prUrl), watch: watchByDispatch.get(id) });
|
|
2765
2977
|
}
|
|
2766
2978
|
const verdict = !daemonUp ? "daemon-down" : stalled ? "stalled" : attention.length > 0 ? "needs-attention" : active.length + queued.length > 0 ? "working" : "idle";
|
|
2979
|
+
const soaking = listSoaking().map((r) => ({
|
|
2980
|
+
session: r.sessionId.slice(0, 8),
|
|
2981
|
+
repo: r.repo ?? "(addressed only)",
|
|
2982
|
+
worktree: r.worktree,
|
|
2983
|
+
claimed: r.claimed,
|
|
2984
|
+
heartbeatAgeSecs: Math.max(0, Math.round((now - (Date.parse(r.heartbeatAt) || 0)) / 1e3))
|
|
2985
|
+
}));
|
|
2767
2986
|
return {
|
|
2768
2987
|
verdict,
|
|
2769
2988
|
daemon: { up: daemonUp, lastHeartbeat: heartbeat },
|
|
@@ -2771,6 +2990,7 @@ function buildTendReport(now = Date.now()) {
|
|
|
2771
2990
|
attention,
|
|
2772
2991
|
stories,
|
|
2773
2992
|
watches,
|
|
2993
|
+
soaking,
|
|
2774
2994
|
merge
|
|
2775
2995
|
};
|
|
2776
2996
|
}
|
|
@@ -2829,6 +3049,22 @@ function renderTend(r) {
|
|
|
2829
3049
|
)
|
|
2830
3050
|
);
|
|
2831
3051
|
}
|
|
3052
|
+
if (r.soaking.length > 0) {
|
|
3053
|
+
lines.push("");
|
|
3054
|
+
lines.push(
|
|
3055
|
+
toonTable(
|
|
3056
|
+
"soaking",
|
|
3057
|
+
r.soaking.map((s) => ({
|
|
3058
|
+
session: s.session,
|
|
3059
|
+
repo: s.repo,
|
|
3060
|
+
claimed: s.claimed ? s.claimed.slice(0, 8) : "",
|
|
3061
|
+
heartbeat: `${s.heartbeatAgeSecs}s ago`,
|
|
3062
|
+
worktree: s.worktree
|
|
3063
|
+
})),
|
|
3064
|
+
["session", "repo", "claimed", "heartbeat", "worktree"]
|
|
3065
|
+
)
|
|
3066
|
+
);
|
|
3067
|
+
}
|
|
2832
3068
|
if (r.merge && (r.merge.open.length > 0 || r.merge.recent.length > 0)) {
|
|
2833
3069
|
lines.push("");
|
|
2834
3070
|
lines.push(
|
|
@@ -2846,13 +3082,13 @@ function renderTend(r) {
|
|
|
2846
3082
|
}
|
|
2847
3083
|
|
|
2848
3084
|
// apps/cli/src/cull.ts
|
|
2849
|
-
import * as
|
|
2850
|
-
import * as
|
|
3085
|
+
import * as fs22 from "node:fs";
|
|
3086
|
+
import * as path17 from "node:path";
|
|
2851
3087
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
2852
3088
|
var DAY = 864e5;
|
|
2853
3089
|
function idsIn(dir) {
|
|
2854
3090
|
try {
|
|
2855
|
-
return new Set(
|
|
3091
|
+
return new Set(fs22.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => f.replace(/\.json$/, "")));
|
|
2856
3092
|
} catch {
|
|
2857
3093
|
return /* @__PURE__ */ new Set();
|
|
2858
3094
|
}
|
|
@@ -2867,39 +3103,39 @@ function planCull(olderThanDays, now = Date.now()) {
|
|
|
2867
3103
|
for (const id of idsIn(d.queue)) live.add(id);
|
|
2868
3104
|
for (const id of idsIn(d.active)) live.add(id);
|
|
2869
3105
|
for (const id of idsIn(d.done)) {
|
|
2870
|
-
const p =
|
|
2871
|
-
const m =
|
|
3106
|
+
const p = path17.join(d.done, id);
|
|
3107
|
+
const m = fs22.statSync(p).mtimeMs;
|
|
2872
3108
|
doneMtimes.set(id, m);
|
|
2873
3109
|
if (m < cutoff) items.push({ kind: "done", id, target: p, ageDays: Math.floor((now - m) / DAY) });
|
|
2874
3110
|
}
|
|
2875
3111
|
}
|
|
2876
|
-
const wtRoot =
|
|
3112
|
+
const wtRoot = path17.join(lobstahHome(), "worktrees");
|
|
2877
3113
|
for (const id of idsIn(wtRoot)) {
|
|
2878
3114
|
if (live.has(id)) continue;
|
|
2879
3115
|
const doneAt = doneMtimes.get(id);
|
|
2880
3116
|
if (doneAt !== void 0 && doneAt >= cutoff) continue;
|
|
2881
|
-
const p =
|
|
2882
|
-
items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now -
|
|
3117
|
+
const p = path17.join(wtRoot, id);
|
|
3118
|
+
items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now - fs22.statSync(p).mtimeMs) / DAY) });
|
|
2883
3119
|
}
|
|
2884
3120
|
for (const lane of ["work", "chore"]) {
|
|
2885
3121
|
const d = laneDirs(lane);
|
|
2886
3122
|
const seen = /* @__PURE__ */ new Set();
|
|
2887
|
-
for (const f of
|
|
3123
|
+
for (const f of fs22.readdirSync(d.state)) {
|
|
2888
3124
|
const id = f.replace(/\.(status|events|evidence|attn|notified|runner\.log)$/, "");
|
|
2889
3125
|
if (id === f || seen.has(id) || live.has(id) || doneMtimes.has(id)) continue;
|
|
2890
|
-
const m =
|
|
3126
|
+
const m = fs22.statSync(path17.join(d.state, f)).mtimeMs;
|
|
2891
3127
|
if (m >= cutoff) continue;
|
|
2892
3128
|
seen.add(id);
|
|
2893
|
-
items.push({ kind: "state", id, target:
|
|
3129
|
+
items.push({ kind: "state", id, target: path17.join(d.state, `${id}.*`), ageDays: Math.floor((now - m) / DAY) });
|
|
2894
3130
|
}
|
|
2895
3131
|
}
|
|
2896
3132
|
return items;
|
|
2897
3133
|
}
|
|
2898
3134
|
function removeWorktree(id, dir) {
|
|
2899
3135
|
for (const lane of ["work", "chore"]) {
|
|
2900
|
-
const descFile =
|
|
3136
|
+
const descFile = path17.join(laneDirs(lane).done, id, "descriptor.json");
|
|
2901
3137
|
try {
|
|
2902
|
-
const desc = JSON.parse(
|
|
3138
|
+
const desc = JSON.parse(fs22.readFileSync(descFile, "utf8"));
|
|
2903
3139
|
const repo = loadConfig().repos[desc.repo];
|
|
2904
3140
|
if (repo) {
|
|
2905
3141
|
execFileSync2("git", ["worktree", "remove", "--force", dir], { cwd: repo.path, stdio: "ignore" });
|
|
@@ -2908,15 +3144,15 @@ function removeWorktree(id, dir) {
|
|
|
2908
3144
|
} catch {
|
|
2909
3145
|
}
|
|
2910
3146
|
}
|
|
2911
|
-
|
|
3147
|
+
fs22.rmSync(dir, { recursive: true, force: true });
|
|
2912
3148
|
}
|
|
2913
3149
|
function applyCull(items) {
|
|
2914
3150
|
for (const item of items.filter((i) => i.kind === "worktree")) removeWorktree(item.id, item.target);
|
|
2915
|
-
for (const item of items.filter((i) => i.kind === "done"))
|
|
3151
|
+
for (const item of items.filter((i) => i.kind === "done")) fs22.rmSync(item.target, { recursive: true, force: true });
|
|
2916
3152
|
for (const item of items.filter((i) => i.kind === "state")) {
|
|
2917
|
-
const dir =
|
|
2918
|
-
for (const f of
|
|
2919
|
-
if (f.startsWith(`${item.id}.`))
|
|
3153
|
+
const dir = path17.dirname(item.target);
|
|
3154
|
+
for (const f of fs22.readdirSync(dir)) {
|
|
3155
|
+
if (f.startsWith(`${item.id}.`)) fs22.rmSync(path17.join(dir, f), { force: true });
|
|
2920
3156
|
}
|
|
2921
3157
|
}
|
|
2922
3158
|
}
|
|
@@ -2946,6 +3182,11 @@ your working set:
|
|
|
2946
3182
|
external (a review session, a
|
|
2947
3183
|
CI run) \u2014 its events wake you
|
|
2948
3184
|
like any dispatch would
|
|
3185
|
+
lobstah set ... --for session:<id> drop bait into a soaking trap:
|
|
3186
|
+
a live session that volunteered
|
|
3187
|
+
(\`lobstah soak\`) works it in
|
|
3188
|
+
its own worktree instead of a
|
|
3189
|
+
fresh headless spawn
|
|
2949
3190
|
|
|
2950
3191
|
getting woken instead of asking:
|
|
2951
3192
|
lobstah man wait block until a dispatch needs attention (arm it as a
|
|
@@ -2965,8 +3206,7 @@ paste into your liaison instructions:
|
|
|
2965
3206
|
full pattern and trade-offs: docs/lobsterman.md \xB7 config: docs/configuration.md`;
|
|
2966
3207
|
|
|
2967
3208
|
// apps/cli/src/doctor.ts
|
|
2968
|
-
import * as
|
|
2969
|
-
import { createRequire as createRequire3 } from "node:module";
|
|
3209
|
+
import * as fs23 from "node:fs";
|
|
2970
3210
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
2971
3211
|
var HEARTBEAT_STALE_MS2 = 9e4;
|
|
2972
3212
|
function git(repoPath, ...args) {
|
|
@@ -2982,25 +3222,20 @@ function runDoctor(now = Date.now()) {
|
|
|
2982
3222
|
const claude = onPath("claude");
|
|
2983
3223
|
push("harness claude", claude ? "ok" : "warn", claude ? "on PATH" : "claude not on PATH \u2014 claude dispatches will fail");
|
|
2984
3224
|
const codexPath = onPath("codex");
|
|
2985
|
-
|
|
2986
|
-
try {
|
|
2987
|
-
createRequire3(import.meta.url).resolve("@openai/codex-sdk");
|
|
2988
|
-
codexSdk = true;
|
|
2989
|
-
} catch {
|
|
2990
|
-
}
|
|
3225
|
+
const codexSdk = packagePresent("@openai/codex-sdk");
|
|
2991
3226
|
push(
|
|
2992
3227
|
"harness codex",
|
|
2993
3228
|
codexPath || codexSdk ? "ok" : "warn",
|
|
2994
3229
|
codexPath ? "on PATH" : codexSdk ? "vendored SDK (attach uses it too)" : "neither codex nor the SDK \u2014 codex dispatches will fail"
|
|
2995
3230
|
);
|
|
2996
3231
|
const cfgFile = configPath();
|
|
2997
|
-
if (!
|
|
3232
|
+
if (!fs23.existsSync(cfgFile)) {
|
|
2998
3233
|
push("config", "fail", `${cfgFile} missing \u2014 run \`lobstah init\``);
|
|
2999
3234
|
return rows;
|
|
3000
3235
|
}
|
|
3001
3236
|
let parsed;
|
|
3002
3237
|
try {
|
|
3003
|
-
parsed = parse(
|
|
3238
|
+
parsed = parse(fs23.readFileSync(cfgFile, "utf8"));
|
|
3004
3239
|
push("config", "ok", cfgFile);
|
|
3005
3240
|
} catch (err) {
|
|
3006
3241
|
push("config", "fail", `${cfgFile}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -3011,7 +3246,7 @@ function runDoctor(now = Date.now()) {
|
|
|
3011
3246
|
if (repoKeys.length === 0) push("repos", "warn", "none configured \u2014 add one with `lobstah repos add <path>`");
|
|
3012
3247
|
for (const [key, repo] of Object.entries(cfg.repos)) {
|
|
3013
3248
|
const label = `repo ${key}`;
|
|
3014
|
-
if (!
|
|
3249
|
+
if (!fs23.existsSync(repo.path)) {
|
|
3015
3250
|
push(label, "fail", `path ${repo.path} does not exist`);
|
|
3016
3251
|
continue;
|
|
3017
3252
|
}
|
|
@@ -3039,11 +3274,11 @@ function runDoctor(now = Date.now()) {
|
|
|
3039
3274
|
}
|
|
3040
3275
|
}
|
|
3041
3276
|
const hb = executorPath();
|
|
3042
|
-
if (!
|
|
3277
|
+
if (!fs23.existsSync(hb)) {
|
|
3043
3278
|
push("daemon", "warn", "no heartbeat \u2014 daemon not running (`lobstah daemon install`)");
|
|
3044
3279
|
} else {
|
|
3045
3280
|
try {
|
|
3046
|
-
const payload = JSON.parse(
|
|
3281
|
+
const payload = JSON.parse(fs23.readFileSync(hb, "utf8"));
|
|
3047
3282
|
const age = now - new Date(payload.heartbeat ?? 0).getTime();
|
|
3048
3283
|
push(
|
|
3049
3284
|
"daemon",
|
|
@@ -3059,9 +3294,9 @@ function runDoctor(now = Date.now()) {
|
|
|
3059
3294
|
}
|
|
3060
3295
|
|
|
3061
3296
|
// apps/cli/src/service.ts
|
|
3062
|
-
import * as
|
|
3297
|
+
import * as fs24 from "node:fs";
|
|
3063
3298
|
import * as os5 from "node:os";
|
|
3064
|
-
import * as
|
|
3299
|
+
import * as path18 from "node:path";
|
|
3065
3300
|
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
3066
3301
|
function xml(s) {
|
|
3067
3302
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
@@ -3085,8 +3320,8 @@ function renderLaunchdPlist(spec) {
|
|
|
3085
3320
|
</dict>
|
|
3086
3321
|
<key>RunAtLoad</key><true/>
|
|
3087
3322
|
<key>KeepAlive</key><true/>
|
|
3088
|
-
<key>StandardOutPath</key><string>${xml(
|
|
3089
|
-
<key>StandardErrorPath</key><string>${xml(
|
|
3323
|
+
<key>StandardOutPath</key><string>${xml(path18.posix.join(spec.logDir, `${spec.kind}.log`))}</string>
|
|
3324
|
+
<key>StandardErrorPath</key><string>${xml(path18.posix.join(spec.logDir, `${spec.kind}.err`))}</string>
|
|
3090
3325
|
</dict>
|
|
3091
3326
|
</plist>
|
|
3092
3327
|
`;
|
|
@@ -3101,8 +3336,8 @@ Restart=always
|
|
|
3101
3336
|
RestartSec=5
|
|
3102
3337
|
Environment=PATH=${spec.pathEnv}
|
|
3103
3338
|
Environment=LOBSTAH_HOME=${spec.home}
|
|
3104
|
-
StandardOutput=append:${
|
|
3105
|
-
StandardError=append:${
|
|
3339
|
+
StandardOutput=append:${path18.posix.join(spec.logDir, `${spec.kind}.log`)}
|
|
3340
|
+
StandardError=append:${path18.posix.join(spec.logDir, `${spec.kind}.err`)}
|
|
3106
3341
|
|
|
3107
3342
|
[Install]
|
|
3108
3343
|
WantedBy=default.target
|
|
@@ -3110,8 +3345,8 @@ WantedBy=default.target
|
|
|
3110
3345
|
}
|
|
3111
3346
|
function servicePathEnv(nodePath, home = os5.homedir()) {
|
|
3112
3347
|
const dirs = [
|
|
3113
|
-
|
|
3114
|
-
|
|
3348
|
+
path18.posix.dirname(nodePath),
|
|
3349
|
+
path18.posix.join(home, ".local", "bin"),
|
|
3115
3350
|
"/opt/homebrew/bin",
|
|
3116
3351
|
"/usr/local/bin",
|
|
3117
3352
|
"/usr/bin",
|
|
@@ -3120,7 +3355,7 @@ function servicePathEnv(nodePath, home = os5.homedir()) {
|
|
|
3120
3355
|
return [...new Set(dirs)].join(":");
|
|
3121
3356
|
}
|
|
3122
3357
|
function serviceFile(kind) {
|
|
3123
|
-
return process.platform === "darwin" ?
|
|
3358
|
+
return process.platform === "darwin" ? path18.join(os5.homedir(), "Library", "LaunchAgents", `lobstah.${kind}.plist`) : path18.join(os5.homedir(), ".config", "systemd", "user", `lobstah-${kind}.service`);
|
|
3124
3359
|
}
|
|
3125
3360
|
function run(cmd, args) {
|
|
3126
3361
|
const res = spawnSync3(cmd, args, { encoding: "utf8" });
|
|
@@ -3130,28 +3365,28 @@ function installService(kind) {
|
|
|
3130
3365
|
if (process.platform === "win32") {
|
|
3131
3366
|
throw new Error(`no service manager support on Windows \u2014 run \`lobstah ${kind}\` under your process manager of choice`);
|
|
3132
3367
|
}
|
|
3133
|
-
const entry =
|
|
3368
|
+
const entry = fs24.realpathSync(process.argv[1]);
|
|
3134
3369
|
const spec = {
|
|
3135
3370
|
kind,
|
|
3136
3371
|
nodePath: process.execPath,
|
|
3137
3372
|
entry,
|
|
3138
3373
|
pathEnv: servicePathEnv(process.execPath),
|
|
3139
3374
|
home: lobstahHome(),
|
|
3140
|
-
logDir:
|
|
3375
|
+
logDir: path18.join(lobstahHome(), "logs")
|
|
3141
3376
|
};
|
|
3142
3377
|
if (!onPath("git", spec.pathEnv)) {
|
|
3143
3378
|
throw new Error(`git is not reachable on the service PATH (${spec.pathEnv}) \u2014 the daemon cannot allocate worktrees`);
|
|
3144
3379
|
}
|
|
3145
|
-
|
|
3380
|
+
fs24.mkdirSync(spec.logDir, { recursive: true });
|
|
3146
3381
|
const file = serviceFile(kind);
|
|
3147
|
-
|
|
3382
|
+
fs24.mkdirSync(path18.dirname(file), { recursive: true });
|
|
3148
3383
|
if (process.platform === "darwin") {
|
|
3149
|
-
if (
|
|
3150
|
-
|
|
3384
|
+
if (fs24.existsSync(file)) run("launchctl", ["unload", file]);
|
|
3385
|
+
fs24.writeFileSync(file, renderLaunchdPlist(spec));
|
|
3151
3386
|
const load = run("launchctl", ["load", file]);
|
|
3152
3387
|
return { file, loaded: load.ok, detail: load.ok ? `label lobstah.${kind}` : load.out };
|
|
3153
3388
|
}
|
|
3154
|
-
|
|
3389
|
+
fs24.writeFileSync(file, renderSystemdUnit(spec));
|
|
3155
3390
|
run("systemctl", ["--user", "daemon-reload"]);
|
|
3156
3391
|
const enable = run("systemctl", ["--user", "enable", "--now", `lobstah-${kind}.service`]);
|
|
3157
3392
|
return { file, loaded: enable.ok, detail: enable.ok ? `unit lobstah-${kind}.service` : enable.out };
|
|
@@ -3159,19 +3394,19 @@ function installService(kind) {
|
|
|
3159
3394
|
function uninstallService(kind) {
|
|
3160
3395
|
const file = serviceFile(kind);
|
|
3161
3396
|
if (process.platform === "darwin") {
|
|
3162
|
-
if (
|
|
3397
|
+
if (fs24.existsSync(file)) run("launchctl", ["unload", file]);
|
|
3163
3398
|
} else {
|
|
3164
3399
|
run("systemctl", ["--user", "disable", "--now", `lobstah-${kind}.service`]);
|
|
3165
3400
|
}
|
|
3166
|
-
const removed =
|
|
3167
|
-
if (removed)
|
|
3401
|
+
const removed = fs24.existsSync(file);
|
|
3402
|
+
if (removed) fs24.unlinkSync(file);
|
|
3168
3403
|
if (process.platform !== "darwin") run("systemctl", ["--user", "daemon-reload"]);
|
|
3169
3404
|
return { file, removed };
|
|
3170
3405
|
}
|
|
3171
3406
|
|
|
3172
3407
|
// apps/cli/src/repos.ts
|
|
3173
|
-
import * as
|
|
3174
|
-
import * as
|
|
3408
|
+
import * as fs25 from "node:fs";
|
|
3409
|
+
import * as path19 from "node:path";
|
|
3175
3410
|
import { spawnSync as spawnSync4 } from "node:child_process";
|
|
3176
3411
|
function git2(dir, ...args) {
|
|
3177
3412
|
const res = spawnSync4("git", ["-C", dir, ...args], { encoding: "utf8" });
|
|
@@ -3187,41 +3422,41 @@ var LOCKFILE_SETUP = [
|
|
|
3187
3422
|
["poetry.lock", ["poetry install"]]
|
|
3188
3423
|
];
|
|
3189
3424
|
function detectRepo(dir) {
|
|
3190
|
-
const abs =
|
|
3425
|
+
const abs = path19.resolve(dir);
|
|
3191
3426
|
if (git2(abs, "rev-parse", "--is-inside-work-tree") !== "true") return void 0;
|
|
3192
3427
|
const top = git2(abs, "rev-parse", "--show-toplevel");
|
|
3193
|
-
const
|
|
3194
|
-
const real =
|
|
3428
|
+
const canon2 = (p) => {
|
|
3429
|
+
const real = path19.resolve(fs25.realpathSync.native(p));
|
|
3195
3430
|
return process.platform === "win32" ? real.toLowerCase() : real;
|
|
3196
3431
|
};
|
|
3197
|
-
if (!top ||
|
|
3432
|
+
if (!top || canon2(top) !== canon2(abs)) return void 0;
|
|
3198
3433
|
const origin = git2(abs, "remote", "get-url", "origin");
|
|
3199
3434
|
const headRef = git2(abs, "symbolic-ref", "--short", "refs/remotes/origin/HEAD");
|
|
3200
3435
|
const trunk = headRef?.replace(/^origin\//, "") ?? git2(abs, "branch", "--show-current") ?? "main";
|
|
3201
|
-
const setup = LOCKFILE_SETUP.find(([f]) =>
|
|
3202
|
-
return { key:
|
|
3436
|
+
const setup = LOCKFILE_SETUP.find(([f]) => fs25.existsSync(path19.join(abs, f)))?.[1];
|
|
3437
|
+
return { key: path19.basename(abs), path: abs, trunk, origin, setup };
|
|
3203
3438
|
}
|
|
3204
3439
|
function scanForRepos(roots) {
|
|
3205
3440
|
const found = [];
|
|
3206
3441
|
for (const root of roots) {
|
|
3207
|
-
const abs =
|
|
3208
|
-
if (!
|
|
3442
|
+
const abs = path19.resolve(root);
|
|
3443
|
+
if (!fs25.existsSync(abs)) continue;
|
|
3209
3444
|
const self = detectRepo(abs);
|
|
3210
3445
|
if (self) {
|
|
3211
3446
|
found.push(self);
|
|
3212
3447
|
continue;
|
|
3213
3448
|
}
|
|
3214
|
-
for (const entry of
|
|
3449
|
+
for (const entry of fs25.readdirSync(abs, { withFileTypes: true })) {
|
|
3215
3450
|
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
3216
|
-
const child = detectRepo(
|
|
3451
|
+
const child = detectRepo(path19.join(abs, entry.name));
|
|
3217
3452
|
if (child) found.push(child);
|
|
3218
3453
|
}
|
|
3219
3454
|
}
|
|
3220
3455
|
return found;
|
|
3221
3456
|
}
|
|
3222
3457
|
function configuredRepoKeys(file = configPath()) {
|
|
3223
|
-
if (!
|
|
3224
|
-
const raw = parse(
|
|
3458
|
+
if (!fs25.existsSync(file)) return /* @__PURE__ */ new Set();
|
|
3459
|
+
const raw = parse(fs25.readFileSync(file, "utf8"));
|
|
3225
3460
|
return new Set(Object.keys(raw.repos ?? {}));
|
|
3226
3461
|
}
|
|
3227
3462
|
function appendRepoBlock(repo, opts = {}, file = configPath()) {
|
|
@@ -3229,21 +3464,232 @@ function appendRepoBlock(repo, opts = {}, file = configPath()) {
|
|
|
3229
3464
|
if (repo.origin) lines.push(`origin = ${JSON.stringify(repo.origin)}`);
|
|
3230
3465
|
if (repo.setup) lines.push(`setup = [${repo.setup.map((s) => JSON.stringify(s)).join(", ")}]`);
|
|
3231
3466
|
if (opts.pickup) lines.push(`pickup = true`);
|
|
3232
|
-
|
|
3467
|
+
fs25.appendFileSync(file, `${lines.join("\n")}
|
|
3233
3468
|
`);
|
|
3234
3469
|
}
|
|
3235
3470
|
|
|
3471
|
+
// apps/cli/src/report-args.ts
|
|
3472
|
+
function parseReportArgs(rest) {
|
|
3473
|
+
const prIndex = rest.indexOf("--pr");
|
|
3474
|
+
const prUrl = prIndex >= 0 ? rest[prIndex + 1] : void 0;
|
|
3475
|
+
const note = rest.filter((_, i) => prIndex < 0 || i !== prIndex && i !== prIndex + 1).join(" ") || void 0;
|
|
3476
|
+
return { note, prUrl };
|
|
3477
|
+
}
|
|
3478
|
+
|
|
3479
|
+
// apps/cli/src/soak-site.ts
|
|
3480
|
+
import * as fs26 from "node:fs";
|
|
3481
|
+
import * as path20 from "node:path";
|
|
3482
|
+
import { spawnSync as spawnSync5 } from "node:child_process";
|
|
3483
|
+
function git3(dir, ...args) {
|
|
3484
|
+
const res = spawnSync5("git", ["-C", dir, ...args], { encoding: "utf8" });
|
|
3485
|
+
return res.status === 0 ? res.stdout.trim() : void 0;
|
|
3486
|
+
}
|
|
3487
|
+
function canon(p) {
|
|
3488
|
+
try {
|
|
3489
|
+
const real = path20.resolve(fs26.realpathSync.native(p));
|
|
3490
|
+
return process.platform === "win32" ? real.toLowerCase() : real;
|
|
3491
|
+
} catch {
|
|
3492
|
+
return path20.resolve(p);
|
|
3493
|
+
}
|
|
3494
|
+
}
|
|
3495
|
+
function inspectSoakSite(cwd, repos) {
|
|
3496
|
+
const top = git3(cwd, "rev-parse", "--show-toplevel");
|
|
3497
|
+
if (!top) return void 0;
|
|
3498
|
+
const gitDir = git3(cwd, "rev-parse", "--git-dir");
|
|
3499
|
+
const commonDir = git3(cwd, "rev-parse", "--git-common-dir");
|
|
3500
|
+
if (!gitDir || !commonDir) return void 0;
|
|
3501
|
+
const abs = (p) => path20.isAbsolute(p) ? p : path20.join(cwd, p);
|
|
3502
|
+
const primary = canon(abs(gitDir)) === canon(abs(commonDir));
|
|
3503
|
+
const primaryRoot = canon(path20.dirname(abs(commonDir)));
|
|
3504
|
+
const repoKey = Object.entries(repos).find(([, r]) => canon(r.path) === primaryRoot)?.[0];
|
|
3505
|
+
return { worktree: canon(top), primary, repoKey };
|
|
3506
|
+
}
|
|
3507
|
+
function readHookStdin() {
|
|
3508
|
+
if (process.stdin.isTTY) return void 0;
|
|
3509
|
+
try {
|
|
3510
|
+
const raw = fs26.readFileSync(0, "utf8");
|
|
3511
|
+
if (!raw.trim()) return void 0;
|
|
3512
|
+
const parsed = JSON.parse(raw);
|
|
3513
|
+
return typeof parsed === "object" && parsed !== null ? parsed : void 0;
|
|
3514
|
+
} catch {
|
|
3515
|
+
return void 0;
|
|
3516
|
+
}
|
|
3517
|
+
}
|
|
3518
|
+
|
|
3519
|
+
// apps/cli/src/usage.ts
|
|
3520
|
+
var HARNESS = "claude|codex";
|
|
3521
|
+
var COMMANDS = {
|
|
3522
|
+
dispatch: {
|
|
3523
|
+
flags: {
|
|
3524
|
+
"--repo": { value: "<key>", required: true },
|
|
3525
|
+
"--brief": { value: "<file>" },
|
|
3526
|
+
"--bait": { value: "<file>" },
|
|
3527
|
+
"--brief-text": { value: "<text>" },
|
|
3528
|
+
"--harness": { value: HARNESS },
|
|
3529
|
+
"--model": { value: "<m>" },
|
|
3530
|
+
"--effort": { value: "<e>" },
|
|
3531
|
+
"--follow-up": { value: "<uuid>" },
|
|
3532
|
+
"--for": { value: "session:<id>" },
|
|
3533
|
+
"--chore": {},
|
|
3534
|
+
"--id": { value: "<uuid>" }
|
|
3535
|
+
}
|
|
3536
|
+
},
|
|
3537
|
+
ls: { flags: { "--all": {} } },
|
|
3538
|
+
status: { flags: {}, positionals: "[<uuid>]" },
|
|
3539
|
+
logs: { flags: { "--follow": {}, "--full": {} }, positionals: "<uuid>" },
|
|
3540
|
+
send: { flags: {}, positionals: "<uuid> <message...>", tailAfter: 1 },
|
|
3541
|
+
inbox: { flags: {}, positionals: "<uuid>" },
|
|
3542
|
+
attach: { flags: { "--print": {}, "--force": {} }, positionals: "<uuid>" },
|
|
3543
|
+
swap: {
|
|
3544
|
+
flags: { "--harness": { value: HARNESS }, "--model": { value: "<m>" }, "--effort": { value: "<e>" } },
|
|
3545
|
+
positionals: "<uuid>"
|
|
3546
|
+
},
|
|
3547
|
+
catch: { flags: {}, positionals: "<uuid>" },
|
|
3548
|
+
cull: { flags: { "--older-than": { value: "<days>" }, "--apply": {} } },
|
|
3549
|
+
cancel: { flags: {}, positionals: "<uuid>" },
|
|
3550
|
+
report: { flags: {}, positionals: "<uuid> <verb> [note...] [--pr <url>]", tailAfter: 2 },
|
|
3551
|
+
watch: {
|
|
3552
|
+
subverbs: ["add", "rm", "ls"],
|
|
3553
|
+
flags: {
|
|
3554
|
+
"--check": { value: "<cmd>" },
|
|
3555
|
+
"--for": { value: "<uuid>" },
|
|
3556
|
+
"--cursor": { value: "<c>" },
|
|
3557
|
+
"--every": { value: "<s>" },
|
|
3558
|
+
"--brief": { value: "<template>" },
|
|
3559
|
+
"--stream": { value: "<cmd>" }
|
|
3560
|
+
},
|
|
3561
|
+
positionals: "[<key>]"
|
|
3562
|
+
},
|
|
3563
|
+
soak: { flags: { "--session": { value: "<id>", required: true }, "--one": {}, "--harness": { value: HARNESS } } },
|
|
3564
|
+
stow: { flags: { "--session": { value: "<id>" }, "--quiet": {} } },
|
|
3565
|
+
daemon: { subverbs: ["install", "uninstall"], flags: { "--interval": { value: "<ms>" } } },
|
|
3566
|
+
pick: { subverbs: ["once", "install", "uninstall"], flags: {} },
|
|
3567
|
+
doctor: { flags: {} },
|
|
3568
|
+
repos: { subverbs: ["add"], flags: { "--pickup": {}, "--key": { value: "<k>" } }, positionals: "[<path>]" },
|
|
3569
|
+
init: { flags: { "--scan": {}, "--pickup": {} }, positionals: "[<dir>...]" },
|
|
3570
|
+
version: { flags: {} },
|
|
3571
|
+
"man:manual": { flags: {} },
|
|
3572
|
+
"man:tend": { flags: { "--json": {} } },
|
|
3573
|
+
"man:wait": { flags: { "--timeout": { value: "<secs>" }, "--peek": {} } },
|
|
3574
|
+
"man:init": { flags: { "--shared": {}, "--global": {}, "--marker": {} } },
|
|
3575
|
+
"man:haul": { flags: { "--timeout": { value: "<secs>" } } },
|
|
3576
|
+
"man:brief": { flags: {} }
|
|
3577
|
+
};
|
|
3578
|
+
var PROSE = {
|
|
3579
|
+
dispatch: `Queue a supervised dispatch; prints the id. --for addresses the bait to a
|
|
3580
|
+
soaking session instead of a fresh headless worker. Alias: set --bait.`,
|
|
3581
|
+
ls: `Queue, active, and recent done dispatches (--all includes chores). Alias: buoys.`,
|
|
3582
|
+
status: `Reconciled state for one dispatch, or all active without an id. Alias: buoy.`,
|
|
3583
|
+
logs: `The dispatch's normalized event stream \u2014 last 50 events by default,
|
|
3584
|
+
--full for everything, --follow to tail.`,
|
|
3585
|
+
send: `Deliver an instruction to a running dispatch between its turns.`,
|
|
3586
|
+
inbox: `Read and acknowledge pending messages (workers: check at natural checkpoints).`,
|
|
3587
|
+
attach: `Open the dispatch's own harness session in its worktree. Refused while
|
|
3588
|
+
working unless --force; --print shows the command instead of running it.`,
|
|
3589
|
+
swap: `Hand an active dispatch to a fresh session \u2014 same worktree and brief plus a
|
|
3590
|
+
git progress note.`,
|
|
3591
|
+
catch: `The evidence: branch, commits, PR, session.`,
|
|
3592
|
+
cull: `Sweep aged done entries, orphaned worktrees, and stale state. Dry run
|
|
3593
|
+
without --apply (default 14 days).`,
|
|
3594
|
+
cancel: `Request cancellation; the daemon (or the claiming session) winds it down.`,
|
|
3595
|
+
report: `The validated status write path: working | needs-decision | blocked |
|
|
3596
|
+
paused | done | failed.`,
|
|
3597
|
+
watch: `Stand watch on something external; bare \`watch\` (or \`watch ls\`) lists.
|
|
3598
|
+
The check command answers "anything since {cursor}?" in JSON.`,
|
|
3599
|
+
soak: `Volunteer this session as a worker: it parks at turn end and takes matching
|
|
3600
|
+
bait. Refused from a primary checkout \u2014 soak from a worktree. --one stows
|
|
3601
|
+
after the first catch.`,
|
|
3602
|
+
stow: `Sign a soaking session off; an open catch goes back to the queue.`,
|
|
3603
|
+
daemon: `The supervisor process (claims, worktrees, liveness, restarts). install
|
|
3604
|
+
writes + loads a launchd agent / systemd user unit.`,
|
|
3605
|
+
pick: `Tracker loops: poll Linear/GitHub, dispatch assigned work, report back,
|
|
3606
|
+
reconcile, merge.`,
|
|
3607
|
+
doctor: `Check binaries, config, repos, harnesses, and the daemon heartbeat; exit 1
|
|
3608
|
+
on failures.`,
|
|
3609
|
+
repos: `List configured repos, or detect one and append its [repos.*] block.`,
|
|
3610
|
+
init: `Create ~/.lobstah + config; --scan appends a [repos.*] block per repo found
|
|
3611
|
+
under the given directories.`,
|
|
3612
|
+
version: `The installed lobstah version.`,
|
|
3613
|
+
"man:manual": `The lobsterman's manual.`,
|
|
3614
|
+
"man:tend": `The whole-fleet pass: verdict, unanswered questions, each work item's chain,
|
|
3615
|
+
PR, and merge gate. Pure disk read.`,
|
|
3616
|
+
"man:wait": `Block until a dispatch or watched source needs attention; exit 3 on timeout.
|
|
3617
|
+
--peek surfaces standing events without consuming them.`,
|
|
3618
|
+
"man:init": `Install the haul Stop hook into Claude settings; --marker touches
|
|
3619
|
+
.lobstah-man to arm this directory.`,
|
|
3620
|
+
"man:haul": `Stop-hook entry point: park the session while work is in flight (lobsterman
|
|
3621
|
+
or soaking worker); prints hook-decision JSON on an event.`,
|
|
3622
|
+
"man:brief": `SessionStart-hook entry point: announce the session id and fleet state into
|
|
3623
|
+
the conversation.`
|
|
3624
|
+
};
|
|
3625
|
+
function synopsis(cmd) {
|
|
3626
|
+
const spec = COMMANDS[cmd];
|
|
3627
|
+
if (!spec) return `lobstah ${cmd.replace(":", " ")}`;
|
|
3628
|
+
const parts = [`lobstah ${cmd.replace(":", " ")}`];
|
|
3629
|
+
if (spec.subverbs) parts.push(`[${spec.subverbs.join("|")}]`);
|
|
3630
|
+
if (spec.positionals) parts.push(spec.positionals);
|
|
3631
|
+
for (const [flag, f] of Object.entries(spec.flags)) {
|
|
3632
|
+
const body = f.value ? `${flag} ${f.value}` : flag;
|
|
3633
|
+
parts.push(f.required ? body : `[${body}]`);
|
|
3634
|
+
}
|
|
3635
|
+
const lines = [];
|
|
3636
|
+
let line = "";
|
|
3637
|
+
for (const part of parts) {
|
|
3638
|
+
const candidate = line === "" ? part : `${line} ${part}`;
|
|
3639
|
+
if (candidate.length > 78 && line !== "") {
|
|
3640
|
+
lines.push(line);
|
|
3641
|
+
line = ` ${part}`;
|
|
3642
|
+
} else {
|
|
3643
|
+
line = candidate;
|
|
3644
|
+
}
|
|
3645
|
+
}
|
|
3646
|
+
lines.push(line);
|
|
3647
|
+
return lines.join("\n");
|
|
3648
|
+
}
|
|
3649
|
+
function usageFor(cmd) {
|
|
3650
|
+
if (!COMMANDS[cmd]) return void 0;
|
|
3651
|
+
const prose = PROSE[cmd];
|
|
3652
|
+
return prose ? `${synopsis(cmd)}
|
|
3653
|
+
${prose}` : synopsis(cmd);
|
|
3654
|
+
}
|
|
3655
|
+
var UsageError = class extends Error {
|
|
3656
|
+
};
|
|
3657
|
+
function validateArgs(cmd, args) {
|
|
3658
|
+
const spec = COMMANDS[cmd];
|
|
3659
|
+
if (!spec) return void 0;
|
|
3660
|
+
let positionals = 0;
|
|
3661
|
+
for (let i = 0; i < args.length; i++) {
|
|
3662
|
+
const tok = args[i];
|
|
3663
|
+
if (spec.tailAfter !== void 0 && positionals >= spec.tailAfter) break;
|
|
3664
|
+
if (!tok.startsWith("--")) {
|
|
3665
|
+
if (positionals === 0 && spec.subverbs && !spec.subverbs.includes(tok)) {
|
|
3666
|
+
return { error: `unknown ${cmd} subcommand "${tok}" (expected ${spec.subverbs.join(" | ")})` };
|
|
3667
|
+
}
|
|
3668
|
+
positionals++;
|
|
3669
|
+
continue;
|
|
3670
|
+
}
|
|
3671
|
+
if (tok === "--help") return { help: true };
|
|
3672
|
+
const f = spec.flags[tok];
|
|
3673
|
+
if (!f) return { error: `unknown flag ${tok} for ${cmd.replace(":", " ")}` };
|
|
3674
|
+
if (f.value) i++;
|
|
3675
|
+
}
|
|
3676
|
+
return {};
|
|
3677
|
+
}
|
|
3678
|
+
|
|
3236
3679
|
// apps/cli/src/main.ts
|
|
3237
3680
|
var HELP = `lobstah \u2014 supervision framework for coding agents
|
|
3238
3681
|
|
|
3239
3682
|
work (humans and agents):
|
|
3240
3683
|
dispatch --repo <key> (--brief <file> | --brief-text <text>) (alias: set --bait)
|
|
3241
3684
|
[--harness claude|codex] [--model <m>] [--effort <e>]
|
|
3242
|
-
[--follow-up <uuid>] [--chore] [--id <uuid>]
|
|
3243
|
-
queue a supervised dispatch; prints the id
|
|
3685
|
+
[--follow-up <uuid>] [--for session:<id>] [--chore] [--id <uuid>]
|
|
3686
|
+
queue a supervised dispatch; prints the id.
|
|
3687
|
+
--for addresses the bait to a soaking
|
|
3688
|
+
session instead of a fresh headless worker
|
|
3244
3689
|
ls [--all] queue, active, recent done (alias: buoys)
|
|
3245
3690
|
status [<uuid>] reconciled state (alias: buoy)
|
|
3246
|
-
logs <uuid> [--follow]
|
|
3691
|
+
logs <uuid> [--follow|--full] the normalized event stream (last 50 events
|
|
3692
|
+
by default; --full for everything)
|
|
3247
3693
|
send <uuid> <message> deliver an instruction between agent turns
|
|
3248
3694
|
inbox <uuid> read and acknowledge pending messages
|
|
3249
3695
|
(workers: check at natural checkpoints)
|
|
@@ -3289,7 +3735,7 @@ lobsterman (orchestrator sessions \u2014 bare \`lobstah man\` prints the manual)
|
|
|
3289
3735
|
man wait [--timeout <secs>] [--peek]
|
|
3290
3736
|
block until a dispatch or watched source
|
|
3291
3737
|
needs attention, then print the event and
|
|
3292
|
-
what to do next; exit
|
|
3738
|
+
what to do next; exit 3 on timeout. Runs due
|
|
3293
3739
|
watch checks itself when no pick process is.
|
|
3294
3740
|
Unanswered questions re-fire every
|
|
3295
3741
|
remindSecs until answered.
|
|
@@ -3310,6 +3756,19 @@ workers (dispatched agents; injected into every brief):
|
|
|
3310
3756
|
the validated status write path
|
|
3311
3757
|
(${VERBS.join(" | ")})
|
|
3312
3758
|
|
|
3759
|
+
soaking (interactive sessions volunteering as workers):
|
|
3760
|
+
soak --session <id> [--one] [--harness claude|codex]
|
|
3761
|
+
put this session in the water: it parks at
|
|
3762
|
+
turn end and takes matching bait from the
|
|
3763
|
+
work queue. Refused from a primary checkout
|
|
3764
|
+
\u2014 soak from a worktree. --one stows after
|
|
3765
|
+
the first catch. The session id comes from
|
|
3766
|
+
the plugin's session-start brief.
|
|
3767
|
+
stow [--session <id>] [--quiet] take the trap out: sign the session off; an
|
|
3768
|
+
open catch goes back to the queue. A stale
|
|
3769
|
+
registration (ghost trap) is swept
|
|
3770
|
+
automatically after [soak].ttlSecs.
|
|
3771
|
+
|
|
3313
3772
|
setup:
|
|
3314
3773
|
init [--scan <dir>... [--pickup]]
|
|
3315
3774
|
create ~/.lobstah + example config; --scan
|
|
@@ -3330,6 +3789,40 @@ function arg(args, flag) {
|
|
|
3330
3789
|
return i >= 0 ? args[i + 1] : void 0;
|
|
3331
3790
|
}
|
|
3332
3791
|
var WATCH_EVERY_SECS = 45;
|
|
3792
|
+
async function soakPark(sessionId, args) {
|
|
3793
|
+
const timeoutSecs = Number(arg(args, "--timeout") ?? "14000");
|
|
3794
|
+
const deadline = Date.now() + timeoutSecs * 1e3;
|
|
3795
|
+
const block = (reason) => console.log(JSON.stringify({ decision: "block", reason }));
|
|
3796
|
+
while (true) {
|
|
3797
|
+
const reg = heartbeatSoak(sessionId);
|
|
3798
|
+
if (!reg) return;
|
|
3799
|
+
if (hasOpenCatch(reg)) {
|
|
3800
|
+
const id = reg.claimed;
|
|
3801
|
+
if (cancelRequested(id, "work")) {
|
|
3802
|
+
block(
|
|
3803
|
+
`Your catch ${id} was cancelled. Stop working it, leave the worktree as it is, and run \`lobstah report ${id} failed "cancelled by request"\`.`
|
|
3804
|
+
);
|
|
3805
|
+
return;
|
|
3806
|
+
}
|
|
3807
|
+
if (unhandled(id, "work").length > 0) {
|
|
3808
|
+
block(`New instruction for your catch ${id} \u2014 read it with \`lobstah inbox ${id}\`, act on it, and keep reporting.`);
|
|
3809
|
+
return;
|
|
3810
|
+
}
|
|
3811
|
+
} else {
|
|
3812
|
+
if (reg.one && reg.claimed) {
|
|
3813
|
+
stowSoak(sessionId);
|
|
3814
|
+
return;
|
|
3815
|
+
}
|
|
3816
|
+
const caught = claimBait(reg);
|
|
3817
|
+
if (caught) {
|
|
3818
|
+
block(baitBrief(caught.id, caught.descriptor));
|
|
3819
|
+
return;
|
|
3820
|
+
}
|
|
3821
|
+
}
|
|
3822
|
+
if (Date.now() >= deadline) return;
|
|
3823
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3333
3826
|
function runDueManWatches() {
|
|
3334
3827
|
for (const w of listWatches()) {
|
|
3335
3828
|
if (w.owner === "man" && watchDue(w, WATCH_EVERY_SECS)) runWatchCheck(w);
|
|
@@ -3348,7 +3841,7 @@ function emitWatchAttention(attns) {
|
|
|
3348
3841
|
function findLane(id) {
|
|
3349
3842
|
for (const lane of ["work", "chore"]) {
|
|
3350
3843
|
const d = laneDirs(lane);
|
|
3351
|
-
if (
|
|
3844
|
+
if (fs27.existsSync(path21.join(d.active, id)) || fs27.existsSync(path21.join(d.queue, `${id}.json`)) || fs27.existsSync(path21.join(d.done, id)) || fs27.existsSync(path21.join(d.state, `${id}.status`))) {
|
|
3352
3845
|
return lane;
|
|
3353
3846
|
}
|
|
3354
3847
|
}
|
|
@@ -3356,7 +3849,7 @@ function findLane(id) {
|
|
|
3356
3849
|
}
|
|
3357
3850
|
function rowsFor(lane, bucket) {
|
|
3358
3851
|
const dir = laneDirs(lane)[bucket];
|
|
3359
|
-
const entries =
|
|
3852
|
+
const entries = fs27.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => ({ f, m: fs27.statSync(path21.join(dir, f)).mtimeMs })).sort((a, b) => b.m - a.m);
|
|
3360
3853
|
const sliced = bucket === "done" ? entries.slice(0, 10) : entries;
|
|
3361
3854
|
return sliced.map(({ f, m }) => {
|
|
3362
3855
|
const id = f.replace(/\.json$/, "");
|
|
@@ -3369,10 +3862,20 @@ async function mainCli() {
|
|
|
3369
3862
|
const ALIASES = { set: "dispatch", buoys: "ls", buoy: "status" };
|
|
3370
3863
|
cmd = cmd !== void 0 ? ALIASES[cmd] ?? cmd : cmd;
|
|
3371
3864
|
if (cmd === "man") {
|
|
3372
|
-
cmd = args.length > 0 ? `man:${args[0]}` : "man:manual";
|
|
3865
|
+
cmd = args.length > 0 && args[0] !== "--help" ? `man:${args[0]}` : "man:manual";
|
|
3373
3866
|
args = args.slice(1);
|
|
3374
3867
|
}
|
|
3375
3868
|
ensureLayout();
|
|
3869
|
+
if (cmd !== void 0) {
|
|
3870
|
+
const v = validateArgs(cmd, args);
|
|
3871
|
+
if (v?.help) {
|
|
3872
|
+
console.log(usageFor(cmd));
|
|
3873
|
+
return;
|
|
3874
|
+
}
|
|
3875
|
+
if (v?.error) throw new UsageError(`${v.error}
|
|
3876
|
+
|
|
3877
|
+
${usageFor(cmd)}`);
|
|
3878
|
+
}
|
|
3376
3879
|
switch (cmd) {
|
|
3377
3880
|
case "dispatch": {
|
|
3378
3881
|
const repo = arg(args, "--repo");
|
|
@@ -3381,18 +3884,30 @@ async function mainCli() {
|
|
|
3381
3884
|
if (!repo || !briefFile && !briefText) {
|
|
3382
3885
|
throw new Error("dispatch requires --repo and --brief <file> (or --brief-text)");
|
|
3383
3886
|
}
|
|
3887
|
+
const address = arg(args, "--for");
|
|
3888
|
+
if (address && !address.startsWith("session:")) {
|
|
3889
|
+
throw new Error("dispatch --for takes a session address: --for session:<id>");
|
|
3890
|
+
}
|
|
3384
3891
|
const d = {
|
|
3385
3892
|
id: arg(args, "--id") ?? randomUUID4(),
|
|
3386
3893
|
repo,
|
|
3387
|
-
brief: briefText ??
|
|
3894
|
+
brief: briefText ?? fs27.readFileSync(briefFile, "utf8"),
|
|
3388
3895
|
harness: arg(args, "--harness"),
|
|
3389
3896
|
model: arg(args, "--model"),
|
|
3390
3897
|
effort: arg(args, "--effort"),
|
|
3391
|
-
followUp: arg(args, "--follow-up")
|
|
3898
|
+
followUp: arg(args, "--follow-up"),
|
|
3899
|
+
for: address
|
|
3392
3900
|
};
|
|
3393
3901
|
const lane = args.includes("--chore") ? "chore" : "work";
|
|
3394
3902
|
enqueue(d, lane);
|
|
3395
3903
|
console.log(toonKV({ id: d.id, repo, lane, queued: (/* @__PURE__ */ new Date()).toISOString() }));
|
|
3904
|
+
console.log(
|
|
3905
|
+
toonHelp([
|
|
3906
|
+
`lobstah status ${d.id}`,
|
|
3907
|
+
`lobstah send ${d.id} "<instruction>"`,
|
|
3908
|
+
"lobstah man wait (block until something needs you)"
|
|
3909
|
+
])
|
|
3910
|
+
);
|
|
3396
3911
|
break;
|
|
3397
3912
|
}
|
|
3398
3913
|
case "ls": {
|
|
@@ -3401,6 +3916,7 @@ async function mainCli() {
|
|
|
3401
3916
|
(lane) => ["queue", "active", "done"].flatMap((b) => rowsFor(lane, b))
|
|
3402
3917
|
);
|
|
3403
3918
|
console.log(toonTable("dispatches", rows, ["id", "lane", "bucket", "state", "updated"]));
|
|
3919
|
+
console.log(toonHelp(["lobstah status <id>", "lobstah man tend (verdict + stories + gates)"]));
|
|
3404
3920
|
break;
|
|
3405
3921
|
}
|
|
3406
3922
|
case "status": {
|
|
@@ -3414,6 +3930,11 @@ async function mainCli() {
|
|
|
3414
3930
|
const log = readStatusLog(id, lane);
|
|
3415
3931
|
const state = reconcile({ log, lastEventAt: lastEventAt(id, lane) });
|
|
3416
3932
|
console.log(toonKV({ id, lane, state, lastNote: log.at(-1)?.note, entries: log.length }));
|
|
3933
|
+
console.log(
|
|
3934
|
+
toonHelp(
|
|
3935
|
+
state === "needs-decision" || state === "blocked" ? [`lobstah send ${id} "<answer>"`, `lobstah logs ${id} --follow`] : state === "done" || state === "failed" ? [`lobstah catch ${id} (branch, commits, PR)`] : [`lobstah logs ${id} --follow`, `lobstah send ${id} "<instruction>"`]
|
|
3936
|
+
)
|
|
3937
|
+
);
|
|
3417
3938
|
break;
|
|
3418
3939
|
}
|
|
3419
3940
|
case "logs": {
|
|
@@ -3421,17 +3942,29 @@ async function mainCli() {
|
|
|
3421
3942
|
if (!id) throw new Error("logs requires a dispatch id");
|
|
3422
3943
|
const lane = findLane(id);
|
|
3423
3944
|
const file = eventsPath(id, lane);
|
|
3424
|
-
if (
|
|
3945
|
+
if (fs27.existsSync(file)) {
|
|
3946
|
+
const raw = fs27.readFileSync(file, "utf8");
|
|
3947
|
+
const lines = raw.split("\n").filter((l) => l.length > 0);
|
|
3948
|
+
const LIMIT = 50;
|
|
3949
|
+
if (!args.includes("--full") && lines.length > LIMIT) {
|
|
3950
|
+
console.log(
|
|
3951
|
+
`(truncated: last ${LIMIT} of ${lines.length} events \u2014 \`lobstah logs ${id} --full\` for all)`
|
|
3952
|
+
);
|
|
3953
|
+
for (const l of lines.slice(-LIMIT)) console.log(l);
|
|
3954
|
+
} else {
|
|
3955
|
+
process.stdout.write(raw);
|
|
3956
|
+
}
|
|
3957
|
+
}
|
|
3425
3958
|
if (args.includes("--follow")) {
|
|
3426
|
-
let size =
|
|
3959
|
+
let size = fs27.existsSync(file) ? fs27.statSync(file).size : 0;
|
|
3427
3960
|
setInterval(() => {
|
|
3428
|
-
if (!
|
|
3429
|
-
const now =
|
|
3961
|
+
if (!fs27.existsSync(file)) return;
|
|
3962
|
+
const now = fs27.statSync(file).size;
|
|
3430
3963
|
if (now > size) {
|
|
3431
|
-
const fd =
|
|
3964
|
+
const fd = fs27.openSync(file, "r");
|
|
3432
3965
|
const buf = Buffer.alloc(now - size);
|
|
3433
|
-
|
|
3434
|
-
|
|
3966
|
+
fs27.readSync(fd, buf, 0, buf.length, size);
|
|
3967
|
+
fs27.closeSync(fd);
|
|
3435
3968
|
process.stdout.write(buf.toString("utf8"));
|
|
3436
3969
|
size = now;
|
|
3437
3970
|
}
|
|
@@ -3452,9 +3985,7 @@ async function mainCli() {
|
|
|
3452
3985
|
const [id, verb, ...rest] = args;
|
|
3453
3986
|
if (!id || !verb) throw new Error(`report requires an id and a verb (${VERBS.join("|")})`);
|
|
3454
3987
|
const lane = findLane(id);
|
|
3455
|
-
const
|
|
3456
|
-
const prUrl = prIndex >= 0 ? rest[prIndex + 1] : void 0;
|
|
3457
|
-
const note = rest.filter((_, i) => i !== prIndex && i !== prIndex + 1).join(" ") || void 0;
|
|
3988
|
+
const { note, prUrl } = parseReportArgs(rest);
|
|
3458
3989
|
const entry = appendStatus(id, lane, verb, note);
|
|
3459
3990
|
if (prUrl) mergeEvidence(id, lane, { prUrl });
|
|
3460
3991
|
console.log(toonKV({ id, verb: entry.verb, at: entry.at, ...prUrl ? { prUrl } : {} }));
|
|
@@ -3487,23 +4018,23 @@ async function mainCli() {
|
|
|
3487
4018
|
);
|
|
3488
4019
|
}
|
|
3489
4020
|
const dirs = laneDirs(lane);
|
|
3490
|
-
const descriptorFile = ["active", "done"].map((b) =>
|
|
3491
|
-
const harness = descriptorFile ? JSON.parse(
|
|
4021
|
+
const descriptorFile = ["active", "done"].map((b) => path21.join(dirs[b], id, "descriptor.json")).find((f) => fs27.existsSync(f));
|
|
4022
|
+
const harness = descriptorFile ? JSON.parse(fs27.readFileSync(descriptorFile, "utf8")).harness ?? "claude" : "claude";
|
|
3492
4023
|
let sessionId;
|
|
3493
4024
|
try {
|
|
3494
|
-
sessionId = JSON.parse(
|
|
4025
|
+
sessionId = JSON.parse(fs27.readFileSync(path21.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
|
|
3495
4026
|
} catch {
|
|
3496
4027
|
}
|
|
3497
4028
|
if (!sessionId) throw new Error(`${id} has no recorded harness session to attach to`);
|
|
3498
|
-
const worktree =
|
|
3499
|
-
const cwd =
|
|
4029
|
+
const worktree = path21.join(lobstahHome(), "worktrees", id);
|
|
4030
|
+
const cwd = fs27.existsSync(worktree) ? worktree : process.cwd();
|
|
3500
4031
|
const invocation = harness === "codex" ? codexInvocation(["resume", sessionId]) : { file: "claude", argv: ["--resume", sessionId] };
|
|
3501
4032
|
if (!invocation) throw new Error("no codex CLI found \u2014 neither on PATH nor vendored by @openai/codex-sdk");
|
|
3502
4033
|
if (args.includes("--print")) {
|
|
3503
4034
|
console.log(toonKV({ id, harness, sessionId, cwd, command: `${invocation.file} ${invocation.argv.join(" ")}` }));
|
|
3504
4035
|
break;
|
|
3505
4036
|
}
|
|
3506
|
-
const res =
|
|
4037
|
+
const res = spawnSync6(invocation.file, invocation.argv, { cwd, stdio: "inherit" });
|
|
3507
4038
|
if (res.error) throw new Error(`could not launch ${invocation.file}: ${res.error.message}`);
|
|
3508
4039
|
break;
|
|
3509
4040
|
}
|
|
@@ -3511,39 +4042,39 @@ async function mainCli() {
|
|
|
3511
4042
|
const id = args[0];
|
|
3512
4043
|
if (!id) throw new Error("swap requires a dispatch id");
|
|
3513
4044
|
const lane = findLane(id);
|
|
3514
|
-
const activeDir =
|
|
3515
|
-
if (!
|
|
3516
|
-
const descFile =
|
|
3517
|
-
const descriptor = JSON.parse(
|
|
4045
|
+
const activeDir = path21.join(laneDirs(lane).active, id);
|
|
4046
|
+
if (!fs27.existsSync(activeDir)) throw new Error(`${id} is not active \u2014 swap only applies to in-flight dispatches`);
|
|
4047
|
+
const descFile = path21.join(activeDir, "descriptor.json");
|
|
4048
|
+
const descriptor = JSON.parse(fs27.readFileSync(descFile, "utf8"));
|
|
3518
4049
|
const fromHarness = descriptor.harness ?? "default";
|
|
3519
4050
|
for (const key of ["harness", "model", "effort"]) {
|
|
3520
4051
|
const v = arg(args, `--${key}`);
|
|
3521
4052
|
if (v) descriptor[key] = v;
|
|
3522
4053
|
}
|
|
3523
|
-
|
|
3524
|
-
const worktree =
|
|
4054
|
+
fs27.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
|
|
4055
|
+
const worktree = path21.join(lobstahHome(), "worktrees", id);
|
|
3525
4056
|
let progress = "No worktree progress recorded.";
|
|
3526
|
-
if (
|
|
3527
|
-
const
|
|
3528
|
-
const commits =
|
|
3529
|
-
const status =
|
|
4057
|
+
if (fs27.existsSync(worktree)) {
|
|
4058
|
+
const git4 = (...a) => spawnSync6("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
|
|
4059
|
+
const commits = git4("log", "--oneline", "-15");
|
|
4060
|
+
const status = git4("status", "--short");
|
|
3530
4061
|
progress = `Commits so far:
|
|
3531
4062
|
${commits || "(none)"}
|
|
3532
4063
|
|
|
3533
4064
|
Uncommitted changes:
|
|
3534
4065
|
${status || "(clean)"}`;
|
|
3535
4066
|
}
|
|
3536
|
-
|
|
3537
|
-
|
|
4067
|
+
fs27.writeFileSync(
|
|
4068
|
+
path21.join(activeDir, "handoff"),
|
|
3538
4069
|
`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.
|
|
3539
4070
|
|
|
3540
4071
|
${progress}`
|
|
3541
4072
|
);
|
|
3542
|
-
const runnerFile =
|
|
3543
|
-
if (
|
|
3544
|
-
const runner = JSON.parse(
|
|
4073
|
+
const runnerFile = path21.join(activeDir, "runner.json");
|
|
4074
|
+
if (fs27.existsSync(runnerFile)) {
|
|
4075
|
+
const runner = JSON.parse(fs27.readFileSync(runnerFile, "utf8"));
|
|
3545
4076
|
if (pidAlive(runner.pid, runner.processStartTime)) killGroup(runner.pid, "SIGKILL");
|
|
3546
|
-
|
|
4077
|
+
fs27.unlinkSync(runnerFile);
|
|
3547
4078
|
}
|
|
3548
4079
|
appendStatus(id, lane, "working", `swapped to ${descriptor.harness ?? "default"} \u2014 awaiting respawn`);
|
|
3549
4080
|
console.log(toonKV({ id, harness: descriptor.harness ?? "default", model: descriptor.model, swapped: true }));
|
|
@@ -3644,24 +4175,24 @@ ${progress}`
|
|
|
3644
4175
|
}
|
|
3645
4176
|
}
|
|
3646
4177
|
console.log(toonKV({ timeout: true, waitedSecs: timeoutSecs }));
|
|
3647
|
-
process.exitCode =
|
|
4178
|
+
process.exitCode = 3;
|
|
3648
4179
|
break;
|
|
3649
4180
|
}
|
|
3650
4181
|
case "man:init": {
|
|
3651
|
-
const file = args.includes("--global") ?
|
|
4182
|
+
const file = args.includes("--global") ? path21.join(os6.homedir(), ".claude", "settings.json") : path21.join(".claude", args.includes("--shared") ? "settings.json" : "settings.local.json");
|
|
3652
4183
|
let existing;
|
|
3653
4184
|
try {
|
|
3654
|
-
existing = JSON.parse(
|
|
4185
|
+
existing = JSON.parse(fs27.readFileSync(file, "utf8"));
|
|
3655
4186
|
} catch {
|
|
3656
4187
|
existing = void 0;
|
|
3657
4188
|
}
|
|
3658
4189
|
const { settings, changed } = mergeHaulHook(existing);
|
|
3659
4190
|
if (changed) {
|
|
3660
|
-
|
|
3661
|
-
|
|
4191
|
+
fs27.mkdirSync(path21.dirname(file), { recursive: true });
|
|
4192
|
+
fs27.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
|
|
3662
4193
|
`);
|
|
3663
4194
|
}
|
|
3664
|
-
if (args.includes("--marker"))
|
|
4195
|
+
if (args.includes("--marker")) fs27.writeFileSync(".lobstah-man", "");
|
|
3665
4196
|
console.log(
|
|
3666
4197
|
toonKV({
|
|
3667
4198
|
hook: "lobstah man haul",
|
|
@@ -3674,9 +4205,15 @@ ${progress}`
|
|
|
3674
4205
|
}
|
|
3675
4206
|
case "man:haul": {
|
|
3676
4207
|
try {
|
|
3677
|
-
|
|
4208
|
+
const hook = readHookStdin();
|
|
4209
|
+
const soakReg = hook?.session_id ? readSoak(hook.session_id) : void 0;
|
|
4210
|
+
if (soakReg) {
|
|
4211
|
+
await soakPark(soakReg.sessionId, args);
|
|
4212
|
+
break;
|
|
4213
|
+
}
|
|
4214
|
+
if (process.env.LOBSTAH_MAN !== "1" && !fs27.existsSync(".lobstah-man")) break;
|
|
3678
4215
|
const anyActive = ["work", "chore"].some(
|
|
3679
|
-
(l) =>
|
|
4216
|
+
(l) => fs27.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
|
|
3680
4217
|
);
|
|
3681
4218
|
const anyWatch = listWatches().some((w) => w.owner === "man");
|
|
3682
4219
|
if (!anyActive && !anyWatch) break;
|
|
@@ -3716,6 +4253,87 @@ ${progress}`
|
|
|
3716
4253
|
}
|
|
3717
4254
|
break;
|
|
3718
4255
|
}
|
|
4256
|
+
case "man:brief": {
|
|
4257
|
+
const hook = readHookStdin();
|
|
4258
|
+
if (!hook?.session_id) break;
|
|
4259
|
+
let fleet = "";
|
|
4260
|
+
try {
|
|
4261
|
+
const r = buildTendReport();
|
|
4262
|
+
fleet = ` Fleet: ${r.verdict} (${r.counts.queued} queued, ${r.counts.active} active` + (r.attention.length > 0 ? `, ${r.attention.length} awaiting a human` : "") + ") \u2014 `lobstah` for the live view.";
|
|
4263
|
+
} catch {
|
|
4264
|
+
}
|
|
4265
|
+
const soaking = readSoak(hook.session_id) !== void 0;
|
|
4266
|
+
const context = soaking ? `lobstah: session id ${hook.session_id} \u2014 this session is soaking (a volunteered worker); \`lobstah stow --session ${hook.session_id}\` signs it off.${fleet}` : `lobstah: session id ${hook.session_id} (for \`lobstah soak|stow --session <id>\`).${fleet}`;
|
|
4267
|
+
console.log(
|
|
4268
|
+
JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context } })
|
|
4269
|
+
);
|
|
4270
|
+
break;
|
|
4271
|
+
}
|
|
4272
|
+
case "soak": {
|
|
4273
|
+
const sessionId = arg(args, "--session");
|
|
4274
|
+
if (!sessionId) {
|
|
4275
|
+
throw new Error(
|
|
4276
|
+
"soak requires --session <id> \u2014 the harness session id, announced at session start by the lobstah plugin (`lobstah man brief`)"
|
|
4277
|
+
);
|
|
4278
|
+
}
|
|
4279
|
+
const cfg = loadConfig();
|
|
4280
|
+
const site = inspectSoakSite(process.cwd(), cfg.repos);
|
|
4281
|
+
if (!site) throw new Error("soak must run inside a git checkout \u2014 the trap is the worktree");
|
|
4282
|
+
if (site.primary) {
|
|
4283
|
+
throw new Error(
|
|
4284
|
+
"this is the repo's primary checkout \u2014 never claimable. Create a worktree (`git worktree add ../<name> -b <branch>`) and soak from there."
|
|
4285
|
+
);
|
|
4286
|
+
}
|
|
4287
|
+
const rival = listSoaking().find((r) => r.sessionId !== sessionId && r.worktree === site.worktree);
|
|
4288
|
+
if (rival) {
|
|
4289
|
+
throw new Error(
|
|
4290
|
+
`session ${rival.sessionId.slice(0, 8)} already soaks this worktree \u2014 one trap per worktree. Stow it first (\`lobstah stow --session ${rival.sessionId}\`) or soak from another worktree.`
|
|
4291
|
+
);
|
|
4292
|
+
}
|
|
4293
|
+
const reg = signOnSoak({
|
|
4294
|
+
sessionId,
|
|
4295
|
+
harness: arg(args, "--harness") ?? "claude",
|
|
4296
|
+
repo: site.repoKey,
|
|
4297
|
+
worktree: site.worktree,
|
|
4298
|
+
cwd: process.cwd(),
|
|
4299
|
+
one: args.includes("--one") || void 0
|
|
4300
|
+
});
|
|
4301
|
+
console.log(
|
|
4302
|
+
toonKV({
|
|
4303
|
+
soaking: sessionId,
|
|
4304
|
+
repo: reg.repo ?? "(none configured \u2014 addressed bait only)",
|
|
4305
|
+
worktree: reg.worktree,
|
|
4306
|
+
...reg.one ? { one: true } : {},
|
|
4307
|
+
note: "parks at turn end via the Stop hook (lobstah plugin or `man init --global`); bait arrives as a wake"
|
|
4308
|
+
})
|
|
4309
|
+
);
|
|
4310
|
+
console.log(toonHelp([`lobstah stow --session ${sessionId} (sign off)`]));
|
|
4311
|
+
break;
|
|
4312
|
+
}
|
|
4313
|
+
case "stow": {
|
|
4314
|
+
const sessionId = arg(args, "--session") ?? readHookStdin()?.session_id;
|
|
4315
|
+
const quiet = args.includes("--quiet");
|
|
4316
|
+
if (!sessionId) {
|
|
4317
|
+
if (quiet) break;
|
|
4318
|
+
throw new Error("stow requires --session <id> (or hook input on stdin)");
|
|
4319
|
+
}
|
|
4320
|
+
const reg = stowSoak(sessionId);
|
|
4321
|
+
if (!reg) {
|
|
4322
|
+
if (!quiet) console.log(toonKV({ session: sessionId, soaking: false }));
|
|
4323
|
+
break;
|
|
4324
|
+
}
|
|
4325
|
+
const released = releaseCatch(reg);
|
|
4326
|
+
if (!quiet) {
|
|
4327
|
+
console.log(
|
|
4328
|
+
toonKV({
|
|
4329
|
+
stowed: sessionId,
|
|
4330
|
+
...released.requeued ? { requeued: released.requeued } : {},
|
|
4331
|
+
...released.finalized ? { finalized: released.finalized } : {}
|
|
4332
|
+
})
|
|
4333
|
+
);
|
|
4334
|
+
}
|
|
4335
|
+
break;
|
|
4336
|
+
}
|
|
3719
4337
|
case "daemon":
|
|
3720
4338
|
case "pick": {
|
|
3721
4339
|
const kind = cmd;
|
|
@@ -3757,7 +4375,7 @@ ${progress}`
|
|
|
3757
4375
|
path: r.path,
|
|
3758
4376
|
trunk: r.trunk,
|
|
3759
4377
|
pickup: r.pickup ?? false,
|
|
3760
|
-
exists:
|
|
4378
|
+
exists: fs27.existsSync(r.path)
|
|
3761
4379
|
}));
|
|
3762
4380
|
console.log(toonTable("repos", rows, ["key", "path", "trunk", "pickup", "exists"]));
|
|
3763
4381
|
break;
|
|
@@ -3782,6 +4400,11 @@ ${progress}`
|
|
|
3782
4400
|
stream: arg(args, "--stream")
|
|
3783
4401
|
});
|
|
3784
4402
|
console.log(toonKV({ key: w.key, owner: w.owner, cursor: w.cursor, registered: true }));
|
|
4403
|
+
console.log(
|
|
4404
|
+
toonHelp(
|
|
4405
|
+
w.owner === "man" ? ["lobstah man wait (its events wake you)", "lobstah watch (list watches)"] : ["lobstah watch (list watches; events fork the owning chain)"]
|
|
4406
|
+
)
|
|
4407
|
+
);
|
|
3785
4408
|
break;
|
|
3786
4409
|
}
|
|
3787
4410
|
if (sub === "rm") {
|
|
@@ -3802,7 +4425,7 @@ ${progress}`
|
|
|
3802
4425
|
break;
|
|
3803
4426
|
}
|
|
3804
4427
|
case "init": {
|
|
3805
|
-
if (!
|
|
4428
|
+
if (!fs27.existsSync(configPath())) {
|
|
3806
4429
|
const exampleRepo = args.includes("--scan") ? "" : `[repos.example]
|
|
3807
4430
|
path = "~/src/example"
|
|
3808
4431
|
trunk = "main"
|
|
@@ -3811,7 +4434,7 @@ trunk = "main"
|
|
|
3811
4434
|
# pickup = true # opt into [pickup.github] multi-repo tracker pickup
|
|
3812
4435
|
|
|
3813
4436
|
`;
|
|
3814
|
-
|
|
4437
|
+
fs27.writeFileSync(
|
|
3815
4438
|
configPath(),
|
|
3816
4439
|
`# lobstah workspace definitions \u2014 the descriptor's repo key resolves here.
|
|
3817
4440
|
# Top-level keys must come before any [section].
|
|
@@ -3849,12 +4472,12 @@ wallClockSecs = 3600
|
|
|
3849
4472
|
let reference = "https://github.com/aequitas-labs/lobstah/blob/main/docs/configuration.md";
|
|
3850
4473
|
try {
|
|
3851
4474
|
const local = fileURLToPath2(new URL("../docs/configuration.md", import.meta.url));
|
|
3852
|
-
if (
|
|
4475
|
+
if (fs27.existsSync(local)) reference = local;
|
|
3853
4476
|
} catch {
|
|
3854
4477
|
}
|
|
3855
4478
|
console.log(
|
|
3856
4479
|
toonKV({
|
|
3857
|
-
home:
|
|
4480
|
+
home: path21.dirname(configPath()),
|
|
3858
4481
|
config: configPath(),
|
|
3859
4482
|
...added.length > 0 ? { added: added.join(", ") } : {},
|
|
3860
4483
|
...unmarked.length > 0 ? { note: `none marked pickable \u2014 set pickup = true per [repos.*] (or rerun with --pickup)` } : {},
|
|
@@ -3868,18 +4491,35 @@ wallClockSecs = 3600
|
|
|
3868
4491
|
case "-v":
|
|
3869
4492
|
console.log(lobstahVersion());
|
|
3870
4493
|
break;
|
|
3871
|
-
case void 0:
|
|
4494
|
+
case void 0: {
|
|
4495
|
+
console.log(`lobstah ${lobstahVersion()} \u2014 supervision for coding agents (home: ${lobstahHome()})`);
|
|
4496
|
+
console.log("");
|
|
4497
|
+
console.log(renderTend(buildTendReport()));
|
|
4498
|
+
console.log("");
|
|
4499
|
+
console.log(
|
|
4500
|
+
toonHelp([
|
|
4501
|
+
'lobstah dispatch --repo <key> --brief-text "<brief>" queue work',
|
|
4502
|
+
"lobstah status <id> one dispatch",
|
|
4503
|
+
"lobstah man tend full fleet pass",
|
|
4504
|
+
"lobstah help every command"
|
|
4505
|
+
])
|
|
4506
|
+
);
|
|
4507
|
+
break;
|
|
4508
|
+
}
|
|
3872
4509
|
case "--help":
|
|
3873
4510
|
case "help":
|
|
3874
4511
|
console.log(HELP);
|
|
3875
4512
|
break;
|
|
3876
4513
|
default:
|
|
3877
|
-
throw new
|
|
4514
|
+
throw new UsageError(`unknown command "${cmd}" \u2014 run \`lobstah help\``);
|
|
3878
4515
|
}
|
|
3879
4516
|
}
|
|
3880
4517
|
mainCli().catch((err) => {
|
|
3881
|
-
|
|
3882
|
-
|
|
4518
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4519
|
+
const [first, ...rest] = msg.split("\n");
|
|
4520
|
+
console.log(toonKV({ error: first }));
|
|
4521
|
+
if (rest.length > 0) console.log(rest.join("\n").trimStart());
|
|
4522
|
+
process.exit(err instanceof UsageError ? 2 : 1);
|
|
3883
4523
|
});
|
|
3884
4524
|
/*! Bundled license information:
|
|
3885
4525
|
|