@yugabytedb/meko-mcp 2.2.0 → 2.3.0

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