@yugabytedb/meko-mcp 2.3.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 (40) hide show
  1. package/README.md +72 -33
  2. package/assets/hooks-handlers/lib/capture-cleanup-cli.js +735 -74
  3. package/assets/hooks-handlers/lib/capture-notices.js +94 -1
  4. package/assets/hooks-handlers/lib/capture-state.js +111 -0
  5. package/assets/hooks-handlers/lib/capture.js +129 -4
  6. package/assets/hooks-handlers/lib/checkpoint-timer.js +394 -8
  7. package/assets/hooks-handlers/lib/opencode-handler.js +29 -6
  8. package/assets/scripts/meko-statusline.sh +17 -1
  9. package/assets/skills/meko-mcp-tools/SKILL.md +5 -5
  10. package/assets/skills/meko-mcp-tools/references/_sections.md +1 -1
  11. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +193 -9
  12. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +6 -3
  13. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +11 -6
  14. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +4 -4
  15. package/assets/skills/meko-mcp-tools/references/tools-overview.md +16 -11
  16. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +67 -8
  17. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +3 -1
  18. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +7 -7
  19. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +4 -4
  20. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
  21. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +193 -9
  22. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +10 -5
  23. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +4 -4
  24. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +16 -11
  25. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +67 -8
  26. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +3 -1
  27. package/assets/skills/meko-select-datapack-desktop/SKILL.md +91 -12
  28. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +39 -7
  29. package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +8 -6
  30. package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +13 -1
  31. package/bin/create-meko-setup.mjs +117 -24
  32. package/lib/autofix.mjs +100 -0
  33. package/lib/browser-login.mjs +495 -0
  34. package/lib/clients/opencode.mjs +6 -1
  35. package/lib/migrate/datapack.mjs +254 -16
  36. package/lib/migrate/mcp-client.mjs +34 -0
  37. package/lib/migrate/orchestrator.mjs +1 -1
  38. package/lib/migrate.mjs +1 -1
  39. package/lib/validate.mjs +111 -22
  40. package/package.json +5 -5
@@ -1,4 +1,18 @@
1
1
  #!/usr/bin/env node
2
+ /*
3
+ * Licensed to YugabyteDB, Inc. under one or more contributor license agreements.
4
+ * See the NOTICE file distributed with this work for additional information
5
+ * regarding copyright ownership. YugabyteDB licenses this file to you under
6
+ * the Apache License, Version 2.0 (the "License"); you may not use this file
7
+ * except in compliance with the License. You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software distributed
12
+ * under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
13
+ * CONDITIONS OF ANY KIND, either express or implied. See the License for the
14
+ * specific language governing permissions and limitations under the License.
15
+ */
2
16
  /**
3
17
  * capture-cleanup-cli.js: inspect and clean up stuck conversation capture.
4
18
  *
@@ -25,6 +39,12 @@
25
39
  * repair Clear failure fields left on fully delivered sessions, and
26
40
  * remove timer PID files, leases, and SessionEnd job files whose
27
41
  * process is gone.
42
+ * fix Do all of the above for every session that needs it: diagnose
43
+ * why it is stuck, then repair it, drain it, or move its unsent
44
+ * turns to a new conversation when the old one can't be served.
45
+ * Sessions that need you (bad API key, plan limit, datapack) get
46
+ * one attempt per run and are reported if it fails the same way.
47
+ * Stops early when the server itself is failing.
28
48
  *
29
49
  * Parking works with the capture hooks already installed (2.2.0 and later):
30
50
  * a drain skips the network while `failure_class` is set and
@@ -48,12 +68,15 @@ const path = require("path");
48
68
  const cs = require("./capture-state");
49
69
  const { PARKED_REASON_PREFIX } = require("./capture-notices");
50
70
 
51
- const ACTIONS = ["status", "stop", "resume", "drain", "discard", "repair"];
52
- const PARK_UNTIL = "9999-12-31T00:00:00.000Z";
71
+ const ACTIONS = ["status", "fix", "stop", "resume", "drain", "discard", "repair"];
72
+ const PARK_UNTIL = cs.CLEANUP_PARK_UNTIL;
53
73
  const PARKED_BY = "meko-mcp --capture-cleanup";
54
74
  const RESUME_HINT =
55
75
  `${PARKED_REASON_PREFIX}; run \`npx @yugabytedb/meko-mcp@latest ` +
56
- "--capture-cleanup drain --all` to send, or `resume` to hand back to the hooks";
76
+ "--capture-cleanup fix` to send it, or `resume` to hand back to the hooks";
77
+ // A conversation reopened this many times was replayed into over and over
78
+ // (one session reached epoch 7,496); its trace is likely too large to serve.
79
+ const REPLAY_EPOCH_THRESHOLD = 20;
57
80
  const PENDING_DELIVERIES = [
58
81
  "needs_conversation",
59
82
  "pending",
@@ -63,7 +86,7 @@ const PENDING_DELIVERIES = [
63
86
  ];
64
87
  // capture.js modes that run in the background. Foreground hook modes
65
88
  // (session-start, pre-compact) belong to a live client and are left alone.
66
- const BACKGROUND_CAPTURE_MODES = ["drain", "recover", "checkpoint", "session-end"];
89
+ const BACKGROUND_CAPTURE_MODES = ["drain", "recover", "checkpoint", "session-end", "move"];
67
90
  const DEFAULT_MAX_EXCHANGES = 200;
68
91
  const DEFAULT_PACE_MS = 1000;
69
92
  const KILL_WAIT_MS = 3000;
@@ -74,6 +97,7 @@ const REPARK_ATTEMPTS = 10;
74
97
  const REPARK_RETRY_MS = 1000;
75
98
  const LEASE_POLL_MS = 500;
76
99
  const MAX_LEASE_WAITS = 5;
100
+ const MAX_MOVES_PER_DRAIN = 1;
77
101
 
78
102
  // ---------------------------------------------------------------------------
79
103
  // Args
@@ -81,9 +105,9 @@ const MAX_LEASE_WAITS = 5;
81
105
 
82
106
  function usage() {
83
107
  return (
84
- "Usage: capture-cleanup-cli.js <status|stop|resume|drain|discard|repair>\n" +
108
+ "Usage: capture-cleanup-cli.js <status|fix|stop|resume|drain|discard|repair>\n" +
85
109
  " [--session <id>]... [--all] [--dry-run] [--json] [--yes]\n" +
86
- " [--max-exchanges N] [--pace-ms N]\n"
110
+ " [--max-exchanges N] [--pace-ms N] [--auto]\n"
87
111
  );
88
112
  }
89
113
 
@@ -95,6 +119,7 @@ function parseArgs(argv) {
95
119
  dryRun: false,
96
120
  json: false,
97
121
  yes: false,
122
+ auto: false,
98
123
  maxExchanges: DEFAULT_MAX_EXCHANGES,
99
124
  paceMs: DEFAULT_PACE_MS,
100
125
  };
@@ -118,6 +143,7 @@ function parseArgs(argv) {
118
143
  else if (a === "--dry-run") out.dryRun = true;
119
144
  else if (a === "--json") out.json = true;
120
145
  else if (a === "--yes") out.yes = true;
146
+ else if (a === "--auto") out.auto = true;
121
147
  else if (a === "--max-exchanges") out.maxExchanges = intArg(a, next());
122
148
  else if (a === "--pace-ms") out.paceMs = intArg(a, next());
123
149
  else if (!a.startsWith("-") && !out.action) out.action = a;
@@ -390,6 +416,13 @@ function parkedState(state, reason, nowIso) {
390
416
  };
391
417
  }
392
418
 
419
+ const PAUSED_BY_USER = "paused by the user with stop";
420
+
421
+ /** A session the user parked with `stop`, while that park is in effect. */
422
+ function stoppedByUser(state) {
423
+ return isParked(state) && state.parked.reason === "stop";
424
+ }
425
+
393
426
  /**
394
427
  * Retry fields for a session handed back to capture.js. A failure that was
395
428
  * `persistent` with scope `exchange` is what lets capture.js skip one
@@ -497,6 +530,19 @@ function waitForLease(sessionId, maxMs) {
497
530
  return false;
498
531
  }
499
532
 
533
+ /**
534
+ * capture.js answers retry_wait with no failure class and no error when it
535
+ * has just moved the session's backlog to a new conversation (MEKO-723).
536
+ */
537
+ function movedToNewConversation(result) {
538
+ return Boolean(
539
+ result &&
540
+ result.status === "retry_wait" &&
541
+ !result.failure_class &&
542
+ !result.error,
543
+ );
544
+ }
545
+
500
546
  function heldByAnotherWorker(result) {
501
547
  return (
502
548
  result &&
@@ -511,7 +557,7 @@ function heldByAnotherWorker(result) {
511
557
  // ---------------------------------------------------------------------------
512
558
 
513
559
  const HOOK_SCRIPT_RE =
514
- /hooks-handlers[\\/]lib[\\/](checkpoint-timer|terminal-handoff|capture)\.js(?:\s+(\S+))?(?:\s+(\S+))?/;
560
+ /hooks-handlers[\\/]lib[\\/](checkpoint-timer|terminal-handoff|capture|capture-cleanup-cli)\.js(?:\s+(\S+))?(?:\s+(\S+))?/;
515
561
 
516
562
  /**
517
563
  * List background capture processes from `ps`. Returns { ok, processes,
@@ -555,6 +601,9 @@ function listCaptureProcesses() {
555
601
  kind = "session_end_worker";
556
602
  jobFile = hit[3] || null;
557
603
  sessionId = sessionFromJobFile(jobFile);
604
+ } else if (script === "capture-cleanup-cli" && hit[2] === "fix" && /\s--auto(\s|$)/.test(command)) {
605
+ // An automatic repair run started by a checkpoint timer.
606
+ kind = "autofix_run";
558
607
  } else if (script === "capture" && BACKGROUND_CAPTURE_MODES.includes(hit[2])) {
559
608
  kind = `capture_${hit[2].replace("-", "_")}`;
560
609
  }
@@ -689,7 +738,10 @@ function resolveCredentials() {
689
738
  MEKO_API_KEY: process.env.MEKO_API_KEY || "",
690
739
  };
691
740
  let source = env.MEKO_MCP_URL ? "environment" : null;
692
- if (!env.MEKO_MCP_URL) {
741
+ // The checkpoint timer sets MEKO_CLEANUP_ENV_ONLY for automatic runs: a
742
+ // client whose hook credentials were removed must not borrow Claude
743
+ // Code's from settings.json.
744
+ if (!env.MEKO_MCP_URL && !process.env.MEKO_CLEANUP_ENV_ONLY) {
693
745
  const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
694
746
  try {
695
747
  const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
@@ -715,6 +767,7 @@ function resolveCredentials() {
715
767
  function drainSucceeded(result) {
716
768
  if (!result) return false;
717
769
  if (result.status === "ok" || result.status === "noop") return true;
770
+ if (movedToNewConversation(result)) return true;
718
771
  return result.status === "partial" && !result.failure_class && !result.error;
719
772
  }
720
773
 
@@ -769,6 +822,20 @@ function refreshHealthCache() {
769
822
  return health;
770
823
  }
771
824
 
825
+ function describeLastFix(last) {
826
+ if (!last || !last.ran_at) return null;
827
+ const c = last.counts || {};
828
+ const parts = [`fixed ${c.fixed || 0}`];
829
+ if (c.moved) parts.push(`moved ${c.moved}`);
830
+ if (c.paused) parts.push(`left paused ${c.paused}`);
831
+ if (c.failed) parts.push(`failed ${c.failed}`);
832
+ if (c.not_attempted) parts.push(`not attempted ${c.not_attempted}`);
833
+ for (const n of last.needs_you || []) parts.push(`needs you (${n.cause}): ${n.sessions}`);
834
+ if (last.error) parts.push(`error: ${String(last.error).slice(0, 120)}`);
835
+ if (last.running) parts.push("did not finish");
836
+ return `${last.mode === "auto" ? "automatic" : "manual"} run at ${last.ran_at}: ${parts.join(", ")}`;
837
+ }
838
+
772
839
  function actionStatus(opts) {
773
840
  const nowMs = Date.now();
774
841
  const entries = scan();
@@ -794,6 +861,8 @@ function actionStatus(opts) {
794
861
  action: "status",
795
862
  watermark_dir: cs.watermarkDir(),
796
863
  total_sessions: summaries.length,
864
+ last_fix: cs.readAutofixLast(),
865
+ autofix: { ...cs.autofixSetting(), interval_seconds: cs.autofixIntervalSeconds() },
797
866
  sessions: selected,
798
867
  processes: procs.processes,
799
868
  process_scan_error: procs.ok ? null : procs.error,
@@ -854,6 +923,35 @@ function actionStop(opts) {
854
923
  : [];
855
924
  // Stop processes before parking: a drain that is mid-flight would
856
925
  // otherwise commit its own state over the park.
926
+ // An automatic repair run for this directory: the child of the timer that
927
+ // holds this directory's autofix.lock. Matching by name alone could stop
928
+ // another directory's run.
929
+ if (procs.ok && opts.sessions.length === 0) {
930
+ let lockPid = NaN;
931
+ let runPid = NaN;
932
+ try {
933
+ const lock = JSON.parse(fs.readFileSync(path.join(cs.watermarkDir(), "autofix.lock"), "utf8"));
934
+ lockPid = Number(lock.pid);
935
+ runPid = Number(lock.run_pid);
936
+ } catch {
937
+ lockPid = NaN;
938
+ runPid = NaN;
939
+ }
940
+ // A run outlives a timer that was killed, and is then reparented, so
941
+ // also match the run's own PID from the lock.
942
+ for (const p of procs.processes) {
943
+ if (p.kind === "autofix_run" && (p.ppid === lockPid || p.pid === runPid) && !targets.includes(p)) targets.push(p);
944
+ }
945
+ }
946
+ // Stopping the automatic run's wrapper doesn't stop the capture.js child it
947
+ // is blocked on, so stop those children too and wait for them before
948
+ // parking.
949
+ const autofixPids = new Set(targets.filter((p) => p.kind === "autofix_run").map((p) => p.pid));
950
+ if (procs.ok) {
951
+ for (const p of procs.processes) {
952
+ if (p.kind.startsWith("capture_") && autofixPids.has(p.ppid) && !targets.includes(p)) targets.push(p);
953
+ }
954
+ }
857
955
  let killed = [];
858
956
  if (!opts.dryRun) {
859
957
  killed = killProcesses(targets);
@@ -867,7 +965,9 @@ function actionStop(opts) {
867
965
  selected.map((e) => e.session_id),
868
966
  opts,
869
967
  activeIds,
870
- ).filter((p) => p.kind.startsWith("capture_") && !seen.has(p.pid));
968
+ )
969
+ .concat(again.processes.filter((p) => p.kind.startsWith("capture_") && autofixPids.has(p.ppid)))
970
+ .filter((p, i, all) => p.kind.startsWith("capture_") && !seen.has(p.pid) && all.indexOf(p) === i);
871
971
  killed = killed.concat(killProcesses(late));
872
972
  }
873
973
  }
@@ -922,71 +1022,46 @@ function actionResume(opts) {
922
1022
  };
923
1023
  }
924
1024
 
925
- function actionDrain(opts, log) {
926
- if (opts.sessions.length === 0 && !opts.all) {
927
- return { action: "drain", errors: ["drain needs --session <id> or --all"], exit: 1 };
928
- }
929
- const { env, source } = resolveCredentials();
930
- if (!env.MEKO_MCP_URL) {
931
- return {
932
- action: "drain",
933
- errors: [
934
- "MEKO_MCP_URL is not set and ~/.claude/settings.json has no env.MEKO_MCP_URL; " +
935
- "export MEKO_MCP_URL and MEKO_API_KEY, then retry",
936
- ],
937
- exit: 1,
938
- };
939
- }
940
- const nowMs = Date.now();
941
- const entries = scan();
942
- const { entries: selected, errors } = selectEntries(entries, opts, null);
943
- const targets = selected.filter((e) => e.ok && hasPendingWork(e.state));
944
- const results = [];
945
- // Say why a session named on the command line isn't drained, instead of
946
- // "nothing to do".
947
- if (opts.sessions.length > 0) {
948
- for (const e of selected) {
949
- if (targets.includes(e)) continue;
950
- let reason = "nothing to send";
951
- if (!e.ok) reason = "watermark unreadable";
952
- else if (hasStaleFailure(e.state)) {
953
- reason =
954
- "nothing to send; only leftover failure fields remain. Run `--capture-cleanup repair` to clear them";
955
- }
956
- results.push({ session_id: e.session_id, outcome: "skipped", reason });
957
- }
1025
+ /**
1026
+ * Drain one session in the foreground until it is empty, fails, or the
1027
+ * shared `ctx.budget` runs out. Unparks first and parks again on failure.
1028
+ * Returns { result, stop }: `stop` asks the caller not to attempt later
1029
+ * sessions, because the failure may be the server's.
1030
+ */
1031
+ function drainOneSession(e, opts, ctx, options) {
1032
+ const sid = e.session_id;
1033
+ const log = ctx.log;
1034
+ // freshEntry() returns no state when the watermark can't be read; report
1035
+ // that session as failed instead of throwing and ending the whole run.
1036
+ if (!e.ok || !e.state) {
1037
+ return { result: { session_id: sid, outcome: "failed", reason: e.error || "could not read the session state" }, stop: false };
958
1038
  }
959
- let budget = opts.maxExchanges;
960
- let stopped = null;
961
-
962
- for (const e of targets) {
963
- const sid = e.session_id;
964
- if (stopped) {
965
- results.push({ session_id: sid, outcome: "not_attempted" });
966
- continue;
967
- }
968
- if (budget <= 0) {
969
- results.push({ session_id: sid, outcome: "not_attempted", reason: "--max-exchanges reached" });
970
- continue;
971
- }
972
- if (opts.dryRun) {
973
- results.push({ session_id: sid, outcome: "would_drain", queued_exchanges: Number(e.state.queued_exchanges) || 0 });
974
- continue;
975
- }
976
- const wasParked = isParked(e.state);
1039
+ {
1040
+ // A caller that already changed the state (a move ends the park) passes
1041
+ // whether the session was parked before, so "a parked session stays
1042
+ // parked" still holds for partial and skipped drains.
1043
+ const wasParked =
1044
+ Boolean(options && options.wasParked) || isParked(e.state);
977
1045
  let state = e.state;
978
1046
  if (wasParked || state.failure_class) {
979
- const res = mutate(sid, (current) =>
980
- isParked(current) ? unparkedState(current) : handBackState(current),
981
- );
1047
+ const res = mutate(sid, (current) => {
1048
+ // An automatic run scanned before the user may have run `stop`;
1049
+ // check the current state under the lease, not the scan.
1050
+ if (opts.auto && stoppedByUser(current)) {
1051
+ return { ok: false, error: PAUSED_BY_USER };
1052
+ }
1053
+ return isParked(current) ? unparkedState(current) : handBackState(current);
1054
+ });
1055
+ if (!res.ok && res.error === PAUSED_BY_USER) {
1056
+ return { result: { session_id: sid, outcome: "paused_by_user" }, stop: false };
1057
+ }
982
1058
  if (!res.ok) {
983
- results.push({ session_id: sid, outcome: res.busy ? "busy" : "failed", reason: res.error });
984
- continue;
1059
+ return { result: { session_id: sid, outcome: res.busy ? "busy" : "failed", reason: res.error }, stop: false };
985
1060
  }
986
1061
  state = res.state;
987
1062
  }
988
1063
 
989
- const live = looksLive(state, transcriptInfo(state), nowMs);
1064
+ const live = looksLive(state, transcriptInfo(state), ctx.nowMs);
990
1065
  // Never rebucket from here: this process's cwd says nothing about the
991
1066
  // session's owner. A session whose client is gone is finalized the way
992
1067
  // timer recovery does it.
@@ -995,11 +1070,13 @@ function actionDrain(opts, log) {
995
1070
  let last = null;
996
1071
  let stderr = "";
997
1072
  let leaseWaits = 0;
1073
+ let moves = 0;
1074
+ let stop = false;
998
1075
  for (;;) {
999
1076
  const run = runCaptureChild(
1000
1077
  mode,
1001
- { session_id: sid, recorded_owner_only: true, max_exchanges: Math.min(budget, cs.maxCaptureBatchSize()) },
1002
- env,
1078
+ { session_id: sid, recorded_owner_only: true, max_exchanges: Math.min(ctx.budget, cs.maxCaptureBatchSize()) },
1079
+ ctx.env,
1003
1080
  );
1004
1081
  last = run.result;
1005
1082
  stderr = run.stderr;
@@ -1012,10 +1089,17 @@ function actionDrain(opts, log) {
1012
1089
  if (waitForLease(sid, waitMs)) continue;
1013
1090
  }
1014
1091
  captured += Number(last.captured) || 0;
1015
- budget -= Number(last.captured) || 0;
1092
+ ctx.budget -= Number(last.captured) || 0;
1016
1093
  const remaining = Number(last.queued_remaining) || 0;
1017
1094
  if (!drainSucceeded(last)) break;
1018
- if (remaining === 0 || !last.captured || budget <= 0) break;
1095
+ if (movedToNewConversation(last) && moves < MAX_MOVES_PER_DRAIN) {
1096
+ // capture.js moved the backlog to a new conversation (MEKO-723);
1097
+ // nothing was sent yet, so drain again into it.
1098
+ moves++;
1099
+ log(` ${sid.slice(0, 8)}: moved to a new conversation; continuing`);
1100
+ continue;
1101
+ }
1102
+ if (remaining === 0 || !last.captured || ctx.budget <= 0) break;
1019
1103
  log(` ${sid.slice(0, 8)}: sent ${captured}, ${remaining} left`);
1020
1104
  sleepHoldingLease(sid, opts.paceMs);
1021
1105
  }
@@ -1033,7 +1117,7 @@ function actionDrain(opts, log) {
1033
1117
  // hold the lease right now (that is often why the drain failed), so
1034
1118
  // retry, and say so when the park doesn't land.
1035
1119
  const res = repark(`drain failed: ${last.error || last.status}`);
1036
- stopped = sid;
1120
+ stop = true;
1037
1121
  outcome = res.ok ? "failed_and_parked" : "failed_not_parked";
1038
1122
  if (!res.ok) reparkError = res.error;
1039
1123
  } else if (last.status === "noop" && last.error && captured === 0) {
@@ -1058,7 +1142,9 @@ function actionDrain(opts, log) {
1058
1142
  } else {
1059
1143
  outcome = "drained";
1060
1144
  }
1061
- results.push({
1145
+ return {
1146
+ stop,
1147
+ result: {
1062
1148
  session_id: sid,
1063
1149
  outcome,
1064
1150
  captured,
@@ -1067,9 +1153,68 @@ function actionDrain(opts, log) {
1067
1153
  error: last.error || null,
1068
1154
  repark_error: reparkError || undefined,
1069
1155
  stderr: ok ? undefined : stderr || undefined,
1070
- });
1156
+ },
1157
+ };
1158
+ }
1159
+ }
1160
+
1161
+ function actionDrain(opts, log) {
1162
+ if (opts.sessions.length === 0 && !opts.all) {
1163
+ return { action: "drain", errors: ["drain needs --session <id> or --all"], exit: 1 };
1164
+ }
1165
+ const { env, source } = resolveCredentials();
1166
+ if (!env.MEKO_MCP_URL) {
1167
+ return {
1168
+ action: "drain",
1169
+ errors: [
1170
+ "MEKO_MCP_URL is not set and ~/.claude/settings.json has no env.MEKO_MCP_URL; " +
1171
+ "export MEKO_MCP_URL and MEKO_API_KEY, then retry",
1172
+ ],
1173
+ exit: 1,
1174
+ };
1175
+ }
1176
+ const nowMs = Date.now();
1177
+ const entries = scan();
1178
+ const { entries: selected, errors } = selectEntries(entries, opts, null);
1179
+ const targets = selected.filter((e) => e.ok && hasPendingWork(e.state));
1180
+ const results = [];
1181
+ // Say why a session named on the command line isn't drained, instead of
1182
+ // "nothing to do".
1183
+ if (opts.sessions.length > 0) {
1184
+ for (const e of selected) {
1185
+ if (targets.includes(e)) continue;
1186
+ let reason = "nothing to send";
1187
+ if (!e.ok) reason = "watermark unreadable";
1188
+ else if (hasStaleFailure(e.state)) {
1189
+ reason =
1190
+ "nothing to send; only leftover failure fields remain. Run `--capture-cleanup repair` to clear them";
1191
+ }
1192
+ results.push({ session_id: e.session_id, outcome: "skipped", reason });
1193
+ }
1194
+ }
1195
+ const ctx = { env, log, nowMs, budget: opts.maxExchanges };
1196
+ let stopped = null;
1197
+
1198
+ for (const e of targets) {
1199
+ const sid = e.session_id;
1200
+ if (stopped) {
1201
+ results.push({ session_id: sid, outcome: "not_attempted" });
1202
+ continue;
1203
+ }
1204
+ if (ctx.budget <= 0) {
1205
+ results.push({ session_id: sid, outcome: "not_attempted", reason: "--max-exchanges reached" });
1206
+ continue;
1207
+ }
1208
+ if (opts.dryRun) {
1209
+ results.push({ session_id: sid, outcome: "would_drain", queued_exchanges: Number(e.state.queued_exchanges) || 0 });
1210
+ continue;
1211
+ }
1212
+ const { result, stop } = drainOneSession(e, opts, ctx);
1213
+ results.push(result);
1214
+ if (stop) stopped = sid;
1215
+ const outcome = result.outcome;
1071
1216
  if (!["drained", "skipped", "skipped_and_parked"].includes(outcome)) continue;
1072
- if (budget > 0) sleepMs(opts.paceMs);
1217
+ if (ctx.budget > 0) sleepMs(opts.paceMs);
1073
1218
  }
1074
1219
  if (!opts.dryRun) refreshHealthCache();
1075
1220
  const partial =
@@ -1166,6 +1311,502 @@ function actionDiscard(opts) {
1166
1311
  };
1167
1312
  }
1168
1313
 
1314
+ // ---------------------------------------------------------------------------
1315
+ // Diagnose and fix
1316
+ // ---------------------------------------------------------------------------
1317
+
1318
+ function lastErrorOf(state) {
1319
+ const previous = isParked(state) && state.parked.previous;
1320
+ return String((previous && previous.last_error) || state.last_error || "");
1321
+ }
1322
+
1323
+ /**
1324
+ * A failure that says the server, not this session, is in trouble. An error
1325
+ * about this session's conversation ("Could not look up conversation <id>:
1326
+ * timed out") is about the conversation, even when it mentions a timeout.
1327
+ */
1328
+ function serverLevelFailure(error) {
1329
+ if (conversationUnusable(error)) return false;
1330
+ return /timed out|ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|socket hang up|connection refused|MCP HTTP 5\d\d/i.test(
1331
+ String(error || ""),
1332
+ );
1333
+ }
1334
+
1335
+ /** Errors that mean the recorded conversation itself can't be used. */
1336
+ function conversationUnusable(error) {
1337
+ return /agent_id_mismatch|lookup_failed|conversation\s+\S+\s+not\s+found|"not_found"/i.test(
1338
+ String(error || ""),
1339
+ );
1340
+ }
1341
+
1342
+ /**
1343
+ * Causes only the user can fix. Matches the server's error codes and the
1344
+ * reasons capture.js stores in last_error (classifyPersistentCaptureFailure),
1345
+ * for example "the account does not have access to the target datapack".
1346
+ * Returns { cause, hint } or null.
1347
+ */
1348
+ function userActionableCause(error) {
1349
+ const err = String(error || "");
1350
+ if (/\b401\b|unauthori[sz]ed|invalid[_\s-]?api[_\s-]?key/i.test(err)) {
1351
+ return {
1352
+ cause: "auth",
1353
+ hint: "the server rejected the API key; fix MEKO_API_KEY, then run fix again",
1354
+ };
1355
+ }
1356
+ if (
1357
+ /datapack_id_required|datapack_access_denied|read_only_access|access to the target datapack|datapack[^"]*(denied|missing|invalid|mismatch)/i.test(
1358
+ err,
1359
+ )
1360
+ ) {
1361
+ return {
1362
+ cause: "datapack",
1363
+ hint: "this account can't write to the session's datapack; pin another datapack with meko-select-datapack or get write access, then run fix again",
1364
+ };
1365
+ }
1366
+ if (
1367
+ /\b402\b|payment|quota|free[_\s-]?tier|subscription|entitle|plan[_\s-]?limit|plan's capture limit|usage[_\s-]?limit|capture usage limit/i.test(
1368
+ err,
1369
+ )
1370
+ ) {
1371
+ return {
1372
+ cause: "account_limit",
1373
+ hint: "the account hit a plan or capture limit; upgrade or wait, then run fix again",
1374
+ };
1375
+ }
1376
+ if (/Meko rejected capture with HTTP 403/i.test(err)) {
1377
+ return {
1378
+ cause: "forbidden",
1379
+ hint: "the server refused capture (HTTP 403); check the account's access, then run fix again",
1380
+ };
1381
+ }
1382
+ return null;
1383
+ }
1384
+
1385
+ /**
1386
+ * Why a session is stuck and what fixes it. Returns null for a session with
1387
+ * nothing to fix. Actions: repair, drain, move (to a new conversation, then
1388
+ * drain), drain_then_move, retry_once (a cause only the user can fix: one
1389
+ * attempt per run, then report), or none (an unreadable watermark).
1390
+ */
1391
+ function diagnose(entry) {
1392
+ if (!entry.ok) {
1393
+ return {
1394
+ cause: "unreadable",
1395
+ action: "none",
1396
+ hint: "the watermark can't be read; it is left on disk for inspection",
1397
+ };
1398
+ }
1399
+ const s = entry.state;
1400
+ if (hasStaleFailure(s)) {
1401
+ return {
1402
+ cause: "stale_failure",
1403
+ action: "repair",
1404
+ hint: "everything was delivered; only leftover failure fields remain",
1405
+ };
1406
+ }
1407
+ if (!hasPendingWork(s) && !isParked(s)) return null;
1408
+ const err = lastErrorOf(s);
1409
+ const epoch = Number(s.conversation_epoch) || 0;
1410
+ const userCause = userActionableCause(err);
1411
+ if (userCause) {
1412
+ // Try once per run: the user usually runs fix right after changing the
1413
+ // key, plan or access. If it fails the same way, fix reports needs_you
1414
+ // and the session stays parked, so the hooks don't retry it.
1415
+ return { ...userCause, action: "retry_once" };
1416
+ }
1417
+ // A session that was just moved to a new conversation waits for that
1418
+ // conversation to accept a turn before it can be moved again (the guard
1419
+ // from MEKO-723). Moving again would recreate the replay loop.
1420
+ if (s.recovered_from_conversation_id) {
1421
+ return {
1422
+ cause: "awaiting_new_conversation",
1423
+ action: "drain",
1424
+ hint: "already moved to a new conversation that hasn't accepted a turn yet",
1425
+ };
1426
+ }
1427
+ if (/agent_id_mismatch/i.test(err)) {
1428
+ return {
1429
+ cause: "owner_lost",
1430
+ action: "move",
1431
+ hint: "the server no longer recognizes this conversation's owner",
1432
+ };
1433
+ }
1434
+ // conversation_epoch only goes up over a session's life, so count the
1435
+ // epochs since this tool last moved the session, and only when a failure
1436
+ // is recorded; a healthy session moved off a high epoch stays put.
1437
+ const epochsSinceMove = epoch - (Number(s.cleanup_moved_epoch) || 0);
1438
+ if (err && epochsSinceMove >= REPLAY_EPOCH_THRESHOLD) {
1439
+ return {
1440
+ cause: "replay_loop",
1441
+ action: "move",
1442
+ hint: `the conversation was reopened ${epochsSinceMove} times; its trace is likely too large to serve`,
1443
+ };
1444
+ }
1445
+ if (/lookup_failed/i.test(err)) {
1446
+ return {
1447
+ cause: "lookup_failing",
1448
+ action: "drain_then_move",
1449
+ hint: "the server couldn't look the conversation up",
1450
+ };
1451
+ }
1452
+ if (/conversation\s+\S+\s+not\s+found|"not_found"/i.test(err)) {
1453
+ return {
1454
+ cause: "conversation_missing",
1455
+ action: "drain_then_move",
1456
+ hint: "the server answered not_found for the conversation",
1457
+ };
1458
+ }
1459
+ return { cause: "retry", action: "drain", hint: err ? `last error: ${err.slice(0, 120)}` : "unsent turns are waiting" };
1460
+ }
1461
+
1462
+ /**
1463
+ * Run `capture.js move` for one session. `expectedConversation` is the
1464
+ * conversation fix diagnosed; the move is refused (`changed`) if the session
1465
+ * no longer points at it when the lease is taken.
1466
+ */
1467
+ function moveSession(sid, ctx, expectedConversation) {
1468
+ const run = runCaptureChild(
1469
+ "move",
1470
+ {
1471
+ session_id: sid,
1472
+ expected_conversation_id: expectedConversation || undefined,
1473
+ respect_stop: ctx.auto ? true : undefined,
1474
+ defer_if_live: true,
1475
+ },
1476
+ ctx.env,
1477
+ );
1478
+ const r = run.result || {};
1479
+ if (r.ok) return { ok: true, from: r.from, to: r.to };
1480
+ return {
1481
+ ok: false,
1482
+ busy: Boolean(r.busy),
1483
+ changed: Boolean(r.changed),
1484
+ paused: Boolean(r.paused),
1485
+ deferred: Boolean(r.deferred),
1486
+ error: r.error || run.stderr || "move failed",
1487
+ };
1488
+ }
1489
+
1490
+ function freshEntry(sid) {
1491
+ const read = cs.readState(sid);
1492
+ return read.ok
1493
+ ? { ok: true, session_id: sid, state: read.state }
1494
+ : { ok: false, session_id: sid, error: read.error };
1495
+ }
1496
+
1497
+ /**
1498
+ * Move to a new conversation, then drain. The move itself ends a park (it
1499
+ * resets the retry fields), so nothing is unparked first. A busy move, or
1500
+ * one refused because the session changed while waiting, leaves the
1501
+ * watermark untouched; a move that fails is parked with its diagnosed error.
1502
+ * `wasParked` is the session's parked status before fix touched it.
1503
+ */
1504
+ function moveThenDrain(e, opts, ctx, wasParkedBefore) {
1505
+ const sid = e.session_id;
1506
+ const wasParked = wasParkedBefore != null ? Boolean(wasParkedBefore) : isParked(e.state);
1507
+ const diagnosedError = lastErrorOf(e.state);
1508
+ const expected = e.state.conversation_id;
1509
+ let moved = moveSession(sid, ctx, expected);
1510
+ for (let waits = 0; !moved.ok && moved.busy && waits < MAX_LEASE_WAITS; waits++) {
1511
+ // A hook is draining this session; wait for it rather than give up.
1512
+ ctx.log(` ${sid.slice(0, 8)}: another worker holds the lease; waiting`);
1513
+ const waitMs = (cs.leaseTtlSeconds() + cs.checkpointTimeout()) * 1000;
1514
+ if (!waitForLease(sid, waitMs)) break;
1515
+ moved = moveSession(sid, ctx, expected);
1516
+ }
1517
+ if (!moved.ok && moved.busy) {
1518
+ return { result: { session_id: sid, outcome: "busy", reason: moved.error }, stop: false };
1519
+ }
1520
+ if (!moved.ok && moved.paused) {
1521
+ return { result: { session_id: sid, outcome: "paused_by_user" }, stop: false };
1522
+ }
1523
+ if (!moved.ok && moved.deferred) {
1524
+ // The session became live after fix planned the move; capture.js
1525
+ // refused it under the lease. A later fix moves it after it ends.
1526
+ return {
1527
+ result: { session_id: sid, outcome: "move_deferred", reason: "the session is still open; fix moves it to a new conversation after the session ends" },
1528
+ stop: false,
1529
+ };
1530
+ }
1531
+ if (!moved.ok && moved.changed) {
1532
+ // A hook recovered or delivered the session while fix waited for the
1533
+ // lease. Don't create another conversation; drain what is there now.
1534
+ ctx.log(` ${sid.slice(0, 8)}: ${moved.error}; draining instead of moving`);
1535
+ const drained = drainOneSession(freshEntry(sid), opts, ctx, { wasParked });
1536
+ drained.result.move_skipped = moved.error;
1537
+ return drained;
1538
+ }
1539
+ if (!moved.ok) {
1540
+ // Keep the diagnosed error, so the next fix still sees why the session
1541
+ // needs a move.
1542
+ const parked = mutateWithRetry(sid, (current) =>
1543
+ isParked(current)
1544
+ ? current
1545
+ : parkedState(
1546
+ { ...current, last_error: current.last_error || diagnosedError || null },
1547
+ `move failed: ${moved.error}`,
1548
+ new Date().toISOString(),
1549
+ ),
1550
+ );
1551
+ return {
1552
+ result: {
1553
+ session_id: sid,
1554
+ outcome: parked.ok ? "move_failed_and_parked" : "move_failed_not_parked",
1555
+ error: moved.error,
1556
+ },
1557
+ stop: serverLevelFailure(moved.error),
1558
+ };
1559
+ }
1560
+ ctx.log(` ${sid.slice(0, 8)}: moved to new conversation ${moved.to}`);
1561
+ const drained = drainOneSession(freshEntry(sid), opts, ctx, { wasParked });
1562
+ drained.result.moved_from = moved.from;
1563
+ drained.result.moved_to = moved.to;
1564
+ return drained;
1565
+ }
1566
+
1567
+ /**
1568
+ * The datapack an agent's pin file names, read the way capture.js
1569
+ * readDatapackPin() does (pin-<slug>.json in the capture directory), or null.
1570
+ */
1571
+ function pinnedDatapackId(agentId) {
1572
+ if (!agentId) return null;
1573
+ try {
1574
+ const pin = JSON.parse(
1575
+ fs.readFileSync(path.join(cs.watermarkDir(), `pin-${cs.agentSlug(agentId)}.json`), "utf8"),
1576
+ );
1577
+ const id = typeof pin.datapack_id === "string" ? pin.datapack_id.trim() : "";
1578
+ const name = typeof pin.datapack_name === "string" ? pin.datapack_name.trim() : "";
1579
+ return id && name ? id : null;
1580
+ } catch {
1581
+ return null;
1582
+ }
1583
+ }
1584
+
1585
+ /**
1586
+ * Record what a fix run did in autofix-last.json. The status bar and
1587
+ * `status` read it, and the checkpoint timer uses ran_at to schedule the
1588
+ * next automatic run.
1589
+ */
1590
+ function writeFixSummary(opts, results, stopped, error) {
1591
+ const counts = { fixed: 0, moved: 0, needs_you: 0, paused: 0, failed: 0, not_attempted: 0, partial: 0 };
1592
+ const needs = new Map();
1593
+ for (const r of results) {
1594
+ const o = String(r.outcome || "");
1595
+ if (o === "drained" || o === "repaired") counts.fixed++;
1596
+ if (r.moved_to) counts.moved++;
1597
+ if (o === "needs_you") {
1598
+ counts.needs_you++;
1599
+ const n = needs.get(r.cause) || { cause: r.cause, hint: r.hint, sessions: 0 };
1600
+ n.sessions++;
1601
+ needs.set(r.cause, n);
1602
+ }
1603
+ if (o === "paused_by_user") counts.paused++;
1604
+ if (o.includes("failed")) counts.failed++;
1605
+ if (o === "not_attempted" || o === "busy" || o === "move_deferred") counts.not_attempted++;
1606
+ if (o.startsWith("partly_drained")) counts.partial++;
1607
+ }
1608
+ try {
1609
+ cs.atomicWriteJson(cs.autofixLastPath(), {
1610
+ schema_version: 1,
1611
+ ran_at: new Date().toISOString(),
1612
+ mode: opts.auto ? "auto" : "manual",
1613
+ error: error || null,
1614
+ stopped_at: stopped || null,
1615
+ counts,
1616
+ needs_you: [...needs.values()],
1617
+ });
1618
+ } catch {
1619
+ /* the summary is informational; the run itself already happened */
1620
+ }
1621
+ }
1622
+
1623
+ function actionFix(opts, log) {
1624
+ const entries = scan();
1625
+ const { entries: selected, errors } = selectEntries(entries, opts, null);
1626
+ const plan = [];
1627
+ for (const e of selected) {
1628
+ const d = diagnose(e);
1629
+ if (d) plan.push({ entry: e, diagnosis: d });
1630
+ else if (opts.sessions.length > 0) {
1631
+ plan.push({ entry: e, diagnosis: null });
1632
+ }
1633
+ }
1634
+ const needsServer = plan.some(
1635
+ (p) => p.diagnosis && ["drain", "move", "drain_then_move", "retry_once"].includes(p.diagnosis.action),
1636
+ );
1637
+ const { env, source } = resolveCredentials();
1638
+ if (needsServer && !env.MEKO_MCP_URL && !opts.dryRun) {
1639
+ const message =
1640
+ "MEKO_MCP_URL is not set and ~/.claude/settings.json has no env.MEKO_MCP_URL; " +
1641
+ "export MEKO_MCP_URL and MEKO_API_KEY, then retry";
1642
+ writeFixSummary(opts, [], null, message);
1643
+ refreshHealthCache();
1644
+ return { action: "fix", errors: [message], exit: 1 };
1645
+ }
1646
+ const ctx = { env, log, nowMs: Date.now(), budget: opts.maxExchanges, auto: opts.auto };
1647
+ const results = [];
1648
+ let stopped = null;
1649
+ // Causes only the user can fix, found earlier in this run. A rejected key,
1650
+ // a plan limit or an account-level 403 affects every session; a datapack
1651
+ // problem affects the sessions in that datapack. Trying the rest would
1652
+ // only repeat the same failure.
1653
+ const blockedBy = new Map();
1654
+ // A session with no datapack in its watermark drains to its agent's pin
1655
+ // (capture.js re-reads it), so key it by that pinned datapack. Only an
1656
+ // agent with no pin falls back to a per-agent key.
1657
+ const datapackKey = (state) => {
1658
+ const id = (state && state.datapack_id) || pinnedDatapackId(state && state.agent_id);
1659
+ return id ? `datapack:${id}` : `datapack-of-agent:${(state && state.agent_id) || ""}`;
1660
+ };
1661
+ const blockKeys = (state, cause) => (cause === "datapack" ? [datapackKey(state)] : ["account"]);
1662
+ const blockedFor = (state) => blockedBy.get("account") || blockedBy.get(datapackKey(state)) || null;
1663
+
1664
+ for (const { entry: e, diagnosis: d } of plan) {
1665
+ const sid = e.session_id;
1666
+ if (!d) {
1667
+ results.push({ session_id: sid, outcome: "nothing_to_fix" });
1668
+ continue;
1669
+ }
1670
+ const base = { session_id: sid, cause: d.cause, action: d.action, hint: d.hint };
1671
+ if (
1672
+ opts.auto &&
1673
+ e.ok &&
1674
+ isParked(e.state) &&
1675
+ e.state.parked.reason === "stop"
1676
+ ) {
1677
+ // The user paused this session with `stop`; an automatic run leaves
1678
+ // it alone until they run `fix` or `resume`.
1679
+ results.push({ ...base, outcome: "paused_by_user" });
1680
+ continue;
1681
+ }
1682
+ if (opts.dryRun) {
1683
+ results.push({ ...base, outcome: "would_fix" });
1684
+ continue;
1685
+ }
1686
+ if (d.action === "none") {
1687
+ results.push({ ...base, outcome: "needs_you" });
1688
+ continue;
1689
+ }
1690
+ if (d.action === "repair") {
1691
+ const res = mutate(sid, (state) => (hasStaleFailure(state) ? clearedFailureState(state) : state));
1692
+ results.push({ ...base, outcome: res.ok ? "repaired" : res.busy ? "busy" : "failed", reason: res.ok ? undefined : res.error });
1693
+ continue;
1694
+ }
1695
+ if (stopped) {
1696
+ results.push({ ...base, outcome: "not_attempted", reason: `stopped at ${stopped.slice(0, 8)}: the server is failing` });
1697
+ continue;
1698
+ }
1699
+ if (ctx.budget <= 0) {
1700
+ results.push({ ...base, outcome: "not_attempted", reason: "--max-exchanges reached" });
1701
+ continue;
1702
+ }
1703
+ const blocked = e.ok && blockedFor(e.state);
1704
+ if (blocked) {
1705
+ // Park it with the shared cause, without another request, so its
1706
+ // hooks stop sending the same rejected key or limit too. The next fix
1707
+ // retries one session per cause.
1708
+ const parked = mutateWithRetry(sid, (current) => {
1709
+ if (opts.auto && stoppedByUser(current)) return current;
1710
+ return isParked(current)
1711
+ ? current
1712
+ : parkedState(
1713
+ { ...current, last_error: blocked.error || current.last_error || null },
1714
+ `${blocked.cause}: same cause as another session`,
1715
+ new Date().toISOString(),
1716
+ );
1717
+ });
1718
+ results.push({
1719
+ ...base,
1720
+ cause: blocked.cause,
1721
+ hint: blocked.hint,
1722
+ outcome: parked.ok ? "needs_you" : "needs_you_not_parked",
1723
+ reason: "not tried: another session hit the same cause in this run",
1724
+ });
1725
+ continue;
1726
+ }
1727
+
1728
+ let run;
1729
+ const wasParkedBefore = isParked(e.state);
1730
+ // SessionStart gave a live session's agent its conversation_id, and a
1731
+ // move can't tell the agent the new one, so its tool calls would stay on
1732
+ // the old conversation while captured turns go to the new one. Wait until
1733
+ // the session ends; a later fix moves it.
1734
+ const deferMove = looksLive(e.state, transcriptInfo(e.state), ctx.nowMs);
1735
+ if (d.action === "move" && deferMove) {
1736
+ results.push({ ...base, outcome: "move_deferred", reason: "the session is still open; fix moves it to a new conversation after the session ends" });
1737
+ continue;
1738
+ }
1739
+ if (d.action === "move") {
1740
+ run = moveThenDrain(e, opts, ctx, wasParkedBefore);
1741
+ } else {
1742
+ const diagnosedConversation = e.state.conversation_id;
1743
+ run = drainOneSession(e, opts, ctx);
1744
+ const err = run.result.error;
1745
+ if (
1746
+ d.action === "drain_then_move" &&
1747
+ !["drained", "skipped"].includes(run.result.outcome) &&
1748
+ !serverLevelFailure(err) &&
1749
+ conversationUnusable(err)
1750
+ ) {
1751
+ // The same conversation error came back. Move only if it is still
1752
+ // about the conversation diagnosed above and no move is waiting for
1753
+ // its new conversation to accept a turn; capture.js may already have
1754
+ // moved the session during this drain.
1755
+ const now = freshEntry(sid);
1756
+ const sameConversation = now.ok && now.state.conversation_id === diagnosedConversation;
1757
+ const guarded = now.ok && Boolean(now.state.recovered_from_conversation_id);
1758
+ if (sameConversation && !guarded && deferMove) {
1759
+ log(` ${sid.slice(0, 8)}: ${String(err).slice(0, 80)}; the session is still open, so the move waits until it ends`);
1760
+ run = { ...run, result: { ...run.result, move_deferred: true } };
1761
+ } else if (sameConversation && !guarded) {
1762
+ log(` ${sid.slice(0, 8)}: ${String(err).slice(0, 80)}; moving to a new conversation`);
1763
+ // The failed drain just re-parked the session; keep its status
1764
+ // from before fix ran.
1765
+ run = moveThenDrain(now, opts, ctx, wasParkedBefore);
1766
+ }
1767
+ }
1768
+ }
1769
+ const userCause = run.result.outcome !== "drained" && userActionableCause(run.result.error);
1770
+ if (userCause) {
1771
+ // The drain found a cause only the user can fix (it may differ from the
1772
+ // stored error). When the session is parked the hooks won't retry it,
1773
+ // and the next fix tries it once more. When the park didn't land, keep
1774
+ // the outcome that says so.
1775
+ const parkedNow = /_and_parked$/.test(String(run.result.outcome));
1776
+ for (const key of blockKeys(e.state, userCause.cause)) {
1777
+ if (!blockedBy.has(key)) blockedBy.set(key, { ...userCause, error: run.result.error });
1778
+ }
1779
+ results.push({
1780
+ ...base,
1781
+ ...run.result,
1782
+ ...userCause,
1783
+ outcome: parkedNow ? "needs_you" : run.result.outcome,
1784
+ });
1785
+ continue;
1786
+ }
1787
+ results.push({ ...base, ...run.result });
1788
+ if (run.stop && serverLevelFailure(run.result.error)) stopped = sid;
1789
+ if (ctx.budget > 0) sleepMs(opts.paceMs);
1790
+ }
1791
+
1792
+ if (!opts.dryRun) {
1793
+ writeFixSummary(opts, results, stopped);
1794
+ refreshHealthCache();
1795
+ }
1796
+ const done = ["repaired", "drained", "nothing_to_fix", "would_fix", "paused_by_user"];
1797
+ const partial = results.some((r) => !done.includes(r.outcome));
1798
+ return {
1799
+ action: "fix",
1800
+ dry_run: opts.dryRun,
1801
+ credentials_from: needsServer ? source : undefined,
1802
+ mcp_url: needsServer ? env.MEKO_MCP_URL : undefined,
1803
+ stopped_at: stopped,
1804
+ sessions: results,
1805
+ errors,
1806
+ exit: errors.length ? 1 : partial ? 2 : 0,
1807
+ };
1808
+ }
1809
+
1169
1810
  function readPidFile(filePath) {
1170
1811
  try {
1171
1812
  const raw = fs.readFileSync(filePath, "utf8").trim();
@@ -1307,6 +1948,18 @@ function formatBytes(n) {
1307
1948
  function formatStatus(report) {
1308
1949
  const lines = [];
1309
1950
  lines.push(`Capture state: ${report.watermark_dir} (${report.total_sessions} sessions)`);
1951
+ const lastFix = describeLastFix(report.last_fix);
1952
+ const auto = report.autofix || {};
1953
+ lines.push(
1954
+ `Automatic repair: ${
1955
+ auto.enabled
1956
+ ? `on, every ${Math.max(1, Math.round((auto.interval_seconds || 0) / 60))} minutes`
1957
+ : auto.source === "env"
1958
+ ? "off (MEKO_AUTOFIX in the environment)"
1959
+ : `off (${auto.path || "autofix-config.json"}; reinstall with --autofix to turn it on)`
1960
+ }` +
1961
+ (lastFix ? `; last ${lastFix}` : ""),
1962
+ );
1310
1963
  if (report.sessions.length === 0) {
1311
1964
  lines.push("No session has unsent work, a stale failure, or a park.");
1312
1965
  } else {
@@ -1372,6 +2025,8 @@ function formatActionResult(report) {
1372
2025
  if (report.mcp_url) lines.push(` server: ${report.mcp_url} (credentials from ${report.credentials_from})`);
1373
2026
  for (const s of report.sessions || []) {
1374
2027
  const extra = [
2028
+ s.cause ? `cause=${s.cause}` : null,
2029
+ s.moved_to ? `new conversation ${short(s.moved_to)}` : null,
1375
2030
  s.queued_exchanges != null ? `queued=${s.queued_exchanges}` : null,
1376
2031
  s.captured != null ? `sent=${s.captured}` : null,
1377
2032
  s.queued_remaining != null && s.outcome !== "would_drain" ? `left=${s.queued_remaining}` : null,
@@ -1379,6 +2034,7 @@ function formatActionResult(report) {
1379
2034
  s.reason || s.error || null,
1380
2035
  ].filter(Boolean);
1381
2036
  lines.push(` ${short(s.session_id)} ${s.outcome}${extra.length ? ` ${extra.join(" ")}` : ""}`);
2037
+ if (s.hint && ["needs_you", "would_fix"].includes(s.outcome)) lines.push(` ${s.hint}`);
1382
2038
  if (s.stderr) lines.push(` ${s.stderr.split("\n").join("\n ")}`);
1383
2039
  }
1384
2040
  for (const f of report.files || []) {
@@ -1434,6 +2090,9 @@ function run(argv, io) {
1434
2090
  case "repair":
1435
2091
  report = actionRepair(opts);
1436
2092
  break;
2093
+ case "fix":
2094
+ report = actionFix(opts, log);
2095
+ break;
1437
2096
  default:
1438
2097
  throw new Error(`unknown action ${opts.action}`);
1439
2098
  }
@@ -1457,6 +2116,8 @@ function run(argv, io) {
1457
2116
  module.exports = {
1458
2117
  parseArgs,
1459
2118
  run,
2119
+ drainOneSession,
2120
+ diagnose,
1460
2121
  hasPendingWork,
1461
2122
  hasStaleFailure,
1462
2123
  parkedState,