@yugabytedb/meko-mcp 2.2.0 → 2.5.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 (67) hide show
  1. package/README.md +168 -61
  2. package/assets/hooks-handlers/lib/capture-cleanup-cli.js +2134 -0
  3. package/assets/hooks-handlers/lib/capture-notices.js +224 -14
  4. package/assets/hooks-handlers/lib/capture-state.js +206 -1
  5. package/assets/hooks-handlers/lib/capture-status-cli.js +6 -2
  6. package/assets/hooks-handlers/lib/capture.js +1364 -139
  7. package/assets/hooks-handlers/lib/checkpoint-timer.js +445 -18
  8. package/assets/hooks-handlers/lib/journal-bridge.js +218 -0
  9. package/assets/hooks-handlers/lib/kiro-handler.js +26 -4
  10. package/assets/hooks-handlers/lib/opencode-handler.js +643 -0
  11. package/assets/hooks-handlers/lib/opencode-normalizer.js +145 -0
  12. package/assets/hooks-handlers/lib/terminal-handoff.js +10 -1
  13. package/assets/opencode-plugin/meko.js +213 -0
  14. package/assets/scripts/meko-statusline.sh +102 -19
  15. package/assets/skills/meko-mcp-tools/SKILL.md +59 -16
  16. package/assets/skills/meko-mcp-tools/references/_sections.md +2 -2
  17. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +15 -7
  18. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +275 -19
  19. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +7 -4
  20. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +39 -11
  21. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +10 -6
  22. package/assets/skills/meko-mcp-tools/references/tools-overview.md +44 -29
  23. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +79 -14
  24. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +47 -1
  25. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +6 -39
  26. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +57 -21
  27. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +4 -4
  28. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
  29. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +8 -6
  30. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +284 -38
  31. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +32 -10
  32. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +16 -12
  33. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +46 -31
  34. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +79 -14
  35. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +69 -1
  36. package/assets/skills/meko-select-datapack-desktop/SKILL.md +93 -14
  37. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +44 -12
  38. package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +14 -10
  39. package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +17 -5
  40. package/bin/create-meko-setup.mjs +186 -30
  41. package/lib/autofix.mjs +100 -0
  42. package/lib/browser-login.mjs +495 -0
  43. package/lib/capture-cleanup.mjs +63 -0
  44. package/lib/clients/claude-code.mjs +1 -0
  45. package/lib/clients/codex.mjs +80 -35
  46. package/lib/clients/common.mjs +7 -0
  47. package/lib/clients/index.mjs +2 -0
  48. package/lib/clients/kiro.mjs +5 -5
  49. package/lib/clients/opencode.mjs +537 -0
  50. package/lib/config.mjs +68 -1
  51. package/lib/installer.mjs +18 -2
  52. package/lib/migrate/adapters/claude-code.mjs +11 -2
  53. package/lib/migrate/adapters/codex.mjs +9 -2
  54. package/lib/migrate/adapters/memory-md.mjs +27 -8
  55. package/lib/migrate/adapters/opencode.mjs +148 -0
  56. package/lib/migrate/claude-parser.mjs +153 -9
  57. package/lib/migrate/datapack.mjs +254 -16
  58. package/lib/migrate/id.mjs +195 -13
  59. package/lib/migrate/mcp-client.mjs +59 -1
  60. package/lib/migrate/opencode-normalizer.mjs +132 -0
  61. package/lib/migrate/orchestrator.mjs +294 -78
  62. package/lib/migrate/progress.mjs +239 -5
  63. package/lib/migrate/registry.mjs +72 -0
  64. package/lib/migrate.mjs +97 -9
  65. package/lib/validate.mjs +111 -22
  66. package/package.json +5 -5
  67. package/scripts/bundle-assets.mjs +4 -0
@@ -17,7 +17,7 @@
17
17
 
18
18
  "use strict";
19
19
 
20
- const { execFile } = require("child_process");
20
+ const { execFile, spawn } = require("child_process");
21
21
  const fs = require("fs");
22
22
  const path = require("path");
23
23
  const crypto = require("crypto");
@@ -31,11 +31,20 @@ const {
31
31
  timerPidPath,
32
32
  watermarkDir,
33
33
  scanStates,
34
+ scanCompleteForDropClamp,
34
35
  sortOldestFirst,
35
36
  computeAggregateHealth,
36
37
  writeHealthCache,
37
38
  importLegacyCaptureErrors,
38
39
  legacyErrorsSafeToRemove,
40
+ isResumableState,
41
+ readState,
42
+ atomicWriteJson,
43
+ autofixEnabled,
44
+ autofixIntervalSeconds,
45
+ autofixLastPath,
46
+ readAutofixLast,
47
+ envInt,
39
48
  } = require("./capture-state");
40
49
 
41
50
  const INTERVAL_SECS = Math.max(1, checkpointInterval());
@@ -44,6 +53,23 @@ const MAX_AGE_SECS = Math.max(1, timerMaxAge());
44
53
  const IDLE_EXIT_SECS = parseInt(process.env.MEKO_TIMER_IDLE_EXIT, 10) || 7200;
45
54
  const STARTUP_GRACE_SECS =
46
55
  parseInt(process.env.MEKO_TIMER_STARTUP_GRACE, 10) || 1800;
56
+ const FOLLOWUP_SECS =
57
+ parseInt(process.env.MEKO_TIMER_FOLLOWUP_INTERVAL, 10) || 30;
58
+
59
+ // Automatic repair: one `--capture-cleanup fix --auto` run per machine every
60
+ // MEKO_AUTOFIX_INTERVAL seconds (default 1800). Any timer may run it; a lock
61
+ // file keeps it to one at a time.
62
+ const AUTOFIX_CHECK_SECS = Math.max(1, envInt("MEKO_AUTOFIX_CHECK_INTERVAL", 60));
63
+ const AUTOFIX_FIRST_CHECK_SECS = Math.max(1, envInt("MEKO_AUTOFIX_FIRST_CHECK", 30));
64
+ const AUTOFIX_TIMEOUT_MS = Math.max(60, envInt("MEKO_AUTOFIX_TIMEOUT", 900)) * 1000;
65
+ const AUTOFIX_MAX_EXCHANGES = Math.max(1, envInt("MEKO_AUTOFIX_MAX_EXCHANGES", 100));
66
+ const cleanupScript = path.join(__dirname, "capture-cleanup-cli.js");
67
+ let autofixInFlight = false;
68
+ // The running automatic-repair child and its lock, so cleanup() can stop it:
69
+ // the timer exits through process.exit, which skips runAutofix's finally.
70
+ let autofixChild = null;
71
+ let autofixLockPath = null;
72
+ let exiting = false;
47
73
 
48
74
  const startedAt = Date.now();
49
75
  const STATE_DIR = watermarkDir();
@@ -150,7 +176,107 @@ function cleanup() {
150
176
  } catch {
151
177
  /* already gone */
152
178
  }
153
- process.exit(0);
179
+ if (exiting) return;
180
+ exiting = true;
181
+ // Stopping an automatic run waits for it to exit, which needs the event
182
+ // loop (a synchronous wait would leave it a zombie that still looks alive).
183
+ stopAutofixChild()
184
+ .catch(() => {})
185
+ .finally(() => process.exit(0));
186
+ }
187
+
188
+ function groupAlive(pgid) {
189
+ if (!Number.isInteger(pgid) || pgid <= 0) return false;
190
+ try {
191
+ process.kill(-pgid, 0);
192
+ return true;
193
+ } catch (err) {
194
+ return Boolean(err && err.code === "EPERM");
195
+ }
196
+ }
197
+
198
+ function signalGroup(pgid, signal) {
199
+ try {
200
+ process.kill(-pgid, signal);
201
+ } catch {
202
+ /* already gone */
203
+ }
204
+ }
205
+
206
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
207
+
208
+ /**
209
+ * Stop an in-flight automatic repair: its whole process group, so the
210
+ * capture.js child the cleanup CLI is blocked on stops too. The lock is
211
+ * released only once the group is gone; until then it names the run's pid,
212
+ * so no other timer starts a second run while this one is still sending.
213
+ */
214
+ async function stopAutofixChild() {
215
+ const child = autofixChild;
216
+ if (child && child.exitCode === null && child.signalCode === null) {
217
+ const closed = new Promise((r) => child.once("close", r));
218
+ signalGroup(child.pid, "SIGTERM");
219
+ const exited = await Promise.race([closed.then(() => true), sleep(3000).then(() => false)]);
220
+ if (!exited) {
221
+ signalGroup(child.pid, "SIGKILL");
222
+ await Promise.race([closed, sleep(1000)]);
223
+ }
224
+ }
225
+ if (child) {
226
+ // The rest of the group (capture.js) was re-parented when the CLI died.
227
+ for (let waited = 0; waited < 3000 && groupAlive(child.pid); waited += 100) await sleep(100);
228
+ if (groupAlive(child.pid)) signalGroup(child.pid, "SIGKILL");
229
+ }
230
+ releaseAutofixLock(child ? child.pid : null);
231
+ }
232
+
233
+ function releaseAutofixLock(runPid) {
234
+ if (!autofixLockPath) return;
235
+ if (runPid && groupAlive(runPid)) return;
236
+ try {
237
+ const held = JSON.parse(fs.readFileSync(autofixLockPath, "utf8"));
238
+ if (Number(held.pid) === process.pid) fs.unlinkSync(autofixLockPath);
239
+ } catch {
240
+ /* already gone */
241
+ }
242
+ autofixLockPath = null;
243
+ }
244
+
245
+ // MEKO-918: an OpenCode timer outlives edits to its hook env file. Without a
246
+ // re-read, a timer started during an outage sends every attempt to the dead
247
+ // URL until it exits: MEKO_TIMER_IDLE_EXIT after the session's last turn, or
248
+ // MEKO_TIMER_MAX_AGE (24 h) after it started.
249
+ //
250
+ // The installer always writes MEKO_MCP_URL and MEKO_API_KEY, so a file that
251
+ // lacks them was emptied by uninstall or by hand. Drop the inherited values
252
+ // then, so the timer stops sending with removed credentials. A file that
253
+ // can't be parsed keeps the inherited environment.
254
+ const RELOADED_CREDENTIAL_KEYS = ["MEKO_MCP_URL", "MEKO_API_KEY"];
255
+
256
+ function captureChildEnv() {
257
+ if (
258
+ process.env.MEKO_HOOK_CLIENT !== "opencode" ||
259
+ !process.env.MEKO_OPENCODE_ENV_FILE
260
+ ) {
261
+ return process.env;
262
+ }
263
+ try {
264
+ const { status, set, unset } = require("./opencode-handler").readHookEnvironment(
265
+ process.env.MEKO_OPENCODE_ENV_FILE,
266
+ );
267
+ if (status === "unreadable") return process.env;
268
+ const env = { ...process.env, ...set };
269
+ for (const key of unset) delete env[key];
270
+ for (const key of RELOADED_CREDENTIAL_KEYS) {
271
+ if (!(key in set)) delete env[key];
272
+ }
273
+ return env;
274
+ } catch (err) {
275
+ process.stderr.write(
276
+ `[meko-timer] Could not reload OpenCode hook environment: ${err.message}\n`,
277
+ );
278
+ return process.env;
279
+ }
154
280
  }
155
281
 
156
282
  function runCaptureChild(mode, payload, timeoutMs) {
@@ -158,7 +284,7 @@ function runCaptureChild(mode, payload, timeoutMs) {
158
284
  const child = execFile(
159
285
  process.execPath,
160
286
  [captureScript, mode],
161
- { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 },
287
+ { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024, env: captureChildEnv() },
162
288
  (err, stdout, stderr) => {
163
289
  let result = null;
164
290
  const text = (stdout || "").trim();
@@ -185,11 +311,27 @@ function runCaptureChild(mode, payload, timeoutMs) {
185
311
  });
186
312
  }
187
313
 
314
+ // MEKO-635: settle OpenCode pending turns past the 30-minute default.
315
+ function sweepOpenCodeSettles() {
316
+ try {
317
+ const opencode = require("./opencode-handler");
318
+ if (typeof opencode.sweepExpiredSettles === "function") {
319
+ opencode.sweepExpiredSettles();
320
+ }
321
+ } catch (err) {
322
+ process.stderr.write(
323
+ `[meko-timer] OpenCode settle sweep failed: ${err.message}\n`,
324
+ );
325
+ }
326
+ }
327
+
188
328
  function rebuildHealth() {
189
329
  try {
190
330
  const scan = scanStates({ includeBlocked: true });
191
331
  const health = computeAggregateHealth(scan);
192
- writeHealthCache(health);
332
+ writeHealthCache(health, {
333
+ clampDropped: scanCompleteForDropClamp(scan),
334
+ });
193
335
  } catch (err) {
194
336
  process.stderr.write(
195
337
  `[meko-timer] Health rebuild failed: ${err.message}\n`,
@@ -245,11 +387,17 @@ function refreshStartupPersistentQueue(scan, forcePersistentRetry) {
245
387
  * Returns true if more recoverable work may remain for a later cycle.
246
388
  */
247
389
  async function runRecoveryCycle() {
248
- if (recoveryInFlight) return workRemains;
390
+ // Once cleanup() starts, start no new capture.js child: it would outlive
391
+ // this timer's exit.
392
+ if (recoveryInFlight || exiting) return workRemains;
249
393
  recoveryInFlight = true;
250
394
  const forcePersistentRetry = startupRecoveryPending;
251
395
  let moreWork = false;
252
396
  try {
397
+ // Finalize any resumable session whose settling window has expired before
398
+ // the scan snapshots eligibility. Otherwise recovery keeps draining a
399
+ // pending user (queued_exchanges === 1) whose assistant will never arrive.
400
+ sweepOpenCodeSettles();
253
401
  const scan = scanStates({
254
402
  includeBlocked: true,
255
403
  forcePersistentRetry,
@@ -292,11 +440,12 @@ async function runRecoveryCycle() {
292
440
  }
293
441
 
294
442
  const state = entry.state || {};
443
+ const resumable = isResumableState(state);
295
444
  const payload = {
296
445
  session_id: entry.session_id,
297
446
  transcript_path: state.transcript_path || undefined,
298
- lifecycle: state.lifecycle === "closing" ? "closing" : "closing",
299
- confirmed_dead: true,
447
+ lifecycle: resumable ? "active" : "closing",
448
+ confirmed_dead: !resumable,
300
449
  recorded_owner_only: true,
301
450
  max_exchanges: Math.min(budget, maxCaptureBatchSize()),
302
451
  force_retry: Boolean(
@@ -321,7 +470,10 @@ async function runRecoveryCycle() {
321
470
  const captured = Number((result && result.captured) || 0);
322
471
  budget -= captured;
323
472
  const queued = Number((result && result.queued_remaining) || 0);
324
- if (queued > 0 || (result && result.status === "retry_wait")) {
473
+ if (
474
+ (queued > 0 || (result && result.status === "retry_wait")) &&
475
+ !(resumable && captured === 0 && queued <= 1 && result.status !== "retry_wait")
476
+ ) {
325
477
  moreWork = true;
326
478
  }
327
479
  if (result && (result.status === "partial" || result.status === "retry_wait" || result.status === "blocked")) {
@@ -346,7 +498,10 @@ async function runRecoveryCycle() {
346
498
  e.eligibility &&
347
499
  e.session_id !== sessionId &&
348
500
  Number(e.eligibility.queued_exchanges || 0) > 0 &&
349
- e.eligibility.retry_due === false,
501
+ e.eligibility.retry_due === false &&
502
+ !(isResumableState(e.state) &&
503
+ Number(e.eligibility.queued_exchanges || 0) <= 1 &&
504
+ e.state && e.state.delivery === "pending"),
350
505
  );
351
506
  }
352
507
 
@@ -361,10 +516,72 @@ async function runRecoveryCycle() {
361
516
  return moreWork;
362
517
  }
363
518
 
519
+ // MEKO-635: for the daemon's own session, look up whether its captured intent
520
+ // declared a resumable pending-turn policy. If so, timer-idle and
521
+ // transcript-gone paths must drain without finalizing the session as dead.
522
+ function selfIsResumable() {
523
+ const read = readState(sessionId);
524
+ return read.ok ? isResumableState(read.state) : false;
525
+ }
526
+
527
+ // MEKO-918: recovery skips the daemon's own session, so without this check a
528
+ // failed live drain waits for the next full tick (600 s by default) instead
529
+ // of retrying at next_retry_at. Each scheduled retry runs once: a checkpoint
530
+ // that exits before the network leaves next_retry_at unchanged.
531
+ //
532
+ // The retry has its own in-flight promise rather than tickInFlight. A tick
533
+ // that fires during a retry waits for it and then runs, so the exit checks
534
+ // and recovery cycle are not skipped for a whole interval.
535
+ let ownRetryAttemptedFor = null;
536
+ let ownRetryInFlight = null;
537
+
538
+ function ownRetryDue(nowMs) {
539
+ const read = readState(sessionId);
540
+ if (!read.ok) return false;
541
+ const state = read.state;
542
+ // needs_conversation counts: on a watermark without a conversation_id,
543
+ // writeSessionIntent resets a failed attempt's retry_wait to
544
+ // needs_conversation and keeps next_retry_at. OpenCode does this on every
545
+ // snapshot (ensureCaptureIntent).
546
+ if (["idle", "complete", "draining"].includes(state.delivery)) return false;
547
+ if (!(Number(state.queued_exchanges || 0) > 0)) return false;
548
+ const retryMs = Date.parse(state.next_retry_at || "");
549
+ if (!Number.isFinite(retryMs) || retryMs > nowMs) return false;
550
+ return state.next_retry_at !== ownRetryAttemptedFor;
551
+ }
552
+
553
+ function retryOwnSession() {
554
+ if (exiting || tickInFlight || recoveryInFlight || ownRetryInFlight) {
555
+ return Promise.resolve();
556
+ }
557
+ ownRetryInFlight = runOwnRetry().finally(() => {
558
+ ownRetryInFlight = null;
559
+ });
560
+ return ownRetryInFlight;
561
+ }
562
+
563
+ async function runOwnRetry() {
564
+ const read = readState(sessionId);
565
+ ownRetryAttemptedFor = read.ok ? read.state.next_retry_at : null;
566
+ const live = await runCaptureChild(
567
+ "checkpoint",
568
+ { transcript_path: transcriptPath, force_retry: false },
569
+ CAPTURE_TIMEOUT_MS,
570
+ );
571
+ if (!live.ok) {
572
+ process.stderr.write(
573
+ `[meko-timer] Own-session retry failed: ${live.error}\n`,
574
+ );
575
+ }
576
+ rebuildHealth();
577
+ }
578
+
364
579
  async function tick() {
365
- if (tickInFlight) return;
580
+ if (tickInFlight || exiting) return;
366
581
  tickInFlight = true;
367
582
  try {
583
+ // Never run two checkpoints of this session at once.
584
+ if (ownRetryInFlight) await ownRetryInFlight.catch(() => {});
368
585
  if (Date.now() - startedAt >= MAX_AGE_SECS * 1000) {
369
586
  process.stderr.write(
370
587
  `[meko-timer] Max age (${MAX_AGE_SECS}s) reached; self-exiting (outbox left pending).\n`,
@@ -382,14 +599,16 @@ async function tick() {
382
599
  await runRecoveryCycle();
383
600
  return;
384
601
  }
385
- // Transcript gone after grace — run one closing recovery for self, then exit.
602
+ // Transcript gone after grace. For a resumable session drain without
603
+ // finalizing so a later snapshot can complete its pending turn.
604
+ const resumable = selfIsResumable();
386
605
  await runCaptureChild(
387
606
  "drain",
388
607
  {
389
608
  session_id: sessionId,
390
609
  transcript_path: transcriptPath,
391
- lifecycle: "closing",
392
- confirmed_dead: true,
610
+ lifecycle: resumable ? "active" : "closing",
611
+ confirmed_dead: !resumable,
393
612
  recorded_owner_only: true,
394
613
  },
395
614
  CAPTURE_TIMEOUT_MS,
@@ -403,13 +622,14 @@ async function tick() {
403
622
  process.stderr.write(
404
623
  `[meko-timer] Transcript idle > ${IDLE_EXIT_SECS}s; final drain then exit.\n`,
405
624
  );
625
+ const resumable = selfIsResumable();
406
626
  await runCaptureChild(
407
627
  "drain",
408
628
  {
409
629
  session_id: sessionId,
410
630
  transcript_path: transcriptPath,
411
- lifecycle: "closing",
412
- confirmed_dead: true,
631
+ lifecycle: resumable ? "active" : "closing",
632
+ confirmed_dead: !resumable,
413
633
  recorded_owner_only: true,
414
634
  },
415
635
  CAPTURE_TIMEOUT_MS,
@@ -459,6 +679,192 @@ async function tick() {
459
679
  }
460
680
  }
461
681
 
682
+ function autofixDue(nowMs) {
683
+ if (!autofixEnabled() || !fs.existsSync(cleanupScript)) return false;
684
+ const last = readAutofixLast();
685
+ const ranAt = last ? Date.parse(last.ran_at) : NaN;
686
+ return !Number.isFinite(ranAt) || nowMs - ranAt >= autofixIntervalSeconds() * 1000;
687
+ }
688
+
689
+ function acquireAutofixLock(nowMs) {
690
+ const lockPath = path.join(STATE_DIR, "autofix.lock");
691
+ for (let attempt = 0; attempt < 2; attempt++) {
692
+ try {
693
+ fs.writeFileSync(
694
+ lockPath,
695
+ JSON.stringify({ pid: process.pid, at: new Date(nowMs).toISOString() }),
696
+ { flag: "wx", mode: 0o600 },
697
+ );
698
+ return lockPath;
699
+ } catch (err) {
700
+ if (!err || err.code !== "EEXIST") return null;
701
+ let held = null;
702
+ try {
703
+ held = JSON.parse(fs.readFileSync(lockPath, "utf8"));
704
+ } catch {
705
+ held = null;
706
+ }
707
+ const heldAt = held ? Date.parse(held.at) : NaN;
708
+ // Stale only when neither the timer that took it nor the run it
709
+ // started is alive: a run outlives its timer if the timer is killed.
710
+ const holderAlive = held && (isProcessAlive(Number(held.pid)) || groupAlive(Number(held.run_pid)));
711
+ const stale =
712
+ !held ||
713
+ !holderAlive ||
714
+ !Number.isFinite(heldAt) ||
715
+ nowMs - heldAt > AUTOFIX_TIMEOUT_MS * 2;
716
+ if (!stale) return null;
717
+ try {
718
+ fs.unlinkSync(lockPath);
719
+ } catch {
720
+ /* another timer removed it first */
721
+ }
722
+ }
723
+ }
724
+ return null;
725
+ }
726
+
727
+ function summarizeAutofix(stdout, err) {
728
+ let report = null;
729
+ try {
730
+ report = JSON.parse(stdout);
731
+ } catch {
732
+ report = null;
733
+ }
734
+ const errors = report && Array.isArray(report.errors) ? report.errors : [];
735
+ const sessions = report && Array.isArray(report.sessions) ? report.sessions : [];
736
+ const parts = [];
737
+ if (sessions.length) {
738
+ const byOutcome = {};
739
+ for (const x of sessions) byOutcome[x.outcome] = (byOutcome[x.outcome] || 0) + 1;
740
+ parts.push(Object.entries(byOutcome).map(([k, v]) => `${k}=${v}`).join(" "));
741
+ }
742
+ if (errors.length) parts.push(`errors: ${errors.join("; ")}`);
743
+ if (!report) parts.push(err ? `failed: ${err.message}` : "failed: no report");
744
+ else if (err && !errors.length) parts.push(`exit ${err.code != null ? err.code : "?"}`);
745
+ return parts.length ? parts.join("; ") : "nothing to fix";
746
+ }
747
+
748
+ /**
749
+ * A run that crashed or timed out never wrote its own summary. Replace the
750
+ * placeholder so status and the status bar don't show a run that never
751
+ * finished, or the previous run's needs_you list as current.
752
+ */
753
+ function finishAutofixPlaceholder(error) {
754
+ const last = readAutofixLast();
755
+ if (!last || !last.running) return;
756
+ try {
757
+ atomicWriteJson(autofixLastPath(), {
758
+ ...last,
759
+ running: false,
760
+ error,
761
+ counts: {},
762
+ needs_you: [],
763
+ });
764
+ } catch {
765
+ /* informational */
766
+ }
767
+ }
768
+
769
+ async function runAutofix() {
770
+ if (autofixInFlight || exiting) return;
771
+ const now = Date.now();
772
+ if (!autofixDue(now)) return;
773
+ // A client whose hook credentials were removed has nothing to repair with.
774
+ const childEnv = { ...captureChildEnv(), MEKO_CLEANUP_ENV_ONLY: "1" };
775
+ if (!childEnv.MEKO_MCP_URL) return;
776
+ const lockPath = acquireAutofixLock(now);
777
+ if (!lockPath) return;
778
+ autofixLockPath = lockPath;
779
+ autofixInFlight = true;
780
+ try {
781
+ // Another timer may have finished a run between the check and the lock.
782
+ if (!autofixDue(Date.now())) return;
783
+ // Record the attempt first: if the run crashes, the next one still
784
+ // waits a full interval instead of every timer retrying every minute.
785
+ const previous = readAutofixLast() || {};
786
+ try {
787
+ atomicWriteJson(autofixLastPath(), {
788
+ schema_version: 1,
789
+ ran_at: new Date().toISOString(),
790
+ mode: "auto",
791
+ running: true,
792
+ counts: {},
793
+ needs_you: Array.isArray(previous.needs_you) ? previous.needs_you : [],
794
+ });
795
+ } catch {
796
+ return;
797
+ }
798
+ await new Promise((resolve) => {
799
+ // Its own process group (execFile ignores `detached`), so stopping it
800
+ // also stops the capture.js child it is blocked on.
801
+ const child = spawn(
802
+ process.execPath,
803
+ [
804
+ cleanupScript,
805
+ "fix",
806
+ "--auto",
807
+ "--json",
808
+ "--max-exchanges",
809
+ String(AUTOFIX_MAX_EXCHANGES),
810
+ "--pace-ms",
811
+ "500",
812
+ ],
813
+ { env: childEnv, detached: true, stdio: ["ignore", "pipe", "ignore"] },
814
+ );
815
+ autofixChild = child;
816
+ let stdout = "";
817
+ child.stdout.on("data", (chunk) => {
818
+ if (stdout.length < 16 * 1024 * 1024) stdout += chunk;
819
+ });
820
+ let timedOut = false;
821
+ const timer = setTimeout(() => {
822
+ timedOut = true;
823
+ signalGroup(child.pid, "SIGTERM");
824
+ setTimeout(() => signalGroup(child.pid, "SIGKILL"), 3000).unref();
825
+ }, AUTOFIX_TIMEOUT_MS);
826
+ // Record the run in the lock, so the lock isn't taken as stale while
827
+ // the run is alive.
828
+ try {
829
+ atomicWriteJson(lockPath, { pid: process.pid, run_pid: child.pid, at: new Date(now).toISOString() });
830
+ } catch {
831
+ /* the lock still names this timer */
832
+ }
833
+ let finished = false;
834
+ const finish = (err) => {
835
+ if (finished) return;
836
+ finished = true;
837
+ clearTimeout(timer);
838
+ const failure = timedOut
839
+ ? new Error(`timed out after ${AUTOFIX_TIMEOUT_MS / 1000}s`)
840
+ : err;
841
+ // Anything left in the group (capture.js) after the CLI failed.
842
+ if (failure) signalGroup(child.pid, "SIGTERM");
843
+ const summary = summarizeAutofix(stdout, failure);
844
+ if (failure) finishAutofixPlaceholder(summary);
845
+ process.stderr.write(`[meko-timer] Automatic repair: ${summary}\n`);
846
+ resolve();
847
+ };
848
+ child.on("error", (err) => finish(err));
849
+ child.on("close", (code, signal) => {
850
+ if (code === 0 || code === 2) return finish(null);
851
+ const err = new Error(signal ? `stopped by ${signal}` : `exit ${code}`);
852
+ err.code = code;
853
+ finish(err);
854
+ });
855
+ });
856
+ } finally {
857
+ autofixInFlight = false;
858
+ const runPid = autofixChild ? autofixChild.pid : null;
859
+ autofixChild = null;
860
+ if (!exiting) {
861
+ // Wait for any capture.js left in the run's group before releasing.
862
+ for (let waited = 0; runPid && waited < 3000 && groupAlive(runPid); waited += 100) await sleep(100);
863
+ releaseAutofixLock(runPid);
864
+ }
865
+ }
866
+ }
867
+
462
868
  if (!acquirePidFile()) {
463
869
  process.exit(0);
464
870
  }
@@ -477,13 +883,34 @@ setInterval(() => {
477
883
  });
478
884
  }, INTERVAL_SECS * 1000);
479
885
 
886
+ // Automatic repair check, once a minute; the first one runs shortly after
887
+ // startup so a stuck machine is repaired without waiting a full interval.
888
+ setTimeout(() => {
889
+ runAutofix().catch((err) => {
890
+ process.stderr.write(`[meko-timer] Automatic repair failed: ${err.message}\n`);
891
+ });
892
+ }, AUTOFIX_FIRST_CHECK_SECS * 1000).unref();
893
+ setInterval(() => {
894
+ runAutofix().catch((err) => {
895
+ process.stderr.write(`[meko-timer] Automatic repair failed: ${err.message}\n`);
896
+ });
897
+ }, AUTOFIX_CHECK_SECS * 1000);
898
+
480
899
  // While backlog remains, also schedule a shorter recovery follow-up without
481
- // waiting for the full checkpoint interval (self-draining).
900
+ // waiting for the full checkpoint interval (self-draining). The same loop
901
+ // retries this daemon's own session once its next_retry_at passes.
482
902
  setInterval(() => {
483
- if (!workRemains || recoveryInFlight || tickInFlight) return;
903
+ if (exiting || recoveryInFlight || tickInFlight || ownRetryInFlight) return;
904
+ if (ownRetryDue(Date.now())) {
905
+ retryOwnSession().catch((err) => {
906
+ process.stderr.write(`[meko-timer] Own-session retry failed: ${err.message}\n`);
907
+ });
908
+ return;
909
+ }
910
+ if (!workRemains) return;
484
911
  runRecoveryCycle().catch((err) => {
485
912
  process.stderr.write(
486
913
  `[meko-timer] Follow-up recovery failed: ${err.message}\n`,
487
914
  );
488
915
  });
489
- }, Math.min(30, INTERVAL_SECS) * 1000);
916
+ }, Math.min(FOLLOWUP_SECS, INTERVAL_SECS) * 1000);