@yugabytedb/meko-mcp 2.1.0 → 2.3.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 (70) hide show
  1. package/README.md +233 -40
  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-cleanup-cli.js +1473 -0
  5. package/assets/hooks-handlers/lib/capture-notices.js +438 -0
  6. package/assets/hooks-handlers/lib/capture-state.js +2173 -0
  7. package/assets/hooks-handlers/lib/capture-status-cli.js +86 -0
  8. package/assets/hooks-handlers/lib/capture.js +2782 -403
  9. package/assets/hooks-handlers/lib/checkpoint-timer.js +485 -81
  10. package/assets/hooks-handlers/lib/journal-bridge.js +218 -0
  11. package/assets/hooks-handlers/lib/kiro-handler.js +760 -0
  12. package/assets/hooks-handlers/lib/opencode-handler.js +620 -0
  13. package/assets/hooks-handlers/lib/opencode-normalizer.js +145 -0
  14. package/assets/hooks-handlers/lib/terminal-handoff.js +427 -0
  15. package/assets/hooks-handlers/session-end.sh +3 -34
  16. package/assets/opencode-plugin/meko.js +213 -0
  17. package/assets/scripts/meko-statusline.sh +123 -16
  18. package/assets/skills/meko-mcp-tools/SKILL.md +116 -126
  19. package/assets/skills/meko-mcp-tools/references/_sections.md +3 -3
  20. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +25 -18
  21. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +99 -24
  22. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +1 -1
  23. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +53 -6
  24. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +25 -79
  25. package/assets/skills/meko-mcp-tools/references/tools-overview.md +37 -26
  26. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +14 -8
  27. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +66 -1
  28. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +7 -40
  29. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +56 -20
  30. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +1 -1
  31. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
  32. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +15 -8
  33. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +99 -36
  34. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +22 -5
  35. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +18 -14
  36. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +39 -28
  37. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +14 -8
  38. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +67 -1
  39. package/assets/skills/meko-select-datapack-desktop/SKILL.md +4 -4
  40. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +8 -8
  41. package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +7 -5
  42. package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +4 -4
  43. package/bin/create-meko-setup.mjs +113 -12
  44. package/lib/capture-cleanup.mjs +63 -0
  45. package/lib/capture-status.mjs +67 -0
  46. package/lib/clients/aliases.mjs +5 -0
  47. package/lib/clients/claude-code.mjs +1 -0
  48. package/lib/clients/claude-desktop.mjs +120 -5
  49. package/lib/clients/codex.mjs +85 -37
  50. package/lib/clients/common.mjs +7 -0
  51. package/lib/clients/index.mjs +4 -0
  52. package/lib/clients/kiro.mjs +705 -0
  53. package/lib/clients/opencode.mjs +532 -0
  54. package/lib/config.mjs +158 -1
  55. package/lib/installer.mjs +18 -1
  56. package/lib/migrate/adapters/claude-code.mjs +11 -2
  57. package/lib/migrate/adapters/codex.mjs +497 -0
  58. package/lib/migrate/adapters/memory-md.mjs +27 -8
  59. package/lib/migrate/adapters/opencode.mjs +148 -0
  60. package/lib/migrate/claude-parser.mjs +153 -9
  61. package/lib/migrate/id.mjs +217 -10
  62. package/lib/migrate/mcp-client.mjs +25 -1
  63. package/lib/migrate/opencode-normalizer.mjs +132 -0
  64. package/lib/migrate/orchestrator.mjs +299 -56
  65. package/lib/migrate/progress.mjs +239 -5
  66. package/lib/migrate/registry.mjs +72 -0
  67. package/lib/migrate.mjs +100 -10
  68. package/lib/prompt.mjs +16 -1
  69. package/package.json +4 -4
  70. package/scripts/bundle-assets.mjs +4 -0
@@ -1,126 +1,530 @@
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
+ scanCompleteForDropClamp,
35
+ sortOldestFirst,
36
+ computeAggregateHealth,
37
+ writeHealthCache,
38
+ importLegacyCaptureErrors,
39
+ legacyErrorsSafeToRemove,
40
+ isResumableState,
41
+ readState,
42
+ } = require("./capture-state");
28
43
 
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;
44
+ const INTERVAL_SECS = Math.max(1, checkpointInterval());
45
+ const CAPTURE_TIMEOUT_MS = Math.max(1, checkpointTimeout()) * 1000;
46
+ const MAX_AGE_SECS = Math.max(1, timerMaxAge());
34
47
  const IDLE_EXIT_SECS = parseInt(process.env.MEKO_TIMER_IDLE_EXIT, 10) || 7200;
35
48
  const STARTUP_GRACE_SECS =
36
49
  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
50
 
51
+ const startedAt = Date.now();
52
+ const STATE_DIR = watermarkDir();
42
53
  const transcriptPath = process.argv[2];
54
+
43
55
  if (!transcriptPath) {
44
- process.stderr.write("[meko-timer] Usage: checkpoint-timer.js <transcript_path>\n");
56
+ process.stderr.write(
57
+ "[meko-timer] Usage: checkpoint-timer.js <transcript_path>\n",
58
+ );
45
59
  process.exit(1);
46
60
  }
47
61
 
48
62
  const sessionId = path.basename(transcriptPath, ".jsonl");
49
- const pidFile = path.join(WATERMARK_DIR, `${sessionId}.timer.pid`);
63
+ const pidFile = timerPidPath(sessionId);
50
64
  const captureScript = path.join(__dirname, "capture.js");
51
65
 
52
- // Write PID file
53
- fs.mkdirSync(WATERMARK_DIR, { recursive: true });
54
- fs.writeFileSync(pidFile, String(process.pid));
66
+ let tickInFlight = false;
67
+ let recoveryInFlight = false;
68
+ let workRemains = true; // force immediate recovery on start
69
+ let startupRecoveryPending = true;
70
+ let startupPersistentQueue = null;
55
71
 
56
- function cleanup() {
57
- try { fs.unlinkSync(pidFile); } catch { /* already gone */ }
58
- process.exit(0);
72
+ function isProcessAlive(pid) {
73
+ if (!Number.isInteger(pid) || pid <= 0) return false;
74
+ try {
75
+ process.kill(pid, 0);
76
+ return true;
77
+ } catch {
78
+ return false;
79
+ }
59
80
  }
60
81
 
61
- process.on("SIGTERM", cleanup);
62
- process.on("SIGINT", cleanup);
63
-
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) {
82
+ /**
83
+ * Atomic PID publish (PR #254): write populated candidate inode, then linkSync.
84
+ * Avoids empty-lock races from create-then-write on the published path.
85
+ */
86
+ function acquirePidFile() {
87
+ fs.mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
88
+ const candidateFile = path.join(
89
+ STATE_DIR,
90
+ `${sessionId}.timer.pid.${process.pid}.${Date.now()}.${crypto
91
+ .randomBytes(4)
92
+ .toString("hex")}.tmp`,
93
+ );
94
+ try {
95
+ fs.writeFileSync(candidateFile, String(process.pid), {
96
+ flag: "wx",
97
+ mode: 0o600,
98
+ });
99
+ } catch (err) {
69
100
  process.stderr.write(
70
- `[meko-timer] Max age (${MAX_AGE_SECS}s) reached; self-exiting to avoid orphan.\n`,
101
+ `[meko-timer] Could not stage PID file: ${err.message}\n`,
71
102
  );
72
- cleanup();
73
- return;
103
+ return false;
74
104
  }
75
105
 
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;
106
+ let acquired = false;
80
107
  try {
81
- stat = fs.statSync(transcriptPath);
108
+ for (let attempt = 0; attempt < 2; attempt++) {
109
+ try {
110
+ fs.linkSync(candidateFile, pidFile);
111
+ acquired = true;
112
+ break;
113
+ } catch (err) {
114
+ if (!err || err.code !== "EEXIST") {
115
+ process.stderr.write(
116
+ `[meko-timer] PID publish failed: ${err.message}\n`,
117
+ );
118
+ break;
119
+ }
120
+ let existingPid = NaN;
121
+ try {
122
+ existingPid = parseInt(fs.readFileSync(pidFile, "utf8"), 10);
123
+ } catch {
124
+ existingPid = NaN;
125
+ }
126
+ if (isProcessAlive(existingPid)) {
127
+ process.stderr.write(
128
+ `[meko-timer] Timer already active for ${sessionId} (pid ${existingPid})\n`,
129
+ );
130
+ break;
131
+ }
132
+ try {
133
+ fs.unlinkSync(pidFile);
134
+ } catch {
135
+ /* ignore */
136
+ }
137
+ }
138
+ }
139
+ } finally {
140
+ try {
141
+ fs.unlinkSync(candidateFile);
142
+ } catch {
143
+ /* ignore */
144
+ }
145
+ }
146
+ return acquired;
147
+ }
148
+
149
+ function cleanup() {
150
+ try {
151
+ const recorded = parseInt(fs.readFileSync(pidFile, "utf8"), 10);
152
+ if (recorded === process.pid) fs.unlinkSync(pidFile);
82
153
  } catch {
83
- if (Date.now() - startedAt < STARTUP_GRACE_SECS * 1000) {
84
- return;
154
+ /* already gone */
155
+ }
156
+ process.exit(0);
157
+ }
158
+
159
+ function runCaptureChild(mode, payload, timeoutMs) {
160
+ return new Promise((resolve) => {
161
+ const child = execFile(
162
+ process.execPath,
163
+ [captureScript, mode],
164
+ { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 },
165
+ (err, stdout, stderr) => {
166
+ let result = null;
167
+ const text = (stdout || "").trim();
168
+ if (text) {
169
+ try {
170
+ const line = text
171
+ .split(/\r?\n/)
172
+ .find((l) => l.trim().startsWith("{"));
173
+ if (line) result = JSON.parse(line);
174
+ } catch {
175
+ result = null;
176
+ }
177
+ }
178
+ resolve({
179
+ ok: !err,
180
+ error: err ? err.message : null,
181
+ result,
182
+ stderr: stderr || "",
183
+ });
184
+ },
185
+ );
186
+ child.stdin.on("error", () => {});
187
+ child.stdin.end(JSON.stringify(payload || {}));
188
+ });
189
+ }
190
+
191
+ // MEKO-635: settle OpenCode pending turns past the 30-minute default.
192
+ function sweepOpenCodeSettles() {
193
+ try {
194
+ const opencode = require("./opencode-handler");
195
+ if (typeof opencode.sweepExpiredSettles === "function") {
196
+ opencode.sweepExpiredSettles();
85
197
  }
86
- cleanup();
87
- return;
198
+ } catch (err) {
199
+ process.stderr.write(
200
+ `[meko-timer] OpenCode settle sweep failed: ${err.message}\n`,
201
+ );
88
202
  }
203
+ }
89
204
 
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) {
205
+ function rebuildHealth() {
206
+ try {
207
+ const scan = scanStates({ includeBlocked: true });
208
+ const health = computeAggregateHealth(scan);
209
+ writeHealthCache(health, {
210
+ clampDropped: scanCompleteForDropClamp(scan),
211
+ });
212
+ } catch (err) {
93
213
  process.stderr.write(
94
- `[meko-timer] Transcript idle > ${IDLE_EXIT_SECS}s; self-exiting.\n`,
214
+ `[meko-timer] Health rebuild failed: ${err.message}\n`,
95
215
  );
96
- cleanup();
97
- return;
98
216
  }
217
+ }
99
218
 
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`);
219
+ function migrateLegacyErrors() {
220
+ try {
221
+ importLegacyCaptureErrors();
222
+ const uncapped = scanStates({ includeBlocked: true });
223
+ for (const legacyPath of legacyErrorsSafeToRemove(uncapped)) {
224
+ try {
225
+ fs.unlinkSync(legacyPath);
226
+ } catch {
227
+ /* best effort; a future uncapped scan retries */
113
228
  }
114
- },
229
+ }
230
+ } catch (err) {
231
+ process.stderr.write(
232
+ `[meko-timer] Legacy capture error import failed: ${err.message}\n`,
233
+ );
234
+ }
235
+ }
236
+
237
+ function refreshStartupPersistentQueue(scan, forcePersistentRetry) {
238
+ const forcedIds = new Set(
239
+ (scan.entries || [])
240
+ .filter(
241
+ (entry) =>
242
+ entry &&
243
+ entry.ok &&
244
+ entry.eligibility &&
245
+ entry.eligibility.persistent_startup_override,
246
+ )
247
+ .map((entry) => entry.session_id),
115
248
  );
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);
249
+ if (forcePersistentRetry && startupPersistentQueue === null && scan.ok) {
250
+ // Snapshot the daemon-start backlog. A bounded cycle may attempt only a
251
+ // subset; retain the rest for follow-up cycles without also forcing new
252
+ // persistent failures that happen later in this daemon's lifetime.
253
+ startupPersistentQueue = new Set(forcedIds);
254
+ } else if (startupPersistentQueue) {
255
+ for (const queuedId of startupPersistentQueue) {
256
+ if (!forcedIds.has(queuedId)) startupPersistentQueue.delete(queuedId);
257
+ }
258
+ }
259
+ return forcedIds;
124
260
  }
125
261
 
126
- setInterval(tick, INTERVAL_SECS * 1000);
262
+ /**
263
+ * Uncapped discovery, then capped fair recovery (oldest first).
264
+ * Returns true if more recoverable work may remain for a later cycle.
265
+ */
266
+ async function runRecoveryCycle() {
267
+ if (recoveryInFlight) return workRemains;
268
+ recoveryInFlight = true;
269
+ const forcePersistentRetry = startupRecoveryPending;
270
+ let moreWork = false;
271
+ try {
272
+ // Finalize any resumable session whose settling window has expired before
273
+ // the scan snapshots eligibility. Otherwise recovery keeps draining a
274
+ // pending user (queued_exchanges === 1) whose assistant will never arrive.
275
+ sweepOpenCodeSettles();
276
+ const scan = scanStates({
277
+ includeBlocked: true,
278
+ forcePersistentRetry,
279
+ });
280
+ refreshStartupPersistentQueue(scan, forcePersistentRetry);
281
+ const eligible = sortOldestFirst(
282
+ (scan.entries || []).filter(
283
+ (e) =>
284
+ e &&
285
+ e.ok &&
286
+ e.eligibility &&
287
+ e.eligibility.eligible_for_recovery &&
288
+ (!e.eligibility.persistent_startup_override ||
289
+ (startupPersistentQueue && startupPersistentQueue.has(e.session_id))),
290
+ ),
291
+ );
292
+
293
+ const maxSessions = recoveryMaxSessions();
294
+ const maxExchanges = recoveryMaxExchanges();
295
+ const selected = eligible.slice(0, maxSessions);
296
+ moreWork = eligible.length > selected.length;
297
+
298
+ let budget = maxExchanges;
299
+ for (const entry of selected) {
300
+ if (budget <= 0) {
301
+ moreWork = true;
302
+ break;
303
+ }
304
+ // Skip the live session this daemon owns — live tick handles it.
305
+ if (entry.session_id === sessionId) continue;
306
+
307
+ if (
308
+ entry.eligibility &&
309
+ entry.eligibility.persistent_startup_override &&
310
+ startupPersistentQueue
311
+ ) {
312
+ // One forced attempt per session per daemon start. Remove before
313
+ // launch so a child crash cannot make this daemon hammer the session.
314
+ startupPersistentQueue.delete(entry.session_id);
315
+ }
316
+
317
+ const state = entry.state || {};
318
+ const resumable = isResumableState(state);
319
+ const payload = {
320
+ session_id: entry.session_id,
321
+ transcript_path: state.transcript_path || undefined,
322
+ lifecycle: resumable ? "active" : "closing",
323
+ confirmed_dead: !resumable,
324
+ recorded_owner_only: true,
325
+ max_exchanges: Math.min(budget, maxCaptureBatchSize()),
326
+ force_retry: Boolean(
327
+ entry.eligibility &&
328
+ entry.eligibility.persistent_startup_override,
329
+ ),
330
+ };
331
+ const outcome = await runCaptureChild(
332
+ "recover",
333
+ payload,
334
+ CAPTURE_TIMEOUT_MS,
335
+ );
336
+ const result = outcome.result;
337
+ if (!outcome.ok) {
338
+ process.stderr.write(
339
+ `[meko-timer] Recovery drain failed for ${entry.session_id}: ${outcome.error}\n`,
340
+ );
341
+ moreWork = true;
342
+ // Stop hammering an unhealthy server this cycle.
343
+ break;
344
+ }
345
+ const captured = Number((result && result.captured) || 0);
346
+ budget -= captured;
347
+ const queued = Number((result && result.queued_remaining) || 0);
348
+ if (
349
+ (queued > 0 || (result && result.status === "retry_wait")) &&
350
+ !(resumable && captured === 0 && queued <= 1 && result.status !== "retry_wait")
351
+ ) {
352
+ moreWork = true;
353
+ }
354
+ if (result && (result.status === "partial" || result.status === "retry_wait" || result.status === "blocked")) {
355
+ // Continue other sessions unless this looks like a systemic outage.
356
+ if (result.failure_class === "transient" && captured === 0) {
357
+ moreWork = true;
358
+ break;
359
+ }
360
+ }
361
+ }
362
+
363
+ startupRecoveryPending =
364
+ startupPersistentQueue === null || startupPersistentQueue.size > 0;
365
+ if (startupRecoveryPending) moreWork = true;
366
+
367
+ // Also consider pending entries we skipped due to retry_wait not due yet.
368
+ if (!moreWork) {
369
+ moreWork = (scan.entries || []).some(
370
+ (e) =>
371
+ e &&
372
+ e.ok &&
373
+ e.eligibility &&
374
+ e.session_id !== sessionId &&
375
+ Number(e.eligibility.queued_exchanges || 0) > 0 &&
376
+ e.eligibility.retry_due === false &&
377
+ !(isResumableState(e.state) &&
378
+ Number(e.eligibility.queued_exchanges || 0) <= 1 &&
379
+ e.state && e.state.delivery === "pending"),
380
+ );
381
+ }
382
+
383
+ rebuildHealth();
384
+ } catch (err) {
385
+ process.stderr.write(`[meko-timer] Recovery cycle error: ${err.message}\n`);
386
+ moreWork = true;
387
+ } finally {
388
+ recoveryInFlight = false;
389
+ }
390
+ workRemains = moreWork;
391
+ return moreWork;
392
+ }
393
+
394
+ // MEKO-635: for the daemon's own session, look up whether its captured intent
395
+ // declared a resumable pending-turn policy. If so, timer-idle and
396
+ // transcript-gone paths must drain without finalizing the session as dead.
397
+ function selfIsResumable() {
398
+ const read = readState(sessionId);
399
+ return read.ok ? isResumableState(read.state) : false;
400
+ }
401
+
402
+ async function tick() {
403
+ if (tickInFlight) return;
404
+ tickInFlight = true;
405
+ try {
406
+ if (Date.now() - startedAt >= MAX_AGE_SECS * 1000) {
407
+ process.stderr.write(
408
+ `[meko-timer] Max age (${MAX_AGE_SECS}s) reached; self-exiting (outbox left pending).\n`,
409
+ );
410
+ cleanup();
411
+ return;
412
+ }
413
+
414
+ let stat;
415
+ try {
416
+ stat = fs.statSync(transcriptPath);
417
+ } catch {
418
+ if (Date.now() - startedAt < STARTUP_GRACE_SECS * 1000) {
419
+ // Still start recovery even if our transcript isn't ready yet.
420
+ await runRecoveryCycle();
421
+ return;
422
+ }
423
+ // Transcript gone after grace. For a resumable session drain without
424
+ // finalizing so a later snapshot can complete its pending turn.
425
+ const resumable = selfIsResumable();
426
+ await runCaptureChild(
427
+ "drain",
428
+ {
429
+ session_id: sessionId,
430
+ transcript_path: transcriptPath,
431
+ lifecycle: resumable ? "active" : "closing",
432
+ confirmed_dead: !resumable,
433
+ recorded_owner_only: true,
434
+ },
435
+ CAPTURE_TIMEOUT_MS,
436
+ );
437
+ await runRecoveryCycle();
438
+ cleanup();
439
+ return;
440
+ }
441
+
442
+ if (Date.now() - stat.mtimeMs >= IDLE_EXIT_SECS * 1000) {
443
+ process.stderr.write(
444
+ `[meko-timer] Transcript idle > ${IDLE_EXIT_SECS}s; final drain then exit.\n`,
445
+ );
446
+ const resumable = selfIsResumable();
447
+ await runCaptureChild(
448
+ "drain",
449
+ {
450
+ session_id: sessionId,
451
+ transcript_path: transcriptPath,
452
+ lifecycle: resumable ? "active" : "closing",
453
+ confirmed_dead: !resumable,
454
+ recorded_owner_only: true,
455
+ },
456
+ CAPTURE_TIMEOUT_MS,
457
+ );
458
+ await runRecoveryCycle();
459
+ cleanup();
460
+ return;
461
+ }
462
+
463
+ // Live session checkpoint. Recovery skips the daemon's own session so it
464
+ // cannot accidentally finalize a live transcript; consume that session's
465
+ // one-shot persistent retry here instead.
466
+ let forceLivePersistentRetry = false;
467
+ if (startupRecoveryPending) {
468
+ const startupScan = scanStates({
469
+ includeBlocked: true,
470
+ forcePersistentRetry: true,
471
+ });
472
+ const forcedIds = refreshStartupPersistentQueue(startupScan, true);
473
+ forceLivePersistentRetry = Boolean(
474
+ forcedIds.has(sessionId) &&
475
+ startupPersistentQueue &&
476
+ startupPersistentQueue.has(sessionId),
477
+ );
478
+ if (forceLivePersistentRetry) {
479
+ // Remove before launch so a child crash still consumes the one-shot.
480
+ startupPersistentQueue.delete(sessionId);
481
+ }
482
+ }
483
+ const live = await runCaptureChild(
484
+ "checkpoint",
485
+ {
486
+ transcript_path: transcriptPath,
487
+ force_retry: forceLivePersistentRetry,
488
+ },
489
+ CAPTURE_TIMEOUT_MS,
490
+ );
491
+ if (!live.ok) {
492
+ process.stderr.write(
493
+ `[meko-timer] Checkpoint tick failed: ${live.error}\n`,
494
+ );
495
+ }
496
+
497
+ await runRecoveryCycle();
498
+ } finally {
499
+ tickInFlight = false;
500
+ }
501
+ }
502
+
503
+ if (!acquirePidFile()) {
504
+ process.exit(0);
505
+ }
506
+
507
+ process.on("SIGTERM", cleanup);
508
+ process.on("SIGINT", cleanup);
509
+
510
+ // Immediate recovery on start, then periodic ticks.
511
+ migrateLegacyErrors();
512
+ tick().catch((err) => {
513
+ process.stderr.write(`[meko-timer] Startup tick failed: ${err.message}\n`);
514
+ });
515
+ setInterval(() => {
516
+ tick().catch((err) => {
517
+ process.stderr.write(`[meko-timer] Tick failed: ${err.message}\n`);
518
+ });
519
+ }, INTERVAL_SECS * 1000);
520
+
521
+ // While backlog remains, also schedule a shorter recovery follow-up without
522
+ // waiting for the full checkpoint interval (self-draining).
523
+ setInterval(() => {
524
+ if (!workRemains || recoveryInFlight || tickInFlight) return;
525
+ runRecoveryCycle().catch((err) => {
526
+ process.stderr.write(
527
+ `[meko-timer] Follow-up recovery failed: ${err.message}\n`,
528
+ );
529
+ });
530
+ }, Math.min(30, INTERVAL_SECS) * 1000);