@yugabytedb/meko-mcp 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +233 -40
  2. package/assets/hooks-handlers/codex-session-end.sh +4 -69
  3. package/assets/hooks-handlers/cursor-session-start.sh +19 -1
  4. package/assets/hooks-handlers/lib/capture-cleanup-cli.js +1473 -0
  5. package/assets/hooks-handlers/lib/capture-notices.js +438 -0
  6. package/assets/hooks-handlers/lib/capture-state.js +2173 -0
  7. package/assets/hooks-handlers/lib/capture-status-cli.js +86 -0
  8. package/assets/hooks-handlers/lib/capture.js +2782 -403
  9. package/assets/hooks-handlers/lib/checkpoint-timer.js +485 -81
  10. package/assets/hooks-handlers/lib/journal-bridge.js +218 -0
  11. package/assets/hooks-handlers/lib/kiro-handler.js +760 -0
  12. package/assets/hooks-handlers/lib/opencode-handler.js +620 -0
  13. package/assets/hooks-handlers/lib/opencode-normalizer.js +145 -0
  14. package/assets/hooks-handlers/lib/terminal-handoff.js +427 -0
  15. package/assets/hooks-handlers/session-end.sh +3 -34
  16. package/assets/opencode-plugin/meko.js +213 -0
  17. package/assets/scripts/meko-statusline.sh +123 -16
  18. package/assets/skills/meko-mcp-tools/SKILL.md +116 -126
  19. package/assets/skills/meko-mcp-tools/references/_sections.md +3 -3
  20. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +25 -18
  21. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +99 -24
  22. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +1 -1
  23. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +53 -6
  24. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +25 -79
  25. package/assets/skills/meko-mcp-tools/references/tools-overview.md +37 -26
  26. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +14 -8
  27. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +66 -1
  28. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +7 -40
  29. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +56 -20
  30. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +1 -1
  31. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
  32. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +15 -8
  33. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +99 -36
  34. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +22 -5
  35. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +18 -14
  36. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +39 -28
  37. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +14 -8
  38. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +67 -1
  39. package/assets/skills/meko-select-datapack-desktop/SKILL.md +4 -4
  40. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +8 -8
  41. package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +7 -5
  42. package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +4 -4
  43. package/bin/create-meko-setup.mjs +113 -12
  44. package/lib/capture-cleanup.mjs +63 -0
  45. package/lib/capture-status.mjs +67 -0
  46. package/lib/clients/aliases.mjs +5 -0
  47. package/lib/clients/claude-code.mjs +1 -0
  48. package/lib/clients/claude-desktop.mjs +120 -5
  49. package/lib/clients/codex.mjs +85 -37
  50. package/lib/clients/common.mjs +7 -0
  51. package/lib/clients/index.mjs +4 -0
  52. package/lib/clients/kiro.mjs +705 -0
  53. package/lib/clients/opencode.mjs +532 -0
  54. package/lib/config.mjs +158 -1
  55. package/lib/installer.mjs +18 -1
  56. package/lib/migrate/adapters/claude-code.mjs +11 -2
  57. package/lib/migrate/adapters/codex.mjs +497 -0
  58. package/lib/migrate/adapters/memory-md.mjs +27 -8
  59. package/lib/migrate/adapters/opencode.mjs +148 -0
  60. package/lib/migrate/claude-parser.mjs +153 -9
  61. package/lib/migrate/id.mjs +217 -10
  62. package/lib/migrate/mcp-client.mjs +25 -1
  63. package/lib/migrate/opencode-normalizer.mjs +132 -0
  64. package/lib/migrate/orchestrator.mjs +299 -56
  65. package/lib/migrate/progress.mjs +239 -5
  66. package/lib/migrate/registry.mjs +72 -0
  67. package/lib/migrate.mjs +100 -10
  68. package/lib/prompt.mjs +16 -1
  69. package/package.json +4 -4
  70. package/scripts/bundle-assets.mjs +4 -0
@@ -1,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
  }
@@ -212,10 +218,10 @@ export async function startMigration(opts) {
212
218
  // don't move when the state key changes.
213
219
  const urlDatapackKey = datapackKeyFromUrl(opts.url);
214
220
  let P = paths({ datapackKey: urlDatapackKey });
215
- fs.mkdirSync(P.root, { recursive: true });
216
221
 
217
222
  // Dry-run is a local-only planning pass: no HTTP, no lock, no fork.
218
- // Don't gate it on URL/apiKey.
223
+ // Don't gate it on URL/apiKey, and don't create the migration root: a
224
+ // planning pass must not appear as a phantom active run in --migrate-status.
219
225
  if (opts.dryRun) {
220
226
  const runId = generateRunId();
221
227
  const runDir = P.runDir(runId);
@@ -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` +
@@ -260,6 +289,8 @@ export async function startMigration(opts) {
260
289
  };
261
290
  }
262
291
 
292
+ fs.mkdirSync(P.root, { recursive: true });
293
+
263
294
  // Real run — now URL/apiKey are required (dry-run branch already returned).
264
295
  if (!opts.url) {
265
296
  return { ok: false, message: "--url (or MEKO_MCP_URL) is required for --migrate." };
@@ -373,7 +404,7 @@ export async function startMigration(opts) {
373
404
  scriptPath: opts.scriptPath,
374
405
  runId,
375
406
  args: passthrough,
376
- logFilePath: P.logFile(runId),
407
+ logFilePath: P.stdioFile(runId),
377
408
  env,
378
409
  cwd: opts.projectRoot,
379
410
  });
@@ -393,7 +424,7 @@ export async function startMigration(opts) {
393
424
  message:
394
425
  `${datapackBanner}\n` +
395
426
  `Migration started. run-id=${runId}, pid=${pid}\n` +
396
- `Logs: ${P.logFile(runId)}\n` +
427
+ `Logs: ${P.eventLogFile(runId)} (one JSON object per line)\n` +
397
428
  `Status: ${selfInvocation()} --migrate-status`,
398
429
  };
399
430
  }
@@ -518,11 +549,21 @@ export async function runChildMigration(opts) {
518
549
  log.warn("legacy_state_migrate_failed", { error: err.message });
519
550
  }
520
551
 
552
+ const progressPath = P.progressFile(opts.runId);
553
+
521
554
  let shuttingDown = false;
522
555
  const onSignal = (sig) => {
523
556
  if (shuttingDown) return;
524
557
  shuttingDown = true;
525
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 */ }
526
567
  try { lockfile.release(P.lockPath); } catch { /* ignore */ }
527
568
  log.close();
528
569
  process.exit(130);
@@ -552,18 +593,37 @@ export async function runChildMigration(opts) {
552
593
  return result.ok ? 0 : 1;
553
594
  } catch (err) {
554
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 */ }
555
605
  log.close();
556
606
  try { lockfile.release(P.lockPath); } catch { /* ignore */ }
557
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();
558
612
  }
559
613
  }
560
614
 
561
615
  /**
562
616
  * Print and return the status of the latest (or requested) migration run.
563
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
+ *
564
624
  * @param {object} opts
565
625
  * @param {string} [opts.runId]
566
- * @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}}
567
627
  */
568
628
  export function readStatus(opts = {}) {
569
629
  const P = paths();
@@ -618,23 +678,53 @@ export function readStatus(opts = {}) {
618
678
  const RESET = useColor ? "\x1b[0m" : "";
619
679
  const note = (s) => (s ? ` ${GRAY}${s}${RESET}` : "");
620
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
+
621
696
  const lines = [
622
697
  `run-id: ${prog.runId}`,
623
- `status: ${prog.status}${note("(pending → running → done | failed | stopped)")}`,
698
+ `status: ${statusLabel}${note("(running → succeeded | failed | cancelled; crashed is inferred)")}`,
624
699
  `scope: ${prog.scope}`,
625
700
  `datapack: ${datapackKeyLabel}${note("(resume state lives under state/<key>.json)")}`,
626
701
  `target pack: ${datapackTargetLabel}${note("(ingestion destination resolved at run start)")}`,
627
702
  `started: ${prog.startedAt}`,
628
- `finished: ${prog.finishedAt ?? "(in progress)"}`,
629
- `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>)")}`,
630
706
  `sessions: ${prog.counts.sessionsDone} / ${prog.counts.sessionsPlanned}${note("(processed / discovered)")}`,
631
707
  `messages: ${prog.counts.messagesSent}`,
632
708
  `memory pairs: ${prog.counts.memoryPairsSent}${note("(memory_add from Mem0 extractor)")}`,
633
709
  `memory (MD): ${prog.counts.memoriesFromMD}${note("(memory_add from CLAUDE.md / memory files)")}`,
634
710
  `warnings: ${prog.counts.warnings}${note("(non-fatal; run continues)")}`,
635
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)")}`,
636
713
  ];
637
- 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
+ };
638
728
  }
639
729
 
640
730
  /**
package/lib/prompt.mjs CHANGED
@@ -37,6 +37,21 @@ function question(query) {
37
37
  });
38
38
  }
39
39
 
40
+ /**
41
+ * Apply terminal erase bytes that survive readline's muted-output path.
42
+ * Some PTYs leave DEL/BS in the answer when readline is attached to a
43
+ * non-TTY Writable, even with terminal mode enabled. Treat them exactly as
44
+ * interactive backspace so a corrected secret never persists control bytes.
45
+ */
46
+ function applyEraseCharacters(value) {
47
+ const chars = [];
48
+ for (const char of value) {
49
+ if (char === "\x7f" || char === "\b") chars.pop();
50
+ else chars.push(char);
51
+ }
52
+ return chars.join("");
53
+ }
54
+
40
55
  // ---------------------------------------------------------------------------
41
56
  // askChoice
42
57
  // ---------------------------------------------------------------------------
@@ -218,7 +233,7 @@ export async function askSecret(questionText) {
218
233
 
219
234
  secretRL.question("", (answer) => {
220
235
  finish();
221
- resolve(answer);
236
+ resolve(applyEraseCharacters(answer));
222
237
  });
223
238
  });
224
239
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@yugabytedb/meko-mcp",
3
- "version": "2.1.0",
4
- "description": "Single-line installer for Meko MCP Server across Claude and Cursor clients",
3
+ "version": "2.3.0",
4
+ "description": "Installer for Meko across Claude, Cursor, Codex, and Kiro clients",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "create-meko-setup": "bin/create-meko-setup.mjs",
@@ -18,14 +18,14 @@
18
18
  "prepack": "node scripts/bundle-assets.mjs",
19
19
  "postpack": "node scripts/bundle-assets.mjs --clean",
20
20
  "bundle-assets": "node scripts/bundle-assets.mjs",
21
- "test": "node --test \"test/*.mjs\"",
21
+ "test": "node --test \"test/*.mjs\" && node --test \"test/serial/*.mjs\"",
22
22
  "smoke": "./scripts/prepublish-smoke.sh",
23
23
  "test:unit": "node --test test/config.test.mjs test/validate.test.mjs test/clients.test.mjs",
24
24
  "test:integration": "RUN_INSTALLER_INTEGRATION=1 node --test test/installer.test.mjs",
25
25
  "test:integration:cloud": "RUN_INSTALLER_INTEGRATION=1 node --test test/installer.test.mjs"
26
26
  },
27
27
  "engines": {
28
- "node": ">=18.0.0"
28
+ "node": ">=20.18.1"
29
29
  },
30
30
  "keywords": [
31
31
  "meko",
@@ -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"),