@vibecompass/vibecompass 0.10.7 → 0.11.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.
@@ -34,8 +34,12 @@ const KNOWN_PROJECT_FIELDS = new Set([
34
34
  'repos',
35
35
  'default_branch',
36
36
  'sync',
37
+ 'runtime',
37
38
  'metadata',
38
39
  ]);
40
+ // D-282 runtime-isolation settings; deep value validation (ranges, absolute
41
+ // paths) lives at the consumption point in lane-runtime.js.
42
+ const KNOWN_RUNTIME_FIELDS = new Set(['port_base', 'port_step', 'tmp_base']);
39
43
  const KNOWN_REPO_FIELDS = new Set(['id', 'source', 'remote', 'path', 'default_branch']);
40
44
  const KNOWN_SYNC_FIELDS = new Set([
41
45
  'provider',
@@ -97,7 +101,12 @@ export async function scanProjectMemory(rootDir) {
97
101
  const sessionDocuments = await parseMarkdownTree({
98
102
  rootDir,
99
103
  directoryName: 'sessions',
100
- shouldInclude: (filename) => !NON_CANONICAL_SESSION_FILES.has(filename),
104
+ // D-278: everything under sessions/active/ is local-only lane scratch,
105
+ // never canonical — a stray lane-local file (e.g. a plan note) must not
106
+ // fail-close the whole scan as a malformed session note. The filename set
107
+ // still covers the legacy root-level wip.md/handoff.md.
108
+ shouldInclude: (filename, relativePath) =>
109
+ !NON_CANONICAL_SESSION_FILES.has(filename) && !relativePath.startsWith('sessions/active/'),
101
110
  parseDocument: (documentPath, relativePath, content) =>
102
111
  parseSessionDocument({
103
112
  content,
@@ -336,6 +345,23 @@ function validateProjectData(data, relativePath) {
336
345
  }
337
346
  }
338
347
 
348
+ if (data.runtime !== undefined) {
349
+ if (!isPlainObject(data.runtime)) {
350
+ warnings.push(
351
+ createWarning(
352
+ 'project-runtime-invalid',
353
+ 'project.yaml "runtime" must be a mapping of runtime-isolation settings (D-282); the defaults apply.',
354
+ ),
355
+ );
356
+ } else {
357
+ for (const key of Object.keys(data.runtime)) {
358
+ if (!KNOWN_RUNTIME_FIELDS.has(key)) {
359
+ warnings.push(createWarning('project-runtime-unknown-field', `Unknown project.yaml runtime field "${key}".`));
360
+ }
361
+ }
362
+ }
363
+ }
364
+
339
365
  if (data.metadata !== undefined && JSON.stringify(data.metadata).length > 500) {
340
366
  warnings.push(
341
367
  createWarning(
@@ -6,11 +6,19 @@ import { serializeProjectConfig } from './project-yaml.js';
6
6
  import { buildWorkflowScaffoldFiles } from './scaffold.js';
7
7
  import { parseSimpleYaml } from './simple-yaml.js';
8
8
  import { syncAgentInstructionFiles } from './generators/agent-files/index.js';
9
+ import { withMemoryRootLock } from './serialization.js';
9
10
  import { PACKAGE_VERSION } from './version.js';
10
11
 
11
12
  export async function refreshWorkflow(options = {}) {
12
13
  const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
13
14
  const rootDir = path.resolve(cwd, options.rootDir ?? '.compass');
15
+ if (!options.apply) {
16
+ return refreshWorkflowResolved(options, cwd, rootDir);
17
+ }
18
+ return withMemoryRootLock(rootDir, 'refresh-workflow', () => refreshWorkflowResolved(options, cwd, rootDir));
19
+ }
20
+
21
+ async function refreshWorkflowResolved(options, cwd, rootDir) {
14
22
  const toolingRootDir = options.toolingRootDir
15
23
  ? path.resolve(cwd, options.toolingRootDir)
16
24
  : cwd;
package/src/scaffold.js CHANGED
@@ -222,16 +222,22 @@ This workspace uses VibeCompass project memory rooted at \`${rootRelativePath}\`
222
222
  ## Derived and scratch files
223
223
  - \`${rootRelativePath}/context.md\` — generated AI-facing workflow context
224
224
  - \`${rootRelativePath}/state/manifest.json\` — machine-owned local state; do not hand-edit
225
- - \`${rootRelativePath}/sessions/active/index.yaml\` — active session lane index and current lane pointer
226
- - \`${rootRelativePath}/sessions/active/<lane-id>/session.yaml\` — lane metadata
225
+ - \`${rootRelativePath}/sessions/active/index.yaml\` — active session lane inventory and continuity pointer
226
+ - \`${rootRelativePath}/sessions/active/<lane-id>/session.yaml\` — lane metadata (including the recorded \`lane_marker\` when one exists)
227
227
  - \`${rootRelativePath}/sessions/active/<lane-id>/wip.md\` — lane-local builder scratchpad
228
228
  - \`${rootRelativePath}/sessions/active/<lane-id>/handoff.md\` — lane-local builder/reviewer relay
229
+ - \`.vibecompass-lane.yaml\` — worktree-local lane marker (D-280); lives outside the memory root, written only by \`vibecompass write-lane-marker\` or worktree provisioning, never synced
230
+ - \`${rootRelativePath}/decisions/INDEX.md\` — derived grouped decision index; refresh with \`vibecompass refresh-decision-index\` (D-283, structure-preserving) instead of hand-editing rows
229
231
 
230
232
  ## Session model
231
233
  - VibeCompass active builder sessions are named lanes. Use one lane per active feature or workstream.
232
234
  - \`vibecompass start-session\` requires \`--id <lane-id>\` so each active lane has a meaningful feature or workstream name.
233
235
  - The active lane scratch files live under \`${rootRelativePath}/sessions/active/<lane-id>/\`.
234
- - \`${rootRelativePath}/sessions/active/index.yaml\` is the authoritative current lane pointer; the tool-specific Current session block is a human-readable continuity summary, not the lane-selection source of truth.
236
+ - Lane selection follows D-277: an explicit \`--session\` wins, then the nearest worktree lane marker (\`.vibecompass-lane.yaml\`, walking up from cwd), then the single active lane. With two or more active lanes there is no implicit current-lane fallback.
237
+ - \`${rootRelativePath}/sessions/active/index.yaml\` is the lane inventory; its \`current\` pointer and the tool-specific Current session block are human-readable continuity summaries, not the lane-selection source of truth.
238
+ - Optional git binding (D-281): \`start-session --branch <name> --repo <id> [--worktree]\` creates or reuses the branch in every bound repo; \`--worktree\` additionally provisions per-repo worktrees under \`<workspace>/worktrees/<lane-id>/<repo-id>\` with the lane marker written into the container, so commands run from inside a worktree need neither \`--root\` nor \`--session\`. Binding is opt-in and git is never required for lanes.
239
+ - At close, \`close-session\` removes a bound lane's recorded clean worktrees (guarded and never forced: dirty, in-use, or unverifiable worktrees survive with guidance, the lane marker is kept while any worktree survives, and branches are never deleted). Do not hand-remove provisioned worktrees; follow the printed guidance instead.
240
+ - Every lane gets a per-lane runtime assignment at start (D-282): a lane port and temp dir recorded in \`session.yaml\` and exported with \`eval "$(vibecompass lane-env)"\` (includes conventional \`PORT\`/\`TMPDIR\` aliases), so parallel lanes never fight over dev-server ports or temp paths. Defaults are configurable under \`project.yaml\` \`runtime:\`; \`close-session\` removes the lane temp dir under guards.
235
241
  - Finalized sessions are append-only notes named \`${rootRelativePath}/sessions/YYYY-MM-DD-N-title.md\`; multiple sessions on the same day increment \`N\`.
236
242
  - Decisions remain append-only and independent from session notes. A session note may reference decisions, but the decision entry in \`${rootRelativePath}/decisions/\` is the durable decision record for this root.
237
243
 
@@ -256,20 +262,21 @@ ${renderWorkflowDefaults(workflow)}
256
262
 
257
263
  ## Documentation coverage
258
264
  - The architecture, decision, and session files created by init are an initial scaffold, not a comprehensive codebase review.
259
- - Before risky implementation work, inspect the relevant code and update the affected architecture docs.
265
+ - Comprehensive \`docs review\` establishes a breadth-first baseline first: inventory the project, propose a coverage plan, create compact accepted docs, and leave deferred/missing areas visible before focused deepening.
266
+ - Before implementation work on a specific feature, inspect the relevant architecture docs, decisions, coverage/documentation-plan state if present, and source files. If the area is missing, partial, or has never been deepened enough for the build, record that gap in the lane scratch and use \`docs update\`, a focused docs-review/deepening pass, or an explicit deferral before close-out.
260
267
  - Use \`vibecompass docs-update --session <lane-id>\` during a session for a targeted maintenance plan based on changed files, lane claims, session repos/features, and new decisions. This is a session-delta planner, not a broad architecture review.
261
268
  - Use the \`docs review\` prompt command when you want the current AI session to run a comprehensive documentation review. It may run \`vibecompass docs-review --guided\` as package mechanics to record marker state and print the canonical review contract.
262
269
 
263
270
  ## Session startup
264
271
  1. Read \`${rootRelativePath}/project.yaml\`.
265
272
  2. Read the latest finalized session note in \`${rootRelativePath}/sessions/\`.
266
- 3. If present, read \`${rootRelativePath}/sessions/active/index.yaml\` and choose the selected or current lane.
273
+ 3. If present, read \`${rootRelativePath}/sessions/active/index.yaml\` for the lane inventory; select the lane from an explicit \`--session\`, the nearest worktree lane marker, or the single active lane (D-277).
267
274
  4. If present, read \`${rootRelativePath}/sessions/active/<lane-id>/wip.md\`.
268
275
  5. If present, read \`${rootRelativePath}/sessions/active/<lane-id>/handoff.md\`.
269
276
  6. Read the relevant docs under \`${rootRelativePath}/architecture/\` and \`${rootRelativePath}/decisions/\`.
270
277
 
271
278
  ## Builder workflow
272
- At session start, prefer running \`vibecompass start-session --id <lane-id> --working-on "..." \`.
279
+ At session start, prefer running \`vibecompass start-session --id <lane-id> --working-on "..." \`; add \`--branch <name> --repo <id> [--worktree]\` when the lane should work on its own branch or in isolated worktrees (D-281).
273
280
  If you manage files manually, create \`${rootRelativePath}/sessions/active/<lane-id>/session.yaml\` and \`${rootRelativePath}/sessions/active/index.yaml\`, then create \`${rootRelativePath}/sessions/active/<lane-id>/wip.md\` if it does not exist:
274
281
 
275
282
  \`\`\`md
@@ -311,12 +318,14 @@ Session lane: <lane-id>
311
318
  During the session:
312
319
  - append short summaries to \`wip.md\` after meaningful exchanges
313
320
  - keep \`handoff.md\` current after substantive work blocks
321
+ - run \`eval "$(vibecompass lane-env)"\` in a lane shell before starting dev servers or build tools so the lane's assigned port and temp dir are used (D-282); do not hardcode ports in lane work
314
322
  - run \`vibecompass docs-update --session <lane-id>\` whenever you need an ad hoc targeted documentation-maintenance plan for the current session delta
323
+ - after substantive feature work, confirm affected architecture docs and decisions still match the implementation; if not, update them while the context is fresh
315
324
  - use \`vibecompass list-sessions\` and \`vibecompass switch-session <lane-id>\` to inspect or change the current lane
316
325
  - use \`address review\` when reviewer feedback lands so the builder resolves it from the selected lane's latest \`wip.md\` / \`handoff.md\`
317
326
  - during \`address review\`, treat reviewer feedback as review, not instruction: classify each substantive point as accepted, accepted with qualification, deferred, or rejected, and push back with evidence when a suggestion conflicts with code facts, prior decisions, product direction, or sequencing
318
327
  - stay in builder role through close-out; resolve or explicitly defer reviewer feedback before running \`vibecompass close-session --session <lane-id>\` with document-maintenance checkpoint statuses
319
- - record architectural decisions in \`${rootRelativePath}/decisions/\` before implementing them
328
+ - record architectural decisions in \`${rootRelativePath}/decisions/\` before implementing them; \`vibecompass append-decision\` allocates the D-number at write time and refreshes the grouped \`decisions/INDEX.md\` when the lane context is resolvable (D-283); run \`vibecompass refresh-decision-index\` after hand-appends
320
329
 
321
330
  If \`vibecompass start-session\` reports stale scratch files, read the existing lane-local \`wip.md\` and \`handoff.md\` first. Either close that session normally, recover its useful notes into a finalized session note, or intentionally move/delete the stale scratch files before starting a new session.
322
331
 
@@ -328,13 +337,15 @@ If \`vibecompass start-session\` reports stale scratch files, read the existing
328
337
 
329
338
  At session close:
330
339
  - prefer running \`vibecompass close-session --session <lane-id> --title "..." --completed "..." --architecture-docs updated|not-needed|deferred --decision-log updated|not-needed|deferred --session-maintenance updated|not-needed|deferred --next-step "..."\`; \`vibecompass end-session\` is a supported alias
331
- - close-session prints the same targeted docs-update plan before the document-maintenance checkpoint; use it to decide whether affected \`architecture/\`, \`decisions/\`, and active-session scratch/final note inputs are updated, not-needed, or deferred
340
+ - close-session prints the same targeted docs-update plan — including the pre-close staleness set (new decisions since lane start, stale base revisions, newer finalized notes touching this lane's scope, claim overlap with other active lanes) — before the document-maintenance checkpoint; use it to decide whether affected \`architecture/\`, \`decisions/\`, and active-session scratch/final note inputs are updated, not-needed, or deferred
332
341
  - document-maintenance checkpoint statuses are required before close-session writes the finalized note; the package validates the status values, while the closer owns semantic doc authorship
333
342
  - follow the stored close-session defaults from \`${rootRelativePath}/project.yaml\`
334
343
  - finalize the lane-local \`wip.md\` into \`${rootRelativePath}/sessions/YYYY-MM-DD-N-title.md\`; the permanent note distills decisions, completions, blockers, and next steps rather than preserving the full \`## Review log\`
335
344
  - if a granular reviewer trail must remain durable, summarize it explicitly in the session note inputs or create a separate finalized session note before close-session deletes lane scratch files
336
- - delete the closed lane directory under \`${rootRelativePath}/sessions/active/<lane-id>/\`
345
+ - close-session deletes the closed lane directory under \`${rootRelativePath}/sessions/active/<lane-id>/\` and cleans up provisioned worktrees, the lane marker, the container, and the lane temp dir when they are safely removable; leftover pieces come with printed guidance
337
346
  - refresh any affected architecture/decision docs
347
+ - for local-primary roots with hosted sync configured, run \`vibecompass push --root ${rootRelativePath}\` after canonical docs/session files are finalized when the hosted dashboard should reflect the session; pass \`--sync-target <name>\` when using a non-default or named target
348
+ - for hosted-only projects, there is no local authoritative push; confirm hosted dashboard/proposal/Understanding state was updated or record the refresh/apply work as deferred
338
349
 
339
350
  ## Reviewer workflow
340
351
  - use \`review handoff\` when you want the reviewer to run the next review pass
@@ -525,8 +536,9 @@ Append-only decision log for ${projectConfig.name}.
525
536
  ## Conventions
526
537
  - store canonical decision entries in domain-grouped files such as \`cross-cutting.md\`
527
538
  - never edit or delete prior decision entries
528
- - use headings in the form \`### D-<number> — Title\`
539
+ - use headings in the form \`### D-<number> — Title\` (em-dash; hyphen-only headings cannot be indexed)
529
540
  - include \`**Timestamp:**\`, \`**Decision:**\`, and \`**Rationale:**\`
541
+ - \`INDEX.md\` is a derived grouped index: refresh it with \`vibecompass refresh-decision-index\` (structure-preserving, D-283) rather than hand-editing rows; \`vibecompass append-decision\` refreshes it automatically when it can label the session group
530
542
 
531
543
  ## Example
532
544
 
@@ -559,8 +571,8 @@ Finalized session notes for ${projectConfig.name} live here.
559
571
  - \`## Next session should start with\`
560
572
 
561
573
  ## Active-session scratch files
562
- - \`active/index.yaml\` — active lane index and current lane pointer
563
- - \`active/<lane-id>/session.yaml\` — lane metadata
574
+ - \`active/index.yaml\` — active lane inventory and continuity pointer (not a resolver with 2+ lanes; D-277)
575
+ - \`active/<lane-id>/session.yaml\` — lane metadata, including the recorded \`lane_marker\` when one exists
564
576
  - \`active/<lane-id>/wip.md\` — builder scratchpad during an active lane
565
577
  - \`active/<lane-id>/handoff.md\` — reviewer/builder baton-pass during an active lane
566
578
 
@@ -0,0 +1,205 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+
6
+ /**
7
+ * Single-machine serialization tier for shared project-memory writers (D-276).
8
+ *
9
+ * Three layers cooperate so every package-owned writer of shared memory runs
10
+ * one at a time per root:
11
+ * - an on-disk mkdir-as-mutex lock directory under `state/` serializes across
12
+ * processes (mkdir without `recursive` is atomic on APFS/ext4)
13
+ * - an in-process promise queue serializes concurrent async callers inside one
14
+ * process, where the disk lock alone cannot arbitrate
15
+ * - AsyncLocalStorage tracks held roots so nested calls inside a lock holder
16
+ * re-enter instead of deadlocking (e.g. close-session refreshing the
17
+ * manifest, which is itself lock-wrapped)
18
+ *
19
+ * This tier is intentionally local-only. Cross-machine serialization belongs
20
+ * to git merge (local-primary teams) or the hosted database (hosted-only);
21
+ * no distributed locking is ever built on top of this file.
22
+ */
23
+
24
+ const LOCK_DIR_NAME = 'memory-root.lock';
25
+ const OWNER_FILE_NAME = 'owner.json';
26
+ const DEFAULT_STALE_MS = 30_000;
27
+ const DEFAULT_ATTEMPTS = 120;
28
+ const RETRY_BASE_DELAY_MS = 25;
29
+ const RETRY_MAX_DELAY_MS = 100;
30
+
31
+ const heldRoots = new AsyncLocalStorage();
32
+ const processQueues = new Map();
33
+
34
+ export class MemoryRootLockError extends Error {
35
+ constructor(message) {
36
+ super(message);
37
+ this.name = 'MemoryRootLockError';
38
+ }
39
+ }
40
+
41
+ export function memoryRootLockPath(rootDir) {
42
+ return path.join(path.resolve(rootDir), 'state', LOCK_DIR_NAME);
43
+ }
44
+
45
+ /**
46
+ * Runs `fn` while holding the serialization lock for `rootDir`. Reentrant:
47
+ * a nested call from inside the lock holder runs immediately.
48
+ */
49
+ export async function withMemoryRootLock(rootDir, label, fn, options = {}) {
50
+ const key = path.resolve(rootDir);
51
+ const held = heldRoots.getStore();
52
+ if (held?.has(key)) {
53
+ return fn();
54
+ }
55
+
56
+ const previous = processQueues.get(key) ?? Promise.resolve();
57
+ let releaseQueue;
58
+ const queueSlot = new Promise((resolve) => {
59
+ releaseQueue = resolve;
60
+ });
61
+ processQueues.set(key, previous.then(() => queueSlot));
62
+ await previous;
63
+
64
+ try {
65
+ const ownerToken = await acquireDiskLock(key, label, options);
66
+ try {
67
+ const nextHeld = new Set(held ?? []);
68
+ nextHeld.add(key);
69
+ return await heldRoots.run(nextHeld, fn);
70
+ } finally {
71
+ await releaseDiskLock(key, ownerToken);
72
+ }
73
+ } finally {
74
+ releaseQueue();
75
+ if (processQueues.get(key) === queueSlot) {
76
+ processQueues.delete(key);
77
+ }
78
+ }
79
+ }
80
+
81
+ async function acquireDiskLock(key, label, options) {
82
+ const lockDir = memoryRootLockPath(key);
83
+ const attempts = options.attempts ?? DEFAULT_ATTEMPTS;
84
+ const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
85
+ await mkdir(path.dirname(lockDir), { recursive: true });
86
+
87
+ for (let attempt = 1; ; attempt += 1) {
88
+ const ownerToken = randomUUID();
89
+ try {
90
+ await mkdir(lockDir);
91
+ await writeFile(
92
+ path.join(lockDir, OWNER_FILE_NAME),
93
+ `${JSON.stringify({ pid: process.pid, token: ownerToken, label, acquired_at: new Date().toISOString() })}\n`,
94
+ 'utf8',
95
+ );
96
+ return ownerToken;
97
+ } catch (error) {
98
+ if (error?.code !== 'EEXIST') {
99
+ throw error;
100
+ }
101
+ }
102
+
103
+ if (await isAbandonedLock(lockDir, staleMs)) {
104
+ await reclaimAbandonedLock(lockDir);
105
+ continue;
106
+ }
107
+
108
+ if (attempt >= attempts) {
109
+ const owner = await readLockOwner(lockDir);
110
+ const ownerText = owner
111
+ ? `held by live pid ${owner.pid ?? 'unknown'} (${owner.label ?? 'unknown command'}) since ${owner.acquired_at ?? 'unknown'}`
112
+ : 'owner unreadable';
113
+ throw new MemoryRootLockError(
114
+ `Timed out waiting for the project-memory lock at ${lockDir} — ${ownerText}. ` +
115
+ 'Locks held by a live process are never stolen, however long they run. ' +
116
+ 'If no other vibecompass command is running against this root, remove the lock directory and retry.',
117
+ );
118
+ }
119
+
120
+ await delay(RETRY_BASE_DELAY_MS + Math.floor(Math.random() * RETRY_MAX_DELAY_MS));
121
+ }
122
+ }
123
+
124
+ /**
125
+ * A lock is abandoned only when its owner process is provably gone (dead PID),
126
+ * or the owner file never became readable within the grace window (a crash
127
+ * between mkdir and the owner write). A lock held by a live process is NEVER
128
+ * treated as stale, regardless of age — long-running holders time out waiters
129
+ * with an actionable error instead of being silently stolen.
130
+ */
131
+ async function isAbandonedLock(lockDir, staleMs) {
132
+ const owner = await readLockOwner(lockDir);
133
+
134
+ if (owner && Number.isInteger(owner.pid)) {
135
+ return !isProcessAlive(owner.pid);
136
+ }
137
+
138
+ try {
139
+ const stats = await stat(lockDir);
140
+ return Date.now() - stats.mtimeMs > staleMs;
141
+ } catch (error) {
142
+ if (error?.code === 'ENOENT') {
143
+ return false;
144
+ }
145
+ throw error;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Reclaims an abandoned lock via atomic rename so racing waiters cannot
151
+ * double-free: exactly one contender wins the rename and deletes the moved
152
+ * directory; the others see ENOENT and simply retry the mkdir claim. This can
153
+ * never delete a freshly acquired lock, because a new claim creates a new
154
+ * directory at the original path rather than reusing the renamed one.
155
+ */
156
+ async function reclaimAbandonedLock(lockDir) {
157
+ const reclaimPath = `${lockDir}.reclaim-${process.pid}-${randomUUID()}`;
158
+ try {
159
+ await rename(lockDir, reclaimPath);
160
+ } catch (error) {
161
+ if (error?.code === 'ENOENT') {
162
+ return;
163
+ }
164
+ throw error;
165
+ }
166
+ await rm(reclaimPath, { recursive: true, force: true });
167
+ }
168
+
169
+ /**
170
+ * Releases only a lock this caller still owns (token compare), so a holder
171
+ * whose lock was reclaimed after a crash-recovery cannot delete a successor's
172
+ * live lock.
173
+ */
174
+ async function releaseDiskLock(key, ownerToken) {
175
+ const lockDir = memoryRootLockPath(key);
176
+ const owner = await readLockOwner(lockDir);
177
+ if (owner?.token !== ownerToken) {
178
+ return;
179
+ }
180
+ await rm(lockDir, { recursive: true, force: true });
181
+ }
182
+
183
+ async function readLockOwner(lockDir) {
184
+ try {
185
+ return JSON.parse(await readFile(path.join(lockDir, OWNER_FILE_NAME), 'utf8'));
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
190
+
191
+ function isProcessAlive(pid) {
192
+ try {
193
+ process.kill(pid, 0);
194
+ return true;
195
+ } catch (error) {
196
+ // EPERM means the process exists but belongs to another user.
197
+ return error?.code === 'EPERM';
198
+ }
199
+ }
200
+
201
+ function delay(ms) {
202
+ return new Promise((resolve) => {
203
+ setTimeout(resolve, ms);
204
+ });
205
+ }