lobstah 0.5.5 → 0.5.7

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/runner.js CHANGED
@@ -1,7 +1,6 @@
1
1
  // packages/runner/src/index.ts
2
- import * as fs10 from "node:fs";
3
- import * as path8 from "node:path";
4
- import { fileURLToPath } from "node:url";
2
+ import * as fs13 from "node:fs";
3
+ import * as path11 from "node:path";
5
4
 
6
5
  // packages/core/dist/types.js
7
6
  var VERBS = ["working", "needs-decision", "blocked", "paused", "done", "failed"];
@@ -26,6 +25,9 @@ function laneDirs(lane) {
26
25
  inbox: path.join(root, "inbox")
27
26
  };
28
27
  }
28
+ function soakingDir() {
29
+ return path.join(lobstahHome(), "soaking");
30
+ }
29
31
  function statusPath(id, lane) {
30
32
  return path.join(laneDirs(lane).state, `${id}.status`);
31
33
  }
@@ -80,6 +82,20 @@ function readStatusLog(id, lane) {
80
82
  }
81
83
 
82
84
  // packages/core/dist/queue.js
85
+ function storedDescriptor(id, lane) {
86
+ const dirs = laneDirs(lane);
87
+ for (const file of [
88
+ path2.join(dirs.queue, `${id}.json`),
89
+ path2.join(dirs.active, id, "descriptor.json"),
90
+ path2.join(dirs.done, id, "descriptor.json")
91
+ ]) {
92
+ try {
93
+ return JSON.parse(fs2.readFileSync(file, "utf8"));
94
+ } catch {
95
+ }
96
+ }
97
+ return void 0;
98
+ }
83
99
  function complete(id, lane) {
84
100
  const dirs = laneDirs(lane);
85
101
  fs2.renameSync(path2.join(dirs.active, id), path2.join(dirs.done, id));
@@ -794,8 +810,8 @@ function parse(toml, { maxDepth = 1e3, integersAsBigInt } = {}) {
794
810
  }
795
811
 
796
812
  // packages/core/dist/config.js
797
- var ATTENTION_KINDS = ["question", "landed", "pr:draft", "pr:review", "pr:checks", "pr:ready"];
798
- var DEFAULT_ATTENTION_KINDS = ["question", "pr:draft", "pr:review", "pr:checks", "pr:ready"];
813
+ var ATTENTION_KINDS = ["question", "landed", "pr:draft", "pr:review", "pr:checks", "pr:conflict", "pr:ready"];
814
+ var DEFAULT_ATTENTION_KINDS = ["question", "pr:draft", "pr:review", "pr:checks", "pr:conflict", "pr:ready"];
799
815
  function parseAttentionKinds(raw) {
800
816
  if (raw === void 0)
801
817
  return [...DEFAULT_ATTENTION_KINDS];
@@ -928,12 +944,127 @@ function codexInvocation(argv) {
928
944
  return void 0;
929
945
  }
930
946
 
947
+ // packages/core/dist/soak.js
948
+ import * as fs8 from "node:fs";
949
+ import * as path6 from "node:path";
950
+ function regPath(trapId) {
951
+ return path6.join(soakingDir(), `${trapId}.json`);
952
+ }
953
+ function readTrap(trapId) {
954
+ try {
955
+ return JSON.parse(fs8.readFileSync(regPath(trapId), "utf8"));
956
+ } catch {
957
+ return void 0;
958
+ }
959
+ }
960
+
931
961
  // packages/core/dist/compiled.js
932
962
  var COMPILED_BINARY = (() => {
933
963
  const bun = globalThis.Bun;
934
964
  return bun !== void 0 && String(bun.main ?? "").includes("$bunfs");
935
965
  })();
936
966
 
967
+ // packages/core/dist/resume.js
968
+ import * as fs9 from "node:fs";
969
+ import * as path7 from "node:path";
970
+ import { spawnSync } from "node:child_process";
971
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-([0-9a-f])[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
972
+ function harnessFromSessionId(sessionId) {
973
+ const version = sessionId ? UUID.exec(sessionId)?.[1] : void 0;
974
+ return version === "7" ? "codex" : version === "4" ? "claude" : void 0;
975
+ }
976
+ function claimOf(id, lane) {
977
+ const dirs = laneDirs(lane);
978
+ for (const dir of [dirs.active, dirs.done]) {
979
+ try {
980
+ return JSON.parse(fs9.readFileSync(path7.join(dir, id, "claim.json"), "utf8"));
981
+ } catch {
982
+ }
983
+ }
984
+ return void 0;
985
+ }
986
+ function laneOf(id) {
987
+ for (const lane of ["work", "chore"]) {
988
+ if (storedDescriptor(id, lane) || readEvidence(id, lane).sessionId)
989
+ return lane;
990
+ }
991
+ return void 0;
992
+ }
993
+ function resolveSessionHarness(id, cfg, laneHint) {
994
+ const lane = laneHint ?? laneOf(id);
995
+ if (!lane)
996
+ return {};
997
+ const ev = readEvidence(id, lane);
998
+ const sessionId = ev.sessionId;
999
+ const out = (harness, source) => ({
1000
+ sessionId,
1001
+ harness,
1002
+ source,
1003
+ lane
1004
+ });
1005
+ if (ev.harness)
1006
+ return out(ev.harness, "evidence");
1007
+ const claim = claimOf(id, lane);
1008
+ if (claim?.harness)
1009
+ return out(claim.harness, "trap");
1010
+ if (ev.deliveredTo?.startsWith("wt:")) {
1011
+ const reg = readTrap(ev.deliveredTo.slice("wt:".length));
1012
+ if (reg?.harness)
1013
+ return out(reg.harness, "trap");
1014
+ }
1015
+ const byId = harnessFromSessionId(sessionId);
1016
+ if (byId)
1017
+ return out(byId, "session-id");
1018
+ const d = storedDescriptor(id, lane);
1019
+ const repo = d && cfg?.repos[d.repo];
1020
+ const fromDescriptor = d?.harness ?? repo?.harness?.default ?? cfg?.harness.default;
1021
+ if (fromDescriptor)
1022
+ return out(fromDescriptor, "descriptor");
1023
+ return { sessionId, lane };
1024
+ }
1025
+ var UNRESUMABLE = /no conversation found|no rollout found|(session|thread|conversation)( id)?[^.\n]{0,80}\b(not found|does not exist|unknown|invalid)|(could not|cannot|failed to|unable to) (find|load|resume)[^.\n]{0,40}\b(session|thread|conversation)/i;
1026
+ function isUnresumable(error) {
1027
+ return error !== void 0 && UNRESUMABLE.test(error);
1028
+ }
1029
+ function worktreeProgress(worktree, trunk) {
1030
+ if (!worktree || !fs9.existsSync(worktree))
1031
+ return "No worktree progress recorded.";
1032
+ const git2 = (...a) => spawnSync("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
1033
+ const commits = trunk ? git2("log", "--oneline", `origin/${trunk}..HEAD`) : git2("log", "--oneline", "-15");
1034
+ const status = git2("status", "--short");
1035
+ return `Commits so far:
1036
+ ${commits || "(none)"}
1037
+
1038
+ Uncommitted changes:
1039
+ ${status || "(clean)"}`;
1040
+ }
1041
+ function originProgress(originId, lane) {
1042
+ const ev = readEvidence(originId, lane);
1043
+ const last = readStatusLog(originId, lane).at(-1);
1044
+ const lines = [`The earlier dispatch ${originId} left:`];
1045
+ if (ev.branch)
1046
+ lines.push(`- branch: ${ev.branch}`);
1047
+ if (ev.prUrl)
1048
+ lines.push(`- PR: ${ev.prUrl}`);
1049
+ if (last)
1050
+ lines.push(`- last status: ${last.verb}${last.note ? ` \u2014 ${last.note}` : ""}`);
1051
+ if (ev.commits?.length)
1052
+ lines.push("- commits:", ...ev.commits.map((c) => ` ${c}`));
1053
+ if (lines.length === 1)
1054
+ lines.push("- (no recorded branch, PR, or commits)");
1055
+ return lines.join("\n");
1056
+ }
1057
+ function handoffNote(fromHarness, progress, why) {
1058
+ return `You are taking over this dispatch from a previous agent session (harness: ${fromHarness}). Its conversation is not available${why ? ` (${why})` : ""} \u2014 the state below is the ground truth. Review it, then continue the brief from where it stops.
1059
+
1060
+ ${progress}`;
1061
+ }
1062
+
1063
+ // packages/runner/src/run.ts
1064
+ import * as fs12 from "node:fs";
1065
+ import * as path10 from "node:path";
1066
+ import { fileURLToPath } from "node:url";
1067
+
937
1068
  // packages/adapters/dist/claude.js
938
1069
  import { spawn } from "node:child_process";
939
1070
 
@@ -1212,25 +1343,25 @@ function createClaudeAdapter() {
1212
1343
  }
1213
1344
 
1214
1345
  // packages/adapters/dist/codex.js
1215
- import * as fs8 from "node:fs";
1346
+ import * as fs10 from "node:fs";
1216
1347
  import * as os3 from "node:os";
1217
- import * as path6 from "node:path";
1348
+ import * as path8 from "node:path";
1218
1349
  import { spawn as spawn2 } from "node:child_process";
1219
1350
  function isolatedCodexHome() {
1220
- const dir = path6.join(lobstahHome(), "codex-home");
1221
- fs8.mkdirSync(dir, { recursive: true });
1222
- const auth = path6.join(dir, "auth.json");
1223
- const realAuth = path6.join(os3.homedir(), ".codex", "auth.json");
1224
- if (!fs8.existsSync(auth) && fs8.existsSync(realAuth)) {
1351
+ const dir = path8.join(lobstahHome(), "codex-home");
1352
+ fs10.mkdirSync(dir, { recursive: true });
1353
+ const auth = path8.join(dir, "auth.json");
1354
+ const realAuth = path8.join(os3.homedir(), ".codex", "auth.json");
1355
+ if (!fs10.existsSync(auth) && fs10.existsSync(realAuth)) {
1225
1356
  try {
1226
- fs8.symlinkSync(realAuth, auth);
1357
+ fs10.symlinkSync(realAuth, auth);
1227
1358
  } catch {
1228
- fs8.copyFileSync(realAuth, auth);
1359
+ fs10.copyFileSync(realAuth, auth);
1229
1360
  }
1230
1361
  }
1231
- const cfg = path6.join(dir, "config.toml");
1232
- if (!fs8.existsSync(cfg)) {
1233
- fs8.writeFileSync(cfg, "# lobstah-managed: auth inherited from ~/.codex, interactive config not\n");
1362
+ const cfg = path8.join(dir, "config.toml");
1363
+ if (!fs10.existsSync(cfg)) {
1364
+ fs10.writeFileSync(cfg, "# lobstah-managed: auth inherited from ~/.codex, interactive config not\n");
1234
1365
  }
1235
1366
  return dir;
1236
1367
  }
@@ -1427,14 +1558,14 @@ function loadAdapter(name) {
1427
1558
  }
1428
1559
 
1429
1560
  // packages/worktree/dist/index.js
1430
- import * as fs9 from "node:fs";
1431
- import * as path7 from "node:path";
1561
+ import * as fs11 from "node:fs";
1562
+ import * as path9 from "node:path";
1432
1563
  import { exec, execFile } from "node:child_process";
1433
1564
  import { promisify } from "node:util";
1434
1565
  var run = promisify(execFile);
1435
1566
  var shell = promisify(exec);
1436
1567
  function worktreePath(id) {
1437
- return path7.join(lobstahHome(), "worktrees", id);
1568
+ return path9.join(lobstahHome(), "worktrees", id);
1438
1569
  }
1439
1570
  async function git(cwd, ...args) {
1440
1571
  const { stdout } = await run("git", args, { cwd, env: process.env });
@@ -1442,10 +1573,10 @@ async function git(cwd, ...args) {
1442
1573
  }
1443
1574
  async function allocate(repo, id) {
1444
1575
  const dir = worktreePath(id);
1445
- if (fs9.existsSync(dir)) {
1576
+ if (fs11.existsSync(dir)) {
1446
1577
  throw new Error(`worktree for ${id} already exists at ${dir} \u2014 never allocate a second`);
1447
1578
  }
1448
- if (!fs9.existsSync(repo.path)) {
1579
+ if (!fs11.existsSync(repo.path)) {
1449
1580
  if (!repo.origin)
1450
1581
  throw new Error(`repo path ${repo.path} missing and no origin configured`);
1451
1582
  await run("git", ["clone", repo.origin, repo.path], { env: process.env });
@@ -1495,6 +1626,7 @@ function deliver(run2, id, lane) {
1495
1626
  async function drive(run2, opts) {
1496
1627
  const { id, lane, pollMs = 3e3, stopped = () => false } = opts;
1497
1628
  let cancelled = false;
1629
+ let activity = 0;
1498
1630
  let finished = false;
1499
1631
  void run2.done.then(() => finished = true);
1500
1632
  const cancel = () => {
@@ -1517,6 +1649,7 @@ async function drive(run2, opts) {
1517
1649
  };
1518
1650
  for await (const ev of run2.events) {
1519
1651
  appendEvent(id, lane, ev);
1652
+ if (ev.type === "tool-start" || ev.type === "text") activity++;
1520
1653
  if (ev.type === "session" && ev.data?.sessionId) {
1521
1654
  mergeEvidence(id, lane, { sessionId: String(ev.data.sessionId) });
1522
1655
  }
@@ -1537,7 +1670,7 @@ async function drive(run2, opts) {
1537
1670
  }
1538
1671
  run2.end();
1539
1672
  }
1540
- return { cancelled };
1673
+ return { cancelled, activity };
1541
1674
  }
1542
1675
  function settle(id, lane, r) {
1543
1676
  const lastVerb = readStatusLog(id, lane).at(-1)?.verb;
@@ -1547,74 +1680,174 @@ function settle(id, lane, r) {
1547
1680
  else if (!lastVerb || !TERMINAL_VERBS.includes(lastVerb)) appendStatus(id, lane, "done");
1548
1681
  }
1549
1682
 
1550
- // packages/runner/src/index.ts
1551
- function followUpSession(followUp) {
1552
- for (const lane of ["work", "chore"]) {
1553
- const ev = readEvidence(followUp, lane);
1554
- if (ev.sessionId) return ev.sessionId;
1683
+ // packages/runner/src/plan.ts
1684
+ var short = (s) => s.slice(0, 8);
1685
+ function askedHarness(id, lane, cfg) {
1686
+ const d = storedDescriptor(id, lane);
1687
+ return d?.harness ?? (d ? cfg.repos[d.repo]?.harness?.default : void 0) ?? cfg.harness.default ?? "claude";
1688
+ }
1689
+ function planStart(input) {
1690
+ const { id, lane, descriptor, cfg, resolvedHarness, envResume } = input;
1691
+ if (envResume) {
1692
+ const own = resolveSessionHarness(id, cfg, lane);
1693
+ const harness = (own.sessionId === envResume ? own.harness : harnessFromSessionId(envResume)) ?? resolvedHarness;
1694
+ return {
1695
+ harness,
1696
+ resume: { sessionId: envResume, own: true },
1697
+ note: harness !== resolvedHarness ? `resuming own ${harness} session ${short(envResume)}` : void 0
1698
+ };
1555
1699
  }
1556
- return void 0;
1700
+ const prior = readEvidence(id, lane).sessionId;
1701
+ if (prior) {
1702
+ const from = resolveSessionHarness(id, cfg, lane).harness;
1703
+ return {
1704
+ harness: resolvedHarness,
1705
+ cold: { why: `session ${short(prior)} is not resumed on a respawn`, fromHarness: from },
1706
+ note: `respawn: starting cold on ${resolvedHarness}${from ? ` (previous session: ${from})` : ""}`
1707
+ };
1708
+ }
1709
+ if (!descriptor.followUp) return { harness: resolvedHarness };
1710
+ const originId = descriptor.followUp;
1711
+ const origin = resolveSessionHarness(originId, cfg);
1712
+ const originLane = origin.lane ?? lane;
1713
+ const originRef = { id: originId, lane: originLane };
1714
+ if (!origin.sessionId) {
1715
+ return {
1716
+ harness: resolvedHarness,
1717
+ cold: { why: `follow-up origin ${short(originId)} has no recorded session`, origin: originRef },
1718
+ note: `follow-up of ${short(originId)}: no session recorded \u2014 starting cold on ${resolvedHarness} with a progress note`
1719
+ };
1720
+ }
1721
+ if (!origin.harness) {
1722
+ return {
1723
+ harness: resolvedHarness,
1724
+ resume: { sessionId: origin.sessionId, own: false, origin: originRef },
1725
+ note: `follow-up of ${short(originId)}: resuming session ${short(origin.sessionId)} on ${resolvedHarness} (origin harness unknown)`
1726
+ };
1727
+ }
1728
+ const requested = descriptor.harness;
1729
+ const swap = requested !== void 0 && requested !== origin.harness && requested !== askedHarness(originId, originLane, cfg);
1730
+ if (swap) {
1731
+ return {
1732
+ harness: requested,
1733
+ cold: { why: `swap from ${origin.harness} to ${requested}`, fromHarness: origin.harness, origin: originRef },
1734
+ note: `follow-up of ${short(originId)}: swap \u2014 origin session is ${origin.harness}, ${requested} requested; starting cold on ${requested} with a progress note`
1735
+ };
1736
+ }
1737
+ const ignored = requested !== void 0 && requested !== origin.harness ? ` (--harness ${requested} ignored for the resume)` : "";
1738
+ return {
1739
+ harness: origin.harness,
1740
+ resume: { sessionId: origin.sessionId, own: false, origin: originRef },
1741
+ note: `follow-up of ${short(originId)}: resuming ${origin.harness} session ${short(origin.sessionId)} (harness from ${origin.source})${ignored}`
1742
+ };
1557
1743
  }
1558
- async function main(activeDir, lane) {
1559
- const id = path8.basename(activeDir);
1744
+
1745
+ // packages/runner/src/run.ts
1746
+ var defaultDeps = { loadAdapter, allocate, collectEvidence };
1747
+ function coldNote(cold, cwd, trunk) {
1748
+ const parts = [worktreeProgress(cwd, trunk)];
1749
+ if (cold.origin) parts.push(originProgress(cold.origin.id, cold.origin.lane));
1750
+ return handoffNote(cold.fromHarness ?? "unknown", parts.join("\n\n"), cold.why);
1751
+ }
1752
+ async function main(activeDir, lane, deps = defaultDeps) {
1753
+ const id = path10.basename(activeDir);
1560
1754
  const status = (verb, note) => appendStatus(id, lane, verb, note);
1561
1755
  const cfg = loadConfig();
1562
- const descriptor = JSON.parse(fs10.readFileSync(path8.join(activeDir, "descriptor.json"), "utf8"));
1756
+ const descriptor = JSON.parse(fs12.readFileSync(path10.join(activeDir, "descriptor.json"), "utf8"));
1563
1757
  const resolved = resolveDispatch(descriptor, cfg);
1564
1758
  const repo = cfg.repos[descriptor.repo];
1565
- const briefFile = path8.join(activeDir, "brief.md");
1566
- if (!fs10.existsSync(briefFile)) fs10.writeFileSync(briefFile, descriptor.brief);
1567
- const brief = fs10.readFileSync(briefFile, "utf8");
1759
+ const briefFile = path10.join(activeDir, "brief.md");
1760
+ if (!fs12.existsSync(briefFile)) fs12.writeFileSync(briefFile, descriptor.brief);
1761
+ const brief = fs12.readFileSync(briefFile, "utf8");
1568
1762
  const attempts = Number(process.env.LOBSTAH_ATTEMPTS ?? "1");
1569
1763
  const runnerInfo = {
1570
1764
  pid: process.pid,
1571
1765
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1572
1766
  attempts
1573
1767
  };
1574
- fs10.writeFileSync(path8.join(activeDir, "runner.json"), JSON.stringify(runnerInfo, null, 2));
1575
- status("working", attempts > 1 ? `attempt ${attempts}` : void 0);
1576
- appendEvent(id, lane, { at: (/* @__PURE__ */ new Date()).toISOString(), type: "runner", data: { pid: process.pid, attempts } });
1577
- const wtFile = path8.join(activeDir, "worktree.json");
1768
+ fs12.writeFileSync(path10.join(activeDir, "runner.json"), JSON.stringify(runnerInfo, null, 2));
1769
+ const plan = planStart({
1770
+ id,
1771
+ lane,
1772
+ descriptor,
1773
+ cfg,
1774
+ resolvedHarness: resolved.harness,
1775
+ envResume: process.env.LOBSTAH_RESUME
1776
+ });
1777
+ const firstNote = [attempts > 1 ? `attempt ${attempts}` : void 0, plan.note].filter(Boolean).join("; ");
1778
+ status("working", firstNote || void 0);
1779
+ appendEvent(id, lane, {
1780
+ at: (/* @__PURE__ */ new Date()).toISOString(),
1781
+ type: "runner",
1782
+ data: { pid: process.pid, attempts, harness: plan.harness, ...plan.resume ? { resume: plan.resume.sessionId } : {} }
1783
+ });
1784
+ const own = readEvidence(id, lane).sessionId;
1785
+ mergeEvidence(id, lane, { harness: plan.harness, sessionId: plan.resume?.own ? own : void 0 });
1786
+ const wtFile = path10.join(activeDir, "worktree.json");
1578
1787
  let cwd;
1579
- if (fs10.existsSync(wtFile)) {
1580
- cwd = JSON.parse(fs10.readFileSync(wtFile, "utf8")).path;
1581
- if (!fs10.existsSync(cwd)) throw new Error(`recorded worktree ${cwd} is gone`);
1582
- } else if (fs10.existsSync(worktreePath(id))) {
1788
+ if (fs12.existsSync(wtFile)) {
1789
+ cwd = JSON.parse(fs12.readFileSync(wtFile, "utf8")).path;
1790
+ if (!fs12.existsSync(cwd)) throw new Error(`recorded worktree ${cwd} is gone`);
1791
+ } else if (fs12.existsSync(worktreePath(id))) {
1583
1792
  throw new Error(`unrecorded worktree already exists for ${id} \u2014 refusing to proceed`);
1584
1793
  } else {
1585
- cwd = await allocate(repo, id);
1586
- fs10.writeFileSync(wtFile, JSON.stringify({ path: cwd }, null, 2));
1587
- }
1588
- const resumeSession = process.env.LOBSTAH_RESUME ?? (descriptor.followUp ? followUpSession(descriptor.followUp) : void 0);
1589
- const adapter = loadAdapter(resolved.harness);
1590
- const prompt = buildPrompt(brief, { id, nudge: process.env.LOBSTAH_NUDGE, attachments: descriptor.attachments });
1591
- const binDir = path8.resolve(path8.dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "bin");
1592
- const workerPath = fs10.existsSync(path8.join(binDir, "lobstah")) ? `${binDir}${path8.delimiter}${process.env.PATH ?? ""}` : void 0;
1593
- const run2 = await adapter.start({
1594
- id,
1595
- cwd,
1596
- prompt,
1597
- model: resolved.model,
1598
- effort: resolved.effort,
1599
- limits: resolved.limits,
1600
- env: {
1601
- ...resolved.env,
1602
- LOBSTAH_HOME: lobstahHome(),
1603
- ...workerPath ? { PATH: workerPath } : {}
1604
- },
1605
- flags: resolved.flags,
1606
- resumeSession
1607
- });
1794
+ cwd = await deps.allocate(repo, id);
1795
+ fs12.writeFileSync(wtFile, JSON.stringify({ path: cwd }, null, 2));
1796
+ }
1797
+ const adapter = deps.loadAdapter(plan.harness);
1798
+ const envNudge = process.env.LOBSTAH_NUDGE;
1799
+ const planNudge = plan.cold && !envNudge ? coldNote(plan.cold, cwd, repo.trunk) : void 0;
1800
+ const promptWith = (nudge) => buildPrompt(brief, { id, nudge, attachments: descriptor.attachments });
1801
+ const binDir = path10.resolve(path10.dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "bin");
1802
+ const workerPath = fs12.existsSync(path10.join(binDir, "lobstah")) ? `${binDir}${path10.delimiter}${process.env.PATH ?? ""}` : void 0;
1608
1803
  let wallClockHit = false;
1804
+ let current;
1609
1805
  const wallTimer = resolved.limits.wallClockSecs ? setTimeout(() => {
1610
1806
  wallClockHit = true;
1611
- run2.kill();
1807
+ current?.kill();
1612
1808
  }, resolved.limits.wallClockSecs * 1e3) : void 0;
1613
- const { cancelled } = await drive(run2, { id, lane, stopped: () => wallClockHit });
1614
- const result = await run2.done;
1809
+ const runOnce = async (prompt, resumeSession) => {
1810
+ const run2 = await adapter.start({
1811
+ id,
1812
+ cwd,
1813
+ prompt,
1814
+ model: resolved.model,
1815
+ effort: resolved.effort,
1816
+ limits: resolved.limits,
1817
+ env: {
1818
+ ...resolved.env,
1819
+ LOBSTAH_HOME: lobstahHome(),
1820
+ ...workerPath ? { PATH: workerPath } : {}
1821
+ },
1822
+ flags: resolved.flags,
1823
+ resumeSession
1824
+ });
1825
+ current = run2;
1826
+ const { cancelled: cancelled2, activity } = await drive(run2, { id, lane, stopped: () => wallClockHit });
1827
+ const result2 = await run2.done;
1828
+ return { cancelled: cancelled2, activity, result: result2 };
1829
+ };
1830
+ let outcome = await runOnce(promptWith(envNudge ?? planNudge), plan.resume?.sessionId);
1831
+ if (plan.resume && !outcome.cancelled && !wallClockHit && outcome.activity === 0 && isUnresumable(outcome.result.error)) {
1832
+ const reason = outcome.result.error.replace(/\s+/g, " ").slice(0, 200);
1833
+ status("working", `resume-fallback: ${reason} \u2014 starting cold on ${plan.harness} with a progress note`);
1834
+ appendEvent(id, lane, { at: (/* @__PURE__ */ new Date()).toISOString(), type: "runner", data: { resumeFallback: reason } });
1835
+ mergeEvidence(id, lane, { resumeFallback: reason, sessionId: void 0 });
1836
+ const note = coldNote(
1837
+ {
1838
+ why: `resume of session ${plan.resume.sessionId} failed`,
1839
+ fromHarness: plan.harness,
1840
+ ...plan.resume.origin ? { origin: plan.resume.origin } : {}
1841
+ },
1842
+ cwd,
1843
+ repo.trunk
1844
+ );
1845
+ outcome = await runOnce(promptWith([envNudge, note].filter(Boolean).join("\n\n")), void 0);
1846
+ }
1615
1847
  if (wallTimer) clearTimeout(wallTimer);
1848
+ const { cancelled, result } = outcome;
1616
1849
  try {
1617
- const gitEvidence = await collectEvidence(repo, id);
1850
+ const gitEvidence = await deps.collectEvidence(repo, id);
1618
1851
  console.log(`[runner] git evidence: ${JSON.stringify(gitEvidence)}`);
1619
1852
  mergeEvidence(id, lane, { ...gitEvidence, sessionId: result.sessionId ?? readEvidence(id, lane).sessionId });
1620
1853
  console.log(`[runner] evidence after merge: ${JSON.stringify(readEvidence(id, lane))}`);
@@ -1626,13 +1859,15 @@ async function main(activeDir, lane) {
1626
1859
  settle(id, lane, { cancelled, wallClockHit, error: result.error });
1627
1860
  complete(id, lane);
1628
1861
  }
1862
+
1863
+ // packages/runner/src/index.ts
1629
1864
  function runRunner(activeDirArg2, laneArg2) {
1630
1865
  const lane = laneArg2 === "chore" ? "chore" : "work";
1631
1866
  main(activeDirArg2, lane).catch((err) => {
1632
- const id = path8.basename(activeDirArg2);
1867
+ const id = path11.basename(activeDirArg2);
1633
1868
  try {
1634
1869
  appendStatus(id, lane, "failed", String(err instanceof Error ? err.message : err).slice(0, 500));
1635
- fs10.renameSync(activeDirArg2, path8.join(laneDirs(lane).done, id));
1870
+ fs13.renameSync(activeDirArg2, path11.join(laneDirs(lane).done, id));
1636
1871
  } catch {
1637
1872
  }
1638
1873
  process.exitCode = 1;
@@ -1642,6 +1877,7 @@ var [activeDirArg, laneArg] = process.argv.slice(2);
1642
1877
  if (activeDirArg && activeDirArg !== "__runner") runRunner(activeDirArg, laneArg);
1643
1878
  export {
1644
1879
  main,
1880
+ planStart,
1645
1881
  runRunner
1646
1882
  };
1647
1883
  /*! Bundled license information:
@@ -14,7 +14,7 @@ keys of that section.
14
14
  |---|---|---|
15
15
  | `notifyCommand` | — | Exec'd by the daemon on wake-worthy status transitions with `LOBSTAH_ID`, `LOBSTAH_LANE`, `LOBSTAH_VERB`, `LOBSTAH_NOTE`, `LOBSTAH_AT` in the environment. Fire-and-forget; point it at ntfy, a Slack helper, anything. |
16
16
  | `notifyVerbs` | `["needs-decision", "blocked", "done", "failed"]` | Which verbs fire `notifyCommand`. |
17
- | `attentionKinds` | `["question", "pr:draft", "pr:review", "pr:checks", "pr:ready"]` | Which attention kinds `man tend` lists — and so what the desktop pet and the glass walk across the screen. Valid kinds: `question`, `landed` (opt-in), `pr:draft`, `pr:review`, `pr:checks`, `pr:ready`; an unknown kind is a config error naming the valid set. Notify is edge-triggered and fires once per transition; attention is level-triggered and stands until its clear condition ([vocabulary.md](vocabulary.md#attention-contract)). |
17
+ | `attentionKinds` | `["question", "pr:draft", "pr:review", "pr:checks", "pr:conflict", "pr:ready"]` | Which attention kinds `man tend` lists — and so what the desktop pet and the glass walk across the screen. Valid kinds: `question`, `landed` (opt-in), `pr:draft`, `pr:review`, `pr:checks`, `pr:conflict`, `pr:ready`; an unknown kind is a config error naming the valid set. Notify is edge-triggered and fires once per transition; attention is level-triggered and stands until its clear condition ([vocabulary.md](vocabulary.md#attention-contract)). |
18
18
  | `remindSecs` | `900` | An unanswered `needs-decision`/`blocked` re-fires to `man wait`/`man haul` on this interval until answered. `0` = report once only. |
19
19
 
20
20
  ## `[repos.<key>]` — workspace definitions
package/docs/design.md CHANGED
@@ -452,6 +452,20 @@ event stream each poll so the wedge detector sees a live wait, delivers the
452
452
  answer into the next turn, and stamps `working`. Only a turn ending on `working`
453
453
  (or no report) with an empty inbox ends the session and is stamped `done`.
454
454
 
455
+ **The session's own harness resumes it.** A session only resumes under the
456
+ harness that wrote it: a Codex thread id handed to Claude Code is "No
457
+ conversation found". The descriptor's `harness` records what was asked, not
458
+ what ran (a trap claims unaddressed work with whatever harness it is), so every
459
+ run stamps `harness` into evidence beside `sessionId` (the adapter's for a
460
+ headless run, the trap's for a claim), and one resolver reads it back for a
461
+ follow-up's fork, a daemon restart, `lobstah swap`, and `attach`. It falls back
462
+ to the claiming trap, then the session id's UUID version, then the descriptor.
463
+ A follow-up whose `--harness` names something the chain never asked for is a
464
+ swap: cold on that harness with the brief plus a progress note. A resume the
465
+ harness refuses before doing any work falls back the same way and records
466
+ `resume-fallback: <reason>` in the status note and evidence. It never fails
467
+ the dispatch. The first status note says which of these happened.
468
+
455
469
  ```ts
456
470
  // inside the runner
457
471
  const thread = await adapter.start({