@yugabytedb/meko-mcp 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +106 -38
  2. package/assets/hooks-handlers/lib/capture-cleanup-cli.js +1473 -0
  3. package/assets/hooks-handlers/lib/capture-notices.js +131 -14
  4. package/assets/hooks-handlers/lib/capture-state.js +95 -1
  5. package/assets/hooks-handlers/lib/capture-status-cli.js +6 -2
  6. package/assets/hooks-handlers/lib/capture.js +1237 -137
  7. package/assets/hooks-handlers/lib/checkpoint-timer.js +51 -10
  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 +620 -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 +85 -18
  15. package/assets/skills/meko-mcp-tools/SKILL.md +57 -14
  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 +86 -14
  19. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +1 -1
  20. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +29 -6
  21. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +8 -4
  22. package/assets/skills/meko-mcp-tools/references/tools-overview.md +32 -22
  23. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +14 -8
  24. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +45 -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 +54 -18
  27. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +1 -1
  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 +95 -33
  31. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +22 -5
  32. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +14 -10
  33. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +35 -25
  34. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +14 -8
  35. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +67 -1
  36. package/assets/skills/meko-select-datapack-desktop/SKILL.md +4 -4
  37. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +5 -5
  38. package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +7 -5
  39. package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +4 -4
  40. package/bin/create-meko-setup.mjs +70 -7
  41. package/lib/capture-cleanup.mjs +63 -0
  42. package/lib/clients/claude-code.mjs +1 -0
  43. package/lib/clients/codex.mjs +80 -35
  44. package/lib/clients/common.mjs +7 -0
  45. package/lib/clients/index.mjs +2 -0
  46. package/lib/clients/kiro.mjs +5 -5
  47. package/lib/clients/opencode.mjs +532 -0
  48. package/lib/config.mjs +68 -1
  49. package/lib/installer.mjs +18 -2
  50. package/lib/migrate/adapters/claude-code.mjs +11 -2
  51. package/lib/migrate/adapters/codex.mjs +9 -2
  52. package/lib/migrate/adapters/memory-md.mjs +27 -8
  53. package/lib/migrate/adapters/opencode.mjs +148 -0
  54. package/lib/migrate/claude-parser.mjs +153 -9
  55. package/lib/migrate/id.mjs +195 -13
  56. package/lib/migrate/mcp-client.mjs +25 -1
  57. package/lib/migrate/opencode-normalizer.mjs +132 -0
  58. package/lib/migrate/orchestrator.mjs +293 -77
  59. package/lib/migrate/progress.mjs +239 -5
  60. package/lib/migrate/registry.mjs +72 -0
  61. package/lib/migrate.mjs +96 -8
  62. package/package.json +1 -1
  63. package/scripts/bundle-assets.mjs +4 -0
@@ -1,19 +1,66 @@
1
1
  /**
2
2
  * progress.mjs — Lightweight progress snapshots for `--migrate-status`.
3
+ *
4
+ * A run's status is a small state machine. Four states are written to
5
+ * `progress.json`:
6
+ *
7
+ * running: written once at daemon start.
8
+ * succeeded: written from the normal completion path.
9
+ * failed: written from the top-level exception handler, with the
10
+ * error class and message.
11
+ * cancelled: written from the SIGINT/SIGTERM handler before exit.
12
+ *
13
+ * A fifth state, `crashed`, is never written. The reader infers it for a
14
+ * `running` record whose PID is dead or whose heartbeat has gone stale,
15
+ * which is how a `kill -9`, an OOM kill, or a laptop sleep gets reported
16
+ * honestly instead of as a live migration. See `classify()`.
17
+ *
18
+ * `heartbeatAt` is refreshed on a timer for the same reason: a silently
19
+ * killed daemon runs no handler, so the only evidence of death is the
20
+ * absence of new writes.
3
21
  */
4
22
 
5
23
  import fs from "node:fs";
6
24
  import path from "node:path";
7
25
 
26
+ /** How often the daemon refreshes `heartbeatAt`. */
27
+ export const HEARTBEAT_INTERVAL_MS = 10_000;
28
+
29
+ /**
30
+ * How long a heartbeat may go unrefreshed before the reader calls the run
31
+ * dead. Three intervals of slack absorbs a paused event loop (a long
32
+ * `memory_add` extraction, a suspended laptop) without flapping.
33
+ */
34
+ export const HEARTBEAT_STALE_AFTER_MS = HEARTBEAT_INTERVAL_MS * 3;
35
+
36
+ /** Terminal states: a run in one of these will never advance again. */
37
+ export const TERMINAL_STATUSES = ["succeeded", "failed", "cancelled"];
38
+
39
+ /**
40
+ * Pre-2.3 runs on disk used `done` for success and `interrupted` for a
41
+ * signal. The reader normalizes them so a status read against a run
42
+ * started by an older installer still lands in the documented state set.
43
+ */
44
+ const LEGACY_STATUS_ALIASES = {
45
+ done: "succeeded",
46
+ interrupted: "cancelled",
47
+ stopped: "cancelled",
48
+ };
49
+
8
50
  /**
9
51
  * @typedef {Object} Progress
10
52
  * @property {string} runId
11
53
  * @property {string} scope
12
54
  * @property {string} startedAt
13
55
  * @property {string|null} finishedAt
14
- * @property {"running"|"done"|"failed"|"interrupted"} status
56
+ * @property {string|null} heartbeatAt ISO timestamp, refreshed on a timer
57
+ * while the run is `running`.
58
+ * @property {number|null} pid PID of the daemon that owns the run.
59
+ * @property {"running"|"succeeded"|"failed"|"cancelled"} status
60
+ * @property {string|null} error Set alongside `failed`.
15
61
  * @property {string|null} currentAgent
16
- * @property {string|null} currentSession
62
+ * @property {string|null} currentSession Source session UUID being replayed.
63
+ * @property {string|null} currentMessage Source message (turn) id being sent.
17
64
  * @property {Counts} counts
18
65
  *
19
66
  * @typedef {Object} Counts
@@ -24,20 +71,26 @@ import path from "node:path";
24
71
  * @property {number} memoriesFromMD
25
72
  * @property {number} warnings
26
73
  * @property {number} memoryErrors
74
+ * @property {number} authSkipped Calls dropped on a mid-run 401/403.
27
75
  */
28
76
 
29
77
  /**
30
78
  * @returns {Progress}
31
79
  */
32
- export function initial(runId, scope) {
80
+ export function initial(runId, scope, opts = {}) {
81
+ const now = new Date().toISOString();
33
82
  return {
34
83
  runId,
35
84
  scope,
36
- startedAt: new Date().toISOString(),
85
+ startedAt: now,
37
86
  finishedAt: null,
87
+ heartbeatAt: now,
88
+ pid: opts.pid ?? process.pid,
38
89
  status: "running",
90
+ error: null,
39
91
  currentAgent: null,
40
92
  currentSession: null,
93
+ currentMessage: null,
41
94
  counts: {
42
95
  sessionsPlanned: 0,
43
96
  sessionsDone: 0,
@@ -46,17 +99,26 @@ export function initial(runId, scope) {
46
99
  memoriesFromMD: 0,
47
100
  warnings: 0,
48
101
  memoryErrors: 0,
102
+ authSkipped: 0,
49
103
  },
50
104
  };
51
105
  }
52
106
 
53
107
  /**
54
- * Atomic write.
108
+ * Atomic write. Refreshes `heartbeatAt` on every write of a `running`
109
+ * record, so ordinary progress writes double as heartbeats and the timer
110
+ * only matters during long gaps between them.
55
111
  *
56
112
  * @param {string} progressPath
57
113
  * @param {Progress} progress
58
114
  */
59
115
  export function write(progressPath, progress) {
116
+ if (progress && progress.status === "running") {
117
+ // Once `finalize()` has closed this run, a `running` write can only be
118
+ // stale: the live object a caller still holds, or a heartbeat tick.
119
+ if (sealedPaths.has(progressPath)) return;
120
+ progress.heartbeatAt = new Date().toISOString();
121
+ }
60
122
  fs.mkdirSync(path.dirname(progressPath), { recursive: true });
61
123
  const tmp = `${progressPath}.tmp.${process.pid}.${Date.now()}`;
62
124
  fs.writeFileSync(tmp, JSON.stringify(progress, null, 2));
@@ -74,3 +136,175 @@ export function read(progressPath) {
74
136
  return null;
75
137
  }
76
138
  }
139
+
140
+ // --- Heartbeat -------------------------------------------------------------
141
+
142
+ // Module singleton. The orchestrator starts the heartbeat and the daemon
143
+ // entrypoint stops it from its catch/signal handlers, which run in the same
144
+ // process after `runMigration` has stopped writing. Keeping the handle here
145
+ // rather than threading it through a return value means the handlers can
146
+ // stop it even on the path where `runMigration` threw and returned nothing.
147
+ let heartbeatTimer = null;
148
+
149
+ // Progress files that `finalize()` has moved to a terminal status. The
150
+ // catch and signal handlers finalize by read-modify-write, so the
151
+ // orchestrator's live `prog` object still says `running`. Sealing the path
152
+ // makes any later write of that object a no-op instead of a resurrection.
153
+ const sealedPaths = new Set();
154
+
155
+ /**
156
+ * Refresh `heartbeatAt` every `HEARTBEAT_INTERVAL_MS` while the run is in
157
+ * flight. The timer is `unref`'d so it never keeps the process alive on its
158
+ * own; `stopHeartbeat()` is still the correct way to end it, because a live
159
+ * timer that fires after a terminal write would resurrect `running`.
160
+ *
161
+ * @param {string} progressPath
162
+ * @param {Progress} prog The live object the orchestrator mutates.
163
+ * @returns {() => void} Stop function; same effect as `stopHeartbeat()`.
164
+ */
165
+ export function startHeartbeat(progressPath, prog) {
166
+ stopHeartbeat();
167
+ heartbeatTimer = setInterval(() => {
168
+ if (!prog || prog.status !== "running") return;
169
+ try {
170
+ write(progressPath, prog);
171
+ } catch {
172
+ /* best effort; a missed heartbeat only costs a stale-looking read */
173
+ }
174
+ }, HEARTBEAT_INTERVAL_MS);
175
+ if (typeof heartbeatTimer.unref === "function") heartbeatTimer.unref();
176
+ return stopHeartbeat;
177
+ }
178
+
179
+ export function stopHeartbeat() {
180
+ if (heartbeatTimer) {
181
+ clearInterval(heartbeatTimer);
182
+ heartbeatTimer = null;
183
+ }
184
+ }
185
+
186
+ /** Whether a heartbeat timer is live. Lets tests prove the run stopped it. */
187
+ export function isHeartbeatRunning() {
188
+ return heartbeatTimer !== null;
189
+ }
190
+
191
+ /**
192
+ * Write a terminal status by read-modify-write, preserving whatever counts
193
+ * the orchestrator last flushed.
194
+ *
195
+ * Used by the daemon entrypoint's catch and signal handlers, which don't
196
+ * hold the orchestrator's live `prog` object. Stops the heartbeat and seals
197
+ * the path first, so neither the timer nor a late write of the live object
198
+ * can overwrite the terminal record.
199
+ *
200
+ * @param {string} progressPath
201
+ * @param {object} opts
202
+ * @param {"succeeded"|"failed"|"cancelled"} opts.status
203
+ * @param {string|null} [opts.error]
204
+ * @returns {{record: Progress|null, wrote: boolean}} `record` is null when
205
+ * there was no progress file. `wrote` is false when there was nothing to
206
+ * finalize or the record was already terminal, so callers only mirror a
207
+ * transition into the event log when it actually happened.
208
+ */
209
+ export function finalize(progressPath, opts) {
210
+ stopHeartbeat();
211
+ sealedPaths.add(progressPath);
212
+ const prog = read(progressPath);
213
+ if (!prog) return { record: null, wrote: false };
214
+ // Don't overwrite an already-terminal record: a signal that arrives while
215
+ // the process is on its way out shouldn't rewrite `succeeded` as
216
+ // `cancelled`.
217
+ if (TERMINAL_STATUSES.includes(normalizeStatus(prog.status))) {
218
+ return { record: prog, wrote: false };
219
+ }
220
+ prog.status = opts.status;
221
+ prog.error = opts.error ?? null;
222
+ prog.finishedAt = new Date().toISOString();
223
+ prog.currentAgent = null;
224
+ prog.currentSession = null;
225
+ prog.currentMessage = null;
226
+ try {
227
+ write(progressPath, prog);
228
+ } catch {
229
+ return { record: prog, wrote: false };
230
+ }
231
+ return { record: prog, wrote: true };
232
+ }
233
+
234
+ // --- Reader-side classification --------------------------------------------
235
+
236
+ /**
237
+ * Map a legacy on-disk status onto the documented state set.
238
+ *
239
+ * @param {string|undefined} status
240
+ * @returns {string}
241
+ */
242
+ export function normalizeStatus(status) {
243
+ if (!status) return "unknown";
244
+ return LEGACY_STATUS_ALIASES[status] ?? status;
245
+ }
246
+
247
+ /**
248
+ * Decide what a progress record actually means, given process liveness.
249
+ *
250
+ * A `running` record is only believable if the daemon is still alive. Two
251
+ * independent checks, either of which condemns it:
252
+ *
253
+ * - the PID it names has no live process (`isPidAlive` is supplied by the
254
+ * caller, normally `lockfile.isStale` inverted), or
255
+ * - its heartbeat is older than `HEARTBEAT_STALE_AFTER_MS`.
256
+ *
257
+ * The heartbeat check is what catches a run whose PID has been recycled by
258
+ * an unrelated process; the PID check is what reports a `kill -9` within
259
+ * milliseconds instead of waiting out the stale window.
260
+ *
261
+ * @param {Progress} prog
262
+ * @param {object} [opts]
263
+ * @param {number|null} [opts.pid] PID to test; defaults to `prog.pid`.
264
+ * @param {(pid: number) => boolean} [opts.isPidAlive]
265
+ * @param {boolean} [opts.holdsLock] Whether the migrate lockfile still
266
+ * names this run. A live daemon always
267
+ * holds it; it is released on the way
268
+ * out. Pass this only when the caller
269
+ * actually read the lockfile.
270
+ * @param {number} [opts.now] Epoch ms; injectable for tests.
271
+ * @returns {{status: string, crashed: boolean, reason: string|null}}
272
+ */
273
+ export function classify(prog, opts = {}) {
274
+ const status = normalizeStatus(prog?.status);
275
+ if (status !== "running") return { status, crashed: false, reason: null };
276
+
277
+ const now = opts.now ?? Date.now();
278
+ const pid = opts.pid ?? prog?.pid ?? null;
279
+
280
+ if (typeof pid === "number" && pid > 0 && typeof opts.isPidAlive === "function") {
281
+ if (!opts.isPidAlive(pid)) {
282
+ return { status: "crashed", crashed: true, reason: `pid ${pid} not alive` };
283
+ }
284
+ }
285
+
286
+ const beat = Date.parse(prog?.heartbeatAt ?? "");
287
+ if (Number.isFinite(beat)) {
288
+ const age = now - beat;
289
+ if (age > HEARTBEAT_STALE_AFTER_MS) {
290
+ return {
291
+ status: "crashed",
292
+ crashed: true,
293
+ reason: `no heartbeat for ${Math.round(age / 1000)}s`,
294
+ };
295
+ }
296
+ } else if (pid === null) {
297
+ // A pre-heartbeat run record: no PID to test, no parseable heartbeat.
298
+ // The lockfile is the last piece of evidence. A daemon holds it for the
299
+ // whole run and releases it on the way out, so a `running` record that
300
+ // no longer owns the lock is a run that ended without saying so:
301
+ // exactly the stuck-forever records this fix exists to explain.
302
+ if (opts.holdsLock === false) {
303
+ return { status: "crashed", crashed: true, reason: "no lock holder" };
304
+ }
305
+ // Otherwise report it as running, but say why we can't vouch for it.
306
+ return { status: "running", crashed: false, reason: "no liveness data" };
307
+ }
308
+
309
+ return { status: "running", crashed: false, reason: null };
310
+ }
@@ -0,0 +1,72 @@
1
+ /** Session migration adapters: id, displayName, listSessions, parseSession. */
2
+ import * as claudeCodeAdapter from "./adapters/claude-code.mjs";
3
+ import * as cursorAdapter from "./adapters/cursor.mjs";
4
+ import * as codexAdapter from "./adapters/codex.mjs";
5
+ import * as openCodeAdapter from "./adapters/opencode.mjs";
6
+ /** @type {Array<{id: string, displayName: string, listSessions: Function, parseSession: Function}>} */
7
+ export const SESSION_ADAPTERS = [
8
+ {
9
+ id: "claude-code",
10
+ displayName: "Claude Code",
11
+ listSessions: claudeCodeAdapter.listSessions,
12
+ parseSession: (ref) => claudeCodeAdapter.parseSession(ref.path),
13
+ },
14
+ {
15
+ id: "cursor",
16
+ displayName: "Cursor",
17
+ listSessions: cursorAdapter.listSessions,
18
+ parseSession: (ref) => cursorAdapter.parseSession(ref.path),
19
+ },
20
+ {
21
+ id: "codex",
22
+ displayName: "Codex",
23
+ listSessions: codexAdapter.listSessions,
24
+ parseSession: (ref) => codexAdapter.parseSession(ref.path),
25
+ },
26
+ {
27
+ id: "opencode",
28
+ displayName: "OpenCode",
29
+ listSessions: openCodeAdapter.listSessions,
30
+ parseSession: (ref, opts) => openCodeAdapter.parseSession(ref, opts),
31
+ },
32
+ ];
33
+ /** @returns {Promise<Array<{ ref: object, source: string }>>} */
34
+ export async function enumerateSessionRefs(opts) {
35
+ const out = [];
36
+ const adapters = opts.sessionAdapters ?? SESSION_ADAPTERS;
37
+ for (const adapter of adapters) {
38
+ let refs;
39
+ try {
40
+ refs = await adapter.listSessions(opts);
41
+ if (!Array.isArray(refs)) {
42
+ throw new Error("listSessions did not return an array");
43
+ }
44
+ for (const ref of refs) {
45
+ out.push({ ref, source: adapter.id, adapter });
46
+ }
47
+ } catch (error) {
48
+ opts.onSourceError?.({
49
+ source: adapter.id,
50
+ displayName: adapter.displayName,
51
+ error,
52
+ });
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+ /** @returns {Promise<object>|object} */
58
+ export function parseTaggedSession(tagged, opts = {}) {
59
+ const adapter =
60
+ tagged.adapter ?? SESSION_ADAPTERS.find((a) => a.id === tagged.source);
61
+ if (!adapter) throw new Error(`unknown migration source: ${tagged.source}`);
62
+ return adapter.parseSession(tagged.ref, opts);
63
+ }
64
+ /** Legacy summary field names keyed by adapter id. */
65
+ export function sessionsByAdapter(tagged) {
66
+ /** @type {Record<string, number>} */
67
+ const counts = {};
68
+ for (const { source } of tagged) {
69
+ counts[source] = (counts[source] || 0) + 1;
70
+ }
71
+ return counts;
72
+ }
package/lib/migrate.mjs CHANGED
@@ -56,7 +56,13 @@ function paths(opts = {}) {
56
56
  statePath,
57
57
  lockPath: path.join(root, "lock"),
58
58
  runDir: (runId) => path.join(root, runId),
59
- logFile: (runId) => path.join(root, runId, "stdout.log"),
59
+ // Where the child's stdio is redirected. Everything the daemon has to
60
+ // say goes through the NDJSON logger instead, so this file is normally
61
+ // empty; it only catches process-level output that escapes the logger
62
+ // (a stack trace from before it opens, a native crash). Don't point
63
+ // users here; point them at `eventLogFile`.
64
+ stdioFile: (runId) => path.join(root, runId, "stdout.log"),
65
+ eventLogFile: (runId) => path.join(root, runId, "log.ndjson"),
60
66
  progressFile: (runId) => path.join(root, runId, "progress.json"),
61
67
  };
62
68
  }
@@ -231,9 +237,29 @@ export async function startMigration(opts) {
231
237
  dryRun: true,
232
238
  noExtract: opts.noExtract,
233
239
  concurrency: opts.rate,
240
+ sessionAdapters: opts.sessionAdapters,
241
+ opencodeBin: opts.opencodeBin,
242
+ runExport: opts.runExport,
234
243
  });
235
244
  const s = summary.summary;
245
+ if (!summary.ok) {
246
+ return {
247
+ ok: false,
248
+ runId,
249
+ message: `Dry run failed: ${s.detail || s.error || "unknown migration error"}`,
250
+ };
251
+ }
236
252
  const planned = s.plannedCalls ?? {};
253
+ const warningLines = [
254
+ ...(s.sourceErrors ?? []).map(
255
+ ({ source, error }) => ` Warning: ${source} enumeration failed: ${error}\n`,
256
+ ),
257
+ ...(s.sessionErrors ?? []).map(
258
+ ({ source, sessionUuid, error }) =>
259
+ ` Warning: ${source} session ${sessionUuid} parse failed: ${error}\n`,
260
+ ),
261
+ ]
262
+ .join("");
237
263
  // Dry-run never opens an MCP session, so we can't resolve names→UUIDs
238
264
  // here. Echo what the user asked for; the real run will resolve and
239
265
  // print the canonical destination before doing any work.
@@ -249,8 +275,11 @@ export async function startMigration(opts) {
249
275
  destLine +
250
276
  ` Claude Code sessions: ${s.claudeSessions}\n` +
251
277
  ` Cursor sessions: ${s.cursorSessions}\n` +
278
+ ` Codex sessions: ${s.codexSessions}\n` +
279
+ ` OpenCode sessions: ${s.openCodeSessions}\n` +
252
280
  ` Paired user↔asst turns: ${s.pairedTurns}\n` +
253
281
  ` Memory items (MD): ${s.memoryItems}\n` +
282
+ warningLines +
254
283
  ` Planned MCP calls:\n` +
255
284
  ` conversation_create: ${planned.conversation_create ?? 0}\n` +
256
285
  ` conversation_add_message: ${planned.conversation_add_message ?? 0}\n` +
@@ -375,7 +404,7 @@ export async function startMigration(opts) {
375
404
  scriptPath: opts.scriptPath,
376
405
  runId,
377
406
  args: passthrough,
378
- logFilePath: P.logFile(runId),
407
+ logFilePath: P.stdioFile(runId),
379
408
  env,
380
409
  cwd: opts.projectRoot,
381
410
  });
@@ -395,7 +424,7 @@ export async function startMigration(opts) {
395
424
  message:
396
425
  `${datapackBanner}\n` +
397
426
  `Migration started. run-id=${runId}, pid=${pid}\n` +
398
- `Logs: ${P.logFile(runId)}\n` +
427
+ `Logs: ${P.eventLogFile(runId)} (one JSON object per line)\n` +
399
428
  `Status: ${selfInvocation()} --migrate-status`,
400
429
  };
401
430
  }
@@ -520,11 +549,21 @@ export async function runChildMigration(opts) {
520
549
  log.warn("legacy_state_migrate_failed", { error: err.message });
521
550
  }
522
551
 
552
+ const progressPath = P.progressFile(opts.runId);
553
+
523
554
  let shuttingDown = false;
524
555
  const onSignal = (sig) => {
525
556
  if (shuttingDown) return;
526
557
  shuttingDown = true;
527
558
  log.warn("interrupted", { signal: sig });
559
+ // Write the terminal state before releasing the lock. Skipping this is
560
+ // what left a clean `--migrate-stop` reading as `running` forever.
561
+ try {
562
+ const { wrote } = progress.finalize(progressPath, { status: "cancelled", error: `signal ${sig}` });
563
+ // A signal after the orchestrator's own terminal write leaves that
564
+ // status in place, so only log a transition that actually happened.
565
+ if (wrote) log.info("status", { status: "cancelled", signal: sig, runId: opts.runId });
566
+ } catch { /* best effort; we're on our way out */ }
528
567
  try { lockfile.release(P.lockPath); } catch { /* ignore */ }
529
568
  log.close();
530
569
  process.exit(130);
@@ -554,18 +593,37 @@ export async function runChildMigration(opts) {
554
593
  return result.ok ? 0 : 1;
555
594
  } catch (err) {
556
595
  log.error("child_crashed", { error: err.message, stack: err.stack });
596
+ // The orchestrator threw before reaching its own terminal write, so the
597
+ // progress file is still on `running`. Close the state machine here.
598
+ try {
599
+ const { wrote } = progress.finalize(progressPath, {
600
+ status: "failed",
601
+ error: `${err.name ?? "Error"}: ${err.message}`,
602
+ });
603
+ if (wrote) log.info("status", { status: "failed", error: err.message, runId: opts.runId });
604
+ } catch { /* best effort */ }
557
605
  log.close();
558
606
  try { lockfile.release(P.lockPath); } catch { /* ignore */ }
559
607
  return 1;
608
+ } finally {
609
+ // Whatever path we left on, no timer should outlive the run and rewrite
610
+ // a terminal record back to `running`.
611
+ progress.stopHeartbeat();
560
612
  }
561
613
  }
562
614
 
563
615
  /**
564
616
  * Print and return the status of the latest (or requested) migration run.
565
617
  *
618
+ * The on-disk `status` field is not taken at face value. A `running` record
619
+ * whose daemon is gone reports as `crashed`, which is how a `kill -9`, an
620
+ * OOM kill, or a reboot shows up. None of them run a handler that could
621
+ * have written a terminal state. `progress.status` stays as written;
622
+ * the returned `status` is the effective one.
623
+ *
566
624
  * @param {object} opts
567
625
  * @param {string} [opts.runId]
568
- * @returns {{ok: boolean, runId: string|null, progress: object|null, message: string}}
626
+ * @returns {{ok: boolean, runId: string|null, progress: object|null, status?: string, crashed?: boolean, message: string}}
569
627
  */
570
628
  export function readStatus(opts = {}) {
571
629
  const P = paths();
@@ -620,23 +678,53 @@ export function readStatus(opts = {}) {
620
678
  const RESET = useColor ? "\x1b[0m" : "";
621
679
  const note = (s) => (s ? ` ${GRAY}${s}${RESET}` : "");
622
680
 
681
+ // A `running` record is only worth believing if the daemon is still there.
682
+ // The lockfile names the owning PID; prefer it over the PID the run
683
+ // recorded, since --force can hand the lock to a newer process.
684
+ const holder = lockfile.read(P.lockPath);
685
+ const holdsLock = !!holder && holder.runId === runId;
686
+ const livePid = holdsLock ? holder.pid : (prog.pid ?? null);
687
+ const verdict = progress.classify(prog, {
688
+ pid: livePid,
689
+ holdsLock,
690
+ isPidAlive: (pid) => !lockfile.isStale({ pid }),
691
+ });
692
+ const statusLabel = verdict.reason
693
+ ? `${verdict.status} (${verdict.reason})`
694
+ : verdict.status;
695
+
623
696
  const lines = [
624
697
  `run-id: ${prog.runId}`,
625
- `status: ${prog.status}${note("(pending → running → done | failed | stopped)")}`,
698
+ `status: ${statusLabel}${note("(running → succeeded | failed | cancelled; crashed is inferred)")}`,
626
699
  `scope: ${prog.scope}`,
627
700
  `datapack: ${datapackKeyLabel}${note("(resume state lives under state/<key>.json)")}`,
628
701
  `target pack: ${datapackTargetLabel}${note("(ingestion destination resolved at run start)")}`,
629
702
  `started: ${prog.startedAt}`,
630
- `finished: ${prog.finishedAt ?? "(in progress)"}`,
631
- `current: ${prog.currentAgent ?? "-"} / ${prog.currentSession ?? "-"}${note("(<adapter>:<source-session> / <message-id>)")}`,
703
+ `finished: ${prog.finishedAt ?? (verdict.crashed ? "(never, daemon died)" : "(in progress)")}`,
704
+ `heartbeat: ${prog.heartbeatAt ?? "-"}${note("(refreshed while running; staleness implies crashed)")}`,
705
+ `current: ${prog.currentAgent ?? "-"} / ${prog.currentSession ?? "-"} / ${prog.currentMessage ?? "-"}${note("(<agent-id> / <source-session> / <message-id>)")}`,
632
706
  `sessions: ${prog.counts.sessionsDone} / ${prog.counts.sessionsPlanned}${note("(processed / discovered)")}`,
633
707
  `messages: ${prog.counts.messagesSent}`,
634
708
  `memory pairs: ${prog.counts.memoryPairsSent}${note("(memory_add from Mem0 extractor)")}`,
635
709
  `memory (MD): ${prog.counts.memoriesFromMD}${note("(memory_add from CLAUDE.md / memory files)")}`,
636
710
  `warnings: ${prog.counts.warnings}${note("(non-fatal; run continues)")}`,
637
711
  `memory errs: ${prog.counts.memoryErrors}${note("(failed memory_add calls; re-run with --force to retry)")}`,
712
+ `auth skipped: ${prog.counts.authSkipped ?? 0}${note("(calls dropped on a mid-run 401/403; re-run to retry them)")}`,
638
713
  ];
639
- return { ok: true, runId, progress: prog, message: lines.join("\n") };
714
+ if (prog.error) {
715
+ lines.push(`error: ${prog.error}`);
716
+ }
717
+ return {
718
+ ok: true,
719
+ runId,
720
+ progress: prog,
721
+ // The effective status, after the liveness check. `progress.status` is
722
+ // the raw on-disk field and can still read `running` for a dead daemon;
723
+ // callers that care whether the run is actually alive want this one.
724
+ status: verdict.status,
725
+ crashed: verdict.crashed,
726
+ message: lines.join("\n"),
727
+ };
640
728
  }
641
729
 
642
730
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yugabytedb/meko-mcp",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Installer for Meko across Claude, Cursor, Codex, and Kiro clients",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,6 +30,10 @@ const PAYLOAD = [
30
30
  from: join(repoRoot, "skills", "hooks-handlers"),
31
31
  to: join(assetsDir, "hooks-handlers"),
32
32
  },
33
+ {
34
+ from: join(repoRoot, "skills", "opencode-plugin"),
35
+ to: join(assetsDir, "opencode-plugin"),
36
+ },
33
37
  {
34
38
  from: join(repoRoot, "skills", "scripts", "package-desktop-skill.sh"),
35
39
  to: join(assetsDir, "scripts", "package-desktop-skill.sh"),