@mearl/daemon-core 2.9.4 → 2.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,9 +34,13 @@ keyedLogFile(subdir, key): string // ~/.mearl/<subdir>/<hash>.log
34
34
  interface DaemonRecord { pid: number; startedAt?: string; [k: string]: unknown }
35
35
 
36
36
  ensureDir(dir: string): void
37
- writeRecord(file: string, record: DaemonRecord): void // 临时文件 + rename 原子写
37
+ writeRecord(file: string, record: DaemonRecord): void // 跨进程加锁,临时文件 + rename 原子写
38
+ claimRecord(file: string, record: DaemonRecord): boolean // 仅在文件不存在时原子占用
38
39
  readRecord<T>(file: string): T | null
39
40
  removeRecord(file: string): void
41
+ removeRecordIfPid(file: string, pid: number): boolean // 仅删除指定进程仍拥有的记录
42
+ writeRecordIfPid(file: string, pid: number, record: DaemonRecord): boolean
43
+ removeInvalidRecord(file: string): boolean // 清理无法解析的残留记录
40
44
  listRecordFiles(dir: string): string[] // 目录下所有 *.json 绝对路径
41
45
  readLiveRecords<T>(dir: string): T[] // 读取存活记录,并顺带清理死记录
42
46
  ```
@@ -47,7 +51,10 @@ readLiveRecords<T>(dir: string): T[] // 读取存活记录,
47
51
  createDaemon(opts: CreateDaemonOptions): Daemon
48
52
  ```
49
53
 
50
- 围绕一对 `(configFile, logFile)` 构建单实例守护进程管理器。模型:`start()` 以 `--foreground` detached 方式重新拉起消费方自己的 CLI;子进程运行实际服务,就绪后把自己的 pid 写入 `configFile`,父进程轮询 `record.pid === child.pid` 确认其已起来(而非死亡或卡住)。多实例子系统按实例 key 派生 `configFile`/`logFile`,为每个实例各创建一个管理器。
54
+ 围绕一对 `(configFile, logFile)` 构建单实例守护进程管理器。`start()` 先原子占用启动锁,
55
+ 再以 `--foreground` detached 方式拉起消费方 CLI,并立即写入带子进程 pid 的 `starting`
56
+ 记录。子进程就绪后覆盖该记录,父进程据此确认启动完成。并发 `start()` 会复用正在启动或
57
+ 已经运行的子进程。多实例子系统按实例 key 派生各自的 `configFile` / `logFile`。
51
58
 
52
59
  ```typescript
53
60
  interface CreateDaemonOptions {
@@ -58,11 +65,12 @@ interface CreateDaemonOptions {
58
65
  foregroundArgs?: string[]; // 追加在 `--foreground` 之后的 argv
59
66
  readyTimeoutMs?: number; // 等待子进程写记录的超时(默认 15000ms)
60
67
  formatInfo?: (record: DaemonRecord) => string | null; // 起来后额外打印的信息
68
+ startingRecord?: (pid: number, startedAt: string) => DaemonRecord;
61
69
  }
62
70
 
63
71
  interface Daemon {
64
72
  start(): Promise<void>;
65
- stop(): Promise<void>;
73
+ stop(expectedPid?: number): Promise<void>; // 指定 PID 时仅停止仍匹配的实例
66
74
  restart(): Promise<void>;
67
75
  status(): void;
68
76
  logs(lines?: number): void;
package/dist/daemon.d.ts CHANGED
@@ -10,7 +10,7 @@ export interface CreateDaemonOptions {
10
10
  * module that is bundled INTO the cli entry.
11
11
  */
12
12
  scriptPath: string;
13
- /** Path to this instance's JSON record file (written by the foreground child). */
13
+ /** Path to this instance's JSON record file. */
14
14
  configFile: string;
15
15
  /** Path to this instance's log file (the detached child's stdout/stderr). */
16
16
  logFile: string;
@@ -20,10 +20,12 @@ export interface CreateDaemonOptions {
20
20
  readyTimeoutMs?: number;
21
21
  /** Extra lines to print once the instance is up/queried (e.g. a connect hint). */
22
22
  formatInfo?: (record: DaemonRecord) => string | null;
23
+ /** Record persisted immediately after spawn so concurrent starts can reuse the child. */
24
+ startingRecord?: (pid: number, startedAt: string) => DaemonRecord;
23
25
  }
24
26
  export interface Daemon {
25
27
  start(): Promise<void>;
26
- stop(): Promise<void>;
28
+ stop(expectedPid?: number): Promise<void>;
27
29
  restart(): Promise<void>;
28
30
  status(): void;
29
31
  logs(lines?: number): void;
@@ -33,10 +35,9 @@ export interface Daemon {
33
35
  /**
34
36
  * Build a single-instance daemon manager around a `(configFile, logFile)` pair.
35
37
  *
36
- * The model: `start()` spawns the consumer's CLI detached with `--foreground`;
37
- * that child runs the actual service and, once ready, writes its own pid into
38
- * `configFile`. The parent polls for `record.pid === child.pid` to confirm the
39
- * child came up (vs. died or hung). Multi-instance subsystems create one manager
40
- * per instance, deriving `configFile`/`logFile` from the instance key.
38
+ * The model: `start()` atomically reserves startup, spawns the consumer's CLI
39
+ * detached with `--foreground`, and records the child pid as `starting`. The
40
+ * child replaces that marker when ready. Multi-instance subsystems create one
41
+ * manager per instance, deriving `configFile` / `logFile` from the instance key.
41
42
  */
42
43
  export declare function createDaemon(opts: CreateDaemonOptions): Daemon;
package/dist/daemon.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { openSync } from 'node:fs';
2
+ import { closeSync, openSync } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
4
  import { isAlive } from './process.js';
5
- import { ensureDir, readRecord, removeRecord } from './registry.js';
5
+ import { claimRecord, ensureDir, readRecord, removeInvalidRecord, removeRecordIfPid, writeRecord, } from './registry.js';
6
6
  import { rotateLogIfNeeded, tailFile } from './log.js';
7
7
  function sleep(ms) {
8
8
  return new Promise(resolve => setTimeout(resolve, ms));
@@ -10,15 +10,15 @@ function sleep(ms) {
10
10
  /**
11
11
  * Build a single-instance daemon manager around a `(configFile, logFile)` pair.
12
12
  *
13
- * The model: `start()` spawns the consumer's CLI detached with `--foreground`;
14
- * that child runs the actual service and, once ready, writes its own pid into
15
- * `configFile`. The parent polls for `record.pid === child.pid` to confirm the
16
- * child came up (vs. died or hung). Multi-instance subsystems create one manager
17
- * per instance, deriving `configFile`/`logFile` from the instance key.
13
+ * The model: `start()` atomically reserves startup, spawns the consumer's CLI
14
+ * detached with `--foreground`, and records the child pid as `starting`. The
15
+ * child replaces that marker when ready. Multi-instance subsystems create one
16
+ * manager per instance, deriving `configFile` / `logFile` from the instance key.
18
17
  */
19
18
  export function createDaemon(opts) {
20
- const { name, scriptPath, configFile, logFile, foregroundArgs = [], readyTimeoutMs = 15_000, formatInfo, } = opts;
19
+ const { name, scriptPath, configFile, logFile, foregroundArgs = [], readyTimeoutMs = 15_000, formatInfo, startingRecord, } = opts;
21
20
  const tag = `[${name}]`;
21
+ const startLockFile = `${configFile}.start.lock`;
22
22
  function running() {
23
23
  const rec = readRecord(configFile);
24
24
  return rec && isAlive(rec.pid) ? rec : null;
@@ -31,28 +31,89 @@ export function createDaemon(opts) {
31
31
  async function start() {
32
32
  const live = running();
33
33
  if (live) {
34
- console.log(`${tag} Already running (pid ${live.pid})`);
34
+ const state = live.daemonState === 'starting' ? 'starting' : 'running';
35
+ console.log(`${tag} Already ${state} (pid ${live.pid})`);
35
36
  printInfo(live);
36
37
  return;
37
38
  }
38
- // Clear any stale record left by a crashed run before spawning a new child.
39
- removeRecord(configFile);
39
+ const existingLock = readRecord(startLockFile);
40
+ if (existingLock && isAlive(existingLock.pid)) {
41
+ console.log(`${tag} Start already in progress (pid ${existingLock.pid})`);
42
+ return;
43
+ }
44
+ if (existingLock)
45
+ removeRecordIfPid(startLockFile, existingLock.pid);
46
+ else
47
+ removeInvalidRecord(startLockFile);
48
+ const lockPid = process.pid;
49
+ if (!claimRecord(startLockFile, { pid: lockPid, startedAt: new Date().toISOString() })) {
50
+ const competingLock = readRecord(startLockFile);
51
+ if (competingLock && isAlive(competingLock.pid)) {
52
+ console.log(`${tag} Start already in progress (pid ${competingLock.pid})`);
53
+ return;
54
+ }
55
+ if (competingLock)
56
+ removeRecordIfPid(startLockFile, competingLock.pid);
57
+ else
58
+ removeInvalidRecord(startLockFile);
59
+ if (!claimRecord(startLockFile, { pid: lockPid, startedAt: new Date().toISOString() })) {
60
+ const currentLock = readRecord(startLockFile);
61
+ if (currentLock && isAlive(currentLock.pid)) {
62
+ console.log(`${tag} Start already in progress (pid ${currentLock.pid})`);
63
+ return;
64
+ }
65
+ console.error(`${tag} Failed to acquire startup lock: ${startLockFile}`);
66
+ process.exitCode = 1;
67
+ return;
68
+ }
69
+ }
70
+ // Clear any stale record left by a crashed run after taking the startup lock.
71
+ const stale = readRecord(configFile);
72
+ if (stale && !isAlive(stale.pid))
73
+ removeRecordIfPid(configFile, stale.pid);
74
+ else if (!stale)
75
+ removeInvalidRecord(configFile);
40
76
  ensureDir(dirname(configFile));
41
77
  ensureDir(dirname(logFile));
42
78
  rotateLogIfNeeded(logFile);
79
+ const startedAt = new Date().toISOString();
43
80
  const logFd = openSync(logFile, 'a');
44
- const child = spawn(process.execPath, [scriptPath, '--foreground', ...foregroundArgs], {
45
- detached: true,
46
- stdio: ['ignore', logFd, logFd],
47
- env: process.env,
48
- windowsHide: true,
49
- });
81
+ let child;
82
+ try {
83
+ child = spawn(process.execPath, [scriptPath, '--foreground', ...foregroundArgs], {
84
+ detached: true,
85
+ stdio: ['ignore', logFd, logFd],
86
+ env: process.env,
87
+ windowsHide: true,
88
+ });
89
+ }
90
+ catch (error) {
91
+ removeRecordIfPid(startLockFile, lockPid);
92
+ throw error;
93
+ }
94
+ finally {
95
+ closeSync(logFd);
96
+ }
50
97
  child.unref();
98
+ const childPid = child.pid;
99
+ if (!childPid) {
100
+ removeRecordIfPid(startLockFile, lockPid);
101
+ console.error(`${tag} Failed to start: spawned process has no pid`);
102
+ process.exitCode = 1;
103
+ return;
104
+ }
105
+ writeRecord(startLockFile, { pid: childPid, startedAt });
106
+ claimRecord(configFile, {
107
+ ...(startingRecord?.(childPid, startedAt) ?? { pid: childPid, startedAt }),
108
+ daemonState: 'starting',
109
+ });
110
+ removeRecordIfPid(startLockFile, childPid);
51
111
  // Wait until the child writes its own pid into the record (i.e. it is ready),
52
112
  // or until it dies / we time out.
53
113
  const deadline = Date.now() + readyTimeoutMs;
54
114
  while (Date.now() < deadline) {
55
- if (!isAlive(child.pid)) {
115
+ if (!isAlive(childPid)) {
116
+ removeRecordIfPid(configFile, childPid);
56
117
  console.error(`${tag} Failed to start. Recent log:`);
57
118
  const log = tailFile(logFile, 20);
58
119
  if (log)
@@ -62,8 +123,8 @@ export function createDaemon(opts) {
62
123
  return;
63
124
  }
64
125
  const rec = readRecord(configFile);
65
- if (rec && rec.pid === child.pid) {
66
- console.log(`${tag} Started in background (pid ${child.pid})`);
126
+ if (rec && rec.pid === childPid && rec.daemonState !== 'starting') {
127
+ console.log(`${tag} Started in background (pid ${childPid})`);
67
128
  console.log(`${tag} Logs: ${logFile}`);
68
129
  printInfo(rec);
69
130
  return;
@@ -73,11 +134,18 @@ export function createDaemon(opts) {
73
134
  console.error(`${tag} Start timed out after ${Math.round(readyTimeoutMs / 1000)}s. Check the log: ${logFile}`);
74
135
  process.exitCode = 1;
75
136
  }
76
- async function stop() {
137
+ async function stop(expectedPid) {
77
138
  const rec = readRecord(configFile);
139
+ if (expectedPid !== undefined && rec?.pid !== expectedPid) {
140
+ console.log(`${tag} Registered pid ${expectedPid} is no longer the current instance`);
141
+ return;
142
+ }
78
143
  if (!rec || !isAlive(rec.pid)) {
79
144
  console.log(`${tag} Not running`);
80
- removeRecord(configFile);
145
+ if (rec)
146
+ removeRecordIfPid(configFile, rec.pid);
147
+ else
148
+ removeInvalidRecord(configFile);
81
149
  return;
82
150
  }
83
151
  const pid = rec.pid;
@@ -101,7 +169,7 @@ export function createDaemon(opts) {
101
169
  // ignore
102
170
  }
103
171
  }
104
- removeRecord(configFile);
172
+ removeRecordIfPid(configFile, pid);
105
173
  console.log(`${tag} Stopped (pid ${pid})`);
106
174
  }
107
175
  async function restart() {
@@ -114,7 +182,7 @@ export function createDaemon(opts) {
114
182
  console.log(`${tag} Status: stopped`);
115
183
  return;
116
184
  }
117
- console.log(`${tag} Status: running`);
185
+ console.log(`${tag} Status: ${rec.daemonState === 'starting' ? 'starting' : 'running'}`);
118
186
  console.log(` pid: ${rec.pid}`);
119
187
  if (rec.startedAt)
120
188
  console.log(` started at: ${rec.startedAt}`);
@@ -7,12 +7,20 @@ export interface DaemonRecord {
7
7
  export declare function ensureDir(dir: string): void;
8
8
  /**
9
9
  * Atomically write a record: serialise to a temp file then rename over the
10
- * target, so a concurrent reader never observes a half-written file. The temp
11
- * name is pid-scoped so two writers don't clobber each other's temp file.
10
+ * target, so a concurrent reader never observes a half-written file. Temp file
11
+ * names include a random token so independent writes cannot collide.
12
12
  */
13
13
  export declare function writeRecord(file: string, record: DaemonRecord): void;
14
+ /** Atomically create a record only when the target does not already exist. */
15
+ export declare function claimRecord(file: string, record: DaemonRecord): boolean;
14
16
  export declare function readRecord<T extends DaemonRecord = DaemonRecord>(file: string): T | null;
15
17
  export declare function removeRecord(file: string): void;
18
+ /** Remove a record only while it still belongs to the expected process. */
19
+ export declare function removeRecordIfPid(file: string, pid: number): boolean;
20
+ /** Replace a record only while it is still owned by the expected process. */
21
+ export declare function writeRecordIfPid(file: string, pid: number, record: DaemonRecord): boolean;
22
+ /** Remove an existing record whose contents are not a valid daemon record. */
23
+ export declare function removeInvalidRecord(file: string): boolean;
16
24
  /** Absolute paths of every `*.json` record file in a directory. */
17
25
  export declare function listRecordFiles(dir: string): string[];
18
26
  /**
package/dist/registry.js CHANGED
@@ -1,19 +1,35 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, rmSync, readdirSync, } from 'node:fs';
1
+ import { closeSync, existsSync, linkSync, openSync, statSync, mkdirSync, readFileSync, writeFileSync, renameSync, rmSync, readdirSync, } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
3
4
  import { isAlive } from './process.js';
5
+ const LOCK_WAIT_MS = 10;
6
+ const LOCK_TIMEOUT_MS = 5_000;
7
+ const OWNER_WRITE_GRACE_MS = 1_000;
8
+ const lockWaitBuffer = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
4
9
  export function ensureDir(dir) {
5
10
  if (!existsSync(dir)) {
6
11
  mkdirSync(dir, { recursive: true, mode: 0o700 });
7
12
  }
8
13
  }
9
- /**
10
- * Atomically write a record: serialise to a temp file then rename over the
11
- * target, so a concurrent reader never observes a half-written file. The temp
12
- * name is pid-scoped so two writers don't clobber each other's temp file.
13
- */
14
- export function writeRecord(file, record) {
15
- ensureDir(dirname(file));
16
- const tmp = `${file}.${process.pid}.tmp`;
14
+ function pauseForLock() {
15
+ Atomics.wait(lockWaitBuffer, 0, 0, LOCK_WAIT_MS);
16
+ }
17
+ function readRecordUnlocked(file) {
18
+ try {
19
+ if (!existsSync(file))
20
+ return null;
21
+ const parsed = JSON.parse(readFileSync(file, 'utf-8'));
22
+ return parsed && typeof parsed.pid === 'number' ? parsed : null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ function removeRecordUnlocked(file) {
29
+ rmSync(file, { force: true });
30
+ }
31
+ function writeRecordUnlocked(file, record) {
32
+ const tmp = `${file}.${process.pid}.${randomUUID()}.tmp`;
17
33
  try {
18
34
  writeFileSync(tmp, JSON.stringify(record), { mode: 0o600 });
19
35
  renameSync(tmp, file);
@@ -28,25 +44,161 @@ export function writeRecord(file, record) {
28
44
  throw error;
29
45
  }
30
46
  }
31
- export function readRecord(file) {
47
+ function removeStaleLock(lockDir) {
48
+ const ownerFile = join(lockDir, 'owner.json');
49
+ const owner = readRecordUnlocked(ownerFile);
50
+ if (owner) {
51
+ if (isAlive(owner.pid))
52
+ return false;
53
+ }
54
+ else {
55
+ try {
56
+ if (Date.now() - statSync(lockDir).mtimeMs < OWNER_WRITE_GRACE_MS)
57
+ return false;
58
+ }
59
+ catch {
60
+ return true;
61
+ }
62
+ }
32
63
  try {
33
- if (!existsSync(file))
34
- return null;
35
- const parsed = JSON.parse(readFileSync(file, 'utf-8'));
36
- return parsed && typeof parsed.pid === 'number' ? parsed : null;
64
+ rmSync(lockDir, { recursive: true, force: true });
65
+ return true;
37
66
  }
38
67
  catch {
39
- return null;
68
+ return false;
69
+ }
70
+ }
71
+ /** Serialize record ownership changes across processes sharing the registry. */
72
+ function withRecordLock(file, operation) {
73
+ ensureDir(dirname(file));
74
+ const lockDir = `${file}.registry.lock`;
75
+ const ownerFile = join(lockDir, 'owner.json');
76
+ const token = randomUUID();
77
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
78
+ while (true) {
79
+ try {
80
+ mkdirSync(lockDir, { mode: 0o700 });
81
+ }
82
+ catch (error) {
83
+ if (error.code !== 'EEXIST')
84
+ throw error;
85
+ if (!removeStaleLock(lockDir)) {
86
+ if (Date.now() >= deadline) {
87
+ throw new Error(`Timed out waiting for registry lock: ${file}`, { cause: error });
88
+ }
89
+ pauseForLock();
90
+ }
91
+ continue;
92
+ }
93
+ let ownerFd = null;
94
+ try {
95
+ ownerFd = openSync(ownerFile, 'wx', 0o600);
96
+ writeFileSync(ownerFd, JSON.stringify({ pid: process.pid, token }));
97
+ }
98
+ catch (error) {
99
+ if (ownerFd !== null)
100
+ closeSync(ownerFd);
101
+ ownerFd = null;
102
+ if (error.code === 'EEXIST') {
103
+ if (Date.now() >= deadline) {
104
+ throw new Error(`Timed out acquiring registry lock: ${file}`, { cause: error });
105
+ }
106
+ pauseForLock();
107
+ continue;
108
+ }
109
+ throw error;
110
+ }
111
+ finally {
112
+ if (ownerFd !== null)
113
+ closeSync(ownerFd);
114
+ }
115
+ try {
116
+ return operation();
117
+ }
118
+ finally {
119
+ const owner = readRecordUnlocked(ownerFile);
120
+ if (owner?.token === token)
121
+ rmSync(lockDir, { recursive: true, force: true });
122
+ }
40
123
  }
41
124
  }
125
+ /**
126
+ * Atomically write a record: serialise to a temp file then rename over the
127
+ * target, so a concurrent reader never observes a half-written file. Temp file
128
+ * names include a random token so independent writes cannot collide.
129
+ */
130
+ export function writeRecord(file, record) {
131
+ withRecordLock(file, () => writeRecordUnlocked(file, record));
132
+ }
133
+ /** Atomically create a record only when the target does not already exist. */
134
+ export function claimRecord(file, record) {
135
+ return withRecordLock(file, () => {
136
+ const candidate = `${file}.${process.pid}.${randomUUID()}.claim`;
137
+ try {
138
+ writeFileSync(candidate, JSON.stringify(record), { mode: 0o600 });
139
+ linkSync(candidate, file);
140
+ return true;
141
+ }
142
+ catch (error) {
143
+ if (error.code === 'EEXIST')
144
+ return false;
145
+ throw error;
146
+ }
147
+ finally {
148
+ rmSync(candidate, { force: true });
149
+ }
150
+ });
151
+ }
152
+ export function readRecord(file) {
153
+ return readRecordUnlocked(file);
154
+ }
42
155
  export function removeRecord(file) {
43
156
  try {
44
- rmSync(file, { force: true });
157
+ withRecordLock(file, () => removeRecordUnlocked(file));
45
158
  }
46
159
  catch {
47
160
  // ignore
48
161
  }
49
162
  }
163
+ /** Remove a record only while it still belongs to the expected process. */
164
+ export function removeRecordIfPid(file, pid) {
165
+ try {
166
+ return withRecordLock(file, () => {
167
+ const record = readRecordUnlocked(file);
168
+ if (record?.pid !== pid)
169
+ return false;
170
+ removeRecordUnlocked(file);
171
+ return true;
172
+ });
173
+ }
174
+ catch {
175
+ return false;
176
+ }
177
+ }
178
+ /** Replace a record only while it is still owned by the expected process. */
179
+ export function writeRecordIfPid(file, pid, record) {
180
+ return withRecordLock(file, () => {
181
+ const current = readRecordUnlocked(file);
182
+ if (current?.pid !== pid)
183
+ return false;
184
+ writeRecordUnlocked(file, record);
185
+ return true;
186
+ });
187
+ }
188
+ /** Remove an existing record whose contents are not a valid daemon record. */
189
+ export function removeInvalidRecord(file) {
190
+ try {
191
+ return withRecordLock(file, () => {
192
+ if (!existsSync(file) || readRecordUnlocked(file))
193
+ return false;
194
+ removeRecordUnlocked(file);
195
+ return true;
196
+ });
197
+ }
198
+ catch {
199
+ return false;
200
+ }
201
+ }
50
202
  /** Absolute paths of every `*.json` record file in a directory. */
51
203
  export function listRecordFiles(dir) {
52
204
  try {
@@ -70,8 +222,11 @@ export function readLiveRecords(dir) {
70
222
  if (rec && isAlive(rec.pid)) {
71
223
  out.push(rec);
72
224
  }
225
+ else if (rec) {
226
+ removeRecordIfPid(file, rec.pid);
227
+ }
73
228
  else {
74
- removeRecord(file);
229
+ removeInvalidRecord(file);
75
230
  }
76
231
  }
77
232
  return out;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mearl/daemon-core",
3
- "version": "2.9.4",
3
+ "version": "2.9.6",
4
4
  "description": "Shared Node daemon lifecycle primitives for Mearl — process liveness, JSON record registry, log rotation, and a detached daemon manager",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",