@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.
- package/README.md +152 -27
- package/assets/hooks-handlers/codex-session-end.sh +4 -69
- 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 +1578 -299
- package/assets/hooks-handlers/lib/checkpoint-timer.js +444 -81
- 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 -34
- package/assets/scripts/meko-statusline.sh +41 -1
- package/assets/skills/meko-mcp-tools/SKILL.md +72 -125
- package/assets/skills/meko-mcp-tools/references/_sections.md +2 -2
- package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +12 -13
- package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +15 -12
- package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +25 -1
- package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +19 -77
- package/assets/skills/meko-mcp-tools/references/tools-overview.md +8 -7
- package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +22 -1
- package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +1 -1
- package/assets/skills/meko-mcp-tools-desktop/SKILL.md +8 -8
- package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +1 -1
- 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 +6 -1
- package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +6 -5
- package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +4 -4
- package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +7 -6
- package/assets/skills/meko-select-datapack-desktop/SKILL.md +1 -1
- package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +3 -3
- package/bin/create-meko-setup.mjs +48 -10
- package/lib/capture-status.mjs +67 -0
- package/lib/clients/aliases.mjs +5 -0
- package/lib/clients/claude-desktop.mjs +120 -5
- package/lib/clients/codex.mjs +6 -3
- package/lib/clients/index.mjs +2 -0
- package/lib/clients/kiro.mjs +705 -0
- package/lib/config.mjs +90 -0
- 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 +16 -1
- 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 —
|
|
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
|
-
# 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 [[
|
|
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
|
|