lobstah 0.5.9 → 0.5.10

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
@@ -1395,6 +1395,7 @@ var init_config = __esm({
1395
1395
  wedgeThresholdSecs: 600,
1396
1396
  maxRestartAttempts: 2,
1397
1397
  wallClockSecs: 3600,
1398
+ backgroundWaitSecs: 1800,
1398
1399
  choreRetentionDays: 7,
1399
1400
  attachmentMaxBytes: 25 * 1024 * 1024
1400
1401
  };
@@ -2809,6 +2810,9 @@ function pumpClaudeMessage(msg, push, onSession) {
2809
2810
  if (msg.session_id)
2810
2811
  onSession(msg.session_id);
2811
2812
  push({ at, type: "session", data: { sessionId: msg.session_id } });
2813
+ } else if (msg.type === "system" && msg.subtype === "background_tasks_changed") {
2814
+ const live = (msg.tasks ?? []).filter((t) => !t.ambient).length;
2815
+ push({ at, type: "background", data: { live } });
2812
2816
  } else if (msg.type === "assistant") {
2813
2817
  for (const block of msg.message?.content ?? []) {
2814
2818
  if (block.type === "tool_use") {
@@ -2829,6 +2833,19 @@ function pumpClaudeMessage(msg, push, onSession) {
2829
2833
  push({ at, type: "turn-end", data: { subtype: msg.subtype, costUsd: msg.total_cost_usd } });
2830
2834
  }
2831
2835
  }
2836
+ function claudeSdkOptions(opts) {
2837
+ return {
2838
+ cwd: opts.cwd,
2839
+ model: opts.model,
2840
+ ...opts.effort ? { effort: opts.effort } : {},
2841
+ maxTurns: opts.limits.maxTurns,
2842
+ resume: opts.resumeSession,
2843
+ forkSession: opts.resumeSession ? true : void 0,
2844
+ permissionMode: "bypassPermissions",
2845
+ env: { ...process.env, ...opts.env },
2846
+ extraArgs: flagsToExtraArgs(opts.flags)
2847
+ };
2848
+ }
2832
2849
  function claudeCliArgs(opts) {
2833
2850
  const args = [
2834
2851
  "-p",
@@ -2842,6 +2859,8 @@ function claudeCliArgs(opts) {
2842
2859
  ];
2843
2860
  if (opts.model)
2844
2861
  args.push("--model", opts.model);
2862
+ if (opts.effort)
2863
+ args.push("--effort", opts.effort);
2845
2864
  if (opts.limits.maxTurns)
2846
2865
  args.push("--max-turns", String(opts.limits.maxTurns));
2847
2866
  if (opts.resumeSession)
@@ -2949,16 +2968,7 @@ function createClaudeAdapter() {
2949
2968
  }
2950
2969
  const q = sdk.query({
2951
2970
  prompt: userMessages(),
2952
- options: {
2953
- cwd: opts.cwd,
2954
- model: opts.model,
2955
- maxTurns: opts.limits.maxTurns,
2956
- resume: opts.resumeSession,
2957
- forkSession: opts.resumeSession ? true : void 0,
2958
- permissionMode: "bypassPermissions",
2959
- env: { ...process.env, ...opts.env },
2960
- extraArgs: flagsToExtraArgs(opts.flags)
2961
- }
2971
+ options: claudeSdkOptions(opts)
2962
2972
  });
2963
2973
  const events = new AsyncQueue();
2964
2974
  let sessionId;
@@ -3251,6 +3261,22 @@ async function git4(cwd, ...args) {
3251
3261
  const { stdout } = await run3("git", args, { cwd, env: process.env });
3252
3262
  return stdout.trim();
3253
3263
  }
3264
+ function isLockContention(err) {
3265
+ const text = `${err.stderr ?? ""}
3266
+ ${err instanceof Error ? err.message : ""}`;
3267
+ return LOCK_CONTENTION.some((re) => re.test(text));
3268
+ }
3269
+ async function gitShared(cwd, ...args) {
3270
+ for (let attempt = 1; ; attempt++) {
3271
+ try {
3272
+ return await git4(cwd, ...args);
3273
+ } catch (err) {
3274
+ if (attempt >= 6 || !isLockContention(err))
3275
+ throw err;
3276
+ await new Promise((r) => setTimeout(r, 100 * 2 ** (attempt - 1) * (0.5 + Math.random())));
3277
+ }
3278
+ }
3279
+ }
3254
3280
  async function allocate(repo, id) {
3255
3281
  const dir = worktreePath(id);
3256
3282
  if (fs43.existsSync(dir)) {
@@ -3261,8 +3287,8 @@ async function allocate(repo, id) {
3261
3287
  throw new Error(`repo path ${repo.path} missing and no origin configured`);
3262
3288
  await run3("git", ["clone", repo.origin, repo.path], { env: process.env });
3263
3289
  }
3264
- await git4(repo.path, "fetch", "origin", repo.trunk);
3265
- await git4(repo.path, "worktree", "add", dir, "-b", `lobstah/${id}`, `origin/${repo.trunk}`);
3290
+ await gitShared(repo.path, "fetch", "origin", repo.trunk);
3291
+ await gitShared(repo.path, "worktree", "add", "--no-track", dir, "-b", `lobstah/${id}`, `origin/${repo.trunk}`);
3266
3292
  for (const cmd of repo.setup ?? []) {
3267
3293
  await shell(cmd, { cwd: dir, env: { ...process.env, ...repo.env ?? {} } });
3268
3294
  }
@@ -3274,19 +3300,20 @@ async function collectEvidence(repo, id) {
3274
3300
  const log = await git4(dir, "log", "--oneline", `origin/${repo.trunk}..HEAD`);
3275
3301
  return { branch, commits: log ? log.split("\n") : [] };
3276
3302
  }
3277
- var run3, shell;
3303
+ var run3, shell, LOCK_CONTENTION;
3278
3304
  var init_dist4 = __esm({
3279
3305
  "packages/worktree/dist/index.js"() {
3280
3306
  "use strict";
3281
3307
  init_dist2();
3282
3308
  run3 = promisify(execFile);
3283
3309
  shell = promisify(exec);
3310
+ LOCK_CONTENTION = [/cannot lock ref/, /unable to update local ref/, /Unable to create '[^']*\.lock': File exists/];
3284
3311
  }
3285
3312
  });
3286
3313
 
3287
3314
  // packages/runner/dist/contract.js
3288
3315
  function buildPrompt(brief, opts) {
3289
- const reporting = `Report status by running \`lobstah report ${opts.id} <verb> [note]\` (verbs: ${VERBS.join(", ")}). Attach a PR URL to your final report with \`--pr <url>\`. At natural checkpoints, check for operator messages with \`lobstah inbox ${opts.id}\` \u2014 messages also arrive automatically between your turns.`;
3316
+ const reporting = `Report status by running \`lobstah report ${opts.id} <verb> [note]\` (verbs: ${VERBS.join(", ")}). Attach a PR URL to your final report with \`--pr <url>\`. Only your report finishes this dispatch: ending a turn without \`done\` or \`failed\` does not. If you start background work and end your turn to wait for it, you are woken when it finishes. At natural checkpoints, check for operator messages with \`lobstah inbox ${opts.id}\` \u2014 messages also arrive automatically between your turns.`;
3290
3317
  const parts = [
3291
3318
  `You are a dispatched coding agent supervised by lobstah. Dispatch id: ${opts.id}.`,
3292
3319
  `Work only inside the current directory \u2014 it is an isolated git worktree allocated for this dispatch.`,
@@ -3310,6 +3337,9 @@ var init_contract = __esm({
3310
3337
  });
3311
3338
 
3312
3339
  // packages/runner/dist/drive.js
3340
+ function unreportedNudge(id) {
3341
+ return `Your turn ended without a status report, so this dispatch is not finished. If the work is complete, run \`lobstah report ${id} done "<what you did>"\`, adding \`--pr <url>\` if you opened a PR. If no change was needed, report \`done\` with a note saying why. If a human has to answer a question, or you are blocked, report \`needs-decision\` or \`blocked\` with it. If you are waiting on something, run it in the background: you are woken when it finishes.`;
3342
+ }
3313
3343
  function deliver(run4, id, lane) {
3314
3344
  const msgs = unhandled(id, lane);
3315
3345
  for (const m of msgs) {
@@ -3319,7 +3349,7 @@ function deliver(run4, id, lane) {
3319
3349
  return msgs.length;
3320
3350
  }
3321
3351
  async function drive(run4, opts) {
3322
- const { id, lane, pollMs = 3e3, stopped = () => false } = opts;
3352
+ const { id, lane, pollMs = 3e3, stopped = () => false, backgroundWaitMs = 30 * 6e4, settleGraceMs = 12e4 } = opts;
3323
3353
  let cancelled = false;
3324
3354
  let activity = 0;
3325
3355
  let finished = false;
@@ -3345,8 +3375,53 @@ async function drive(run4, opts) {
3345
3375
  await sleep(pollMs);
3346
3376
  }
3347
3377
  };
3378
+ let liveBackground = 0;
3379
+ let nudged = false;
3380
+ let hold;
3381
+ const runnerEvent = (data) => appendEvent(id, lane, { at: (/* @__PURE__ */ new Date()).toISOString(), type: "runner", data });
3382
+ const releaseHold = () => {
3383
+ if (!hold)
3384
+ return;
3385
+ clearInterval(hold.heartbeat);
3386
+ clearTimeout(hold.expiry);
3387
+ hold = void 0;
3388
+ };
3389
+ const unreported = () => {
3390
+ if (!nudged) {
3391
+ nudged = true;
3392
+ runnerEvent({ nudged: "unreported" });
3393
+ run4.send(unreportedNudge(id));
3394
+ return;
3395
+ }
3396
+ appendStatus(id, lane, "failed", "ended without reporting a result, after being asked to");
3397
+ run4.end();
3398
+ };
3399
+ const holdOpen = (ms) => {
3400
+ releaseHold();
3401
+ const heartbeat = setInterval(() => {
3402
+ if (cancelRequested(id, lane)) {
3403
+ releaseHold();
3404
+ cancel();
3405
+ return;
3406
+ }
3407
+ touchEvents(id, lane);
3408
+ }, pollMs);
3409
+ const expiry = setTimeout(() => {
3410
+ releaseHold();
3411
+ runnerEvent({ backgroundWait: "expired", live: liveBackground });
3412
+ unreported();
3413
+ }, ms);
3414
+ hold = { heartbeat, expiry };
3415
+ };
3348
3416
  for await (const ev of run4.events) {
3349
3417
  appendEvent(id, lane, ev);
3418
+ if (ev.type === "background") {
3419
+ liveBackground = Number(ev.data?.live ?? 0);
3420
+ if (hold && liveBackground === 0)
3421
+ holdOpen(settleGraceMs);
3422
+ continue;
3423
+ }
3424
+ releaseHold();
3350
3425
  if (ev.type === "tool-start" || ev.type === "text")
3351
3426
  activity++;
3352
3427
  if (ev.type === "session" && ev.data?.sessionId) {
@@ -3369,8 +3444,23 @@ async function drive(run4, opts) {
3369
3444
  await awaitAnswer(lastVerb);
3370
3445
  continue;
3371
3446
  }
3372
- run4.end();
3447
+ if (lastVerb !== void 0 && TERMINAL_VERBS.includes(lastVerb)) {
3448
+ run4.end();
3449
+ continue;
3450
+ }
3451
+ const subtype = ev.data?.subtype;
3452
+ if (subtype !== void 0 && subtype !== "success") {
3453
+ run4.end();
3454
+ continue;
3455
+ }
3456
+ if (liveBackground > 0) {
3457
+ runnerEvent({ holding: "background", live: liveBackground, forSecs: Math.round(backgroundWaitMs / 1e3) });
3458
+ holdOpen(backgroundWaitMs);
3459
+ continue;
3460
+ }
3461
+ unreported();
3373
3462
  }
3463
+ releaseHold();
3374
3464
  return { cancelled, activity };
3375
3465
  }
3376
3466
  function settle(id, lane, r) {
@@ -3382,7 +3472,7 @@ function settle(id, lane, r) {
3382
3472
  else if (r.error)
3383
3473
  appendStatus(id, lane, "failed", r.error.slice(0, 500));
3384
3474
  else if (!lastVerb || !TERMINAL_VERBS.includes(lastVerb))
3385
- appendStatus(id, lane, "done");
3475
+ appendStatus(id, lane, "failed", "stopped without reporting a result");
3386
3476
  }
3387
3477
  var WAITING_VERBS, sleep;
3388
3478
  var init_drive = __esm({
@@ -3568,7 +3658,12 @@ async function main(activeDir, lane, deps = defaultDeps) {
3568
3658
  resumeSession
3569
3659
  });
3570
3660
  current = run4;
3571
- const { cancelled: cancelled2, activity } = await drive(run4, { id, lane, stopped: () => wallClockHit });
3661
+ const { cancelled: cancelled2, activity } = await drive(run4, {
3662
+ id,
3663
+ lane,
3664
+ stopped: () => wallClockHit,
3665
+ backgroundWaitMs: cfg.limits.backgroundWaitSecs * 1e3
3666
+ });
3572
3667
  const result2 = await run4.done;
3573
3668
  return { cancelled: cancelled2, activity, result: result2 };
3574
3669
  };
@@ -4387,6 +4482,9 @@ var GithubSource = class {
4387
4482
  this.cfg = cfg;
4388
4483
  this.name = `gh:${cfg.repo}`;
4389
4484
  }
4485
+ owns(key) {
4486
+ return key.startsWith(`${this.name}#`);
4487
+ }
4390
4488
  async api(method, url, body) {
4391
4489
  const res = await fetch(`${API}${url}`, {
4392
4490
  method,
@@ -4619,6 +4717,9 @@ var LinearSource = class {
4619
4717
  constructor(cfg) {
4620
4718
  this.cfg = cfg;
4621
4719
  }
4720
+ owns(key) {
4721
+ return key.startsWith("linear:");
4722
+ }
4622
4723
  async gql(query, variables = {}) {
4623
4724
  const res = await fetch(API2, {
4624
4725
  method: "POST",
@@ -4766,42 +4867,51 @@ function dispatchLane(uuid) {
4766
4867
  async function reportLoop(source, state, log = () => {
4767
4868
  }, notify = () => {
4768
4869
  }) {
4870
+ const failures = [];
4769
4871
  for (const [key, entry] of state.entries()) {
4770
- if (entry.released)
4872
+ if (!source.owns(key))
4771
4873
  continue;
4772
- const lane = dispatchLane(entry.uuid);
4773
- if (!lane)
4874
+ if (entry.released)
4774
4875
  continue;
4775
- const verb = reconcile({
4776
- log: readStatusLog(entry.uuid, lane),
4777
- lastEventAt: lastEventAt(entry.uuid, lane)
4778
- });
4779
- if (verb !== "unknown" && verb !== entry.lastReported) {
4780
- const evidence = readEvidence(entry.uuid, lane);
4781
- await source.report(key, verb, { ...evidence, uuid: entry.uuid });
4782
- state.update(key, { lastReported: verb });
4783
- log(`${key}: reported ${verb}`);
4784
- notify({
4785
- key,
4786
- uuid: entry.uuid,
4787
- verb,
4788
- note: readStatusLog(entry.uuid, lane).at(-1)?.note,
4789
- prUrl: evidence.prUrl
4876
+ try {
4877
+ const lane = dispatchLane(entry.uuid);
4878
+ if (!lane)
4879
+ continue;
4880
+ const verb = reconcile({
4881
+ log: readStatusLog(entry.uuid, lane),
4882
+ lastEventAt: lastEventAt(entry.uuid, lane)
4790
4883
  });
4791
- }
4792
- if (entry.kind === "issue" && verb === "failed" && fs22.existsSync(path17.join(laneDirs(lane).done, entry.uuid))) {
4793
- state.releaseIssue(key);
4794
- log(`${key}: finalized failure released for a bounded retry`);
4795
- continue;
4796
- }
4797
- const msgs = await source.inbound(key, entry.lastInboundAt);
4798
- if (msgs.length > 0) {
4799
- for (const m of msgs)
4800
- sendMessage(entry.uuid, lane, m, `tracker:${source.name.startsWith("gh:") ? "github" : source.name}`);
4801
- state.update(key, { lastInboundAt: (/* @__PURE__ */ new Date()).toISOString() });
4802
- log(`${key}: forwarded ${msgs.length} comment(s) to inbox`);
4884
+ if (verb !== "unknown" && verb !== entry.lastReported) {
4885
+ const evidence = readEvidence(entry.uuid, lane);
4886
+ await source.report(key, verb, { ...evidence, uuid: entry.uuid });
4887
+ state.update(key, { lastReported: verb });
4888
+ log(`${key}: reported ${verb}`);
4889
+ notify({
4890
+ key,
4891
+ uuid: entry.uuid,
4892
+ verb,
4893
+ note: readStatusLog(entry.uuid, lane).at(-1)?.note,
4894
+ prUrl: evidence.prUrl
4895
+ });
4896
+ }
4897
+ if (entry.kind === "issue" && verb === "failed" && fs22.existsSync(path17.join(laneDirs(lane).done, entry.uuid))) {
4898
+ state.releaseIssue(key);
4899
+ log(`${key}: finalized failure released for a bounded retry`);
4900
+ continue;
4901
+ }
4902
+ const msgs = await source.inbound(key, entry.lastInboundAt);
4903
+ if (msgs.length > 0) {
4904
+ for (const m of msgs)
4905
+ sendMessage(entry.uuid, lane, m, `tracker:${source.name.startsWith("gh:") ? "github" : source.name}`);
4906
+ state.update(key, { lastInboundAt: (/* @__PURE__ */ new Date()).toISOString() });
4907
+ log(`${key}: forwarded ${msgs.length} comment(s) to inbox`);
4908
+ }
4909
+ } catch (err) {
4910
+ failures.push(`${key}: ${err instanceof Error ? err.message : String(err)}`);
4803
4911
  }
4804
4912
  }
4913
+ if (failures.length > 0)
4914
+ throw new Error(failures.join("; "));
4805
4915
  }
4806
4916
 
4807
4917
  // apps/pick/dist/loops/dispatch.js
@@ -5273,12 +5383,16 @@ function syncStreams(children, flight, log, notify) {
5273
5383
  }
5274
5384
  async function cycle(sources, merges, state, pollSecs2, log, notify) {
5275
5385
  for (const source of sources) {
5276
- try {
5277
- await dispatchLoop(source, state, log);
5278
- await reportLoop(source, state, log, notify);
5279
- await reconcileLoop(source, state, log);
5280
- } catch (err) {
5281
- log(`${source.name}: ${err instanceof Error ? err.message : String(err)}`);
5386
+ for (const loop of [
5387
+ () => dispatchLoop(source, state, log),
5388
+ () => reportLoop(source, state, log, notify),
5389
+ () => reconcileLoop(source, state, log)
5390
+ ]) {
5391
+ try {
5392
+ await loop();
5393
+ } catch (err) {
5394
+ log(`${source.name}: ${err instanceof Error ? err.message : String(err)}`);
5395
+ }
5282
5396
  }
5283
5397
  }
5284
5398
  for (const { source, policy } of merges) {
package/dist/runner.js CHANGED
@@ -841,6 +841,7 @@ var DEFAULT_LIMITS = {
841
841
  wedgeThresholdSecs: 600,
842
842
  maxRestartAttempts: 2,
843
843
  wallClockSecs: 3600,
844
+ backgroundWaitSecs: 1800,
844
845
  choreRetentionDays: 7,
845
846
  attachmentMaxBytes: 25 * 1024 * 1024
846
847
  };
@@ -1223,6 +1224,9 @@ function pumpClaudeMessage(msg, push, onSession) {
1223
1224
  if (msg.session_id)
1224
1225
  onSession(msg.session_id);
1225
1226
  push({ at, type: "session", data: { sessionId: msg.session_id } });
1227
+ } else if (msg.type === "system" && msg.subtype === "background_tasks_changed") {
1228
+ const live = (msg.tasks ?? []).filter((t) => !t.ambient).length;
1229
+ push({ at, type: "background", data: { live } });
1226
1230
  } else if (msg.type === "assistant") {
1227
1231
  for (const block of msg.message?.content ?? []) {
1228
1232
  if (block.type === "tool_use") {
@@ -1249,6 +1253,19 @@ var userMessage = (content) => ({
1249
1253
  parent_tool_use_id: null,
1250
1254
  session_id: ""
1251
1255
  });
1256
+ function claudeSdkOptions(opts) {
1257
+ return {
1258
+ cwd: opts.cwd,
1259
+ model: opts.model,
1260
+ ...opts.effort ? { effort: opts.effort } : {},
1261
+ maxTurns: opts.limits.maxTurns,
1262
+ resume: opts.resumeSession,
1263
+ forkSession: opts.resumeSession ? true : void 0,
1264
+ permissionMode: "bypassPermissions",
1265
+ env: { ...process.env, ...opts.env },
1266
+ extraArgs: flagsToExtraArgs(opts.flags)
1267
+ };
1268
+ }
1252
1269
  function claudeCliArgs(opts) {
1253
1270
  const args = [
1254
1271
  "-p",
@@ -1262,6 +1279,8 @@ function claudeCliArgs(opts) {
1262
1279
  ];
1263
1280
  if (opts.model)
1264
1281
  args.push("--model", opts.model);
1282
+ if (opts.effort)
1283
+ args.push("--effort", opts.effort);
1265
1284
  if (opts.limits.maxTurns)
1266
1285
  args.push("--max-turns", String(opts.limits.maxTurns));
1267
1286
  if (opts.resumeSession)
@@ -1369,16 +1388,7 @@ function createClaudeAdapter() {
1369
1388
  }
1370
1389
  const q = sdk.query({
1371
1390
  prompt: userMessages(),
1372
- options: {
1373
- cwd: opts.cwd,
1374
- model: opts.model,
1375
- maxTurns: opts.limits.maxTurns,
1376
- resume: opts.resumeSession,
1377
- forkSession: opts.resumeSession ? true : void 0,
1378
- permissionMode: "bypassPermissions",
1379
- env: { ...process.env, ...opts.env },
1380
- extraArgs: flagsToExtraArgs(opts.flags)
1381
- }
1391
+ options: claudeSdkOptions(opts)
1382
1392
  });
1383
1393
  const events = new AsyncQueue();
1384
1394
  let sessionId;
@@ -1642,6 +1652,23 @@ async function git(cwd, ...args) {
1642
1652
  const { stdout } = await run("git", args, { cwd, env: process.env });
1643
1653
  return stdout.trim();
1644
1654
  }
1655
+ var LOCK_CONTENTION = [/cannot lock ref/, /unable to update local ref/, /Unable to create '[^']*\.lock': File exists/];
1656
+ function isLockContention(err) {
1657
+ const text = `${err.stderr ?? ""}
1658
+ ${err instanceof Error ? err.message : ""}`;
1659
+ return LOCK_CONTENTION.some((re) => re.test(text));
1660
+ }
1661
+ async function gitShared(cwd, ...args) {
1662
+ for (let attempt = 1; ; attempt++) {
1663
+ try {
1664
+ return await git(cwd, ...args);
1665
+ } catch (err) {
1666
+ if (attempt >= 6 || !isLockContention(err))
1667
+ throw err;
1668
+ await new Promise((r) => setTimeout(r, 100 * 2 ** (attempt - 1) * (0.5 + Math.random())));
1669
+ }
1670
+ }
1671
+ }
1645
1672
  async function allocate(repo, id) {
1646
1673
  const dir = worktreePath(id);
1647
1674
  if (fs11.existsSync(dir)) {
@@ -1652,8 +1679,8 @@ async function allocate(repo, id) {
1652
1679
  throw new Error(`repo path ${repo.path} missing and no origin configured`);
1653
1680
  await run("git", ["clone", repo.origin, repo.path], { env: process.env });
1654
1681
  }
1655
- await git(repo.path, "fetch", "origin", repo.trunk);
1656
- await git(repo.path, "worktree", "add", dir, "-b", `lobstah/${id}`, `origin/${repo.trunk}`);
1682
+ await gitShared(repo.path, "fetch", "origin", repo.trunk);
1683
+ await gitShared(repo.path, "worktree", "add", "--no-track", dir, "-b", `lobstah/${id}`, `origin/${repo.trunk}`);
1657
1684
  for (const cmd of repo.setup ?? []) {
1658
1685
  await shell(cmd, { cwd: dir, env: { ...process.env, ...repo.env ?? {} } });
1659
1686
  }
@@ -1668,7 +1695,7 @@ async function collectEvidence(repo, id) {
1668
1695
 
1669
1696
  // packages/runner/src/contract.ts
1670
1697
  function buildPrompt(brief, opts) {
1671
- const reporting = `Report status by running \`lobstah report ${opts.id} <verb> [note]\` (verbs: ${VERBS.join(", ")}). Attach a PR URL to your final report with \`--pr <url>\`. At natural checkpoints, check for operator messages with \`lobstah inbox ${opts.id}\` \u2014 messages also arrive automatically between your turns.`;
1698
+ const reporting = `Report status by running \`lobstah report ${opts.id} <verb> [note]\` (verbs: ${VERBS.join(", ")}). Attach a PR URL to your final report with \`--pr <url>\`. Only your report finishes this dispatch: ending a turn without \`done\` or \`failed\` does not. If you start background work and end your turn to wait for it, you are woken when it finishes. At natural checkpoints, check for operator messages with \`lobstah inbox ${opts.id}\` \u2014 messages also arrive automatically between your turns.`;
1672
1699
  const parts = [
1673
1700
  `You are a dispatched coding agent supervised by lobstah. Dispatch id: ${opts.id}.`,
1674
1701
  `Work only inside the current directory \u2014 it is an isolated git worktree allocated for this dispatch.`,
@@ -1685,6 +1712,9 @@ function buildPrompt(brief, opts) {
1685
1712
 
1686
1713
  // packages/runner/src/drive.ts
1687
1714
  var WAITING_VERBS = ["needs-decision", "blocked", "paused"];
1715
+ function unreportedNudge(id) {
1716
+ return `Your turn ended without a status report, so this dispatch is not finished. If the work is complete, run \`lobstah report ${id} done "<what you did>"\`, adding \`--pr <url>\` if you opened a PR. If no change was needed, report \`done\` with a note saying why. If a human has to answer a question, or you are blocked, report \`needs-decision\` or \`blocked\` with it. If you are waiting on something, run it in the background: you are woken when it finishes.`;
1717
+ }
1688
1718
  var sleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
1689
1719
  function deliver(run2, id, lane) {
1690
1720
  const msgs = unhandled(id, lane);
@@ -1695,7 +1725,14 @@ function deliver(run2, id, lane) {
1695
1725
  return msgs.length;
1696
1726
  }
1697
1727
  async function drive(run2, opts) {
1698
- const { id, lane, pollMs = 3e3, stopped = () => false } = opts;
1728
+ const {
1729
+ id,
1730
+ lane,
1731
+ pollMs = 3e3,
1732
+ stopped = () => false,
1733
+ backgroundWaitMs = 30 * 6e4,
1734
+ settleGraceMs = 12e4
1735
+ } = opts;
1699
1736
  let cancelled = false;
1700
1737
  let activity = 0;
1701
1738
  let finished = false;
@@ -1718,8 +1755,51 @@ async function drive(run2, opts) {
1718
1755
  await sleep(pollMs);
1719
1756
  }
1720
1757
  };
1758
+ let liveBackground = 0;
1759
+ let nudged = false;
1760
+ let hold;
1761
+ const runnerEvent = (data) => appendEvent(id, lane, { at: (/* @__PURE__ */ new Date()).toISOString(), type: "runner", data });
1762
+ const releaseHold = () => {
1763
+ if (!hold) return;
1764
+ clearInterval(hold.heartbeat);
1765
+ clearTimeout(hold.expiry);
1766
+ hold = void 0;
1767
+ };
1768
+ const unreported = () => {
1769
+ if (!nudged) {
1770
+ nudged = true;
1771
+ runnerEvent({ nudged: "unreported" });
1772
+ run2.send(unreportedNudge(id));
1773
+ return;
1774
+ }
1775
+ appendStatus(id, lane, "failed", "ended without reporting a result, after being asked to");
1776
+ run2.end();
1777
+ };
1778
+ const holdOpen = (ms) => {
1779
+ releaseHold();
1780
+ const heartbeat = setInterval(() => {
1781
+ if (cancelRequested(id, lane)) {
1782
+ releaseHold();
1783
+ cancel();
1784
+ return;
1785
+ }
1786
+ touchEvents(id, lane);
1787
+ }, pollMs);
1788
+ const expiry = setTimeout(() => {
1789
+ releaseHold();
1790
+ runnerEvent({ backgroundWait: "expired", live: liveBackground });
1791
+ unreported();
1792
+ }, ms);
1793
+ hold = { heartbeat, expiry };
1794
+ };
1721
1795
  for await (const ev of run2.events) {
1722
1796
  appendEvent(id, lane, ev);
1797
+ if (ev.type === "background") {
1798
+ liveBackground = Number(ev.data?.live ?? 0);
1799
+ if (hold && liveBackground === 0) holdOpen(settleGraceMs);
1800
+ continue;
1801
+ }
1802
+ releaseHold();
1723
1803
  if (ev.type === "tool-start" || ev.type === "text") activity++;
1724
1804
  if (ev.type === "session" && ev.data?.sessionId) {
1725
1805
  mergeEvidence(id, lane, { sessionId: String(ev.data.sessionId) });
@@ -1739,8 +1819,23 @@ async function drive(run2, opts) {
1739
1819
  await awaitAnswer(lastVerb);
1740
1820
  continue;
1741
1821
  }
1742
- run2.end();
1822
+ if (lastVerb !== void 0 && TERMINAL_VERBS.includes(lastVerb)) {
1823
+ run2.end();
1824
+ continue;
1825
+ }
1826
+ const subtype = ev.data?.subtype;
1827
+ if (subtype !== void 0 && subtype !== "success") {
1828
+ run2.end();
1829
+ continue;
1830
+ }
1831
+ if (liveBackground > 0) {
1832
+ runnerEvent({ holding: "background", live: liveBackground, forSecs: Math.round(backgroundWaitMs / 1e3) });
1833
+ holdOpen(backgroundWaitMs);
1834
+ continue;
1835
+ }
1836
+ unreported();
1743
1837
  }
1838
+ releaseHold();
1744
1839
  return { cancelled, activity };
1745
1840
  }
1746
1841
  function settle(id, lane, r) {
@@ -1748,7 +1843,7 @@ function settle(id, lane, r) {
1748
1843
  if (r.cancelled) appendStatus(id, lane, "failed", "cancelled by operator");
1749
1844
  else if (r.wallClockHit) appendStatus(id, lane, "failed", "wall-clock limit exceeded");
1750
1845
  else if (r.error) appendStatus(id, lane, "failed", r.error.slice(0, 500));
1751
- else if (!lastVerb || !TERMINAL_VERBS.includes(lastVerb)) appendStatus(id, lane, "done");
1846
+ else if (!lastVerb || !TERMINAL_VERBS.includes(lastVerb)) appendStatus(id, lane, "failed", "stopped without reporting a result");
1752
1847
  }
1753
1848
 
1754
1849
  // packages/runner/src/plan.ts
@@ -1912,7 +2007,12 @@ async function main(activeDir, lane, deps = defaultDeps) {
1912
2007
  resumeSession
1913
2008
  });
1914
2009
  current = run2;
1915
- const { cancelled: cancelled2, activity } = await drive(run2, { id, lane, stopped: () => wallClockHit });
2010
+ const { cancelled: cancelled2, activity } = await drive(run2, {
2011
+ id,
2012
+ lane,
2013
+ stopped: () => wallClockHit,
2014
+ backgroundWaitMs: cfg.limits.backgroundWaitSecs * 1e3
2015
+ });
1916
2016
  const result2 = await run2.done;
1917
2017
  return { cancelled: cancelled2, activity, result: result2 };
1918
2018
  };
@@ -33,6 +33,12 @@ The descriptor's `repo` field resolves here; the key is what dispatchers name.
33
33
  `[repos.<key>.harness]` — per-repo harness defaults: `default` (`claude` \|
34
34
  `codex`), `model`, `effort`.
35
35
 
36
+ `effort` reaches both harnesses: Claude as its effort level (`low`, `medium`,
37
+ `high`, `xhigh`, `max`), Codex as `model_reasoning_effort`. Set it for Claude
38
+ rather than relying on the default — left unset, a Claude session takes
39
+ `effortLevel` from the host's settings files, including the user's own
40
+ `~/.claude/settings.json`.
41
+
36
42
  `lobstah repos add <path> [--pickup]` detects and appends a block (origin,
37
43
  default branch from `origin/HEAD`, setup from the lockfile); `lobstah init
38
44
  --scan <dir>...` does the same for every git repo found under the given
@@ -52,6 +58,7 @@ Same three keys as the per-repo block. Precedence for every harness setting:
52
58
  | `wedgeThresholdSecs` | `600` | No tool activity for this long while alive = wedged → killed and forked with a nudge. |
53
59
  | `maxRestartAttempts` | `2` | Bounded restart ladder for dead and wedged runners. |
54
60
  | `wallClockSecs` | `3600` | Hard per-dispatch ceiling, enforced by the runner. |
61
+ | `backgroundWaitSecs` | `1800` | A turn that ends without a report is held open this long while background work the worker started is still running (a push behind a slow pre-push gate); the harness wakes the worker when it settles. Heartbeats keep the wedge detector off the wait. Keep it below `wallClockSecs`, which still ends the run. |
55
62
  | `choreRetentionDays` | `7` | Completed chores age out of `chores/done/`. |
56
63
  | `attachmentMaxBytes` | `26214400` (25 MiB) | Maximum size of each file supplied with repeatable `dispatch --attach` or `send --attach`. |
57
64
 
package/docs/design.md CHANGED
@@ -449,8 +449,20 @@ worker's last report on `needs-decision`, `blocked`, or `paused` does not end
449
449
  the session: a headless worker waiting on a question stays alive until
450
450
  answered, cancelled, or the wall clock — the runner polls the inbox, touches the
451
451
  event stream each poll so the wedge detector sees a live wait, delivers the
452
- answer into the next turn, and stamps `working`. Only a turn ending on `working`
453
- (or no report) with an empty inbox ends the session and is stamped `done`.
452
+ answer into the next turn, and stamps `working`. A turn ending on `done` or
453
+ `failed` ends the session.
454
+
455
+ **Only the worker's report finishes a dispatch.** A turn can end with no report
456
+ while the worker is still mid-task: it started a push behind a slow pre-push gate
457
+ and ended the turn to be woken when it lands, which is how the harness asks a
458
+ model to wait. Ending the session there kills the push. So a quiet turn is held
459
+ open while the harness reports live background work (ambient watchers excluded),
460
+ up to `[limits].backgroundWaitSecs`, with the same heartbeats as a question wait;
461
+ the harness wakes the worker when the work settles. With nothing live, or once
462
+ the window passes, the worker is asked once to report. Silence after that is
463
+ `failed`, as is a run that stops on its own without a report. Stamping `done` on
464
+ silence turned a question never asked, two in-flight pushes, and a conclusion
465
+ nobody saw into finished work.
454
466
 
455
467
  **The session's own harness resumes it.** A session only resumes under the
456
468
  harness that wrote it: a Codex thread id handed to Claude Code is "No
package/docs/pickup.md CHANGED
@@ -356,6 +356,13 @@ design rather than by trust:
356
356
  In Progress item because the table lacks an entry for it. A missing mapping
357
357
  triggers a rebuild, and only a rebuilt table may declare an orphan. Losing a
358
358
  file must never cancel live work.
359
+ - **Shared, so owned.** Every source writes into one table. A loop that walks
360
+ it acts only on the keys its source `owns()` — a Linear key handed to the
361
+ GitHub source, or the reverse, is not an item that source can read. And no
362
+ single entry may fail the walk: each is isolated, the rest still report, and
363
+ the loop rejects afterwards so the cycle logs it. Dispatch, report, and
364
+ reconcile are guarded separately, so a report that cannot land never takes
365
+ reconciliation down with it.
359
366
 
360
367
  ---
361
368
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lobstah",
3
- "version": "0.5.9",
3
+ "version": "0.5.10",
4
4
  "description": "Harness-agnostic, token-efficient supervision framework for coding agents",
5
5
  "license": "MIT",
6
6
  "author": "aequitas labs LLC",