@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.
- package/README.md +168 -61
- package/assets/hooks-handlers/lib/capture-cleanup-cli.js +2134 -0
- package/assets/hooks-handlers/lib/capture-notices.js +224 -14
- package/assets/hooks-handlers/lib/capture-state.js +206 -1
- package/assets/hooks-handlers/lib/capture-status-cli.js +6 -2
- package/assets/hooks-handlers/lib/capture.js +1364 -139
- package/assets/hooks-handlers/lib/checkpoint-timer.js +445 -18
- package/assets/hooks-handlers/lib/journal-bridge.js +218 -0
- package/assets/hooks-handlers/lib/kiro-handler.js +26 -4
- package/assets/hooks-handlers/lib/opencode-handler.js +643 -0
- package/assets/hooks-handlers/lib/opencode-normalizer.js +145 -0
- package/assets/hooks-handlers/lib/terminal-handoff.js +10 -1
- package/assets/opencode-plugin/meko.js +213 -0
- package/assets/scripts/meko-statusline.sh +102 -19
- package/assets/skills/meko-mcp-tools/SKILL.md +59 -16
- package/assets/skills/meko-mcp-tools/references/_sections.md +2 -2
- package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +15 -7
- package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +275 -19
- package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +7 -4
- package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +39 -11
- package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +10 -6
- package/assets/skills/meko-mcp-tools/references/tools-overview.md +44 -29
- package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +79 -14
- package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +47 -1
- package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +6 -39
- package/assets/skills/meko-mcp-tools-desktop/SKILL.md +57 -21
- package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +4 -4
- package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
- package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +8 -6
- package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +284 -38
- package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +32 -10
- package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +16 -12
- package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +46 -31
- package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +79 -14
- package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +69 -1
- package/assets/skills/meko-select-datapack-desktop/SKILL.md +93 -14
- package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +44 -12
- package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +14 -10
- package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +17 -5
- package/bin/create-meko-setup.mjs +186 -30
- package/lib/autofix.mjs +100 -0
- package/lib/browser-login.mjs +495 -0
- package/lib/capture-cleanup.mjs +63 -0
- package/lib/clients/claude-code.mjs +1 -0
- package/lib/clients/codex.mjs +80 -35
- package/lib/clients/common.mjs +7 -0
- package/lib/clients/index.mjs +2 -0
- package/lib/clients/kiro.mjs +5 -5
- package/lib/clients/opencode.mjs +537 -0
- package/lib/config.mjs +68 -1
- package/lib/installer.mjs +18 -2
- package/lib/migrate/adapters/claude-code.mjs +11 -2
- package/lib/migrate/adapters/codex.mjs +9 -2
- package/lib/migrate/adapters/memory-md.mjs +27 -8
- package/lib/migrate/adapters/opencode.mjs +148 -0
- package/lib/migrate/claude-parser.mjs +153 -9
- package/lib/migrate/datapack.mjs +254 -16
- package/lib/migrate/id.mjs +195 -13
- package/lib/migrate/mcp-client.mjs +59 -1
- package/lib/migrate/opencode-normalizer.mjs +132 -0
- package/lib/migrate/orchestrator.mjs +294 -78
- package/lib/migrate/progress.mjs +239 -5
- package/lib/migrate/registry.mjs +72 -0
- package/lib/migrate.mjs +97 -9
- package/lib/validate.mjs +111 -22
- package/package.json +5 -5
- package/scripts/bundle-assets.mjs +4 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CJS mirror of installer/lib/migrate/opencode-normalizer.mjs for capture handler.
|
|
3
|
+
* Keep logic in sync — golden tests assert byte-equivalent exchanges.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
const SYNTHETIC_PART_TYPES = new Set(["compaction", "subtask", "continuation"]);
|
|
9
|
+
const EXCLUDED_PART_TYPES = new Set([
|
|
10
|
+
"tool", "tool-invocation", "tool-result", "tool_result", "reasoning",
|
|
11
|
+
"file", "attachment", "step", "snapshot", "patch",
|
|
12
|
+
]);
|
|
13
|
+
const INTERRUPTED_MARKER =
|
|
14
|
+
"[Meko capture: assistant response was interrupted before completion.]";
|
|
15
|
+
function normalizeOpenCodeSession(exportShape, opts = {}) {
|
|
16
|
+
const root = exportShape && typeof exportShape === "object" ? exportShape : {};
|
|
17
|
+
const info = root.info && typeof root.info === "object" ? root.info : {};
|
|
18
|
+
const rawMessages = Array.isArray(root.messages) ? root.messages : [];
|
|
19
|
+
const sessionId = firstString(info.id, info.sessionID, info.sessionId, root.sessionId) || "unknown";
|
|
20
|
+
const cwd = firstString(info.cwd, info.directory, info.projectPath, root.cwd) || "";
|
|
21
|
+
const parentSessionId = firstString(info.parentID, info.parentId, info.parentSessionId) || null;
|
|
22
|
+
const title = firstString(info.title, info.name) || sessionId;
|
|
23
|
+
const startedAt = firstTimestamp(info.time?.created, info.created, info.startedAt, info.time);
|
|
24
|
+
const visibleMessages = rawMessages.map(normalizeMessage).filter(Boolean).sort(compareMessageOrder);
|
|
25
|
+
const exchanges = [];
|
|
26
|
+
let pendingUser = null;
|
|
27
|
+
let firstUserText = "";
|
|
28
|
+
for (const msg of visibleMessages) {
|
|
29
|
+
if (msg.role === "user") {
|
|
30
|
+
if (pendingUser) exchanges.push(makeExchange(pendingUser, null, { forceInterrupted: true }));
|
|
31
|
+
pendingUser = msg;
|
|
32
|
+
if (!firstUserText && msg.text) firstUserText = msg.text.slice(0, 240);
|
|
33
|
+
} else if (msg.role === "assistant" && pendingUser) {
|
|
34
|
+
exchanges.push(makeExchange(pendingUser, msg, {}));
|
|
35
|
+
pendingUser = null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
let partial = Boolean(pendingUser);
|
|
39
|
+
if (pendingUser && opts.emitPendingAsExchange) {
|
|
40
|
+
// Live-capture mode. Emit the trailing pending user with no assistant
|
|
41
|
+
// output; the reconciler holds it until a later snapshot completes it,
|
|
42
|
+
// a later user interrupts it, or MEKO_OPENCODE_SETTLE_MS expires.
|
|
43
|
+
exchanges.push(makeExchange(pendingUser, null, {}));
|
|
44
|
+
pendingUser = null;
|
|
45
|
+
partial = false;
|
|
46
|
+
} else if (pendingUser && (opts.terminalError || opts.interrupted)) {
|
|
47
|
+
exchanges.push(makeExchange(pendingUser, null, { ...opts, interrupted: true }));
|
|
48
|
+
pendingUser = null;
|
|
49
|
+
partial = false;
|
|
50
|
+
} else if (!pendingUser && (opts.terminalError || opts.interrupted) && exchanges.length) {
|
|
51
|
+
markInterrupted(exchanges[exchanges.length - 1]);
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
sessionId, cwd, parentSessionId, title,
|
|
55
|
+
startedAt: startedAt || visibleMessages[0]?.timestamp || null,
|
|
56
|
+
firstUserText,
|
|
57
|
+
exchanges: exchanges.filter((ex) => ex.input || ex.output),
|
|
58
|
+
partial,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function normalizeMessage(raw) {
|
|
62
|
+
if (!raw || typeof raw !== "object") return null;
|
|
63
|
+
const info = raw.info && typeof raw.info === "object" ? raw.info : raw;
|
|
64
|
+
const role = firstString(info.role, raw.role)?.toLowerCase();
|
|
65
|
+
if (role !== "user" && role !== "assistant") return null;
|
|
66
|
+
const synthetic = Boolean(
|
|
67
|
+
info.synthetic || info.compaction || info.subtask || info.continuation ||
|
|
68
|
+
SYNTHETIC_PART_TYPES.has(String(info.type || "").toLowerCase()),
|
|
69
|
+
);
|
|
70
|
+
if (synthetic) return null;
|
|
71
|
+
const parts = Array.isArray(raw.parts) ? raw.parts : [];
|
|
72
|
+
const text = joinVisibleText(parts);
|
|
73
|
+
if (!text && role === "assistant") return null;
|
|
74
|
+
return {
|
|
75
|
+
role,
|
|
76
|
+
id: firstString(info.id, raw.id, raw.uuid) || "",
|
|
77
|
+
timestamp: firstTimestamp(info.time?.created, info.createdAt, info.timestamp, raw.timestamp) || "",
|
|
78
|
+
text,
|
|
79
|
+
order: Number.isFinite(Number(info.order)) ? Number(info.order) : null,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function joinVisibleText(parts) {
|
|
83
|
+
const chunks = [];
|
|
84
|
+
for (const part of parts) {
|
|
85
|
+
if (!part || typeof part !== "object") continue;
|
|
86
|
+
if (part.synthetic || part.ignored) continue;
|
|
87
|
+
const type = String(part.type || "text").toLowerCase();
|
|
88
|
+
if (EXCLUDED_PART_TYPES.has(type) || SYNTHETIC_PART_TYPES.has(type)) continue;
|
|
89
|
+
if (type !== "text") continue;
|
|
90
|
+
const text = typeof part.text === "string" ? part.text : "";
|
|
91
|
+
if (!text.trim()) continue;
|
|
92
|
+
chunks.push(text);
|
|
93
|
+
}
|
|
94
|
+
return chunks.join("\n");
|
|
95
|
+
}
|
|
96
|
+
function compareMessageOrder(a, b) {
|
|
97
|
+
const ao = a.order; const bo = b.order;
|
|
98
|
+
if (ao != null && bo != null && ao !== bo) return ao - bo;
|
|
99
|
+
if (a.timestamp && b.timestamp && a.timestamp !== b.timestamp) {
|
|
100
|
+
return a.timestamp < b.timestamp ? -1 : 1;
|
|
101
|
+
}
|
|
102
|
+
return String(a.id).localeCompare(String(b.id));
|
|
103
|
+
}
|
|
104
|
+
function makeExchange(userMsg, assistantMsg, opts) {
|
|
105
|
+
const interrupted = Boolean(opts.interrupted || (!assistantMsg && opts.forceInterrupted));
|
|
106
|
+
return {
|
|
107
|
+
userId: userMsg.id || `opencode-user-${hashish(userMsg.text)}`,
|
|
108
|
+
assistantId: assistantMsg?.id || null,
|
|
109
|
+
input: userMsg.text,
|
|
110
|
+
output: interrupted ? INTERRUPTED_MARKER : (assistantMsg?.text || ""),
|
|
111
|
+
timestamp: assistantMsg?.timestamp || userMsg.timestamp || "",
|
|
112
|
+
interrupted,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function markInterrupted(exchange) {
|
|
116
|
+
exchange.interrupted = true;
|
|
117
|
+
exchange.output = exchange.output
|
|
118
|
+
? `${exchange.output}\n\n${INTERRUPTED_MARKER}`
|
|
119
|
+
: INTERRUPTED_MARKER;
|
|
120
|
+
}
|
|
121
|
+
function firstString(...values) {
|
|
122
|
+
for (const value of values) {
|
|
123
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
124
|
+
}
|
|
125
|
+
return "";
|
|
126
|
+
}
|
|
127
|
+
function firstTimestamp(...values) {
|
|
128
|
+
for (const value of values) {
|
|
129
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
130
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
131
|
+
const date = new Date(value);
|
|
132
|
+
if (!Number.isNaN(date.getTime())) return date.toISOString();
|
|
133
|
+
}
|
|
134
|
+
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString();
|
|
135
|
+
}
|
|
136
|
+
return "";
|
|
137
|
+
}
|
|
138
|
+
function hashish(text) {
|
|
139
|
+
let h = 0;
|
|
140
|
+
const s = String(text || "");
|
|
141
|
+
for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
|
142
|
+
return Math.abs(h).toString(36);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = { normalizeOpenCodeSession, INTERRUPTED_MARKER };
|
|
@@ -359,7 +359,16 @@ async function runWorker(jobFile) {
|
|
|
359
359
|
? Math.max(0, dueMs - Date.now())
|
|
360
360
|
: delayMs;
|
|
361
361
|
const remaining = Math.max(0, WORKER_MAX_AGE_MS - (Date.now() - started));
|
|
362
|
-
|
|
362
|
+
if (untilDue >= remaining) {
|
|
363
|
+
// The retry falls after this worker's age ceiling (for example a
|
|
364
|
+
// session parked by --capture-cleanup). Sleeping would only hold a
|
|
365
|
+
// process for the rest of the day; timer recovery owns the retry.
|
|
366
|
+
process.stderr.write(
|
|
367
|
+
`[meko-capture] SessionEnd worker: next retry ${result.next_retry_at} is past the worker ceiling; leaving outbox pending.\n`,
|
|
368
|
+
);
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
await sleep(untilDue);
|
|
363
372
|
delayMs = Math.min(delayMs * 2, 60_000);
|
|
364
373
|
continue;
|
|
365
374
|
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Managed by meko installer; reinstall rather than editing.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
const PLUGIN_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const MEKO_ROOT = path.join(PLUGIN_DIR, "..", "meko");
|
|
10
|
+
const ENV_PATH = path.join(MEKO_ROOT, "opencode-hook-env.json");
|
|
11
|
+
const HANDLER_PATH = path.join(MEKO_ROOT, "hooks-handlers", "lib", "opencode-handler.js");
|
|
12
|
+
const MANAGED_MARKER = "Managed by meko installer";
|
|
13
|
+
function readEnv() {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(fs.readFileSync(ENV_PATH, "utf8"));
|
|
16
|
+
} catch {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// MEKO-635: staged snapshots live under the durable Capture v2 watermark root
|
|
21
|
+
// (matches skills/hooks-handlers/lib/capture-state.js). This keeps them
|
|
22
|
+
// outside installer-owned runtime and lets uninstall preserve them.
|
|
23
|
+
function resolveStagingDir(env) {
|
|
24
|
+
const watermarkDir =
|
|
25
|
+
(typeof env.MEKO_WATERMARK_DIR === "string" && env.MEKO_WATERMARK_DIR) ||
|
|
26
|
+
path.join(os.homedir(), ".claude", "meko-capture");
|
|
27
|
+
return path.join(watermarkDir, "opencode-snapshots");
|
|
28
|
+
}
|
|
29
|
+
function safeSegment(value) {
|
|
30
|
+
return String(value || "unknown").replace(/[^A-Za-z0-9_.-]+/g, "_").slice(0, 120);
|
|
31
|
+
}
|
|
32
|
+
function sanitizeSnapshot(session, messages) {
|
|
33
|
+
return {
|
|
34
|
+
info: {
|
|
35
|
+
id: session?.id,
|
|
36
|
+
title: session?.title,
|
|
37
|
+
directory: session?.directory,
|
|
38
|
+
parentID: session?.parentID,
|
|
39
|
+
time: session?.time,
|
|
40
|
+
},
|
|
41
|
+
messages: (Array.isArray(messages) ? messages : []).map((message) => ({
|
|
42
|
+
info: {
|
|
43
|
+
id: message?.info?.id,
|
|
44
|
+
role: message?.info?.role,
|
|
45
|
+
type: message?.info?.type,
|
|
46
|
+
order: message?.info?.order,
|
|
47
|
+
synthetic: message?.info?.synthetic,
|
|
48
|
+
compaction: message?.info?.compaction,
|
|
49
|
+
subtask: message?.info?.subtask,
|
|
50
|
+
continuation: message?.info?.continuation,
|
|
51
|
+
time: message?.info?.time,
|
|
52
|
+
createdAt: message?.info?.createdAt,
|
|
53
|
+
timestamp: message?.info?.timestamp,
|
|
54
|
+
},
|
|
55
|
+
parts: (Array.isArray(message?.parts) ? message.parts : [])
|
|
56
|
+
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
57
|
+
.map((part) => ({
|
|
58
|
+
type: "text",
|
|
59
|
+
text: part.text,
|
|
60
|
+
synthetic: Boolean(part.synthetic),
|
|
61
|
+
ignored: Boolean(part.ignored),
|
|
62
|
+
})),
|
|
63
|
+
})),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function stageSnapshot(sessionId, snapshot, terminalError, stagingDir) {
|
|
67
|
+
fs.mkdirSync(stagingDir, { recursive: true, mode: 0o700 });
|
|
68
|
+
// MEKO-635: two events in the same PID and millisecond can race. The 8-byte
|
|
69
|
+
// random suffix makes both the target and the temp path collision-proof so a
|
|
70
|
+
// session.error and a session.idle firing back-to-back cannot overwrite each
|
|
71
|
+
// other (nor let a late pending snapshot clobber one the reconciler is
|
|
72
|
+
// about to read).
|
|
73
|
+
const unique = randomBytes(8).toString("hex");
|
|
74
|
+
const target = path.join(
|
|
75
|
+
stagingDir,
|
|
76
|
+
`${safeSegment(sessionId)}.${process.pid}.${Date.now()}.${unique}.json`,
|
|
77
|
+
);
|
|
78
|
+
const tmp = `${target}.tmp`;
|
|
79
|
+
fs.writeFileSync(
|
|
80
|
+
tmp,
|
|
81
|
+
JSON.stringify({
|
|
82
|
+
schema_version: 1,
|
|
83
|
+
terminalError: Boolean(terminalError),
|
|
84
|
+
export: snapshot,
|
|
85
|
+
}),
|
|
86
|
+
{ encoding: "utf8", mode: 0o600 },
|
|
87
|
+
);
|
|
88
|
+
fs.renameSync(tmp, target);
|
|
89
|
+
try { fs.chmodSync(target, 0o600); } catch { /* best effort */ }
|
|
90
|
+
return target;
|
|
91
|
+
}
|
|
92
|
+
function launchHandler(stagedPath, nodeExecutable, spawnProcess) {
|
|
93
|
+
if (!nodeExecutable) {
|
|
94
|
+
throw new Error("MEKO_NODE_EXECUTABLE is missing; reinstall Meko for OpenCode");
|
|
95
|
+
}
|
|
96
|
+
const child = spawnProcess(nodeExecutable, [HANDLER_PATH], {
|
|
97
|
+
detached: false,
|
|
98
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
99
|
+
env: {
|
|
100
|
+
...process.env,
|
|
101
|
+
MEKO_OPENCODE_ENV_FILE: ENV_PATH,
|
|
102
|
+
MEKO_HOOK_CLIENT: "opencode",
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
child.once?.("error", (err) => {
|
|
106
|
+
console.error(`[meko-opencode] handler launch failed: ${err.message}`);
|
|
107
|
+
});
|
|
108
|
+
child.stdin?.on?.("error", () => {});
|
|
109
|
+
child.stdin?.end?.(JSON.stringify({ snapshotPath: stagedPath }));
|
|
110
|
+
return new Promise((resolve) => {
|
|
111
|
+
if (!child || typeof child.once !== "function") {
|
|
112
|
+
resolve();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const done = () => resolve();
|
|
116
|
+
child.once("exit", done);
|
|
117
|
+
child.once("close", done);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
async function captureSession(
|
|
121
|
+
client,
|
|
122
|
+
sessionID,
|
|
123
|
+
terminalError,
|
|
124
|
+
nodeExecutable,
|
|
125
|
+
spawnProcess,
|
|
126
|
+
stagingDir,
|
|
127
|
+
) {
|
|
128
|
+
if (!sessionID) return;
|
|
129
|
+
let staged = "";
|
|
130
|
+
try {
|
|
131
|
+
const [sessionResponse, messagesResponse] = await Promise.all([
|
|
132
|
+
client.session.get({ path: { id: sessionID } }),
|
|
133
|
+
client.session.messages({ path: { id: sessionID } }),
|
|
134
|
+
]);
|
|
135
|
+
const session = sessionResponse?.data;
|
|
136
|
+
const messages = messagesResponse?.data;
|
|
137
|
+
if (!session || !Array.isArray(messages)) {
|
|
138
|
+
throw new Error("OpenCode SDK returned an incomplete session snapshot");
|
|
139
|
+
}
|
|
140
|
+
staged = stageSnapshot(
|
|
141
|
+
session.id || sessionID,
|
|
142
|
+
sanitizeSnapshot(session, messages),
|
|
143
|
+
terminalError,
|
|
144
|
+
stagingDir,
|
|
145
|
+
);
|
|
146
|
+
await launchHandler(staged, nodeExecutable, spawnProcess);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
console.error(`[meko-opencode] capture failed: ${err.message}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
export default async function MekoPlugin({ client }, options = {}) {
|
|
152
|
+
const runtimeEnv = readEnv();
|
|
153
|
+
const nodeExecutable = runtimeEnv.MEKO_NODE_EXECUTABLE;
|
|
154
|
+
const stagingDir = resolveStagingDir(runtimeEnv);
|
|
155
|
+
const spawnProcess =
|
|
156
|
+
typeof options.__mekoSpawn === "function" ? options.__mekoSpawn : spawn;
|
|
157
|
+
const sessionTails = new Map();
|
|
158
|
+
function enqueue(sessionID, task) {
|
|
159
|
+
const key = sessionID || "_";
|
|
160
|
+
const next = (sessionTails.get(key) || Promise.resolve()).then(task, task);
|
|
161
|
+
sessionTails.set(key, next);
|
|
162
|
+
return next;
|
|
163
|
+
}
|
|
164
|
+
if (process.env.MEKO_OPENCODE_DISABLE_RECOVERY !== "1") {
|
|
165
|
+
try {
|
|
166
|
+
if (!nodeExecutable) {
|
|
167
|
+
throw new Error("MEKO_NODE_EXECUTABLE is missing; reinstall Meko for OpenCode");
|
|
168
|
+
}
|
|
169
|
+
spawnProcess(nodeExecutable, [HANDLER_PATH, "--recovery-scan"], {
|
|
170
|
+
detached: true,
|
|
171
|
+
windowsHide: true,
|
|
172
|
+
stdio: "ignore",
|
|
173
|
+
env: {
|
|
174
|
+
...process.env,
|
|
175
|
+
MEKO_OPENCODE_ENV_FILE: ENV_PATH,
|
|
176
|
+
MEKO_HOOK_CLIENT: "opencode",
|
|
177
|
+
},
|
|
178
|
+
}).unref();
|
|
179
|
+
} catch (err) {
|
|
180
|
+
console.error(`[meko-opencode] recovery scan failed: ${err.message}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
async config(config) {
|
|
186
|
+
const env = readEnv();
|
|
187
|
+
const serverName = env.MEKO_MCP_SERVER_NAME || "meko";
|
|
188
|
+
if (!env.MEKO_MCP_URL) return;
|
|
189
|
+
const headers = {
|
|
190
|
+
"User-Agent": env.MEKO_MCP_USER_AGENT || "meko-mcp-installer/1.0",
|
|
191
|
+
};
|
|
192
|
+
if (env.MEKO_API_KEY) headers.Authorization = `Bearer ${env.MEKO_API_KEY}`;
|
|
193
|
+
config.mcp ??= {};
|
|
194
|
+
config.mcp[serverName] = {
|
|
195
|
+
type: "remote",
|
|
196
|
+
url: env.MEKO_MCP_URL,
|
|
197
|
+
enabled: true,
|
|
198
|
+
oauth: false,
|
|
199
|
+
headers,
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
async event({ event }) {
|
|
203
|
+
const sessionID = event.properties?.sessionID;
|
|
204
|
+
if (event?.type === "session.idle") {
|
|
205
|
+
await enqueue(sessionID, () =>
|
|
206
|
+
captureSession(client, sessionID, false, nodeExecutable, spawnProcess, stagingDir));
|
|
207
|
+
} else if (event?.type === "session.error") {
|
|
208
|
+
await enqueue(sessionID, () =>
|
|
209
|
+
captureSession(client, sessionID, true, nodeExecutable, spawnProcess, stagingDir));
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -46,12 +46,18 @@ fi
|
|
|
46
46
|
|
|
47
47
|
# --- Derive agent_id from cwd ---
|
|
48
48
|
#
|
|
49
|
-
# Mirrors deriveAgentId() in
|
|
50
|
-
#
|
|
49
|
+
# Mirrors deriveAgentId() + resolveRepoRoot() in
|
|
50
|
+
# skills/hooks-handlers/lib/capture.js:
|
|
51
|
+
# - walk parents of cwd for a '.git' entry (dir OR file, for worktrees and
|
|
52
|
+
# submodules); the walk stops at $HOME from below, so a git-tracked home
|
|
53
|
+
# does not claim every directory beneath it
|
|
54
|
+
# - basename of that repo root, lowercased
|
|
51
55
|
# - non-[a-z0-9-]+ → '-'
|
|
52
56
|
# - trim leading/trailing '-'
|
|
53
57
|
# - max 64 chars
|
|
54
58
|
# - prefix with 'claude_code:'
|
|
59
|
+
# - no repo root found → bare 'claude_code' (NOT 'claude_code:<leaf>'), so
|
|
60
|
+
# this segment reads the same pin file the hook writes
|
|
55
61
|
#
|
|
56
62
|
# Then mirrors datapackPinSlug() in capture.js:341-348:
|
|
57
63
|
# - non-[A-Za-z0-9._-]+ → '_'
|
|
@@ -74,19 +80,72 @@ if [[ -n "$STDIN_PAYLOAD" ]]; then
|
|
|
74
80
|
fi
|
|
75
81
|
fi
|
|
76
82
|
|
|
83
|
+
if [[ -n "${HOME:-}" ]]; then
|
|
84
|
+
USER_HOME="$(cd -L -- "$HOME" 2>/dev/null && pwd -L)"
|
|
85
|
+
else
|
|
86
|
+
USER_HOME="$(cd -L -- ~ 2>/dev/null && pwd -L)"
|
|
87
|
+
fi
|
|
88
|
+
|
|
77
89
|
# MEKO_AGENT_ID overrides the cwd-derived id, matching resolveSessionAgentId() in
|
|
78
90
|
# capture.js (the env override wins verbatim when non-empty).
|
|
79
91
|
if [[ -n "${MEKO_AGENT_ID:-}" ]]; then
|
|
80
92
|
AGENT_ID="$MEKO_AGENT_ID"
|
|
93
|
+
LEGACY_AGENT_ID="$MEKO_AGENT_ID"
|
|
81
94
|
else
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
95
|
+
# Walk up from $CWD for a '.git' entry. $HOME bounds the walk from below.
|
|
96
|
+
REPO_ROOT=""
|
|
97
|
+
# Normalize relative paths and '..' before the textual $HOME comparison,
|
|
98
|
+
# matching path.resolve() for live directories. Unlike JavaScript's lexical
|
|
99
|
+
# resolution, cd/pwd requires the directory to exist; statusline cwd does.
|
|
100
|
+
START_DIR="$(cd -L -- "$CWD" 2>/dev/null && pwd -L)"
|
|
101
|
+
if [[ -z "$START_DIR" ]]; then
|
|
102
|
+
START_DIR="${CWD%/}"
|
|
103
|
+
[[ -z "$START_DIR" ]] && START_DIR="$CWD"
|
|
104
|
+
fi
|
|
105
|
+
WALK_DIR="$START_DIR"
|
|
106
|
+
HOME_BOUND="$USER_HOME"
|
|
107
|
+
while [[ -n "$WALK_DIR" ]]; do
|
|
108
|
+
# A directory below a git-tracked home must not inherit home's .git.
|
|
109
|
+
if [[ -n "$HOME_BOUND" && "$WALK_DIR" == "$HOME_BOUND" && "$WALK_DIR" != "$START_DIR" ]]; then
|
|
110
|
+
break
|
|
111
|
+
fi
|
|
112
|
+
if [[ -e "$WALK_DIR/.git" ]]; then
|
|
113
|
+
REPO_ROOT="$WALK_DIR"
|
|
114
|
+
break
|
|
115
|
+
fi
|
|
116
|
+
# Check home itself for .git, then stop. A non-git home must not inherit
|
|
117
|
+
# repository state from /Users, /home, or /.
|
|
118
|
+
if [[ -n "$HOME_BOUND" && "$WALK_DIR" == "$HOME_BOUND" ]]; then
|
|
119
|
+
break
|
|
120
|
+
fi
|
|
121
|
+
PARENT="$(dirname "$WALK_DIR")"
|
|
122
|
+
[[ "$PARENT" == "$WALK_DIR" ]] && break
|
|
123
|
+
WALK_DIR="$PARENT"
|
|
124
|
+
done
|
|
85
125
|
|
|
86
|
-
if [[ -z "$
|
|
126
|
+
if [[ -z "$REPO_ROOT" ]]; then
|
|
127
|
+
# Outside any repo: bare client name, matching deriveAgentId().
|
|
87
128
|
AGENT_ID="claude_code"
|
|
88
129
|
else
|
|
89
|
-
|
|
130
|
+
BASENAME="$(basename "$REPO_ROOT")"
|
|
131
|
+
PROJECT_SLUG="$(printf '%s' "$BASENAME" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/^-+|-+$//g')"
|
|
132
|
+
PROJECT_SLUG="${PROJECT_SLUG:0:64}"
|
|
133
|
+
|
|
134
|
+
if [[ -z "$PROJECT_SLUG" || "$PROJECT_SLUG" == "-" ]]; then
|
|
135
|
+
AGENT_ID="claude_code"
|
|
136
|
+
else
|
|
137
|
+
AGENT_ID="claude_code:$PROJECT_SLUG"
|
|
138
|
+
fi
|
|
139
|
+
fi
|
|
140
|
+
|
|
141
|
+
# Read-only compatibility alias for pins written before MEKO-590.
|
|
142
|
+
LEGACY_BASENAME="$(basename "$CWD")"
|
|
143
|
+
LEGACY_SLUG="$(printf '%s' "$LEGACY_BASENAME" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/^-+|-+$//g')"
|
|
144
|
+
LEGACY_SLUG="${LEGACY_SLUG:0:64}"
|
|
145
|
+
if [[ -n "$LEGACY_SLUG" && "$LEGACY_SLUG" != "-" ]]; then
|
|
146
|
+
LEGACY_AGENT_ID="claude_code:$LEGACY_SLUG"
|
|
147
|
+
else
|
|
148
|
+
LEGACY_AGENT_ID="claude_code"
|
|
90
149
|
fi
|
|
91
150
|
fi
|
|
92
151
|
|
|
@@ -96,7 +155,11 @@ PIN_SLUG="$(printf '%s' "$AGENT_ID" | sed -E 's/[^A-Za-z0-9._-]+/_/g; s/^_+|_+$/
|
|
|
96
155
|
if [[ -z "$PIN_SLUG" ]]; then
|
|
97
156
|
PIN_SLUG="meko_agent"
|
|
98
157
|
fi
|
|
99
|
-
|
|
158
|
+
PIN_DIR="${MEKO_WATERMARK_DIR:-$USER_HOME/.claude/meko-capture}"
|
|
159
|
+
PIN_FILE="${PIN_DIR}/pin-${PIN_SLUG}.json"
|
|
160
|
+
LEGACY_PIN_SLUG="$(printf '%s' "$LEGACY_AGENT_ID" | sed -E 's/[^A-Za-z0-9._-]+/_/g; s/^_+|_+$//g')"
|
|
161
|
+
[[ -z "$LEGACY_PIN_SLUG" ]] && LEGACY_PIN_SLUG="meko_agent"
|
|
162
|
+
LEGACY_PIN_FILE="${PIN_DIR}/pin-${LEGACY_PIN_SLUG}.json"
|
|
100
163
|
|
|
101
164
|
# --- Read the pin file and append the suffix if a valid pin is present ---
|
|
102
165
|
|
|
@@ -105,20 +168,24 @@ if [[ -n "${INNER_OUT:-}" ]]; then
|
|
|
105
168
|
HAVE_SEGMENT=1
|
|
106
169
|
fi
|
|
107
170
|
|
|
171
|
+
PIN_NAME=""
|
|
108
172
|
if [[ -f "$PIN_FILE" ]]; then
|
|
109
173
|
# grep -oE isolates the exact "datapack_name":"…" pair before extracting, so a
|
|
110
174
|
# greedy `.*` can't grab a different key that merely ends in datapack_name.
|
|
111
175
|
PIN_NAME="$(grep -oE '"datapack_name"[[:space:]]*:[[:space:]]*"[^"]+"' "$PIN_FILE" | sed -E 's/.*"([^"]+)"$/\1/' | head -n 1)"
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
176
|
+
fi
|
|
177
|
+
if [[ -z "$PIN_NAME" && "$LEGACY_PIN_FILE" != "$PIN_FILE" && -f "$LEGACY_PIN_FILE" ]]; then
|
|
178
|
+
PIN_NAME="$(grep -oE '"datapack_name"[[:space:]]*:[[:space:]]*"[^"]+"' "$LEGACY_PIN_FILE" | sed -E 's/.*"([^"]+)"$/\1/' | head -n 1)"
|
|
179
|
+
fi
|
|
180
|
+
if [[ -n "$PIN_NAME" ]]; then
|
|
181
|
+
SUFFIX="Meko: $PIN_NAME"
|
|
182
|
+
# Add a separator only if the inner command produced output.
|
|
183
|
+
if [[ "$HAVE_SEGMENT" -eq 1 ]]; then
|
|
184
|
+
printf ' \xe2\x80\xa2 %s' "$SUFFIX"
|
|
185
|
+
else
|
|
186
|
+
printf '%s' "$SUFFIX"
|
|
121
187
|
fi
|
|
188
|
+
HAVE_SEGMENT=1
|
|
122
189
|
fi
|
|
123
190
|
|
|
124
191
|
# --- Capture health suffix (Capture V2) ---
|
|
@@ -127,10 +194,26 @@ fi
|
|
|
127
194
|
# nothing. Reads rebuildable capture-health.json (no full watermark scan).
|
|
128
195
|
# Format mirrors statuslineSuffixFromHealth() in capture-notices.js.
|
|
129
196
|
|
|
130
|
-
WATERMARK_DIR="$
|
|
197
|
+
WATERMARK_DIR="$PIN_DIR"
|
|
131
198
|
HEALTH_FILE="${WATERMARK_DIR}/capture-health.json"
|
|
132
199
|
CAPTURE_SUFFIX=""
|
|
133
|
-
|
|
200
|
+
# Hooks from 2.5.0 store a ready-made "statusline" text in the health cache
|
|
201
|
+
# (statuslineText() in capture-notices.js): needs you, paused, retrying with
|
|
202
|
+
# the next automatic repair, auto-fixed. An empty value means show nothing.
|
|
203
|
+
# Older caches fall back to the status/queued suffix below.
|
|
204
|
+
HAVE_STATUSLINE_FIELD=0
|
|
205
|
+
if [[ -f "$HEALTH_FILE" ]] && grep -qE '"statusline"[[:space:]]*:' "$HEALTH_FILE"; then
|
|
206
|
+
HAVE_STATUSLINE_FIELD=1
|
|
207
|
+
CAPTURE_SUFFIX="$(grep -oE '"statusline"[[:space:]]*:[[:space:]]*"[^"]*"' "$HEALTH_FILE" | sed -E 's/^"statusline"[[:space:]]*:[[:space:]]*"(.*)"$/\1/' | head -n 1)"
|
|
208
|
+
# The text can carry an expiry: after statusline_until (epoch seconds),
|
|
209
|
+
# show statusline_after instead ("auto-fixed N" fades, "auto-fix at"
|
|
210
|
+
# becomes "auto-fix soon").
|
|
211
|
+
UNTIL="$(grep -oE '"statusline_until"[[:space:]]*:[[:space:]]*[0-9]+' "$HEALTH_FILE" | sed -E 's/.*:[[:space:]]*([0-9]+)$/\1/' | head -n 1)"
|
|
212
|
+
if [[ -n "$UNTIL" && "$(date +%s)" -gt "$UNTIL" ]]; then
|
|
213
|
+
CAPTURE_SUFFIX="$(grep -oE '"statusline_after"[[:space:]]*:[[:space:]]*"[^"]*"' "$HEALTH_FILE" | sed -E 's/^"statusline_after"[[:space:]]*:[[:space:]]*"(.*)"$/\1/' | head -n 1)"
|
|
214
|
+
fi
|
|
215
|
+
fi
|
|
216
|
+
if [[ "$HAVE_STATUSLINE_FIELD" -eq 0 && -f "$HEALTH_FILE" ]]; then
|
|
134
217
|
HEALTH_STATUS="$(grep -oE '"status"[[:space:]]*:[[:space:]]*"[^"]+"' "$HEALTH_FILE" | sed -E 's/.*"([^"]+)"$/\1/' | head -n 1)"
|
|
135
218
|
QUEUED="$(grep -oE '"queued_exchanges"[[:space:]]*:[[:space:]]*[0-9]+' "$HEALTH_FILE" | sed -E 's/.*:[[:space:]]*([0-9]+)$/\1/' | head -n 1)"
|
|
136
219
|
QUEUED="${QUEUED:-0}"
|