@yugabytedb/meko-mcp 1.0.8 → 1.2.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 (51) hide show
  1. package/README.md +242 -44
  2. package/assets/hooks-handlers/before-submit-prompt.sh +44 -0
  3. package/assets/hooks-handlers/cursor-session-start.sh +42 -0
  4. package/assets/hooks-handlers/lib/capture.js +457 -107
  5. package/assets/hooks-handlers/lib/package.json +1 -0
  6. package/assets/hooks-handlers/test_single_conversation.sh +1 -1
  7. package/assets/scripts/package-desktop-skill.sh +36 -0
  8. package/assets/skills/meko-mcp-tools/SKILL.md +167 -53
  9. package/assets/skills/meko-mcp-tools/references/_sections.md +1 -1
  10. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +84 -22
  11. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +89 -56
  12. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +15 -7
  13. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +17 -9
  14. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +94 -5
  15. package/assets/skills/meko-mcp-tools/references/tools-overview.md +52 -37
  16. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +108 -37
  17. package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +6 -10
  18. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +30 -18
  19. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +55 -69
  20. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +251 -0
  21. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +150 -0
  22. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +24 -0
  23. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +99 -0
  24. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +406 -0
  25. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +69 -0
  26. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +217 -0
  27. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +112 -0
  28. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +134 -0
  29. package/assets/skills/meko-mcp-tools-desktop/references/tools-scope-permissions.md +75 -0
  30. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +155 -0
  31. package/bin/create-meko-setup.mjs +151 -22
  32. package/lib/clients/claude-code.mjs +378 -68
  33. package/lib/clients/claude-desktop.mjs +45 -8
  34. package/lib/clients/codex.mjs +1209 -0
  35. package/lib/clients/common.mjs +223 -1
  36. package/lib/clients/cursor.mjs +118 -13
  37. package/lib/clients/index.mjs +2 -0
  38. package/lib/config.mjs +56 -0
  39. package/lib/detect.mjs +52 -44
  40. package/lib/installer.mjs +11 -4
  41. package/lib/mcp-transport.mjs +61 -2
  42. package/lib/migrate/adapters/memory-md.mjs +13 -6
  43. package/lib/migrate/datapack.mjs +167 -0
  44. package/lib/migrate/id.mjs +127 -9
  45. package/lib/migrate/orchestrator.mjs +68 -10
  46. package/lib/migrate.mjs +346 -3
  47. package/lib/prompt.mjs +22 -4
  48. package/lib/validate.mjs +313 -9
  49. package/lib/version-check.mjs +178 -0
  50. package/package.json +2 -1
  51. package/scripts/bundle-assets.mjs +8 -0
@@ -14,6 +14,7 @@ import * as lockfile from "./lockfile.mjs";
14
14
  import { McpClient } from "./mcp-client.mjs";
15
15
  import { createLogger, nullLogger } from "./logger.mjs";
16
16
  import { seedFor } from "./id.mjs";
17
+ import { resolveDestinationDatapack } from "./datapack.mjs";
17
18
  import * as claudeCodeAdapter from "./adapters/claude-code.mjs";
18
19
  import * as cursorAdapter from "./adapters/cursor.mjs";
19
20
  import * as memoryMdAdapter from "./adapters/memory-md.mjs";
@@ -34,6 +35,14 @@ import * as memoryMdAdapter from "./adapters/memory-md.mjs";
34
35
  * @param {boolean} [opts.lockAlreadyHeld] Caller (e.g. detached child) says the lock is already owned by this process; skip acquire.
35
36
  * @param {number} [opts.concurrency]
36
37
  * @param {string} [opts.home]
38
+ * @param {string|null} [opts.datapack] Raw `--datapack` value (UUID or
39
+ * name). Resolved against the
40
+ * server before any writes; the
41
+ * resolved id is then injected
42
+ * into every tool call so the
43
+ * destination is explicit on the
44
+ * wire rather than relying on the
45
+ * server's tenant-context fallback.
37
46
  * @returns {Promise<{ok: boolean, summary: object, counts: object}>}
38
47
  */
39
48
  export async function runMigration(opts) {
@@ -160,6 +169,54 @@ export async function runMigration(opts) {
160
169
  return { ok: false, summary: { error: "mcp_connect_failed", detail: err.message }, counts: prog.counts };
161
170
  }
162
171
 
172
+ // Resolve --datapack against the live datapack_list so every subsequent
173
+ // tools/call carries an explicit datapack_id. Without this we'd be silently
174
+ // relying on the server's tenant-context default — fine when it's correct,
175
+ // invisible when it isn't.
176
+ let datapackId = null;
177
+ try {
178
+ const resolved = await resolveDestinationDatapack({
179
+ client,
180
+ requested: opts.datapack ?? null,
181
+ });
182
+ if (!resolved.ok) {
183
+ log.error("resolve_datapack_failed", { message: resolved.message });
184
+ releaseLock();
185
+ prog.status = "failed";
186
+ prog.finishedAt = new Date().toISOString();
187
+ writeProgress();
188
+ return {
189
+ ok: false,
190
+ summary: { error: "resolve_datapack_failed", detail: resolved.message },
191
+ counts: prog.counts,
192
+ };
193
+ }
194
+ datapackId = resolved.id ?? null;
195
+ log.info("migration_target", {
196
+ requested: opts.datapack ?? null,
197
+ resolvedId: resolved.id ?? null,
198
+ resolvedName: resolved.name ?? null,
199
+ source: resolved.source,
200
+ });
201
+ } catch (err) {
202
+ log.error("resolve_datapack_threw", { error: err.message });
203
+ releaseLock();
204
+ prog.status = "failed";
205
+ prog.finishedAt = new Date().toISOString();
206
+ writeProgress();
207
+ return {
208
+ ok: false,
209
+ summary: { error: "resolve_datapack_threw", detail: err.message },
210
+ counts: prog.counts,
211
+ };
212
+ }
213
+
214
+ // Injects datapack_id into every tools/call args object when set. When
215
+ // resolution fell into 'server-fallback' (no row matched), datapackId is
216
+ // null and we omit the key entirely so the server's own fallback chain
217
+ // still applies — the migration would otherwise fail before doing any work.
218
+ const withDp = (args) => (datapackId ? { ...args, datapack_id: datapackId } : args);
219
+
163
220
  // -- Conversations --
164
221
  for (const ref of [...claudeSessions, ...cursorSessions]) {
165
222
  let existing = state.getSession(st, ref.agentId, ref.sessionUuid);
@@ -197,7 +254,7 @@ export async function runMigration(opts) {
197
254
  // Ensure a conversation_id exists.
198
255
  let conversationId = existing?.conversationId || "";
199
256
  if (!conversationId) {
200
- const createRes = await safeCall(client, "conversation_create", {
257
+ const createRes = await safeCall(client, "conversation_create", withDp({
201
258
  scope: "write",
202
259
  agent_id: ref.agentId,
203
260
  session_id: ref.sessionUuid,
@@ -208,7 +265,7 @@ export async function runMigration(opts) {
208
265
  gitBranch: ref.slug ? (parsed.gitBranch || null) : null,
209
266
  startedAt: parsed.startedAt || null,
210
267
  }),
211
- }, log);
268
+ }), log);
212
269
 
213
270
  if (!createRes.ok) {
214
271
  log.error("conversation_create_failed", { agentId: ref.agentId, sessionUuid: ref.sessionUuid, error: createRes.error?.message });
@@ -245,7 +302,7 @@ export async function runMigration(opts) {
245
302
  const seed = seedFor(ref.agentId, ref.sessionUuid, ex.user_uuid);
246
303
 
247
304
  // (a) conversation_add_message — fire-and-forget; server batches.
248
- const addMsgPromise = client.callTool("conversation_add_message", {
305
+ const addMsgPromise = client.callTool("conversation_add_message", withDp({
249
306
  scope: "write",
250
307
  conversation_id: conversationId,
251
308
  agent_id: ref.agentId,
@@ -259,12 +316,12 @@ export async function runMigration(opts) {
259
316
  userUuid: ex.user_uuid,
260
317
  timestamp: ex.timestamp,
261
318
  }),
262
- }, { fireAndForget: true });
319
+ }), { fireAndForget: true });
263
320
 
264
321
  // (b) memory_add(messages=...) — invokes Mem0 extractor server-side.
265
322
  let memPromise = Promise.resolve(null);
266
323
  if (!opts.noExtract && (ex.input || ex.output)) {
267
- memPromise = client.callTool("memory_add", {
324
+ memPromise = client.callTool("memory_add", withDp({
268
325
  scope: "write",
269
326
  agent_id: ref.agentId,
270
327
  conversation_id: conversationId,
@@ -276,8 +333,9 @@ export async function runMigration(opts) {
276
333
  source: "migrate",
277
334
  sessionUuid: ref.sessionUuid,
278
335
  userUuid: ex.user_uuid,
336
+ cwd: ref.cwd || null,
279
337
  }),
280
- }, { fireAndForget: true });
338
+ }), { fireAndForget: true });
281
339
  }
282
340
 
283
341
  const [, memRes] = await Promise.all([addMsgPromise, memPromise]);
@@ -342,12 +400,12 @@ export async function runMigration(opts) {
342
400
  if (existingUmbrella) {
343
401
  convId = existingUmbrella;
344
402
  } else {
345
- const res = await safeCall(client, "conversation_create", {
403
+ const res = await safeCall(client, "conversation_create", withDp({
346
404
  scope: "write",
347
405
  agent_id: item.agentId,
348
406
  title: "memory-migration",
349
407
  metadata: JSON.stringify({ source: "migrate", purpose: "memory-umbrella" }),
350
- }, log);
408
+ }), log);
351
409
  if (!res.ok) {
352
410
  log.error("memory_umbrella_failed", { agentId: item.agentId, error: res.error?.message });
353
411
  prog.counts.warnings++;
@@ -359,13 +417,13 @@ export async function runMigration(opts) {
359
417
  umbrellaPerAgent.set(item.agentId, convId);
360
418
  }
361
419
 
362
- const res = await client.callTool("memory_add", {
420
+ const res = await client.callTool("memory_add", withDp({
363
421
  scope: "write",
364
422
  agent_id: item.agentId,
365
423
  conversation_id: convId,
366
424
  text: item.text,
367
425
  metadata: JSON.stringify(item.metadata),
368
- }, { fireAndForget: true });
426
+ }), { fireAndForget: true });
369
427
 
370
428
  if (res.ok && !isErrorText(res.text)) {
371
429
  state.markMemory(st, item.agentId, item.hash, {
package/lib/migrate.mjs CHANGED
@@ -17,17 +17,43 @@ import { createLogger } from "./migrate/logger.mjs";
17
17
  import { generateRunId, forkDetached, migrateRoot } from "./migrate/daemon.mjs";
18
18
  import * as lockfile from "./migrate/lockfile.mjs";
19
19
  import * as progress from "./migrate/progress.mjs";
20
+ import { datapackKeyFromUrl } from "./migrate/id.mjs";
21
+ import { resolveDestinationDatapack } from "./migrate/datapack.mjs";
22
+ import { McpClient } from "./migrate/mcp-client.mjs";
20
23
  import { selfInvocation } from "./clients/common.mjs";
24
+ import { askYesNo } from "./prompt.mjs";
21
25
 
22
26
  function home() {
23
27
  return process.env.HOME || "";
24
28
  }
25
29
 
30
+ /**
31
+ * Build on-disk paths for a migration run.
32
+ *
33
+ * The `statePath` is scoped by `datapackKey` so switching datapacks does not
34
+ * make the migrator falsely skip every session based on checkpoints from a
35
+ * different datapack. See issue #45 for the regression that motivated this.
36
+ *
37
+ * When `datapackKey` is omitted, `statePath` points at the legacy top-level
38
+ * `state.json` — only used for read paths that don't know the datapack yet
39
+ * (e.g. `readStatus` when run-id metadata doesn't record it). New writes
40
+ * should always supply a key.
41
+ *
42
+ * @param {object} [opts]
43
+ * @param {string} [opts.home]
44
+ * @param {string} [opts.datapackKey]
45
+ */
26
46
  function paths(opts = {}) {
27
47
  const root = migrateRoot({ home: opts.home ?? home() });
48
+ const stateDir = path.join(root, "state");
49
+ const statePath = opts.datapackKey
50
+ ? path.join(stateDir, `${opts.datapackKey}.json`)
51
+ : path.join(root, "state.json");
28
52
  return {
29
53
  root,
30
- statePath: path.join(root, "state.json"),
54
+ stateDir,
55
+ legacyStatePath: path.join(root, "state.json"),
56
+ statePath,
31
57
  lockPath: path.join(root, "lock"),
32
58
  runDir: (runId) => path.join(root, runId),
33
59
  logFile: (runId) => path.join(root, runId, "stdout.log"),
@@ -35,6 +61,108 @@ function paths(opts = {}) {
35
61
  };
36
62
  }
37
63
 
64
+ /**
65
+ * One-time migration of the legacy global `state.json` into the per-datapack
66
+ * `state/<key>.json` layout. Called on the first run after upgrade.
67
+ *
68
+ * We keep the legacy file on disk (don't delete it) so a rollback to an
69
+ * older installer keeps resume state visible there. Future runs ignore
70
+ * `state.json` entirely once `state/` is populated.
71
+ *
72
+ * Safe to call on every run — a no-op if there's nothing to migrate.
73
+ *
74
+ * @param {object} opts
75
+ * @param {ReturnType<typeof paths>} opts.P Already-constructed paths bundle
76
+ * whose `statePath` is the per-key
77
+ * destination.
78
+ * @param {string} opts.datapackKey
79
+ * @param {boolean} [opts.interactive] Whether to prompt; false on CI / --yes.
80
+ * @param {(entry: object) => void} [opts.onWarning] Hook for the orchestrator
81
+ * to log a structured `warnings++`
82
+ * entry. Not invoked on no-op.
83
+ * @returns {Promise<{migrated: boolean, from?: string, to?: string, declined?: boolean}>}
84
+ */
85
+ async function migrateLegacyStateIfNeeded(opts) {
86
+ const { P, datapackKey } = opts;
87
+ // We've already crossed this bridge iff `state/` holds any `.json` file.
88
+ // Plain emptiness isn't enough: a stray `.DS_Store` (macOS) or editor
89
+ // swap file would make `readdirSync().length === 0` return false and
90
+ // silently skip the one-time copy, stranding the user's pre-upgrade
91
+ // resume state under `state.json`.
92
+ let hasScopedState = false;
93
+ try {
94
+ hasScopedState = fs.readdirSync(P.stateDir, { withFileTypes: true })
95
+ .some((entry) => entry.isFile() && entry.name.endsWith(".json"));
96
+ } catch {
97
+ // ENOENT (dir doesn't exist yet) → trivially no scoped state.
98
+ }
99
+ if (hasScopedState) return { migrated: false };
100
+ if (!fs.existsSync(P.legacyStatePath)) return { migrated: false };
101
+
102
+ // Legacy file exists and state/ is empty → one-time copy.
103
+ if (opts.interactive) {
104
+ const accept = await askYesNo(
105
+ `Legacy global migrate state found. Treating it as belonging to the current datapack ${datapackKey}?`,
106
+ true,
107
+ );
108
+ if (!accept) {
109
+ // User declined: don't copy. Future runs will see state/ empty again
110
+ // and re-offer — but since state/<key>.json will likely be created by
111
+ // this very run, the prompt won't repeat.
112
+ return { migrated: false, declined: true };
113
+ }
114
+ }
115
+
116
+ fs.mkdirSync(P.stateDir, { recursive: true });
117
+ fs.copyFileSync(P.legacyStatePath, P.statePath);
118
+ if (typeof opts.onWarning === "function") {
119
+ opts.onWarning({
120
+ event: "legacy_state_migrated",
121
+ from: P.legacyStatePath,
122
+ to: P.statePath,
123
+ datapackKey,
124
+ });
125
+ }
126
+ return { migrated: true, from: P.legacyStatePath, to: P.statePath };
127
+ }
128
+
129
+ /**
130
+ * Choose the on-disk state key for a real run.
131
+ *
132
+ * Default (no --datapack) keeps the URL-derived key — that's what existing
133
+ * installs already wrote into. An explicit `--datapack` that resolved to a
134
+ * *non-default* id is scoped by `dp-<resolved-id>` so two runs against the
135
+ * same URL with different packs don't share resume state and silently skip
136
+ * work.
137
+ *
138
+ * If the explicit value resolved to the same datapack the default flow
139
+ * targets (i.e. the user typed the default's UUID or the literal
140
+ * `meko_default_datapack`), collapse back onto the URL key so the explicit
141
+ * and implicit forms share resume state and don't reprocess every session
142
+ * from a fresh file. Without this collapse, a user who runs the default
143
+ * migration and later re-runs with `--datapack <default-uuid>` would
144
+ * silently re-extract every memory.
145
+ *
146
+ * Falls back to the URL key when the resolver couldn't pin an id
147
+ * (server-fallback / no API-side default row).
148
+ *
149
+ * @param {object} opts
150
+ * @param {string} opts.urlDatapackKey
151
+ * @param {string|null} opts.requested Raw --datapack value (or null).
152
+ * @param {string|null} opts.resolvedId Resolver output (or null).
153
+ * @param {string|null} [opts.defaultId] meko_default_datapack id, when known.
154
+ * @returns {string}
155
+ */
156
+ export function stateKeyFor({ urlDatapackKey, requested, resolvedId, defaultId }) {
157
+ if (!requested || !resolvedId) return urlDatapackKey;
158
+ // Explicit request that resolves to the user's default datapack: stay on
159
+ // the URL-keyed file the implicit-default flow uses.
160
+ if (defaultId && resolvedId.toLowerCase() === defaultId.toLowerCase()) {
161
+ return urlDatapackKey;
162
+ }
163
+ return `dp-${resolvedId.toLowerCase()}`;
164
+ }
165
+
38
166
  function findLatestRunId(root) {
39
167
  try {
40
168
  const dirs = fs.readdirSync(root, { withFileTypes: true })
@@ -61,10 +189,29 @@ function findLatestRunId(root) {
61
189
  * @param {boolean} [opts.noExtract]
62
190
  * @param {boolean} [opts.force]
63
191
  * @param {number} [opts.rate]
192
+ * @param {boolean} [opts.nonInteractive] Set by callers that lack a TTY
193
+ * (e.g. --yes, CI). Skips prompts.
194
+ * @param {string|null} [opts.datapack] Raw `--datapack` value (UUID or
195
+ * name). Resolved against the server
196
+ * before the lock is taken so a typo
197
+ * fails fast.
64
198
  * @returns {Promise<{ok: boolean, runId?: string, message: string}>}
65
199
  */
66
200
  export async function startMigration(opts) {
67
- const P = paths();
201
+ // Real runs scope state by destination datapack so switching datapacks
202
+ // doesn't falsely flag every session as already-done. Two layers:
203
+ // - URL-derived key: stable enough for the default flow (cloud URLs
204
+ // already encode the default datapack UUID in their hostname; see
205
+ // issue #45 for the regression that motivated URL scoping).
206
+ // - When --datapack is explicit and resolves to a real id, scope by
207
+ // that id instead. Same URL + different --datapack values land in
208
+ // different state files, so an alt-pack run after a default-pack
209
+ // run doesn't silently skip every session marked done by the first.
210
+ // Until preflight runs we only have the URL-derived key; that's enough
211
+ // for the dry-run branch and for sizing P.root / P.lockPath, which
212
+ // don't move when the state key changes.
213
+ const urlDatapackKey = datapackKeyFromUrl(opts.url);
214
+ let P = paths({ datapackKey: urlDatapackKey });
68
215
  fs.mkdirSync(P.root, { recursive: true });
69
216
 
70
217
  // Dry-run is a local-only planning pass: no HTTP, no lock, no fork.
@@ -87,12 +234,19 @@ export async function startMigration(opts) {
87
234
  });
88
235
  const s = summary.summary;
89
236
  const planned = s.plannedCalls ?? {};
237
+ // Dry-run never opens an MCP session, so we can't resolve names→UUIDs
238
+ // here. Echo what the user asked for; the real run will resolve and
239
+ // print the canonical destination before doing any work.
240
+ const destLine = opts.datapack
241
+ ? ` Destination datapack: ${opts.datapack} (will be resolved at run time)\n`
242
+ : " Destination datapack: server default (will be resolved at run time)\n";
90
243
  return {
91
244
  ok: summary.ok,
92
245
  runId,
93
246
  message:
94
247
  (opts.url ? "" : "(no --url provided — planning only)\n") +
95
248
  `Dry run:\n` +
249
+ destLine +
96
250
  ` Claude Code sessions: ${s.claudeSessions}\n` +
97
251
  ` Cursor sessions: ${s.cursorSessions}\n` +
98
252
  ` Paired user↔asst turns: ${s.pairedTurns}\n` +
@@ -114,6 +268,49 @@ export async function startMigration(opts) {
114
268
  return { ok: false, message: "--api-key (or MEKO_API_KEY) is required for cloud --migrate." };
115
269
  }
116
270
 
271
+ // Pre-flight: resolve the destination datapack *before* taking the lock or
272
+ // forking the child. A typo in --datapack should fail fast with a useful
273
+ // error, not after the child has spun up. We open a short-lived MCP client
274
+ // here purely for the datapack_list call; the child opens its own session.
275
+ // The resolved id isn't persisted into passthrough — the child re-resolves,
276
+ // which costs one extra cheap read but keeps the wire-level routing
277
+ // logic in a single place.
278
+ const preflight = await preflightDatapack({
279
+ url: opts.url,
280
+ apiKey: opts.apiKey,
281
+ requested: opts.datapack ?? null,
282
+ });
283
+ if (!preflight.ok) {
284
+ return { ok: false, message: preflight.message };
285
+ }
286
+ const datapackBanner = formatDatapackBanner(preflight, opts.datapack ?? null);
287
+
288
+ // Now that we know the resolved destination, pick the state key. An
289
+ // explicit --datapack that resolved to an id wins so two runs against
290
+ // the same URL but different packs get separate resume state files.
291
+ // Default flows (no --datapack) keep the URL-derived key for back-compat
292
+ // with checkpoints written before this PR.
293
+ const stateKey = stateKeyFor({
294
+ urlDatapackKey,
295
+ requested: opts.datapack ?? null,
296
+ resolvedId: preflight.id ?? null,
297
+ defaultId: preflight.defaultId ?? null,
298
+ });
299
+ P = paths({ datapackKey: stateKey });
300
+
301
+ // Back-compat for the legacy global `state.json`. This prompt has to run
302
+ // in the foreground wrapper because the detached child has no TTY. The
303
+ // non-interactive branch accepts silently, matching the behavior of
304
+ // `runChildMigration` (which is the path exercised by already-started
305
+ // runs with pre-existing state/).
306
+ try {
307
+ await migrateLegacyStateIfNeeded({
308
+ P,
309
+ datapackKey: stateKey,
310
+ interactive: !opts.nonInteractive && Boolean(process.stdin.isTTY),
311
+ });
312
+ } catch { /* best effort; child path will retry non-interactively */ }
313
+
117
314
  // Reserve the lock slot in the foreground so a concurrent --migrate fails
118
315
  // fast. We use a two-step dance: first, O_EXCL-create the lock with the
119
316
  // foreground PID (just to claim the slot), then — after spawning the
@@ -140,6 +337,17 @@ export async function startMigration(opts) {
140
337
  runId,
141
338
  scope: opts.scope,
142
339
  url: opts.url,
340
+ // urlDatapackKey records the key derived from the URL alone (issue
341
+ // #45 layout); stateKey is the actual on-disk file scope for this
342
+ // run, which may differ when --datapack is explicit. The child
343
+ // reads stateKey out of this file to land on the same path.
344
+ urlDatapackKey,
345
+ stateKey,
346
+ datapackKey: stateKey, // back-compat alias for older readers
347
+ datapackRequested: opts.datapack ?? null,
348
+ datapackResolvedId: preflight.id ?? null,
349
+ datapackResolvedName: preflight.name ?? null,
350
+ datapackSource: preflight.source,
143
351
  apiKeyPresent: !!opts.apiKey,
144
352
  projectRoot: opts.projectRoot,
145
353
  noExtract: !!opts.noExtract,
@@ -156,6 +364,7 @@ export async function startMigration(opts) {
156
364
  if (opts.noExtract) passthrough.push("--no-extract");
157
365
  if (opts.force) passthrough.push("--force");
158
366
  if (opts.rate) passthrough.push("--rate", String(opts.rate));
367
+ if (opts.datapack) passthrough.push("--datapack", String(opts.datapack));
159
368
 
160
369
  const env = { ...process.env };
161
370
  if (opts.apiKey) env.MEKO_API_KEY = opts.apiKey;
@@ -182,12 +391,70 @@ export async function startMigration(opts) {
182
391
  ok: true,
183
392
  runId,
184
393
  message:
394
+ `${datapackBanner}\n` +
185
395
  `Migration started. run-id=${runId}, pid=${pid}\n` +
186
396
  `Logs: ${P.logFile(runId)}\n` +
187
397
  `Status: ${selfInvocation()} --migrate-status`,
188
398
  };
189
399
  }
190
400
 
401
+ /**
402
+ * Open a short-lived MCP client and resolve --datapack before the lock is
403
+ * taken. Surfacing this synchronously means a typo in --datapack fails with
404
+ * a clear, actionable message before the user ever sees a `run-id`.
405
+ *
406
+ * @param {object} opts
407
+ * @param {string} opts.url
408
+ * @param {string|null} opts.apiKey
409
+ * @param {string|null} opts.requested
410
+ * @returns {Promise<{ok:boolean, id?:string|null, name?:string|null, source?:string, message?:string}>}
411
+ */
412
+ async function preflightDatapack({ url, apiKey, requested }) {
413
+ const client = new McpClient({ url, apiKey, concurrency: 1 });
414
+ try {
415
+ await client.connect();
416
+ } catch (err) {
417
+ return {
418
+ ok: false,
419
+ message:
420
+ `Could not connect to MCP server to resolve --datapack: ${err.message}.`,
421
+ };
422
+ }
423
+ const resolved = await resolveDestinationDatapack({ client, requested });
424
+ if (!resolved.ok) {
425
+ return { ok: false, message: resolved.message };
426
+ }
427
+ return {
428
+ ok: true,
429
+ id: resolved.id ?? null,
430
+ name: resolved.name ?? null,
431
+ source: resolved.source,
432
+ defaultId: resolved.defaultId ?? null,
433
+ };
434
+ }
435
+
436
+ /**
437
+ * Format the one-line "Migrating to ..." banner printed before the child
438
+ * forks. Distinguishes the four cases so a misconfigured tenant (no default
439
+ * row) is visible rather than silent.
440
+ */
441
+ function formatDatapackBanner(preflight, requested) {
442
+ const id = preflight.id ?? null;
443
+ const name = preflight.name ?? null;
444
+ if (preflight.source === "default") {
445
+ return `Migrating to default datapack: ${name} (${id})`;
446
+ }
447
+ if (preflight.source === "server-fallback") {
448
+ return (
449
+ `Migrating to default datapack (server-resolved at runtime — no ` +
450
+ `'meko_default_datapack' row visible to this API key).`
451
+ );
452
+ }
453
+ // flag-id or flag-name
454
+ const label = name ?? requested ?? "(unknown)";
455
+ return `Migrating to datapack: ${label} (${id ?? "unknown id"}) [explicit]`;
456
+ }
457
+
191
458
  /**
192
459
  * Invoked by the detached child via bin/create-meko-setup.mjs.
193
460
  *
@@ -204,15 +471,53 @@ export async function startMigration(opts) {
204
471
  * @param {boolean} [opts.noExtract]
205
472
  * @param {boolean} [opts.force]
206
473
  * @param {number} [opts.rate]
474
+ * @param {string|null} [opts.datapack] Forwarded `--datapack` value.
207
475
  * @returns {Promise<number>} Exit code.
208
476
  */
209
477
  export async function runChildMigration(opts) {
210
- const P = paths();
478
+ // The foreground wrapper picked the state key — possibly differently from
479
+ // the URL-only derivation when --datapack overrode the destination — and
480
+ // recorded it in cmd.json. Read that back so child writes land in the
481
+ // same state file the foreground would have used. The cmd.json runDir
482
+ // doesn't depend on the state key so we can locate it from the URL key.
483
+ const urlDatapackKey = datapackKeyFromUrl(opts.url);
484
+ const cmdRunDir = paths({ datapackKey: urlDatapackKey }).runDir(opts.runId);
485
+ let stateKey = urlDatapackKey;
486
+ try {
487
+ const cmd = JSON.parse(fs.readFileSync(path.join(cmdRunDir, "cmd.json"), "utf8"));
488
+ if (cmd && typeof cmd.stateKey === "string" && cmd.stateKey) {
489
+ stateKey = cmd.stateKey;
490
+ }
491
+ } catch { /* no cmd.json (test entry / older foreground) → URL key. */ }
492
+
493
+ const P = paths({ datapackKey: stateKey });
211
494
  const runDir = P.runDir(opts.runId);
212
495
  fs.mkdirSync(runDir, { recursive: true });
496
+ fs.mkdirSync(P.stateDir, { recursive: true });
213
497
 
214
498
  const log = createLogger(path.join(runDir, "log.ndjson"), { runId: opts.runId });
215
499
 
500
+ // Back-compat: if `state/` is empty but the legacy `state.json` exists,
501
+ // copy it into `state/<datapackKey>.json` so this run resumes off the
502
+ // same checkpoints. CI / --yes path accepts silently; the detached child
503
+ // has no TTY anyway, so we always take the non-interactive branch here.
504
+ // (Interactive prompting, when introduced, happens in startMigration().)
505
+ try {
506
+ // The NDJSON `legacy_state_migrated` entry emitted via onWarning is the
507
+ // audit trail; we intentionally don't bump prog.counts.warnings here
508
+ // because the orchestrator owns that counter.
509
+ await migrateLegacyStateIfNeeded({
510
+ P,
511
+ datapackKey: stateKey,
512
+ interactive: false,
513
+ onWarning: (entry) => {
514
+ log.warn("legacy_state_migrated", entry);
515
+ },
516
+ });
517
+ } catch (err) {
518
+ log.warn("legacy_state_migrate_failed", { error: err.message });
519
+ }
520
+
216
521
  let shuttingDown = false;
217
522
  const onSignal = (sig) => {
218
523
  if (shuttingDown) return;
@@ -239,6 +544,7 @@ export async function runChildMigration(opts) {
239
544
  force: opts.force,
240
545
  lockAlreadyHeld: true,
241
546
  concurrency: opts.rate,
547
+ datapack: opts.datapack ?? null,
242
548
  });
243
549
  log.info("child_done", result.summary);
244
550
  log.close();
@@ -269,6 +575,41 @@ export function readStatus(opts = {}) {
269
575
  if (!prog) {
270
576
  return { ok: false, runId, progress: null, message: `No progress file for run-id=${runId}.` };
271
577
  }
578
+
579
+ // Fish the datapack key out of cmd.json if the run recorded one. Older runs
580
+ // (from before per-datapack scoping) don't have this field, in which case
581
+ // we print `(legacy)` to flag that its resume state is under the global
582
+ // `state.json`, not `state/<key>.json`.
583
+ let datapackKeyLabel = "(legacy)";
584
+ // The destination datapack (where the migration writes) is independent of
585
+ // the resume-state key (where checkpoints are stored on disk). Older runs
586
+ // didn't record this; show "-" for them.
587
+ let datapackTargetLabel = "-";
588
+ try {
589
+ const cmd = JSON.parse(
590
+ fs.readFileSync(path.join(P.runDir(runId), "cmd.json"), "utf8"),
591
+ );
592
+ if (cmd && typeof cmd.datapackKey === "string" && cmd.datapackKey) {
593
+ datapackKeyLabel = cmd.datapackKey;
594
+ }
595
+ if (cmd) {
596
+ const id = cmd.datapackResolvedId ?? null;
597
+ const name = cmd.datapackResolvedName ?? null;
598
+ const requested = cmd.datapackRequested ?? null;
599
+ const source = cmd.datapackSource ?? null;
600
+ if (source === "server-fallback") {
601
+ datapackTargetLabel = "(server default)";
602
+ } else if (id || name) {
603
+ const tag = source === "default" ? "default" : "explicit";
604
+ datapackTargetLabel = `${name ?? "(unknown)"} (${id ?? "?"}) [${tag}]`;
605
+ } else if (requested) {
606
+ datapackTargetLabel = `${requested} (unresolved)`;
607
+ }
608
+ }
609
+ } catch {
610
+ /* cmd.json missing or unreadable — fall back to legacy label */
611
+ }
612
+
272
613
  // Gray annotations next to each field. Keeps the output self-documenting
273
614
  // without forcing the reader to cross-reference README docs for common
274
615
  // fields. Respects NO_COLOR.
@@ -281,6 +622,8 @@ export function readStatus(opts = {}) {
281
622
  `run-id: ${prog.runId}`,
282
623
  `status: ${prog.status}${note("(pending → running → done | failed | stopped)")}`,
283
624
  `scope: ${prog.scope}`,
625
+ `datapack: ${datapackKeyLabel}${note("(resume state lives under state/<key>.json)")}`,
626
+ `target pack: ${datapackTargetLabel}${note("(ingestion destination resolved at run start)")}`,
284
627
  `started: ${prog.startedAt}`,
285
628
  `finished: ${prog.finishedAt ?? "(in progress)"}`,
286
629
  `current: ${prog.currentAgent ?? "-"} / ${prog.currentSession ?? "-"}${note("(<adapter>:<source-session> / <message-id>)")}`,
package/lib/prompt.mjs CHANGED
@@ -173,6 +173,15 @@ export async function askSecret(questionText) {
173
173
  rl.close();
174
174
  rl = null;
175
175
  }
176
+ // `readline.createInterface` expects stdin in paused mode — it calls
177
+ // `input.resume()` itself when it attaches its `data` listener. Closing
178
+ // the previous readline above leaves stdin in the flowing state it was
179
+ // using, so the muted readline we're about to create reads the user's
180
+ // first keypress through a partially-attached listener chain. Pausing
181
+ // here resets the precondition. Without this, the *next* readline (the
182
+ // post-askSecret singleton recreated in getRL()) never fires `line` and
183
+ // the install hangs with `unsettled top-level await` (issue #81).
184
+ process.stdin.pause();
176
185
 
177
186
  // Print the prompt through the real stdout so the user sees it.
178
187
  process.stdout.write(` ${questionText} `);
@@ -190,16 +199,25 @@ export async function askSecret(questionText) {
190
199
  terminal: true,
191
200
  });
192
201
 
202
+ // Shared teardown: close the muted readline, re-pause stdin so the next
203
+ // `createInterface` (typically the post-askSecret singleton from getRL())
204
+ // starts from the same precondition Node readline expects, and emit our
205
+ // own newline since the muted output swallowed the one readline would
206
+ // normally print on submit.
207
+ const finish = () => {
208
+ secretRL.close();
209
+ process.stdin.pause();
210
+ process.stdout.write("\n");
211
+ };
212
+
193
213
  return new Promise((resolve, reject) => {
194
214
  secretRL.once("SIGINT", () => {
195
- secretRL.close();
196
- process.stdout.write("\n");
215
+ finish();
197
216
  reject(new Error("User cancelled"));
198
217
  });
199
218
 
200
219
  secretRL.question("", (answer) => {
201
- secretRL.close();
202
- process.stdout.write("\n");
220
+ finish();
203
221
  resolve(answer);
204
222
  });
205
223
  });