@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
@@ -0,0 +1,2134 @@
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
+ */
16
+ /**
17
+ * capture-cleanup-cli.js: inspect and clean up stuck conversation capture.
18
+ *
19
+ * Usage:
20
+ * node capture-cleanup-cli.js <action> [--session <id>]... [--all]
21
+ * [--dry-run] [--json] [--yes]
22
+ * [--max-exchanges N] [--pace-ms N]
23
+ *
24
+ * Actions:
25
+ * status Read-only. Sessions with unsent work, parked sessions, and the
26
+ * background capture processes that are running.
27
+ * stop Stop the background capture work that can retry (SessionEnd
28
+ * workers, in-flight drains, timers of sessions that are no
29
+ * longer active), then park every session with unsent work so no
30
+ * hook retries it. Timers of open sessions keep running: they send
31
+ * nothing for a parked session, and drain uses them to tell an
32
+ * open session from a dead one. Nothing is deleted.
33
+ * resume Unpark sessions and hand them back to automatic capture.
34
+ * drain Send parked or pending sessions now, one session at a time, and
35
+ * stop at the first failure. A session that fails is parked again.
36
+ * discard Give up on the unsent exchanges of the named sessions: move the
37
+ * cursor to the end of the transcript and mark them complete.
38
+ * Requires --yes. The exchanges are never sent.
39
+ * repair Clear failure fields left on fully delivered sessions, and
40
+ * remove timer PID files, leases, and SessionEnd job files whose
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.
48
+ *
49
+ * Parking works with the capture hooks already installed (2.2.0 and later):
50
+ * a drain skips the network while `failure_class` is set and
51
+ * `next_retry_at` is in the future, and recovery never selects such a
52
+ * session unless its failure class is `persistent`. A parked session keeps
53
+ * its cursor, so `resume` or `drain` loses nothing.
54
+ *
55
+ * Exit codes: 0 done, 1 usage or I/O error, 2 partly done (a session was
56
+ * busy, or drain stopped at a failure).
57
+ *
58
+ * Honors MEKO_WATERMARK_DIR. Invoked by installer --capture-cleanup.
59
+ */
60
+
61
+ "use strict";
62
+
63
+ const { spawnSync } = require("child_process");
64
+ const fs = require("fs");
65
+ const os = require("os");
66
+ const path = require("path");
67
+
68
+ const cs = require("./capture-state");
69
+ const { PARKED_REASON_PREFIX } = require("./capture-notices");
70
+
71
+ const ACTIONS = ["status", "fix", "stop", "resume", "drain", "discard", "repair"];
72
+ const PARK_UNTIL = cs.CLEANUP_PARK_UNTIL;
73
+ const PARKED_BY = "meko-mcp --capture-cleanup";
74
+ const RESUME_HINT =
75
+ `${PARKED_REASON_PREFIX}; run \`npx @yugabytedb/meko-mcp@latest ` +
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;
80
+ const PENDING_DELIVERIES = [
81
+ "needs_conversation",
82
+ "pending",
83
+ "draining",
84
+ "retry_wait",
85
+ "blocked",
86
+ ];
87
+ // capture.js modes that run in the background. Foreground hook modes
88
+ // (session-start, pre-compact) belong to a live client and are left alone.
89
+ const BACKGROUND_CAPTURE_MODES = ["drain", "recover", "checkpoint", "session-end", "move"];
90
+ const DEFAULT_MAX_EXCHANGES = 200;
91
+ const DEFAULT_PACE_MS = 1000;
92
+ const KILL_WAIT_MS = 3000;
93
+ // A lease file that doesn't parse may be mid-write (acquireLease creates it
94
+ // with `wx`, then writes); only an old one is debris.
95
+ const UNPARSABLE_LEASE_MIN_AGE_MS = 60_000;
96
+ const REPARK_ATTEMPTS = 10;
97
+ const REPARK_RETRY_MS = 1000;
98
+ const LEASE_POLL_MS = 500;
99
+ const MAX_LEASE_WAITS = 5;
100
+ const MAX_MOVES_PER_DRAIN = 1;
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Args
104
+ // ---------------------------------------------------------------------------
105
+
106
+ function usage() {
107
+ return (
108
+ "Usage: capture-cleanup-cli.js <status|fix|stop|resume|drain|discard|repair>\n" +
109
+ " [--session <id>]... [--all] [--dry-run] [--json] [--yes]\n" +
110
+ " [--max-exchanges N] [--pace-ms N] [--auto]\n"
111
+ );
112
+ }
113
+
114
+ function parseArgs(argv) {
115
+ const out = {
116
+ action: null,
117
+ sessions: [],
118
+ all: false,
119
+ dryRun: false,
120
+ json: false,
121
+ yes: false,
122
+ auto: false,
123
+ maxExchanges: DEFAULT_MAX_EXCHANGES,
124
+ paceMs: DEFAULT_PACE_MS,
125
+ };
126
+ const intArg = (name, raw) => {
127
+ const n = Number(raw);
128
+ if (!Number.isInteger(n) || n < 0) {
129
+ throw new Error(`${name} must be a non-negative integer, got "${raw}"`);
130
+ }
131
+ return n;
132
+ };
133
+ for (let i = 0; i < argv.length; i++) {
134
+ const a = argv[i];
135
+ const next = () => {
136
+ if (i + 1 >= argv.length) throw new Error(`${a} requires a value`);
137
+ return argv[++i];
138
+ };
139
+ if (a === "--help" || a === "-h") out.help = true;
140
+ else if (a === "--session") out.sessions.push(next());
141
+ else if (a.startsWith("--session=")) out.sessions.push(a.slice(10));
142
+ else if (a === "--all") out.all = true;
143
+ else if (a === "--dry-run") out.dryRun = true;
144
+ else if (a === "--json") out.json = true;
145
+ else if (a === "--yes") out.yes = true;
146
+ else if (a === "--auto") out.auto = true;
147
+ else if (a === "--max-exchanges") out.maxExchanges = intArg(a, next());
148
+ else if (a === "--pace-ms") out.paceMs = intArg(a, next());
149
+ else if (!a.startsWith("-") && !out.action) out.action = a;
150
+ else throw new Error(`unknown argument: ${a}`);
151
+ }
152
+ if (!out.help) {
153
+ if (!out.action) out.action = "status";
154
+ if (!ACTIONS.includes(out.action)) {
155
+ throw new Error(
156
+ `unknown action "${out.action}"; expected one of ${ACTIONS.join(", ")}`,
157
+ );
158
+ }
159
+ }
160
+ return out;
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Session helpers
165
+ // ---------------------------------------------------------------------------
166
+
167
+ function isPidAlive(pid) {
168
+ if (!Number.isInteger(pid) || pid <= 0) return false;
169
+ try {
170
+ process.kill(pid, 0);
171
+ return true;
172
+ } catch (err) {
173
+ // EPERM means the process exists but belongs to someone else.
174
+ return Boolean(err && err.code === "EPERM");
175
+ }
176
+ }
177
+
178
+ function hasPendingWork(state) {
179
+ if (!state || state.delivery === "complete") return false;
180
+ return (
181
+ (Number(state.queued_exchanges) || 0) > 0 ||
182
+ PENDING_DELIVERIES.includes(state.delivery)
183
+ );
184
+ }
185
+
186
+ /**
187
+ * True only while the park is still in effect. Hook writes merge the file on
188
+ * disk under their candidate, so a `parked` object survives a rebucket or a
189
+ * successful drain that has already reset the retry fields.
190
+ */
191
+ function isParked(state) {
192
+ return Boolean(
193
+ state &&
194
+ state.parked &&
195
+ typeof state.parked === "object" &&
196
+ state.failure_class &&
197
+ state.next_retry_at === PARK_UNTIL,
198
+ );
199
+ }
200
+
201
+ /** Failure fields left behind on a session that has nothing left to send. */
202
+ function hasStaleFailure(state) {
203
+ if (!state || isParked(state)) return false;
204
+ if (!["complete", "idle"].includes(state.delivery)) return false;
205
+ if ((Number(state.queued_exchanges) || 0) > 0) return false;
206
+ return Boolean(
207
+ state.failure_class || state.next_retry_at || state.last_error,
208
+ );
209
+ }
210
+
211
+ function transcriptInfo(state) {
212
+ const p = state && state.transcript_path;
213
+ if (!p) return { exists: false, bytes: null, mtime_ms: null };
214
+ try {
215
+ const st = fs.statSync(p);
216
+ return { exists: true, bytes: st.size, mtime_ms: st.mtimeMs };
217
+ } catch {
218
+ return { exists: false, bytes: null, mtime_ms: null };
219
+ }
220
+ }
221
+
222
+ function timerAlive(sessionId) {
223
+ try {
224
+ const pid = Number.parseInt(
225
+ fs.readFileSync(cs.timerPidPath(sessionId), "utf8"),
226
+ 10,
227
+ );
228
+ return isPidAlive(pid);
229
+ } catch {
230
+ return false;
231
+ }
232
+ }
233
+
234
+ /** The transcript changed within the recovery idle window. */
235
+ function recentlyWritten(info, nowMs) {
236
+ if (!info.exists || info.mtime_ms == null) return false;
237
+ return nowMs - info.mtime_ms < cs.recoveryMinIdle() * 1000;
238
+ }
239
+
240
+ /**
241
+ * An active session whose client may still be writing turns. The timer only
242
+ * treats an active session as abandoned when it is idle AND no checkpoint
243
+ * timer is alive (buildEligibility in capture-state.js); a session counts as
244
+ * live here if either says so.
245
+ */
246
+ function looksLive(state, info, nowMs) {
247
+ if (!state || state.lifecycle !== "active") return false;
248
+ return recentlyWritten(info, nowMs) || timerAlive(state.session_id);
249
+ }
250
+
251
+ function countLines(filePath) {
252
+ const buf = fs.readFileSync(filePath);
253
+ if (buf.length === 0) return 0;
254
+ let n = 0;
255
+ for (let i = 0; i < buf.length; i++) if (buf[i] === 10) n++;
256
+ if (buf[buf.length - 1] !== 10) n++;
257
+ return n;
258
+ }
259
+
260
+ function summarize(entry, nowMs) {
261
+ if (!entry.ok) {
262
+ return {
263
+ session_id: entry.session_id,
264
+ readable: false,
265
+ health: entry.health,
266
+ error: entry.error || null,
267
+ path: entry.path || null,
268
+ };
269
+ }
270
+ const s = entry.state;
271
+ const info = transcriptInfo(s);
272
+ return {
273
+ session_id: s.session_id,
274
+ readable: true,
275
+ health: entry.health,
276
+ client: s.client || null,
277
+ agent_id: s.agent_id || null,
278
+ conversation_id: s.conversation_id || null,
279
+ lifecycle: s.lifecycle,
280
+ delivery: s.delivery,
281
+ queued_exchanges: Number(s.queued_exchanges) || 0,
282
+ attempt_count: Number(s.attempt_count) || 0,
283
+ failure_class: s.failure_class || null,
284
+ next_retry_at: s.next_retry_at || null,
285
+ last_error: s.last_error || null,
286
+ last_success_at: s.last_success_at || null,
287
+ parked: isParked(s) ? s.parked : null,
288
+ pending_work: hasPendingWork(s),
289
+ stale_failure: hasStaleFailure(s),
290
+ transcript_path: s.transcript_path || null,
291
+ transcript_exists: info.exists,
292
+ transcript_bytes: info.bytes,
293
+ live: looksLive(s, info, nowMs),
294
+ };
295
+ }
296
+
297
+ function needsAttention(summary) {
298
+ if (!summary.readable) return true;
299
+ return (
300
+ summary.pending_work ||
301
+ Boolean(summary.parked) ||
302
+ summary.stale_failure ||
303
+ summary.health !== "healthy"
304
+ );
305
+ }
306
+
307
+ /**
308
+ * Resolve --session values (full IDs or unique prefixes) against the scan.
309
+ * Returns { entries, errors }.
310
+ */
311
+ function selectEntries(scanEntries, opts, defaultFilter) {
312
+ if (opts.sessions.length === 0) {
313
+ const filtered = opts.all || !defaultFilter
314
+ ? scanEntries
315
+ : scanEntries.filter(defaultFilter);
316
+ return { entries: filtered, errors: [] };
317
+ }
318
+ const entries = [];
319
+ const errors = [];
320
+ for (const wanted of opts.sessions) {
321
+ const exact = scanEntries.find((e) => e.session_id === wanted);
322
+ const matches = exact
323
+ ? [exact]
324
+ : scanEntries.filter((e) => String(e.session_id).startsWith(wanted));
325
+ if (matches.length === 1) {
326
+ if (!entries.includes(matches[0])) entries.push(matches[0]);
327
+ } else if (matches.length === 0) {
328
+ errors.push(`no watermark matches session "${wanted}"`);
329
+ } else {
330
+ errors.push(
331
+ `session "${wanted}" is ambiguous (${matches.length} matches); use more characters`,
332
+ );
333
+ }
334
+ }
335
+ return { entries, errors };
336
+ }
337
+
338
+ /**
339
+ * mutate(), retried while a hook drain holds the lease. Used to park a
340
+ * session again after a failed drain, when a hook may have picked it up in
341
+ * the moment it was unparked.
342
+ */
343
+ function mutateWithRetry(sessionId, mutator) {
344
+ let res;
345
+ for (let i = 0; i < REPARK_ATTEMPTS; i++) {
346
+ res = mutate(sessionId, mutator);
347
+ if (res.ok || !res.busy) return res;
348
+ sleepMs(REPARK_RETRY_MS);
349
+ }
350
+ return res;
351
+ }
352
+
353
+ /**
354
+ * Run a state mutation while holding the session's drain lease, so no hook
355
+ * drain can start or commit in between. Returns { ok, busy?, error?, state? }.
356
+ */
357
+ function withLease(sessionId, fn) {
358
+ const lease = cs.acquireLease(sessionId);
359
+ if (!lease.ok) {
360
+ return {
361
+ ok: false,
362
+ busy: lease.failure_class === "ownership",
363
+ error: lease.error,
364
+ };
365
+ }
366
+ try {
367
+ return fn();
368
+ } finally {
369
+ cs.releaseLease(sessionId, lease.lease.token);
370
+ }
371
+ }
372
+
373
+ function mutate(sessionId, mutator) {
374
+ return withLease(sessionId, () => {
375
+ const res = cs.mutateState(sessionId, (current) => {
376
+ if (!current.ok) {
377
+ return {
378
+ ok: false,
379
+ failure_class: current.failure_class || "state_corrupt",
380
+ error: current.error || "watermark unreadable",
381
+ };
382
+ }
383
+ return mutator({ ...current.state });
384
+ });
385
+ return res.ok
386
+ ? { ok: true, state: res.state }
387
+ : { ok: false, error: res.error };
388
+ });
389
+ }
390
+
391
+ // ---------------------------------------------------------------------------
392
+ // State transitions
393
+ // ---------------------------------------------------------------------------
394
+
395
+ function parkedState(state, reason, nowIso) {
396
+ return {
397
+ ...state,
398
+ parked: {
399
+ at: nowIso,
400
+ by: PARKED_BY,
401
+ reason,
402
+ previous: {
403
+ delivery: state.delivery,
404
+ failure_class: state.failure_class || null,
405
+ next_retry_at: state.next_retry_at || null,
406
+ attempt_count: Number(state.attempt_count) || 0,
407
+ last_error: state.last_error || null,
408
+ },
409
+ },
410
+ // Not `persistent`: the timer force-retries persistent failures once at
411
+ // startup regardless of next_retry_at.
412
+ delivery: "blocked",
413
+ failure_class: "transient",
414
+ next_retry_at: PARK_UNTIL,
415
+ blocked_reason: RESUME_HINT,
416
+ };
417
+ }
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
+
426
+ /**
427
+ * Retry fields for a session handed back to capture.js. A failure that was
428
+ * `persistent` with scope `exchange` is what lets capture.js skip one
429
+ * exchange the edge keeps rejecting (HTML 403), once its backoff is due.
430
+ * Clearing it would resend that exchange and hold the queue again, so keep
431
+ * the class and make the retry due now.
432
+ */
433
+ function handBackRetryFields(previousFailureClass, state) {
434
+ if (previousFailureClass === "persistent" && state.last_failure_scope === "exchange") {
435
+ return {
436
+ failure_class: "persistent",
437
+ next_retry_at: new Date().toISOString(),
438
+ };
439
+ }
440
+ return { failure_class: null, next_retry_at: null };
441
+ }
442
+
443
+ function unparkedState(state) {
444
+ const queued = Number(state.queued_exchanges) || 0;
445
+ const previous = (state.parked && state.parked.previous) || {};
446
+ let delivery;
447
+ if (!state.conversation_id) delivery = "needs_conversation";
448
+ else if (state.lifecycle === "closed" && queued === 0) delivery = "complete";
449
+ else delivery = "pending";
450
+ return {
451
+ ...state,
452
+ // writeState merges the file on disk under the candidate, so a removed
453
+ // key would survive; null clears it.
454
+ parked: null,
455
+ delivery,
456
+ ...handBackRetryFields(previous.failure_class, state),
457
+ attempt_count: 0,
458
+ last_error: null,
459
+ blocked_reason: null,
460
+ };
461
+ }
462
+
463
+ function discardedState(state, lineCount) {
464
+ const queued = Number(state.queued_exchanges) || 0;
465
+ return {
466
+ ...state,
467
+ parked: null,
468
+ last_line_number: Math.max(Number(state.last_line_number) || 0, lineCount),
469
+ in_flight: { seed: null, user_line: null, user_turn_id: null },
470
+ lifecycle: "closed",
471
+ delivery: "complete",
472
+ queued_exchanges: 0,
473
+ failure_class: null,
474
+ next_retry_at: null,
475
+ attempt_count: 0,
476
+ last_error: null,
477
+ blocked_reason: null,
478
+ dropped_exchanges: (Number(state.dropped_exchanges) || 0) + queued,
479
+ last_drop_reason: `discarded by ${PARKED_BY}`,
480
+ };
481
+ }
482
+
483
+ function clearedFailureState(state) {
484
+ return {
485
+ ...state,
486
+ failure_class: null,
487
+ next_retry_at: null,
488
+ attempt_count: 0,
489
+ last_error: null,
490
+ blocked_reason: null,
491
+ };
492
+ }
493
+
494
+ /** clearedFailureState, keeping capture.js's one-exchange escape. */
495
+ function handBackState(state) {
496
+ return {
497
+ ...clearedFailureState(state),
498
+ ...handBackRetryFields(state.failure_class, state),
499
+ };
500
+ }
501
+
502
+ /**
503
+ * Sleep while holding the session's drain lease, so a hook timer's recovery
504
+ * cycle can't start a drain of it between two of our batches.
505
+ */
506
+ function sleepHoldingLease(sessionId, ms) {
507
+ if (ms <= 0) return;
508
+ const lease = cs.acquireLease(sessionId);
509
+ try {
510
+ sleepMs(ms);
511
+ } finally {
512
+ if (lease.ok) cs.releaseLease(sessionId, lease.lease.token);
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Wait until no live worker holds the session's drain lease. Returns false
518
+ * when it's still held after the wait.
519
+ */
520
+ function waitForLease(sessionId, maxMs) {
521
+ const deadline = Date.now() + maxMs;
522
+ while (Date.now() < deadline) {
523
+ const lease = cs.acquireLease(sessionId);
524
+ if (lease.ok) {
525
+ cs.releaseLease(sessionId, lease.lease.token);
526
+ return true;
527
+ }
528
+ sleepMs(LEASE_POLL_MS);
529
+ }
530
+ return false;
531
+ }
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
+
546
+ function heldByAnotherWorker(result) {
547
+ return (
548
+ result &&
549
+ result.status === "retry_wait" &&
550
+ result.failure_class === "ownership" &&
551
+ /lease held/.test(String(result.error || ""))
552
+ );
553
+ }
554
+
555
+ // ---------------------------------------------------------------------------
556
+ // Processes
557
+ // ---------------------------------------------------------------------------
558
+
559
+ const HOOK_SCRIPT_RE =
560
+ /hooks-handlers[\\/]lib[\\/](checkpoint-timer|terminal-handoff|capture|capture-cleanup-cli)\.js(?:\s+(\S+))?(?:\s+(\S+))?/;
561
+
562
+ /**
563
+ * List background capture processes from `ps`. Returns { ok, processes,
564
+ * error }. Unsupported platforms (no `ps`) return ok:false.
565
+ */
566
+ function listCaptureProcesses() {
567
+ const res = spawnSync("ps", ["-axo", "pid=,ppid=,command="], {
568
+ encoding: "utf8",
569
+ maxBuffer: 16 * 1024 * 1024,
570
+ });
571
+ if (res.error || res.status !== 0) {
572
+ return {
573
+ ok: false,
574
+ processes: [],
575
+ error: res.error ? res.error.message : `ps exited ${res.status}`,
576
+ };
577
+ }
578
+ const processes = [];
579
+ const commands = new Map();
580
+ const rows = [];
581
+ for (const line of res.stdout.split("\n")) {
582
+ const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
583
+ if (m) rows.push(m);
584
+ if (m) commands.set(Number(m[1]), m[3]);
585
+ }
586
+ for (const m of rows) {
587
+ const pid = Number(m[1]);
588
+ const ppid = Number(m[2]);
589
+ const command = m[3];
590
+ if (pid === process.pid) continue;
591
+ const hit = HOOK_SCRIPT_RE.exec(command);
592
+ if (!hit) continue;
593
+ const script = hit[1];
594
+ let kind = null;
595
+ let sessionId = null;
596
+ let jobFile = null;
597
+ if (script === "checkpoint-timer") {
598
+ kind = "timer";
599
+ if (hit[2]) sessionId = path.basename(hit[2], ".jsonl");
600
+ } else if (script === "terminal-handoff" && hit[2] === "worker") {
601
+ kind = "session_end_worker";
602
+ jobFile = hit[3] || null;
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";
607
+ } else if (script === "capture" && BACKGROUND_CAPTURE_MODES.includes(hit[2])) {
608
+ kind = `capture_${hit[2].replace("-", "_")}`;
609
+ }
610
+ if (!kind) continue;
611
+ processes.push({
612
+ pid,
613
+ ppid,
614
+ kind,
615
+ session_id: sessionId,
616
+ job_file: jobFile,
617
+ command,
618
+ orphan: isInitLike(ppid, commands.get(ppid)),
619
+ });
620
+ }
621
+ return { ok: true, processes };
622
+ }
623
+
624
+ /**
625
+ * The parent a detached child is reparented to: PID 1, or a subreaper such
626
+ * as `systemd --user` or launchd.
627
+ */
628
+ function isInitLike(ppid, parentCommand) {
629
+ if (ppid === 1) return true;
630
+ return /(^|\/)(systemd|launchd|init)(\s|$)/.test(String(parentCommand || ""));
631
+ }
632
+
633
+ /**
634
+ * The processes `stop` may signal: SessionEnd workers whose job file lives
635
+ * under this watermark directory, timers of in-scope sessions that are no
636
+ * longer active, and the capture.js children of any in-scope timer or
637
+ * worker. Timers of active sessions (`keepTimerSessionIds`) keep running;
638
+ * only their in-flight children are stopped. Detached capture.js runs with
639
+ * no parent (Kiro's Stop-time drain) can't be tied to a directory, so they
640
+ * are included only for an unfiltered stop of the default directory.
641
+ */
642
+ function processesInScope(processes, sessionIds, opts, keepTimerSessionIds) {
643
+ const inScope = new Set(sessionIds);
644
+ const keep = new Set(keepTimerSessionIds || []);
645
+ const jobsDir = path.join(cs.watermarkDir(), "session-end-jobs") + path.sep;
646
+ const owners = processes.filter(
647
+ (p) =>
648
+ (p.kind === "timer" && inScope.has(p.session_id)) ||
649
+ (p.kind === "session_end_worker" &&
650
+ typeof p.job_file === "string" &&
651
+ p.job_file.startsWith(jobsDir) &&
652
+ (!p.session_id || inScope.has(p.session_id))),
653
+ );
654
+ const parents = new Set(owners.map((p) => p.pid));
655
+ const picked = owners.filter(
656
+ (p) => !(p.kind === "timer" && keep.has(p.session_id)),
657
+ );
658
+ const defaultDir =
659
+ path.resolve(cs.watermarkDir()) ===
660
+ path.resolve(os.homedir(), ".claude", "meko-capture");
661
+ const includeOrphans = defaultDir && opts.sessions.length === 0;
662
+ for (const p of processes) {
663
+ if (!p.kind.startsWith("capture_")) continue;
664
+ if (parents.has(p.ppid) || (includeOrphans && p.orphan)) picked.push(p);
665
+ }
666
+ return picked;
667
+ }
668
+
669
+ function sessionFromJobFile(jobFile) {
670
+ if (!jobFile) return null;
671
+ try {
672
+ const job = JSON.parse(fs.readFileSync(jobFile, "utf8"));
673
+ if (job.session_id) return String(job.session_id);
674
+ const t = job.transcript_path || (job.hookSpecificInput || {}).transcript_path;
675
+ return t ? path.basename(String(t), ".jsonl") : null;
676
+ } catch {
677
+ return null;
678
+ }
679
+ }
680
+
681
+ function sleepMs(ms) {
682
+ if (ms <= 0) return;
683
+ const cell = new Int32Array(new SharedArrayBuffer(4));
684
+ Atomics.wait(cell, 0, 0, ms);
685
+ }
686
+
687
+ /**
688
+ * SIGTERM each process (timers and workers clean up their own PID and job
689
+ * files on SIGTERM), wait, then SIGKILL survivors.
690
+ */
691
+ function killProcesses(processes) {
692
+ const results = [];
693
+ for (const p of processes) {
694
+ try {
695
+ process.kill(p.pid, "SIGTERM");
696
+ results.push({ ...p, signal: "SIGTERM", ok: true });
697
+ } catch (err) {
698
+ results.push({
699
+ ...p,
700
+ signal: "SIGTERM",
701
+ ok: Boolean(err && err.code === "ESRCH"),
702
+ error: err && err.code !== "ESRCH" ? err.message : null,
703
+ });
704
+ }
705
+ }
706
+ const deadline = Date.now() + KILL_WAIT_MS;
707
+ while (Date.now() < deadline && results.some((r) => isPidAlive(r.pid))) {
708
+ sleepMs(100);
709
+ }
710
+ for (const r of results) {
711
+ if (!isPidAlive(r.pid)) {
712
+ r.exited = true;
713
+ continue;
714
+ }
715
+ try {
716
+ process.kill(r.pid, "SIGKILL");
717
+ r.signal = "SIGKILL";
718
+ } catch (err) {
719
+ r.error = err.message;
720
+ }
721
+ sleepMs(100);
722
+ r.exited = !isPidAlive(r.pid);
723
+ }
724
+ return results;
725
+ }
726
+
727
+ // ---------------------------------------------------------------------------
728
+ // Drain credentials
729
+ // ---------------------------------------------------------------------------
730
+
731
+ /**
732
+ * MEKO_MCP_URL / MEKO_API_KEY from the environment, else from the `env`
733
+ * block the installer writes into ~/.claude/settings.json for Claude Code.
734
+ */
735
+ function resolveCredentials() {
736
+ const env = {
737
+ MEKO_MCP_URL: process.env.MEKO_MCP_URL || "",
738
+ MEKO_API_KEY: process.env.MEKO_API_KEY || "",
739
+ };
740
+ let source = env.MEKO_MCP_URL ? "environment" : null;
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) {
745
+ const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
746
+ try {
747
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
748
+ const block = (settings && settings.env) || {};
749
+ if (block.MEKO_MCP_URL) {
750
+ env.MEKO_MCP_URL = String(block.MEKO_MCP_URL);
751
+ if (!env.MEKO_API_KEY && block.MEKO_API_KEY) {
752
+ env.MEKO_API_KEY = String(block.MEKO_API_KEY);
753
+ }
754
+ source = settingsPath;
755
+ }
756
+ } catch {
757
+ /* no Claude Code settings; the caller reports the missing URL */
758
+ }
759
+ }
760
+ return { env, source };
761
+ }
762
+
763
+ /**
764
+ * A drain result that made progress or had nothing to do. `partial` without
765
+ * a failure class means the batch cap was reached with work still queued.
766
+ */
767
+ function drainSucceeded(result) {
768
+ if (!result) return false;
769
+ if (result.status === "ok" || result.status === "noop") return true;
770
+ if (movedToNewConversation(result)) return true;
771
+ return result.status === "partial" && !result.failure_class && !result.error;
772
+ }
773
+
774
+ function runCaptureChild(mode, input, env) {
775
+ const captureJs = path.join(__dirname, "capture.js");
776
+ const res = spawnSync(process.execPath, [captureJs, mode], {
777
+ input: JSON.stringify(input),
778
+ encoding: "utf8",
779
+ env: { ...process.env, ...env },
780
+ timeout: cs.checkpointTimeout() * 1000,
781
+ maxBuffer: 16 * 1024 * 1024,
782
+ });
783
+ const lines = String(res.stdout || "").trim().split("\n").filter(Boolean);
784
+ let result = null;
785
+ try {
786
+ result = JSON.parse(lines[lines.length - 1]);
787
+ } catch {
788
+ /* handled below */
789
+ }
790
+ if (!result) {
791
+ const why = res.error
792
+ ? res.error.message
793
+ : `capture.js ${mode} exited ${res.status} without a result`;
794
+ result = { status: "error", error: why, captured: 0, queued_remaining: null };
795
+ }
796
+ const stderrTail = String(res.stderr || "")
797
+ .trim()
798
+ .split("\n")
799
+ .slice(-5)
800
+ .join("\n");
801
+ return { result, stderr: stderrTail };
802
+ }
803
+
804
+ // ---------------------------------------------------------------------------
805
+ // Actions
806
+ // ---------------------------------------------------------------------------
807
+
808
+ function scan() {
809
+ const res = cs.scanStates({ includeBlocked: true });
810
+ if (!res.ok) throw new Error(res.error || "watermark scan failed");
811
+ return res.entries || [];
812
+ }
813
+
814
+ function refreshHealthCache() {
815
+ const res = cs.scanStates({ includeBlocked: true });
816
+ const health = cs.computeAggregateHealth(res.entries || []);
817
+ const existing = cs.readHealthCache();
818
+ if (existing && existing.last_notified_status !== undefined) {
819
+ health.last_notified_status = existing.last_notified_status;
820
+ }
821
+ cs.writeHealthCache(health, { clampDropped: cs.scanCompleteForDropClamp(res) });
822
+ return health;
823
+ }
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
+
839
+ function actionStatus(opts) {
840
+ const nowMs = Date.now();
841
+ const entries = scan();
842
+ const summaries = entries.map((e) => summarize(e, nowMs));
843
+ const { entries: selected, errors } = selectEntries(
844
+ summaries,
845
+ opts,
846
+ needsAttention,
847
+ );
848
+ const procs = listCaptureProcesses();
849
+ const parkedIds = new Set(
850
+ summaries.filter((s) => s.parked).map((s) => s.session_id),
851
+ );
852
+ for (const p of procs.processes) {
853
+ // A SessionEnd worker for a parked session makes no requests; it sleeps
854
+ // until its age ceiling (older hooks) or exits (newer ones). A timer is
855
+ // never labelled: it still runs recovery for other sessions.
856
+ if (p.kind === "session_end_worker" && p.session_id && parkedIds.has(p.session_id)) {
857
+ p.idle_parked = true;
858
+ }
859
+ }
860
+ return {
861
+ action: "status",
862
+ watermark_dir: cs.watermarkDir(),
863
+ total_sessions: summaries.length,
864
+ last_fix: cs.readAutofixLast(),
865
+ autofix: { ...cs.autofixSetting(), interval_seconds: cs.autofixIntervalSeconds() },
866
+ sessions: selected,
867
+ processes: procs.processes,
868
+ process_scan_error: procs.ok ? null : procs.error,
869
+ errors,
870
+ exit: errors.length ? 1 : 0,
871
+ };
872
+ }
873
+
874
+ function parkEntries(entries, reason, dryRun) {
875
+ const nowIso = new Date().toISOString();
876
+ const results = [];
877
+ for (const e of entries) {
878
+ const sid = e.session_id;
879
+ if (!e.ok) {
880
+ results.push({ session_id: sid, outcome: "skipped", reason: "watermark unreadable" });
881
+ continue;
882
+ }
883
+ if (isParked(e.state)) {
884
+ results.push({ session_id: sid, outcome: "already_parked" });
885
+ continue;
886
+ }
887
+ if (!hasPendingWork(e.state)) {
888
+ results.push({ session_id: sid, outcome: "skipped", reason: "nothing to send" });
889
+ continue;
890
+ }
891
+ if (dryRun) {
892
+ results.push({ session_id: sid, outcome: "would_park", queued_exchanges: Number(e.state.queued_exchanges) || 0 });
893
+ continue;
894
+ }
895
+ // A hook may be draining this session right now (a kept timer restarts
896
+ // recovery when its child is stopped); wait for it rather than skip.
897
+ const res = mutateWithRetry(sid, (state) =>
898
+ isParked(state) ? state : parkedState(state, reason, nowIso),
899
+ );
900
+ results.push(
901
+ res.ok
902
+ ? { session_id: sid, outcome: "parked", queued_exchanges: Number(res.state.queued_exchanges) || 0 }
903
+ : { session_id: sid, outcome: res.busy ? "busy" : "failed", reason: res.error },
904
+ );
905
+ }
906
+ return results;
907
+ }
908
+
909
+ function actionStop(opts) {
910
+ const entries = scan();
911
+ const { entries: selected, errors } = selectEntries(entries, opts, null);
912
+ const procs = listCaptureProcesses();
913
+ const activeIds = selected
914
+ .filter((e) => e.ok && e.state.lifecycle === "active")
915
+ .map((e) => e.session_id);
916
+ const targets = procs.ok
917
+ ? processesInScope(
918
+ procs.processes,
919
+ selected.map((e) => e.session_id),
920
+ opts,
921
+ activeIds,
922
+ )
923
+ : [];
924
+ // Stop processes before parking: a drain that is mid-flight would
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
+ }
955
+ let killed = [];
956
+ if (!opts.dryRun) {
957
+ killed = killProcesses(targets);
958
+ // A kept timer whose child was just killed starts a recovery cycle at
959
+ // once. Stop any capture.js child that appeared in the meantime.
960
+ const again = listCaptureProcesses();
961
+ if (again.ok) {
962
+ const seen = new Set(killed.map((k) => k.pid));
963
+ const late = processesInScope(
964
+ again.processes,
965
+ selected.map((e) => e.session_id),
966
+ opts,
967
+ activeIds,
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);
971
+ killed = killed.concat(killProcesses(late));
972
+ }
973
+ }
974
+ const parked = parkEntries(selected, "stop", opts.dryRun);
975
+ if (!opts.dryRun) refreshHealthCache();
976
+ const partial = parked.some((r) => r.outcome === "busy" || r.outcome === "failed") ||
977
+ killed.some((k) => !k.exited);
978
+ return {
979
+ action: "stop",
980
+ dry_run: opts.dryRun,
981
+ processes: opts.dryRun ? targets : killed,
982
+ process_scan_error: procs.ok ? null : procs.error,
983
+ sessions: parked,
984
+ errors,
985
+ exit: errors.length ? 1 : partial ? 2 : 0,
986
+ };
987
+ }
988
+
989
+ function actionResume(opts) {
990
+ const entries = scan();
991
+ const { entries: selected, errors } = selectEntries(
992
+ entries,
993
+ opts,
994
+ (e) => e.ok && isParked(e.state),
995
+ );
996
+ const results = [];
997
+ for (const e of selected) {
998
+ const sid = e.session_id;
999
+ if (!e.ok || !isParked(e.state)) {
1000
+ results.push({ session_id: sid, outcome: "skipped", reason: "not parked" });
1001
+ continue;
1002
+ }
1003
+ if (opts.dryRun) {
1004
+ results.push({ session_id: sid, outcome: "would_resume" });
1005
+ continue;
1006
+ }
1007
+ const res = mutate(sid, (state) => (isParked(state) ? unparkedState(state) : state));
1008
+ results.push(
1009
+ res.ok
1010
+ ? { session_id: sid, outcome: "resumed", delivery: res.state.delivery }
1011
+ : { session_id: sid, outcome: res.busy ? "busy" : "failed", reason: res.error },
1012
+ );
1013
+ }
1014
+ if (!opts.dryRun) refreshHealthCache();
1015
+ const partial = results.some((r) => r.outcome === "busy" || r.outcome === "failed");
1016
+ return {
1017
+ action: "resume",
1018
+ dry_run: opts.dryRun,
1019
+ sessions: results,
1020
+ errors,
1021
+ exit: errors.length ? 1 : partial ? 2 : 0,
1022
+ };
1023
+ }
1024
+
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 };
1038
+ }
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);
1045
+ let state = e.state;
1046
+ if (wasParked || state.failure_class) {
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
+ }
1058
+ if (!res.ok) {
1059
+ return { result: { session_id: sid, outcome: res.busy ? "busy" : "failed", reason: res.error }, stop: false };
1060
+ }
1061
+ state = res.state;
1062
+ }
1063
+
1064
+ const live = looksLive(state, transcriptInfo(state), ctx.nowMs);
1065
+ // Never rebucket from here: this process's cwd says nothing about the
1066
+ // session's owner. A session whose client is gone is finalized the way
1067
+ // timer recovery does it.
1068
+ const mode = live ? "drain" : "recover";
1069
+ let captured = 0;
1070
+ let last = null;
1071
+ let stderr = "";
1072
+ let leaseWaits = 0;
1073
+ let moves = 0;
1074
+ let stop = false;
1075
+ for (;;) {
1076
+ const run = runCaptureChild(
1077
+ mode,
1078
+ { session_id: sid, recorded_owner_only: true, max_exchanges: Math.min(ctx.budget, cs.maxCaptureBatchSize()) },
1079
+ ctx.env,
1080
+ );
1081
+ last = run.result;
1082
+ stderr = run.stderr;
1083
+ if (heldByAnotherWorker(last) && leaseWaits < MAX_LEASE_WAITS) {
1084
+ leaseWaits++;
1085
+ // A hook (usually a kept timer's recovery cycle) is draining this
1086
+ // session. That isn't a failure: wait for it, then continue.
1087
+ log(` ${sid.slice(0, 8)}: another worker holds the lease; waiting`);
1088
+ const waitMs = (cs.leaseTtlSeconds() + cs.checkpointTimeout()) * 1000;
1089
+ if (waitForLease(sid, waitMs)) continue;
1090
+ }
1091
+ captured += Number(last.captured) || 0;
1092
+ ctx.budget -= Number(last.captured) || 0;
1093
+ const remaining = Number(last.queued_remaining) || 0;
1094
+ if (!drainSucceeded(last)) 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;
1103
+ log(` ${sid.slice(0, 8)}: sent ${captured}, ${remaining} left`);
1104
+ sleepHoldingLease(sid, opts.paceMs);
1105
+ }
1106
+
1107
+ const ok = drainSucceeded(last);
1108
+ const remaining = Number(last.queued_remaining) || 0;
1109
+ const repark = (reason) =>
1110
+ mutateWithRetry(sid, (current) =>
1111
+ isParked(current) ? current : parkedState(current, reason, new Date().toISOString()),
1112
+ );
1113
+ let outcome;
1114
+ let reparkError = null;
1115
+ if (!ok) {
1116
+ // Park it again so the hooks don't retry what just failed. A hook may
1117
+ // hold the lease right now (that is often why the drain failed), so
1118
+ // retry, and say so when the park doesn't land.
1119
+ const res = repark(`drain failed: ${last.error || last.status}`);
1120
+ stop = true;
1121
+ outcome = res.ok ? "failed_and_parked" : "failed_not_parked";
1122
+ if (!res.ok) reparkError = res.error;
1123
+ } else if (last.status === "noop" && last.error && captured === 0) {
1124
+ // For example "recorded owner missing": nothing was sent. A session
1125
+ // that was parked stays parked.
1126
+ if (wasParked) {
1127
+ const res = repark(`drain skipped: ${last.error}`);
1128
+ outcome = res.ok ? "skipped_and_parked" : "skipped_not_parked";
1129
+ if (!res.ok) reparkError = res.error;
1130
+ } else {
1131
+ outcome = "skipped";
1132
+ }
1133
+ } else if (remaining > 0) {
1134
+ // Out of --max-exchanges. A session that was parked stays parked.
1135
+ if (wasParked) {
1136
+ const res = repark("drain stopped at --max-exchanges");
1137
+ outcome = res.ok ? "partly_drained_and_parked" : "partly_drained_not_parked";
1138
+ if (!res.ok) reparkError = res.error;
1139
+ } else {
1140
+ outcome = "partly_drained";
1141
+ }
1142
+ } else {
1143
+ outcome = "drained";
1144
+ }
1145
+ return {
1146
+ stop,
1147
+ result: {
1148
+ session_id: sid,
1149
+ outcome,
1150
+ captured,
1151
+ queued_remaining: last.queued_remaining,
1152
+ status: last.status,
1153
+ error: last.error || null,
1154
+ repark_error: reparkError || undefined,
1155
+ stderr: ok ? undefined : stderr || undefined,
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;
1216
+ if (!["drained", "skipped", "skipped_and_parked"].includes(outcome)) continue;
1217
+ if (ctx.budget > 0) sleepMs(opts.paceMs);
1218
+ }
1219
+ if (!opts.dryRun) refreshHealthCache();
1220
+ const partial =
1221
+ Boolean(stopped) ||
1222
+ results.some((r) =>
1223
+ [
1224
+ "busy",
1225
+ "failed",
1226
+ "not_attempted",
1227
+ "skipped",
1228
+ "partly_drained",
1229
+ "partly_drained_and_parked",
1230
+ "partly_drained_not_parked",
1231
+ "failed_not_parked",
1232
+ "skipped_and_parked",
1233
+ "skipped_not_parked",
1234
+ ].includes(r.outcome),
1235
+ );
1236
+ return {
1237
+ action: "drain",
1238
+ dry_run: opts.dryRun,
1239
+ credentials_from: source,
1240
+ mcp_url: env.MEKO_MCP_URL,
1241
+ stopped_at: stopped,
1242
+ sessions: results,
1243
+ errors,
1244
+ exit: errors.length ? 1 : partial ? 2 : 0,
1245
+ };
1246
+ }
1247
+
1248
+ function actionDiscard(opts) {
1249
+ if (opts.sessions.length === 0 && !opts.all) {
1250
+ return { action: "discard", errors: ["discard needs --session <id> or --all"], exit: 1 };
1251
+ }
1252
+ if (!opts.yes && !opts.dryRun) {
1253
+ return {
1254
+ action: "discard",
1255
+ errors: ["discard never sends the skipped exchanges; rerun with --yes to confirm, or --dry-run to preview"],
1256
+ exit: 1,
1257
+ };
1258
+ }
1259
+ const nowMs = Date.now();
1260
+ const entries = scan();
1261
+ const { entries: selected, errors } = selectEntries(entries, opts, null);
1262
+ const results = [];
1263
+ for (const e of selected) {
1264
+ const sid = e.session_id;
1265
+ if (!e.ok) {
1266
+ results.push({ session_id: sid, outcome: "skipped", reason: "watermark unreadable" });
1267
+ continue;
1268
+ }
1269
+ if (!hasPendingWork(e.state) && !isParked(e.state)) {
1270
+ results.push({ session_id: sid, outcome: "skipped", reason: "nothing to send" });
1271
+ continue;
1272
+ }
1273
+ const info = transcriptInfo(e.state);
1274
+ if (recentlyWritten(info, nowMs) || timerAlive(sid)) {
1275
+ results.push({
1276
+ session_id: sid,
1277
+ outcome: "skipped",
1278
+ reason: "transcript changed recently or its timer is running; the session may still be open",
1279
+ });
1280
+ continue;
1281
+ }
1282
+ const queued = Number(e.state.queued_exchanges) || 0;
1283
+ if (opts.dryRun) {
1284
+ results.push({ session_id: sid, outcome: "would_discard", queued_exchanges: queued });
1285
+ continue;
1286
+ }
1287
+ let lineCount = 0;
1288
+ if (info.exists) {
1289
+ try {
1290
+ lineCount = countLines(e.state.transcript_path);
1291
+ } catch (err) {
1292
+ results.push({ session_id: sid, outcome: "failed", reason: `read transcript: ${err.message}` });
1293
+ continue;
1294
+ }
1295
+ }
1296
+ const res = mutate(sid, (state) => discardedState(state, lineCount));
1297
+ results.push(
1298
+ res.ok
1299
+ ? { session_id: sid, outcome: "discarded", queued_exchanges: queued }
1300
+ : { session_id: sid, outcome: res.busy ? "busy" : "failed", reason: res.error },
1301
+ );
1302
+ }
1303
+ if (!opts.dryRun) refreshHealthCache();
1304
+ const partial = results.some((r) => r.outcome === "busy" || r.outcome === "failed");
1305
+ return {
1306
+ action: "discard",
1307
+ dry_run: opts.dryRun,
1308
+ sessions: results,
1309
+ errors,
1310
+ exit: errors.length ? 1 : partial ? 2 : 0,
1311
+ };
1312
+ }
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
+
1810
+ function readPidFile(filePath) {
1811
+ try {
1812
+ const raw = fs.readFileSync(filePath, "utf8").trim();
1813
+ if (/^\d+$/.test(raw)) return Number(raw);
1814
+ const obj = JSON.parse(raw);
1815
+ return Number(obj && obj.pid);
1816
+ } catch {
1817
+ return NaN;
1818
+ }
1819
+ }
1820
+
1821
+ function actionRepair(opts) {
1822
+ const dir = cs.watermarkDir();
1823
+ const entries = scan();
1824
+ const { entries: selected, errors } = selectEntries(entries, opts, null);
1825
+ const sessions = [];
1826
+ for (const e of selected) {
1827
+ if (!e.ok || !hasStaleFailure(e.state)) continue;
1828
+ if (opts.dryRun) {
1829
+ sessions.push({ session_id: e.session_id, outcome: "would_clear_failure", failure_class: e.state.failure_class });
1830
+ continue;
1831
+ }
1832
+ const res = mutate(e.session_id, (state) =>
1833
+ hasStaleFailure(state) ? clearedFailureState(state) : state,
1834
+ );
1835
+ sessions.push(
1836
+ res.ok
1837
+ ? { session_id: e.session_id, outcome: "cleared_failure" }
1838
+ : { session_id: e.session_id, outcome: res.busy ? "busy" : "failed", reason: res.error },
1839
+ );
1840
+ }
1841
+
1842
+ // Leftover files whose owning process is gone. Only when no --session
1843
+ // filter narrows the run.
1844
+ const files = [];
1845
+ if (opts.sessions.length === 0) {
1846
+ const procs = listCaptureProcesses();
1847
+ const liveTimerPids = new Set(
1848
+ procs.processes.filter((p) => p.kind === "timer").map((p) => p.pid),
1849
+ );
1850
+ let names = [];
1851
+ try {
1852
+ names = fs.readdirSync(dir);
1853
+ } catch {
1854
+ names = [];
1855
+ }
1856
+ const nowMs = Date.now();
1857
+ for (const name of names) {
1858
+ const full = path.join(dir, name);
1859
+ let stale = false;
1860
+ if (name.endsWith(".timer.pid")) {
1861
+ const pid = readPidFile(full);
1862
+ // With a process list, trust it (a reused PID is not a timer);
1863
+ // without one, fall back to "is any process alive".
1864
+ stale = procs.ok ? !liveTimerPids.has(pid) : !isPidAlive(pid);
1865
+ } else if (name.endsWith(".drain.lease")) {
1866
+ let lease = null;
1867
+ try {
1868
+ lease = JSON.parse(fs.readFileSync(full, "utf8"));
1869
+ } catch {
1870
+ lease = null;
1871
+ }
1872
+ const expires = lease ? Date.parse(lease.expires_at) : NaN;
1873
+ if (lease) {
1874
+ stale =
1875
+ !isPidAlive(Number(lease.pid)) &&
1876
+ (!Number.isFinite(expires) || expires <= nowMs);
1877
+ } else {
1878
+ let ageMs = 0;
1879
+ try {
1880
+ ageMs = nowMs - fs.statSync(full).mtimeMs;
1881
+ } catch {
1882
+ ageMs = 0;
1883
+ }
1884
+ stale = ageMs >= UNPARSABLE_LEASE_MIN_AGE_MS;
1885
+ }
1886
+ }
1887
+ if (stale) files.push(full);
1888
+ }
1889
+ const jobsDir = path.join(dir, "session-end-jobs");
1890
+ let jobs = [];
1891
+ try {
1892
+ jobs = fs.readdirSync(jobsDir);
1893
+ } catch {
1894
+ jobs = [];
1895
+ }
1896
+ if (procs.ok) {
1897
+ const liveJobFiles = procs.processes
1898
+ .filter((p) => p.kind === "session_end_worker")
1899
+ .map((p) => p.command);
1900
+ for (const name of jobs) {
1901
+ const full = path.join(jobsDir, name);
1902
+ if (!liveJobFiles.some((cmd) => cmd.includes(name))) files.push(full);
1903
+ }
1904
+ }
1905
+ }
1906
+ const removed = [];
1907
+ for (const f of files) {
1908
+ if (opts.dryRun) {
1909
+ removed.push({ path: f, outcome: "would_remove" });
1910
+ continue;
1911
+ }
1912
+ try {
1913
+ fs.unlinkSync(f);
1914
+ removed.push({ path: f, outcome: "removed" });
1915
+ } catch (err) {
1916
+ removed.push({ path: f, outcome: "failed", reason: err.message });
1917
+ }
1918
+ }
1919
+ if (!opts.dryRun) refreshHealthCache();
1920
+ const partial =
1921
+ sessions.some((r) => r.outcome === "busy" || r.outcome === "failed") ||
1922
+ removed.some((r) => r.outcome === "failed");
1923
+ return {
1924
+ action: "repair",
1925
+ dry_run: opts.dryRun,
1926
+ sessions,
1927
+ files: removed,
1928
+ errors,
1929
+ exit: errors.length ? 1 : partial ? 2 : 0,
1930
+ };
1931
+ }
1932
+
1933
+ // ---------------------------------------------------------------------------
1934
+ // Text output
1935
+ // ---------------------------------------------------------------------------
1936
+
1937
+ function short(id) {
1938
+ return String(id || "").slice(0, 8);
1939
+ }
1940
+
1941
+ function formatBytes(n) {
1942
+ if (n == null) return "-";
1943
+ if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)}MB`;
1944
+ if (n >= 1024) return `${Math.round(n / 1024)}KB`;
1945
+ return `${n}B`;
1946
+ }
1947
+
1948
+ function formatStatus(report) {
1949
+ const lines = [];
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
+ );
1963
+ if (report.sessions.length === 0) {
1964
+ lines.push("No session has unsent work, a stale failure, or a park.");
1965
+ } else {
1966
+ lines.push("");
1967
+ lines.push("Sessions needing attention:");
1968
+ for (const s of report.sessions) {
1969
+ if (!s.readable) {
1970
+ lines.push(` ${short(s.session_id)} unreadable watermark: ${s.error}`);
1971
+ continue;
1972
+ }
1973
+ const flags = [
1974
+ s.parked ? "PARKED" : null,
1975
+ s.live ? "live" : null,
1976
+ s.stale_failure ? "stale-failure" : null,
1977
+ s.transcript_exists ? null : "no-transcript",
1978
+ ].filter(Boolean);
1979
+ lines.push(
1980
+ ` ${short(s.session_id)} ${s.client || "?"} ${s.agent_id || "?"} ` +
1981
+ `${s.lifecycle}/${s.delivery} queued=${s.queued_exchanges} ` +
1982
+ `attempts=${s.attempt_count} transcript=${formatBytes(s.transcript_bytes)}` +
1983
+ (flags.length ? ` [${flags.join(", ")}]` : ""),
1984
+ );
1985
+ const err = s.parked ? s.parked.previous && s.parked.previous.last_error : s.last_error;
1986
+ if (s.failure_class && !s.parked) {
1987
+ lines.push(` failure=${s.failure_class} next_retry_at=${s.next_retry_at || "-"}`);
1988
+ }
1989
+ if (err) lines.push(` last_error: ${String(err).slice(0, 160)}`);
1990
+ }
1991
+ }
1992
+ lines.push("");
1993
+ if (report.process_scan_error) {
1994
+ lines.push(`Background processes: unknown (${report.process_scan_error})`);
1995
+ } else if (report.processes.length === 0) {
1996
+ lines.push("Background capture processes: none");
1997
+ } else {
1998
+ lines.push("Background capture processes:");
1999
+ for (const p of report.processes) {
2000
+ lines.push(
2001
+ ` pid ${p.pid} ${p.kind} ${p.session_id ? short(p.session_id) : ""}` +
2002
+ (p.idle_parked ? " (session parked; idle, not retrying)" : ""),
2003
+ );
2004
+ }
2005
+ }
2006
+ return lines.join("\n");
2007
+ }
2008
+
2009
+ function formatActionResult(report) {
2010
+ const lines = [];
2011
+ const verb = report.dry_run ? `${report.action} (dry run)` : report.action;
2012
+ lines.push(`capture-cleanup ${verb}`);
2013
+ if (report.processes) {
2014
+ lines.push(
2015
+ report.process_scan_error
2016
+ ? ` processes: not scanned (${report.process_scan_error})`
2017
+ : ` processes: ${report.processes.length}` +
2018
+ (report.dry_run ? " would be stopped" : " stopped"),
2019
+ );
2020
+ for (const p of report.processes) {
2021
+ const tail = report.dry_run ? "" : p.exited ? ` ${p.signal}` : ` STILL RUNNING`;
2022
+ lines.push(` pid ${p.pid} ${p.kind} ${p.session_id ? short(p.session_id) : ""}${tail}`);
2023
+ }
2024
+ }
2025
+ if (report.mcp_url) lines.push(` server: ${report.mcp_url} (credentials from ${report.credentials_from})`);
2026
+ for (const s of report.sessions || []) {
2027
+ const extra = [
2028
+ s.cause ? `cause=${s.cause}` : null,
2029
+ s.moved_to ? `new conversation ${short(s.moved_to)}` : null,
2030
+ s.queued_exchanges != null ? `queued=${s.queued_exchanges}` : null,
2031
+ s.captured != null ? `sent=${s.captured}` : null,
2032
+ s.queued_remaining != null && s.outcome !== "would_drain" ? `left=${s.queued_remaining}` : null,
2033
+ s.delivery ? `delivery=${s.delivery}` : null,
2034
+ s.reason || s.error || null,
2035
+ ].filter(Boolean);
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}`);
2038
+ if (s.stderr) lines.push(` ${s.stderr.split("\n").join("\n ")}`);
2039
+ }
2040
+ for (const f of report.files || []) {
2041
+ lines.push(` ${f.outcome} ${f.path}${f.reason ? ` ${f.reason}` : ""}`);
2042
+ }
2043
+ if (report.stopped_at) {
2044
+ lines.push(` stopped at ${short(report.stopped_at)}; later sessions were not attempted`);
2045
+ }
2046
+ if ((report.sessions || []).length === 0 && !(report.files || []).length && !report.processes) {
2047
+ lines.push(" nothing to do");
2048
+ }
2049
+ return lines.join("\n");
2050
+ }
2051
+
2052
+ // ---------------------------------------------------------------------------
2053
+ // Main
2054
+ // ---------------------------------------------------------------------------
2055
+
2056
+ function run(argv, io) {
2057
+ const out = io || { stdout: process.stdout, stderr: process.stderr };
2058
+ let opts;
2059
+ try {
2060
+ opts = parseArgs(argv);
2061
+ } catch (err) {
2062
+ out.stderr.write(`[meko-capture-cleanup] ${err.message}\n${usage()}`);
2063
+ return 1;
2064
+ }
2065
+ if (opts.help) {
2066
+ out.stdout.write(usage());
2067
+ return 0;
2068
+ }
2069
+ const log = (msg) => {
2070
+ if (!opts.json) out.stderr.write(`${msg}\n`);
2071
+ };
2072
+ let report;
2073
+ try {
2074
+ switch (opts.action) {
2075
+ case "status":
2076
+ report = actionStatus(opts);
2077
+ break;
2078
+ case "stop":
2079
+ report = actionStop(opts);
2080
+ break;
2081
+ case "resume":
2082
+ report = actionResume(opts);
2083
+ break;
2084
+ case "drain":
2085
+ report = actionDrain(opts, log);
2086
+ break;
2087
+ case "discard":
2088
+ report = actionDiscard(opts);
2089
+ break;
2090
+ case "repair":
2091
+ report = actionRepair(opts);
2092
+ break;
2093
+ case "fix":
2094
+ report = actionFix(opts, log);
2095
+ break;
2096
+ default:
2097
+ throw new Error(`unknown action ${opts.action}`);
2098
+ }
2099
+ } catch (err) {
2100
+ out.stderr.write(`[meko-capture-cleanup] ${err.message}\n`);
2101
+ return 1;
2102
+ }
2103
+ const exit = report.exit;
2104
+ delete report.exit;
2105
+ if (opts.json) {
2106
+ out.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
2107
+ } else {
2108
+ for (const e of report.errors || []) out.stderr.write(`[meko-capture-cleanup] ${e}\n`);
2109
+ out.stdout.write(
2110
+ `${report.action === "status" ? formatStatus(report) : formatActionResult(report)}\n`,
2111
+ );
2112
+ }
2113
+ return exit;
2114
+ }
2115
+
2116
+ module.exports = {
2117
+ parseArgs,
2118
+ run,
2119
+ drainOneSession,
2120
+ diagnose,
2121
+ hasPendingWork,
2122
+ hasStaleFailure,
2123
+ parkedState,
2124
+ unparkedState,
2125
+ discardedState,
2126
+ listCaptureProcesses,
2127
+ processesInScope,
2128
+ isInitLike,
2129
+ PARK_UNTIL,
2130
+ };
2131
+
2132
+ if (require.main === module) {
2133
+ process.exit(run(process.argv.slice(2)));
2134
+ }