lobstah 0.2.1 → 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/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 fs25 from "node:fs";
4
+ import * as fs27 from "node:fs";
5
5
  import * as os6 from "node:os";
6
- import * as path19 from "node:path";
6
+ import * as path21 from "node:path";
7
7
  import { randomUUID as randomUUID4 } from "node:crypto";
8
- import { spawnSync as spawnSync5 } from "node:child_process";
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 claimNext(lane) {
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";
@@ -1142,6 +1176,158 @@ function markFollowUp(key, followUpId, deliveredThrough) {
1142
1176
  writeWatch(w);
1143
1177
  }
1144
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
+
1145
1331
  // packages/supervisor/dist/liveness.js
1146
1332
  import { execFileSync } from "node:child_process";
1147
1333
  function classify(input) {
@@ -1200,26 +1386,26 @@ function killGroup(pid, signal = "SIGTERM") {
1200
1386
  }
1201
1387
 
1202
1388
  // packages/supervisor/dist/daemon.js
1203
- import * as fs12 from "node:fs";
1389
+ import * as fs13 from "node:fs";
1204
1390
  import * as os3 from "node:os";
1205
- import * as path8 from "node:path";
1391
+ import * as path9 from "node:path";
1206
1392
  import { spawn as spawn2 } from "node:child_process";
1207
1393
  import { createRequire as createRequire2 } from "node:module";
1208
1394
  import { fileURLToPath } from "node:url";
1209
1395
 
1210
1396
  // packages/supervisor/dist/notify.js
1211
- import * as fs11 from "node:fs";
1212
- import * as path7 from "node:path";
1397
+ import * as fs12 from "node:fs";
1398
+ import * as path8 from "node:path";
1213
1399
  import { spawn } from "node:child_process";
1214
1400
  var DEFAULT_NOTIFY_VERBS = ["needs-decision", "blocked", "done", "failed"];
1215
1401
  function cursorPath(id, lane) {
1216
- return path7.join(laneDirs(lane).state, `${id}.notified`);
1402
+ return path8.join(laneDirs(lane).state, `${id}.notified`);
1217
1403
  }
1218
1404
  function pendingNotifications(id, lane, verbs = DEFAULT_NOTIFY_VERBS, sinceMs = 0) {
1219
1405
  const log = readStatusLog(id, lane);
1220
1406
  let cursor;
1221
1407
  try {
1222
- cursor = Number(fs11.readFileSync(cursorPath(id, lane), "utf8")) || 0;
1408
+ cursor = Number(fs12.readFileSync(cursorPath(id, lane), "utf8")) || 0;
1223
1409
  } catch {
1224
1410
  cursor = void 0;
1225
1411
  }
@@ -1229,13 +1415,13 @@ function pendingNotifications(id, lane, verbs = DEFAULT_NOTIFY_VERBS, sinceMs =
1229
1415
  if (cursor === void 0 && sinceMs > 0) {
1230
1416
  fresh = fresh.filter((e) => Date.parse(e.at) >= sinceMs);
1231
1417
  }
1232
- fs11.writeFileSync(cursorPath(id, lane), String(log.length));
1418
+ fs12.writeFileSync(cursorPath(id, lane), String(log.length));
1233
1419
  return fresh.filter((e) => verbs.includes(e.verb)).map((entry) => ({ id, lane, entry }));
1234
1420
  }
1235
1421
  function notifiableIds(lane, maxAgeMs = 48 * 36e5) {
1236
1422
  const dir = laneDirs(lane).state;
1237
1423
  const cutoff = Date.now() - maxAgeMs;
1238
- return fs11.readdirSync(dir).filter((f) => f.endsWith(".status")).filter((f) => fs11.statSync(path7.join(dir, f)).mtimeMs > cutoff).map((f) => f.slice(0, -".status".length));
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));
1239
1425
  }
1240
1426
  function execNotify(command, ev, log = () => {
1241
1427
  }) {
@@ -1265,11 +1451,11 @@ function captureWaitBaseline() {
1265
1451
  return lengths;
1266
1452
  }
1267
1453
  function attnCursorPath(id, lane) {
1268
- return path7.join(laneDirs(lane).state, `${id}.attn`);
1454
+ return path8.join(laneDirs(lane).state, `${id}.attn`);
1269
1455
  }
1270
1456
  function readAttnCursor(file) {
1271
1457
  try {
1272
- const parsed = JSON.parse(fs11.readFileSync(file, "utf8"));
1458
+ const parsed = JSON.parse(fs12.readFileSync(file, "utf8"));
1273
1459
  return { seen: parsed.seen ?? 0, at: parsed.at ?? 0 };
1274
1460
  } catch {
1275
1461
  return { seen: 0, at: 0 };
@@ -1295,7 +1481,7 @@ function attentionNow(consume = true, remindMs = 15 * 6e4, now = Date.now()) {
1295
1481
  continue;
1296
1482
  out.push({ id, lane, entry });
1297
1483
  if (consume)
1298
- fs11.writeFileSync(file, JSON.stringify({ seen: Math.max(cursor.seen, index + 1), at: now }));
1484
+ fs12.writeFileSync(file, JSON.stringify({ seen: Math.max(cursor.seen, index + 1), at: now }));
1299
1485
  }
1300
1486
  }
1301
1487
  return out;
@@ -1330,33 +1516,33 @@ function runnerEntry() {
1330
1516
  }
1331
1517
  function readRunnerInfo(dir) {
1332
1518
  try {
1333
- return JSON.parse(fs12.readFileSync(path8.join(dir, "runner.json"), "utf8"));
1519
+ return JSON.parse(fs13.readFileSync(path9.join(dir, "runner.json"), "utf8"));
1334
1520
  } catch {
1335
1521
  return void 0;
1336
1522
  }
1337
1523
  }
1338
1524
  function listActive(lane) {
1339
1525
  const dir = laneDirs(lane).active;
1340
- return fs12.readdirSync(dir).filter((f) => !f.startsWith(".")).map((id) => {
1341
- const d = path8.join(dir, id);
1526
+ return fs13.readdirSync(dir).filter((f) => !f.startsWith(".")).map((id) => {
1527
+ const d = path9.join(dir, id);
1342
1528
  return { id, lane, dir: d, runner: readRunnerInfo(d) };
1343
1529
  });
1344
1530
  }
1345
1531
  function finalize(st) {
1346
1532
  try {
1347
- fs12.renameSync(st.dir, path8.join(laneDirs(st.lane).done, st.id));
1533
+ fs13.renameSync(st.dir, path9.join(laneDirs(st.lane).done, st.id));
1348
1534
  } catch {
1349
1535
  }
1350
1536
  }
1351
1537
  function spawnRunner(st, opts) {
1352
1538
  let nudge = opts.nudge;
1353
- const handoffFile = path8.join(st.dir, "handoff");
1354
- if (!nudge && fs12.existsSync(handoffFile)) {
1355
- nudge = fs12.readFileSync(handoffFile, "utf8");
1356
- fs12.unlinkSync(handoffFile);
1539
+ const handoffFile = path9.join(st.dir, "handoff");
1540
+ if (!nudge && fs13.existsSync(handoffFile)) {
1541
+ nudge = fs13.readFileSync(handoffFile, "utf8");
1542
+ fs13.unlinkSync(handoffFile);
1357
1543
  }
1358
- const logPath = path8.join(laneDirs(st.lane).state, `${st.id}.runner.log`);
1359
- const log = fs12.openSync(logPath, "a");
1544
+ const logPath = path9.join(laneDirs(st.lane).state, `${st.id}.runner.log`);
1545
+ const log = fs13.openSync(logPath, "a");
1360
1546
  const child = spawn2(process.execPath, [runnerEntry(), st.dir, st.lane], {
1361
1547
  detached: true,
1362
1548
  stdio: ["ignore", log, log],
@@ -1367,7 +1553,7 @@ function spawnRunner(st, opts) {
1367
1553
  ...nudge ? { LOBSTAH_NUDGE: nudge } : {}
1368
1554
  }
1369
1555
  });
1370
- fs12.closeSync(log);
1556
+ fs13.closeSync(log);
1371
1557
  child.unref();
1372
1558
  const info = {
1373
1559
  pid: child.pid ?? -1,
@@ -1375,27 +1561,35 @@ function spawnRunner(st, opts) {
1375
1561
  processStartTime: child.pid ? processStartTime(child.pid) : void 0,
1376
1562
  attempts: opts.attempts
1377
1563
  };
1378
- fs12.writeFileSync(path8.join(st.dir, "runner.json"), JSON.stringify(info, null, 2));
1564
+ fs13.writeFileSync(path9.join(st.dir, "runner.json"), JSON.stringify(info, null, 2));
1379
1565
  }
1380
1566
  function sessionOf(st) {
1381
1567
  try {
1382
- const ev = JSON.parse(fs12.readFileSync(path8.join(laneDirs(st.lane).state, `${st.id}.evidence`), "utf8"));
1568
+ const ev = JSON.parse(fs13.readFileSync(path9.join(laneDirs(st.lane).state, `${st.id}.evidence`), "utf8"));
1383
1569
  return ev.sessionId;
1384
1570
  } catch {
1385
1571
  return void 0;
1386
1572
  }
1387
1573
  }
1388
1574
  function reconcileOne(st, cfg, log) {
1389
- const hasDescriptor = fs12.existsSync(path8.join(st.dir, "descriptor.json"));
1575
+ const hasDescriptor = fs13.existsSync(path9.join(st.dir, "descriptor.json"));
1390
1576
  if (!hasDescriptor) {
1391
- const age = Date.now() - fs12.statSync(st.dir).mtimeMs;
1577
+ const age = Date.now() - fs13.statSync(st.dir).mtimeMs;
1392
1578
  if (age > 5 * 6e4)
1393
- fs12.rmSync(st.dir, { recursive: true, force: true });
1579
+ fs13.rmSync(st.dir, { recursive: true, force: true });
1394
1580
  return;
1395
1581
  }
1396
1582
  const statusLog = readStatusLog(st.id, st.lane);
1397
1583
  const lastVerb = statusLog.at(-1)?.verb;
1398
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
+ }
1399
1593
  if (cancelRequested(st.id, st.lane)) {
1400
1594
  if (st.runner && alive) {
1401
1595
  log(`${st.id}: cancel requested, killing group ${st.runner.pid}`);
@@ -1474,16 +1668,16 @@ function writeHeartbeat(cfg) {
1474
1668
  heartbeat: (/* @__PURE__ */ new Date()).toISOString()
1475
1669
  };
1476
1670
  const tmp = `${executorPath()}.tmp`;
1477
- fs12.writeFileSync(tmp, JSON.stringify(payload, null, 2));
1478
- fs12.renameSync(tmp, executorPath());
1671
+ fs13.writeFileSync(tmp, JSON.stringify(payload, null, 2));
1672
+ fs13.renameSync(tmp, executorPath());
1479
1673
  }
1480
1674
  function pruneChores(cfg) {
1481
1675
  const dir = laneDirs("chore").done;
1482
1676
  const cutoff = Date.now() - cfg.limits.choreRetentionDays * 864e5;
1483
- for (const id of fs12.readdirSync(dir)) {
1484
- const p = path8.join(dir, id);
1485
- if (fs12.statSync(p).mtimeMs < cutoff)
1486
- fs12.rmSync(p, { recursive: true, force: true });
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 });
1487
1681
  }
1488
1682
  }
1489
1683
  var daemonStartedAt = Date.now();
@@ -1492,6 +1686,10 @@ function tick(log = () => {
1492
1686
  const cfg = loadConfig();
1493
1687
  ensureLayout();
1494
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);
1495
1693
  for (const lane of ["chore", "work"]) {
1496
1694
  const active = listActive(lane);
1497
1695
  for (const st of active)
@@ -1499,7 +1697,7 @@ function tick(log = () => {
1499
1697
  const ceiling = lane === "work" ? cfg.limits.maxConcurrent : cfg.limits.choreConcurrent;
1500
1698
  let inFlight = listActive(lane).length;
1501
1699
  while (inFlight < ceiling) {
1502
- const id = claimNext(lane);
1700
+ const id = claimNext(lane, lane === "work" ? workSkip : void 0);
1503
1701
  if (!id)
1504
1702
  break;
1505
1703
  log(`${id}: claimed (${lane})`);
@@ -1523,9 +1721,9 @@ function tick(log = () => {
1523
1721
  pruneChores(loadConfig());
1524
1722
  }
1525
1723
  function acquireDaemonLock() {
1526
- const lockPath = path8.join(lobstahHome(), "daemon.lock");
1724
+ const lockPath = path9.join(lobstahHome(), "daemon.lock");
1527
1725
  try {
1528
- const prior = JSON.parse(fs12.readFileSync(lockPath, "utf8"));
1726
+ const prior = JSON.parse(fs13.readFileSync(lockPath, "utf8"));
1529
1727
  if (pidAlive(prior.pid, prior.processStartTime)) {
1530
1728
  throw new Error(`another daemon (pid ${prior.pid}) already supervises ${lobstahHome()} \u2014 run a second instance against its own LOBSTAH_HOME instead`);
1531
1729
  }
@@ -1533,10 +1731,10 @@ function acquireDaemonLock() {
1533
1731
  if (err instanceof Error && err.message.includes("already supervises"))
1534
1732
  throw err;
1535
1733
  }
1536
- fs12.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, processStartTime: processStartTime(process.pid), startedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2));
1734
+ fs13.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, processStartTime: processStartTime(process.pid), startedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2));
1537
1735
  const release = () => {
1538
1736
  try {
1539
- fs12.unlinkSync(lockPath);
1737
+ fs13.unlinkSync(lockPath);
1540
1738
  } catch {
1541
1739
  }
1542
1740
  };
@@ -1552,7 +1750,7 @@ function watchQueues(onChange) {
1552
1750
  const watchers = [];
1553
1751
  for (const lane of ["work", "chore"]) {
1554
1752
  try {
1555
- watchers.push(fs12.watch(laneDirs(lane).queue, onChange));
1753
+ watchers.push(fs13.watch(laneDirs(lane).queue, onChange));
1556
1754
  } catch {
1557
1755
  }
1558
1756
  }
@@ -1573,15 +1771,15 @@ async function daemon(intervalMs = 5e3, log = console.log) {
1573
1771
  } catch (err) {
1574
1772
  log(`tick error: ${err instanceof Error ? err.message : String(err)}`);
1575
1773
  }
1576
- await new Promise((resolve2) => {
1774
+ await new Promise((resolve3) => {
1577
1775
  const timer = setTimeout(() => {
1578
1776
  wake = void 0;
1579
- resolve2();
1777
+ resolve3();
1580
1778
  }, intervalMs);
1581
1779
  wake = () => {
1582
1780
  wake = void 0;
1583
1781
  clearTimeout(timer);
1584
- setTimeout(resolve2, 50);
1782
+ setTimeout(resolve3, 50);
1585
1783
  };
1586
1784
  });
1587
1785
  }
@@ -1591,7 +1789,7 @@ async function daemon(intervalMs = 5e3, log = console.log) {
1591
1789
  import { spawn as spawn3 } from "node:child_process";
1592
1790
 
1593
1791
  // apps/pick/dist/config.js
1594
- import * as fs13 from "node:fs";
1792
+ import * as fs14 from "node:fs";
1595
1793
  import { execSync } from "node:child_process";
1596
1794
  import * as os4 from "node:os";
1597
1795
 
@@ -1633,7 +1831,7 @@ function resolveTokenSource(section, label) {
1633
1831
  }
1634
1832
  if (file) {
1635
1833
  return () => {
1636
- const token = fs13.readFileSync(file.startsWith("~/") ? `${os4.homedir()}/${file.slice(2)}` : file, "utf8").trim();
1834
+ const token = fs14.readFileSync(file.startsWith("~/") ? `${os4.homedir()}/${file.slice(2)}` : file, "utf8").trim();
1637
1835
  if (!token)
1638
1836
  throw new Error(`${label}: tokenFile ${file} is empty`);
1639
1837
  return token;
@@ -1651,7 +1849,7 @@ function resolveTokenSource(section, label) {
1651
1849
  }
1652
1850
  function loadPickupConfig() {
1653
1851
  const file = configPath();
1654
- const raw = fs13.existsSync(file) ? parse(fs13.readFileSync(file, "utf8")) : {};
1852
+ const raw = fs14.existsSync(file) ? parse(fs14.readFileSync(file, "utf8")) : {};
1655
1853
  const p = raw.pickup ?? {};
1656
1854
  const out = {
1657
1855
  pollSecs: Number(p.pollSecs ?? 45),
@@ -1724,23 +1922,23 @@ function loadPickupConfig() {
1724
1922
  }
1725
1923
 
1726
1924
  // apps/pick/dist/state.js
1727
- import * as fs14 from "node:fs";
1728
- import * as path9 from "node:path";
1925
+ import * as fs15 from "node:fs";
1926
+ import * as path10 from "node:path";
1729
1927
  var PickupState = class {
1730
1928
  file;
1731
1929
  data;
1732
- constructor(dir = path9.join(lobstahHome(), "pickup")) {
1733
- fs14.mkdirSync(dir, { recursive: true });
1734
- this.file = path9.join(dir, "state.json");
1735
- this.data = fs14.existsSync(this.file) ? JSON.parse(fs14.readFileSync(this.file, "utf8")) : { map: {}, consumedApprovals: {}, rebases: {} };
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: {} };
1736
1934
  this.data.map ??= {};
1737
1935
  this.data.consumedApprovals ??= {};
1738
1936
  this.data.rebases ??= {};
1739
1937
  }
1740
1938
  save() {
1741
1939
  const tmp = `${this.file}.tmp-${process.pid}`;
1742
- fs14.writeFileSync(tmp, JSON.stringify(this.data, null, 2));
1743
- fs14.renameSync(tmp, this.file);
1940
+ fs15.writeFileSync(tmp, JSON.stringify(this.data, null, 2));
1941
+ fs15.renameSync(tmp, this.file);
1744
1942
  }
1745
1943
  get(key) {
1746
1944
  return this.data.map[key];
@@ -2124,12 +2322,12 @@ async function dispatchLoop(source, state, log = () => {
2124
2322
  }
2125
2323
 
2126
2324
  // apps/pick/dist/loops/report.js
2127
- import * as fs15 from "node:fs";
2128
- import * as path10 from "node:path";
2325
+ import * as fs16 from "node:fs";
2326
+ import * as path11 from "node:path";
2129
2327
  function dispatchLane(uuid) {
2130
2328
  for (const lane of ["work", "chore"]) {
2131
2329
  const d = laneDirs(lane);
2132
- if (fs15.existsSync(path10.join(d.active, uuid)) || fs15.existsSync(path10.join(d.queue, `${uuid}.json`)) || fs15.existsSync(path10.join(d.done, uuid)) || fs15.existsSync(path10.join(d.state, `${uuid}.status`))) {
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`))) {
2133
2331
  return lane;
2134
2332
  }
2135
2333
  }
@@ -2170,12 +2368,12 @@ async function reportLoop(source, state, log = () => {
2170
2368
  }
2171
2369
 
2172
2370
  // apps/pick/dist/loops/reconcile.js
2173
- import * as fs16 from "node:fs";
2174
- import * as path11 from "node:path";
2371
+ import * as fs17 from "node:fs";
2372
+ import * as path12 from "node:path";
2175
2373
  function dispatchActive(uuid) {
2176
2374
  for (const lane of ["work", "chore"]) {
2177
2375
  const d = laneDirs(lane);
2178
- if (fs16.existsSync(path11.join(d.active, uuid)) || fs16.existsSync(path11.join(d.queue, `${uuid}.json`))) {
2376
+ if (fs17.existsSync(path12.join(d.active, uuid)) || fs17.existsSync(path12.join(d.queue, `${uuid}.json`))) {
2179
2377
  return lane;
2180
2378
  }
2181
2379
  }
@@ -2215,28 +2413,28 @@ async function reconcileLoop(source, state, log = () => {
2215
2413
 
2216
2414
  // apps/pick/dist/loops/merge.js
2217
2415
  import { randomUUID as randomUUID2 } from "node:crypto";
2218
- import * as fs18 from "node:fs";
2219
- import * as path13 from "node:path";
2416
+ import * as fs19 from "node:fs";
2417
+ import * as path14 from "node:path";
2220
2418
 
2221
2419
  // apps/pick/dist/merge-view.js
2222
- import * as fs17 from "node:fs";
2223
- import * as path12 from "node:path";
2420
+ import * as fs18 from "node:fs";
2421
+ import * as path13 from "node:path";
2224
2422
  function viewPath() {
2225
- return path12.join(lobstahHome(), "pickup", "merge-view.json");
2423
+ return path13.join(lobstahHome(), "pickup", "merge-view.json");
2226
2424
  }
2227
2425
  function readMergeView() {
2228
2426
  try {
2229
- return JSON.parse(fs17.readFileSync(viewPath(), "utf8"));
2427
+ return JSON.parse(fs18.readFileSync(viewPath(), "utf8"));
2230
2428
  } catch {
2231
2429
  return void 0;
2232
2430
  }
2233
2431
  }
2234
2432
  function writeMergeView(view) {
2235
2433
  const file = viewPath();
2236
- fs17.mkdirSync(path12.dirname(file), { recursive: true });
2434
+ fs18.mkdirSync(path13.dirname(file), { recursive: true });
2237
2435
  const tmp = `${file}.tmp-${process.pid}`;
2238
- fs17.writeFileSync(tmp, JSON.stringify(view, null, 2));
2239
- fs17.renameSync(tmp, file);
2436
+ fs18.writeFileSync(tmp, JSON.stringify(view, null, 2));
2437
+ fs18.renameSync(tmp, file);
2240
2438
  }
2241
2439
  var RECENT_RETENTION_MS = 48 * 36e5;
2242
2440
  async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ new Date()) {
@@ -2258,7 +2456,7 @@ async function recordMergeView(repo, open, disposition, now = /* @__PURE__ */ ne
2258
2456
  }
2259
2457
  function readPickupMap() {
2260
2458
  try {
2261
- const raw = JSON.parse(fs17.readFileSync(path12.join(lobstahHome(), "pickup", "state.json"), "utf8"));
2459
+ const raw = JSON.parse(fs18.readFileSync(path13.join(lobstahHome(), "pickup", "state.json"), "utf8"));
2262
2460
  return raw.map ?? {};
2263
2461
  } catch {
2264
2462
  return {};
@@ -2292,7 +2490,7 @@ function rebaseBrief(pr) {
2292
2490
  }
2293
2491
  function choreVerdict(uuid) {
2294
2492
  const d = laneDirs("chore");
2295
- if (fs18.existsSync(path13.join(d.active, uuid)) || fs18.existsSync(path13.join(d.queue, `${uuid}.json`)))
2493
+ if (fs19.existsSync(path14.join(d.active, uuid)) || fs19.existsSync(path14.join(d.queue, `${uuid}.json`)))
2296
2494
  return "active";
2297
2495
  const last = readStatusLog(uuid, "chore").at(-1)?.verb;
2298
2496
  return last === "done" ? "done" : "failed";
@@ -2386,8 +2584,8 @@ async function mergeLoop(ms, policy, state, log = () => {
2386
2584
  }
2387
2585
 
2388
2586
  // apps/pick/dist/loops/watch.js
2389
- import * as fs19 from "node:fs";
2390
- import * as path14 from "node:path";
2587
+ import * as fs20 from "node:fs";
2588
+ import * as path15 from "node:path";
2391
2589
  import { randomUUID as randomUUID3 } from "node:crypto";
2392
2590
  var DEFAULT_BRIEF = `You are resuming earlier work. The watched source {key} produced new events:
2393
2591
 
@@ -2397,7 +2595,7 @@ Read them, act on what they ask, and report the outcome.`;
2397
2595
  function laneOf(id) {
2398
2596
  for (const lane of ["work", "chore"]) {
2399
2597
  const d = laneDirs(lane);
2400
- if (fs19.existsSync(path14.join(d.active, id)) || fs19.existsSync(path14.join(d.queue, `${id}.json`)) || fs19.existsSync(path14.join(d.done, id)) || fs19.existsSync(path14.join(d.state, `${id}.status`))) {
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`))) {
2401
2599
  return lane;
2402
2600
  }
2403
2601
  }
@@ -2406,13 +2604,13 @@ function laneOf(id) {
2406
2604
  function descriptorOf(id, lane) {
2407
2605
  const d = laneDirs(lane);
2408
2606
  for (const bucket of ["active", "done"]) {
2409
- const f = path14.join(d[bucket], id, "descriptor.json");
2410
- if (fs19.existsSync(f))
2411
- return JSON.parse(fs19.readFileSync(f, "utf8"));
2607
+ const f = path15.join(d[bucket], id, "descriptor.json");
2608
+ if (fs20.existsSync(f))
2609
+ return JSON.parse(fs20.readFileSync(f, "utf8"));
2412
2610
  }
2413
- const q = path14.join(d.queue, `${id}.json`);
2414
- if (fs19.existsSync(q))
2415
- return JSON.parse(fs19.readFileSync(q, "utf8"));
2611
+ const q = path15.join(d.queue, `${id}.json`);
2612
+ if (fs20.existsSync(q))
2613
+ return JSON.parse(fs20.readFileSync(q, "utf8"));
2416
2614
  return void 0;
2417
2615
  }
2418
2616
  function isTerminal(id) {
@@ -2439,9 +2637,12 @@ function spawnContinuation(w, pending, log) {
2439
2637
  }
2440
2638
  const id = randomUUID3();
2441
2639
  const brief = (w.brief ?? DEFAULT_BRIEF).replaceAll("{key}", w.key).replaceAll("{events}", JSON.stringify(pending, null, 2));
2442
- enqueue({ id, repo: descriptor.repo, brief, followUp: target }, "work");
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");
2443
2644
  markFollowUp(w.key, id, readWatchEvents(w.key).length);
2444
- 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})`));
2445
2646
  }
2446
2647
  function notifyMan(watch2, fresh, notify) {
2447
2648
  if (fresh.length === 0 || watch2.owner !== "man")
@@ -2642,21 +2843,21 @@ function mergeHaulHook(existing) {
2642
2843
  }
2643
2844
 
2644
2845
  // apps/cli/src/tend.ts
2645
- import * as fs20 from "node:fs";
2646
- import * as path15 from "node:path";
2846
+ import * as fs21 from "node:fs";
2847
+ import * as path16 from "node:path";
2647
2848
  var HEARTBEAT_STALE_MS = 9e4;
2648
2849
  var CLAIM_STALE_MS = 12e4;
2649
2850
  var DAY_MS = 24 * 36e5;
2650
2851
  function readJson(file) {
2651
2852
  try {
2652
- return JSON.parse(fs20.readFileSync(file, "utf8"));
2853
+ return JSON.parse(fs21.readFileSync(file, "utf8"));
2653
2854
  } catch {
2654
2855
  return void 0;
2655
2856
  }
2656
2857
  }
2657
2858
  function doneIds(lane) {
2658
2859
  try {
2659
- return fs20.readdirSync(laneDirs(lane).done).filter((f) => !f.startsWith("."));
2860
+ return fs21.readdirSync(laneDirs(lane).done).filter((f) => !f.startsWith("."));
2660
2861
  } catch {
2661
2862
  return [];
2662
2863
  }
@@ -2675,16 +2876,16 @@ function followUps(uuid) {
2675
2876
  const desc = readJson(file);
2676
2877
  if (desc?.followUp === uuid) out.push({ id, bucket });
2677
2878
  };
2678
- for (const id of pendingIds("work")) scan(id, "queued", path15.join(d.queue, `${id}.json`));
2679
- for (const id of activeIds("work")) scan(id, "active", path15.join(d.active, id, "descriptor.json"));
2680
- for (const id of doneIds("work")) scan(id, "done", path15.join(d.done, id, "descriptor.json"));
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"));
2681
2882
  return out;
2682
2883
  }
2683
2884
  function bucketOf(uuid) {
2684
2885
  const d = laneDirs("work");
2685
- if (fs20.existsSync(path15.join(d.active, uuid))) return "active";
2686
- if (fs20.existsSync(path15.join(d.done, uuid))) return "done";
2687
- if (fs20.existsSync(path15.join(d.queue, `${uuid}.json`))) return "queued";
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";
2688
2889
  return void 0;
2689
2890
  }
2690
2891
  function buildTendReport(now = Date.now()) {
@@ -2744,7 +2945,7 @@ function buildTendReport(now = Date.now()) {
2744
2945
  }
2745
2946
  }
2746
2947
  const oldestQueuedAge = queued.reduce((max, id) => {
2747
- const st = fs20.statSync(path15.join(laneDirs("work").queue, `${id}.json`), { throwIfNoEntry: false });
2948
+ const st = fs21.statSync(path16.join(laneDirs("work").queue, `${id}.json`), { throwIfNoEntry: false });
2748
2949
  return st ? Math.max(max, now - st.mtimeMs) : max;
2749
2950
  }, 0);
2750
2951
  const stalled = daemonUp && queued.length > 0 && active.length < cfg.limits.maxConcurrent && oldestQueuedAge > CLAIM_STALE_MS;
@@ -2775,6 +2976,13 @@ function buildTendReport(now = Date.now()) {
2775
2976
  stories.push({ key: "(direct)", dispatches: [d], prUrl: d.prUrl, gate: gateFor(id, d.prUrl), watch: watchByDispatch.get(id) });
2776
2977
  }
2777
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
+ }));
2778
2986
  return {
2779
2987
  verdict,
2780
2988
  daemon: { up: daemonUp, lastHeartbeat: heartbeat },
@@ -2782,6 +2990,7 @@ function buildTendReport(now = Date.now()) {
2782
2990
  attention,
2783
2991
  stories,
2784
2992
  watches,
2993
+ soaking,
2785
2994
  merge
2786
2995
  };
2787
2996
  }
@@ -2840,6 +3049,22 @@ function renderTend(r) {
2840
3049
  )
2841
3050
  );
2842
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
+ }
2843
3068
  if (r.merge && (r.merge.open.length > 0 || r.merge.recent.length > 0)) {
2844
3069
  lines.push("");
2845
3070
  lines.push(
@@ -2857,13 +3082,13 @@ function renderTend(r) {
2857
3082
  }
2858
3083
 
2859
3084
  // apps/cli/src/cull.ts
2860
- import * as fs21 from "node:fs";
2861
- import * as path16 from "node:path";
3085
+ import * as fs22 from "node:fs";
3086
+ import * as path17 from "node:path";
2862
3087
  import { execFileSync as execFileSync2 } from "node:child_process";
2863
3088
  var DAY = 864e5;
2864
3089
  function idsIn(dir) {
2865
3090
  try {
2866
- return new Set(fs21.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => f.replace(/\.json$/, "")));
3091
+ return new Set(fs22.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => f.replace(/\.json$/, "")));
2867
3092
  } catch {
2868
3093
  return /* @__PURE__ */ new Set();
2869
3094
  }
@@ -2878,39 +3103,39 @@ function planCull(olderThanDays, now = Date.now()) {
2878
3103
  for (const id of idsIn(d.queue)) live.add(id);
2879
3104
  for (const id of idsIn(d.active)) live.add(id);
2880
3105
  for (const id of idsIn(d.done)) {
2881
- const p = path16.join(d.done, id);
2882
- const m = fs21.statSync(p).mtimeMs;
3106
+ const p = path17.join(d.done, id);
3107
+ const m = fs22.statSync(p).mtimeMs;
2883
3108
  doneMtimes.set(id, m);
2884
3109
  if (m < cutoff) items.push({ kind: "done", id, target: p, ageDays: Math.floor((now - m) / DAY) });
2885
3110
  }
2886
3111
  }
2887
- const wtRoot = path16.join(lobstahHome(), "worktrees");
3112
+ const wtRoot = path17.join(lobstahHome(), "worktrees");
2888
3113
  for (const id of idsIn(wtRoot)) {
2889
3114
  if (live.has(id)) continue;
2890
3115
  const doneAt = doneMtimes.get(id);
2891
3116
  if (doneAt !== void 0 && doneAt >= cutoff) continue;
2892
- const p = path16.join(wtRoot, id);
2893
- items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now - fs21.statSync(p).mtimeMs) / DAY) });
3117
+ const p = path17.join(wtRoot, id);
3118
+ items.push({ kind: "worktree", id, target: p, ageDays: Math.floor((now - fs22.statSync(p).mtimeMs) / DAY) });
2894
3119
  }
2895
3120
  for (const lane of ["work", "chore"]) {
2896
3121
  const d = laneDirs(lane);
2897
3122
  const seen = /* @__PURE__ */ new Set();
2898
- for (const f of fs21.readdirSync(d.state)) {
3123
+ for (const f of fs22.readdirSync(d.state)) {
2899
3124
  const id = f.replace(/\.(status|events|evidence|attn|notified|runner\.log)$/, "");
2900
3125
  if (id === f || seen.has(id) || live.has(id) || doneMtimes.has(id)) continue;
2901
- const m = fs21.statSync(path16.join(d.state, f)).mtimeMs;
3126
+ const m = fs22.statSync(path17.join(d.state, f)).mtimeMs;
2902
3127
  if (m >= cutoff) continue;
2903
3128
  seen.add(id);
2904
- items.push({ kind: "state", id, target: path16.join(d.state, `${id}.*`), ageDays: Math.floor((now - m) / DAY) });
3129
+ items.push({ kind: "state", id, target: path17.join(d.state, `${id}.*`), ageDays: Math.floor((now - m) / DAY) });
2905
3130
  }
2906
3131
  }
2907
3132
  return items;
2908
3133
  }
2909
3134
  function removeWorktree(id, dir) {
2910
3135
  for (const lane of ["work", "chore"]) {
2911
- const descFile = path16.join(laneDirs(lane).done, id, "descriptor.json");
3136
+ const descFile = path17.join(laneDirs(lane).done, id, "descriptor.json");
2912
3137
  try {
2913
- const desc = JSON.parse(fs21.readFileSync(descFile, "utf8"));
3138
+ const desc = JSON.parse(fs22.readFileSync(descFile, "utf8"));
2914
3139
  const repo = loadConfig().repos[desc.repo];
2915
3140
  if (repo) {
2916
3141
  execFileSync2("git", ["worktree", "remove", "--force", dir], { cwd: repo.path, stdio: "ignore" });
@@ -2919,15 +3144,15 @@ function removeWorktree(id, dir) {
2919
3144
  } catch {
2920
3145
  }
2921
3146
  }
2922
- fs21.rmSync(dir, { recursive: true, force: true });
3147
+ fs22.rmSync(dir, { recursive: true, force: true });
2923
3148
  }
2924
3149
  function applyCull(items) {
2925
3150
  for (const item of items.filter((i) => i.kind === "worktree")) removeWorktree(item.id, item.target);
2926
- for (const item of items.filter((i) => i.kind === "done")) fs21.rmSync(item.target, { recursive: true, force: true });
3151
+ for (const item of items.filter((i) => i.kind === "done")) fs22.rmSync(item.target, { recursive: true, force: true });
2927
3152
  for (const item of items.filter((i) => i.kind === "state")) {
2928
- const dir = path16.dirname(item.target);
2929
- for (const f of fs21.readdirSync(dir)) {
2930
- if (f.startsWith(`${item.id}.`)) fs21.rmSync(path16.join(dir, f), { force: true });
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 });
2931
3156
  }
2932
3157
  }
2933
3158
  }
@@ -2957,6 +3182,11 @@ your working set:
2957
3182
  external (a review session, a
2958
3183
  CI run) \u2014 its events wake you
2959
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
2960
3190
 
2961
3191
  getting woken instead of asking:
2962
3192
  lobstah man wait block until a dispatch needs attention (arm it as a
@@ -2976,7 +3206,7 @@ paste into your liaison instructions:
2976
3206
  full pattern and trade-offs: docs/lobsterman.md \xB7 config: docs/configuration.md`;
2977
3207
 
2978
3208
  // apps/cli/src/doctor.ts
2979
- import * as fs22 from "node:fs";
3209
+ import * as fs23 from "node:fs";
2980
3210
  import { spawnSync as spawnSync2 } from "node:child_process";
2981
3211
  var HEARTBEAT_STALE_MS2 = 9e4;
2982
3212
  function git(repoPath, ...args) {
@@ -2999,13 +3229,13 @@ function runDoctor(now = Date.now()) {
2999
3229
  codexPath ? "on PATH" : codexSdk ? "vendored SDK (attach uses it too)" : "neither codex nor the SDK \u2014 codex dispatches will fail"
3000
3230
  );
3001
3231
  const cfgFile = configPath();
3002
- if (!fs22.existsSync(cfgFile)) {
3232
+ if (!fs23.existsSync(cfgFile)) {
3003
3233
  push("config", "fail", `${cfgFile} missing \u2014 run \`lobstah init\``);
3004
3234
  return rows;
3005
3235
  }
3006
3236
  let parsed;
3007
3237
  try {
3008
- parsed = parse(fs22.readFileSync(cfgFile, "utf8"));
3238
+ parsed = parse(fs23.readFileSync(cfgFile, "utf8"));
3009
3239
  push("config", "ok", cfgFile);
3010
3240
  } catch (err) {
3011
3241
  push("config", "fail", `${cfgFile}: ${err instanceof Error ? err.message : String(err)}`);
@@ -3016,7 +3246,7 @@ function runDoctor(now = Date.now()) {
3016
3246
  if (repoKeys.length === 0) push("repos", "warn", "none configured \u2014 add one with `lobstah repos add <path>`");
3017
3247
  for (const [key, repo] of Object.entries(cfg.repos)) {
3018
3248
  const label = `repo ${key}`;
3019
- if (!fs22.existsSync(repo.path)) {
3249
+ if (!fs23.existsSync(repo.path)) {
3020
3250
  push(label, "fail", `path ${repo.path} does not exist`);
3021
3251
  continue;
3022
3252
  }
@@ -3044,11 +3274,11 @@ function runDoctor(now = Date.now()) {
3044
3274
  }
3045
3275
  }
3046
3276
  const hb = executorPath();
3047
- if (!fs22.existsSync(hb)) {
3277
+ if (!fs23.existsSync(hb)) {
3048
3278
  push("daemon", "warn", "no heartbeat \u2014 daemon not running (`lobstah daemon install`)");
3049
3279
  } else {
3050
3280
  try {
3051
- const payload = JSON.parse(fs22.readFileSync(hb, "utf8"));
3281
+ const payload = JSON.parse(fs23.readFileSync(hb, "utf8"));
3052
3282
  const age = now - new Date(payload.heartbeat ?? 0).getTime();
3053
3283
  push(
3054
3284
  "daemon",
@@ -3064,9 +3294,9 @@ function runDoctor(now = Date.now()) {
3064
3294
  }
3065
3295
 
3066
3296
  // apps/cli/src/service.ts
3067
- import * as fs23 from "node:fs";
3297
+ import * as fs24 from "node:fs";
3068
3298
  import * as os5 from "node:os";
3069
- import * as path17 from "node:path";
3299
+ import * as path18 from "node:path";
3070
3300
  import { spawnSync as spawnSync3 } from "node:child_process";
3071
3301
  function xml(s) {
3072
3302
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -3090,8 +3320,8 @@ function renderLaunchdPlist(spec) {
3090
3320
  </dict>
3091
3321
  <key>RunAtLoad</key><true/>
3092
3322
  <key>KeepAlive</key><true/>
3093
- <key>StandardOutPath</key><string>${xml(path17.posix.join(spec.logDir, `${spec.kind}.log`))}</string>
3094
- <key>StandardErrorPath</key><string>${xml(path17.posix.join(spec.logDir, `${spec.kind}.err`))}</string>
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>
3095
3325
  </dict>
3096
3326
  </plist>
3097
3327
  `;
@@ -3106,8 +3336,8 @@ Restart=always
3106
3336
  RestartSec=5
3107
3337
  Environment=PATH=${spec.pathEnv}
3108
3338
  Environment=LOBSTAH_HOME=${spec.home}
3109
- StandardOutput=append:${path17.posix.join(spec.logDir, `${spec.kind}.log`)}
3110
- StandardError=append:${path17.posix.join(spec.logDir, `${spec.kind}.err`)}
3339
+ StandardOutput=append:${path18.posix.join(spec.logDir, `${spec.kind}.log`)}
3340
+ StandardError=append:${path18.posix.join(spec.logDir, `${spec.kind}.err`)}
3111
3341
 
3112
3342
  [Install]
3113
3343
  WantedBy=default.target
@@ -3115,8 +3345,8 @@ WantedBy=default.target
3115
3345
  }
3116
3346
  function servicePathEnv(nodePath, home = os5.homedir()) {
3117
3347
  const dirs = [
3118
- path17.posix.dirname(nodePath),
3119
- path17.posix.join(home, ".local", "bin"),
3348
+ path18.posix.dirname(nodePath),
3349
+ path18.posix.join(home, ".local", "bin"),
3120
3350
  "/opt/homebrew/bin",
3121
3351
  "/usr/local/bin",
3122
3352
  "/usr/bin",
@@ -3125,7 +3355,7 @@ function servicePathEnv(nodePath, home = os5.homedir()) {
3125
3355
  return [...new Set(dirs)].join(":");
3126
3356
  }
3127
3357
  function serviceFile(kind) {
3128
- return process.platform === "darwin" ? path17.join(os5.homedir(), "Library", "LaunchAgents", `lobstah.${kind}.plist`) : path17.join(os5.homedir(), ".config", "systemd", "user", `lobstah-${kind}.service`);
3358
+ return process.platform === "darwin" ? path18.join(os5.homedir(), "Library", "LaunchAgents", `lobstah.${kind}.plist`) : path18.join(os5.homedir(), ".config", "systemd", "user", `lobstah-${kind}.service`);
3129
3359
  }
3130
3360
  function run(cmd, args) {
3131
3361
  const res = spawnSync3(cmd, args, { encoding: "utf8" });
@@ -3135,28 +3365,28 @@ function installService(kind) {
3135
3365
  if (process.platform === "win32") {
3136
3366
  throw new Error(`no service manager support on Windows \u2014 run \`lobstah ${kind}\` under your process manager of choice`);
3137
3367
  }
3138
- const entry = fs23.realpathSync(process.argv[1]);
3368
+ const entry = fs24.realpathSync(process.argv[1]);
3139
3369
  const spec = {
3140
3370
  kind,
3141
3371
  nodePath: process.execPath,
3142
3372
  entry,
3143
3373
  pathEnv: servicePathEnv(process.execPath),
3144
3374
  home: lobstahHome(),
3145
- logDir: path17.join(lobstahHome(), "logs")
3375
+ logDir: path18.join(lobstahHome(), "logs")
3146
3376
  };
3147
3377
  if (!onPath("git", spec.pathEnv)) {
3148
3378
  throw new Error(`git is not reachable on the service PATH (${spec.pathEnv}) \u2014 the daemon cannot allocate worktrees`);
3149
3379
  }
3150
- fs23.mkdirSync(spec.logDir, { recursive: true });
3380
+ fs24.mkdirSync(spec.logDir, { recursive: true });
3151
3381
  const file = serviceFile(kind);
3152
- fs23.mkdirSync(path17.dirname(file), { recursive: true });
3382
+ fs24.mkdirSync(path18.dirname(file), { recursive: true });
3153
3383
  if (process.platform === "darwin") {
3154
- if (fs23.existsSync(file)) run("launchctl", ["unload", file]);
3155
- fs23.writeFileSync(file, renderLaunchdPlist(spec));
3384
+ if (fs24.existsSync(file)) run("launchctl", ["unload", file]);
3385
+ fs24.writeFileSync(file, renderLaunchdPlist(spec));
3156
3386
  const load = run("launchctl", ["load", file]);
3157
3387
  return { file, loaded: load.ok, detail: load.ok ? `label lobstah.${kind}` : load.out };
3158
3388
  }
3159
- fs23.writeFileSync(file, renderSystemdUnit(spec));
3389
+ fs24.writeFileSync(file, renderSystemdUnit(spec));
3160
3390
  run("systemctl", ["--user", "daemon-reload"]);
3161
3391
  const enable = run("systemctl", ["--user", "enable", "--now", `lobstah-${kind}.service`]);
3162
3392
  return { file, loaded: enable.ok, detail: enable.ok ? `unit lobstah-${kind}.service` : enable.out };
@@ -3164,19 +3394,19 @@ function installService(kind) {
3164
3394
  function uninstallService(kind) {
3165
3395
  const file = serviceFile(kind);
3166
3396
  if (process.platform === "darwin") {
3167
- if (fs23.existsSync(file)) run("launchctl", ["unload", file]);
3397
+ if (fs24.existsSync(file)) run("launchctl", ["unload", file]);
3168
3398
  } else {
3169
3399
  run("systemctl", ["--user", "disable", "--now", `lobstah-${kind}.service`]);
3170
3400
  }
3171
- const removed = fs23.existsSync(file);
3172
- if (removed) fs23.unlinkSync(file);
3401
+ const removed = fs24.existsSync(file);
3402
+ if (removed) fs24.unlinkSync(file);
3173
3403
  if (process.platform !== "darwin") run("systemctl", ["--user", "daemon-reload"]);
3174
3404
  return { file, removed };
3175
3405
  }
3176
3406
 
3177
3407
  // apps/cli/src/repos.ts
3178
- import * as fs24 from "node:fs";
3179
- import * as path18 from "node:path";
3408
+ import * as fs25 from "node:fs";
3409
+ import * as path19 from "node:path";
3180
3410
  import { spawnSync as spawnSync4 } from "node:child_process";
3181
3411
  function git2(dir, ...args) {
3182
3412
  const res = spawnSync4("git", ["-C", dir, ...args], { encoding: "utf8" });
@@ -3192,41 +3422,41 @@ var LOCKFILE_SETUP = [
3192
3422
  ["poetry.lock", ["poetry install"]]
3193
3423
  ];
3194
3424
  function detectRepo(dir) {
3195
- const abs = path18.resolve(dir);
3425
+ const abs = path19.resolve(dir);
3196
3426
  if (git2(abs, "rev-parse", "--is-inside-work-tree") !== "true") return void 0;
3197
3427
  const top = git2(abs, "rev-parse", "--show-toplevel");
3198
- const canon = (p) => {
3199
- const real = path18.resolve(fs24.realpathSync.native(p));
3428
+ const canon2 = (p) => {
3429
+ const real = path19.resolve(fs25.realpathSync.native(p));
3200
3430
  return process.platform === "win32" ? real.toLowerCase() : real;
3201
3431
  };
3202
- if (!top || canon(top) !== canon(abs)) return void 0;
3432
+ if (!top || canon2(top) !== canon2(abs)) return void 0;
3203
3433
  const origin = git2(abs, "remote", "get-url", "origin");
3204
3434
  const headRef = git2(abs, "symbolic-ref", "--short", "refs/remotes/origin/HEAD");
3205
3435
  const trunk = headRef?.replace(/^origin\//, "") ?? git2(abs, "branch", "--show-current") ?? "main";
3206
- const setup = LOCKFILE_SETUP.find(([f]) => fs24.existsSync(path18.join(abs, f)))?.[1];
3207
- return { key: path18.basename(abs), path: abs, trunk, origin, setup };
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 };
3208
3438
  }
3209
3439
  function scanForRepos(roots) {
3210
3440
  const found = [];
3211
3441
  for (const root of roots) {
3212
- const abs = path18.resolve(root);
3213
- if (!fs24.existsSync(abs)) continue;
3442
+ const abs = path19.resolve(root);
3443
+ if (!fs25.existsSync(abs)) continue;
3214
3444
  const self = detectRepo(abs);
3215
3445
  if (self) {
3216
3446
  found.push(self);
3217
3447
  continue;
3218
3448
  }
3219
- for (const entry of fs24.readdirSync(abs, { withFileTypes: true })) {
3449
+ for (const entry of fs25.readdirSync(abs, { withFileTypes: true })) {
3220
3450
  if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
3221
- const child = detectRepo(path18.join(abs, entry.name));
3451
+ const child = detectRepo(path19.join(abs, entry.name));
3222
3452
  if (child) found.push(child);
3223
3453
  }
3224
3454
  }
3225
3455
  return found;
3226
3456
  }
3227
3457
  function configuredRepoKeys(file = configPath()) {
3228
- if (!fs24.existsSync(file)) return /* @__PURE__ */ new Set();
3229
- const raw = parse(fs24.readFileSync(file, "utf8"));
3458
+ if (!fs25.existsSync(file)) return /* @__PURE__ */ new Set();
3459
+ const raw = parse(fs25.readFileSync(file, "utf8"));
3230
3460
  return new Set(Object.keys(raw.repos ?? {}));
3231
3461
  }
3232
3462
  function appendRepoBlock(repo, opts = {}, file = configPath()) {
@@ -3234,7 +3464,7 @@ function appendRepoBlock(repo, opts = {}, file = configPath()) {
3234
3464
  if (repo.origin) lines.push(`origin = ${JSON.stringify(repo.origin)}`);
3235
3465
  if (repo.setup) lines.push(`setup = [${repo.setup.map((s) => JSON.stringify(s)).join(", ")}]`);
3236
3466
  if (opts.pickup) lines.push(`pickup = true`);
3237
- fs24.appendFileSync(file, `${lines.join("\n")}
3467
+ fs25.appendFileSync(file, `${lines.join("\n")}
3238
3468
  `);
3239
3469
  }
3240
3470
 
@@ -3246,17 +3476,220 @@ function parseReportArgs(rest) {
3246
3476
  return { note, prUrl };
3247
3477
  }
3248
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
+
3249
3679
  // apps/cli/src/main.ts
3250
3680
  var HELP = `lobstah \u2014 supervision framework for coding agents
3251
3681
 
3252
3682
  work (humans and agents):
3253
3683
  dispatch --repo <key> (--brief <file> | --brief-text <text>) (alias: set --bait)
3254
3684
  [--harness claude|codex] [--model <m>] [--effort <e>]
3255
- [--follow-up <uuid>] [--chore] [--id <uuid>]
3256
- 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
3257
3689
  ls [--all] queue, active, recent done (alias: buoys)
3258
3690
  status [<uuid>] reconciled state (alias: buoy)
3259
- logs <uuid> [--follow] the normalized event stream
3691
+ logs <uuid> [--follow|--full] the normalized event stream (last 50 events
3692
+ by default; --full for everything)
3260
3693
  send <uuid> <message> deliver an instruction between agent turns
3261
3694
  inbox <uuid> read and acknowledge pending messages
3262
3695
  (workers: check at natural checkpoints)
@@ -3302,7 +3735,7 @@ lobsterman (orchestrator sessions \u2014 bare \`lobstah man\` prints the manual)
3302
3735
  man wait [--timeout <secs>] [--peek]
3303
3736
  block until a dispatch or watched source
3304
3737
  needs attention, then print the event and
3305
- what to do next; exit 2 on timeout. Runs due
3738
+ what to do next; exit 3 on timeout. Runs due
3306
3739
  watch checks itself when no pick process is.
3307
3740
  Unanswered questions re-fire every
3308
3741
  remindSecs until answered.
@@ -3323,6 +3756,19 @@ workers (dispatched agents; injected into every brief):
3323
3756
  the validated status write path
3324
3757
  (${VERBS.join(" | ")})
3325
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
+
3326
3772
  setup:
3327
3773
  init [--scan <dir>... [--pickup]]
3328
3774
  create ~/.lobstah + example config; --scan
@@ -3343,6 +3789,40 @@ function arg(args, flag) {
3343
3789
  return i >= 0 ? args[i + 1] : void 0;
3344
3790
  }
3345
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
+ }
3346
3826
  function runDueManWatches() {
3347
3827
  for (const w of listWatches()) {
3348
3828
  if (w.owner === "man" && watchDue(w, WATCH_EVERY_SECS)) runWatchCheck(w);
@@ -3361,7 +3841,7 @@ function emitWatchAttention(attns) {
3361
3841
  function findLane(id) {
3362
3842
  for (const lane of ["work", "chore"]) {
3363
3843
  const d = laneDirs(lane);
3364
- if (fs25.existsSync(path19.join(d.active, id)) || fs25.existsSync(path19.join(d.queue, `${id}.json`)) || fs25.existsSync(path19.join(d.done, id)) || fs25.existsSync(path19.join(d.state, `${id}.status`))) {
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`))) {
3365
3845
  return lane;
3366
3846
  }
3367
3847
  }
@@ -3369,7 +3849,7 @@ function findLane(id) {
3369
3849
  }
3370
3850
  function rowsFor(lane, bucket) {
3371
3851
  const dir = laneDirs(lane)[bucket];
3372
- const entries = fs25.readdirSync(dir).filter((f) => !f.startsWith(".")).map((f) => ({ f, m: fs25.statSync(path19.join(dir, f)).mtimeMs })).sort((a, b) => b.m - a.m);
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);
3373
3853
  const sliced = bucket === "done" ? entries.slice(0, 10) : entries;
3374
3854
  return sliced.map(({ f, m }) => {
3375
3855
  const id = f.replace(/\.json$/, "");
@@ -3382,10 +3862,20 @@ async function mainCli() {
3382
3862
  const ALIASES = { set: "dispatch", buoys: "ls", buoy: "status" };
3383
3863
  cmd = cmd !== void 0 ? ALIASES[cmd] ?? cmd : cmd;
3384
3864
  if (cmd === "man") {
3385
- cmd = args.length > 0 ? `man:${args[0]}` : "man:manual";
3865
+ cmd = args.length > 0 && args[0] !== "--help" ? `man:${args[0]}` : "man:manual";
3386
3866
  args = args.slice(1);
3387
3867
  }
3388
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
+ }
3389
3879
  switch (cmd) {
3390
3880
  case "dispatch": {
3391
3881
  const repo = arg(args, "--repo");
@@ -3394,18 +3884,30 @@ async function mainCli() {
3394
3884
  if (!repo || !briefFile && !briefText) {
3395
3885
  throw new Error("dispatch requires --repo and --brief <file> (or --brief-text)");
3396
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
+ }
3397
3891
  const d = {
3398
3892
  id: arg(args, "--id") ?? randomUUID4(),
3399
3893
  repo,
3400
- brief: briefText ?? fs25.readFileSync(briefFile, "utf8"),
3894
+ brief: briefText ?? fs27.readFileSync(briefFile, "utf8"),
3401
3895
  harness: arg(args, "--harness"),
3402
3896
  model: arg(args, "--model"),
3403
3897
  effort: arg(args, "--effort"),
3404
- followUp: arg(args, "--follow-up")
3898
+ followUp: arg(args, "--follow-up"),
3899
+ for: address
3405
3900
  };
3406
3901
  const lane = args.includes("--chore") ? "chore" : "work";
3407
3902
  enqueue(d, lane);
3408
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
+ );
3409
3911
  break;
3410
3912
  }
3411
3913
  case "ls": {
@@ -3414,6 +3916,7 @@ async function mainCli() {
3414
3916
  (lane) => ["queue", "active", "done"].flatMap((b) => rowsFor(lane, b))
3415
3917
  );
3416
3918
  console.log(toonTable("dispatches", rows, ["id", "lane", "bucket", "state", "updated"]));
3919
+ console.log(toonHelp(["lobstah status <id>", "lobstah man tend (verdict + stories + gates)"]));
3417
3920
  break;
3418
3921
  }
3419
3922
  case "status": {
@@ -3427,6 +3930,11 @@ async function mainCli() {
3427
3930
  const log = readStatusLog(id, lane);
3428
3931
  const state = reconcile({ log, lastEventAt: lastEventAt(id, lane) });
3429
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
+ );
3430
3938
  break;
3431
3939
  }
3432
3940
  case "logs": {
@@ -3434,17 +3942,29 @@ async function mainCli() {
3434
3942
  if (!id) throw new Error("logs requires a dispatch id");
3435
3943
  const lane = findLane(id);
3436
3944
  const file = eventsPath(id, lane);
3437
- if (fs25.existsSync(file)) process.stdout.write(fs25.readFileSync(file, "utf8"));
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
+ }
3438
3958
  if (args.includes("--follow")) {
3439
- let size = fs25.existsSync(file) ? fs25.statSync(file).size : 0;
3959
+ let size = fs27.existsSync(file) ? fs27.statSync(file).size : 0;
3440
3960
  setInterval(() => {
3441
- if (!fs25.existsSync(file)) return;
3442
- const now = fs25.statSync(file).size;
3961
+ if (!fs27.existsSync(file)) return;
3962
+ const now = fs27.statSync(file).size;
3443
3963
  if (now > size) {
3444
- const fd = fs25.openSync(file, "r");
3964
+ const fd = fs27.openSync(file, "r");
3445
3965
  const buf = Buffer.alloc(now - size);
3446
- fs25.readSync(fd, buf, 0, buf.length, size);
3447
- fs25.closeSync(fd);
3966
+ fs27.readSync(fd, buf, 0, buf.length, size);
3967
+ fs27.closeSync(fd);
3448
3968
  process.stdout.write(buf.toString("utf8"));
3449
3969
  size = now;
3450
3970
  }
@@ -3498,23 +4018,23 @@ async function mainCli() {
3498
4018
  );
3499
4019
  }
3500
4020
  const dirs = laneDirs(lane);
3501
- const descriptorFile = ["active", "done"].map((b) => path19.join(dirs[b], id, "descriptor.json")).find((f) => fs25.existsSync(f));
3502
- const harness = descriptorFile ? JSON.parse(fs25.readFileSync(descriptorFile, "utf8")).harness ?? "claude" : "claude";
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";
3503
4023
  let sessionId;
3504
4024
  try {
3505
- sessionId = JSON.parse(fs25.readFileSync(path19.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
4025
+ sessionId = JSON.parse(fs27.readFileSync(path21.join(dirs.state, `${id}.evidence`), "utf8")).sessionId;
3506
4026
  } catch {
3507
4027
  }
3508
4028
  if (!sessionId) throw new Error(`${id} has no recorded harness session to attach to`);
3509
- const worktree = path19.join(lobstahHome(), "worktrees", id);
3510
- const cwd = fs25.existsSync(worktree) ? worktree : process.cwd();
4029
+ const worktree = path21.join(lobstahHome(), "worktrees", id);
4030
+ const cwd = fs27.existsSync(worktree) ? worktree : process.cwd();
3511
4031
  const invocation = harness === "codex" ? codexInvocation(["resume", sessionId]) : { file: "claude", argv: ["--resume", sessionId] };
3512
4032
  if (!invocation) throw new Error("no codex CLI found \u2014 neither on PATH nor vendored by @openai/codex-sdk");
3513
4033
  if (args.includes("--print")) {
3514
4034
  console.log(toonKV({ id, harness, sessionId, cwd, command: `${invocation.file} ${invocation.argv.join(" ")}` }));
3515
4035
  break;
3516
4036
  }
3517
- const res = spawnSync5(invocation.file, invocation.argv, { cwd, stdio: "inherit" });
4037
+ const res = spawnSync6(invocation.file, invocation.argv, { cwd, stdio: "inherit" });
3518
4038
  if (res.error) throw new Error(`could not launch ${invocation.file}: ${res.error.message}`);
3519
4039
  break;
3520
4040
  }
@@ -3522,39 +4042,39 @@ async function mainCli() {
3522
4042
  const id = args[0];
3523
4043
  if (!id) throw new Error("swap requires a dispatch id");
3524
4044
  const lane = findLane(id);
3525
- const activeDir = path19.join(laneDirs(lane).active, id);
3526
- if (!fs25.existsSync(activeDir)) throw new Error(`${id} is not active \u2014 swap only applies to in-flight dispatches`);
3527
- const descFile = path19.join(activeDir, "descriptor.json");
3528
- const descriptor = JSON.parse(fs25.readFileSync(descFile, "utf8"));
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"));
3529
4049
  const fromHarness = descriptor.harness ?? "default";
3530
4050
  for (const key of ["harness", "model", "effort"]) {
3531
4051
  const v = arg(args, `--${key}`);
3532
4052
  if (v) descriptor[key] = v;
3533
4053
  }
3534
- fs25.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
3535
- const worktree = path19.join(lobstahHome(), "worktrees", id);
4054
+ fs27.writeFileSync(descFile, JSON.stringify(descriptor, null, 2));
4055
+ const worktree = path21.join(lobstahHome(), "worktrees", id);
3536
4056
  let progress = "No worktree progress recorded.";
3537
- if (fs25.existsSync(worktree)) {
3538
- const git3 = (...a) => spawnSync5("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
3539
- const commits = git3("log", "--oneline", "-15");
3540
- const status = git3("status", "--short");
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");
3541
4061
  progress = `Commits so far:
3542
4062
  ${commits || "(none)"}
3543
4063
 
3544
4064
  Uncommitted changes:
3545
4065
  ${status || "(clean)"}`;
3546
4066
  }
3547
- fs25.writeFileSync(
3548
- path19.join(activeDir, "handoff"),
4067
+ fs27.writeFileSync(
4068
+ path21.join(activeDir, "handoff"),
3549
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.
3550
4070
 
3551
4071
  ${progress}`
3552
4072
  );
3553
- const runnerFile = path19.join(activeDir, "runner.json");
3554
- if (fs25.existsSync(runnerFile)) {
3555
- const runner = JSON.parse(fs25.readFileSync(runnerFile, "utf8"));
4073
+ const runnerFile = path21.join(activeDir, "runner.json");
4074
+ if (fs27.existsSync(runnerFile)) {
4075
+ const runner = JSON.parse(fs27.readFileSync(runnerFile, "utf8"));
3556
4076
  if (pidAlive(runner.pid, runner.processStartTime)) killGroup(runner.pid, "SIGKILL");
3557
- fs25.unlinkSync(runnerFile);
4077
+ fs27.unlinkSync(runnerFile);
3558
4078
  }
3559
4079
  appendStatus(id, lane, "working", `swapped to ${descriptor.harness ?? "default"} \u2014 awaiting respawn`);
3560
4080
  console.log(toonKV({ id, harness: descriptor.harness ?? "default", model: descriptor.model, swapped: true }));
@@ -3655,24 +4175,24 @@ ${progress}`
3655
4175
  }
3656
4176
  }
3657
4177
  console.log(toonKV({ timeout: true, waitedSecs: timeoutSecs }));
3658
- process.exitCode = 2;
4178
+ process.exitCode = 3;
3659
4179
  break;
3660
4180
  }
3661
4181
  case "man:init": {
3662
- const file = args.includes("--global") ? path19.join(os6.homedir(), ".claude", "settings.json") : path19.join(".claude", args.includes("--shared") ? "settings.json" : "settings.local.json");
4182
+ const file = args.includes("--global") ? path21.join(os6.homedir(), ".claude", "settings.json") : path21.join(".claude", args.includes("--shared") ? "settings.json" : "settings.local.json");
3663
4183
  let existing;
3664
4184
  try {
3665
- existing = JSON.parse(fs25.readFileSync(file, "utf8"));
4185
+ existing = JSON.parse(fs27.readFileSync(file, "utf8"));
3666
4186
  } catch {
3667
4187
  existing = void 0;
3668
4188
  }
3669
4189
  const { settings, changed } = mergeHaulHook(existing);
3670
4190
  if (changed) {
3671
- fs25.mkdirSync(path19.dirname(file), { recursive: true });
3672
- fs25.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
4191
+ fs27.mkdirSync(path21.dirname(file), { recursive: true });
4192
+ fs27.writeFileSync(file, `${JSON.stringify(settings, null, 2)}
3673
4193
  `);
3674
4194
  }
3675
- if (args.includes("--marker")) fs25.writeFileSync(".lobstah-man", "");
4195
+ if (args.includes("--marker")) fs27.writeFileSync(".lobstah-man", "");
3676
4196
  console.log(
3677
4197
  toonKV({
3678
4198
  hook: "lobstah man haul",
@@ -3685,9 +4205,15 @@ ${progress}`
3685
4205
  }
3686
4206
  case "man:haul": {
3687
4207
  try {
3688
- if (process.env.LOBSTAH_MAN !== "1" && !fs25.existsSync(".lobstah-man")) break;
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;
3689
4215
  const anyActive = ["work", "chore"].some(
3690
- (l) => fs25.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
4216
+ (l) => fs27.readdirSync(laneDirs(l).active).some((f) => !f.startsWith("."))
3691
4217
  );
3692
4218
  const anyWatch = listWatches().some((w) => w.owner === "man");
3693
4219
  if (!anyActive && !anyWatch) break;
@@ -3727,6 +4253,87 @@ ${progress}`
3727
4253
  }
3728
4254
  break;
3729
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
+ }
3730
4337
  case "daemon":
3731
4338
  case "pick": {
3732
4339
  const kind = cmd;
@@ -3768,7 +4375,7 @@ ${progress}`
3768
4375
  path: r.path,
3769
4376
  trunk: r.trunk,
3770
4377
  pickup: r.pickup ?? false,
3771
- exists: fs25.existsSync(r.path)
4378
+ exists: fs27.existsSync(r.path)
3772
4379
  }));
3773
4380
  console.log(toonTable("repos", rows, ["key", "path", "trunk", "pickup", "exists"]));
3774
4381
  break;
@@ -3793,6 +4400,11 @@ ${progress}`
3793
4400
  stream: arg(args, "--stream")
3794
4401
  });
3795
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
+ );
3796
4408
  break;
3797
4409
  }
3798
4410
  if (sub === "rm") {
@@ -3813,7 +4425,7 @@ ${progress}`
3813
4425
  break;
3814
4426
  }
3815
4427
  case "init": {
3816
- if (!fs25.existsSync(configPath())) {
4428
+ if (!fs27.existsSync(configPath())) {
3817
4429
  const exampleRepo = args.includes("--scan") ? "" : `[repos.example]
3818
4430
  path = "~/src/example"
3819
4431
  trunk = "main"
@@ -3822,7 +4434,7 @@ trunk = "main"
3822
4434
  # pickup = true # opt into [pickup.github] multi-repo tracker pickup
3823
4435
 
3824
4436
  `;
3825
- fs25.writeFileSync(
4437
+ fs27.writeFileSync(
3826
4438
  configPath(),
3827
4439
  `# lobstah workspace definitions \u2014 the descriptor's repo key resolves here.
3828
4440
  # Top-level keys must come before any [section].
@@ -3860,12 +4472,12 @@ wallClockSecs = 3600
3860
4472
  let reference = "https://github.com/aequitas-labs/lobstah/blob/main/docs/configuration.md";
3861
4473
  try {
3862
4474
  const local = fileURLToPath2(new URL("../docs/configuration.md", import.meta.url));
3863
- if (fs25.existsSync(local)) reference = local;
4475
+ if (fs27.existsSync(local)) reference = local;
3864
4476
  } catch {
3865
4477
  }
3866
4478
  console.log(
3867
4479
  toonKV({
3868
- home: path19.dirname(configPath()),
4480
+ home: path21.dirname(configPath()),
3869
4481
  config: configPath(),
3870
4482
  ...added.length > 0 ? { added: added.join(", ") } : {},
3871
4483
  ...unmarked.length > 0 ? { note: `none marked pickable \u2014 set pickup = true per [repos.*] (or rerun with --pickup)` } : {},
@@ -3879,18 +4491,35 @@ wallClockSecs = 3600
3879
4491
  case "-v":
3880
4492
  console.log(lobstahVersion());
3881
4493
  break;
3882
- 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
+ }
3883
4509
  case "--help":
3884
4510
  case "help":
3885
4511
  console.log(HELP);
3886
4512
  break;
3887
4513
  default:
3888
- throw new Error(`unknown command "${cmd}" \u2014 run lobstah help`);
4514
+ throw new UsageError(`unknown command "${cmd}" \u2014 run \`lobstah help\``);
3889
4515
  }
3890
4516
  }
3891
4517
  mainCli().catch((err) => {
3892
- console.error(`error: ${err instanceof Error ? err.message : String(err)}`);
3893
- process.exit(1);
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);
3894
4523
  });
3895
4524
  /*! Bundled license information:
3896
4525