@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.
- package/README.md +183 -33
- package/assets/hooks-handlers/codex-session-end.sh +13 -0
- package/assets/hooks-handlers/cursor-session-start.sh +19 -1
- package/assets/hooks-handlers/lib/capture-notices.js +321 -0
- package/assets/hooks-handlers/lib/capture-state.js +2079 -0
- package/assets/hooks-handlers/lib/capture-status-cli.js +82 -0
- package/assets/hooks-handlers/lib/capture.js +1653 -225
- package/assets/hooks-handlers/lib/checkpoint-timer.js +455 -50
- package/assets/hooks-handlers/lib/kiro-handler.js +738 -0
- package/assets/hooks-handlers/lib/terminal-handoff.js +418 -0
- package/assets/hooks-handlers/session-end.sh +3 -24
- package/assets/hooks-handlers/session-start.sh +8 -1
- package/assets/hooks-handlers/test_checkpoint_timer.sh +162 -0
- package/assets/scripts/meko-statusline.sh +41 -1
- package/assets/scripts/package-desktop-skill.sh +32 -1
- package/assets/skills/meko-mcp-tools/SKILL.md +90 -137
- package/assets/skills/meko-mcp-tools/references/_sections.md +3 -5
- package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +31 -28
- package/assets/skills/meko-mcp-tools/references/tools-conversation-dedup.md +5 -7
- package/assets/skills/meko-mcp-tools/references/tools-conversation-reasoning.md +50 -0
- package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +69 -49
- package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +6 -6
- package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +29 -5
- package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +29 -92
- package/assets/skills/meko-mcp-tools/references/tools-overview.md +42 -40
- package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +1 -2
- package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +18 -11
- package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +10 -9
- package/assets/skills/meko-mcp-tools-desktop/SKILL.md +51 -44
- package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +6 -6
- package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +2 -2
- package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +34 -25
- package/assets/skills/meko-mcp-tools-desktop/references/tools-conversation-reasoning.md +50 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +63 -37
- package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +4 -4
- package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +19 -22
- package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +42 -40
- package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +1 -2
- package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +3 -17
- package/assets/skills/meko-select-datapack-desktop/SKILL.md +31 -21
- package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +8 -8
- package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +41 -0
- package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +1 -1
- package/bin/create-meko-setup.mjs +93 -16
- package/lib/capture-status.mjs +67 -0
- package/lib/clients/aliases.mjs +5 -0
- package/lib/clients/claude-code.mjs +8 -4
- package/lib/clients/claude-desktop.mjs +133 -4
- package/lib/clients/codex.mjs +29 -11
- package/lib/clients/common.mjs +2 -0
- package/lib/clients/cursor.mjs +7 -2
- package/lib/clients/index.mjs +2 -0
- package/lib/clients/kiro.mjs +705 -0
- package/lib/config.mjs +90 -0
- package/lib/detect.mjs +7 -2
- package/lib/installer.mjs +1 -0
- package/lib/migrate/adapters/codex.mjs +490 -0
- package/lib/migrate/id.mjs +28 -3
- package/lib/migrate/orchestrator.mjs +34 -7
- package/lib/migrate.mjs +4 -2
- package/lib/prompt.mjs +17 -2
- package/lib/url-selection.mjs +55 -0
- package/package.json +4 -4
- package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +0 -71
- 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 —
|
|
3
|
-
#
|
|
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
|
-
|
|
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 [[
|
|
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
|
|