@holon-run/agentinbox 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/cli.js CHANGED
@@ -1019,6 +1019,25 @@ async function runServe(args) {
1019
1019
  const daemonPaths = (0, paths_1.resolveDaemonPaths)(process.env, homeOverride);
1020
1020
  const logLevel = (0, logging_1.parseLogLevel)(takeFlagValue(args, "--log-level") ?? process.env.AGENTINBOX_LOG_LEVEL);
1021
1021
  const logger = new logging_1.JsonLogger(logLevel, "agentinbox");
1022
+ // Socket transport: take the admission lock and publish the pid file before
1023
+ // opening the store, so concurrent starters and status checks see this
1024
+ // process during the (possibly slow) store open instead of racing it.
1025
+ let daemonLock = null;
1026
+ if (serveConfig.transport.kind === "socket") {
1027
+ daemonLock = (0, paths_1.daemonLockPath)(serveConfig.transport.socketPath);
1028
+ const acquisition = (0, daemon_1.acquireDaemonLock)(daemonLock);
1029
+ if (!acquisition.acquired) {
1030
+ const holderPid = acquisition.holder?.pid ?? null;
1031
+ logger.warn("daemon.already_running", {
1032
+ pid: holderPid,
1033
+ socketPath: serveConfig.transport.socketPath,
1034
+ lockPath: daemonLock,
1035
+ });
1036
+ console.error(`agentinbox daemon already running${holderPid != null ? ` (pid ${holderPid})` : ""}; not starting a second instance`);
1037
+ process.exit(0);
1038
+ }
1039
+ (0, daemon_1.writePidFile)(daemonPaths.pidPath, process.pid);
1040
+ }
1022
1041
  const store = await store_1.AgentInboxStore.open(serveConfig.dbPath);
1023
1042
  let service;
1024
1043
  const adapters = new adapters_1.AdapterRegistry(store, async (input) => service.appendSourceEvent(input), {
@@ -1029,9 +1048,6 @@ async function runServe(args) {
1029
1048
  await adapters.start();
1030
1049
  await service.start();
1031
1050
  const controlServer = await (0, control_server_1.startControlServer)(server, serveConfig.transport);
1032
- if (serveConfig.transport.kind === "socket") {
1033
- (0, daemon_1.writePidFile)(daemonPaths.pidPath, process.pid);
1034
- }
1035
1051
  (0, daemon_1.writeDaemonMetadata)(daemonPaths.metadataPath, { logLevel });
1036
1052
  logger.info("daemon.ready", {
1037
1053
  homeDir: serveConfig.homeDir,
@@ -1050,8 +1066,11 @@ async function runServe(args) {
1050
1066
  void adapters.stop();
1051
1067
  void service.stop();
1052
1068
  store.close();
1053
- if (serveConfig.transport.kind === "socket") {
1054
- (0, daemon_1.removePidFile)(daemonPaths.pidPath);
1069
+ if (serveConfig.transport.kind === "socket" && daemonLock != null) {
1070
+ // Ownership: only remove files that still belong to this process so a
1071
+ // replaced or rebound instance keeps its own pid file and socket.
1072
+ (0, daemon_1.removePidFileIfOwned)(daemonPaths.pidPath, process.pid);
1073
+ (0, daemon_1.releaseDaemonLock)(daemonLock, process.pid);
1055
1074
  }
1056
1075
  (0, daemon_1.removePidFile)(daemonPaths.metadataPath);
1057
1076
  process.exit(0);
@@ -3,12 +3,20 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.autostartDisabled = autostartDisabled;
6
7
  exports.ensureDaemonForClient = ensureDaemonForClient;
7
8
  exports.startDaemon = startDaemon;
8
9
  exports.stopDaemon = stopDaemon;
9
10
  exports.daemonStatus = daemonStatus;
10
11
  exports.writePidFile = writePidFile;
11
12
  exports.removePidFile = removePidFile;
13
+ exports.removePidFileIfOwned = removePidFileIfOwned;
14
+ exports.readDaemonLock = readDaemonLock;
15
+ exports.isLockHolderLive = isLockHolderLive;
16
+ exports.readLiveDaemonLockHolder = readLiveDaemonLockHolder;
17
+ exports.acquireDaemonLock = acquireDaemonLock;
18
+ exports.releaseDaemonLock = releaseDaemonLock;
19
+ exports.resolveStartTimeoutMs = resolveStartTimeoutMs;
12
20
  exports.resolveDaemonLogLevel = resolveDaemonLogLevel;
13
21
  exports.writeDaemonMetadata = writeDaemonMetadata;
14
22
  const node_fs_1 = __importDefault(require("node:fs"));
@@ -17,11 +25,20 @@ const node_child_process_1 = require("node:child_process");
17
25
  const client_1 = require("./client");
18
26
  const paths_1 = require("./paths");
19
27
  const logging_1 = require("./logging");
20
- const START_TIMEOUT_MS = 15_000;
28
+ const util_1 = require("./util");
29
+ const DEFAULT_START_TIMEOUT_MS = 60_000;
30
+ // A freshly created but still-empty lock file may belong to a process between
31
+ // create and write; never steal it within this grace window.
32
+ const STALE_LOCK_GRACE_MS = 5_000;
21
33
  const PACKAGE_VERSION = readOwnPackageVersion();
34
+ /** AGENTINBOX_NO_AUTOSTART disables implicit daemon spawning (systemd-style deployments). */
35
+ function autostartDisabled(env = process.env) {
36
+ return (0, util_1.isEnvFlagEnabled)(env.AGENTINBOX_NO_AUTOSTART);
37
+ }
22
38
  async function ensureDaemonForClient(options = {}) {
39
+ const env = options.env ?? process.env;
23
40
  const transport = resolveDaemonClientTransport(options);
24
- if (options.noAutoStart || transport.kind !== "socket") {
41
+ if (options.noAutoStart || autostartDisabled(env) || transport.kind !== "socket") {
25
42
  return transport;
26
43
  }
27
44
  if (await canReachHealthz(transport)) {
@@ -37,7 +54,6 @@ async function startDaemon(options = {}) {
37
54
  const homeDir = (0, paths_1.resolveAgentInboxHome)(env, options.homeDirOverride);
38
55
  const { pidPath, logPath } = (0, paths_1.resolveDaemonPaths)(env, options.homeDirOverride);
39
56
  node_fs_1.default.mkdirSync(homeDir, { recursive: true });
40
- cleanupStalePidFile(pidPath);
41
57
  if (await canReachHealthz(transport)) {
42
58
  const pid = readPidFile(pidPath);
43
59
  return {
@@ -49,6 +65,31 @@ async function startDaemon(options = {}) {
49
65
  transport,
50
66
  };
51
67
  }
68
+ // Admission: a live lock holder is starting (or serving) this socket — never
69
+ // spawn a competing daemon. This check is advisory; the spawned serve
70
+ // process enforces the lock authoritatively.
71
+ const lockPath = (0, paths_1.daemonLockPath)(transport.socketPath);
72
+ const timeoutMs = resolveStartTimeoutMs(env);
73
+ let pendingHolder = readLiveDaemonLockHolder(lockPath);
74
+ while (pendingHolder) {
75
+ const outcome = await waitForDaemonReady(transport, lockPath, timeoutMs);
76
+ if (outcome === "ready") {
77
+ const pid = readPidFile(pidPath) ?? pendingHolder.pid;
78
+ return {
79
+ started: false,
80
+ pid,
81
+ logLevel,
82
+ pidPath,
83
+ logPath,
84
+ transport,
85
+ };
86
+ }
87
+ if (outcome === "timeout") {
88
+ throw new Error(`AgentInbox daemon is already starting (pid ${pendingHolder.pid}) but did not become ready within ${timeoutMs}ms`);
89
+ }
90
+ // The pending holder exited before becoming ready; re-check admission.
91
+ pendingHolder = readLiveDaemonLockHolder(lockPath);
92
+ }
52
93
  const logFd = openLogFile(logPath);
53
94
  let child;
54
95
  try {
@@ -69,7 +110,7 @@ async function startDaemon(options = {}) {
69
110
  node_fs_1.default.closeSync(logFd);
70
111
  }
71
112
  child.unref();
72
- await waitForHealthz(transport, START_TIMEOUT_MS);
113
+ await waitForHealthz(transport, timeoutMs);
73
114
  return {
74
115
  started: true,
75
116
  pid: child.pid ?? -1,
@@ -83,42 +124,69 @@ async function stopDaemon(options = {}) {
83
124
  const env = options.env ?? process.env;
84
125
  const transport = requireSocketTransport(resolveDaemonClientTransport(options), "daemon stop");
85
126
  const { pidPath, logPath, metadataPath } = (0, paths_1.resolveDaemonPaths)(env, options.homeDirOverride);
86
- const pid = readPidFile(pidPath);
87
- if (pid != null && isProcessAlive(pid)) {
127
+ let pid = readPidFile(pidPath);
128
+ if (pid == null || !(0, util_1.isPidAlive)(pid)) {
129
+ // The pid file may be stale or overwritten while the live daemon still
130
+ // owns the socket; fall back to the admission lock holder.
131
+ pid = readLiveDaemonLockHolder((0, paths_1.daemonLockPath)(transport.socketPath))?.pid ?? pid;
132
+ }
133
+ if (pid != null && (0, util_1.isPidAlive)(pid)) {
88
134
  process.kill(pid, "SIGTERM");
89
135
  await waitForProcessExit(pid, 3_000);
90
136
  }
91
- cleanupStalePidFile(pidPath, true);
92
- cleanupFile(transport.socketPath);
93
- cleanupFile(metadataPath);
137
+ // Ownership: if an instance we did not stop still serves the socket, the
138
+ // files may now belong to it — leave them untouched. Otherwise remove the
139
+ // pid file only when it no longer references a live process.
140
+ if (!(await canReachHealthz(transport))) {
141
+ cleanupStalePidFile(pidPath);
142
+ cleanupFile(transport.socketPath);
143
+ cleanupFile(metadataPath);
144
+ }
94
145
  return daemonStatus(options);
95
146
  }
96
147
  async function daemonStatus(options = {}) {
97
148
  const env = options.env ?? process.env;
98
149
  const transport = requireSocketTransport(resolveDaemonClientTransport(options), "daemon status");
99
150
  const { pidPath, logPath, metadataPath } = (0, paths_1.resolveDaemonPaths)(env, options.homeDirOverride);
100
- const pid = readPidFile(pidPath);
101
- if (pid != null && !isProcessAlive(pid)) {
102
- cleanupStalePidFile(pidPath, true);
103
- cleanupFile(transport.socketPath);
104
- cleanupFile(metadataPath);
105
- return {
106
- running: false,
107
- pid: null,
108
- logLevel: null,
109
- version: null,
110
- startedAt: null,
111
- command: null,
112
- nodeVersion: null,
113
- pidPath,
114
- logPath,
115
- transport,
116
- };
151
+ let pid = readPidFile(pidPath);
152
+ if (pid != null && !(0, util_1.isPidAlive)(pid)) {
153
+ const holder = readLiveDaemonLockHolder((0, paths_1.daemonLockPath)(transport.socketPath));
154
+ if (holder != null) {
155
+ // The admission lock identifies the live daemon even when the pid file
156
+ // is stale (e.g. overwritten by a mixed-version writer).
157
+ pid = holder.pid;
158
+ }
159
+ else if (!(await canReachHealthz(transport))) {
160
+ // Nothing is serving and no live owner exists: clean stale files.
161
+ cleanupStalePidFile(pidPath);
162
+ cleanupFile(transport.socketPath);
163
+ cleanupFile(metadataPath);
164
+ return {
165
+ running: false,
166
+ starting: false,
167
+ pid: null,
168
+ logLevel: null,
169
+ version: null,
170
+ startedAt: null,
171
+ command: null,
172
+ nodeVersion: null,
173
+ pidPath,
174
+ logPath,
175
+ transport,
176
+ };
177
+ }
178
+ else {
179
+ // Something answers healthz (e.g. an older daemon without a lock);
180
+ // report it without owning its files.
181
+ pid = null;
182
+ }
117
183
  }
118
184
  const processInfo = pid == null ? null : readProcessMetadata(pid);
119
185
  const daemonMetadata = readDaemonMetadata(metadataPath);
186
+ const running = await canReachHealthz(transport);
120
187
  return {
121
- running: await canReachHealthz(transport),
188
+ running,
189
+ starting: !running && pid != null && (0, util_1.isPidAlive)(pid),
122
190
  pid,
123
191
  logLevel: daemonMetadata?.logLevel ?? null,
124
192
  version: pid == null ? null : PACKAGE_VERSION,
@@ -137,6 +205,127 @@ function writePidFile(pidPath, pid) {
137
205
  function removePidFile(pidPath) {
138
206
  cleanupFile(pidPath);
139
207
  }
208
+ /** Removes the pid file only when it still belongs to the given pid. */
209
+ function removePidFileIfOwned(pidPath, pid) {
210
+ if (readPidFile(pidPath) !== pid) {
211
+ return false;
212
+ }
213
+ cleanupFile(pidPath);
214
+ return true;
215
+ }
216
+ function readDaemonLock(lockPath) {
217
+ try {
218
+ const parsed = JSON.parse(node_fs_1.default.readFileSync(lockPath, "utf8"));
219
+ if (typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid <= 0) {
220
+ return null;
221
+ }
222
+ return {
223
+ pid: parsed.pid,
224
+ processStartedAt: typeof parsed.processStartedAt === "string" ? parsed.processStartedAt : null,
225
+ acquiredAt: typeof parsed.acquiredAt === "string" ? parsed.acquiredAt : new Date(0).toISOString(),
226
+ };
227
+ }
228
+ catch {
229
+ return null;
230
+ }
231
+ }
232
+ function isLockHolderLive(holder) {
233
+ if (!(0, util_1.isPidAlive)(holder.pid)) {
234
+ return false;
235
+ }
236
+ if (holder.processStartedAt == null) {
237
+ return true;
238
+ }
239
+ const current = readProcessMetadata(holder.pid);
240
+ if (current?.startedAt == null) {
241
+ return true;
242
+ }
243
+ return current.startedAt === holder.processStartedAt;
244
+ }
245
+ function readLiveDaemonLockHolder(lockPath) {
246
+ const holder = readDaemonLock(lockPath);
247
+ return holder != null && isLockHolderLive(holder) ? holder : null;
248
+ }
249
+ /**
250
+ * Atomically (O_EXCL) acquires the daemon admission lock for this process.
251
+ * A stale lock (dead pid or pid reuse) is removed and retried once. A freshly
252
+ * created but still-empty lock is treated as held to avoid racing a concurrent
253
+ * creator between create and write.
254
+ */
255
+ function acquireDaemonLock(lockPath) {
256
+ let holder = null;
257
+ for (let attempt = 0; attempt < 2; attempt += 1) {
258
+ holder = null;
259
+ try {
260
+ const metadata = readProcessMetadata(process.pid);
261
+ const info = {
262
+ pid: process.pid,
263
+ processStartedAt: metadata?.startedAt ?? null,
264
+ acquiredAt: new Date().toISOString(),
265
+ };
266
+ node_fs_1.default.writeFileSync(lockPath, `${JSON.stringify(info)}\n`, { encoding: "utf8", flag: "wx" });
267
+ return { acquired: true, lockPath, holder: null };
268
+ }
269
+ catch (error) {
270
+ if (!isFileExistsError(error)) {
271
+ throw error;
272
+ }
273
+ }
274
+ holder = readDaemonLock(lockPath);
275
+ if (holder != null && isLockHolderLive(holder)) {
276
+ return { acquired: false, lockPath, holder };
277
+ }
278
+ if (holder == null && isLockFileFresh(lockPath)) {
279
+ return { acquired: false, lockPath, holder: null };
280
+ }
281
+ cleanupFile(lockPath);
282
+ }
283
+ return { acquired: false, lockPath, holder };
284
+ }
285
+ /** Releases the lock only when it still belongs to the given pid. */
286
+ function releaseDaemonLock(lockPath, pid) {
287
+ const holder = readDaemonLock(lockPath);
288
+ if (holder?.pid === pid) {
289
+ cleanupFile(lockPath);
290
+ }
291
+ }
292
+ function resolveStartTimeoutMs(env = process.env) {
293
+ const raw = env.AGENTINBOX_START_TIMEOUT_MS;
294
+ if (raw == null || raw.trim() === "") {
295
+ return DEFAULT_START_TIMEOUT_MS;
296
+ }
297
+ const parsed = Number.parseInt(raw, 10);
298
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_START_TIMEOUT_MS;
299
+ }
300
+ function isFileExistsError(error) {
301
+ return typeof error === "object" && error != null && error.code === "EEXIST";
302
+ }
303
+ function isLockFileFresh(lockPath) {
304
+ try {
305
+ return Date.now() - node_fs_1.default.statSync(lockPath).mtimeMs < STALE_LOCK_GRACE_MS;
306
+ }
307
+ catch {
308
+ return false;
309
+ }
310
+ }
311
+ /**
312
+ * Waits for the daemon holding the lock to serve healthz. Returns "lost" when
313
+ * the holder disappears without becoming ready so the caller can re-check
314
+ * admission instead of timing out.
315
+ */
316
+ async function waitForDaemonReady(transport, lockPath, timeoutMs) {
317
+ const startedAt = Date.now();
318
+ while (Date.now() - startedAt < timeoutMs) {
319
+ if (await canReachHealthz(transport)) {
320
+ return "ready";
321
+ }
322
+ if (readLiveDaemonLockHolder(lockPath) == null) {
323
+ return "lost";
324
+ }
325
+ await sleep(100);
326
+ }
327
+ return "timeout";
328
+ }
140
329
  function daemonChildArgs() {
141
330
  const execArgv = [...process.execArgv];
142
331
  return [...execArgv, process.argv[1], "serve"];
@@ -232,20 +421,20 @@ async function waitForHealthz(transport, timeoutMs) {
232
421
  async function waitForProcessExit(pid, timeoutMs) {
233
422
  const startedAt = Date.now();
234
423
  while (Date.now() - startedAt < timeoutMs) {
235
- if (!isProcessAlive(pid)) {
424
+ if (!(0, util_1.isPidAlive)(pid)) {
236
425
  return;
237
426
  }
238
427
  await sleep(100);
239
428
  }
240
429
  throw new Error(`timed out waiting for process ${pid} to exit`);
241
430
  }
242
- function cleanupStalePidFile(pidPath, force = false) {
431
+ function cleanupStalePidFile(pidPath) {
243
432
  const pid = readPidFile(pidPath);
244
433
  if (pid == null) {
245
434
  cleanupFile(pidPath);
246
435
  return;
247
436
  }
248
- if (force || !isProcessAlive(pid)) {
437
+ if (!(0, util_1.isPidAlive)(pid)) {
249
438
  cleanupFile(pidPath);
250
439
  }
251
440
  }
@@ -325,15 +514,6 @@ function inferNodeVersionFromCommand(command) {
325
514
  const match = command.match(/node\/(v\d+\.\d+\.\d+)\//);
326
515
  return match ? match[1] : null;
327
516
  }
328
- function isProcessAlive(pid) {
329
- try {
330
- process.kill(pid, 0);
331
- return true;
332
- }
333
- catch {
334
- return false;
335
- }
336
- }
337
517
  function cleanupFile(filePath) {
338
518
  try {
339
519
  if (node_fs_1.default.existsSync(filePath)) {
package/dist/src/paths.js CHANGED
@@ -7,6 +7,7 @@ exports.DEFAULT_AGENTINBOX_PORT = void 0;
7
7
  exports.resolveAgentInboxHome = resolveAgentInboxHome;
8
8
  exports.resolveServeConfig = resolveServeConfig;
9
9
  exports.resolveDaemonPaths = resolveDaemonPaths;
10
+ exports.daemonLockPath = daemonLockPath;
10
11
  exports.resolveClientTransport = resolveClientTransport;
11
12
  const node_fs_1 = __importDefault(require("node:fs"));
12
13
  const node_os_1 = __importDefault(require("node:os"));
@@ -74,6 +75,13 @@ function resolveDaemonPaths(env = process.env, homeDirOverride) {
74
75
  metadataPath: node_path_1.default.join(homeDir, "agentinbox.daemon.json"),
75
76
  };
76
77
  }
78
+ /**
79
+ * Admission lock for the daemon serving a given socket. Scoped to the socket
80
+ * identity (not the home dir) so an explicit --socket gets its own lock.
81
+ */
82
+ function daemonLockPath(socketPath) {
83
+ return `${socketPath}.lock`;
84
+ }
77
85
  function resolveClientTransport(input = {}) {
78
86
  const env = input.env ?? process.env;
79
87
  const homeDir = resolveAgentInboxHome(env, input.homeDirOverride);
@@ -2243,9 +2243,15 @@ class AgentInboxService {
2243
2243
  return;
2244
2244
  }
2245
2245
  const state = this.store.getActivationDispatchState(buffer.agentId, buffer.targetId);
2246
+ // Within an active notify lease, buffered entries merge into the pending
2247
+ // set (below) instead of re-dispatching. Webhook targets used to
2248
+ // re-dispatch every flush window during the lease, which queued one
2249
+ // wake per window downstream and produced empty wake turns when the
2250
+ // consuming turn acked everything (issue #233). Merge semantics now
2251
+ // match terminal targets: re-dispatch happens on ack (when pending new
2252
+ // items exist) or lease expiry.
2246
2253
  const shouldAttemptDispatch = !state
2247
- || (state.status === "dirty" && state.leaseExpiresAt == null)
2248
- || (state.status === "notified" && buffer.targetKind === "webhook");
2254
+ || (state.status === "dirty" && state.leaseExpiresAt == null);
2249
2255
  if (shouldAttemptDispatch) {
2250
2256
  const pendingState = state?.status === "dirty" ? state : null;
2251
2257
  const inbox = this.ensureInboxForAgent(buffer.agentId);
@@ -2427,6 +2433,13 @@ class AgentInboxService {
2427
2433
  if (reason === "ack" && state.status !== "dirty") {
2428
2434
  return;
2429
2435
  }
2436
+ if (reason === "ack" && target.kind === "webhook" && state.pendingNewItemCount <= 0) {
2437
+ // Ack-driven re-dispatch for webhook targets requires items that no
2438
+ // previous notification covered. Without new information a POST would
2439
+ // only wake the runtime about entries it already knows. Terminal
2440
+ // targets keep ack-driven re-render behavior.
2441
+ return;
2442
+ }
2430
2443
  if (reason === "lease" && state.status === "notified") {
2431
2444
  // No new items since last notification — just renew lease for both
2432
2445
  // terminal and webhook targets. Re-dispatching would cause an infinite
package/dist/src/store.js CHANGED
@@ -35,8 +35,10 @@ class AgentInboxStore {
35
35
  this.dbPath = dbPath;
36
36
  this.db = db;
37
37
  }
38
- static async open(dbPath) {
38
+ static async open(dbPath, options = {}) {
39
+ const env = options.env ?? process.env;
39
40
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(dbPath), { recursive: true });
41
+ removeOrphanBackupTmps(dbPath);
40
42
  const existedBeforeOpen = node_fs_1.default.existsSync(dbPath);
41
43
  let db = await this.openDatabaseWithRecovery(dbPath);
42
44
  let store = new AgentInboxStore(dbPath, db);
@@ -46,7 +48,7 @@ class AgentInboxStore {
46
48
  db = this.openDatabase(dbPath);
47
49
  store = new AgentInboxStore(dbPath, db);
48
50
  }
49
- else if (existedBeforeOpen) {
51
+ else if (existedBeforeOpen && startupBackupEnabled(env)) {
50
52
  await store.backupHealthyDatabase();
51
53
  }
52
54
  store.migrate();
@@ -2382,3 +2384,40 @@ function uniqueSorted(values) {
2382
2384
  function summarizeBackfilledItemEntry(item) {
2383
2385
  return `${item.eventVariant} from ${item.sourceId}`;
2384
2386
  }
2387
+ function startupBackupEnabled(env) {
2388
+ return !(0, util_1.isEnvFlagDisabled)(env.AGENTINBOX_STARTUP_BACKUP);
2389
+ }
2390
+ /**
2391
+ * Removes leftover `<db>.bak.<pid>.tmp` files whose owning process is dead.
2392
+ * Tmp files owned by a live pid belong to a concurrent open and are preserved.
2393
+ */
2394
+ function removeOrphanBackupTmps(dbPath) {
2395
+ const dir = node_path_1.default.dirname(dbPath);
2396
+ const pattern = new RegExp(`^${escapeRegExp(node_path_1.default.basename(dbPath))}\\.bak\\.(\\d+)\\.tmp$`);
2397
+ let entries;
2398
+ try {
2399
+ entries = node_fs_1.default.readdirSync(dir);
2400
+ }
2401
+ catch {
2402
+ return;
2403
+ }
2404
+ for (const name of entries) {
2405
+ const match = pattern.exec(name);
2406
+ if (!match) {
2407
+ continue;
2408
+ }
2409
+ const pid = Number.parseInt(match[1], 10);
2410
+ if (Number.isInteger(pid) && pid > 0 && (0, util_1.isPidAlive)(pid)) {
2411
+ continue;
2412
+ }
2413
+ try {
2414
+ node_fs_1.default.rmSync(node_path_1.default.join(dir, name), { force: true });
2415
+ }
2416
+ catch {
2417
+ // Best-effort GC.
2418
+ }
2419
+ }
2420
+ }
2421
+ function escapeRegExp(value) {
2422
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2423
+ }
package/dist/src/util.js CHANGED
@@ -4,6 +4,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.nowIso = nowIso;
7
+ exports.isPidAlive = isPidAlive;
8
+ exports.isEnvFlagDisabled = isEnvFlagDisabled;
9
+ exports.isEnvFlagEnabled = isEnvFlagEnabled;
7
10
  exports.generateCanonicalId = generateCanonicalId;
8
11
  exports.generateId = generateId;
9
12
  exports.generateShortToken = generateShortToken;
@@ -21,6 +24,32 @@ const ENTRY_THREAD_ID_PATTERN = new RegExp(`^(ent|thr)_[${CANONICAL_ID_ALPHABET}
21
24
  function nowIso() {
22
25
  return new Date().toISOString();
23
26
  }
27
+ function isPidAlive(pid) {
28
+ try {
29
+ process.kill(pid, 0);
30
+ return true;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ /**
37
+ * Treats an env flag as disabled only when it holds an explicit falsy word.
38
+ * Missing or empty values mean "unset" and keep the default behavior.
39
+ */
40
+ function isEnvFlagDisabled(raw) {
41
+ if (raw == null) {
42
+ return false;
43
+ }
44
+ const normalized = raw.trim().toLowerCase();
45
+ return normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off";
46
+ }
47
+ /**
48
+ * Treats an env flag as set only when it holds a non-empty, non-falsy value.
49
+ */
50
+ function isEnvFlagEnabled(raw) {
51
+ return raw != null && raw.trim() !== "" && !isEnvFlagDisabled(raw);
52
+ }
24
53
  function generateCanonicalId(prefix, length = CANONICAL_ID_TOKEN_LENGTH) {
25
54
  return `${prefix}_${generateShortToken(length)}`;
26
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holon-run/agentinbox",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "Local event subscription and delivery service for agents.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {