@yugabytedb/meko-mcp 2.2.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +168 -61
  2. package/assets/hooks-handlers/lib/capture-cleanup-cli.js +2134 -0
  3. package/assets/hooks-handlers/lib/capture-notices.js +224 -14
  4. package/assets/hooks-handlers/lib/capture-state.js +206 -1
  5. package/assets/hooks-handlers/lib/capture-status-cli.js +6 -2
  6. package/assets/hooks-handlers/lib/capture.js +1364 -139
  7. package/assets/hooks-handlers/lib/checkpoint-timer.js +445 -18
  8. package/assets/hooks-handlers/lib/journal-bridge.js +218 -0
  9. package/assets/hooks-handlers/lib/kiro-handler.js +26 -4
  10. package/assets/hooks-handlers/lib/opencode-handler.js +643 -0
  11. package/assets/hooks-handlers/lib/opencode-normalizer.js +145 -0
  12. package/assets/hooks-handlers/lib/terminal-handoff.js +10 -1
  13. package/assets/opencode-plugin/meko.js +213 -0
  14. package/assets/scripts/meko-statusline.sh +102 -19
  15. package/assets/skills/meko-mcp-tools/SKILL.md +59 -16
  16. package/assets/skills/meko-mcp-tools/references/_sections.md +2 -2
  17. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +15 -7
  18. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +275 -19
  19. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +7 -4
  20. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +39 -11
  21. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +10 -6
  22. package/assets/skills/meko-mcp-tools/references/tools-overview.md +44 -29
  23. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +79 -14
  24. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +47 -1
  25. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +6 -39
  26. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +57 -21
  27. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +4 -4
  28. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
  29. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +8 -6
  30. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +284 -38
  31. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +32 -10
  32. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +16 -12
  33. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +46 -31
  34. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +79 -14
  35. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +69 -1
  36. package/assets/skills/meko-select-datapack-desktop/SKILL.md +93 -14
  37. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +44 -12
  38. package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +14 -10
  39. package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +17 -5
  40. package/bin/create-meko-setup.mjs +186 -30
  41. package/lib/autofix.mjs +100 -0
  42. package/lib/browser-login.mjs +495 -0
  43. package/lib/capture-cleanup.mjs +63 -0
  44. package/lib/clients/claude-code.mjs +1 -0
  45. package/lib/clients/codex.mjs +80 -35
  46. package/lib/clients/common.mjs +7 -0
  47. package/lib/clients/index.mjs +2 -0
  48. package/lib/clients/kiro.mjs +5 -5
  49. package/lib/clients/opencode.mjs +537 -0
  50. package/lib/config.mjs +68 -1
  51. package/lib/installer.mjs +18 -2
  52. package/lib/migrate/adapters/claude-code.mjs +11 -2
  53. package/lib/migrate/adapters/codex.mjs +9 -2
  54. package/lib/migrate/adapters/memory-md.mjs +27 -8
  55. package/lib/migrate/adapters/opencode.mjs +148 -0
  56. package/lib/migrate/claude-parser.mjs +153 -9
  57. package/lib/migrate/datapack.mjs +254 -16
  58. package/lib/migrate/id.mjs +195 -13
  59. package/lib/migrate/mcp-client.mjs +59 -1
  60. package/lib/migrate/opencode-normalizer.mjs +132 -0
  61. package/lib/migrate/orchestrator.mjs +294 -78
  62. package/lib/migrate/progress.mjs +239 -5
  63. package/lib/migrate/registry.mjs +72 -0
  64. package/lib/migrate.mjs +97 -9
  65. package/lib/validate.mjs +111 -22
  66. package/package.json +5 -5
  67. package/scripts/bundle-assets.mjs +4 -0
@@ -0,0 +1,643 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ const childProcess = require("child_process");
4
+ const crypto = require("crypto");
5
+ const fs = require("fs");
6
+ const path = require("path");
7
+ const captureState = require("./capture-state");
8
+ const journalBridge = require("./journal-bridge");
9
+ const DEFAULT_ENV_PATH = path.join(__dirname, "..", "..", "opencode-hook-env.json");
10
+ const EVENT_SUBDIR = "opencode-events";
11
+ const DEFAULT_RECOVERY_MAX_TIMERS = 16;
12
+ function captureLib() {
13
+ return require("./capture");
14
+ }
15
+ function hookEnvPath(filePath) {
16
+ return filePath || process.env.MEKO_OPENCODE_ENV_FILE || DEFAULT_ENV_PATH;
17
+ }
18
+ // Read the env file without touching process.env. The checkpoint timer calls
19
+ // this before each capture child so an edited URL or key takes effect.
20
+ // status is "loaded", "missing" (ENOENT), or "unreadable".
21
+ function readHookEnvironment(filePath) {
22
+ const target = hookEnvPath(filePath);
23
+ const set = {};
24
+ const unset = [];
25
+ let status = "loaded";
26
+ try {
27
+ const parsed = JSON.parse(fs.readFileSync(target, "utf8"));
28
+ for (const key of [
29
+ "MEKO_MCP_URL",
30
+ "MEKO_API_KEY",
31
+ "MEKO_AGENT_ID",
32
+ "MEKO_MCP_SERVER_NAME",
33
+ "MEKO_WATERMARK_DIR",
34
+ "MEKO_DATAPACK_ID",
35
+ "MEKO_DATAPACK_NAME",
36
+ ]) {
37
+ if (typeof parsed[key] === "string" && parsed[key] !== "") {
38
+ set[key] = parsed[key];
39
+ } else if (key === "MEKO_API_KEY" && parsed[key] === "") {
40
+ unset.push(key);
41
+ }
42
+ }
43
+ } catch (err) {
44
+ if (err && err.code === "ENOENT") {
45
+ status = "missing";
46
+ } else {
47
+ status = "unreadable";
48
+ process.stderr.write(`[meko-opencode] Could not read hook environment: ${err.message}\n`);
49
+ }
50
+ }
51
+ return { target, status, set, unset };
52
+ }
53
+ function loadHookEnvironment(filePath) {
54
+ const { target, set, unset } = readHookEnvironment(filePath);
55
+ Object.assign(process.env, set);
56
+ for (const key of unset) delete process.env[key];
57
+ process.env.MEKO_HOOK_CLIENT = "opencode";
58
+ return target;
59
+ }
60
+ function sha(value) {
61
+ return crypto.createHash("sha256").update(String(value)).digest("hex");
62
+ }
63
+ function safeSessionId(value) {
64
+ const raw = String(value || "").trim();
65
+ if (/^[A-Za-z0-9_.-]+$/.test(raw)) return raw;
66
+ const base = raw.replace(/[^A-Za-z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "") || "opencode";
67
+ return `${base.slice(0, 80)}-${sha(raw).slice(0, 12)}`;
68
+ }
69
+ function eventDir() {
70
+ return path.join(captureState.watermarkDir(), EVENT_SUBDIR);
71
+ }
72
+ function eventPaths(sessionId, dir) {
73
+ const id = safeSessionId(sessionId);
74
+ const d = dir || eventDir();
75
+ return {
76
+ journal: path.join(d, `${id}.jsonl`),
77
+ state: path.join(d, `${id}.state.json`),
78
+ lock: path.join(d, `${id}.lock`),
79
+ };
80
+ }
81
+ function userRecord(sessionId, cwd, exchange, observedAtMs) {
82
+ return {
83
+ type: "user",
84
+ uuid: exchange.userId,
85
+ source_message_id: exchange.userId,
86
+ session_id: sessionId,
87
+ cwd,
88
+ timestamp: exchange.timestamp,
89
+ message: { role: "user", content: [{ type: "text", text: exchange.input }] },
90
+ opencode: { source: "snapshot" },
91
+ meko: { observed_at_ms: observedAtMs },
92
+ };
93
+ }
94
+ function assistantRecord(sessionId, cwd, exchange, interrupted = false) {
95
+ const marker = captureLib().INTERRUPTED_ASSISTANT_OUTPUT;
96
+ const prior = typeof exchange.output === "string" ? exchange.output : "";
97
+ const text = interrupted
98
+ ? (prior && !prior.includes("assistant response was interrupted")
99
+ ? `${prior}\n\n${marker}`
100
+ : (prior || marker))
101
+ : exchange.output;
102
+ const assistantId = exchange.assistantId || `opencode-assistant-${sha(`${sessionId}:${exchange.userId}`).slice(0, 24)}`;
103
+ return {
104
+ type: "assistant",
105
+ uuid: assistantId,
106
+ source_message_id: assistantId,
107
+ session_id: sessionId,
108
+ cwd,
109
+ timestamp: exchange.timestamp,
110
+ message: { role: "assistant", content: [{ type: "text", text }] },
111
+ opencode: { source: "snapshot", interrupted },
112
+ };
113
+ }
114
+ // session.error holds a partial; idle completes it only if that assistant
115
+ // message is rewritten in place. Otherwise interrupt on a later user or settle.
116
+ const DEFAULT_SETTLE_MS = 30 * 60 * 1000;
117
+ function settleDeadlineMs() {
118
+ // Same-process test override. Other clients' timers keep 30 minutes.
119
+ const raw = Number(process.env.MEKO_OPENCODE_SETTLE_MS);
120
+ return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_SETTLE_MS;
121
+ }
122
+
123
+ function appendCanonicalExchanges(sessionId, cwd, exchanges, opts = {}) {
124
+ sessionId = safeSessionId(sessionId);
125
+ const fromError = Boolean(opts.fromErrorObservation);
126
+ const nowMs = Number.isFinite(opts.nowMs) ? Number(opts.nowMs) : Date.now();
127
+ const dir = opts.eventDir || eventDir();
128
+ const paths = eventPaths(sessionId, dir);
129
+ journalBridge.acquireEventLock(paths.lock, dir);
130
+ try {
131
+ let sessionCreated = false;
132
+ if (journalBridge.journalEmpty(paths.journal)) {
133
+ journalBridge.appendJournal(paths.journal, {
134
+ type: "opencode_session",
135
+ session_id: sessionId,
136
+ cwd,
137
+ timestamp: new Date(nowMs).toISOString(),
138
+ client: "opencode",
139
+ }, dir);
140
+ sessionCreated = true;
141
+ }
142
+ const state = journalBridge.readEventState(paths.state, paths.journal, sessionId, dir);
143
+ if (state.pending && !Number.isFinite(state.pending.first_observed_at_ms)) {
144
+ try {
145
+ state.pending.first_observed_at_ms = fs.statSync(paths.journal).mtimeMs;
146
+ } catch { /* leave null */ }
147
+ }
148
+ const seen = new Set(state.seen_source_ids || []);
149
+ let appended = 0;
150
+
151
+ const finalizeTentativeAsInterrupted = (timestamp) => {
152
+ if (!state.pending) return false;
153
+ const tentative = state.tentative_observation;
154
+ journalBridge.appendJournal(
155
+ paths.journal,
156
+ assistantRecord(
157
+ sessionId,
158
+ cwd,
159
+ {
160
+ userId: state.pending.user_uuid,
161
+ assistantId: tentative ? (tentative.assistant_id || null) : null,
162
+ output: tentative ? (tentative.assistant_text || "") : "",
163
+ timestamp:
164
+ timestamp ||
165
+ (tentative && tentative.observed_at) ||
166
+ new Date(nowMs).toISOString(),
167
+ },
168
+ true,
169
+ ),
170
+ dir,
171
+ );
172
+ if (tentative && tentative.assistant_id) seen.add(tentative.assistant_id);
173
+ state.pending = null;
174
+ state.tentative_observation = null;
175
+ return true;
176
+ };
177
+
178
+ const settleMs = settleDeadlineMs();
179
+ const pendingObservedAtMs =
180
+ state.pending && Number.isFinite(state.pending.first_observed_at_ms)
181
+ ? state.pending.first_observed_at_ms
182
+ : null;
183
+ const tentativeObservedAtMs =
184
+ state.tentative_observation &&
185
+ Number.isFinite(state.tentative_observation.observed_at_ms)
186
+ ? state.tentative_observation.observed_at_ms
187
+ : null;
188
+
189
+ // Pre-pass: finalize an aged pending turn before the rest of the batch.
190
+ const deadlineBase =
191
+ tentativeObservedAtMs == null ? pendingObservedAtMs
192
+ : pendingObservedAtMs == null ? tentativeObservedAtMs
193
+ : Math.min(tentativeObservedAtMs, pendingObservedAtMs);
194
+ const incomingCompletesPending = Boolean(
195
+ state.pending &&
196
+ exchanges.some((ex) => {
197
+ if (!ex || ex.userId !== state.pending.user_uuid || fromError) return false;
198
+ if (!(ex.output || ex.interrupted)) return false;
199
+ const t = state.tentative_observation;
200
+ if (
201
+ t &&
202
+ (ex.assistantId || null) === (t.assistant_id || null) &&
203
+ (ex.output || "") === (t.assistant_text || "")
204
+ ) {
205
+ return false;
206
+ }
207
+ return true;
208
+ }),
209
+ );
210
+ if (
211
+ state.pending &&
212
+ deadlineBase != null &&
213
+ nowMs - deadlineBase >= settleMs &&
214
+ !incomingCompletesPending
215
+ ) {
216
+ if (finalizeTentativeAsInterrupted()) appended += 1;
217
+ }
218
+
219
+ for (let i = 0; i < exchanges.length; i++) {
220
+ const exchange = exchanges[i];
221
+ if (!exchange?.userId) continue;
222
+ const userId = exchange.userId;
223
+ const hasAssistantContent = Boolean(exchange.output) || Boolean(exchange.interrupted);
224
+ const isPending = state.pending && state.pending.user_uuid === userId;
225
+ const errorHoldsAssistant = fromError && i === exchanges.length - 1;
226
+
227
+ if (!isPending && seen.has(userId)) continue;
228
+
229
+ if (isPending) {
230
+ if (!hasAssistantContent) {
231
+ if (!Number.isFinite(state.pending.first_observed_at_ms)) {
232
+ state.pending.first_observed_at_ms = nowMs;
233
+ }
234
+ continue;
235
+ }
236
+ if (errorHoldsAssistant) {
237
+ const priorObservedAtMs =
238
+ state.tentative_observation &&
239
+ Number.isFinite(state.tentative_observation.observed_at_ms)
240
+ ? state.tentative_observation.observed_at_ms
241
+ : Number.isFinite(state.pending.first_observed_at_ms)
242
+ ? state.pending.first_observed_at_ms
243
+ : nowMs;
244
+ state.tentative_observation = {
245
+ assistant_id: exchange.assistantId || null,
246
+ assistant_text: exchange.output || "",
247
+ observed_at: exchange.timestamp || new Date(nowMs).toISOString(),
248
+ observed_at_ms: priorObservedAtMs,
249
+ };
250
+ continue;
251
+ }
252
+ const t = state.tentative_observation;
253
+ if (
254
+ t &&
255
+ (exchange.assistantId || null) === (t.assistant_id || null) &&
256
+ (exchange.output || "") === (t.assistant_text || "")
257
+ ) {
258
+ continue;
259
+ }
260
+ journalBridge.appendJournal(
261
+ paths.journal,
262
+ assistantRecord(sessionId, cwd, exchange, Boolean(exchange.interrupted)),
263
+ dir,
264
+ );
265
+ if (exchange.assistantId) seen.add(exchange.assistantId);
266
+ state.pending = null;
267
+ state.tentative_observation = null;
268
+ appended += 1;
269
+ continue;
270
+ }
271
+
272
+ // Next user interrupts the prior pending (omitted, or held as a matching session.error partial).
273
+ if (state.pending && state.pending.user_uuid !== userId) {
274
+ finalizeTentativeAsInterrupted(exchange.timestamp);
275
+ }
276
+
277
+ journalBridge.appendJournal(paths.journal, userRecord(sessionId, cwd, exchange, nowMs), dir);
278
+ seen.add(userId);
279
+ state.turn_index += 1;
280
+ state.tentative_observation = null;
281
+ state.pending = {
282
+ user_uuid: userId,
283
+ source_message_id: userId,
284
+ turn_index: state.turn_index,
285
+ first_observed_at_ms: nowMs,
286
+ };
287
+ if (hasAssistantContent) {
288
+ if (errorHoldsAssistant) {
289
+ state.tentative_observation = {
290
+ assistant_id: exchange.assistantId || null,
291
+ assistant_text: exchange.output || "",
292
+ observed_at: exchange.timestamp || new Date(nowMs).toISOString(),
293
+ observed_at_ms: nowMs,
294
+ };
295
+ } else {
296
+ journalBridge.appendJournal(
297
+ paths.journal,
298
+ assistantRecord(sessionId, cwd, exchange, Boolean(exchange.interrupted)),
299
+ dir,
300
+ );
301
+ if (exchange.assistantId) seen.add(exchange.assistantId);
302
+ state.pending = null;
303
+ state.tentative_observation = null;
304
+ }
305
+ }
306
+ appended += 1;
307
+ }
308
+
309
+ state.seen_source_ids = [...seen];
310
+ state.updated_at = new Date(nowMs).toISOString();
311
+ journalBridge.atomicWriteJson(paths.state, state, dir);
312
+ return { paths, state, appended, sessionCreated };
313
+ } finally {
314
+ journalBridge.releaseEventLock(paths.lock);
315
+ }
316
+ }
317
+
318
+ function finalizeExpiredSettleForSession(sessionId, opts = {}) {
319
+ const dir = opts.eventDir || eventDir();
320
+ const journalPath = eventPaths(sessionId, dir).journal;
321
+ if (journalBridge.journalEmpty(journalPath)) return { appended: 0 };
322
+ let cwd = "";
323
+ try {
324
+ const rec = JSON.parse(fs.readFileSync(journalPath, "utf8").split("\n").find((l) => l.trim()));
325
+ if (rec && typeof rec.cwd === "string") cwd = rec.cwd;
326
+ } catch { /* keep "" */ }
327
+ return appendCanonicalExchanges(sessionId, cwd, [], opts);
328
+ }
329
+
330
+ // Peek without a lock. Rebuild if missing, corrupt, or the journal is newer.
331
+ function peekPendingObservation(sessionId, dir) {
332
+ const statePath = path.join(dir, `${sessionId}.state.json`);
333
+ const journalPath = path.join(dir, `${sessionId}.jsonl`);
334
+ let sidecar = null;
335
+ try {
336
+ sidecar = JSON.parse(fs.readFileSync(statePath, "utf8"));
337
+ } catch { /* sidecar lost or corrupt; fall through to journal */ }
338
+ if (sidecar && sidecar.schema_version === 1) {
339
+ try {
340
+ if (fs.statSync(journalPath).mtimeMs > fs.statSync(statePath).mtimeMs) {
341
+ sidecar = null;
342
+ }
343
+ } catch { /* missing journal; trust sidecar */ }
344
+ }
345
+ if (sidecar && sidecar.schema_version === 1) {
346
+ if (!sidecar.pending) return null;
347
+ const tentative = sidecar.tentative_observation;
348
+ const t = tentative && Number.isFinite(tentative.observed_at_ms)
349
+ ? tentative.observed_at_ms : null;
350
+ const p = Number.isFinite(sidecar.pending.first_observed_at_ms)
351
+ ? sidecar.pending.first_observed_at_ms : null;
352
+ if (t != null && p != null) return Math.min(t, p);
353
+ if (t != null) return t;
354
+ if (p != null) return p;
355
+ }
356
+ const rebuilt = journalBridge.rebuildEventState(journalPath, sessionId);
357
+ if (rebuilt.pending && Number.isFinite(rebuilt.pending.first_observed_at_ms)) {
358
+ return rebuilt.pending.first_observed_at_ms;
359
+ }
360
+ if (rebuilt.pending) {
361
+ try { return fs.statSync(journalPath).mtimeMs; } catch { return null; }
362
+ }
363
+ return null;
364
+ }
365
+
366
+ // Skip a current completed sidecar. Rebuild if missing, corrupt, or stale.
367
+ function sweepExpiredSettles(opts = {}) {
368
+ const dir = opts.eventDir || eventDir();
369
+ const nowMs = Number.isFinite(opts.nowMs) ? Number(opts.nowMs) : Date.now();
370
+ const deadline = settleDeadlineMs();
371
+ const ids = new Set();
372
+ try {
373
+ for (const file of fs.readdirSync(dir)) {
374
+ if (file.endsWith(".state.json")) {
375
+ ids.add(file.slice(0, -".state.json".length));
376
+ } else if (file.endsWith(".jsonl")) {
377
+ ids.add(file.slice(0, -".jsonl".length));
378
+ }
379
+ }
380
+ } catch { return { swept: 0, promoted: 0, finalized: 0 }; }
381
+ let finalized = 0;
382
+ let promoted = 0;
383
+ for (const sessionId of ids) {
384
+ const observedAtMs = peekPendingObservation(sessionId, dir);
385
+ if (observedAtMs == null) continue;
386
+ if (nowMs - observedAtMs < deadline) continue;
387
+ promoted += 1;
388
+ try {
389
+ const out = finalizeExpiredSettleForSession(sessionId, { ...opts, nowMs });
390
+ if (out && out.appended > 0) finalized += 1;
391
+ } catch (err) {
392
+ process.stderr.write(
393
+ `[meko-opencode] Could not finalize expired settle for ${sessionId}: ${err.message}\n`,
394
+ );
395
+ }
396
+ }
397
+ return { swept: ids.size, promoted, finalized };
398
+ }
399
+ function ensureCaptureIntent(sessionId, journalPath, cwd) {
400
+ const agentId = captureLib().deriveAgentId({
401
+ client: "opencode",
402
+ cwd,
403
+ envOverride: process.env.MEKO_AGENT_ID,
404
+ });
405
+ return captureState.writeSessionIntent(sessionId, {
406
+ client: "opencode",
407
+ agent_id: agentId,
408
+ agent_id_source: (process.env.MEKO_AGENT_ID || "").trim() ? "explicit" : "derived",
409
+ transcript_path: journalPath,
410
+ datapack_id: process.env.MEKO_DATAPACK_ID || null,
411
+ datapack_name: process.env.MEKO_DATAPACK_NAME || null,
412
+ pending_turn_policy: "resumable",
413
+ });
414
+ }
415
+ function launchCapture(mode, payload) {
416
+ const captureScript = path.join(__dirname, "capture.js");
417
+ const child = childProcess.spawn(process.execPath, [captureScript, mode], {
418
+ detached: true,
419
+ windowsHide: true,
420
+ stdio: ["pipe", "ignore", "ignore"],
421
+ env: { ...process.env, MEKO_HOOK_CLIENT: "opencode" },
422
+ });
423
+ child.stdin.on("error", () => {});
424
+ child.stdin.end(JSON.stringify(payload));
425
+ child.unref();
426
+ }
427
+ function launchRecoveryTimer(journalPath) {
428
+ const timerScript = path.join(__dirname, "checkpoint-timer.js");
429
+ const child = childProcess.spawn(process.execPath, [timerScript, journalPath], {
430
+ detached: true,
431
+ windowsHide: true,
432
+ stdio: "ignore",
433
+ env: {
434
+ ...process.env,
435
+ MEKO_HOOK_CLIENT: "opencode",
436
+ MEKO_OPENCODE_ENV_FILE: hookEnvPath(),
437
+ },
438
+ });
439
+ child.unref();
440
+ }
441
+ function stagingDir() {
442
+ return path.join(captureState.watermarkDir(), "opencode-snapshots");
443
+ }
444
+ function legacyStagingDir() {
445
+ return path.join(__dirname, "..", "..", "snapshots");
446
+ }
447
+ function listStagedSnapshotFiles(root) {
448
+ try {
449
+ return fs.readdirSync(root)
450
+ .filter((file) => file.endsWith(".json"))
451
+ .map((file) => {
452
+ try {
453
+ return {
454
+ file,
455
+ root,
456
+ mtimeMs: fs.statSync(path.join(root, file)).mtimeMs,
457
+ };
458
+ } catch {
459
+ return null;
460
+ }
461
+ })
462
+ .filter(Boolean);
463
+ } catch {
464
+ return [];
465
+ }
466
+ }
467
+ async function scanRecovery(options = {}) {
468
+ const snapshotsRoot = options.stagingDir || stagingDir();
469
+ const legacyRoot =
470
+ options.legacyStagingDir !== undefined
471
+ ? options.legacyStagingDir
472
+ : options.stagingDir
473
+ ? null
474
+ : legacyStagingDir();
475
+ const listed = listStagedSnapshotFiles(snapshotsRoot);
476
+ if (legacyRoot) listed.push(...listStagedSnapshotFiles(legacyRoot));
477
+ const snapshots = listed
478
+ .sort((a, b) => a.mtimeMs - b.mtimeMs || a.file.localeCompare(b.file) || a.root.localeCompare(b.root))
479
+ .map(({ file, root }) => ({ file, root }));
480
+ const replay = options.handleStagedRequest || handleStagedRequest;
481
+ for (const { file, root } of snapshots) {
482
+ const snapshotPath = path.join(root, file);
483
+ try {
484
+ await replay({ snapshotPath }, options);
485
+ } catch (err) {
486
+ process.stderr.write(
487
+ `[meko-opencode] Could not replay staged snapshot ${file}: ${err.message}\n`,
488
+ );
489
+ }
490
+ }
491
+ const dir = options.eventDir || eventDir();
492
+ sweepExpiredSettles({ eventDir: dir });
493
+ const startRecoveryTimer =
494
+ options.launchRecoveryTimer || launchRecoveryTimer;
495
+ let files;
496
+ try {
497
+ files = fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
498
+ } catch {
499
+ return;
500
+ }
501
+ const shouldRecover = options.shouldRecoverJournal || journalNeedsRecovery;
502
+ const maxTimers = Number.isInteger(options.maxRecoveryTimers)
503
+ ? Math.max(0, options.maxRecoveryTimers)
504
+ : DEFAULT_RECOVERY_MAX_TIMERS;
505
+ files = files
506
+ .map((file) => {
507
+ try { return { file, mtimeMs: fs.statSync(path.join(dir, file)).mtimeMs }; }
508
+ catch { return null; }
509
+ })
510
+ .filter(Boolean)
511
+ .filter(({ file }) => shouldRecover(path.join(dir, file)))
512
+ .sort((a, b) => a.mtimeMs - b.mtimeMs)
513
+ .slice(0, maxTimers);
514
+ for (const { file } of files) {
515
+ startRecoveryTimer(path.join(dir, file));
516
+ }
517
+ }
518
+ function journalNeedsRecovery(journalPath) {
519
+ const sessionId = path.basename(journalPath, ".jsonl");
520
+ const read = captureState.readState(sessionId);
521
+ return !read.ok || captureState.sessionHealthBucket(read) !== "healthy";
522
+ }
523
+ async function handleSnapshot(payload, options = {}) {
524
+ const normalize = options.normalize || require("./opencode-normalizer.js").normalizeOpenCodeSession;
525
+ // session.error is an observation. A pending user stays until a later
526
+ // snapshot completes it, a later user interrupts, or MEKO_OPENCODE_SETTLE_MS.
527
+ const normalized = normalize(payload.export || payload, {
528
+ emitPendingAsExchange: true,
529
+ });
530
+ const sessionId = safeSessionId(normalized.sessionId);
531
+ const journaled = appendCanonicalExchanges(
532
+ sessionId,
533
+ normalized.cwd,
534
+ normalized.exchanges,
535
+ { fromErrorObservation: Boolean(payload.terminalError) },
536
+ );
537
+ const intent = ensureCaptureIntent(
538
+ sessionId,
539
+ journaled.paths.journal,
540
+ normalized.cwd,
541
+ );
542
+ if (!intent.ok) {
543
+ return { ok: false, error: intent.error, journaled };
544
+ }
545
+ const checkpointPayload = {
546
+ session_id: sessionId,
547
+ transcript_path: journaled.paths.journal,
548
+ lifecycle: "active",
549
+ };
550
+ const startCapture = options.launchCapture || launchCapture;
551
+ const startRecoveryTimer = options.launchRecoveryTimer || launchRecoveryTimer;
552
+ if (journaled.sessionCreated || journaled.appended > 0) {
553
+ startCapture("checkpoint", checkpointPayload);
554
+ startRecoveryTimer(journaled.paths.journal);
555
+ }
556
+ return { ok: true, journaled, intent, normalized };
557
+ }
558
+ async function handleStagedRequest(request, options = {}) {
559
+ const snapshotPath =
560
+ typeof request?.snapshotPath === "string" ? request.snapshotPath : "";
561
+ let payload = request;
562
+ if (snapshotPath) {
563
+ let staged;
564
+ try {
565
+ staged = JSON.parse(fs.readFileSync(snapshotPath, "utf8"));
566
+ } catch (err) {
567
+ quarantineSnapshot(snapshotPath);
568
+ throw new Error(`invalid staged snapshot JSON: ${err.message}`);
569
+ }
570
+ const snapshot = staged?.export ?? staged;
571
+ if (!isValidStagedSnapshot(snapshot)) {
572
+ quarantineSnapshot(snapshotPath);
573
+ throw new Error("invalid staged snapshot shape");
574
+ }
575
+ payload = {
576
+ export: snapshot,
577
+ terminalError: staged?.export
578
+ ? Boolean(staged.terminalError)
579
+ : Boolean(request.terminalError),
580
+ };
581
+ }
582
+ const handler = options.handleSnapshot || handleSnapshot;
583
+ const result = await handler(payload, options);
584
+ if (snapshotPath && result?.ok === true) {
585
+ try { fs.unlinkSync(snapshotPath); } catch { /* already acknowledged */ }
586
+ }
587
+ return result;
588
+ }
589
+ function isValidStagedSnapshot(snapshot) {
590
+ if (!snapshot || typeof snapshot !== "object") return false;
591
+ if (!snapshot.info || typeof snapshot.info !== "object") return false;
592
+ const sessionId =
593
+ snapshot.info.id || snapshot.info.sessionID || snapshot.info.sessionId;
594
+ return (
595
+ typeof sessionId === "string" &&
596
+ sessionId.length > 0 &&
597
+ Array.isArray(snapshot.messages)
598
+ );
599
+ }
600
+ function quarantineSnapshot(snapshotPath) {
601
+ const quarantineDir = path.join(path.dirname(snapshotPath), "quarantine");
602
+ fs.mkdirSync(quarantineDir, { recursive: true, mode: 0o700 });
603
+ try { fs.chmodSync(quarantineDir, 0o700); } catch { /* best effort */ }
604
+ const target = path.join(
605
+ quarantineDir,
606
+ `${path.basename(snapshotPath)}.${Date.now()}.invalid`,
607
+ );
608
+ fs.renameSync(snapshotPath, target);
609
+ return target;
610
+ }
611
+ async function main() {
612
+ loadHookEnvironment();
613
+ if (process.argv.includes("--recovery-scan")) {
614
+ await scanRecovery();
615
+ return;
616
+ }
617
+ let request = {};
618
+ try {
619
+ request = JSON.parse(fs.readFileSync(0, "utf8") || "{}");
620
+ } catch (err) {
621
+ process.stderr.write(`[meko-opencode] Invalid handler input: ${err.message}\n`);
622
+ return;
623
+ }
624
+ await handleStagedRequest(request);
625
+ }
626
+ module.exports = {
627
+ readHookEnvironment,
628
+ loadHookEnvironment,
629
+ eventPaths,
630
+ appendCanonicalExchanges,
631
+ finalizeExpiredSettleForSession,
632
+ sweepExpiredSettles,
633
+ handleSnapshot,
634
+ handleStagedRequest,
635
+ scanRecovery,
636
+ launchCapture,
637
+ launchRecoveryTimer,
638
+ };
639
+ if (require.main === module) {
640
+ main().catch((err) => {
641
+ process.stderr.write(`[meko-opencode] Handler failed: ${err.message}\n`);
642
+ });
643
+ }