@yugabytedb/meko-mcp 2.0.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 (65) hide show
  1. package/README.md +183 -33
  2. package/assets/hooks-handlers/codex-session-end.sh +13 -0
  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 +1653 -225
  8. package/assets/hooks-handlers/lib/checkpoint-timer.js +455 -50
  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 -24
  12. package/assets/hooks-handlers/session-start.sh +8 -1
  13. package/assets/hooks-handlers/test_checkpoint_timer.sh +162 -0
  14. package/assets/scripts/meko-statusline.sh +41 -1
  15. package/assets/scripts/package-desktop-skill.sh +32 -1
  16. package/assets/skills/meko-mcp-tools/SKILL.md +90 -137
  17. package/assets/skills/meko-mcp-tools/references/_sections.md +3 -5
  18. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +31 -28
  19. package/assets/skills/meko-mcp-tools/references/tools-conversation-dedup.md +5 -7
  20. package/assets/skills/meko-mcp-tools/references/tools-conversation-reasoning.md +50 -0
  21. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +69 -49
  22. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +6 -6
  23. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +29 -5
  24. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +29 -92
  25. package/assets/skills/meko-mcp-tools/references/tools-overview.md +42 -40
  26. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +1 -2
  27. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +18 -11
  28. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +10 -9
  29. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +51 -44
  30. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +6 -6
  31. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +2 -2
  32. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +34 -25
  33. package/assets/skills/meko-mcp-tools-desktop/references/tools-conversation-reasoning.md +50 -0
  34. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +63 -37
  35. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +4 -4
  36. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +19 -22
  37. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +42 -40
  38. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +1 -2
  39. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +3 -17
  40. package/assets/skills/meko-select-datapack-desktop/SKILL.md +31 -21
  41. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +8 -8
  42. package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +41 -0
  43. package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +1 -1
  44. package/bin/create-meko-setup.mjs +93 -16
  45. package/lib/capture-status.mjs +67 -0
  46. package/lib/clients/aliases.mjs +5 -0
  47. package/lib/clients/claude-code.mjs +8 -4
  48. package/lib/clients/claude-desktop.mjs +133 -4
  49. package/lib/clients/codex.mjs +29 -11
  50. package/lib/clients/common.mjs +2 -0
  51. package/lib/clients/cursor.mjs +7 -2
  52. package/lib/clients/index.mjs +2 -0
  53. package/lib/clients/kiro.mjs +705 -0
  54. package/lib/config.mjs +90 -0
  55. package/lib/detect.mjs +7 -2
  56. package/lib/installer.mjs +1 -0
  57. package/lib/migrate/adapters/codex.mjs +490 -0
  58. package/lib/migrate/id.mjs +28 -3
  59. package/lib/migrate/orchestrator.mjs +34 -7
  60. package/lib/migrate.mjs +4 -2
  61. package/lib/prompt.mjs +17 -2
  62. package/lib/url-selection.mjs +55 -0
  63. package/package.json +4 -4
  64. package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +0 -71
  65. package/assets/skills/meko-mcp-tools-desktop/references/tools-scope-permissions.md +0 -71
@@ -1,84 +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_WATERMARK_DIR Watermark directory (default: ~/.claude/meko-capture)
16
- * MEKO_MCP_URL Inherited by capture.js
17
- * MEKO_API_KEY Inherited by capture.js
15
+ * Environment: see docs/plans/meko-capture-v2-contracts.md
18
16
  */
19
17
 
18
+ "use strict";
19
+
20
20
  const { execFile } = require("child_process");
21
21
  const fs = require("fs");
22
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");
23
40
 
24
- const INTERVAL_SECS = parseInt(process.env.MEKO_CHECKPOINT_INTERVAL || "600", 10);
25
- const WATERMARK_DIR =
26
- process.env.MEKO_WATERMARK_DIR ||
27
- path.join(process.env.HOME || "~", ".claude", "meko-capture");
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());
44
+ const IDLE_EXIT_SECS = parseInt(process.env.MEKO_TIMER_IDLE_EXIT, 10) || 7200;
45
+ const STARTUP_GRACE_SECS =
46
+ parseInt(process.env.MEKO_TIMER_STARTUP_GRACE, 10) || 1800;
28
47
 
48
+ const startedAt = Date.now();
49
+ const STATE_DIR = watermarkDir();
29
50
  const transcriptPath = process.argv[2];
51
+
30
52
  if (!transcriptPath) {
31
- 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
+ );
32
56
  process.exit(1);
33
57
  }
34
58
 
35
59
  const sessionId = path.basename(transcriptPath, ".jsonl");
36
- const pidFile = path.join(WATERMARK_DIR, `${sessionId}.timer.pid`);
60
+ const pidFile = timerPidPath(sessionId);
37
61
  const captureScript = path.join(__dirname, "capture.js");
38
62
 
39
- // Write PID file
40
- fs.mkdirSync(WATERMARK_DIR, { recursive: true });
41
- 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
+ }
42
145
 
43
146
  function cleanup() {
44
- 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
+ }
45
153
  process.exit(0);
46
154
  }
47
155
 
48
- process.on("SIGTERM", cleanup);
49
- 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
+ }
50
187
 
51
- function tick() {
52
- // Skip if transcript doesn't exist (session ended but we weren't killed yet)
53
- if (!fs.existsSync(transcriptPath)) {
54
- cleanup();
55
- return;
188
+ function rebuildHealth() {
189
+ try {
190
+ const scan = scanStates({ includeBlocked: true });
191
+ const health = computeAggregateHealth(scan);
192
+ writeHealthCache(health);
193
+ } catch (err) {
194
+ process.stderr.write(
195
+ `[meko-timer] Health rebuild failed: ${err.message}\n`,
196
+ );
56
197
  }
198
+ }
57
199
 
58
- const hookInput = JSON.stringify({ transcript_path: transcriptPath });
59
-
60
- // Run capture ASYNCHRONOUSLY. execFileSync would block this daemon's event
61
- // loop for up to 30s, during which SIGTERM/SIGINT (clean shutdown) can't be
62
- // handled. execFile keeps the loop responsive so cleanup() fires promptly.
63
- const child = execFile(
64
- "node",
65
- [captureScript, "checkpoint"],
66
- { timeout: 30000 },
67
- (err) => {
68
- if (err) {
69
- // Best-effort: log and continue to the next tick.
70
- process.stderr.write(`[meko-timer] Checkpoint tick failed: ${err.message}\n`);
200
+ function migrateLegacyErrors() {
201
+ try {
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 */
71
209
  }
72
- },
210
+ }
211
+ } catch (err) {
212
+ process.stderr.write(
213
+ `[meko-timer] Legacy capture error import failed: ${err.message}\n`,
214
+ );
215
+ }
216
+ }
217
+
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),
73
229
  );
74
- // The stdin stream can emit EPIPE if the child exits or fails to spawn before
75
- // we finish writing. Without a listener that error is unhandled and crashes
76
- // the daemon — so swallow it here; the execFile callback above reports the
77
- // real failure.
78
- child.stdin.on("error", (err) => {
79
- process.stderr.write(`[meko-timer] Checkpoint stdin error: ${err.message}\n`);
80
- });
81
- child.stdin.end(hookInput);
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;
82
241
  }
83
242
 
84
- setInterval(tick, INTERVAL_SECS * 1000);
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
+ ),
268
+ );
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;
359
+ }
360
+ workRemains = moreWork;
361
+ return moreWork;
362
+ }
363
+
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;
384
+ }
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
+ }
460
+ }
461
+
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);