@yugabytedb/meko-mcp 2.1.0 → 2.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 (45) hide show
  1. package/README.md +152 -27
  2. package/assets/hooks-handlers/codex-session-end.sh +4 -69
  3. package/assets/hooks-handlers/cursor-session-start.sh +19 -1
  4. package/assets/hooks-handlers/lib/capture-notices.js +321 -0
  5. package/assets/hooks-handlers/lib/capture-state.js +2079 -0
  6. package/assets/hooks-handlers/lib/capture-status-cli.js +82 -0
  7. package/assets/hooks-handlers/lib/capture.js +1578 -299
  8. package/assets/hooks-handlers/lib/checkpoint-timer.js +444 -81
  9. package/assets/hooks-handlers/lib/kiro-handler.js +738 -0
  10. package/assets/hooks-handlers/lib/terminal-handoff.js +418 -0
  11. package/assets/hooks-handlers/session-end.sh +3 -34
  12. package/assets/scripts/meko-statusline.sh +41 -1
  13. package/assets/skills/meko-mcp-tools/SKILL.md +72 -125
  14. package/assets/skills/meko-mcp-tools/references/_sections.md +2 -2
  15. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +12 -13
  16. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +15 -12
  17. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +25 -1
  18. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +19 -77
  19. package/assets/skills/meko-mcp-tools/references/tools-overview.md +8 -7
  20. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +22 -1
  21. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +1 -1
  22. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +8 -8
  23. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +1 -1
  24. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
  25. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +6 -1
  26. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +6 -5
  27. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +4 -4
  28. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +7 -6
  29. package/assets/skills/meko-select-datapack-desktop/SKILL.md +1 -1
  30. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +3 -3
  31. package/bin/create-meko-setup.mjs +48 -10
  32. package/lib/capture-status.mjs +67 -0
  33. package/lib/clients/aliases.mjs +5 -0
  34. package/lib/clients/claude-desktop.mjs +120 -5
  35. package/lib/clients/codex.mjs +6 -3
  36. package/lib/clients/index.mjs +2 -0
  37. package/lib/clients/kiro.mjs +705 -0
  38. package/lib/config.mjs +90 -0
  39. package/lib/installer.mjs +1 -0
  40. package/lib/migrate/adapters/codex.mjs +490 -0
  41. package/lib/migrate/id.mjs +28 -3
  42. package/lib/migrate/orchestrator.mjs +34 -7
  43. package/lib/migrate.mjs +4 -2
  44. package/lib/prompt.mjs +16 -1
  45. package/package.json +4 -4
@@ -0,0 +1,418 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * terminal-handoff.js — Shared SessionEnd spool + detached drain worker.
4
+ *
5
+ * Used by Claude Code / Cursor (`session-end.sh`) and Codex
6
+ * (`codex-session-end.sh`) so every client shares the same durable handoff:
7
+ *
8
+ * node terminal-handoff.js enqueue # stdin → job file, detach, print {}
9
+ * node terminal-handoff.js worker <job-file>
10
+ *
11
+ * Foreground enqueue returns immediately (Codex's 3s SessionEnd cap).
12
+ * The worker drains with lifecycle=closing, retries while work remains, and
13
+ * stops after MEKO_WORKER_MAX_AGE_SECONDS (default 24h) leaving the outbox
14
+ * pending for the next daemon.
15
+ */
16
+
17
+ "use strict";
18
+
19
+ const { execFile, spawn } = require("child_process");
20
+ const fs = require("fs");
21
+ const os = require("os");
22
+ const path = require("path");
23
+ const {
24
+ workerMaxAgeSeconds,
25
+ checkpointTimeout,
26
+ watermarkDir,
27
+ mutateState,
28
+ } = require("./capture-state");
29
+
30
+ const CAPTURE_SCRIPT = path.join(__dirname, "capture.js");
31
+ const STATE_DIR = watermarkDir();
32
+ const JOB_DIR = path.join(STATE_DIR, "session-end-jobs");
33
+ const LOG_FILE = path.join(STATE_DIR, "session-end.log");
34
+ const WORKER_MAX_AGE_MS = Math.max(1, workerMaxAgeSeconds()) * 1000;
35
+ const DRAIN_TIMEOUT_MS = Math.max(1, checkpointTimeout()) * 1000;
36
+
37
+ function ensurePrivateDirs() {
38
+ fs.mkdirSync(JOB_DIR, { recursive: true, mode: 0o700 });
39
+ }
40
+
41
+ function killCheckpointTimer(sessionId) {
42
+ if (!sessionId) return;
43
+ const pidFile = path.join(STATE_DIR, `${sessionId}.timer.pid`);
44
+ if (fs.existsSync(pidFile)) {
45
+ try {
46
+ const pid = parseInt(fs.readFileSync(pidFile, "utf8"), 10);
47
+ if (Number.isInteger(pid) && pid > 0) {
48
+ try {
49
+ process.kill(pid);
50
+ } catch {
51
+ /* already gone */
52
+ }
53
+ }
54
+ } catch {
55
+ /* ignore */
56
+ }
57
+ try {
58
+ fs.unlinkSync(pidFile);
59
+ } catch {
60
+ /* ignore */
61
+ }
62
+ }
63
+ // Fallback: match orphaned daemons by session id (same as session-end.sh).
64
+ try {
65
+ const { execFileSync } = require("child_process");
66
+ const out = execFileSync(
67
+ "pgrep",
68
+ ["-f", `checkpoint-timer.js .*${sessionId}`],
69
+ { encoding: "utf8" },
70
+ );
71
+ for (const line of out.split(/\r?\n/)) {
72
+ const pid = parseInt(line.trim(), 10);
73
+ if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) continue;
74
+ try {
75
+ process.kill(pid);
76
+ } catch {
77
+ /* ignore */
78
+ }
79
+ }
80
+ } catch {
81
+ /* pgrep missing or no matches */
82
+ }
83
+ }
84
+
85
+ function extractSessionId(hookInput) {
86
+ const d = hookInput && typeof hookInput === "object" ? hookInput : {};
87
+ const transcriptPath =
88
+ d.transcript_path ||
89
+ (d.hookSpecificInput && d.hookSpecificInput.transcript_path) ||
90
+ "";
91
+ if (transcriptPath) return path.basename(String(transcriptPath), ".jsonl");
92
+ if (d.session_id) return String(d.session_id);
93
+ return "";
94
+ }
95
+
96
+ function readStdinSync() {
97
+ try {
98
+ return fs.readFileSync(0, "utf8");
99
+ } catch {
100
+ return "";
101
+ }
102
+ }
103
+
104
+ function persistClosingIntent(sessionId, hookInput) {
105
+ if (!sessionId) return false;
106
+ let written;
107
+ try {
108
+ written = mutateState(sessionId, (loaded) => {
109
+ if (!loaded.ok) {
110
+ return {
111
+ ...loaded,
112
+ error: loaded.error || "outbox missing",
113
+ };
114
+ }
115
+ const transcriptPath =
116
+ hookInput.transcript_path ||
117
+ (hookInput.hookSpecificInput || {}).transcript_path ||
118
+ loaded.state.transcript_path ||
119
+ null;
120
+ return {
121
+ ...loaded.state,
122
+ lifecycle: "closing",
123
+ transcript_path: transcriptPath,
124
+ // SessionEnd can append a final or interrupted turn after the last
125
+ // checkpoint. Mark even idle/complete states pending so recovery will
126
+ // inspect the transcript if the detached worker never starts.
127
+ delivery: [
128
+ "needs_conversation",
129
+ "pending",
130
+ "draining",
131
+ "retry_wait",
132
+ "blocked",
133
+ ].includes(loaded.state.delivery)
134
+ ? loaded.state.delivery
135
+ : "pending",
136
+ };
137
+ });
138
+ } catch (err) {
139
+ process.stderr.write(
140
+ `[meko-capture] SessionEnd closing-state mutation failed for ${sessionId}: ` +
141
+ `${err.message}; retaining detached job.\n`,
142
+ );
143
+ return false;
144
+ }
145
+ if (!written.ok) {
146
+ process.stderr.write(
147
+ `[meko-capture] SessionEnd could not persist closing state for ${sessionId}: ` +
148
+ `${written.error}; retaining detached job.\n`,
149
+ );
150
+ }
151
+ return Boolean(written.ok);
152
+ }
153
+
154
+ function enqueueFromStdin() {
155
+ ensurePrivateDirs();
156
+ const raw = readStdinSync();
157
+ let hookInput = {};
158
+ try {
159
+ hookInput = JSON.parse(raw || "{}");
160
+ } catch {
161
+ hookInput = {};
162
+ }
163
+
164
+ const sessionId = extractSessionId(hookInput);
165
+ persistClosingIntent(sessionId, hookInput);
166
+ killCheckpointTimer(sessionId);
167
+
168
+ const jobFile = path.join(
169
+ JOB_DIR,
170
+ `session-end.${process.pid}.${Date.now()}.${Math.random()
171
+ .toString(16)
172
+ .slice(2)}.json`,
173
+ );
174
+ try {
175
+ fs.writeFileSync(jobFile, raw || "{}", { mode: 0o600 });
176
+ } catch (err) {
177
+ process.stderr.write(
178
+ `[meko-capture] SessionEnd could not create a job file: ${err.message}\n`,
179
+ );
180
+ process.stdout.write("{}\n");
181
+ return;
182
+ }
183
+
184
+ // Detach: nohup-equivalent. Inherit env; redirect stdio to session-end.log.
185
+ let logFd;
186
+ try {
187
+ logFd = fs.openSync(LOG_FILE, "a");
188
+ } catch {
189
+ logFd = "ignore";
190
+ }
191
+ const child = spawn(
192
+ process.execPath,
193
+ [__filename, "worker", jobFile],
194
+ {
195
+ detached: true,
196
+ stdio: ["ignore", logFd, logFd],
197
+ env: process.env,
198
+ },
199
+ );
200
+ child.on("error", (err) => {
201
+ process.stderr.write(
202
+ `[meko-capture] SessionEnd worker failed to start; job retained at ${jobFile}: ${err.message}\n`,
203
+ );
204
+ });
205
+ child.unref();
206
+
207
+ process.stdout.write("{}");
208
+ }
209
+
210
+ function sleep(ms) {
211
+ return new Promise((resolve) => setTimeout(resolve, ms));
212
+ }
213
+
214
+ function runSessionEndOnce(hookInput) {
215
+ return new Promise((resolve) => {
216
+ const child = execFile(
217
+ process.execPath,
218
+ [CAPTURE_SCRIPT, "session-end"],
219
+ { timeout: DRAIN_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 },
220
+ (err, stdout, stderr) => {
221
+ resolve({
222
+ ok: !err,
223
+ error: err ? err.message : null,
224
+ stdout: stdout || "",
225
+ stderr: stderr || "",
226
+ });
227
+ },
228
+ );
229
+ child.stdin.on("error", () => {});
230
+ child.stdin.end(JSON.stringify(hookInput || {}));
231
+ });
232
+ }
233
+
234
+ function runDrainOnce(hookInput, maxExchanges) {
235
+ const sessionId = extractSessionId(hookInput);
236
+ const transcriptPath =
237
+ (hookInput &&
238
+ (hookInput.transcript_path ||
239
+ (hookInput.hookSpecificInput &&
240
+ hookInput.hookSpecificInput.transcript_path))) ||
241
+ null;
242
+ const payload = {
243
+ session_id: sessionId,
244
+ transcript_path: transcriptPath || undefined,
245
+ lifecycle: "closing",
246
+ confirmed_dead: true,
247
+ recorded_owner_only: true,
248
+ };
249
+ if (maxExchanges != null) payload.max_exchanges = maxExchanges;
250
+
251
+ return new Promise((resolve) => {
252
+ const child = execFile(
253
+ process.execPath,
254
+ [CAPTURE_SCRIPT, "drain"],
255
+ { timeout: DRAIN_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 },
256
+ (err, stdout) => {
257
+ let result = null;
258
+ const text = (stdout || "").trim();
259
+ if (text) {
260
+ try {
261
+ const line = text
262
+ .split(/\r?\n/)
263
+ .find((l) => l.trim().startsWith("{"));
264
+ if (line) result = JSON.parse(line);
265
+ } catch {
266
+ result = null;
267
+ }
268
+ }
269
+ resolve({
270
+ ok: !err,
271
+ error: err ? err.message : null,
272
+ result,
273
+ });
274
+ },
275
+ );
276
+ child.stdin.on("error", () => {});
277
+ child.stdin.end(JSON.stringify(payload));
278
+ });
279
+ }
280
+
281
+ function shouldStop(result) {
282
+ if (!result || typeof result !== "object") return false;
283
+ if (result.status === "blocked") return true;
284
+ if (
285
+ result.status === "noop" &&
286
+ result.error === "recorded owner missing"
287
+ ) {
288
+ // recorded_owner_only recovery cannot synthesize an agent identity, so
289
+ // repeating this pre-network noop for the worker's full age cannot heal.
290
+ return true;
291
+ }
292
+ if (
293
+ result.status === "noop" &&
294
+ result.delivery !== "needs_conversation" &&
295
+ Number(result.queued_remaining || 0) === 0
296
+ ) {
297
+ return true;
298
+ }
299
+ if (result.delivery === "complete" || result.lifecycle === "closed") {
300
+ return Number(result.queued_remaining || 0) === 0;
301
+ }
302
+ if (result.status === "ok" && Number(result.queued_remaining || 0) === 0) {
303
+ return true;
304
+ }
305
+ return false;
306
+ }
307
+
308
+ async function runWorker(jobFile) {
309
+ const started = Date.now();
310
+ const cleanupJob = () => {
311
+ try {
312
+ fs.unlinkSync(jobFile);
313
+ } catch {
314
+ /* ignore */
315
+ }
316
+ };
317
+ process.on("exit", cleanupJob);
318
+ process.on("SIGINT", () => {
319
+ cleanupJob();
320
+ process.exit(0);
321
+ });
322
+ process.on("SIGTERM", () => {
323
+ cleanupJob();
324
+ process.exit(0);
325
+ });
326
+
327
+ if (!jobFile || !fs.existsSync(jobFile)) {
328
+ process.stderr.write(
329
+ "[meko-capture] SessionEnd worker received no readable job file\n",
330
+ );
331
+ process.exit(1);
332
+ }
333
+
334
+ let hookInput = {};
335
+ try {
336
+ hookInput = JSON.parse(fs.readFileSync(jobFile, "utf8") || "{}");
337
+ } catch (err) {
338
+ process.stderr.write(
339
+ `[meko-capture] SessionEnd worker could not parse job: ${err.message}\n`,
340
+ );
341
+ cleanupJob();
342
+ process.exit(1);
343
+ }
344
+
345
+ // First pass: full session-end drain (sets lifecycle=closing in capture.js).
346
+ await runSessionEndOnce(hookInput);
347
+
348
+ // Keep draining while work remains, until the 24h worker ceiling.
349
+ let delayMs = 2000;
350
+ while (Date.now() - started < WORKER_MAX_AGE_MS) {
351
+ const outcome = await runDrainOnce(hookInput);
352
+ const result = outcome.result;
353
+ if (shouldStop(result)) break;
354
+ if (result && result.status === "retry_wait") {
355
+ const dueMs = result.next_retry_at
356
+ ? Date.parse(result.next_retry_at)
357
+ : NaN;
358
+ const untilDue = Number.isFinite(dueMs)
359
+ ? Math.max(0, dueMs - Date.now())
360
+ : delayMs;
361
+ const remaining = Math.max(0, WORKER_MAX_AGE_MS - (Date.now() - started));
362
+ await sleep(Math.min(untilDue, remaining));
363
+ delayMs = Math.min(delayMs * 2, 60_000);
364
+ continue;
365
+ }
366
+ if (result && Number(result.queued_remaining || 0) > 0) {
367
+ // More work immediately available.
368
+ delayMs = 2000;
369
+ continue;
370
+ }
371
+ // Ambiguous / empty result — short pause then retry until age ceiling.
372
+ await sleep(Math.min(delayMs, 30_000));
373
+ delayMs = Math.min(delayMs * 2, 60_000);
374
+ }
375
+
376
+ if (Date.now() - started >= WORKER_MAX_AGE_MS) {
377
+ process.stderr.write(
378
+ `[meko-capture] SessionEnd worker hit ${workerMaxAgeSeconds()}s ceiling; leaving outbox pending.\n`,
379
+ );
380
+ }
381
+
382
+ cleanupJob();
383
+ }
384
+
385
+ module.exports = {
386
+ killCheckpointTimer,
387
+ extractSessionId,
388
+ persistClosingIntent,
389
+ enqueueFromStdin,
390
+ runWorker,
391
+ shouldStop,
392
+ WORKER_MAX_AGE_MS,
393
+ };
394
+
395
+ async function main() {
396
+ const mode = process.argv[2];
397
+ if (mode === "enqueue") {
398
+ enqueueFromStdin();
399
+ return;
400
+ }
401
+ if (mode === "worker") {
402
+ await runWorker(process.argv[3]);
403
+ return;
404
+ }
405
+ process.stderr.write(
406
+ "Usage: terminal-handoff.js <enqueue|worker> [job-file]\n",
407
+ );
408
+ process.exit(1);
409
+ }
410
+
411
+ if (require.main === module) {
412
+ main().catch((err) => {
413
+ process.stderr.write(
414
+ `[meko-capture] terminal-handoff fatal: ${err.message}\n`,
415
+ );
416
+ process.exit(1);
417
+ });
418
+ }
@@ -1,37 +1,6 @@
1
1
  #!/usr/bin/env bash
2
- # SessionEnd hook — kills the checkpoint timer, then delegates to capture.js
3
- # for a final transcript capture.
2
+ # SessionEnd hook — stops the checkpoint timer and hands final capture to the
3
+ # shared detached terminal-handoff worker (durable outbox drain).
4
4
 
5
5
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
6
-
7
- # Read stdin into variable (need it for both timer cleanup and capture.js)
8
- HOOK_INPUT="$(cat)"
9
-
10
- # Extract transcript_path and kill checkpoint timer
11
- TRANSCRIPT_PATH="$(printf '%s' "$HOOK_INPUT" | node -e '
12
- const d = JSON.parse(require("fs").readFileSync(0,"utf-8"));
13
- const p = d.transcript_path || (d.hookSpecificInput||{}).transcript_path || "";
14
- process.stdout.write(p);
15
- ' 2>/dev/null)"
16
-
17
- if [ -n "$TRANSCRIPT_PATH" ]; then
18
- SESSION_ID="$(basename "$TRANSCRIPT_PATH" .jsonl)"
19
- PID_FILE="${MEKO_WATERMARK_DIR:-$HOME/.claude/meko-capture}/${SESSION_ID}.timer.pid"
20
- if [ -f "$PID_FILE" ]; then
21
- kill "$(cat "$PID_FILE")" 2>/dev/null || true
22
- rm -f "$PID_FILE"
23
- fi
24
- # Fallback: the pid file can go missing while the daemon lives (removed out of
25
- # band, a missed/blocked signal). Match the running checkpoint-timer.js by the
26
- # clean session id rather than the absolute transcript path, which may contain
27
- # regex metacharacters that make pgrep fail or match incorrectly.
28
- if command -v pgrep >/dev/null 2>&1; then
29
- for pid in $(pgrep -f "checkpoint-timer.js .*${SESSION_ID}" 2>/dev/null); do
30
- [ "$pid" = "$$" ] && continue
31
- kill "$pid" 2>/dev/null || true
32
- done
33
- fi
34
- fi
35
-
36
- # Run final capture
37
- printf '%s' "$HOOK_INPUT" | node "$SCRIPT_DIR/lib/capture.js" session-end
6
+ exec node "$SCRIPT_DIR/lib/terminal-handoff.js" enqueue
@@ -100,6 +100,11 @@ PIN_FILE="${MEKO_WATERMARK_DIR:-$HOME/.claude/meko-capture}/pin-${PIN_SLUG}.json
100
100
 
101
101
  # --- Read the pin file and append the suffix if a valid pin is present ---
102
102
 
103
+ HAVE_SEGMENT=0
104
+ if [[ -n "${INNER_OUT:-}" ]]; then
105
+ HAVE_SEGMENT=1
106
+ fi
107
+
103
108
  if [[ -f "$PIN_FILE" ]]; then
104
109
  # grep -oE isolates the exact "datapack_name":"…" pair before extracting, so a
105
110
  # greedy `.*` can't grab a different key that merely ends in datapack_name.
@@ -107,11 +112,46 @@ if [[ -f "$PIN_FILE" ]]; then
107
112
  if [[ -n "$PIN_NAME" ]]; then
108
113
  SUFFIX="Meko: $PIN_NAME"
109
114
  # Add a separator only if the inner command produced output.
110
- if [[ -n "${INNER_OUT:-}" ]]; then
115
+ if [[ "$HAVE_SEGMENT" -eq 1 ]]; then
111
116
  printf ' \xe2\x80\xa2 %s' "$SUFFIX"
112
117
  else
113
118
  printf '%s' "$SUFFIX"
114
119
  fi
120
+ HAVE_SEGMENT=1
121
+ fi
122
+ fi
123
+
124
+ # --- Capture health suffix (Capture V2) ---
125
+ #
126
+ # Composable: appends only for catching_up / degraded / blocked. Healthy adds
127
+ # nothing. Reads rebuildable capture-health.json (no full watermark scan).
128
+ # Format mirrors statuslineSuffixFromHealth() in capture-notices.js.
129
+
130
+ WATERMARK_DIR="${MEKO_WATERMARK_DIR:-$HOME/.claude/meko-capture}"
131
+ HEALTH_FILE="${WATERMARK_DIR}/capture-health.json"
132
+ CAPTURE_SUFFIX=""
133
+ if [[ -f "$HEALTH_FILE" ]]; then
134
+ HEALTH_STATUS="$(grep -oE '"status"[[:space:]]*:[[:space:]]*"[^"]+"' "$HEALTH_FILE" | sed -E 's/.*"([^"]+)"$/\1/' | head -n 1)"
135
+ QUEUED="$(grep -oE '"queued_exchanges"[[:space:]]*:[[:space:]]*[0-9]+' "$HEALTH_FILE" | sed -E 's/.*:[[:space:]]*([0-9]+)$/\1/' | head -n 1)"
136
+ QUEUED="${QUEUED:-0}"
137
+ case "$HEALTH_STATUS" in
138
+ catching_up)
139
+ CAPTURE_SUFFIX="capture:catching_up(${QUEUED})"
140
+ ;;
141
+ degraded_retrying)
142
+ CAPTURE_SUFFIX="capture:degraded(${QUEUED})"
143
+ ;;
144
+ blocked_action_required)
145
+ CAPTURE_SUFFIX="capture:blocked(${QUEUED})"
146
+ ;;
147
+ esac
148
+ fi
149
+
150
+ if [[ -n "$CAPTURE_SUFFIX" ]]; then
151
+ if [[ "$HAVE_SEGMENT" -eq 1 ]]; then
152
+ printf ' \xe2\x80\xa2 %s' "$CAPTURE_SUFFIX"
153
+ else
154
+ printf '%s' "$CAPTURE_SUFFIX"
115
155
  fi
116
156
  fi
117
157