@yugabytedb/meko-mcp 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +152 -27
  2. package/assets/hooks-handlers/codex-session-end.sh +4 -69
  3. package/assets/hooks-handlers/cursor-session-start.sh +19 -1
  4. package/assets/hooks-handlers/lib/capture-notices.js +321 -0
  5. package/assets/hooks-handlers/lib/capture-state.js +2079 -0
  6. package/assets/hooks-handlers/lib/capture-status-cli.js +82 -0
  7. package/assets/hooks-handlers/lib/capture.js +1578 -299
  8. package/assets/hooks-handlers/lib/checkpoint-timer.js +444 -81
  9. package/assets/hooks-handlers/lib/kiro-handler.js +738 -0
  10. package/assets/hooks-handlers/lib/terminal-handoff.js +418 -0
  11. package/assets/hooks-handlers/session-end.sh +3 -34
  12. package/assets/scripts/meko-statusline.sh +41 -1
  13. package/assets/skills/meko-mcp-tools/SKILL.md +72 -125
  14. package/assets/skills/meko-mcp-tools/references/_sections.md +2 -2
  15. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +12 -13
  16. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +15 -12
  17. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +25 -1
  18. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +19 -77
  19. package/assets/skills/meko-mcp-tools/references/tools-overview.md +8 -7
  20. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +22 -1
  21. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +1 -1
  22. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +8 -8
  23. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +1 -1
  24. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
  25. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +6 -1
  26. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +6 -5
  27. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +4 -4
  28. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +7 -6
  29. package/assets/skills/meko-select-datapack-desktop/SKILL.md +1 -1
  30. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +3 -3
  31. package/bin/create-meko-setup.mjs +48 -10
  32. package/lib/capture-status.mjs +67 -0
  33. package/lib/clients/aliases.mjs +5 -0
  34. package/lib/clients/claude-desktop.mjs +120 -5
  35. package/lib/clients/codex.mjs +6 -3
  36. package/lib/clients/index.mjs +2 -0
  37. package/lib/clients/kiro.mjs +705 -0
  38. package/lib/config.mjs +90 -0
  39. package/lib/installer.mjs +1 -0
  40. package/lib/migrate/adapters/codex.mjs +490 -0
  41. package/lib/migrate/id.mjs +28 -3
  42. package/lib/migrate/orchestrator.mjs +34 -7
  43. package/lib/migrate.mjs +4 -2
  44. package/lib/prompt.mjs +16 -1
  45. package/package.json +4 -4
@@ -1,126 +1,489 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * checkpoint-timer.js — Background daemon for periodic conversation capture.
3
+ * checkpoint-timer.js — Per-session daemon: live checkpoints + recovery scheduler.
4
4
  *
5
- * Spawned by session-start.sh at session start. Runs capture.js in checkpoint
6
- * mode every MEKO_CHECKPOINT_INTERVAL seconds (default: 600 = 10 minutes).
5
+ * Spawned by session-start.sh (and Cursor native SessionStart) at session start.
6
+ * Every MEKO_CHECKPOINT_INTERVAL seconds (default 600):
7
+ * 1. Drain this live session via `capture.js checkpoint`
8
+ * 2. Uncapped scan → capped fair recovery of eligible dead/closing sessions
9
+ * 3. Rebuild aggregate health cache
7
10
  *
8
- * Writes PID to ~/.claude/meko-capture/<session-id>.timer.pid for cleanup
9
- * by session-end.sh.
11
+ * Recovery also runs immediately on daemon start, then repeats while work remains.
10
12
  *
11
13
  * Usage: node checkpoint-timer.js <transcript_path>
12
14
  *
13
- * Environment:
14
- * MEKO_CHECKPOINT_INTERVAL Seconds between ticks (default: 600)
15
- * MEKO_TIMER_MAX_AGE Hard self-exit age in seconds (default: 86400 = 24h)
16
- * MEKO_TIMER_IDLE_EXIT Self-exit after the transcript is untouched this
17
- * long, in seconds (default: 7200 = 2h)
18
- * MEKO_TIMER_STARTUP_GRACE Wait this long for the transcript to appear
19
- * before self-exiting (default: 1800 = 30min)
20
- * MEKO_WATERMARK_DIR Watermark directory (default: ~/.claude/meko-capture)
21
- * MEKO_MCP_URL Inherited by capture.js
22
- * MEKO_API_KEY Inherited by capture.js
15
+ * Environment: see docs/plans/meko-capture-v2-contracts.md
23
16
  */
24
17
 
18
+ "use strict";
19
+
25
20
  const { execFile } = require("child_process");
26
21
  const fs = require("fs");
27
22
  const path = require("path");
23
+ const crypto = require("crypto");
24
+ const {
25
+ checkpointInterval,
26
+ checkpointTimeout,
27
+ recoveryMaxSessions,
28
+ recoveryMaxExchanges,
29
+ maxCaptureBatchSize,
30
+ timerMaxAge,
31
+ timerPidPath,
32
+ watermarkDir,
33
+ scanStates,
34
+ sortOldestFirst,
35
+ computeAggregateHealth,
36
+ writeHealthCache,
37
+ importLegacyCaptureErrors,
38
+ legacyErrorsSafeToRemove,
39
+ } = require("./capture-state");
28
40
 
29
- const INTERVAL_SECS = parseInt(process.env.MEKO_CHECKPOINT_INTERVAL || "600", 10);
30
- // Self-exit backstops so an orphaned daemon (its pid file removed out of band,
31
- // a missed SessionEnd, a blocked SIGTERM) cannot tick forever. Both are
32
- // generous enough not to interrupt a genuinely long, active session.
33
- const MAX_AGE_SECS = parseInt(process.env.MEKO_TIMER_MAX_AGE, 10) || 86400;
41
+ const INTERVAL_SECS = Math.max(1, checkpointInterval());
42
+ const CAPTURE_TIMEOUT_MS = Math.max(1, checkpointTimeout()) * 1000;
43
+ const MAX_AGE_SECS = Math.max(1, timerMaxAge());
34
44
  const IDLE_EXIT_SECS = parseInt(process.env.MEKO_TIMER_IDLE_EXIT, 10) || 7200;
35
45
  const STARTUP_GRACE_SECS =
36
46
  parseInt(process.env.MEKO_TIMER_STARTUP_GRACE, 10) || 1800;
37
- const startedAt = Date.now();
38
- const WATERMARK_DIR =
39
- process.env.MEKO_WATERMARK_DIR ||
40
- path.join(process.env.HOME || "~", ".claude", "meko-capture");
41
47
 
48
+ const startedAt = Date.now();
49
+ const STATE_DIR = watermarkDir();
42
50
  const transcriptPath = process.argv[2];
51
+
43
52
  if (!transcriptPath) {
44
- process.stderr.write("[meko-timer] Usage: checkpoint-timer.js <transcript_path>\n");
53
+ process.stderr.write(
54
+ "[meko-timer] Usage: checkpoint-timer.js <transcript_path>\n",
55
+ );
45
56
  process.exit(1);
46
57
  }
47
58
 
48
59
  const sessionId = path.basename(transcriptPath, ".jsonl");
49
- const pidFile = path.join(WATERMARK_DIR, `${sessionId}.timer.pid`);
60
+ const pidFile = timerPidPath(sessionId);
50
61
  const captureScript = path.join(__dirname, "capture.js");
51
62
 
52
- // Write PID file
53
- fs.mkdirSync(WATERMARK_DIR, { recursive: true });
54
- fs.writeFileSync(pidFile, String(process.pid));
63
+ let tickInFlight = false;
64
+ let recoveryInFlight = false;
65
+ let workRemains = true; // force immediate recovery on start
66
+ let startupRecoveryPending = true;
67
+ let startupPersistentQueue = null;
68
+
69
+ function isProcessAlive(pid) {
70
+ if (!Number.isInteger(pid) || pid <= 0) return false;
71
+ try {
72
+ process.kill(pid, 0);
73
+ return true;
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Atomic PID publish (PR #254): write populated candidate inode, then linkSync.
81
+ * Avoids empty-lock races from create-then-write on the published path.
82
+ */
83
+ function acquirePidFile() {
84
+ fs.mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
85
+ const candidateFile = path.join(
86
+ STATE_DIR,
87
+ `${sessionId}.timer.pid.${process.pid}.${Date.now()}.${crypto
88
+ .randomBytes(4)
89
+ .toString("hex")}.tmp`,
90
+ );
91
+ try {
92
+ fs.writeFileSync(candidateFile, String(process.pid), {
93
+ flag: "wx",
94
+ mode: 0o600,
95
+ });
96
+ } catch (err) {
97
+ process.stderr.write(
98
+ `[meko-timer] Could not stage PID file: ${err.message}\n`,
99
+ );
100
+ return false;
101
+ }
102
+
103
+ let acquired = false;
104
+ try {
105
+ for (let attempt = 0; attempt < 2; attempt++) {
106
+ try {
107
+ fs.linkSync(candidateFile, pidFile);
108
+ acquired = true;
109
+ break;
110
+ } catch (err) {
111
+ if (!err || err.code !== "EEXIST") {
112
+ process.stderr.write(
113
+ `[meko-timer] PID publish failed: ${err.message}\n`,
114
+ );
115
+ break;
116
+ }
117
+ let existingPid = NaN;
118
+ try {
119
+ existingPid = parseInt(fs.readFileSync(pidFile, "utf8"), 10);
120
+ } catch {
121
+ existingPid = NaN;
122
+ }
123
+ if (isProcessAlive(existingPid)) {
124
+ process.stderr.write(
125
+ `[meko-timer] Timer already active for ${sessionId} (pid ${existingPid})\n`,
126
+ );
127
+ break;
128
+ }
129
+ try {
130
+ fs.unlinkSync(pidFile);
131
+ } catch {
132
+ /* ignore */
133
+ }
134
+ }
135
+ }
136
+ } finally {
137
+ try {
138
+ fs.unlinkSync(candidateFile);
139
+ } catch {
140
+ /* ignore */
141
+ }
142
+ }
143
+ return acquired;
144
+ }
55
145
 
56
146
  function cleanup() {
57
- try { fs.unlinkSync(pidFile); } catch { /* already gone */ }
147
+ try {
148
+ const recorded = parseInt(fs.readFileSync(pidFile, "utf8"), 10);
149
+ if (recorded === process.pid) fs.unlinkSync(pidFile);
150
+ } catch {
151
+ /* already gone */
152
+ }
58
153
  process.exit(0);
59
154
  }
60
155
 
61
- process.on("SIGTERM", cleanup);
62
- process.on("SIGINT", cleanup);
156
+ function runCaptureChild(mode, payload, timeoutMs) {
157
+ return new Promise((resolve) => {
158
+ const child = execFile(
159
+ process.execPath,
160
+ [captureScript, mode],
161
+ { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 },
162
+ (err, stdout, stderr) => {
163
+ let result = null;
164
+ const text = (stdout || "").trim();
165
+ if (text) {
166
+ try {
167
+ const line = text
168
+ .split(/\r?\n/)
169
+ .find((l) => l.trim().startsWith("{"));
170
+ if (line) result = JSON.parse(line);
171
+ } catch {
172
+ result = null;
173
+ }
174
+ }
175
+ resolve({
176
+ ok: !err,
177
+ error: err ? err.message : null,
178
+ result,
179
+ stderr: stderr || "",
180
+ });
181
+ },
182
+ );
183
+ child.stdin.on("error", () => {});
184
+ child.stdin.end(JSON.stringify(payload || {}));
185
+ });
186
+ }
63
187
 
64
- function tick() {
65
- // Self-exit backstop: never outlive the hard max age. Guards against orphans
66
- // whose pid file was removed out of band (so session-end.sh can't kill them)
67
- // or whose SessionEnd never fired (force-quit / terminal closed).
68
- if (Date.now() - startedAt >= MAX_AGE_SECS * 1000) {
188
+ function rebuildHealth() {
189
+ try {
190
+ const scan = scanStates({ includeBlocked: true });
191
+ const health = computeAggregateHealth(scan);
192
+ writeHealthCache(health);
193
+ } catch (err) {
69
194
  process.stderr.write(
70
- `[meko-timer] Max age (${MAX_AGE_SECS}s) reached; self-exiting to avoid orphan.\n`,
195
+ `[meko-timer] Health rebuild failed: ${err.message}\n`,
71
196
  );
72
- cleanup();
73
- return;
74
197
  }
198
+ }
75
199
 
76
- // Exit if the transcript is gone (session ended but we weren't killed yet).
77
- // SessionStart can precede transcript creation by up to 20 minutes, so a
78
- // missing file during the startup grace period is expected.
79
- let stat;
200
+ function migrateLegacyErrors() {
80
201
  try {
81
- stat = fs.statSync(transcriptPath);
82
- } catch {
83
- if (Date.now() - startedAt < STARTUP_GRACE_SECS * 1000) {
84
- return;
202
+ importLegacyCaptureErrors();
203
+ const uncapped = scanStates({ includeBlocked: true });
204
+ for (const legacyPath of legacyErrorsSafeToRemove(uncapped)) {
205
+ try {
206
+ fs.unlinkSync(legacyPath);
207
+ } catch {
208
+ /* best effort; a future uncapped scan retries */
209
+ }
85
210
  }
86
- cleanup();
87
- return;
211
+ } catch (err) {
212
+ process.stderr.write(
213
+ `[meko-timer] Legacy capture error import failed: ${err.message}\n`,
214
+ );
88
215
  }
216
+ }
89
217
 
90
- // Exit if the transcript has been idle far longer than a checkpoint window —
91
- // the session is effectively over even though we were never signalled.
92
- if (Date.now() - stat.mtimeMs >= IDLE_EXIT_SECS * 1000) {
93
- process.stderr.write(
94
- `[meko-timer] Transcript idle > ${IDLE_EXIT_SECS}s; self-exiting.\n`,
218
+ function refreshStartupPersistentQueue(scan, forcePersistentRetry) {
219
+ const forcedIds = new Set(
220
+ (scan.entries || [])
221
+ .filter(
222
+ (entry) =>
223
+ entry &&
224
+ entry.ok &&
225
+ entry.eligibility &&
226
+ entry.eligibility.persistent_startup_override,
227
+ )
228
+ .map((entry) => entry.session_id),
229
+ );
230
+ if (forcePersistentRetry && startupPersistentQueue === null && scan.ok) {
231
+ // Snapshot the daemon-start backlog. A bounded cycle may attempt only a
232
+ // subset; retain the rest for follow-up cycles without also forcing new
233
+ // persistent failures that happen later in this daemon's lifetime.
234
+ startupPersistentQueue = new Set(forcedIds);
235
+ } else if (startupPersistentQueue) {
236
+ for (const queuedId of startupPersistentQueue) {
237
+ if (!forcedIds.has(queuedId)) startupPersistentQueue.delete(queuedId);
238
+ }
239
+ }
240
+ return forcedIds;
241
+ }
242
+
243
+ /**
244
+ * Uncapped discovery, then capped fair recovery (oldest first).
245
+ * Returns true if more recoverable work may remain for a later cycle.
246
+ */
247
+ async function runRecoveryCycle() {
248
+ if (recoveryInFlight) return workRemains;
249
+ recoveryInFlight = true;
250
+ const forcePersistentRetry = startupRecoveryPending;
251
+ let moreWork = false;
252
+ try {
253
+ const scan = scanStates({
254
+ includeBlocked: true,
255
+ forcePersistentRetry,
256
+ });
257
+ refreshStartupPersistentQueue(scan, forcePersistentRetry);
258
+ const eligible = sortOldestFirst(
259
+ (scan.entries || []).filter(
260
+ (e) =>
261
+ e &&
262
+ e.ok &&
263
+ e.eligibility &&
264
+ e.eligibility.eligible_for_recovery &&
265
+ (!e.eligibility.persistent_startup_override ||
266
+ (startupPersistentQueue && startupPersistentQueue.has(e.session_id))),
267
+ ),
95
268
  );
96
- cleanup();
97
- return;
269
+
270
+ const maxSessions = recoveryMaxSessions();
271
+ const maxExchanges = recoveryMaxExchanges();
272
+ const selected = eligible.slice(0, maxSessions);
273
+ moreWork = eligible.length > selected.length;
274
+
275
+ let budget = maxExchanges;
276
+ for (const entry of selected) {
277
+ if (budget <= 0) {
278
+ moreWork = true;
279
+ break;
280
+ }
281
+ // Skip the live session this daemon owns — live tick handles it.
282
+ if (entry.session_id === sessionId) continue;
283
+
284
+ if (
285
+ entry.eligibility &&
286
+ entry.eligibility.persistent_startup_override &&
287
+ startupPersistentQueue
288
+ ) {
289
+ // One forced attempt per session per daemon start. Remove before
290
+ // launch so a child crash cannot make this daemon hammer the session.
291
+ startupPersistentQueue.delete(entry.session_id);
292
+ }
293
+
294
+ const state = entry.state || {};
295
+ const payload = {
296
+ session_id: entry.session_id,
297
+ transcript_path: state.transcript_path || undefined,
298
+ lifecycle: state.lifecycle === "closing" ? "closing" : "closing",
299
+ confirmed_dead: true,
300
+ recorded_owner_only: true,
301
+ max_exchanges: Math.min(budget, maxCaptureBatchSize()),
302
+ force_retry: Boolean(
303
+ entry.eligibility &&
304
+ entry.eligibility.persistent_startup_override,
305
+ ),
306
+ };
307
+ const outcome = await runCaptureChild(
308
+ "recover",
309
+ payload,
310
+ CAPTURE_TIMEOUT_MS,
311
+ );
312
+ const result = outcome.result;
313
+ if (!outcome.ok) {
314
+ process.stderr.write(
315
+ `[meko-timer] Recovery drain failed for ${entry.session_id}: ${outcome.error}\n`,
316
+ );
317
+ moreWork = true;
318
+ // Stop hammering an unhealthy server this cycle.
319
+ break;
320
+ }
321
+ const captured = Number((result && result.captured) || 0);
322
+ budget -= captured;
323
+ const queued = Number((result && result.queued_remaining) || 0);
324
+ if (queued > 0 || (result && result.status === "retry_wait")) {
325
+ moreWork = true;
326
+ }
327
+ if (result && (result.status === "partial" || result.status === "retry_wait" || result.status === "blocked")) {
328
+ // Continue other sessions unless this looks like a systemic outage.
329
+ if (result.failure_class === "transient" && captured === 0) {
330
+ moreWork = true;
331
+ break;
332
+ }
333
+ }
334
+ }
335
+
336
+ startupRecoveryPending =
337
+ startupPersistentQueue === null || startupPersistentQueue.size > 0;
338
+ if (startupRecoveryPending) moreWork = true;
339
+
340
+ // Also consider pending entries we skipped due to retry_wait not due yet.
341
+ if (!moreWork) {
342
+ moreWork = (scan.entries || []).some(
343
+ (e) =>
344
+ e &&
345
+ e.ok &&
346
+ e.eligibility &&
347
+ e.session_id !== sessionId &&
348
+ Number(e.eligibility.queued_exchanges || 0) > 0 &&
349
+ e.eligibility.retry_due === false,
350
+ );
351
+ }
352
+
353
+ rebuildHealth();
354
+ } catch (err) {
355
+ process.stderr.write(`[meko-timer] Recovery cycle error: ${err.message}\n`);
356
+ moreWork = true;
357
+ } finally {
358
+ recoveryInFlight = false;
98
359
  }
360
+ workRemains = moreWork;
361
+ return moreWork;
362
+ }
99
363
 
100
- const hookInput = JSON.stringify({ transcript_path: transcriptPath });
101
-
102
- // Run capture ASYNCHRONOUSLY. execFileSync would block this daemon's event
103
- // loop for up to 30s, during which SIGTERM/SIGINT (clean shutdown) can't be
104
- // handled. execFile keeps the loop responsive so cleanup() fires promptly.
105
- const child = execFile(
106
- "node",
107
- [captureScript, "checkpoint"],
108
- { timeout: 30000 },
109
- (err) => {
110
- if (err) {
111
- // Best-effort: log and continue to the next tick.
112
- process.stderr.write(`[meko-timer] Checkpoint tick failed: ${err.message}\n`);
364
+ async function tick() {
365
+ if (tickInFlight) return;
366
+ tickInFlight = true;
367
+ try {
368
+ if (Date.now() - startedAt >= MAX_AGE_SECS * 1000) {
369
+ process.stderr.write(
370
+ `[meko-timer] Max age (${MAX_AGE_SECS}s) reached; self-exiting (outbox left pending).\n`,
371
+ );
372
+ cleanup();
373
+ return;
374
+ }
375
+
376
+ let stat;
377
+ try {
378
+ stat = fs.statSync(transcriptPath);
379
+ } catch {
380
+ if (Date.now() - startedAt < STARTUP_GRACE_SECS * 1000) {
381
+ // Still start recovery even if our transcript isn't ready yet.
382
+ await runRecoveryCycle();
383
+ return;
113
384
  }
114
- },
115
- );
116
- // The stdin stream can emit EPIPE if the child exits or fails to spawn before
117
- // we finish writing. Without a listener that error is unhandled and crashes
118
- // the daemon — so swallow it here; the execFile callback above reports the
119
- // real failure.
120
- child.stdin.on("error", (err) => {
121
- process.stderr.write(`[meko-timer] Checkpoint stdin error: ${err.message}\n`);
122
- });
123
- child.stdin.end(hookInput);
385
+ // Transcript gone after grace — run one closing recovery for self, then exit.
386
+ await runCaptureChild(
387
+ "drain",
388
+ {
389
+ session_id: sessionId,
390
+ transcript_path: transcriptPath,
391
+ lifecycle: "closing",
392
+ confirmed_dead: true,
393
+ recorded_owner_only: true,
394
+ },
395
+ CAPTURE_TIMEOUT_MS,
396
+ );
397
+ await runRecoveryCycle();
398
+ cleanup();
399
+ return;
400
+ }
401
+
402
+ if (Date.now() - stat.mtimeMs >= IDLE_EXIT_SECS * 1000) {
403
+ process.stderr.write(
404
+ `[meko-timer] Transcript idle > ${IDLE_EXIT_SECS}s; final drain then exit.\n`,
405
+ );
406
+ await runCaptureChild(
407
+ "drain",
408
+ {
409
+ session_id: sessionId,
410
+ transcript_path: transcriptPath,
411
+ lifecycle: "closing",
412
+ confirmed_dead: true,
413
+ recorded_owner_only: true,
414
+ },
415
+ CAPTURE_TIMEOUT_MS,
416
+ );
417
+ await runRecoveryCycle();
418
+ cleanup();
419
+ return;
420
+ }
421
+
422
+ // Live session checkpoint. Recovery skips the daemon's own session so it
423
+ // cannot accidentally finalize a live transcript; consume that session's
424
+ // one-shot persistent retry here instead.
425
+ let forceLivePersistentRetry = false;
426
+ if (startupRecoveryPending) {
427
+ const startupScan = scanStates({
428
+ includeBlocked: true,
429
+ forcePersistentRetry: true,
430
+ });
431
+ const forcedIds = refreshStartupPersistentQueue(startupScan, true);
432
+ forceLivePersistentRetry = Boolean(
433
+ forcedIds.has(sessionId) &&
434
+ startupPersistentQueue &&
435
+ startupPersistentQueue.has(sessionId),
436
+ );
437
+ if (forceLivePersistentRetry) {
438
+ // Remove before launch so a child crash still consumes the one-shot.
439
+ startupPersistentQueue.delete(sessionId);
440
+ }
441
+ }
442
+ const live = await runCaptureChild(
443
+ "checkpoint",
444
+ {
445
+ transcript_path: transcriptPath,
446
+ force_retry: forceLivePersistentRetry,
447
+ },
448
+ CAPTURE_TIMEOUT_MS,
449
+ );
450
+ if (!live.ok) {
451
+ process.stderr.write(
452
+ `[meko-timer] Checkpoint tick failed: ${live.error}\n`,
453
+ );
454
+ }
455
+
456
+ await runRecoveryCycle();
457
+ } finally {
458
+ tickInFlight = false;
459
+ }
124
460
  }
125
461
 
126
- setInterval(tick, INTERVAL_SECS * 1000);
462
+ if (!acquirePidFile()) {
463
+ process.exit(0);
464
+ }
465
+
466
+ process.on("SIGTERM", cleanup);
467
+ process.on("SIGINT", cleanup);
468
+
469
+ // Immediate recovery on start, then periodic ticks.
470
+ migrateLegacyErrors();
471
+ tick().catch((err) => {
472
+ process.stderr.write(`[meko-timer] Startup tick failed: ${err.message}\n`);
473
+ });
474
+ setInterval(() => {
475
+ tick().catch((err) => {
476
+ process.stderr.write(`[meko-timer] Tick failed: ${err.message}\n`);
477
+ });
478
+ }, INTERVAL_SECS * 1000);
479
+
480
+ // While backlog remains, also schedule a shorter recovery follow-up without
481
+ // waiting for the full checkpoint interval (self-draining).
482
+ setInterval(() => {
483
+ if (!workRemains || recoveryInFlight || tickInFlight) return;
484
+ runRecoveryCycle().catch((err) => {
485
+ process.stderr.write(
486
+ `[meko-timer] Follow-up recovery failed: ${err.message}\n`,
487
+ );
488
+ });
489
+ }, Math.min(30, INTERVAL_SECS) * 1000);