lobstah 0.6.1 → 0.6.3

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
@@ -857,6 +857,26 @@ var GB = 1024 ** 3;
857
857
  // packages/core/dist/watch.js
858
858
  import * as fs3 from "node:fs";
859
859
  import * as path3 from "node:path";
860
+
861
+ // packages/core/dist/gh-errors.js
862
+ function firstMeaningfulLine(...outputs) {
863
+ for (const out of outputs) {
864
+ for (const raw of (out ?? "").split("\n")) {
865
+ let line = raw.trim();
866
+ if (!line || /^(warning:|hint:|\(node:|\{|\})/i.test(line))
867
+ continue;
868
+ const m = /^error:\s*(.*)$/.exec(line);
869
+ if (m)
870
+ line = m[1].replace(/^"(.*)"$/, "$1").trim();
871
+ line = line.replace(/^(gh|GraphQL):\s*/i, "");
872
+ if (line)
873
+ return line;
874
+ }
875
+ }
876
+ return void 0;
877
+ }
878
+
879
+ // packages/core/dist/watch.js
860
880
  function watchesDir() {
861
881
  return path3.join(lobstahHome(), "watches");
862
882
  }
@@ -1577,6 +1597,7 @@ var COMPILED_BINARY = (() => {
1577
1597
  })();
1578
1598
 
1579
1599
  // packages/core/dist/pr.js
1600
+ import { spawnSync } from "node:child_process";
1580
1601
  function parsePrRef(s) {
1581
1602
  const m = /^(?:pr:)?([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)#(\d+)$/.exec(s.trim()) ?? /^https?:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/(\d+)(?:[/?#].*)?$/.exec(s.trim());
1582
1603
  if (!m)
@@ -1587,6 +1608,59 @@ function parsePrRef(s) {
1587
1608
  }
1588
1609
  var PR_VIEW_FIELDS = "title,state,isDraft,headRefOid,baseRefName,baseRefOid,headRefName,mergeStateStatus,reviewDecision,statusCheckRollup,mergedAt,closedAt,updatedAt,reviews";
1589
1610
  var PR_VIEW_FIELDS_NO_CHECKS = PR_VIEW_FIELDS.split(",").filter((f) => f !== "statusCheckRollup").join(",");
1611
+ var FORBIDDEN = /resource not accessible by (integration|personal access token)/i;
1612
+ function ghPrView(ref) {
1613
+ const view1 = (fields2) => spawnSync("gh", ["pr", "view", String(ref.number), "--repo", `${ref.owner}/${ref.repo}`, "--json", fields2], {
1614
+ encoding: "utf8",
1615
+ timeout: 6e4
1616
+ });
1617
+ const reason = (r) => firstMeaningfulLine(r.stderr) ?? `gh exited ${r.status}`;
1618
+ const unknownBase = (r) => r.status !== 0 && /unknown json field.*baseRefOid/i.test(r.stderr ?? "");
1619
+ const withoutBase = (fields2) => fields2.split(",").filter((f) => f !== "baseRefOid").join(",");
1620
+ let fields = PR_VIEW_FIELDS;
1621
+ let res = view1(fields);
1622
+ if (unknownBase(res))
1623
+ res = view1(fields = withoutBase(fields));
1624
+ if (res.error)
1625
+ throw new Error(`gh: ${res.error.message}`);
1626
+ let checksError;
1627
+ if (res.status !== 0 && FORBIDDEN.test(res.stderr ?? "")) {
1628
+ checksError = reason(res);
1629
+ const retry = view1(fields === PR_VIEW_FIELDS ? PR_VIEW_FIELDS_NO_CHECKS : withoutBase(PR_VIEW_FIELDS_NO_CHECKS));
1630
+ if (retry.error)
1631
+ throw new Error(`gh: ${retry.error.message}`);
1632
+ if (retry.status !== 0) {
1633
+ throw new Error(FORBIDDEN.test(retry.stderr ?? "") ? `${reason(retry)} (fails even without check results)` : reason(retry));
1634
+ }
1635
+ res = retry;
1636
+ }
1637
+ if (res.status !== 0)
1638
+ throw new Error(reason(res));
1639
+ const view = JSON.parse(res.stdout);
1640
+ if (checksError)
1641
+ view.checksError = checksError;
1642
+ if (view.state === "OPEN") {
1643
+ const threads = ghUnresolvedThreads(ref);
1644
+ if (threads !== void 0)
1645
+ view.unresolvedThreads = threads;
1646
+ }
1647
+ return view;
1648
+ }
1649
+ var THREADS_QUERY = "query($owner:String!,$repo:String!,$n:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$n){reviewThreads(first:100){nodes{isResolved}}}}}";
1650
+ function ghUnresolvedThreads(ref) {
1651
+ const res = spawnSync("gh", ["api", "graphql", "-f", `query=${THREADS_QUERY}`, "-f", `owner=${ref.owner}`, "-f", `repo=${ref.repo}`, "-F", `n=${ref.number}`], { encoding: "utf8", timeout: 6e4 });
1652
+ if (res.error || res.status !== 0)
1653
+ return void 0;
1654
+ return parseUnresolvedThreads(res.stdout);
1655
+ }
1656
+ function parseUnresolvedThreads(stdout) {
1657
+ try {
1658
+ const nodes = JSON.parse(stdout).data?.repository?.pullRequest?.reviewThreads?.nodes;
1659
+ return Array.isArray(nodes) ? nodes.filter((t) => t.isResolved === false).length : void 0;
1660
+ } catch {
1661
+ return void 0;
1662
+ }
1663
+ }
1590
1664
 
1591
1665
  // packages/core/dist/prs.js
1592
1666
  import * as fs14 from "node:fs";
@@ -1594,6 +1668,20 @@ import * as path11 from "node:path";
1594
1668
  function prsDir() {
1595
1669
  return path11.join(lobstahHome(), "prs");
1596
1670
  }
1671
+ function prRecordFile(key) {
1672
+ const ref = parsePrRef(key);
1673
+ if (!ref)
1674
+ throw new Error(`not a PR key: ${key}`);
1675
+ const safe = (s) => s.replace(/[^A-Za-z0-9._-]/g, "_");
1676
+ return path11.join(prsDir(), `${safe(ref.owner)}__${safe(ref.repo)}__${ref.number}.json`);
1677
+ }
1678
+ function readPr(key) {
1679
+ try {
1680
+ return JSON.parse(fs14.readFileSync(prRecordFile(key), "utf8"));
1681
+ } catch {
1682
+ return void 0;
1683
+ }
1684
+ }
1597
1685
  function readPrs() {
1598
1686
  let files;
1599
1687
  try {
@@ -1655,7 +1743,7 @@ function pushRule(branch, id = "<dispatch id>") {
1655
1743
  import * as fs15 from "node:fs";
1656
1744
  import * as os3 from "node:os";
1657
1745
  import * as path12 from "node:path";
1658
- import { spawnSync } from "node:child_process";
1746
+ import { spawnSync as spawnSync2 } from "node:child_process";
1659
1747
  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;
1660
1748
  function harnessFromSessionId(sessionId) {
1661
1749
  const version = sessionId ? UUID.exec(sessionId)?.[1] : void 0;
@@ -1717,7 +1805,7 @@ function isUnresumable(error) {
1717
1805
  function worktreeProgress(worktree, trunk) {
1718
1806
  if (!worktree || !fs15.existsSync(worktree))
1719
1807
  return "No worktree progress recorded.";
1720
- const git2 = (...a) => spawnSync("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
1808
+ const git2 = (...a) => spawnSync2("git", a, { cwd: worktree, encoding: "utf8" }).stdout?.trim() ?? "";
1721
1809
  const commits = trunk ? git2("log", "--oneline", `origin/${trunk}..HEAD`) : git2("log", "--oneline", "-15");
1722
1810
  const status = git2("status", "--short");
1723
1811
  return `Commits so far:
@@ -1827,7 +1915,7 @@ function isFinished(id, lane) {
1827
1915
  // packages/runner/src/run.ts
1828
1916
  import * as fs19 from "node:fs";
1829
1917
  import * as path16 from "node:path";
1830
- import { execFileSync } from "node:child_process";
1918
+ import { execFileSync as execFileSync2 } from "node:child_process";
1831
1919
  import { fileURLToPath as fileURLToPath2 } from "node:url";
1832
1920
 
1833
1921
  // packages/adapters/dist/claude.js
@@ -2346,7 +2434,7 @@ function loadAdapter(name) {
2346
2434
  import * as fs17 from "node:fs";
2347
2435
  import * as path14 from "node:path";
2348
2436
  import { createHash } from "node:crypto";
2349
- import { exec, execFile } from "node:child_process";
2437
+ import { exec, execFile, execFileSync } from "node:child_process";
2350
2438
  import { promisify } from "node:util";
2351
2439
  var run = promisify(execFile);
2352
2440
  var shell = promisify(exec);
@@ -2372,7 +2460,12 @@ var LOCK_CONTENTION = [
2372
2460
  // held by another process ("fetching ref <ref> failed: reference already
2373
2461
  // exists"). The files backend maps the lock's EEXIST to this reason; older
2374
2462
  // git printed "Unable to create '<ref>.lock': File exists" instead.
2375
- /reference already exists/
2463
+ /reference already exists/,
2464
+ // Windows: two fetches unpack the same loose object, and one cannot write
2465
+ // the file the other holds open ("unable to write file
2466
+ // .git/objects/<xx>/<rest>: Permission denied", then "unpack-objects
2467
+ // failed"). Only an object path matches, not any denied write.
2468
+ /unable to write file \S*objects[\\/][0-9a-f]{2}[\\/][0-9a-f]{38,}: Permission denied/
2376
2469
  ];
2377
2470
  function gitStderr(err) {
2378
2471
  const stderr = err.stderr;
@@ -2399,6 +2492,112 @@ ${gitStderr(err)}`, { cause: err });
2399
2492
  }
2400
2493
  }
2401
2494
  }
2495
+ var REPO_LOCK_WAIT_MS = 5 * 6e4;
2496
+ var repoQueues = /* @__PURE__ */ new Map();
2497
+ function pidAlive(pid) {
2498
+ try {
2499
+ process.kill(pid, 0);
2500
+ return true;
2501
+ } catch (err) {
2502
+ return err.code === "EPERM";
2503
+ }
2504
+ }
2505
+ var BUSY = /* @__PURE__ */ new Set(["EPERM", "EACCES", "EBUSY"]);
2506
+ function takeRepoLock(file, mine) {
2507
+ try {
2508
+ fs17.writeFileSync(file, mine, { flag: "wx" });
2509
+ return { state: "held" };
2510
+ } catch (err) {
2511
+ const code = err.code ?? "";
2512
+ if (code !== "EEXIST" && !BUSY.has(code))
2513
+ throw err;
2514
+ }
2515
+ let raw = "";
2516
+ let age = 0;
2517
+ try {
2518
+ raw = fs17.readFileSync(file, "utf8");
2519
+ age = Date.now() - fs17.statSync(file).mtimeMs;
2520
+ } catch {
2521
+ return { state: "retry" };
2522
+ }
2523
+ let holder;
2524
+ try {
2525
+ holder = JSON.parse(raw);
2526
+ } catch {
2527
+ holder = void 0;
2528
+ }
2529
+ const stale = age > REPO_LOCK_WAIT_MS || (holder ? !pidAlive(holder.pid) : age > 5e3);
2530
+ if (stale) {
2531
+ try {
2532
+ if (fs17.readFileSync(file, "utf8") === raw)
2533
+ fs17.rmSync(file, { force: true });
2534
+ } catch {
2535
+ }
2536
+ return { state: "retry" };
2537
+ }
2538
+ return { state: "wait", ...holder ? { holder: holder.pid } : {} };
2539
+ }
2540
+ function releaseRepoLock(file, mine) {
2541
+ try {
2542
+ if (fs17.readFileSync(file, "utf8") === mine)
2543
+ fs17.rmSync(file, { force: true, recursive: true, maxRetries: 5 });
2544
+ } catch {
2545
+ }
2546
+ }
2547
+ async function withRepoLock(common, fn) {
2548
+ const file = path14.join(common, "lobstah-git.lock");
2549
+ const mine = JSON.stringify({ pid: process.pid, at: (/* @__PURE__ */ new Date()).toISOString() });
2550
+ const deadline = Date.now() + REPO_LOCK_WAIT_MS;
2551
+ let held = false;
2552
+ while (Date.now() < deadline) {
2553
+ const took = takeRepoLock(file, mine);
2554
+ if (took.state === "held") {
2555
+ held = true;
2556
+ break;
2557
+ }
2558
+ if (took.state === "wait")
2559
+ await new Promise((r) => setTimeout(r, 50 + Math.random() * 50));
2560
+ }
2561
+ try {
2562
+ await fn();
2563
+ } finally {
2564
+ if (held)
2565
+ releaseRepoLock(file, mine);
2566
+ }
2567
+ }
2568
+ async function oneAtATime(cwd, fn) {
2569
+ const common = await git(cwd, "rev-parse", "--path-format=absolute", "--git-common-dir");
2570
+ const key = realpath(common);
2571
+ const run2 = (repoQueues.get(key) ?? Promise.resolve()).then(() => withRepoLock(common, () => fn(common)));
2572
+ const tail = run2.catch(() => {
2573
+ });
2574
+ repoQueues.set(key, tail);
2575
+ try {
2576
+ await run2;
2577
+ } finally {
2578
+ if (repoQueues.get(key) === tail)
2579
+ repoQueues.delete(key);
2580
+ }
2581
+ }
2582
+ function fetchStamps(common) {
2583
+ try {
2584
+ return JSON.parse(fs17.readFileSync(path14.join(common, "lobstah-fetch.json"), "utf8"));
2585
+ } catch {
2586
+ return {};
2587
+ }
2588
+ }
2589
+ async function fetchShared(cwd, remote, ref) {
2590
+ const asked = Date.now();
2591
+ const key = `${remote} ${ref}`;
2592
+ await oneAtATime(cwd, async (common) => {
2593
+ if ((fetchStamps(common)[key] ?? -Infinity) >= asked)
2594
+ return;
2595
+ const started = Date.now();
2596
+ await gitShared(cwd, "fetch", remote, ref);
2597
+ const stamps = { ...fetchStamps(common), [key]: started };
2598
+ fs17.writeFileSync(path14.join(common, "lobstah-fetch.json"), JSON.stringify(stamps, null, 2));
2599
+ });
2600
+ }
2402
2601
  async function allocate(repo, id, fromRemoteBranch = repo.trunk) {
2403
2602
  const dir = worktreePath(id);
2404
2603
  if (fs17.existsSync(dir)) {
@@ -2409,8 +2608,10 @@ async function allocate(repo, id, fromRemoteBranch = repo.trunk) {
2409
2608
  throw new Error(`repo path ${repo.path} missing and no origin configured`);
2410
2609
  await run("git", ["clone", repo.origin, repo.path], { env: process.env });
2411
2610
  }
2412
- await gitShared(repo.path, "fetch", "origin", fromRemoteBranch);
2413
- await gitShared(repo.path, "worktree", "add", "--no-track", dir, "-b", `lobstah/${id}`, `origin/${fromRemoteBranch}`);
2611
+ await fetchShared(repo.path, "origin", fromRemoteBranch);
2612
+ await oneAtATime(repo.path, async () => {
2613
+ await gitShared(repo.path, "worktree", "add", "--no-track", dir, "-b", `lobstah/${id}`, `origin/${fromRemoteBranch}`);
2614
+ });
2414
2615
  await runSetup(repo, dir);
2415
2616
  return dir;
2416
2617
  }
@@ -2544,7 +2745,7 @@ async function chooseWorktree(input) {
2544
2745
  return { reuse: true, path: found.path, owner: found.owner, from: found.from };
2545
2746
  }
2546
2747
  async function prepareReuse(repo, dir) {
2547
- await gitShared(dir, "fetch", "origin", repo.trunk);
2748
+ await fetchShared(dir, "origin", repo.trunk);
2548
2749
  if (!repo.setup?.length)
2549
2750
  return { setupRan: false };
2550
2751
  if (recordedSetupHash(dir) === setupHash(repo, dir))
@@ -2874,6 +3075,7 @@ function planStart(input) {
2874
3075
  function startWallClock(opts) {
2875
3076
  const now2 = opts.now ?? Date.now;
2876
3077
  const tickMs = Math.max(1, Math.min(opts.tickMs ?? 5e3, opts.limitMs));
3078
+ const sleepGapMs = opts.sleepGapMs ?? Math.max(6e4, tickMs * 6);
2877
3079
  let elapsed = opts.initialElapsedMs ?? 0;
2878
3080
  const maxMs = Math.max(opts.limitMs, opts.maxMs ?? opts.limitMs);
2879
3081
  let windowMs = Math.min(maxMs, Math.max(opts.limitMs, opts.initialWindowMs ?? opts.limitMs));
@@ -2887,7 +3089,8 @@ function startWallClock(opts) {
2887
3089
  } catch {
2888
3090
  paused = false;
2889
3091
  }
2890
- if (!paused) elapsed += t - last;
3092
+ const step = Math.max(0, t - last);
3093
+ if (!paused) elapsed += step > sleepGapMs ? Math.min(step, tickMs) : step;
2891
3094
  last = t;
2892
3095
  if (!done && elapsed >= windowMs && elapsed < maxMs) {
2893
3096
  let advanced = false;
@@ -3094,13 +3297,23 @@ function keepRemote(opts) {
3094
3297
  }
3095
3298
 
3096
3299
  // packages/runner/src/run.ts
3300
+ function livePrState(url) {
3301
+ const ref = parsePrRef(url);
3302
+ if (!ref) return void 0;
3303
+ try {
3304
+ return ghPrView(ref).state;
3305
+ } catch {
3306
+ return void 0;
3307
+ }
3308
+ }
3097
3309
  var defaultDeps = {
3098
3310
  loadAdapter,
3099
3311
  allocate,
3100
3312
  chooseWorktree,
3101
3313
  prepareReuse,
3102
3314
  collectEvidence,
3103
- reap: async () => 0
3315
+ reap: async () => 0,
3316
+ prState: livePrState
3104
3317
  };
3105
3318
  var DONE_WAIT_MS = 2e3;
3106
3319
  function within(p, ms, fallback) {
@@ -3141,6 +3354,14 @@ async function main(activeDir, lane, seams = {}) {
3141
3354
  attempts
3142
3355
  };
3143
3356
  fs19.writeFileSync(path16.join(activeDir, "runner.json"), JSON.stringify(runnerInfo, null, 2));
3357
+ if (descriptor.systemRepair && descriptor.pr?.url) {
3358
+ const state = deps.prState(descriptor.pr.url) ?? readPr(parsePrRef(descriptor.pr.url)?.key ?? "")?.state;
3359
+ if (state === "MERGED" || state === "CLOSED") {
3360
+ status("failed", `cancelled: ${descriptor.pr.url} is ${state.toLowerCase()}; nothing to repair`);
3361
+ complete(id, lane);
3362
+ return;
3363
+ }
3364
+ }
3144
3365
  const plan = planStart({
3145
3366
  id,
3146
3367
  lane,
@@ -3240,7 +3461,7 @@ async function main(activeDir, lane, seams = {}) {
3240
3461
  })();
3241
3462
  const head = () => {
3242
3463
  try {
3243
- return execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
3464
+ return execFileSync2("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
3244
3465
  } catch {
3245
3466
  return void 0;
3246
3467
  }
@@ -3412,10 +3633,10 @@ async function main(activeDir, lane, seams = {}) {
3412
3633
  }
3413
3634
 
3414
3635
  // packages/runner/src/reap.ts
3415
- import { execFileSync as execFileSync2 } from "node:child_process";
3636
+ import { execFileSync as execFileSync3 } from "node:child_process";
3416
3637
  function listProcesses() {
3417
3638
  if (process.platform === "win32") {
3418
- const out2 = execFileSync2(
3639
+ const out2 = execFileSync3(
3419
3640
  "powershell.exe",
3420
3641
  [
3421
3642
  "-NoProfile",
@@ -3427,7 +3648,7 @@ function listProcesses() {
3427
3648
  );
3428
3649
  return out2.split(/\r?\n/).map((l) => l.trim().split(/\s+/).map(Number)).filter(([pid, ppid]) => Number.isInteger(pid) && Number.isInteger(ppid)).map(([pid, ppid]) => ({ pid, ppid }));
3429
3650
  }
3430
- const out = execFileSync2("ps", ["-A", "-o", "pid=,ppid=,pgid="], {
3651
+ const out = execFileSync3("ps", ["-A", "-o", "pid=,ppid=,pgid="], {
3431
3652
  encoding: "utf8",
3432
3653
  timeout: 3e4,
3433
3654
  stdio: ["ignore", "pipe", "ignore"]
@@ -3470,7 +3691,7 @@ async function stopPids(pids, graceMs = 2e3) {
3470
3691
  if (process.platform === "win32") {
3471
3692
  for (const pid of pids) {
3472
3693
  try {
3473
- execFileSync2("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true, timeout: 3e4 });
3694
+ execFileSync3("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true, timeout: 3e4 });
3474
3695
  } catch {
3475
3696
  }
3476
3697
  }
@@ -66,6 +66,8 @@ For the glass's ↗ open button, give the trap its own session link with
66
66
  `claude://claude.ai/...` link in the app; copy that link from the session.
67
67
  Its app id is not the CLI session id. In the VS Code extension, use
68
68
  `vscode://anthropic.claude-code/open?session=<session-id>` with the id above.
69
+ A CLI session in a terminal, including the Claude desktop app's terminal
70
+ panel, passes no link; soak ignores a link that does not fit the session.
69
71
  The glass checks the stored link before showing it. `lobstah focus <trap>`
70
72
  uses the same focus steps from the terminal.
71
73
 
@@ -124,9 +124,10 @@ Codex task ids are UUIDv7 (for example `01a0ceb8-b9bd-7d42-…`); Claude Code
124
124
  session ids are UUIDv4. When both harnesses' variables are set, lobstah uses
125
125
  this format to tell which one signed on.
126
126
 
127
- For the glass's ↗ open button, form `codex://threads/<task-id>` from the
128
- task id in the session-start brief and pass it with
129
- `lobstah soak --link <url>`. The glass checks the stored link before
127
+ For the glass's ↗ open button in the Codex desktop app, form
128
+ `codex://threads/<task-id>` from the task id in the session-start brief and
129
+ pass it with `lobstah soak --link <url>`. A Codex CLI session in a terminal
130
+ passes no link; soak ignores a link that does not fit the session. The glass checks the stored link before
130
131
  showing it. `lobstah focus <trap>` uses the same focus steps from the
131
132
  terminal.
132
133
 
package/docs/man.md CHANGED
@@ -462,9 +462,10 @@ the helm files to keep.
462
462
  unacked first, up to 8, then "+N more", which opens the Reports tab. A
463
463
  report's card or row shows its title, then who filed it (a trap's name, a
464
464
  headless dispatch's id, nothing for the helm), its age, and `acked`. A
465
- dispatch's report renders as a
466
- page at the top of its dispatch modal. A helm report opens in a modal of
467
- its own; `#report/<key>` links to it. The page shows headings, lists,
465
+ report opens on its own page, `/report/<key>`, in a new tab: from its card
466
+ or row, a lob, the desktop pet, or its dispatch's modal. The page renders
467
+ once and does not refresh; opening it does not ack the report.
468
+ `#report/<key>` goes to that page. The page shows headings, lists,
468
469
  tables, fenced code, links (in a new tab), bold, italics, and images.
469
470
  Raw HTML in the markdown shows as text. The glass serves the markdown and
470
471
  the images read-only, and an image only by basename from that report's
@@ -508,7 +509,9 @@ the same rule.
508
509
  `attachments/`. The claimed helm alone may ask.
509
510
  - **Standing.** A decision stands until it is answered or the helm withdraws
510
511
  it with `lobstah man ask --withdraw <key>`, which removes its directory. A
511
- newer `man ask` on the same dispatch replaces the older decision.
512
+ newer `man ask` on the same dispatch keeps the older decisions: each shows
513
+ as its own card. `lobstah man ask ... --replace <key>` replaces that one
514
+ standing decision; a key that is not standing is refused.
512
515
  - **Attention.** A standing decision is attention of kind `decision`, in
513
516
  the default `attentionKinds`. It makes the verdict `needs-attention`. A
514
517
  worker's raw `needs-decision` or `blocked` stays a `question` until the
@@ -558,9 +561,11 @@ the same rule.
558
561
  [--text <text>] [--attach <file>]...` answers the same way, with the same
559
562
  checks.
560
563
  - **The pet.** The desktop pet shows a decision as its title only. A click
561
- opens the glass at the card and acks the item for the pet
562
- (`lobstah attention ack <key> --by pet`). The card stays in the glass until
563
- the decision is answered or withdrawn.
564
+ brings up the live helm's session through the pet's focus ladder: the
565
+ Claude desktop app for a desktop helm, the exact terminal tab for a terminal
566
+ helm. With no live helm, it opens the glass at the card. Either way it acks
567
+ the item for the pet (`lobstah attention ack <key> --by pet`). The card
568
+ stays in the glass until the decision is answered or withdrawn.
564
569
  - **Cull.** `lobstah cull` removes a decision once its answer is older than
565
570
  the retention window, with the `decision-answer` request and its files. A
566
571
  standing decision is never culled.
@@ -806,7 +811,15 @@ lobstah soak --name amber-gull # choose or change this trap's two-word name
806
811
  a VS Code extension URL of the form
807
812
  `vscode://anthropic.claude-code/open?session=<id>`, or a Codex task URL of
808
813
  the form `codex://threads/<task-id>`. It rejects all other schemes and
809
- malformed links. The glass checks a stored link again before rendering it.
814
+ malformed links. A link must also fit the session's surface, taken from
815
+ Claude Code's `CLAUDE_CODE_ENTRYPOINT` and the recorded window: a `vscode://`
816
+ link only in the VS Code extension (`claude-vscode`), a `claude://` link only
817
+ in the Claude desktop app's Code tab, a `codex://` link only in the Codex app.
818
+ A CLI session in a terminal, including the Claude desktop app's terminal
819
+ panel, has no link: soak ignores one and prints `link: ignored — <why>`. The
820
+ glass and `lobstah focus` skip a stored link that contradicts the trap's
821
+ window and focus the window instead. The glass checks a stored link again
822
+ before rendering it.
810
823
  `lobstah focus <trap>` accepts the trap id with or without `wt:` and reports
811
824
  the focus step or why it could not focus. It does not revive a signed-off trap.
812
825
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lobstah",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Harness-agnostic, token-efficient supervision framework for coding agents",
5
5
  "license": "MIT",
6
6
  "author": "aequitas labs LLC",