@yugabytedb/meko-mcp 2.0.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 (65) hide show
  1. package/README.md +183 -33
  2. package/assets/hooks-handlers/codex-session-end.sh +13 -0
  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 +1653 -225
  8. package/assets/hooks-handlers/lib/checkpoint-timer.js +455 -50
  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 -24
  12. package/assets/hooks-handlers/session-start.sh +8 -1
  13. package/assets/hooks-handlers/test_checkpoint_timer.sh +162 -0
  14. package/assets/scripts/meko-statusline.sh +41 -1
  15. package/assets/scripts/package-desktop-skill.sh +32 -1
  16. package/assets/skills/meko-mcp-tools/SKILL.md +90 -137
  17. package/assets/skills/meko-mcp-tools/references/_sections.md +3 -5
  18. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +31 -28
  19. package/assets/skills/meko-mcp-tools/references/tools-conversation-dedup.md +5 -7
  20. package/assets/skills/meko-mcp-tools/references/tools-conversation-reasoning.md +50 -0
  21. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +69 -49
  22. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +6 -6
  23. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +29 -5
  24. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +29 -92
  25. package/assets/skills/meko-mcp-tools/references/tools-overview.md +42 -40
  26. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +1 -2
  27. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +18 -11
  28. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +10 -9
  29. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +51 -44
  30. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +6 -6
  31. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +2 -2
  32. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +34 -25
  33. package/assets/skills/meko-mcp-tools-desktop/references/tools-conversation-reasoning.md +50 -0
  34. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +63 -37
  35. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +4 -4
  36. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +19 -22
  37. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +42 -40
  38. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +1 -2
  39. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +3 -17
  40. package/assets/skills/meko-select-datapack-desktop/SKILL.md +31 -21
  41. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +8 -8
  42. package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +41 -0
  43. package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +1 -1
  44. package/bin/create-meko-setup.mjs +93 -16
  45. package/lib/capture-status.mjs +67 -0
  46. package/lib/clients/aliases.mjs +5 -0
  47. package/lib/clients/claude-code.mjs +8 -4
  48. package/lib/clients/claude-desktop.mjs +133 -4
  49. package/lib/clients/codex.mjs +29 -11
  50. package/lib/clients/common.mjs +2 -0
  51. package/lib/clients/cursor.mjs +7 -2
  52. package/lib/clients/index.mjs +2 -0
  53. package/lib/clients/kiro.mjs +705 -0
  54. package/lib/config.mjs +90 -0
  55. package/lib/detect.mjs +7 -2
  56. package/lib/installer.mjs +1 -0
  57. package/lib/migrate/adapters/codex.mjs +490 -0
  58. package/lib/migrate/id.mjs +28 -3
  59. package/lib/migrate/orchestrator.mjs +34 -7
  60. package/lib/migrate.mjs +4 -2
  61. package/lib/prompt.mjs +17 -2
  62. package/lib/url-selection.mjs +55 -0
  63. package/package.json +4 -4
  64. package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +0 -71
  65. package/assets/skills/meko-mcp-tools-desktop/references/tools-scope-permissions.md +0 -71
@@ -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,27 +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
- fi
25
-
26
- # Run final capture
27
- printf '%s' "$HOOK_INPUT" | node "$SCRIPT_DIR/lib/capture.js" session-end
6
+ exec node "$SCRIPT_DIR/lib/terminal-handoff.js" enqueue
@@ -16,7 +16,14 @@ TRANSCRIPT_PATH="$(printf '%s' "$HOOK_INPUT" | node -e '
16
16
  # Spawn background checkpoint timer (periodic capture without interrupting the agent).
17
17
  # Send the daemon's stderr to a log file instead of /dev/null so startup
18
18
  # failures and crashes are diagnosable; stdout is still discarded.
19
- if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
19
+ #
20
+ # NOTE: we deliberately do NOT gate on [ -f "$TRANSCRIPT_PATH" ]. SessionStart
21
+ # fires before Claude Code writes the transcript .jsonl, so that file usually
22
+ # does not exist yet at hook time (measured gaps of +3s to +20min). Gating on
23
+ # its existence meant the timer never spawned and the whole session went
24
+ # uncaptured. checkpoint-timer.js self-guards a missing transcript on each
25
+ # tick(), so spawning unconditionally (when the path is known) is safe.
26
+ if [ -n "$TRANSCRIPT_PATH" ]; then
20
27
  MEKO_LOG_DIR="${MEKO_WATERMARK_DIR:-$HOME/.claude/meko-capture}"
21
28
  mkdir -p "$MEKO_LOG_DIR"
22
29
  nohup node "$SCRIPT_DIR/lib/checkpoint-timer.js" "$TRANSCRIPT_PATH" \
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # test_checkpoint_timer.sh — Regression tests for issue #159:
4
+ # - Defect 1: the checkpoint timer must spawn even when the transcript file
5
+ # does not exist yet at SessionStart time (spawn race).
6
+ # - Defect 3: the timer must self-exit (max-age / idle backstop) and
7
+ # session-end.sh must kill it by transcript path when the pid file is gone,
8
+ # so orphaned daemons cannot tick forever.
9
+ #
10
+ # These tests do NOT require a live Meko MCP server: the timer's self-guard and
11
+ # self-exit paths run before any capture.js checkpoint would reach MCP, and we
12
+ # assert on process liveness / pid-file presence only.
13
+ #
14
+ # Usage: ./test_checkpoint_timer.sh
15
+
16
+ set -uo pipefail
17
+
18
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
19
+ TIMER_JS="$SCRIPT_DIR/lib/checkpoint-timer.js"
20
+ SESSION_START="$SCRIPT_DIR/session-start.sh"
21
+ SESSION_END="$SCRIPT_DIR/session-end.sh"
22
+
23
+ WM_DIR="$(mktemp -d -t meko-timer-test-XXXXXX)"
24
+ TX_ROOT="$(mktemp -d -t meko-timer-tx-XXXXXX)"
25
+ # Exercise session-end matching when the absolute transcript path contains
26
+ # characters that have special meaning in a regular expression.
27
+ TX_DIR="$TX_ROOT/path with [regex] chars"
28
+ mkdir -p "$TX_DIR"
29
+ SESSION_ID="timer-test-$$"
30
+ TRANSCRIPT_PATH="$TX_DIR/${SESSION_ID}.jsonl"
31
+ PID_FILE="$WM_DIR/${SESSION_ID}.timer.pid"
32
+
33
+ PASS=0
34
+ FAIL=0
35
+ SPAWNED_PIDS=()
36
+
37
+ cleanup() {
38
+ for p in "${SPAWNED_PIDS[@]:-}"; do kill "$p" 2>/dev/null || true; done
39
+ rm -rf "$WM_DIR" "$TX_ROOT"
40
+ }
41
+ trap cleanup EXIT
42
+
43
+ log() { printf " %-55s" "$1"; }
44
+ pass() { printf "\033[32mPASS\033[0m %s\n" "${1:-}"; PASS=$((PASS + 1)); }
45
+ fail() { printf "\033[31mFAIL\033[0m %s\n" "${1:-}"; FAIL=$((FAIL + 1)); }
46
+
47
+ # Poll up to N deciseconds for a process to die. Returns 0 if it died.
48
+ wait_gone() {
49
+ local pid="$1" tries="${2:-50}"
50
+ while [ "$tries" -gt 0 ]; do
51
+ kill -0 "$pid" 2>/dev/null || return 0
52
+ sleep 0.1
53
+ tries=$((tries - 1))
54
+ done
55
+ return 1
56
+ }
57
+
58
+ export MEKO_WATERMARK_DIR="$WM_DIR"
59
+
60
+ echo "============================================================"
61
+ echo "Test: checkpoint-timer regression (issue #159)"
62
+ echo "============================================================"
63
+ echo " Watermark dir: $WM_DIR"
64
+ echo " Transcript: $TRANSCRIPT_PATH"
65
+ echo ""
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Defect 1 — timer spawns even though transcript does not exist yet.
69
+ # session-start.sh backgrounds the timer; capture.js session-start will fail to
70
+ # reach MCP but must not block the spawn. The timer must remain alive after its
71
+ # first tick while transcript creation is still within the startup grace period.
72
+ # ---------------------------------------------------------------------------
73
+ echo "--- Defect 1: spawn race (transcript absent at SessionStart) ---"
74
+ log "Transcript absent before hook"
75
+ [ ! -f "$TRANSCRIPT_PATH" ] && pass || fail "transcript unexpectedly exists"
76
+
77
+ # Spawn the timer directly the way the fixed session-start.sh does. A one-second
78
+ # interval guarantees the missing-transcript branch runs before the assertion.
79
+ MEKO_CHECKPOINT_INTERVAL=1 MEKO_TIMER_MAX_AGE=999 MEKO_TIMER_IDLE_EXIT=999 \
80
+ MEKO_TIMER_STARTUP_GRACE=10 \
81
+ nohup node "$TIMER_JS" "$TRANSCRIPT_PATH" </dev/null >/dev/null 2>&1 &
82
+ TIMER_PID=$!
83
+ SPAWNED_PIDS+=("$TIMER_PID")
84
+ sleep 1.5
85
+
86
+ log "Timer survives first tick with missing transcript"
87
+ if kill -0 "$TIMER_PID" 2>/dev/null; then pass "pid $TIMER_PID"; else fail "timer did not stay up"; fi
88
+
89
+ log "Timer wrote its pid file"
90
+ [ -f "$PID_FILE" ] && pass || fail "no pid file at $PID_FILE"
91
+
92
+ kill "$TIMER_PID" 2>/dev/null || true
93
+ wait_gone "$TIMER_PID" || true
94
+ rm -f "$PID_FILE"
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Defect 3a — max-age self-exit. Transcript exists and is fresh, but max-age is
98
+ # tiny, so the first tick must self-exit and remove its pid file.
99
+ # ---------------------------------------------------------------------------
100
+ echo ""
101
+ echo "--- Defect 3a: max-age self-exit backstop ---"
102
+ printf '{"type":"user","uuid":"u1","message":{"content":"hi"}}\n' > "$TRANSCRIPT_PATH"
103
+
104
+ MEKO_CHECKPOINT_INTERVAL=1 MEKO_TIMER_MAX_AGE=1 MEKO_TIMER_IDLE_EXIT=999 \
105
+ nohup node "$TIMER_JS" "$TRANSCRIPT_PATH" </dev/null >/dev/null 2>&1 &
106
+ TIMER_PID=$!
107
+ SPAWNED_PIDS+=("$TIMER_PID")
108
+
109
+ log "Timer self-exits after max-age"
110
+ if wait_gone "$TIMER_PID" 60; then pass; else fail "still running after max-age"; fi
111
+
112
+ log "Pid file removed on self-exit"
113
+ [ ! -f "$PID_FILE" ] && pass || fail "stale pid file remains"
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Defect 3b — idle self-exit. Make the transcript look old (mtime in the past)
117
+ # so the idle backstop trips on the first tick.
118
+ # ---------------------------------------------------------------------------
119
+ echo ""
120
+ echo "--- Defect 3b: idle self-exit backstop ---"
121
+ printf '{"type":"user","uuid":"u1","message":{"content":"hi"}}\n' > "$TRANSCRIPT_PATH"
122
+ touch -t 202001010000 "$TRANSCRIPT_PATH" # far in the past
123
+
124
+ MEKO_CHECKPOINT_INTERVAL=1 MEKO_TIMER_MAX_AGE=999 MEKO_TIMER_IDLE_EXIT=1 \
125
+ nohup node "$TIMER_JS" "$TRANSCRIPT_PATH" </dev/null >/dev/null 2>&1 &
126
+ TIMER_PID=$!
127
+ SPAWNED_PIDS+=("$TIMER_PID")
128
+
129
+ log "Timer self-exits on idle transcript"
130
+ if wait_gone "$TIMER_PID" 60; then pass; else fail "still running with idle transcript"; fi
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Defect 3c — session-end.sh kills by transcript path when the pid file is gone.
134
+ # ---------------------------------------------------------------------------
135
+ echo ""
136
+ echo "--- Defect 3c: session-end path-based kill (no pid file) ---"
137
+ printf '{"type":"user","uuid":"u1","message":{"content":"hi"}}\n' > "$TRANSCRIPT_PATH"
138
+
139
+ # Long-lived timer; then delete its pid file to simulate the orphan condition.
140
+ MEKO_CHECKPOINT_INTERVAL=999 MEKO_TIMER_MAX_AGE=999 MEKO_TIMER_IDLE_EXIT=999 \
141
+ nohup node "$TIMER_JS" "$TRANSCRIPT_PATH" </dev/null >/dev/null 2>&1 &
142
+ TIMER_PID=$!
143
+ SPAWNED_PIDS+=("$TIMER_PID")
144
+ sleep 0.5
145
+ rm -f "$PID_FILE" # pid file vanished out of band -> classic orphan setup
146
+
147
+ log "Orphan timer running with no pid file"
148
+ if kill -0 "$TIMER_PID" 2>/dev/null && [ ! -f "$PID_FILE" ]; then pass "pid $TIMER_PID"; else fail "precondition not met"; fi
149
+
150
+ # session-end.sh must find it by transcript path and kill it.
151
+ printf '{"transcript_path":"%s"}' "$TRANSCRIPT_PATH" | bash "$SESSION_END" >/dev/null 2>&1 || true
152
+
153
+ log "session-end.sh killed the orphan by transcript path"
154
+ if wait_gone "$TIMER_PID" 30; then pass; else fail "orphan survived session-end"; fi
155
+
156
+ # ---------------------------------------------------------------------------
157
+ echo ""
158
+ echo "============================================================"
159
+ TOTAL=$((PASS + FAIL))
160
+ echo " Total: $TOTAL | Passed: $PASS | Failed: $FAIL"
161
+ echo "============================================================"
162
+ [ "$FAIL" -gt 0 ] && { echo " RESULT: FAILED"; exit 1; } || { echo " RESULT: ALL PASSED"; exit 0; }
@@ -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