quiver-cli 0.8.0 → 1.0.0

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 (200) hide show
  1. package/README.md +110 -45
  2. package/bin/quiver-cli.mjs +3 -1
  3. package/dist/cli.js +546 -458
  4. package/package.json +2 -2
  5. package/template/.agents/AGENTS.md +4 -3
  6. package/template/.agents/config.json +7 -0
  7. package/template/.agents/plugins/opencode/rtk.ts +34 -0
  8. package/template/.agents/skills/agent-browser/SKILL.md +1 -0
  9. package/template/.agents/skills/apps/skybridge/SKILL.md +4 -0
  10. package/template/.agents/skills/design/impeccable/SKILL.md +36 -118
  11. package/template/.agents/skills/design/impeccable/reference/adapt.md +1 -0
  12. package/template/.agents/skills/design/impeccable/reference/adapt.native.md +58 -0
  13. package/template/.agents/skills/design/impeccable/reference/android.md +40 -0
  14. package/template/.agents/skills/design/impeccable/reference/animate.md +73 -188
  15. package/template/.agents/skills/design/impeccable/reference/audit.md +12 -9
  16. package/template/.agents/skills/design/impeccable/reference/audit.native.md +139 -0
  17. package/template/.agents/skills/design/impeccable/reference/bolder.md +19 -101
  18. package/template/.agents/skills/design/impeccable/reference/clarify.md +59 -253
  19. package/template/.agents/skills/design/impeccable/reference/colorize.md +51 -222
  20. package/template/.agents/skills/design/impeccable/reference/craft-floor.md +42 -0
  21. package/template/.agents/skills/design/impeccable/reference/craft.md +3 -121
  22. package/template/.agents/skills/design/impeccable/reference/critique.md +44 -23
  23. package/template/.agents/skills/design/impeccable/reference/degraded/asset-producer.md +90 -0
  24. package/template/.agents/skills/design/impeccable/reference/degraded/documenter.md +24 -0
  25. package/template/.agents/skills/design/impeccable/reference/degraded/finish-reviewer.md +37 -0
  26. package/template/.agents/skills/design/impeccable/reference/degraded/manual-edit-applier.md +92 -0
  27. package/template/.agents/skills/design/impeccable/reference/delight.md +47 -279
  28. package/template/.agents/skills/design/impeccable/reference/distill.md +2 -2
  29. package/template/.agents/skills/design/impeccable/reference/doctor.md +53 -0
  30. package/template/.agents/skills/design/impeccable/reference/document.md +60 -73
  31. package/template/.agents/skills/design/impeccable/reference/harden.md +1 -12
  32. package/template/.agents/skills/design/impeccable/reference/hooks.md +20 -5
  33. package/template/.agents/skills/design/impeccable/reference/init.md +72 -119
  34. package/template/.agents/skills/design/impeccable/reference/ios.md +45 -0
  35. package/template/.agents/skills/design/impeccable/reference/layout.md +54 -131
  36. package/template/.agents/skills/design/impeccable/reference/live-setup.md +102 -0
  37. package/template/.agents/skills/design/impeccable/reference/live.md +116 -511
  38. package/template/.agents/skills/design/impeccable/reference/new-work.md +105 -0
  39. package/template/.agents/skills/design/impeccable/reference/{product.md → operate.md} +6 -5
  40. package/template/.agents/skills/design/impeccable/reference/optimize.md +4 -4
  41. package/template/.agents/skills/design/impeccable/reference/overdrive.md +1 -4
  42. package/template/.agents/skills/design/impeccable/reference/polish.md +68 -212
  43. package/template/.agents/skills/design/impeccable/reference/quieter.md +3 -3
  44. package/template/.agents/skills/design/impeccable/reference/routing.md +18 -0
  45. package/template/.agents/skills/design/impeccable/reference/shape.md +38 -144
  46. package/template/.agents/skills/design/impeccable/reference/typeset.md +51 -250
  47. package/template/.agents/skills/design/impeccable/reference/visualize.md +47 -0
  48. package/template/.agents/skills/design/impeccable/scripts/command-metadata.json +1 -1
  49. package/template/.agents/skills/design/impeccable/scripts/concept-seed.mjs +558 -0
  50. package/template/.agents/skills/design/impeccable/scripts/context-signals.mjs +119 -10
  51. package/template/.agents/skills/design/impeccable/scripts/context.mjs +534 -45
  52. package/template/.agents/skills/design/impeccable/scripts/critique-storage.mjs +18 -47
  53. package/template/.agents/skills/design/impeccable/scripts/detector/browser/injected/index.mjs +96 -10
  54. package/template/.agents/skills/design/impeccable/scripts/detector/cli/main.mjs +174 -26
  55. package/template/.agents/skills/design/impeccable/scripts/detector/design-system.mjs +233 -0
  56. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns-browser.js +3348 -203
  57. package/template/.agents/skills/design/impeccable/scripts/detector/engines/browser/detect-url.mjs +102 -7
  58. package/template/.agents/skills/design/impeccable/scripts/detector/engines/regex/detect-text.mjs +297 -97
  59. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +187 -16
  60. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/detect-html.mjs +41 -11
  61. package/template/.agents/skills/design/impeccable/scripts/detector/findings.mjs +7 -1
  62. package/template/.agents/skills/design/impeccable/scripts/detector/node/file-system.mjs +16 -2
  63. package/template/.agents/skills/design/impeccable/scripts/detector/registry/antipatterns.mjs +207 -38
  64. package/template/.agents/skills/design/impeccable/scripts/detector/rules/checks.mjs +3082 -173
  65. package/template/.agents/skills/design/impeccable/scripts/detector/shared/constants.mjs +11 -0
  66. package/template/.agents/skills/design/impeccable/scripts/detector/shared/fonts.mjs +30 -0
  67. package/template/.agents/skills/design/impeccable/scripts/doctor.mjs +336 -0
  68. package/template/.agents/skills/design/impeccable/scripts/embed-prompt.mjs +133 -0
  69. package/template/.agents/skills/design/impeccable/scripts/generate-image.mjs +240 -0
  70. package/template/.agents/skills/design/impeccable/scripts/hook-admin.mjs +98 -18
  71. package/template/.agents/skills/design/impeccable/scripts/hook-before-edit.mjs +46 -6
  72. package/template/.agents/skills/design/impeccable/scripts/hook-lib.mjs +518 -50
  73. package/template/.agents/skills/design/impeccable/scripts/hook.mjs +25 -8
  74. package/template/.agents/skills/design/impeccable/scripts/lib/artifact-schema.mjs +93 -0
  75. package/template/.agents/skills/design/impeccable/scripts/lib/composition-catalog.mjs +200 -0
  76. package/template/.agents/skills/design/impeccable/scripts/lib/concept-catalog.mjs +357 -0
  77. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-config.mjs +27 -7
  78. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-paths.mjs +17 -8
  79. package/template/.agents/skills/design/impeccable/scripts/lib/provider.mjs +5 -0
  80. package/template/.agents/skills/design/impeccable/scripts/lib/roll-selection.mjs +362 -0
  81. package/template/.agents/skills/design/impeccable/scripts/lib/staleness-deep.mjs +457 -0
  82. package/template/.agents/skills/design/impeccable/scripts/lib/staleness-notice.mjs +169 -0
  83. package/template/.agents/skills/design/impeccable/scripts/lib/staleness.mjs +457 -0
  84. package/template/.agents/skills/design/impeccable/scripts/lib/surface-briefs.mjs +151 -0
  85. package/template/.agents/skills/design/impeccable/scripts/lib/target-slug.mjs +33 -0
  86. package/template/.agents/skills/design/impeccable/scripts/lib/template-extensions.mjs +146 -0
  87. package/template/.agents/skills/design/impeccable/scripts/live/accept-css.mjs +617 -0
  88. package/template/.agents/skills/design/impeccable/scripts/live/accept-verify.mjs +60 -0
  89. package/template/.agents/skills/design/impeccable/scripts/live/browser-script-parts.mjs +7 -1
  90. package/template/.agents/skills/design/impeccable/scripts/live/completion.mjs +10 -1
  91. package/template/.agents/skills/design/impeccable/scripts/live/event-validation.mjs +67 -5
  92. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/astro.mjs +47 -0
  93. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/detect-utils.mjs +73 -0
  94. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/index.mjs +143 -0
  95. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/journal.mjs +197 -0
  96. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/nextjs.mjs +49 -0
  97. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/nuxt.mjs +161 -0
  98. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/script-src.mjs +17 -0
  99. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/static-html.mjs +26 -0
  100. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/sveltekit.mjs +71 -0
  101. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/tag-strategy.mjs +247 -0
  102. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/tanstack-start.mjs +70 -0
  103. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/vite-generic.mjs +42 -0
  104. package/template/.agents/skills/design/impeccable/scripts/live/generation-preflight.mjs +149 -0
  105. package/template/.agents/skills/design/impeccable/scripts/live/instructions.mjs +142 -0
  106. package/template/.agents/skills/design/impeccable/scripts/live/poll-lanes.mjs +14 -0
  107. package/template/.agents/skills/design/impeccable/scripts/live/roots.mjs +508 -0
  108. package/template/.agents/skills/design/impeccable/scripts/live/session-store.mjs +324 -50
  109. package/template/.agents/skills/design/impeccable/scripts/live/source-lock.mjs +105 -0
  110. package/template/.agents/skills/design/impeccable/scripts/live/source-search.mjs +105 -0
  111. package/template/.agents/skills/design/impeccable/scripts/live/svelte-ast.mjs +961 -0
  112. package/template/.agents/skills/design/impeccable/scripts/live/svelte-component.mjs +588 -72
  113. package/template/.agents/skills/design/impeccable/scripts/live/sveltekit-adapter.mjs +59 -17
  114. package/template/.agents/skills/design/impeccable/scripts/live/tanstack-adapter.mjs +280 -0
  115. package/template/.agents/skills/design/impeccable/scripts/live/vocabulary.mjs +135 -0
  116. package/template/.agents/skills/design/impeccable/scripts/live-accept.mjs +210 -68
  117. package/template/.agents/skills/design/impeccable/scripts/live-browser.js +1562 -223
  118. package/template/.agents/skills/design/impeccable/scripts/live-commit-manual-edits.mjs +3 -0
  119. package/template/.agents/skills/design/impeccable/scripts/live-complete.mjs +33 -1
  120. package/template/.agents/skills/design/impeccable/scripts/live-inject.mjs +175 -255
  121. package/template/.agents/skills/design/impeccable/scripts/live-insert.mjs +26 -6
  122. package/template/.agents/skills/design/impeccable/scripts/live-manual-edit-evidence.mjs +6 -1
  123. package/template/.agents/skills/design/impeccable/scripts/live-poll.mjs +61 -16
  124. package/template/.agents/skills/design/impeccable/scripts/live-resume.mjs +39 -10
  125. package/template/.agents/skills/design/impeccable/scripts/live-server.mjs +573 -47
  126. package/template/.agents/skills/design/impeccable/scripts/live-status.mjs +17 -7
  127. package/template/.agents/skills/design/impeccable/scripts/live-wrap.mjs +124 -91
  128. package/template/.agents/skills/design/impeccable/scripts/live.mjs +88 -26
  129. package/template/.agents/skills/design/impeccable/scripts/palette.mjs +76 -81
  130. package/template/.agents/skills/design/impeccable/scripts/pin.mjs +18 -11
  131. package/template/.agents/skills/design/impeccable/scripts/serve-question.mjs +932 -0
  132. package/template/.agents/skills/design/impeccable/scripts/surface-brief.mjs +74 -0
  133. package/template/.agents/skills/design/shadcn/SKILL.md +46 -11
  134. package/template/.agents/skills/design/shadcn/cli.md +49 -16
  135. package/template/.agents/skills/design/shadcn/customization.md +14 -7
  136. package/template/.agents/skills/design/shadcn/evals/evals.json +30 -0
  137. package/template/.agents/skills/design/shadcn/mcp.md +27 -16
  138. package/template/.agents/skills/design/shadcn/registry.md +277 -0
  139. package/template/.agents/skills/design/shadcn/rules/chat.md +224 -0
  140. package/template/.agents/skills/design/shadcn/rules/composition.md +20 -2
  141. package/template/.agents/skills/design/shadcn/rules/styling.md +23 -0
  142. package/template/.agents/skills/find-skills/SKILL.md +2 -3
  143. package/template/.agents/skills/hono/SKILL.md +579 -0
  144. package/template/.agents/skills/integrations/langfuse/SKILL.md +10 -6
  145. package/template/.agents/skills/integrations/langfuse/references/ci-cd.md +41 -0
  146. package/template/.agents/skills/integrations/langfuse/references/cli.md +8 -0
  147. package/template/.agents/skills/integrations/langfuse/references/error-analysis.md +15 -27
  148. package/template/.agents/skills/integrations/langfuse/references/instrumentation.md +39 -52
  149. package/template/.agents/skills/integrations/langfuse/references/judge-calibration.md +5 -2
  150. package/template/.agents/skills/integrations/langfuse/references/prompt-engineering.md +35 -0
  151. package/template/.agents/skills/integrations/langfuse/references/prompt-migration.md +41 -196
  152. package/template/.agents/skills/integrations/langfuse/references/skill-feedback.md +3 -0
  153. package/template/.agents/skills/integrations/langfuse/references/trace-evaluator-upgrade.md +76 -0
  154. package/template/.agents/skills/integrations/langfuse/references/user-feedback.md +4 -0
  155. package/template/.agents/skills/integrations/langfuse/references/v4-project-migration.md +73 -0
  156. package/template/.agents/skills/supabase/CHANGELOG.md +71 -0
  157. package/template/.agents/skills/supabase/SKILL.md +145 -0
  158. package/template/.agents/skills/supabase/assets/feedback-issue-template.md +17 -0
  159. package/template/.agents/skills/supabase/references/skill-feedback.md +17 -0
  160. package/template/.agents/skills/supabase-postgres-best-practices/CHANGELOG.md +73 -0
  161. package/template/.agents/skills/supabase-postgres-best-practices/SKILL.md +64 -0
  162. package/template/.agents/skills/supabase-postgres-best-practices/references/_contributing.md +170 -0
  163. package/template/.agents/skills/supabase-postgres-best-practices/references/_sections.md +39 -0
  164. package/template/.agents/skills/supabase-postgres-best-practices/references/_template.md +34 -0
  165. package/template/.agents/skills/supabase-postgres-best-practices/references/advanced-full-text-search.md +55 -0
  166. package/template/.agents/skills/supabase-postgres-best-practices/references/advanced-jsonb-indexing.md +49 -0
  167. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-idle-timeout.md +46 -0
  168. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-limits.md +44 -0
  169. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-pooling.md +41 -0
  170. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-prepared-statements.md +46 -0
  171. package/template/.agents/skills/supabase-postgres-best-practices/references/data-batch-inserts.md +54 -0
  172. package/template/.agents/skills/supabase-postgres-best-practices/references/data-n-plus-one.md +53 -0
  173. package/template/.agents/skills/supabase-postgres-best-practices/references/data-pagination.md +50 -0
  174. package/template/.agents/skills/supabase-postgres-best-practices/references/data-upsert.md +50 -0
  175. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-advisory.md +56 -0
  176. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-deadlock-prevention.md +68 -0
  177. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-short-transactions.md +50 -0
  178. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-skip-locked.md +54 -0
  179. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-explain-analyze.md +45 -0
  180. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-pg-stat-statements.md +55 -0
  181. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-vacuum-analyze.md +55 -0
  182. package/template/.agents/skills/supabase-postgres-best-practices/references/query-composite-indexes.md +44 -0
  183. package/template/.agents/skills/supabase-postgres-best-practices/references/query-covering-indexes.md +40 -0
  184. package/template/.agents/skills/supabase-postgres-best-practices/references/query-index-types.md +48 -0
  185. package/template/.agents/skills/supabase-postgres-best-practices/references/query-missing-indexes.md +43 -0
  186. package/template/.agents/skills/supabase-postgres-best-practices/references/query-partial-indexes.md +45 -0
  187. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-constraints.md +80 -0
  188. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-data-types.md +46 -0
  189. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-foreign-key-indexes.md +59 -0
  190. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-lowercase-identifiers.md +55 -0
  191. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-partitioning.md +55 -0
  192. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-primary-keys.md +61 -0
  193. package/template/.agents/skills/supabase-postgres-best-practices/references/security-privileges.md +54 -0
  194. package/template/.agents/skills/supabase-postgres-best-practices/references/security-rls-basics.md +50 -0
  195. package/template/.agents/skills/supabase-postgres-best-practices/references/security-rls-performance.md +63 -0
  196. package/template/.agents/upstreams.json +34 -12
  197. package/template/.agents/skills/design/impeccable/reference/brand.md +0 -108
  198. package/template/.agents/skills/design/impeccable/reference/codex.md +0 -105
  199. package/template/.agents/skills/design/impeccable/reference/interaction-design.md +0 -189
  200. package/template/.agents/skills/integrations/langfuse/references/sdk-upgrade.md +0 -175
@@ -1,23 +1,39 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { getLegacyLiveSessionsDir, getLiveSessionsDir } from '../lib/impeccable-paths.mjs';
3
+ import { getLegacyLiveSessionsDir, getLiveSessionsDir, safeSessionId } from '../lib/impeccable-paths.mjs';
4
+ import { COMPLETED_SESSION_PHASES, GENERATION_FENCED_SESSION_PHASES } from './vocabulary.mjs';
4
5
 
5
- const COMPLETED_PHASES = new Set(['completed', 'discarded']);
6
+ const COMPLETED_PHASES = new Set(COMPLETED_SESSION_PHASES);
7
+ export const GENERATION_FENCED_PHASES = new Set(GENERATION_FENCED_SESSION_PHASES);
8
+
9
+ // The snapshot file carries two bookkeeping fields the snapshot itself does not
10
+ // own: how large the journal was when the snapshot was written, and the next
11
+ // sequence number. Both are stripped before a snapshot is handed to a caller.
12
+ // The byte count is what makes a cached snapshot verifiable — the journal is
13
+ // append-only, so a matching size means no event has landed since.
14
+ const META_JOURNAL_BYTES = '__journalBytes';
15
+ const META_NEXT_SEQ = '__nextSeq';
16
+
17
+ // TODO(revision-unification): `checkpointRevision`, `browserCheckpointRevision`,
18
+ // and `publicationCheckpointRevision` are three counters for two domains.
19
+ // `checkpointRevision` is a compatibility mirror of the browser counter kept for
20
+ // older readers. Collapsing them means changing what a resumed browser compares
21
+ // its local revision against, so it belongs in a pass that owns resume ordering,
22
+ // not in a caching change.
6
23
 
7
24
  export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
8
25
  const rootDir = getLiveSessionsDir(cwd);
9
26
  const legacyRootDir = getLegacyLiveSessionsDir(cwd);
10
27
  fs.mkdirSync(rootDir, { recursive: true });
11
- const snapshotCache = new Map();
12
28
 
13
- function loadCachedOrRebuild(id) {
14
- const cached = snapshotCache.get(id);
15
- if (cached) return cached;
16
- const journalPath = getReadableJournalPath(id);
17
- const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
18
- snapshotCache.set(id, rebuilt);
19
- return rebuilt;
20
- }
29
+ // Derived state per session, keyed by what the journal looked like when it was
30
+ // derived. Publisher/complete helpers append from other processes, so the key
31
+ // is the journal's own (path, size, mtime) rather than a trusted local write
32
+ // count: an append this process did not make invalidates the entry and the
33
+ // next read replays. Without the cache every append and every read replayed
34
+ // the whole journal, which made a long session quadratic in its own length.
35
+ /** @type {Map<string, { snapshot: object, nextSeq: number, journalPath: string, size: number, mtimeMs: number }>} */
36
+ const derived = new Map();
21
37
 
22
38
  function getReadableJournalPath(id) {
23
39
  const primary = getJournalPath(rootDir, id);
@@ -27,41 +43,116 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {})
27
43
  return primary;
28
44
  }
29
45
 
46
+ /**
47
+ * The current derived state for a session, from the in-memory cache when the
48
+ * journal has not moved, from the snapshot file when that file is provably
49
+ * current, and from a full replay otherwise.
50
+ */
51
+ function readState(id, { allowSnapshotFile = true } = {}) {
52
+ const journalPath = getReadableJournalPath(id);
53
+ const stat = statOrNull(journalPath);
54
+ const size = stat ? stat.size : -1;
55
+ const mtimeMs = stat ? stat.mtimeMs : -1;
56
+
57
+ const cached = derived.get(id);
58
+ if (cached && cached.journalPath === journalPath && cached.size === size && cached.mtimeMs === mtimeMs) {
59
+ return cached;
60
+ }
61
+
62
+ if (allowSnapshotFile && stat) {
63
+ const hydrated = readSnapshotFile(getSnapshotPath(rootDir, id), id, size);
64
+ if (hydrated) {
65
+ const entry = { ...hydrated, journalPath, size, mtimeMs };
66
+ derived.set(id, entry);
67
+ return entry;
68
+ }
69
+ }
70
+
71
+ const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
72
+ const entry = { snapshot: rebuilt.snapshot, nextSeq: rebuilt.nextSeq, journalPath, size, mtimeMs };
73
+ derived.set(id, entry);
74
+ return entry;
75
+ }
76
+
77
+ function persist(id, snapshot, nextSeq) {
78
+ const snapshotPath = getSnapshotPath(rootDir, id);
79
+ const journalPath = getReadableJournalPath(id);
80
+ const stat = statOrNull(journalPath);
81
+ writeSnapshot(snapshotPath, snapshot, { journalBytes: stat ? stat.size : -1, nextSeq });
82
+ derived.set(id, {
83
+ snapshot,
84
+ nextSeq,
85
+ journalPath,
86
+ size: stat ? stat.size : -1,
87
+ mtimeMs: stat ? stat.mtimeMs : -1,
88
+ });
89
+ }
90
+
30
91
  return {
31
92
  rootDir,
32
93
  legacyRootDir,
33
94
  appendEvent(event) {
34
95
  const normalized = normalizeEvent(event, sessionId);
35
96
  const journalPath = getJournalPath(rootDir, normalized.id);
36
- const snapshotPath = getSnapshotPath(rootDir, normalized.id);
37
97
  const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
38
98
  if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
39
99
  fs.copyFileSync(legacyJournalPath, journalPath);
100
+ // The readable path just moved from legacy to primary; anything derived
101
+ // against the old path describes a file this session no longer reads.
102
+ derived.delete(normalized.id);
40
103
  }
41
- const prior = loadCachedOrRebuild(normalized.id);
42
- const seq = prior.nextSeq;
104
+ // Reuse the derived state when the journal has not changed under us, and
105
+ // apply the new event on top of it. Correctness still comes from the
106
+ // journal: any append from another process invalidates the entry above
107
+ // and this replays before writing, so sequence numbers and phase fences
108
+ // are never taken from a stale copy.
109
+ const prior = readState(normalized.id);
43
110
  const entry = {
44
- seq,
111
+ seq: prior.nextSeq,
45
112
  id: normalized.id,
46
113
  type: normalized.type,
47
114
  ts: new Date().toISOString(),
48
115
  event: normalized,
49
116
  };
50
117
  fs.appendFileSync(journalPath, JSON.stringify(entry) + '\n');
51
- const next = applyEvent(prior.snapshot, entry, prior.diagnostics);
52
- snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
53
- writeSnapshot(snapshotPath, next);
118
+ const next = applyEvent(prior.snapshot, entry);
119
+ persist(normalized.id, next, prior.nextSeq + 1);
54
120
  return next;
55
121
  },
122
+ /**
123
+ * True when a journal exists for the id in either root. appendEvent
124
+ * CREATES a journal for any id it is handed, so callers that should only
125
+ * ever touch existing sessions (browser checkpoints, mount acks) check
126
+ * here first — otherwise a stale id from another project's browser
127
+ * storage materializes a ghost session in this store.
128
+ */
129
+ has(id) {
130
+ if (!id || typeof id !== 'string') return false;
131
+ return fs.existsSync(getJournalPath(rootDir, id))
132
+ || fs.existsSync(getJournalPath(legacyRootDir, id));
133
+ },
134
+ /**
135
+ * Read-only. `live-status` and `live-resume` call this against a session a
136
+ * running server owns; writing the snapshot file here made every read a
137
+ * write and let a reader's replay of a half-written journal land on disk.
138
+ * Snapshot files are written by appendEvent and by flush().
139
+ */
56
140
  getSnapshot(id = sessionId, opts = {}) {
57
141
  if (!id) throw new Error('session id required');
58
- const journalPath = getReadableJournalPath(id);
59
- const snapshotPath = getSnapshotPath(rootDir, id);
60
- const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
61
- snapshotCache.set(id, rebuilt);
62
- writeSnapshot(snapshotPath, rebuilt.snapshot);
63
- if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
64
- return rebuilt.snapshot;
142
+ const { snapshot } = readState(id);
143
+ if (!opts.includeCompleted && COMPLETED_PHASES.has(snapshot.phase)) return null;
144
+ return snapshot;
145
+ },
146
+ /**
147
+ * Write the snapshot file for a session without appending an event. The
148
+ * durable truth is the journal, so this only refreshes the read cache other
149
+ * processes use; callers that need the state itself should use getSnapshot.
150
+ */
151
+ flush(id = sessionId) {
152
+ if (!id) throw new Error('session id required');
153
+ const state = readState(id, { allowSnapshotFile: false });
154
+ persist(id, state.snapshot, state.nextSeq);
155
+ return state.snapshot;
65
156
  },
66
157
  listActiveSessions() {
67
158
  const ids = new Set();
@@ -71,6 +162,9 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {})
71
162
  if (name.endsWith('.jsonl')) ids.add(name.slice(0, -'.jsonl'.length));
72
163
  }
73
164
  }
165
+ // Each id goes through readState, so a session whose journal has not moved
166
+ // since it was last derived costs a stat and nothing more. The server calls
167
+ // this on every /status and on every SSE connect.
74
168
  return [...ids]
75
169
  .sort()
76
170
  .map((id) => this.getSnapshot(id))
@@ -79,6 +173,39 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {})
79
173
  };
80
174
  }
81
175
 
176
+ function statOrNull(filePath) {
177
+ try {
178
+ return fs.statSync(filePath);
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Hydrate derived state from a snapshot file, but only when it provably
186
+ * describes the journal as it stands right now. Anything short of an exact byte
187
+ * match on an append-only file means events landed after the snapshot was
188
+ * written, and the caller replays instead.
189
+ */
190
+ function readSnapshotFile(snapshotPath, id, journalBytes) {
191
+ let parsed;
192
+ try {
193
+ parsed = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8'));
194
+ } catch {
195
+ return null;
196
+ }
197
+ if (!parsed || typeof parsed !== 'object') return null;
198
+ if (parsed[META_JOURNAL_BYTES] !== journalBytes) return null;
199
+ if (!Number.isInteger(parsed[META_NEXT_SEQ])) return null;
200
+ const nextSeq = parsed[META_NEXT_SEQ];
201
+ delete parsed[META_JOURNAL_BYTES];
202
+ delete parsed[META_NEXT_SEQ];
203
+ // The journal owns identity; a snapshot file copied between session ids is
204
+ // not a reason to answer with the wrong id.
205
+ if (parsed.id !== id) return null;
206
+ return { snapshot: { ...baseSnapshot(id), ...parsed }, nextSeq };
207
+ }
208
+
82
209
  function normalizeEvent(event, fallbackId) {
83
210
  if (!event || typeof event !== 'object') throw new Error('event object required');
84
211
  const id = event.id || fallbackId;
@@ -95,11 +222,6 @@ function getSnapshotPath(rootDir, id) {
95
222
  return path.join(rootDir, safeSessionId(id) + '.snapshot.json');
96
223
  }
97
224
 
98
- function safeSessionId(id) {
99
- if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
100
- return id;
101
- }
102
-
103
225
  function baseSnapshot(id) {
104
226
  return {
105
227
  id,
@@ -116,15 +238,50 @@ function baseSnapshot(id) {
116
238
  pendingEvent: null,
117
239
  deliveryLease: null,
118
240
  checkpointRevision: 0,
241
+ browserCheckpointRevision: 0,
242
+ publicationCheckpointRevision: 0,
119
243
  activeOwner: null,
120
244
  sourceMarkers: {},
121
245
  fallbackMode: null,
246
+ generationPhase: null,
247
+ generationCompletedAt: null,
248
+ generationTimings: {},
249
+ variantPlan: null,
250
+ generationCanceled: false,
251
+ generationCanceledAt: null,
252
+ cancelReason: null,
122
253
  annotationArtifacts: [],
254
+ // Render truth. `arrivedVariants` says what the agent published; these say
255
+ // what the browser actually got on screen. They are kept alongside the
256
+ // published counters rather than replacing them so older readers keep
257
+ // working, but they are the only fields that answer "did the user ever see
258
+ // a variant".
259
+ mountedVariants: [],
260
+ mountFailures: [],
261
+ renderState: null,
123
262
  diagnostics: [],
124
263
  updatedAt: null,
125
264
  };
126
265
  }
127
266
 
267
+ // How many mount failures a session keeps. The card in the browser shows the
268
+ // newest one; the agent needs enough history to spot a variant that fails
269
+ // every republish, not the whole retry storm.
270
+ const MOUNT_FAILURE_HISTORY = 5;
271
+
272
+ /**
273
+ * `pending` = the agent published and nothing has acked yet, `mounted` = at
274
+ * least one variant reached the DOM, `failed` = the browser reported failures
275
+ * and nothing ever mounted. A single success outranks any number of failures:
276
+ * the user is looking at something.
277
+ */
278
+ function deriveRenderState(snapshot) {
279
+ if (snapshot.mountedVariants.length > 0) return 'mounted';
280
+ if (snapshot.mountFailures.length > 0) return 'failed';
281
+ if (snapshot.generationCompletedAt) return 'pending';
282
+ return null;
283
+ }
284
+
128
285
  function rebuildSnapshotFromJournal(journalPath, id) {
129
286
  let snapshot = baseSnapshot(id);
130
287
  const diagnostics = [];
@@ -152,21 +309,22 @@ function rebuildSnapshotFromJournal(journalPath, id) {
152
309
  return { snapshot, diagnostics, nextSeq };
153
310
  }
154
311
 
155
- function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
312
+ function applyEvent(snapshot, entry) {
156
313
  const event = entry.event || entry;
157
314
  const next = {
158
315
  ...snapshot,
159
316
  paramValues: { ...(snapshot.paramValues || {}) },
160
317
  sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
318
+ generationTimings: { ...(snapshot.generationTimings || {}) },
319
+ variantPlan: snapshot.variantPlan || null,
161
320
  annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
321
+ mountedVariants: [...(snapshot.mountedVariants || [])],
322
+ mountFailures: [...(snapshot.mountFailures || [])],
323
+ renderState: snapshot.renderState ?? null,
162
324
  diagnostics: [...(snapshot.diagnostics || [])],
163
325
  updatedAt: entry.ts || new Date().toISOString(),
164
326
  };
165
327
 
166
- if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
167
- next.diagnostics = [...inheritedDiagnostics];
168
- }
169
-
170
328
  switch (event.type) {
171
329
  case 'generate':
172
330
  next.phase = 'generate_requested';
@@ -174,11 +332,53 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
174
332
  next.expectedVariants = event.count ?? next.expectedVariants;
175
333
  next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
176
334
  next.pendingEvent = toPendingEvent(event);
335
+ next.variantPlan = null;
336
+ // A new cycle publishes new files: everything the browser told us about
337
+ // the previous batch is now about modules that no longer exist.
338
+ next.mountedVariants = [];
339
+ next.mountFailures = [];
340
+ next.renderState = null;
177
341
  if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
178
342
  break;
343
+ case 'variant_plan':
344
+ if (!next.generationCanceled && !GENERATION_FENCED_PHASES.has(next.phase)) {
345
+ next.variantPlan = event.plan ?? next.variantPlan;
346
+ }
347
+ break;
348
+ case 'detector_waivers':
349
+ if (!next.generationCanceled && !GENERATION_FENCED_PHASES.has(next.phase)) {
350
+ next.detectorWaivers = [
351
+ ...(next.detectorWaivers || []),
352
+ ...(Array.isArray(event.waivers) ? event.waivers : []),
353
+ ];
354
+ }
355
+ break;
356
+ case 'agent_phase':
357
+ next.generationPhase = event.phase ?? next.generationPhase;
358
+ if (event.phase) {
359
+ next.generationTimings[event.phase] = {
360
+ at: event.at ?? (Date.parse(entry.ts || '') || null),
361
+ durationMs: event.durationMs ?? null,
362
+ };
363
+ }
364
+ break;
179
365
  case 'variants_ready':
180
366
  case 'agent_done':
367
+ if ((next.generationCanceled || GENERATION_FENCED_PHASES.has(next.phase))
368
+ && !(event.type === 'agent_done' && event.carbonize === true && next.phase === 'accept_requested')) {
369
+ next.diagnostics.push({
370
+ error: 'late_generation_event_ignored',
371
+ type: event.type,
372
+ phase: next.phase,
373
+ });
374
+ break;
375
+ }
181
376
  next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
377
+ // Durable completion marker: later browser checkpoints (a resumed page
378
+ // reporting phase "generating") regress `phase`, but generation staying
379
+ // finished is monotone — the live server keys missed-`done` redelivery
380
+ // on this field.
381
+ next.generationCompletedAt = event.at ?? (Date.parse(entry.ts || '') || Date.now());
182
382
  next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
183
383
  next.previewFile = event.previewFile ?? next.previewFile;
184
384
  next.previewMode = event.previewMode ?? next.previewMode;
@@ -192,29 +392,85 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
192
392
  message: 'Accepted variant still has carbonize markers that must be folded into source CSS.',
193
393
  });
194
394
  }
395
+ next.renderState = deriveRenderState(next);
195
396
  break;
397
+ case 'variant_mounted': {
398
+ const variant = Number(event.variant);
399
+ if (!Number.isInteger(variant) || variant < 1) {
400
+ next.diagnostics.push({ error: 'malformed_mount_ack', type: event.type, variant: event.variant ?? null });
401
+ break;
402
+ }
403
+ if (!next.mountedVariants.includes(variant)) {
404
+ next.mountedVariants = [...next.mountedVariants, variant].sort((a, b) => a - b);
405
+ }
406
+ next.renderState = deriveRenderState(next);
407
+ break;
408
+ }
409
+ case 'variant_mount_failed': {
410
+ const variant = Number(event.variant);
411
+ if (!Number.isInteger(variant) || variant < 1) {
412
+ next.diagnostics.push({ error: 'malformed_mount_ack', type: event.type, variant: event.variant ?? null });
413
+ break;
414
+ }
415
+ next.mountFailures = [
416
+ ...next.mountFailures,
417
+ {
418
+ variant,
419
+ url: typeof event.url === 'string' ? event.url : null,
420
+ error: typeof event.error === 'string' ? event.error : null,
421
+ at: event.at ?? (Date.parse(entry.ts || '') || Date.now()),
422
+ },
423
+ ].slice(-MOUNT_FAILURE_HISTORY);
424
+ next.renderState = deriveRenderState(next);
425
+ // The failure needs an agent reply, so it must survive a helper
426
+ // restart the same way a generate does. Never clobber a still-pending
427
+ // generate: a progressive publish can fail an early mount while the
428
+ // generate event itself is still leased.
429
+ if (!next.pendingEvent) {
430
+ next.pendingEvent = toPendingEvent(event);
431
+ }
432
+ break;
433
+ }
196
434
  case 'checkpoint':
197
- if (COMPLETED_PHASES.has(next.phase)) {
435
+ if (next.generationCanceled || GENERATION_FENCED_PHASES.has(next.phase)) {
198
436
  next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
199
437
  break;
200
438
  }
201
- if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
202
- next.phase = event.phase ?? next.phase;
203
- next.checkpointRevision = event.revision ?? next.checkpointRevision;
204
- next.activeOwner = event.owner ?? next.activeOwner;
205
- next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
206
- next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
207
- next.sourceFile = event.sourceFile ?? next.sourceFile;
208
- next.previewFile = event.previewFile ?? next.previewFile;
209
- next.previewMode = event.previewMode ?? next.previewMode;
210
- if (event.paramValues) next.paramValues = { ...event.paramValues };
211
- } else {
212
- next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
439
+ {
440
+ const revisionDomain = event.revisionDomain === 'publication'
441
+ || (event.reason === 'variants_progress' && !event.owner)
442
+ ? 'publication'
443
+ : 'browser';
444
+ const revisionField = revisionDomain === 'publication'
445
+ ? 'publicationCheckpointRevision'
446
+ : 'browserCheckpointRevision';
447
+ const currentRevision = next[revisionField]
448
+ ?? (revisionDomain === 'browser' ? next.checkpointRevision : 0)
449
+ ?? 0;
450
+ if ((event.revision ?? 0) >= currentRevision) {
451
+ next.phase = event.phase ?? next.phase;
452
+ next[revisionField] = event.revision ?? currentRevision;
453
+ if (revisionDomain === 'browser') {
454
+ next.checkpointRevision = event.revision ?? next.checkpointRevision;
455
+ next.activeOwner = event.owner ?? next.activeOwner;
456
+ }
457
+ next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
458
+ if (revisionDomain === 'browser') next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
459
+ next.sourceFile = event.sourceFile ?? next.sourceFile;
460
+ next.previewFile = event.previewFile ?? next.previewFile;
461
+ next.previewMode = event.previewMode ?? next.previewMode;
462
+ if (revisionDomain === 'browser' && event.paramValues) next.paramValues = { ...event.paramValues };
463
+ } else {
464
+ next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision, revisionDomain });
465
+ }
213
466
  }
214
467
  break;
215
468
  case 'accept':
216
469
  case 'accept_intent':
217
470
  next.phase = 'accept_requested';
471
+ next.generationCanceled = true;
472
+ next.generationCanceledAt = event.at ?? (Date.parse(entry.ts || '') || Date.now());
473
+ next.cancelReason = 'accept';
218
474
  next.visibleVariant = Number(event.variantId ?? next.visibleVariant);
219
475
  if (event.paramValues) next.paramValues = { ...event.paramValues };
220
476
  next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
@@ -232,6 +488,12 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
232
488
  next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
233
489
  next.pendingEvent = toPendingEvent(event);
234
490
  break;
491
+ case 'carbonize_cleanup':
492
+ next.phase = 'carbonize_cleanup_requested';
493
+ next.sourceFile = event.file ?? next.sourceFile;
494
+ next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
495
+ next.pendingEvent = toPendingEvent(event);
496
+ break;
235
497
  case 'steer_done':
236
498
  next.phase = 'steer_done';
237
499
  next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
@@ -243,6 +505,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
243
505
  break;
244
506
  case 'discard':
245
507
  next.phase = 'discard_requested';
508
+ next.generationCanceled = true;
509
+ next.generationCanceledAt = event.at ?? (Date.parse(entry.ts || '') || Date.now());
510
+ next.cancelReason = 'discard';
246
511
  next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
247
512
  next.pendingEvent = toPendingEvent(event);
248
513
  break;
@@ -260,6 +525,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
260
525
  next.pendingEvent = null;
261
526
  break;
262
527
  case 'agent_error':
528
+ if (next.generationCanceled && event.sourceEventType === 'generate') {
529
+ next.diagnostics.push({ error: 'late_generation_event_ignored', type: event.type, phase: next.phase });
530
+ break;
531
+ }
263
532
  next.phase = 'agent_error';
264
533
  next.pendingEventSeq = null;
265
534
  next.pendingEvent = null;
@@ -284,6 +553,11 @@ function upsertArtifact(artifacts, artifact) {
284
553
  }
285
554
  }
286
555
 
287
- function writeSnapshot(snapshotPath, snapshot) {
288
- fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
556
+ function writeSnapshot(snapshotPath, snapshot, meta) {
557
+ const payload = {
558
+ ...snapshot,
559
+ [META_JOURNAL_BYTES]: meta?.journalBytes ?? -1,
560
+ [META_NEXT_SEQ]: meta?.nextSeq ?? 1,
561
+ };
562
+ fs.writeFileSync(snapshotPath, JSON.stringify(payload, null, 2) + '\n');
289
563
  }
@@ -0,0 +1,105 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { getLiveDir, isLiveServerPidReachable } from '../lib/impeccable-paths.mjs';
5
+
6
+ // Only used to retire a lock whose contents we cannot read (empty or truncated
7
+ // by a crash mid-write). A readable lock's fate is decided by its owner's
8
+ // liveness instead, so a slow critical section is never swept.
9
+ const UNREADABLE_LOCK_STALE_MS = 60_000;
10
+
11
+ export function sourceLockPath(file, cwd = process.cwd()) {
12
+ const digest = createHash('sha256').update(path.resolve(cwd, file)).digest('hex').slice(0, 24);
13
+ return path.join(getLiveDir(cwd), 'locks', digest + '.lock');
14
+ }
15
+
16
+ export function withSourceLockSync(file, owner, fn, {
17
+ cwd = process.cwd(),
18
+ waitMs = 0,
19
+ retryMs = 5,
20
+ } = {}) {
21
+ const lockPath = sourceLockPath(file, cwd);
22
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
23
+ const deadline = Date.now() + Math.max(0, Number(waitMs) || 0);
24
+ // Identifies this acquisition specifically, so release can tell our own lock
25
+ // from a replacement that some other writer created.
26
+ const token = randomUUID();
27
+ let acquired = false;
28
+
29
+ while (!acquired) {
30
+ clearStaleLock(lockPath);
31
+ let fd;
32
+ try {
33
+ fd = fs.openSync(lockPath, 'wx');
34
+ fs.writeFileSync(fd, JSON.stringify({
35
+ owner,
36
+ token,
37
+ pid: process.pid,
38
+ at: Date.now(),
39
+ file: path.resolve(cwd, file),
40
+ }) + '\n');
41
+ acquired = true;
42
+ } catch (error) {
43
+ if (error?.code !== 'EEXIST') throw error;
44
+ if (Date.now() >= deadline) {
45
+ const locked = new Error('source_locked');
46
+ locked.code = 'SOURCE_LOCKED';
47
+ locked.lockPath = lockPath;
48
+ throw locked;
49
+ }
50
+ sleepSync(Math.max(1, Math.min(Number(retryMs) || 5, deadline - Date.now())));
51
+ } finally {
52
+ try { if (fd !== undefined) fs.closeSync(fd); } catch {}
53
+ }
54
+ }
55
+
56
+ try {
57
+ return fn();
58
+ } finally {
59
+ releaseOwnLock(lockPath, token);
60
+ }
61
+ }
62
+
63
+ function sleepSync(ms) {
64
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
65
+ }
66
+
67
+ function readLock(lockPath) {
68
+ try { return JSON.parse(fs.readFileSync(lockPath, 'utf-8')); } catch { return null; }
69
+ }
70
+
71
+ /**
72
+ * Remove the lock only if it is still the one this call created. If a sweeper
73
+ * judged our lock stale and another writer replaced it, unlinking here would
74
+ * end *their* critical section and admit a third writer to the same file.
75
+ */
76
+ function releaseOwnLock(lockPath, token) {
77
+ const held = readLock(lockPath);
78
+ if (held && held.token !== token) return;
79
+ try { fs.unlinkSync(lockPath); } catch {}
80
+ }
81
+
82
+ /**
83
+ * A lock is stale when its owner is gone, not when it is old.
84
+ *
85
+ * Age alone cuts both ways: it sweeps a live holder whose critical section
86
+ * outran the timeout (a suspended laptop, a stopped process), letting two
87
+ * writers into the same source file, while still making every accept on a
88
+ * crashed holder's file wait out the full timeout. Asking the OS whether the
89
+ * recorded pid is alive answers both correctly: a dead owner releases at once,
90
+ * and a live owner keeps its lock however long it needs.
91
+ */
92
+ function clearStaleLock(lockPath) {
93
+ const held = readLock(lockPath);
94
+ if (!held) {
95
+ // Unreadable: either a crash truncated it, or we caught the brief window
96
+ // between create and write in a live acquisition. mtime distinguishes them.
97
+ try {
98
+ const stat = fs.statSync(lockPath);
99
+ if (Date.now() - stat.mtimeMs > UNREADABLE_LOCK_STALE_MS) fs.unlinkSync(lockPath);
100
+ } catch { /* gone already */ }
101
+ return;
102
+ }
103
+ if (typeof held.pid === 'number' && isLiveServerPidReachable(held.pid)) return;
104
+ try { fs.unlinkSync(lockPath); } catch {}
105
+ }