lobstah 0.5.0 → 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
  }
@@ -36,6 +38,17 @@ function evidencePath(id, lane) {
36
38
  return path.join(laneDirs(lane).state, `${id}.evidence`);
37
39
  }
38
40
 
41
+ // packages/core/dist/attachments.js
42
+ function attachmentBlock(attachments) {
43
+ if (attachments.length === 0)
44
+ return "";
45
+ return [
46
+ "--- ATTACHMENTS ---",
47
+ ...attachments.map((a) => `${a.name} (${a.type}, ${a.bytes} bytes) at ${a.path}`),
48
+ "Images and PDFs can be read directly with your file tools."
49
+ ].join("\n");
50
+ }
51
+
39
52
  // packages/core/dist/queue.js
40
53
  import * as fs2 from "node:fs";
41
54
  import * as path2 from "node:path";
@@ -69,6 +82,20 @@ function readStatusLog(id, lane) {
69
82
  }
70
83
 
71
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
+ }
72
99
  function complete(id, lane) {
73
100
  const dirs = laneDirs(lane);
74
101
  fs2.renameSync(path2.join(dirs.active, id), path2.join(dirs.done, id));
@@ -83,6 +110,15 @@ function appendEvent(id, lane, ev) {
83
110
  fs3.appendFileSync(eventsPath(id, lane), `${JSON.stringify(ev)}
84
111
  `);
85
112
  }
113
+ function touchEvents(id, lane, at = /* @__PURE__ */ new Date()) {
114
+ const file = eventsPath(id, lane);
115
+ try {
116
+ fs3.utimesSync(file, at, at);
117
+ } catch {
118
+ fs3.appendFileSync(file, "");
119
+ fs3.utimesSync(file, at, at);
120
+ }
121
+ }
86
122
 
87
123
  // packages/core/dist/evidence.js
88
124
  import * as fs4 from "node:fs";
@@ -103,6 +139,7 @@ function mergeEvidence(id, lane, patch) {
103
139
  // packages/core/dist/inbox.js
104
140
  import * as fs5 from "node:fs";
105
141
  import * as path3 from "node:path";
142
+ var metaName = (file) => file.replace(/\.msg$/, ".meta.json");
106
143
  function inboxDir(id, lane) {
107
144
  return path3.join(laneDirs(lane).inbox, id);
108
145
  }
@@ -116,6 +153,10 @@ function acknowledge(id, lane, file) {
116
153
  const dir = inboxDir(id, lane);
117
154
  fs5.mkdirSync(path3.join(dir, "handled"), { recursive: true });
118
155
  fs5.renameSync(path3.join(dir, file), path3.join(dir, "handled", file));
156
+ try {
157
+ fs5.renameSync(path3.join(dir, metaName(file)), path3.join(dir, "handled", metaName(file)));
158
+ } catch {
159
+ }
119
160
  }
120
161
 
121
162
  // packages/core/dist/config.js
@@ -769,6 +810,21 @@ function parse(toml, { maxDepth = 1e3, integersAsBigInt } = {}) {
769
810
  }
770
811
 
771
812
  // packages/core/dist/config.js
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"];
815
+ function parseAttentionKinds(raw) {
816
+ if (raw === void 0)
817
+ return [...DEFAULT_ATTENTION_KINDS];
818
+ if (!Array.isArray(raw)) {
819
+ throw new Error(`attentionKinds must be an array of kinds (${ATTENTION_KINDS.join(", ")}) in ${configPath()}`);
820
+ }
821
+ for (const k of raw) {
822
+ if (!ATTENTION_KINDS.includes(k)) {
823
+ throw new Error(`attentionKinds: unknown kind "${String(k)}" in ${configPath()} \u2014 valid kinds: ${ATTENTION_KINDS.join(", ")}`);
824
+ }
825
+ }
826
+ return [...new Set(raw)];
827
+ }
772
828
  var DEFAULT_SOAK = {
773
829
  deferSecs: 90,
774
830
  ttlSecs: 1800
@@ -783,7 +839,8 @@ var DEFAULT_LIMITS = {
783
839
  wedgeThresholdSecs: 600,
784
840
  maxRestartAttempts: 2,
785
841
  wallClockSecs: 3600,
786
- choreRetentionDays: 7
842
+ choreRetentionDays: 7,
843
+ attachmentMaxBytes: 25 * 1024 * 1024
787
844
  };
788
845
  function configPath() {
789
846
  return path4.join(lobstahHome(), "config.toml");
@@ -821,7 +878,8 @@ function loadConfig() {
821
878
  grounds,
822
879
  notifyCommand: raw.notifyCommand ? String(raw.notifyCommand) : void 0,
823
880
  notifyVerbs: Array.isArray(raw.notifyVerbs) ? raw.notifyVerbs.map(String) : void 0,
824
- remindSecs: raw.remindSecs !== void 0 ? Number(raw.remindSecs) : void 0
881
+ remindSecs: raw.remindSecs !== void 0 ? Number(raw.remindSecs) : void 0,
882
+ attentionKinds: parseAttentionKinds(raw.attentionKinds)
825
883
  };
826
884
  }
827
885
  function resolveDispatch(d, cfg) {
@@ -886,12 +944,127 @@ function codexInvocation(argv) {
886
944
  return void 0;
887
945
  }
888
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
+
889
961
  // packages/core/dist/compiled.js
890
962
  var COMPILED_BINARY = (() => {
891
963
  const bun = globalThis.Bun;
892
964
  return bun !== void 0 && String(bun.main ?? "").includes("$bunfs");
893
965
  })();
894
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
+
895
1068
  // packages/adapters/dist/claude.js
896
1069
  import { spawn } from "node:child_process";
897
1070
 
@@ -1170,25 +1343,25 @@ function createClaudeAdapter() {
1170
1343
  }
1171
1344
 
1172
1345
  // packages/adapters/dist/codex.js
1173
- import * as fs8 from "node:fs";
1346
+ import * as fs10 from "node:fs";
1174
1347
  import * as os3 from "node:os";
1175
- import * as path6 from "node:path";
1348
+ import * as path8 from "node:path";
1176
1349
  import { spawn as spawn2 } from "node:child_process";
1177
1350
  function isolatedCodexHome() {
1178
- const dir = path6.join(lobstahHome(), "codex-home");
1179
- fs8.mkdirSync(dir, { recursive: true });
1180
- const auth = path6.join(dir, "auth.json");
1181
- const realAuth = path6.join(os3.homedir(), ".codex", "auth.json");
1182
- 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)) {
1183
1356
  try {
1184
- fs8.symlinkSync(realAuth, auth);
1357
+ fs10.symlinkSync(realAuth, auth);
1185
1358
  } catch {
1186
- fs8.copyFileSync(realAuth, auth);
1359
+ fs10.copyFileSync(realAuth, auth);
1187
1360
  }
1188
1361
  }
1189
- const cfg = path6.join(dir, "config.toml");
1190
- if (!fs8.existsSync(cfg)) {
1191
- 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");
1192
1365
  }
1193
1366
  return dir;
1194
1367
  }
@@ -1385,14 +1558,14 @@ function loadAdapter(name) {
1385
1558
  }
1386
1559
 
1387
1560
  // packages/worktree/dist/index.js
1388
- import * as fs9 from "node:fs";
1389
- import * as path7 from "node:path";
1561
+ import * as fs11 from "node:fs";
1562
+ import * as path9 from "node:path";
1390
1563
  import { exec, execFile } from "node:child_process";
1391
1564
  import { promisify } from "node:util";
1392
1565
  var run = promisify(execFile);
1393
1566
  var shell = promisify(exec);
1394
1567
  function worktreePath(id) {
1395
- return path7.join(lobstahHome(), "worktrees", id);
1568
+ return path9.join(lobstahHome(), "worktrees", id);
1396
1569
  }
1397
1570
  async function git(cwd, ...args) {
1398
1571
  const { stdout } = await run("git", args, { cwd, env: process.env });
@@ -1400,10 +1573,10 @@ async function git(cwd, ...args) {
1400
1573
  }
1401
1574
  async function allocate(repo, id) {
1402
1575
  const dir = worktreePath(id);
1403
- if (fs9.existsSync(dir)) {
1576
+ if (fs11.existsSync(dir)) {
1404
1577
  throw new Error(`worktree for ${id} already exists at ${dir} \u2014 never allocate a second`);
1405
1578
  }
1406
- if (!fs9.existsSync(repo.path)) {
1579
+ if (!fs11.existsSync(repo.path)) {
1407
1580
  if (!repo.origin)
1408
1581
  throw new Error(`repo path ${repo.path} missing and no origin configured`);
1409
1582
  await run("git", ["clone", repo.origin, repo.path], { env: process.env });
@@ -1434,100 +1607,247 @@ function buildPrompt(brief, opts) {
1434
1607
  `--- BRIEF ---`,
1435
1608
  brief
1436
1609
  ];
1610
+ if (opts.attachments?.length) parts.push(attachmentBlock(opts.attachments));
1437
1611
  if (opts.nudge) parts.push(`--- SUPERVISOR NOTE ---`, opts.nudge);
1438
1612
  return parts.join("\n\n");
1439
1613
  }
1440
1614
 
1441
- // packages/runner/src/index.ts
1442
- function followUpSession(followUp) {
1443
- for (const lane of ["work", "chore"]) {
1444
- const ev = readEvidence(followUp, lane);
1445
- if (ev.sessionId) return ev.sessionId;
1615
+ // packages/runner/src/drive.ts
1616
+ var WAITING_VERBS = ["needs-decision", "blocked", "paused"];
1617
+ var sleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
1618
+ function deliver(run2, id, lane) {
1619
+ const msgs = unhandled(id, lane);
1620
+ for (const m of msgs) {
1621
+ run2.send(m.text);
1622
+ acknowledge(id, lane, m.file);
1446
1623
  }
1447
- return void 0;
1624
+ return msgs.length;
1625
+ }
1626
+ async function drive(run2, opts) {
1627
+ const { id, lane, pollMs = 3e3, stopped = () => false } = opts;
1628
+ let cancelled = false;
1629
+ let activity = 0;
1630
+ let finished = false;
1631
+ void run2.done.then(() => finished = true);
1632
+ const cancel = () => {
1633
+ cancelled = true;
1634
+ run2.kill();
1635
+ };
1636
+ const answered = (verb) => {
1637
+ appendStatus(id, lane, "working", "operator message delivered");
1638
+ appendEvent(id, lane, { at: (/* @__PURE__ */ new Date()).toISOString(), type: "runner", data: { resumed: verb } });
1639
+ };
1640
+ const awaitAnswer = async (verb) => {
1641
+ appendEvent(id, lane, { at: (/* @__PURE__ */ new Date()).toISOString(), type: "runner", data: { waiting: verb } });
1642
+ while (true) {
1643
+ if (stopped() || finished) return;
1644
+ if (cancelRequested(id, lane)) return cancel();
1645
+ if (deliver(run2, id, lane) > 0) return answered(verb);
1646
+ touchEvents(id, lane);
1647
+ await sleep(pollMs);
1648
+ }
1649
+ };
1650
+ for await (const ev of run2.events) {
1651
+ appendEvent(id, lane, ev);
1652
+ if (ev.type === "tool-start" || ev.type === "text") activity++;
1653
+ if (ev.type === "session" && ev.data?.sessionId) {
1654
+ mergeEvidence(id, lane, { sessionId: String(ev.data.sessionId) });
1655
+ }
1656
+ if (ev.type !== "turn-end" || cancelled || stopped()) continue;
1657
+ if (cancelRequested(id, lane)) {
1658
+ cancel();
1659
+ continue;
1660
+ }
1661
+ const lastVerb = readStatusLog(id, lane).at(-1)?.verb;
1662
+ const waiting = lastVerb !== void 0 && WAITING_VERBS.includes(lastVerb);
1663
+ if (deliver(run2, id, lane) > 0) {
1664
+ if (waiting) answered(lastVerb);
1665
+ continue;
1666
+ }
1667
+ if (waiting) {
1668
+ await awaitAnswer(lastVerb);
1669
+ continue;
1670
+ }
1671
+ run2.end();
1672
+ }
1673
+ return { cancelled, activity };
1674
+ }
1675
+ function settle(id, lane, r) {
1676
+ const lastVerb = readStatusLog(id, lane).at(-1)?.verb;
1677
+ if (r.cancelled) appendStatus(id, lane, "failed", "cancelled by operator");
1678
+ else if (r.wallClockHit) appendStatus(id, lane, "failed", "wall-clock limit exceeded");
1679
+ else if (r.error) appendStatus(id, lane, "failed", r.error.slice(0, 500));
1680
+ else if (!lastVerb || !TERMINAL_VERBS.includes(lastVerb)) appendStatus(id, lane, "done");
1681
+ }
1682
+
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
+ };
1699
+ }
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
+ };
1743
+ }
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);
1448
1751
  }
1449
- async function main(activeDir, lane) {
1450
- const id = path8.basename(activeDir);
1752
+ async function main(activeDir, lane, deps = defaultDeps) {
1753
+ const id = path10.basename(activeDir);
1451
1754
  const status = (verb, note) => appendStatus(id, lane, verb, note);
1452
1755
  const cfg = loadConfig();
1453
- 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"));
1454
1757
  const resolved = resolveDispatch(descriptor, cfg);
1455
1758
  const repo = cfg.repos[descriptor.repo];
1456
- const briefFile = path8.join(activeDir, "brief.md");
1457
- if (!fs10.existsSync(briefFile)) fs10.writeFileSync(briefFile, descriptor.brief);
1458
- 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");
1459
1762
  const attempts = Number(process.env.LOBSTAH_ATTEMPTS ?? "1");
1460
1763
  const runnerInfo = {
1461
1764
  pid: process.pid,
1462
1765
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1463
1766
  attempts
1464
1767
  };
1465
- fs10.writeFileSync(path8.join(activeDir, "runner.json"), JSON.stringify(runnerInfo, null, 2));
1466
- status("working", attempts > 1 ? `attempt ${attempts}` : void 0);
1467
- appendEvent(id, lane, { at: (/* @__PURE__ */ new Date()).toISOString(), type: "runner", data: { pid: process.pid, attempts } });
1468
- 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");
1469
1787
  let cwd;
1470
- if (fs10.existsSync(wtFile)) {
1471
- cwd = JSON.parse(fs10.readFileSync(wtFile, "utf8")).path;
1472
- if (!fs10.existsSync(cwd)) throw new Error(`recorded worktree ${cwd} is gone`);
1473
- } 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))) {
1474
1792
  throw new Error(`unrecorded worktree already exists for ${id} \u2014 refusing to proceed`);
1475
1793
  } else {
1476
- cwd = await allocate(repo, id);
1477
- fs10.writeFileSync(wtFile, JSON.stringify({ path: cwd }, null, 2));
1478
- }
1479
- const resumeSession = process.env.LOBSTAH_RESUME ?? (descriptor.followUp ? followUpSession(descriptor.followUp) : void 0);
1480
- const adapter = loadAdapter(resolved.harness);
1481
- const prompt = buildPrompt(brief, { id, nudge: process.env.LOBSTAH_NUDGE });
1482
- const binDir = path8.resolve(path8.dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "bin");
1483
- const workerPath = fs10.existsSync(path8.join(binDir, "lobstah")) ? `${binDir}${path8.delimiter}${process.env.PATH ?? ""}` : void 0;
1484
- const run2 = await adapter.start({
1485
- id,
1486
- cwd,
1487
- prompt,
1488
- model: resolved.model,
1489
- effort: resolved.effort,
1490
- limits: resolved.limits,
1491
- env: {
1492
- ...resolved.env,
1493
- LOBSTAH_HOME: lobstahHome(),
1494
- ...workerPath ? { PATH: workerPath } : {}
1495
- },
1496
- flags: resolved.flags,
1497
- resumeSession
1498
- });
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;
1499
1803
  let wallClockHit = false;
1804
+ let current;
1500
1805
  const wallTimer = resolved.limits.wallClockSecs ? setTimeout(() => {
1501
1806
  wallClockHit = true;
1502
- run2.kill();
1807
+ current?.kill();
1503
1808
  }, resolved.limits.wallClockSecs * 1e3) : void 0;
1504
- let cancelled = false;
1505
- for await (const ev of run2.events) {
1506
- appendEvent(id, lane, ev);
1507
- if (ev.type === "session" && ev.data?.sessionId) {
1508
- mergeEvidence(id, lane, { sessionId: String(ev.data.sessionId) });
1509
- }
1510
- if (ev.type === "turn-end") {
1511
- if (cancelRequested(id, lane)) {
1512
- cancelled = true;
1513
- run2.kill();
1514
- continue;
1515
- }
1516
- const msgs = unhandled(id, lane);
1517
- if (msgs.length > 0) {
1518
- for (const m of msgs) {
1519
- run2.send(m.text);
1520
- acknowledge(id, lane, m.file);
1521
- }
1522
- } else {
1523
- run2.end();
1524
- }
1525
- }
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);
1526
1846
  }
1527
- const result = await run2.done;
1528
1847
  if (wallTimer) clearTimeout(wallTimer);
1848
+ const { cancelled, result } = outcome;
1529
1849
  try {
1530
- const gitEvidence = await collectEvidence(repo, id);
1850
+ const gitEvidence = await deps.collectEvidence(repo, id);
1531
1851
  console.log(`[runner] git evidence: ${JSON.stringify(gitEvidence)}`);
1532
1852
  mergeEvidence(id, lane, { ...gitEvidence, sessionId: result.sessionId ?? readEvidence(id, lane).sessionId });
1533
1853
  console.log(`[runner] evidence after merge: ${JSON.stringify(readEvidence(id, lane))}`);
@@ -1536,25 +1856,18 @@ async function main(activeDir, lane) {
1536
1856
  note: `evidence collection failed: ${err instanceof Error ? err.message : String(err)}`.slice(0, 300)
1537
1857
  });
1538
1858
  }
1539
- const lastVerb = readStatusLog(id, lane).at(-1)?.verb;
1540
- if (cancelled) {
1541
- status("failed", "cancelled by operator");
1542
- } else if (wallClockHit) {
1543
- status("failed", "wall-clock limit exceeded");
1544
- } else if (result.error) {
1545
- status("failed", result.error.slice(0, 500));
1546
- } else if (!lastVerb || !TERMINAL_VERBS.includes(lastVerb)) {
1547
- status("done");
1548
- }
1859
+ settle(id, lane, { cancelled, wallClockHit, error: result.error });
1549
1860
  complete(id, lane);
1550
1861
  }
1862
+
1863
+ // packages/runner/src/index.ts
1551
1864
  function runRunner(activeDirArg2, laneArg2) {
1552
1865
  const lane = laneArg2 === "chore" ? "chore" : "work";
1553
1866
  main(activeDirArg2, lane).catch((err) => {
1554
- const id = path8.basename(activeDirArg2);
1867
+ const id = path11.basename(activeDirArg2);
1555
1868
  try {
1556
1869
  appendStatus(id, lane, "failed", String(err instanceof Error ? err.message : err).slice(0, 500));
1557
- fs10.renameSync(activeDirArg2, path8.join(laneDirs(lane).done, id));
1870
+ fs13.renameSync(activeDirArg2, path11.join(laneDirs(lane).done, id));
1558
1871
  } catch {
1559
1872
  }
1560
1873
  process.exitCode = 1;
@@ -1564,6 +1877,7 @@ var [activeDirArg, laneArg] = process.argv.slice(2);
1564
1877
  if (activeDirArg && activeDirArg !== "__runner") runRunner(activeDirArg, laneArg);
1565
1878
  export {
1566
1879
  main,
1880
+ planStart,
1567
1881
  runRunner
1568
1882
  };
1569
1883
  /*! Bundled license information: