@inetafrica/open-claudia 2.15.0 → 3.0.1

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 (152) hide show
  1. package/.env.example +30 -4
  2. package/CHANGELOG.md +22 -0
  3. package/README.md +87 -66
  4. package/bin/agent.js +44 -18
  5. package/bin/cli.js +30 -28
  6. package/bin/dream.js +5 -11
  7. package/bin/loopback-client.js +29 -5
  8. package/bin/schedule.js +19 -5
  9. package/bin/tool.js +23 -5
  10. package/bot-agent.js +5 -2350
  11. package/bot.js +81 -3
  12. package/channels/telegram/adapter.js +65 -5
  13. package/channels/voice/adapter.js +1 -0
  14. package/channels/voice/manage.js +43 -5
  15. package/config-dir.js +3 -11
  16. package/core/actions.js +155 -40
  17. package/core/approvals.js +136 -29
  18. package/core/auth-flow.js +103 -19
  19. package/core/config-dir.js +19 -0
  20. package/core/config.js +115 -69
  21. package/core/doctor.js +111 -57
  22. package/core/dream.js +219 -62
  23. package/core/enforcer.js +0 -0
  24. package/core/entities.js +2 -1
  25. package/core/fsutil.js +21 -4
  26. package/core/handlers.js +542 -224
  27. package/core/ideas.js +2 -1
  28. package/core/jobs.js +589 -72
  29. package/core/lessons.js +3 -2
  30. package/core/loopback.js +42 -6
  31. package/core/memory-mutation-queue.js +241 -0
  32. package/core/migration-backup.js +1060 -0
  33. package/core/pack-review.js +295 -88
  34. package/core/packs.js +4 -3
  35. package/core/persona.js +2 -1
  36. package/core/process-tree.js +19 -2
  37. package/core/provider-migration.js +39 -0
  38. package/core/provider-status.js +80 -0
  39. package/core/providers/claude-events.js +121 -0
  40. package/core/providers/claude-hook.js +114 -0
  41. package/core/providers/claude.js +296 -0
  42. package/core/providers/codex-events.js +125 -0
  43. package/core/providers/codex-hook.js +280 -0
  44. package/core/providers/codex.js +286 -0
  45. package/core/providers/contract.js +153 -0
  46. package/core/providers/env.js +148 -0
  47. package/core/providers/events.js +171 -0
  48. package/core/providers/hook-command.js +22 -0
  49. package/core/providers/index.js +240 -0
  50. package/core/providers/utility-policy.js +269 -0
  51. package/core/recall/classic.js +2 -2
  52. package/core/recall/discoverer.js +71 -22
  53. package/core/recall/metrics.js +27 -1
  54. package/core/recall/tuning.js +4 -1
  55. package/core/recall/warm-walker.js +151 -108
  56. package/core/recall-filter.js +86 -61
  57. package/core/router.js +79 -7
  58. package/core/run-context.js +185 -0
  59. package/core/runner.js +1562 -1286
  60. package/core/scheduler.js +515 -210
  61. package/core/side-chat.js +346 -0
  62. package/core/single-instance.js +46 -0
  63. package/core/state.js +1084 -97
  64. package/core/subagent.js +72 -98
  65. package/core/system-prompt.js +462 -279
  66. package/core/tool-guard.js +73 -39
  67. package/core/tools.js +22 -5
  68. package/core/transcripts.js +30 -1
  69. package/core/usage-log.js +19 -4
  70. package/core/utility-agent.js +654 -0
  71. package/core/web-auth.js +29 -13
  72. package/docs/CHANNEL_DESIGN.md +181 -0
  73. package/docs/MULTI_CHANNEL_PLAN.md +254 -0
  74. package/docs/PROVIDER_MIGRATION.md +67 -0
  75. package/health.js +50 -39
  76. package/package.json +48 -4
  77. package/setup.js +198 -38
  78. package/soul.md +39 -0
  79. package/test-ability-extraction.js +5 -0
  80. package/test-approval-async.js +63 -4
  81. package/test-cursor-removal.js +337 -0
  82. package/test-enforcer.js +70 -27
  83. package/test-fixtures/current-provider-parity.json +132 -0
  84. package/test-fixtures/fake-agent-cli.js +509 -0
  85. package/test-fixtures/migrations/claude-only/jobs.json +3 -0
  86. package/test-fixtures/migrations/claude-only/sessions.json +5 -0
  87. package/test-fixtures/migrations/claude-only/state.json +5 -0
  88. package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
  89. package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
  90. package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
  91. package/test-fixtures/migrations/cursor-selected/state.json +6 -0
  92. package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
  93. package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
  94. package/test-fixtures/migrations/dual-provider/state.json +10 -0
  95. package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
  96. package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
  97. package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
  98. package/test-fixtures/migrations/malformed-partial/state.json +1 -0
  99. package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
  100. package/test-fixtures/migrations/missing-project/jobs.json +3 -0
  101. package/test-fixtures/migrations/missing-project/sessions.json +3 -0
  102. package/test-fixtures/migrations/missing-project/state.json +4 -0
  103. package/test-fixtures/migrations/multi-user/jobs.json +4 -0
  104. package/test-fixtures/migrations/multi-user/sessions.json +4 -0
  105. package/test-fixtures/migrations/multi-user/state.json +6 -0
  106. package/test-fixtures/provider-event-samples.json +17 -0
  107. package/test-learning-e2e.js +5 -0
  108. package/test-memory-mutation-queue.js +191 -0
  109. package/test-provider-boot-matrix.js +399 -0
  110. package/test-provider-bootstrap.js +158 -0
  111. package/test-provider-capabilities.js +232 -0
  112. package/test-provider-claude.js +206 -0
  113. package/test-provider-codex.js +228 -0
  114. package/test-provider-compaction.js +371 -0
  115. package/test-provider-core-prompt.js +264 -0
  116. package/test-provider-coupling-audit.js +90 -0
  117. package/test-provider-dream.js +312 -0
  118. package/test-provider-enforcer.js +252 -0
  119. package/test-provider-env-isolation.js +150 -0
  120. package/test-provider-events.js +171 -0
  121. package/test-provider-fixture.js +332 -0
  122. package/test-provider-gateway.js +508 -0
  123. package/test-provider-language.js +89 -0
  124. package/test-provider-migration-backup.js +424 -0
  125. package/test-provider-pack-review.js +436 -0
  126. package/test-provider-parity-e2e.js +537 -0
  127. package/test-provider-prompt-parity.js +89 -0
  128. package/test-provider-recall.js +271 -0
  129. package/test-provider-registry.js +251 -0
  130. package/test-provider-resume-parity.js +41 -0
  131. package/test-provider-run-lifecycle.js +349 -0
  132. package/test-provider-runner.js +271 -0
  133. package/test-provider-scheduler.js +689 -0
  134. package/test-provider-session-commands.js +185 -0
  135. package/test-provider-session-history.js +205 -0
  136. package/test-provider-side-chat.js +337 -0
  137. package/test-provider-state-migration.js +316 -0
  138. package/test-provider-stream-decoder.js +69 -0
  139. package/test-provider-subagent.js +228 -0
  140. package/test-provider-tool-hooks.js +360 -0
  141. package/test-recall-discoverer.js +1 -0
  142. package/test-recall-engine.js +3 -3
  143. package/test-recall-evolution.js +18 -0
  144. package/test-runner-watchdog-static.js +16 -8
  145. package/test-single-runtime.js +35 -0
  146. package/test-telegram-poll-recovery.js +93 -0
  147. package/test-tool-guard.js +56 -0
  148. package/test-tools.js +19 -0
  149. package/test-unified-identity.js +3 -1
  150. package/test-usage-accounting.js +92 -20
  151. package/test-utility-provider-policy.js +486 -0
  152. package/web.js +130 -35
@@ -0,0 +1,346 @@
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const { deepFreeze } = require("./run-context");
7
+ const { redactSensitive } = require("./redact");
8
+
9
+ const ACTIVE_TASK_DESCRIPTION_MAX = 320;
10
+ const SIDE_QUESTION_MAX = 8000;
11
+ const SIDE_RESPONSE_MAX = 3500;
12
+ const SIDE_TIMEOUT_MS = 90 * 1000;
13
+ const DEFAULT_PENDING_TTL_MS = 15 * 60 * 1000;
14
+ const DEFAULT_PENDING_LIMIT = 20;
15
+
16
+ function boundedText(value, maxLength) {
17
+ const clean = redactSensitive(String(value || "")).replace(/\s+/g, " ").trim();
18
+ if (clean.length <= maxLength) return clean;
19
+ return `${clean.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
20
+ }
21
+
22
+ function readRuntimeMode(configDir) {
23
+ try {
24
+ const mode = fs.readFileSync(path.join(configDir, ".bot-mode"), "utf8").trim().toLowerCase();
25
+ if (mode === "agent") return "agent";
26
+ } catch (_) {}
27
+ return "direct";
28
+ }
29
+
30
+ function randomToken() {
31
+ return crypto.randomBytes(9).toString("base64url");
32
+ }
33
+
34
+ function sideRunId(originalRunId, token) {
35
+ const base = String(originalRunId || "run").slice(0, 40);
36
+ return `side-${base}-${token}`;
37
+ }
38
+
39
+ function createSideChatRequest({
40
+ runContext,
41
+ activeRunContext = null,
42
+ token = randomToken(),
43
+ now = Date.now(),
44
+ } = {}) {
45
+ if (!runContext || typeof runContext !== "object") throw new TypeError("captured runContext is required");
46
+ if (!runContext.canonicalUserId) throw new TypeError("side chat requires a canonical user");
47
+ const activeTaskDescription = boundedText(
48
+ activeRunContext?.userPrompt || activeRunContext?.label || "Open Claudia is working on the active task.",
49
+ ACTIVE_TASK_DESCRIPTION_MAX,
50
+ );
51
+ const question = boundedText(runContext.userPrompt, SIDE_QUESTION_MAX);
52
+ const providerSettings = {
53
+ ...(runContext.providerSettings || {}),
54
+ budget: null,
55
+ permissionMode: "plan",
56
+ worktree: false,
57
+ };
58
+ const sideContext = deepFreeze({
59
+ ...runContext,
60
+ runId: sideRunId(runContext.runId, token),
61
+ admittedAt: now,
62
+ timeoutMs: SIDE_TIMEOUT_MS,
63
+ purpose: "side-chat",
64
+ providerSettings,
65
+ sessionId: null,
66
+ admittedSessionId: null,
67
+ previousSessionId: null,
68
+ resumeSessionId: null,
69
+ continueSession: false,
70
+ fresh: true,
71
+ skipAutoCompact: true,
72
+ requiredDelivery: false,
73
+ userPrompt: question,
74
+ activeTaskDescription,
75
+ maxTurns: 1,
76
+ toolHookSettings: null,
77
+ imagePaths: [],
78
+ attachments: [],
79
+ lastInputWasVoice: false,
80
+ voiceStream: false,
81
+ });
82
+ return deepFreeze({
83
+ token,
84
+ canonicalUserId: runContext.canonicalUserId,
85
+ replyToMsgId: runContext.replyToMsgId,
86
+ provider: runContext.provider,
87
+ project: runContext.project,
88
+ origin: runContext.origin,
89
+ originalMessage: runContext.userPrompt,
90
+ activeTaskDescription,
91
+ activeRunId: activeRunContext?.runId || null,
92
+ originalRunContext: runContext,
93
+ runContext: sideContext,
94
+ });
95
+ }
96
+
97
+ function createSideChatPromptBuilder(buildCoreInstructions) {
98
+ const coreBuilder = buildCoreInstructions || ((opts) => require("./system-prompt").buildCoreInstructions(opts));
99
+ return async function buildSideChatPrompt(userPrompt, { runContext, provider } = {}) {
100
+ const activeTask = boundedText(runContext?.activeTaskDescription, ACTIVE_TASK_DESCRIPTION_MAX);
101
+ const projectName = boundedText(runContext?.project?.name || "unknown project", 120);
102
+ const transport = boundedText(runContext?.origin?.transport || "chat", 40);
103
+ const dynamicContext = [
104
+ "## Isolated side chat",
105
+ "A main Open Claudia task is still running. Answer this one side question briefly and conversationally.",
106
+ `Active task: ${activeTask || "Open Claudia is working on the active task."}`,
107
+ `Captured context: project=${projectName}; channel=${transport}; provider=${provider?.id || runContext?.provider || "unknown"}.`,
108
+ "This is a fresh, read-only, ephemeral response. Do not resume the main provider session, use tools, change files or Open Claudia state, schedule work, update memory, or claim the user's message was added to the main task.",
109
+ "The user can explicitly choose “Add to main task” after this response if they want the original message handled by the main conversation.",
110
+ ].join("\n");
111
+ return {
112
+ coreInstructions: coreBuilder({ runContext }),
113
+ dynamicContext,
114
+ userPrompt: boundedText(userPrompt, SIDE_QUESTION_MAX),
115
+ transcriptPaths: null,
116
+ recallMetadata: { status: "omitted", omitted: true, reason: "isolated_side_chat" },
117
+ };
118
+ };
119
+ }
120
+
121
+ async function defaultDeliver(payload, store) {
122
+ const execute = async () => {
123
+ let text = payload.text;
124
+ try {
125
+ if (require("./relationship").isCurrentSpeakerGuarded()) {
126
+ const guard = await require("./enforcer").guardOutboundReply(text);
127
+ if (!guard.allow) text = "I need to check with the owner before I can answer that side question.";
128
+ }
129
+ } catch (error) {
130
+ text = "I couldn't safely classify that side response, so I held it.";
131
+ }
132
+ return require("./io").send(text, {
133
+ replyTo: payload.replyToMsgId,
134
+ keyboard: {
135
+ inline_keyboard: [[{
136
+ text: payload.action.label,
137
+ callback_data: payload.action.callbackData,
138
+ }]],
139
+ },
140
+ });
141
+ };
142
+ if (store) return require("./context").chatContext.run(store, execute);
143
+ return execute();
144
+ }
145
+
146
+ class SideChatCoordinator {
147
+ constructor(options = {}) {
148
+ this.runSideChat = options.runSideChat || ((request) => require("./runner").runSideChatRequest(request));
149
+ this.enqueueMain = options.enqueueMain || ((request, store, lineage) => require("./runner").enqueueSideChatRequest(request, store, lineage));
150
+ this.deliver = options.deliver || defaultDeliver;
151
+ this.modeReader = options.modeReader || (() => readRuntimeMode(require("./config").CONFIG_DIR));
152
+ this.tokenFactory = options.tokenFactory || randomToken;
153
+ this.now = options.now || Date.now;
154
+ this.pendingTtlMs = options.pendingTtlMs || DEFAULT_PENDING_TTL_MS;
155
+ this.pendingLimit = options.pendingLimit || DEFAULT_PENDING_LIMIT;
156
+ this.activeByUser = new Map();
157
+ this.processByUser = new Map();
158
+ this.cancelledRequests = new Set();
159
+ this.pending = new Map();
160
+ }
161
+
162
+ enabled() {
163
+ return this.modeReader() === "agent";
164
+ }
165
+
166
+ isActive(canonicalUserId) {
167
+ return this.activeByUser.has(String(canonicalUserId));
168
+ }
169
+
170
+ attachProcess(canonicalUserId, proc) {
171
+ if (!proc) return;
172
+ let resolveDone;
173
+ const done = new Promise((resolve) => { resolveDone = resolve; });
174
+ const settle = () => resolveDone();
175
+ proc.once("close", settle);
176
+ proc.once("error", settle);
177
+ this.processByUser.set(String(canonicalUserId), { proc, done });
178
+ }
179
+
180
+ detachProcess(canonicalUserId, proc) {
181
+ const key = String(canonicalUserId);
182
+ const entry = this.processByUser.get(key);
183
+ if (!proc || entry?.proc === proc) this.processByUser.delete(key);
184
+ }
185
+
186
+ async cancel(canonicalUserId) {
187
+ const key = String(canonicalUserId);
188
+ const active = this.activeByUser.get(key);
189
+ if (active) this.cancelledRequests.add(active.token);
190
+ const entry = this.processByUser.get(key);
191
+ if (!entry) return !!active;
192
+ const { proc, done } = entry;
193
+ const { killProcessTree } = require("./process-tree");
194
+ try { killProcessTree(proc.pid, "SIGTERM"); } catch (_) {}
195
+ const termClosed = await Promise.race([
196
+ done.then(() => true),
197
+ new Promise((resolve) => setTimeout(() => resolve(false), 3000)),
198
+ ]);
199
+ if (!termClosed) {
200
+ try { killProcessTree(proc.pid, "SIGKILL"); } catch (_) {}
201
+ await Promise.race([
202
+ done,
203
+ new Promise((resolve) => setTimeout(resolve, 2000)),
204
+ ]);
205
+ }
206
+ if (this.processByUser.get(key) === entry) this.processByUser.delete(key);
207
+ return true;
208
+ }
209
+
210
+ async cancelAll() {
211
+ const users = new Set([...this.activeByUser.keys(), ...this.processByUser.keys()]);
212
+ const outcomes = await Promise.all([...users].map((canonicalUserId) => this.cancel(canonicalUserId)));
213
+ return outcomes.filter(Boolean).length;
214
+ }
215
+
216
+ noteMainResult(runContext, result) {
217
+ if (!runContext?.runId || !result?.ok || !result.sessionId) return;
218
+ for (const entry of this.pending.values()) {
219
+ if (entry.request.activeRunId === runContext.runId) entry.mainSessionId = result.sessionId;
220
+ }
221
+ }
222
+
223
+ prune() {
224
+ const now = this.now();
225
+ for (const [token, entry] of this.pending) {
226
+ if (entry.expiresAt <= now) this.pending.delete(token);
227
+ }
228
+ while (this.pending.size >= this.pendingLimit) {
229
+ this.pending.delete(this.pending.keys().next().value);
230
+ }
231
+ }
232
+
233
+ makePending(input) {
234
+ this.prune();
235
+ let token = this.tokenFactory();
236
+ while (!token || this.pending.has(token)) token = this.tokenFactory();
237
+ const request = createSideChatRequest({
238
+ runContext: input.runContext,
239
+ activeRunContext: input.activeRunContext,
240
+ token,
241
+ now: this.now(),
242
+ });
243
+ this.pending.set(token, {
244
+ request,
245
+ store: input.store || null,
246
+ mainSessionId: null,
247
+ expiresAt: this.now() + this.pendingTtlMs,
248
+ });
249
+ return request;
250
+ }
251
+
252
+ actionFor(request) {
253
+ return {
254
+ label: "Add to main task",
255
+ callbackData: `sc:q:${request.token}`,
256
+ };
257
+ }
258
+
259
+ async respond(input = {}) {
260
+ const request = this.makePending(input);
261
+ const userKey = String(request.canonicalUserId);
262
+ if (this.activeByUser.has(userKey)) {
263
+ try {
264
+ const delivery = await this.deliver({
265
+ text: "A side reply is already running. Add this message to the main task if it should be handled next.",
266
+ replyToMsgId: request.replyToMsgId,
267
+ action: this.actionFor(request),
268
+ }, input.store || null);
269
+ if (delivery?.ok === false) throw new Error(delivery.error?.message || "side response delivery failed");
270
+ return { ok: true, status: "busy", provider: request.provider, purpose: "side-chat", enqueueToken: request.token };
271
+ } catch (error) {
272
+ return { ok: false, status: "failed", provider: request.provider, purpose: "side-chat", delivery: { ok: false, error }, enqueueToken: request.token };
273
+ }
274
+ }
275
+
276
+ this.activeByUser.set(userKey, request);
277
+ let result;
278
+ try {
279
+ result = await this.runSideChat(request);
280
+ } catch (error) {
281
+ result = { ok: false, status: "failed", provider: request.provider, purpose: "side-chat", diagnostic: `Side response failed: ${error.message}`, error };
282
+ }
283
+ try {
284
+ if (this.cancelledRequests.has(request.token)) {
285
+ return { ...result, ok: false, status: "cancelled", enqueueToken: request.token };
286
+ }
287
+ const text = result?.ok
288
+ ? boundedText(result.text || "(no response)", SIDE_RESPONSE_MAX)
289
+ : boundedText(result?.diagnostic || result?.error?.message || "The side response failed.", SIDE_RESPONSE_MAX);
290
+ const delivery = await this.deliver({
291
+ text,
292
+ replyToMsgId: request.replyToMsgId,
293
+ action: this.actionFor(request),
294
+ }, input.store || null);
295
+ if (delivery?.ok === false) throw new Error(delivery.error?.message || "side response delivery failed");
296
+ return { ...result, status: result?.status || (result?.ok ? "succeeded" : "failed"), enqueueToken: request.token };
297
+ } catch (deliveryError) {
298
+ return {
299
+ ...result,
300
+ ok: false,
301
+ status: "failed",
302
+ delivery: { ok: false, error: deliveryError },
303
+ enqueueToken: request.token,
304
+ };
305
+ } finally {
306
+ this.cancelledRequests.delete(request.token);
307
+ if (this.activeByUser.get(userKey) === request) this.activeByUser.delete(userKey);
308
+ }
309
+ }
310
+
311
+ async enqueue(token, canonicalUserId) {
312
+ this.prune();
313
+ const entry = this.pending.get(String(token));
314
+ if (!entry) return { ok: false, status: "expired" };
315
+ if (String(entry.request.canonicalUserId) !== String(canonicalUserId)) {
316
+ return { ok: false, status: "forbidden" };
317
+ }
318
+ this.pending.delete(String(token));
319
+ try {
320
+ const queued = await this.enqueueMain(entry.request, entry.store, {
321
+ mainRunId: entry.request.activeRunId,
322
+ mainSessionId: entry.mainSessionId,
323
+ });
324
+ return { ok: true, status: "queued", ...queued };
325
+ } catch (error) {
326
+ return { ok: false, status: "failed", error };
327
+ }
328
+ }
329
+ }
330
+
331
+ const sideChatCoordinator = new SideChatCoordinator();
332
+
333
+ module.exports = {
334
+ ACTIVE_TASK_DESCRIPTION_MAX,
335
+ DEFAULT_PENDING_LIMIT,
336
+ DEFAULT_PENDING_TTL_MS,
337
+ SIDE_QUESTION_MAX,
338
+ SIDE_RESPONSE_MAX,
339
+ SIDE_TIMEOUT_MS,
340
+ SideChatCoordinator,
341
+ boundedText,
342
+ createSideChatPromptBuilder,
343
+ createSideChatRequest,
344
+ readRuntimeMode,
345
+ sideChatCoordinator,
346
+ };
@@ -0,0 +1,46 @@
1
+ // Single-instance lock on the config dir. Two bot processes sharing one
2
+ // CONFIG_DIR share one Telegram token; Telegram then 409-terminates their
3
+ // getUpdates polls in turns and each side keeps exiting/respawning — the
4
+ // 2026-07-12 restart storm, where a debugger-run dev checkout fought the
5
+ // launchd service copy all morning. Whoever boots second must refuse to
6
+ // start BEFORE touching any channel.
7
+
8
+ "use strict";
9
+
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+
13
+ function pidAlive(pid) {
14
+ try { process.kill(pid, 0); return true; }
15
+ catch (e) { return e.code === "EPERM"; } // EPERM: alive, different owner
16
+ }
17
+
18
+ function acquireSingleInstanceLock({ configDir, name = "bot" }) {
19
+ const lockPath = path.join(configDir, `${name}.lock`);
20
+ try {
21
+ const prev = JSON.parse(fs.readFileSync(lockPath, "utf8"));
22
+ if (prev && prev.pid && prev.pid !== process.pid && pidAlive(prev.pid)) {
23
+ return { acquired: false, holder: prev, lockPath };
24
+ }
25
+ // Missing, corrupt, own-pid, or dead-pid (stale after SIGKILL): claim it.
26
+ } catch (e) {}
27
+ fs.writeFileSync(lockPath, JSON.stringify({
28
+ pid: process.pid,
29
+ startedAt: new Date().toISOString(),
30
+ entrypoint: process.argv[1] || "",
31
+ }));
32
+ let released = false;
33
+ const release = () => {
34
+ if (released) return;
35
+ released = true;
36
+ try {
37
+ const cur = JSON.parse(fs.readFileSync(lockPath, "utf8"));
38
+ // Never delete a lock a successor already owns.
39
+ if (cur && cur.pid === process.pid) fs.unlinkSync(lockPath);
40
+ } catch (e) {}
41
+ };
42
+ process.on("exit", release);
43
+ return { acquired: true, lockPath, release };
44
+ }
45
+
46
+ module.exports = { acquireSingleInstanceLock, pidAlive };