oh-my-opencode 5.0.0-beta.60 → 5.0.0-beta.63

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.
Files changed (227) hide show
  1. package/THIRD-PARTY-NOTICES.md +8 -6
  2. package/dist/cli/doctor/checks/browser-provider.d.ts +2 -0
  3. package/dist/cli/index.js +465 -348
  4. package/dist/cli-node/index.js +465 -348
  5. package/dist/config/schema/agent-names.d.ts +0 -1
  6. package/dist/config/schema/browser-automation.d.ts +0 -2
  7. package/dist/config/schema/oh-my-opencode-config.d.ts +0 -1
  8. package/dist/hooks/comment-checker/__fixtures__/download-probe.fixture.d.ts +1 -0
  9. package/dist/index.js +61 -535
  10. package/dist/oh-my-opencode.schema.json +0 -1
  11. package/dist/skills/ast-grep/SKILL.md +12 -0
  12. package/dist/skills/debugging/references/tools/playwright-cli.md +79 -161
  13. package/dist/skills/frontend/SKILL.md +1 -1
  14. package/dist/skills/frontend/references/design/aside.md +1 -1
  15. package/dist/skills/frontend/references/design/clone-from-url.md +1 -1
  16. package/dist/skills/frontend/scripts/perfection/lighthouse-audit.py +2 -2
  17. package/dist/skills/review-work/SKILL.md +1 -1
  18. package/dist/skills/ultimate-browsing/ATTRIBUTION.md +1 -82
  19. package/dist/skills/ultimate-browsing/SKILL.md +17 -29
  20. package/dist/skills/ultimate-browsing/engine/AGENTS.md +2 -1
  21. package/dist/skills/ultimate-browsing/engine/templates/package.json +2 -2
  22. package/dist/skills/ultimate-browsing/engine/templates/playwright_mobile_chrome.js +5 -2
  23. package/dist/skills/ultimate-browsing/engine/templates/playwright_real_chrome.js +8 -6
  24. package/dist/skills/ultimate-browsing/engine/tests/test_playwright_stealth.py +86 -0
  25. package/dist/skills/ultimate-browsing/engine/tests/test_playwright_templates.py +4 -7
  26. package/dist/skills/ultimate-browsing/references/chrome-stealth.md +86 -100
  27. package/dist/skills/ultimate-browsing/references/insane-search/README.md +9 -3
  28. package/dist/skills/ultimate-browsing/references/insane-search/playwright.md +16 -13
  29. package/dist/skills/ultimate-browsing/scripts/extract_cookies.py +3 -3
  30. package/dist/skills/ulw-execute/SKILL.md +1 -1
  31. package/dist/skills/ulw-plan/scripts/scaffold-plan.mjs +1 -1
  32. package/dist/skills/ulw-research/SKILL.md +3 -3
  33. package/dist/skills/visual-qa/AGENTS.md +1 -1
  34. package/dist/skills/visual-qa/SKILL.md +1 -1
  35. package/dist/skills/visual-qa/references/browser-setup.md +75 -0
  36. package/dist/tui.js +12 -5
  37. package/package.json +16 -23
  38. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  39. package/packages/omo-codex/plugin/components/bootstrap/dist/cli.js +6887 -249
  40. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  41. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  42. package/packages/omo-codex/plugin/components/bootstrap/src/setup.ts +7 -1
  43. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  44. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  45. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  46. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  47. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  48. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  49. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +2 -2
  50. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  51. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  52. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  53. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  54. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  55. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  56. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  57. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  58. package/packages/omo-codex/plugin/components/ultrawork/agents/plan.toml +2 -2
  59. package/packages/omo-codex/plugin/components/ultrawork/dist/cli.js +25 -15
  60. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  61. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  62. package/packages/omo-codex/plugin/components/ultrawork/src/directive-content.ts +1 -1
  63. package/packages/omo-codex/plugin/components/ulw-execute-continuation/directive.md +1 -1
  64. package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +1 -1
  65. package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
  66. package/packages/omo-codex/plugin/components/ulw-loop/CHANGELOG.md +15 -0
  67. package/packages/omo-codex/plugin/components/ulw-loop/directive.md +25 -15
  68. package/packages/omo-codex/plugin/components/ulw-loop/dist/checkpoint.js +5 -4
  69. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli.js +640 -327
  70. package/packages/omo-codex/plugin/components/ulw-loop/dist/domain-types.d.ts +5 -0
  71. package/packages/omo-codex/plugin/components/ulw-loop/dist/evidence.js +16 -12
  72. package/packages/omo-codex/plugin/components/ulw-loop/dist/ledger.d.ts +4 -0
  73. package/packages/omo-codex/plugin/components/ulw-loop/dist/ledger.js +34 -0
  74. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-commit.d.ts +19 -0
  75. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-commit.js +107 -0
  76. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-crud.d.ts +2 -1
  77. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-crud.js +48 -22
  78. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-io.d.ts +9 -7
  79. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-io.js +105 -114
  80. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-log.d.ts +15 -0
  81. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-log.js +86 -0
  82. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-missing-recovery.d.ts +5 -1
  83. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-missing-recovery.js +15 -3
  84. package/packages/omo-codex/plugin/components/ulw-loop/dist/review-blockers.js +3 -4
  85. package/packages/omo-codex/plugin/components/ulw-loop/dist/sdk/factory.js +29 -6
  86. package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-budget-io.d.ts +5 -0
  87. package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-budget-io.js +67 -0
  88. package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-guard.d.ts +1 -0
  89. package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-guard.js +29 -66
  90. package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-role-guard.d.ts +2 -0
  91. package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-role-guard.js +22 -0
  92. package/packages/omo-codex/plugin/components/ulw-loop/dist/state-lock.d.ts +11 -1
  93. package/packages/omo-codex/plugin/components/ulw-loop/dist/state-lock.js +120 -88
  94. package/packages/omo-codex/plugin/components/ulw-loop/dist/status-next-actions.d.ts +3 -2
  95. package/packages/omo-codex/plugin/components/ulw-loop/dist/status-next-actions.js +18 -10
  96. package/packages/omo-codex/plugin/components/ulw-loop/dist/steering-batch.js +4 -4
  97. package/packages/omo-codex/plugin/components/ulw-loop/dist/steering.js +5 -5
  98. package/packages/omo-codex/plugin/components/ulw-loop/dist/stop-resume-hook.js +8 -17
  99. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +7 -7
  100. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  101. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md +2 -2
  102. package/packages/omo-codex/plugin/components/ulw-loop/src/checkpoint.ts +5 -4
  103. package/packages/omo-codex/plugin/components/ulw-loop/src/domain-types.ts +5 -0
  104. package/packages/omo-codex/plugin/components/ulw-loop/src/evidence.ts +16 -16
  105. package/packages/omo-codex/plugin/components/ulw-loop/src/ledger.ts +33 -0
  106. package/packages/omo-codex/plugin/components/ulw-loop/src/plan-commit.ts +124 -0
  107. package/packages/omo-codex/plugin/components/ulw-loop/src/plan-crud.ts +58 -51
  108. package/packages/omo-codex/plugin/components/ulw-loop/src/plan-io.ts +126 -140
  109. package/packages/omo-codex/plugin/components/ulw-loop/src/plan-log.ts +84 -0
  110. package/packages/omo-codex/plugin/components/ulw-loop/src/plan-missing-recovery.ts +26 -3
  111. package/packages/omo-codex/plugin/components/ulw-loop/src/review-blockers.ts +3 -3
  112. package/packages/omo-codex/plugin/components/ulw-loop/src/sdk/factory.ts +55 -6
  113. package/packages/omo-codex/plugin/components/ulw-loop/src/spawn-budget-io.ts +59 -0
  114. package/packages/omo-codex/plugin/components/ulw-loop/src/spawn-guard.ts +29 -62
  115. package/packages/omo-codex/plugin/components/ulw-loop/src/spawn-role-guard.ts +22 -0
  116. package/packages/omo-codex/plugin/components/ulw-loop/src/state-lock.ts +129 -111
  117. package/packages/omo-codex/plugin/components/ulw-loop/src/status-next-actions.ts +19 -10
  118. package/packages/omo-codex/plugin/components/ulw-loop/src/steering-batch.ts +4 -4
  119. package/packages/omo-codex/plugin/components/ulw-loop/src/steering.ts +5 -4
  120. package/packages/omo-codex/plugin/components/ulw-loop/src/stop-resume-hook.ts +8 -16
  121. package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/barrier.ts +13 -0
  122. package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/commit-child.ts +65 -0
  123. package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/commit-cli-qa.mjs +76 -0
  124. package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/commit-crash.ts +23 -0
  125. package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/commit-mutations.mjs +58 -0
  126. package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/commit-writer.ts +65 -0
  127. package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/lease-clock.ts +23 -0
  128. package/packages/omo-codex/plugin/components/ulw-loop/test/plan-commit-log.test.ts +252 -0
  129. package/packages/omo-codex/plugin/components/ulw-loop/test/plan-commit-recovery.test.ts +190 -0
  130. package/packages/omo-codex/plugin/components/ulw-loop/test/plan-io.test.ts +50 -35
  131. package/packages/omo-codex/plugin/components/ulw-loop/test/plan-log-newest.test.ts +72 -0
  132. package/packages/omo-codex/plugin/components/ulw-loop/test/sdk-completed-plan.test.ts +43 -0
  133. package/packages/omo-codex/plugin/components/ulw-loop/test/sdk-driver-states.test.ts +145 -0
  134. package/packages/omo-codex/plugin/components/ulw-loop/test/sdk-session-context.test.ts +21 -0
  135. package/packages/omo-codex/plugin/components/ulw-loop/test/spawn-guard.test.ts +7 -1
  136. package/packages/omo-codex/plugin/components/ulw-loop/test/spawn-role-matrix.test.ts +113 -0
  137. package/packages/omo-codex/plugin/components/ulw-loop/test/state-lock.test.ts +92 -0
  138. package/packages/omo-codex/plugin/components/ulw-loop/test/status-next-actions-surface.test.ts +133 -0
  139. package/packages/omo-codex/plugin/components/ulw-loop/test/steering-batch.test.ts +12 -12
  140. package/packages/omo-codex/plugin/components/ulw-loop/test/steering.test.ts +4 -1
  141. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  142. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  143. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  144. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  145. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  146. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  147. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  148. package/packages/omo-codex/plugin/hooks/post-tool-use-recording-spawn-admission.json +1 -1
  149. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  150. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  151. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  152. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  153. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  154. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  155. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  156. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
  157. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  158. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  159. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  160. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  161. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  162. package/packages/omo-codex/plugin/package-lock.json +12 -12
  163. package/packages/omo-codex/plugin/package.json +1 -1
  164. package/packages/omo-codex/plugin/scripts/sync-skills.d.mts +4 -0
  165. package/packages/omo-codex/plugin/scripts/sync-skills.mjs +18 -10
  166. package/packages/omo-codex/plugin/skills/ast-grep/SKILL.md +12 -0
  167. package/packages/omo-codex/plugin/skills/debugging/references/tools/playwright-cli.md +79 -161
  168. package/packages/omo-codex/plugin/skills/frontend/SKILL.md +1 -1
  169. package/packages/omo-codex/plugin/skills/frontend/references/design/aside.md +1 -1
  170. package/packages/omo-codex/plugin/skills/frontend/references/design/clone-from-url.md +1 -1
  171. package/packages/omo-codex/plugin/skills/frontend/scripts/perfection/lighthouse-audit.py +2 -2
  172. package/packages/omo-codex/plugin/skills/init-deep/SKILL.md +3 -3
  173. package/packages/omo-codex/plugin/skills/refactor/SKILL.md +3 -3
  174. package/packages/omo-codex/plugin/skills/remove-ai-slops/SKILL.md +3 -3
  175. package/packages/omo-codex/plugin/skills/review-work/SKILL.md +4 -4
  176. package/packages/omo-codex/plugin/skills/ultimate-browsing/ATTRIBUTION.md +1 -82
  177. package/packages/omo-codex/plugin/skills/ultimate-browsing/SKILL.md +17 -29
  178. package/packages/omo-codex/plugin/skills/ultimate-browsing/engine/AGENTS.md +2 -1
  179. package/packages/omo-codex/plugin/skills/ultimate-browsing/engine/templates/package.json +2 -2
  180. package/packages/omo-codex/plugin/skills/ultimate-browsing/engine/templates/playwright_mobile_chrome.js +5 -2
  181. package/packages/omo-codex/plugin/skills/ultimate-browsing/engine/templates/playwright_real_chrome.js +8 -6
  182. package/packages/omo-codex/plugin/skills/ultimate-browsing/engine/tests/test_playwright_stealth.py +86 -0
  183. package/packages/omo-codex/plugin/skills/ultimate-browsing/engine/tests/test_playwright_templates.py +4 -7
  184. package/packages/omo-codex/plugin/skills/ultimate-browsing/references/chrome-stealth.md +86 -100
  185. package/packages/omo-codex/plugin/skills/ultimate-browsing/references/insane-search/README.md +9 -3
  186. package/packages/omo-codex/plugin/skills/ultimate-browsing/references/insane-search/playwright.md +16 -13
  187. package/packages/omo-codex/plugin/skills/ultimate-browsing/scripts/extract_cookies.py +3 -3
  188. package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +25 -15
  189. package/packages/omo-codex/plugin/skills/ulw-execute/SKILL.md +4 -4
  190. package/packages/omo-codex/plugin/skills/ulw-loop/SKILL.md +3 -3
  191. package/packages/omo-codex/plugin/skills/ulw-loop/references/full-workflow.md +2 -2
  192. package/packages/omo-codex/plugin/skills/ulw-research/SKILL.md +6 -6
  193. package/packages/omo-codex/plugin/skills/visual-qa/AGENTS.md +1 -1
  194. package/packages/omo-codex/plugin/skills/visual-qa/SKILL.md +4 -4
  195. package/packages/omo-codex/plugin/skills/visual-qa/references/browser-setup.md +75 -0
  196. package/packages/omo-codex/plugin/test/bootstrap-default-role.test.mjs +66 -0
  197. package/packages/omo-codex/plugin/test/component-hook-contract-cases.mjs +13 -1
  198. package/packages/omo-codex/scripts/install-dist/install-local.mjs +7060 -417
  199. package/packages/prompts-core/prompts/ultrawork/codex.md +25 -15
  200. package/packages/shared-skills/skills/ast-grep/SKILL.md +12 -0
  201. package/packages/shared-skills/skills/debugging/references/tools/playwright-cli.md +79 -161
  202. package/packages/shared-skills/skills/frontend/SKILL.md +1 -1
  203. package/packages/shared-skills/skills/frontend/references/design/aside.md +1 -1
  204. package/packages/shared-skills/skills/frontend/references/design/clone-from-url.md +1 -1
  205. package/packages/shared-skills/skills/frontend/scripts/perfection/lighthouse-audit.py +2 -2
  206. package/packages/shared-skills/skills/review-work/SKILL.md +1 -1
  207. package/packages/shared-skills/skills/ultimate-browsing/ATTRIBUTION.md +1 -82
  208. package/packages/shared-skills/skills/ultimate-browsing/SKILL.md +17 -29
  209. package/packages/shared-skills/skills/ultimate-browsing/engine/AGENTS.md +2 -1
  210. package/packages/shared-skills/skills/ultimate-browsing/engine/templates/package.json +2 -2
  211. package/packages/shared-skills/skills/ultimate-browsing/engine/templates/playwright_mobile_chrome.js +5 -2
  212. package/packages/shared-skills/skills/ultimate-browsing/engine/templates/playwright_real_chrome.js +8 -6
  213. package/packages/shared-skills/skills/ultimate-browsing/engine/tests/test_playwright_stealth.py +86 -0
  214. package/packages/shared-skills/skills/ultimate-browsing/engine/tests/test_playwright_templates.py +4 -7
  215. package/packages/shared-skills/skills/ultimate-browsing/references/chrome-stealth.md +86 -100
  216. package/packages/shared-skills/skills/ultimate-browsing/references/insane-search/README.md +9 -3
  217. package/packages/shared-skills/skills/ultimate-browsing/references/insane-search/playwright.md +16 -13
  218. package/packages/shared-skills/skills/ultimate-browsing/scripts/extract_cookies.py +3 -3
  219. package/packages/shared-skills/skills/ulw-execute/SKILL.md +1 -1
  220. package/packages/shared-skills/skills/ulw-plan/scripts/scaffold-plan.mjs +1 -1
  221. package/packages/shared-skills/skills/ulw-research/SKILL.md +3 -3
  222. package/packages/shared-skills/skills/visual-qa/AGENTS.md +1 -1
  223. package/packages/shared-skills/skills/visual-qa/SKILL.md +1 -1
  224. package/packages/shared-skills/skills/visual-qa/references/browser-setup.md +75 -0
  225. package/dist/skills/visual-qa/references/agent-browser-setup.md +0 -45
  226. package/packages/omo-codex/plugin/skills/visual-qa/references/agent-browser-setup.md +0 -45
  227. package/packages/shared-skills/skills/visual-qa/references/agent-browser-setup.md +0 -45
@@ -0,0 +1,59 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+
5
+ export function readAdmissionBreaker(sessionId: string): string | null {
6
+ const dataDir = process.env["PLUGIN_DATA"];
7
+ if (typeof dataDir !== "string") return null;
8
+ try {
9
+ const value: unknown = JSON.parse(readFileSync(join(dataDir, "spawn-breaker", `${sessionId}.json`), "utf8"));
10
+ return typeof value === "object" && value !== null && "reason" in value && typeof value.reason === "string"
11
+ ? value.reason
12
+ : "capacity limit";
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+
18
+ // These hook budget files are not plan/audit state and remain outside the log.
19
+ export function atomicWriteJson(targetPath: string, data: unknown): void {
20
+ const tmp = join(dirname(targetPath), `.tmp-${randomBytes(6).toString("hex")}`);
21
+ writeFileSync(tmp, JSON.stringify(data));
22
+ renameSync(tmp, targetPath);
23
+ }
24
+ export function isNonEmptyFile(path: string): boolean {
25
+ try {
26
+ return existsSync(path) && statSync(path).size > 0;
27
+ } catch (error) {
28
+ if (error instanceof Error) return false;
29
+ throw error;
30
+ }
31
+ }
32
+ export function readCount(counterPath: string): number {
33
+ try {
34
+ const parsed: unknown = JSON.parse(readFileSync(counterPath, "utf8"));
35
+ return typeof parsed === "object" &&
36
+ parsed !== null &&
37
+ "count" in parsed &&
38
+ typeof parsed.count === "number" &&
39
+ parsed.count >= 0
40
+ ? parsed.count
41
+ : 0;
42
+ } catch (error) {
43
+ if (error instanceof Error) return 0;
44
+ throw error;
45
+ }
46
+ }
47
+ export function readCounts(counterPath: string): Record<string, number> {
48
+ try {
49
+ const parsed: unknown = JSON.parse(readFileSync(counterPath, "utf8"));
50
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
51
+ const counts: Record<string, number> = {};
52
+ for (const [key, value] of Object.entries(parsed))
53
+ if (typeof value === "number" && value >= 0) counts[key] = value;
54
+ return counts;
55
+ } catch (error) {
56
+ if (error instanceof Error) return {};
57
+ throw error;
58
+ }
59
+ }
@@ -1,11 +1,13 @@
1
- import { randomBytes } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
1
+ import { mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
4
3
 
5
4
  import type { PreToolUsePayload } from "./codex-hook.js";
6
5
  import { parsePreToolUsePayload } from "./codex-hook.js";
7
6
  import { isFinalRunCompletionCandidate } from "./goal-status.js";
8
7
  import { ulwLoopAttemptEvidenceDir, ulwLoopDir, ulwLoopStateLockPath } from "./paths.js";
8
+ import { readUlwLoopPlanSync } from "./plan-io.js";
9
+ import { atomicWriteJson, isNonEmptyFile, readAdmissionBreaker, readCount, readCounts } from "./spawn-budget-io.js";
10
+ import { spawnRoleDenial } from "./spawn-role-guard.js";
9
11
  import { isStateLockTimeout, type StateLockOptions, withStateLockSync } from "./state-lock.js";
10
12
  import {
11
13
  GATE_REVIEWER_AGENT_NAMES,
@@ -18,7 +20,12 @@ import type { UlwLoopPlan } from "./types.js";
18
20
  // spawn_agent = v1; collaborationspawn_agent = the delimiter-free flattened v2
19
21
  // hook token from codex-rs hook_names.rs; collaboration.spawn_agent = the
20
22
  // dotted token observed live in the task-1 probe (hook-tool-tokens.txt).
21
- const SPAWN_TOOL_TOKENS = new Set(["spawn_agent", "collaborationspawn_agent", "collaboration.spawn_agent"]);
23
+ const SPAWN_TOOL_TOKENS = new Set([
24
+ "spawn_agent",
25
+ "multi_agent_v1.spawn_agent",
26
+ "collaborationspawn_agent",
27
+ "collaboration.spawn_agent",
28
+ ]);
22
29
  export const DEFAULT_FANOUT_LIMIT = 24;
23
30
  const DEFAULT_REVIEW_SPAWN_LIMIT = 3;
24
31
  const GATE_MESSAGE_PATTERN = /lazycodex-gate-reviewer|omo-senpi-gate-reviewer|final gate review/i;
@@ -34,6 +41,15 @@ export interface SpawnGuardOptions {
34
41
  }
35
42
 
36
43
  export function applySpawnGuards(payload: PreToolUsePayload, options: SpawnGuardOptions = {}): string {
44
+ if (payload.hook_event_name !== "PreToolUse" || !SPAWN_TOOL_TOKENS.has(payload.tool_name)) return "";
45
+ if (resolveToolkitSurface() === "lazycodex") {
46
+ const reason = spawnRoleDenial(payload.tool_input);
47
+ if (reason !== null) return deny(reason);
48
+ }
49
+ return applySpawnBudgetGuards(payload, options);
50
+ }
51
+
52
+ export function applySpawnBudgetGuards(payload: PreToolUsePayload, options: SpawnGuardOptions = {}): string {
37
53
  if (payload.hook_event_name !== "PreToolUse" || !SPAWN_TOOL_TOKENS.has(payload.tool_name)) return "";
38
54
  const breaker = readAdmissionBreaker(payload.session_id);
39
55
  if (breaker !== null)
@@ -42,7 +58,7 @@ export function applySpawnGuards(payload: PreToolUsePayload, options: SpawnGuard
42
58
  );
43
59
  const scope = { sessionId: payload.session_id } as const;
44
60
  const stateDir = ulwLoopDir(payload.cwd, scope);
45
- const plan = readPlan(join(stateDir, "goals.json"));
61
+ const plan = readPlan(payload.cwd, payload.session_id);
46
62
  if (plan === null) return "";
47
63
  const lockOptions: StateLockOptions =
48
64
  options.lockTimeoutMs === undefined ? {} : { timeoutMs: options.lockTimeoutMs };
@@ -110,11 +126,14 @@ export async function runSpawnGuardCli(stdin: NodeJS.ReadableStream, stdout: Nod
110
126
  const chunks: Buffer[] = [];
111
127
  for await (const chunk of stdin) chunks.push(Buffer.from(chunk));
112
128
  const payload = parsePreToolUsePayload(Buffer.concat(chunks).toString("utf8"));
113
- if (payload === null) return;
129
+ if (payload === null) {
130
+ stdout.write(deny("LazyCodex spawn guard received an invalid hook payload; role routing was not verified."));
131
+ return;
132
+ }
114
133
  const output = applySpawnGuards(payload);
115
134
  if (output.length > 0) stdout.write(output);
116
135
  } catch (error) {
117
- if (error instanceof Error) return;
136
+ stdout.write(deny(`LazyCodex spawn guard failed: ${error instanceof Error ? error.message : String(error)}`));
118
137
  }
119
138
  }
120
139
 
@@ -130,6 +149,7 @@ function peekFanOutBudget(stateDir: string): string | null {
130
149
  return `ulw-loop spawn fan-out cap reached (${count}/${limit}). Consolidate work into the agents already running, or raise OMO_SPAWN_FANOUT_LIMIT if this volume is intentional.`;
131
150
  }
132
151
 
152
+ // Hook budget counters are exempt from plan/audit commits.
133
153
  // Per-session spawn counter; depth/lineage tracking is descoped — this is a
134
154
  // total-volume backstop against fan-out explosions, not a recursion tracker.
135
155
  function consumeFanOutBudget(stateDir: string): string | null {
@@ -227,12 +247,6 @@ function activeSurfaceReviewerAlias(reviewer: string): string {
227
247
  return reviewer;
228
248
  }
229
249
 
230
- function atomicWriteJson(targetPath: string, data: unknown): void {
231
- const tmp = join(dirname(targetPath), `.tmp-${randomBytes(6).toString("hex")}`);
232
- writeFileSync(tmp, JSON.stringify(data));
233
- renameSync(tmp, targetPath);
234
- }
235
-
236
250
  function deny(reason: string): string {
237
251
  return `${JSON.stringify({
238
252
  hookSpecificOutput: {
@@ -244,19 +258,6 @@ function deny(reason: string): string {
244
258
  })}\n`;
245
259
  }
246
260
 
247
- function readAdmissionBreaker(sessionId: string): string | null {
248
- const dataDir = process.env["PLUGIN_DATA"];
249
- if (typeof dataDir !== "string") return null;
250
- try {
251
- const value = JSON.parse(readFileSync(join(dataDir, "spawn-breaker", `${sessionId}.json`), "utf8")) as {
252
- reason?: unknown;
253
- };
254
- return typeof value.reason === "string" ? value.reason : "capacity limit";
255
- } catch {
256
- return null;
257
- }
258
- }
259
-
260
261
  function fanOutLimit(): number {
261
262
  const raw = process.env["OMO_SPAWN_FANOUT_LIMIT"];
262
263
  if (raw === undefined) return DEFAULT_FANOUT_LIMIT;
@@ -271,43 +272,9 @@ function reviewSpawnLimit(): number {
271
272
  return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_REVIEW_SPAWN_LIMIT;
272
273
  }
273
274
 
274
- function isNonEmptyFile(path: string): boolean {
275
- try {
276
- return existsSync(path) && statSync(path).size > 0;
277
- } catch (error) {
278
- if (error instanceof Error) return false;
279
- throw error;
280
- }
281
- }
282
-
283
- function readCount(counterPath: string): number {
284
- try {
285
- const parsed = JSON.parse(readFileSync(counterPath, "utf8")) as Record<string, unknown>;
286
- return typeof parsed["count"] === "number" && parsed["count"] >= 0 ? parsed["count"] : 0;
287
- } catch (error) {
288
- if (error instanceof Error) return 0;
289
- throw error;
290
- }
291
- }
292
-
293
- function readCounts(counterPath: string): Record<string, number> {
294
- try {
295
- const parsed: unknown = JSON.parse(readFileSync(counterPath, "utf8"));
296
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
297
- const counts: Record<string, number> = {};
298
- for (const [key, value] of Object.entries(parsed)) {
299
- if (typeof value === "number" && value >= 0) counts[key] = value;
300
- }
301
- return counts;
302
- } catch (error) {
303
- if (error instanceof Error) return {};
304
- throw error;
305
- }
306
- }
307
-
308
- function readPlan(goalsPath: string): UlwLoopPlan | null {
275
+ function readPlan(repoRoot: string, sessionId: string): UlwLoopPlan | null {
309
276
  try {
310
- return JSON.parse(readFileSync(goalsPath, "utf8")) as UlwLoopPlan;
277
+ return readUlwLoopPlanSync(repoRoot, { sessionId });
311
278
  } catch (error) {
312
279
  if (error instanceof Error) return null;
313
280
  throw error;
@@ -0,0 +1,22 @@
1
+ // Explicit selectors, pinned by a test to the bundled role TOMLs. The generated
2
+ // default is a non-fork fallback, not a selector: a user can opt out of owning it.
3
+ export const LAZYCODEX_SPAWN_ROLES: ReadonlySet<string> = new Set([
4
+ "explorer",
5
+ "lazycodex-clone-fidelity-reviewer",
6
+ "lazycodex-code-reviewer",
7
+ "lazycodex-gate-reviewer",
8
+ "lazycodex-qa-executor",
9
+ "lazycodex-worker-high",
10
+ "lazycodex-worker-low",
11
+ "lazycodex-worker-medium",
12
+ "librarian",
13
+ "metis",
14
+ "momus",
15
+ "plan",
16
+ ]);
17
+
18
+ export function spawnRoleDenial(input: unknown): string | null {
19
+ const role = typeof input === "object" && input !== null && "agent_type" in input ? input.agent_type : undefined;
20
+ if (typeof role === "string" && LAZYCODEX_SPAWN_ROLES.has(role)) return null;
21
+ return `LazyCodex requires an explicit registered agent_type: ${[...LAZYCODEX_SPAWN_ROLES].join(", ")}. Received ${JSON.stringify(role) ?? "no agent_type"}. Use the matching role and fork_turns: "none" (V2) or fork_context: false (V1), unless full history is deliberately required. The hook cannot see the tool schema; if agent_type is unavailable, stop and report incompatible role routing rather than spawning a generic agent. Describing a role in message does not select its TOML.`;
22
+ }
@@ -1,120 +1,159 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { closeSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeSync } from "node:fs";
2
+ import { closeSync, ftruncateSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
- import { setTimeout as sleep } from "node:timers/promises";
5
-
6
4
  import { UlwLoopError } from "./types.js";
7
5
 
8
- // Cross-process exclusive lock for one ulw-loop state directory. Every CLI
9
- // invocation is its own process, so the in-process promise chain in plan-io
10
- // serializes nothing across them; this file-level lock is what makes the
11
- // read-modify-write of goals.json (and the counters next to it) atomic.
12
- //
13
- // Protocol: the lock is a file created with O_EXCL whose body records the
14
- // owner (pid + a per-acquisition token). A waiter reclaims it only when the
15
- // owner is provably gone (pid dead) or the body never became a record within
16
- // `staleMs` (a creator that died mid-write); a live owner is never reclaimed by
17
- // age alone, because overlapping two bodies is exactly the lost update this lock
18
- // exists to prevent. Waiters back off and fail closed at `timeoutMs`. Release
19
- // unlinks only a lock that still carries the releaser's own token.
20
-
6
+ // O_EXCL establishes ownership. Async holders keep the fd open and refresh a
7
+ // lease through that fd, never through the pathname: a reclaimed inode is invisible.
8
+ // Sync/legacy holders are lease-less and only dead pids retire their records.
9
+ // Expiry permits overlapping bodies; immutable revision publication fences them.
10
+ // Reclaim's reread/unlink is not atomic. On Windows an open inode may not unlink:
11
+ // treat that refusal as a live owner and fail closed. Release checks its token.
21
12
  export const ULW_LOOP_LOCK_TIMEOUT_CODE = "ULW_LOOP_LOCK_TIMEOUT";
22
-
13
+ export interface StateLockClock {
14
+ readonly now: () => number;
15
+ readonly schedule: (fn: () => void, ms: number) => { unref(): void; cancel(): void };
16
+ }
23
17
  export interface StateLockOptions {
24
18
  readonly timeoutMs?: number;
25
19
  readonly staleMs?: number;
20
+ readonly leaseMs?: number;
21
+ readonly heartbeatMs?: number;
22
+ readonly clock?: StateLockClock;
26
23
  }
27
-
28
24
  interface LockRecord {
29
25
  readonly pid: number;
30
26
  readonly createdAt: string;
31
27
  readonly token: string;
28
+ leaseUntil?: number;
29
+ }
30
+ interface Holder {
31
+ readonly fd: number;
32
+ readonly record: LockRecord;
32
33
  }
33
-
34
34
  interface LockSnapshot {
35
35
  readonly raw: string;
36
36
  readonly record: LockRecord | null;
37
37
  readonly ageMs: number;
38
38
  }
39
-
40
- type AttemptOutcome = { readonly kind: "acquired"; readonly token: string } | { readonly kind: "retry" | "wait" };
41
-
39
+ type AttemptOutcome = { readonly kind: "acquired"; readonly holder: Holder } | { readonly kind: "retry" | "wait" };
42
40
  const DEFAULT_TIMEOUT_MS = 10_000;
43
41
  const DEFAULT_STALE_MS = 60_000;
44
- const MIN_DELAY_MS = 5;
45
- const MAX_DELAY_MS = 100;
42
+ const DEFAULT_LEASE_MS = 30_000;
46
43
  const SLEEP_CELL = new Int32Array(new SharedArrayBuffer(4));
44
+ const systemClock: StateLockClock = {
45
+ now: Date.now,
46
+ schedule(fn, ms) {
47
+ const timer = setTimeout(fn, ms);
48
+ return {
49
+ unref: () => {
50
+ timer.unref();
51
+ },
52
+ cancel: () => clearTimeout(timer),
53
+ };
54
+ },
55
+ };
47
56
 
48
57
  export async function withStateLock<T>(
49
58
  lockPath: string,
50
- fn: () => Promise<T>,
59
+ fn: (token: string) => Promise<T>,
51
60
  options: StateLockOptions = {},
52
61
  ): Promise<T> {
53
- const token = await acquireAsync(lockPath, options);
62
+ const holder = await acquireAsync(lockPath, options);
63
+ const clock = options.clock ?? systemClock;
64
+ let timer: ReturnType<StateLockClock["schedule"]> | undefined;
65
+ let heartbeatError: unknown;
66
+ const beat = () => {
67
+ try {
68
+ holder.record.leaseUntil = clock.now() + (options.leaseMs ?? DEFAULT_LEASE_MS);
69
+ writeRecord(holder);
70
+ schedule();
71
+ } catch (error) {
72
+ heartbeatError = error;
73
+ }
74
+ };
75
+ const schedule = () => {
76
+ if (options.heartbeatMs === 0) return;
77
+ timer = clock.schedule(beat, options.heartbeatMs ?? (options.leaseMs ?? DEFAULT_LEASE_MS) / 3);
78
+ timer.unref();
79
+ };
80
+ schedule();
54
81
  try {
55
- return await fn();
82
+ const result = await fn(holder.record.token);
83
+ if (heartbeatError !== undefined) throw heartbeatError;
84
+ return result;
56
85
  } finally {
57
- release(lockPath, token);
86
+ timer?.cancel();
87
+ closeSync(holder.fd);
88
+ release(lockPath, holder.record.token);
58
89
  }
59
90
  }
60
-
61
91
  export function withStateLockSync<T>(lockPath: string, fn: () => T, options: StateLockOptions = {}): T {
62
- const token = acquireSync(lockPath, options);
92
+ const holder = acquireSync(lockPath, options);
63
93
  try {
64
94
  return fn();
65
95
  } finally {
66
- release(lockPath, token);
96
+ closeSync(holder.fd);
97
+ release(lockPath, holder.record.token);
67
98
  }
68
99
  }
69
-
70
100
  export function isStateLockTimeout(error: unknown): error is UlwLoopError {
71
101
  return error instanceof UlwLoopError && error.code === ULW_LOOP_LOCK_TIMEOUT_CODE;
72
102
  }
73
-
74
- async function acquireAsync(lockPath: string, options: StateLockOptions): Promise<string> {
75
- const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
76
- const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
103
+ async function acquireAsync(lockPath: string, options: StateLockOptions): Promise<Holder> {
104
+ const clock = options.clock ?? systemClock;
105
+ const deadline = clock.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
77
106
  mkdirSync(dirname(lockPath), { recursive: true });
78
107
  for (let attempt = 0; ; ) {
79
- const outcome = attemptOnce(lockPath, staleMs);
80
- if (outcome.kind === "acquired") return outcome.token;
108
+ const outcome = attemptOnce(lockPath, options, clock.now(), options.leaseMs ?? DEFAULT_LEASE_MS);
109
+ if (outcome.kind === "acquired") return outcome.holder;
81
110
  if (outcome.kind === "retry") continue;
82
- if (Date.now() >= deadline) throw lockTimeout(lockPath, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
83
- await sleep(backoffMs(attempt));
111
+ if (clock.now() >= deadline) throw lockTimeout(lockPath, options);
112
+ await new Promise<void>((resolve) => clock.schedule(resolve, backoffMs(attempt)));
84
113
  attempt += 1;
85
114
  }
86
115
  }
87
-
88
- function acquireSync(lockPath: string, options: StateLockOptions): string {
116
+ function acquireSync(lockPath: string, options: StateLockOptions): Holder {
89
117
  const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
90
- const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
91
118
  mkdirSync(dirname(lockPath), { recursive: true });
92
119
  for (let attempt = 0; ; ) {
93
- const outcome = attemptOnce(lockPath, staleMs);
94
- if (outcome.kind === "acquired") return outcome.token;
120
+ const outcome = attemptOnce(lockPath, options, Date.now());
121
+ if (outcome.kind === "acquired") return outcome.holder;
95
122
  if (outcome.kind === "retry") continue;
96
- if (Date.now() >= deadline) throw lockTimeout(lockPath, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
123
+ if (Date.now() >= deadline) throw lockTimeout(lockPath, options);
97
124
  Atomics.wait(SLEEP_CELL, 0, 0, backoffMs(attempt));
98
125
  attempt += 1;
99
126
  }
100
127
  }
101
-
102
- // EINTR surfaces raw from macOS fs syscalls under some runtimes; it is a retry, never a verdict.
103
- function attemptOnce(lockPath: string, staleMs: number): AttemptOutcome {
128
+ function attemptOnce(lockPath: string, options: StateLockOptions, now: number, leaseMs?: number): AttemptOutcome {
104
129
  try {
105
- const token = tryCreate(lockPath);
106
- if (token !== null) return { kind: "acquired", token };
107
- const snapshot = readSnapshot(lockPath);
130
+ const holder = tryCreate(lockPath, now, leaseMs);
131
+ if (holder !== null) return { kind: "acquired", holder };
132
+ const snapshot = readSnapshot(lockPath, now);
108
133
  if (snapshot === null) return { kind: "retry" };
109
- if (isStale(snapshot, staleMs) && reclaim(lockPath, snapshot.raw)) return { kind: "retry" };
134
+ const record = snapshot.record;
135
+ const stale =
136
+ record === null
137
+ ? snapshot.ageMs > (options.staleMs ?? DEFAULT_STALE_MS)
138
+ : !isProcessAlive(record.pid) || (record.leaseUntil !== undefined && now > record.leaseUntil);
139
+ if (stale && reclaim(lockPath, snapshot.raw)) return { kind: "retry" };
110
140
  return { kind: "wait" };
111
141
  } catch (error) {
112
142
  if (hasCode(error, "EINTR")) return { kind: "wait" };
113
143
  throw error;
114
144
  }
115
145
  }
116
-
117
- function tryCreate(lockPath: string): string | null {
146
+ function writeRecord(holder: Holder): void {
147
+ const buf = Buffer.from(JSON.stringify(holder.record));
148
+ ftruncateSync(holder.fd, 0);
149
+ let offset = 0;
150
+ while (offset < buf.length) {
151
+ const written = writeSync(holder.fd, buf, offset, buf.length - offset, offset);
152
+ if (written === 0) throw new Error("State lock write made no progress.");
153
+ offset += written;
154
+ }
155
+ }
156
+ function tryCreate(lockPath: string, now: number, leaseMs?: number): Holder | null {
118
157
  let fd: number;
119
158
  try {
120
159
  fd = openSync(lockPath, "wx");
@@ -122,60 +161,54 @@ function tryCreate(lockPath: string): string | null {
122
161
  if (hasCode(error, "EEXIST")) return null;
123
162
  throw error;
124
163
  }
125
- const record: LockRecord = { pid: process.pid, createdAt: new Date().toISOString(), token: randomUUID() };
164
+ const record: LockRecord = {
165
+ pid: process.pid,
166
+ createdAt: new Date(now).toISOString(),
167
+ token: randomUUID(),
168
+ ...(leaseMs === undefined ? {} : { leaseUntil: now + leaseMs }),
169
+ };
170
+ const holder = { fd, record };
126
171
  try {
127
- writeSync(fd, JSON.stringify(record));
172
+ writeRecord(holder);
128
173
  } catch (error) {
129
174
  closeSync(fd);
130
- // A failed write (e.g. EINTR) must not leave a partial lock the owner never recorded;
131
- // otherwise later readers see a young ownerless lock and only age can retire it.
132
175
  try {
133
176
  unlinkSync(lockPath);
134
- } catch {
135
- // another process already reclaimed the partial file; leave it alone
177
+ } catch (cleanup) {
178
+ if (!hasCode(cleanup, "ENOENT")) throw cleanup;
136
179
  }
137
180
  throw error;
138
181
  }
139
- closeSync(fd);
140
- return record.token;
182
+ return holder;
141
183
  }
142
-
143
- function readSnapshot(lockPath: string): LockSnapshot | null {
184
+ function readSnapshot(lockPath: string, now: number): LockSnapshot | null {
144
185
  try {
145
186
  const raw = readFileSync(lockPath, "utf8");
146
- const ageMs = Date.now() - statSync(lockPath).mtimeMs;
147
- return { raw, record: parseRecord(raw), ageMs };
187
+ return { raw, record: parseRecord(raw), ageMs: now - statSync(lockPath).mtimeMs };
148
188
  } catch (error) {
149
189
  if (hasCode(error, "ENOENT")) return null;
150
190
  throw error;
151
191
  }
152
192
  }
153
-
154
193
  function parseRecord(raw: string): LockRecord | null {
155
194
  try {
156
- const parsed: unknown = JSON.parse(raw);
157
- if (typeof parsed !== "object" || parsed === null) return null;
158
- const record = parsed as Record<string, unknown>;
159
- const pid = record["pid"];
160
- const createdAt = record["createdAt"];
161
- const token = record["token"];
162
- if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0 || typeof createdAt !== "string") return null;
163
- if (typeof token !== "string" || token.length === 0) return null;
164
- return { pid, createdAt, token };
195
+ const record: unknown = JSON.parse(raw);
196
+ if (typeof record !== "object" || record === null) return null;
197
+ if (!("pid" in record) || typeof record.pid !== "number" || !Number.isInteger(record.pid) || record.pid <= 0)
198
+ return null;
199
+ if (!("createdAt" in record) || typeof record.createdAt !== "string") return null;
200
+ if (!("token" in record) || typeof record.token !== "string" || record.token.length === 0) return null;
201
+ return {
202
+ pid: record.pid,
203
+ createdAt: record.createdAt,
204
+ token: record.token,
205
+ ...("leaseUntil" in record && typeof record.leaseUntil === "number" ? { leaseUntil: record.leaseUntil } : {}),
206
+ };
165
207
  } catch (error) {
166
208
  if (error instanceof SyntaxError) return null;
167
209
  throw error;
168
210
  }
169
211
  }
170
-
171
- // A body that is not a record is a lock mid-write (or a foreign/legacy file); only
172
- // age retires it. A parsable record is stale only when its owner is dead: a live
173
- // owner running past staleMs is slow, not gone, and the waiter fails closed instead.
174
- function isStale(snapshot: LockSnapshot, staleMs: number): boolean {
175
- if (snapshot.record === null) return snapshot.ageMs > staleMs;
176
- return !isProcessAlive(snapshot.record.pid);
177
- }
178
-
179
212
  function isProcessAlive(pid: number): boolean {
180
213
  try {
181
214
  process.kill(pid, 0);
@@ -186,54 +219,39 @@ function isProcessAlive(pid: number): boolean {
186
219
  throw error;
187
220
  }
188
221
  }
189
-
190
- // Re-read right before unlinking so a lock that changed hands since the stale
191
- // verdict (a sibling waiter reclaimed and re-acquired it) is left alone.
192
222
  function reclaim(lockPath: string, expectedRaw: string): boolean {
193
- const current = readSnapshot(lockPath);
223
+ const current = readSnapshot(lockPath, Date.now());
194
224
  if (current === null) return true;
195
225
  if (current.raw !== expectedRaw) return false;
196
226
  try {
197
227
  unlinkSync(lockPath);
198
228
  } catch (error) {
229
+ if (hasCode(error, "EPERM") || hasCode(error, "EACCES")) return false;
199
230
  if (!hasCode(error, "ENOENT")) throw error;
200
231
  }
201
232
  return true;
202
233
  }
203
-
204
- // Only the acquisition that wrote this token may unlink: if the file now carries
205
- // another token, a waiter has legitimately taken over and its lock must stand.
206
234
  function release(lockPath: string, token: string): void {
207
- const current = readSnapshot(lockPath);
208
- if (current === null || current.record?.token !== token) return;
235
+ if (readSnapshot(lockPath, Date.now())?.record?.token !== token) return;
209
236
  try {
210
237
  unlinkSync(lockPath);
211
238
  } catch (error) {
212
239
  if (!hasCode(error, "ENOENT")) throw error;
213
240
  }
214
241
  }
215
-
216
242
  function backoffMs(attempt: number): number {
217
- const exponential = Math.min(MAX_DELAY_MS, MIN_DELAY_MS * 2 ** attempt);
218
- return exponential + Math.random() * MIN_DELAY_MS;
243
+ return Math.min(100, 5 * 2 ** attempt) + Math.random() * 5;
219
244
  }
220
-
221
- function lockTimeout(lockPath: string, timeoutMs: number): UlwLoopError {
222
- const holder = readSnapshot(lockPath)?.record;
223
- const owner = holder === undefined || holder === null ? "another process" : `pid ${holder.pid}`;
245
+ function lockTimeout(lockPath: string, options: StateLockOptions): UlwLoopError {
246
+ const holder = readSnapshot(lockPath, Date.now())?.record;
247
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
248
+ const owner = holder == null ? "another process" : `pid ${holder.pid}`;
224
249
  return new UlwLoopError(
225
- `ulw-loop state lock ${lockPath} is held by ${owner} for more than ${timeoutMs}ms; retry once that process finishes, or delete the lock file if that process is gone.`,
250
+ `ulw-loop state lock ${lockPath} is held by ${owner} for more than ${timeoutMs}ms. The lock owner is still alive. If a JS eval kernel was interrupted while writing, wait for its lease to expire (${options.leaseMs ?? DEFAULT_LEASE_MS} ms) or restart the owning senpi process; never delete a lock owned by a live process.`,
226
251
  ULW_LOOP_LOCK_TIMEOUT_CODE,
227
- {
228
- details: {
229
- lockPath,
230
- timeoutMs,
231
- ...(holder === undefined || holder === null ? {} : { holderPid: holder.pid }),
232
- },
233
- },
252
+ { details: { lockPath, timeoutMs, ...(holder == null ? {} : { holderPid: holder.pid }) } },
234
253
  );
235
254
  }
236
-
237
255
  function hasCode(error: unknown, code: string): boolean {
238
256
  return error instanceof Error && "code" in error && error.code === code;
239
257
  }