@yugabytedb/meko-mcp 2.1.0 → 2.3.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 +233 -40
- 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-cleanup-cli.js +1473 -0
- package/assets/hooks-handlers/lib/capture-notices.js +438 -0
- package/assets/hooks-handlers/lib/capture-state.js +2173 -0
- package/assets/hooks-handlers/lib/capture-status-cli.js +86 -0
- package/assets/hooks-handlers/lib/capture.js +2782 -403
- package/assets/hooks-handlers/lib/checkpoint-timer.js +485 -81
- package/assets/hooks-handlers/lib/journal-bridge.js +218 -0
- package/assets/hooks-handlers/lib/kiro-handler.js +760 -0
- package/assets/hooks-handlers/lib/opencode-handler.js +620 -0
- package/assets/hooks-handlers/lib/opencode-normalizer.js +145 -0
- package/assets/hooks-handlers/lib/terminal-handoff.js +427 -0
- package/assets/hooks-handlers/session-end.sh +3 -34
- package/assets/opencode-plugin/meko.js +213 -0
- package/assets/scripts/meko-statusline.sh +123 -16
- package/assets/skills/meko-mcp-tools/SKILL.md +116 -126
- package/assets/skills/meko-mcp-tools/references/_sections.md +3 -3
- package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +25 -18
- package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +99 -24
- package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +1 -1
- package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +53 -6
- package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +25 -79
- package/assets/skills/meko-mcp-tools/references/tools-overview.md +37 -26
- package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +14 -8
- package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +66 -1
- package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +7 -40
- package/assets/skills/meko-mcp-tools-desktop/SKILL.md +56 -20
- 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 +15 -8
- package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +99 -36
- package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +22 -5
- package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +18 -14
- package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +39 -28
- package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +14 -8
- package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +67 -1
- package/assets/skills/meko-select-datapack-desktop/SKILL.md +4 -4
- package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +8 -8
- package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +7 -5
- package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +4 -4
- package/bin/create-meko-setup.mjs +113 -12
- package/lib/capture-cleanup.mjs +63 -0
- package/lib/capture-status.mjs +67 -0
- package/lib/clients/aliases.mjs +5 -0
- package/lib/clients/claude-code.mjs +1 -0
- package/lib/clients/claude-desktop.mjs +120 -5
- package/lib/clients/codex.mjs +85 -37
- package/lib/clients/common.mjs +7 -0
- package/lib/clients/index.mjs +4 -0
- package/lib/clients/kiro.mjs +705 -0
- package/lib/clients/opencode.mjs +532 -0
- package/lib/config.mjs +158 -1
- package/lib/installer.mjs +18 -1
- package/lib/migrate/adapters/claude-code.mjs +11 -2
- package/lib/migrate/adapters/codex.mjs +497 -0
- package/lib/migrate/adapters/memory-md.mjs +27 -8
- package/lib/migrate/adapters/opencode.mjs +148 -0
- package/lib/migrate/claude-parser.mjs +153 -9
- package/lib/migrate/id.mjs +217 -10
- package/lib/migrate/mcp-client.mjs +25 -1
- package/lib/migrate/opencode-normalizer.mjs +132 -0
- package/lib/migrate/orchestrator.mjs +299 -56
- package/lib/migrate/progress.mjs +239 -5
- package/lib/migrate/registry.mjs +72 -0
- package/lib/migrate.mjs +100 -10
- package/lib/prompt.mjs +16 -1
- package/package.json +4 -4
- package/scripts/bundle-assets.mjs +4 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CJS mirror of installer/lib/migrate/opencode-normalizer.mjs for capture handler.
|
|
3
|
+
* Keep logic in sync — golden tests assert byte-equivalent exchanges.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
const SYNTHETIC_PART_TYPES = new Set(["compaction", "subtask", "continuation"]);
|
|
9
|
+
const EXCLUDED_PART_TYPES = new Set([
|
|
10
|
+
"tool", "tool-invocation", "tool-result", "tool_result", "reasoning",
|
|
11
|
+
"file", "attachment", "step", "snapshot", "patch",
|
|
12
|
+
]);
|
|
13
|
+
const INTERRUPTED_MARKER =
|
|
14
|
+
"[Meko capture: assistant response was interrupted before completion.]";
|
|
15
|
+
function normalizeOpenCodeSession(exportShape, opts = {}) {
|
|
16
|
+
const root = exportShape && typeof exportShape === "object" ? exportShape : {};
|
|
17
|
+
const info = root.info && typeof root.info === "object" ? root.info : {};
|
|
18
|
+
const rawMessages = Array.isArray(root.messages) ? root.messages : [];
|
|
19
|
+
const sessionId = firstString(info.id, info.sessionID, info.sessionId, root.sessionId) || "unknown";
|
|
20
|
+
const cwd = firstString(info.cwd, info.directory, info.projectPath, root.cwd) || "";
|
|
21
|
+
const parentSessionId = firstString(info.parentID, info.parentId, info.parentSessionId) || null;
|
|
22
|
+
const title = firstString(info.title, info.name) || sessionId;
|
|
23
|
+
const startedAt = firstTimestamp(info.time?.created, info.created, info.startedAt, info.time);
|
|
24
|
+
const visibleMessages = rawMessages.map(normalizeMessage).filter(Boolean).sort(compareMessageOrder);
|
|
25
|
+
const exchanges = [];
|
|
26
|
+
let pendingUser = null;
|
|
27
|
+
let firstUserText = "";
|
|
28
|
+
for (const msg of visibleMessages) {
|
|
29
|
+
if (msg.role === "user") {
|
|
30
|
+
if (pendingUser) exchanges.push(makeExchange(pendingUser, null, { forceInterrupted: true }));
|
|
31
|
+
pendingUser = msg;
|
|
32
|
+
if (!firstUserText && msg.text) firstUserText = msg.text.slice(0, 240);
|
|
33
|
+
} else if (msg.role === "assistant" && pendingUser) {
|
|
34
|
+
exchanges.push(makeExchange(pendingUser, msg, {}));
|
|
35
|
+
pendingUser = null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
let partial = Boolean(pendingUser);
|
|
39
|
+
if (pendingUser && opts.emitPendingAsExchange) {
|
|
40
|
+
// Live-capture mode. Emit the trailing pending user with no assistant
|
|
41
|
+
// output; the reconciler holds it until a later snapshot completes it,
|
|
42
|
+
// a later user interrupts it, or MEKO_OPENCODE_SETTLE_MS expires.
|
|
43
|
+
exchanges.push(makeExchange(pendingUser, null, {}));
|
|
44
|
+
pendingUser = null;
|
|
45
|
+
partial = false;
|
|
46
|
+
} else if (pendingUser && (opts.terminalError || opts.interrupted)) {
|
|
47
|
+
exchanges.push(makeExchange(pendingUser, null, { ...opts, interrupted: true }));
|
|
48
|
+
pendingUser = null;
|
|
49
|
+
partial = false;
|
|
50
|
+
} else if (!pendingUser && (opts.terminalError || opts.interrupted) && exchanges.length) {
|
|
51
|
+
markInterrupted(exchanges[exchanges.length - 1]);
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
sessionId, cwd, parentSessionId, title,
|
|
55
|
+
startedAt: startedAt || visibleMessages[0]?.timestamp || null,
|
|
56
|
+
firstUserText,
|
|
57
|
+
exchanges: exchanges.filter((ex) => ex.input || ex.output),
|
|
58
|
+
partial,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function normalizeMessage(raw) {
|
|
62
|
+
if (!raw || typeof raw !== "object") return null;
|
|
63
|
+
const info = raw.info && typeof raw.info === "object" ? raw.info : raw;
|
|
64
|
+
const role = firstString(info.role, raw.role)?.toLowerCase();
|
|
65
|
+
if (role !== "user" && role !== "assistant") return null;
|
|
66
|
+
const synthetic = Boolean(
|
|
67
|
+
info.synthetic || info.compaction || info.subtask || info.continuation ||
|
|
68
|
+
SYNTHETIC_PART_TYPES.has(String(info.type || "").toLowerCase()),
|
|
69
|
+
);
|
|
70
|
+
if (synthetic) return null;
|
|
71
|
+
const parts = Array.isArray(raw.parts) ? raw.parts : [];
|
|
72
|
+
const text = joinVisibleText(parts);
|
|
73
|
+
if (!text && role === "assistant") return null;
|
|
74
|
+
return {
|
|
75
|
+
role,
|
|
76
|
+
id: firstString(info.id, raw.id, raw.uuid) || "",
|
|
77
|
+
timestamp: firstTimestamp(info.time?.created, info.createdAt, info.timestamp, raw.timestamp) || "",
|
|
78
|
+
text,
|
|
79
|
+
order: Number.isFinite(Number(info.order)) ? Number(info.order) : null,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function joinVisibleText(parts) {
|
|
83
|
+
const chunks = [];
|
|
84
|
+
for (const part of parts) {
|
|
85
|
+
if (!part || typeof part !== "object") continue;
|
|
86
|
+
if (part.synthetic || part.ignored) continue;
|
|
87
|
+
const type = String(part.type || "text").toLowerCase();
|
|
88
|
+
if (EXCLUDED_PART_TYPES.has(type) || SYNTHETIC_PART_TYPES.has(type)) continue;
|
|
89
|
+
if (type !== "text") continue;
|
|
90
|
+
const text = typeof part.text === "string" ? part.text : "";
|
|
91
|
+
if (!text.trim()) continue;
|
|
92
|
+
chunks.push(text);
|
|
93
|
+
}
|
|
94
|
+
return chunks.join("\n");
|
|
95
|
+
}
|
|
96
|
+
function compareMessageOrder(a, b) {
|
|
97
|
+
const ao = a.order; const bo = b.order;
|
|
98
|
+
if (ao != null && bo != null && ao !== bo) return ao - bo;
|
|
99
|
+
if (a.timestamp && b.timestamp && a.timestamp !== b.timestamp) {
|
|
100
|
+
return a.timestamp < b.timestamp ? -1 : 1;
|
|
101
|
+
}
|
|
102
|
+
return String(a.id).localeCompare(String(b.id));
|
|
103
|
+
}
|
|
104
|
+
function makeExchange(userMsg, assistantMsg, opts) {
|
|
105
|
+
const interrupted = Boolean(opts.interrupted || (!assistantMsg && opts.forceInterrupted));
|
|
106
|
+
return {
|
|
107
|
+
userId: userMsg.id || `opencode-user-${hashish(userMsg.text)}`,
|
|
108
|
+
assistantId: assistantMsg?.id || null,
|
|
109
|
+
input: userMsg.text,
|
|
110
|
+
output: interrupted ? INTERRUPTED_MARKER : (assistantMsg?.text || ""),
|
|
111
|
+
timestamp: assistantMsg?.timestamp || userMsg.timestamp || "",
|
|
112
|
+
interrupted,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function markInterrupted(exchange) {
|
|
116
|
+
exchange.interrupted = true;
|
|
117
|
+
exchange.output = exchange.output
|
|
118
|
+
? `${exchange.output}\n\n${INTERRUPTED_MARKER}`
|
|
119
|
+
: INTERRUPTED_MARKER;
|
|
120
|
+
}
|
|
121
|
+
function firstString(...values) {
|
|
122
|
+
for (const value of values) {
|
|
123
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
124
|
+
}
|
|
125
|
+
return "";
|
|
126
|
+
}
|
|
127
|
+
function firstTimestamp(...values) {
|
|
128
|
+
for (const value of values) {
|
|
129
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
130
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
131
|
+
const date = new Date(value);
|
|
132
|
+
if (!Number.isNaN(date.getTime())) return date.toISOString();
|
|
133
|
+
}
|
|
134
|
+
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString();
|
|
135
|
+
}
|
|
136
|
+
return "";
|
|
137
|
+
}
|
|
138
|
+
function hashish(text) {
|
|
139
|
+
let h = 0;
|
|
140
|
+
const s = String(text || "");
|
|
141
|
+
for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
|
142
|
+
return Math.abs(h).toString(36);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = { normalizeOpenCodeSession, INTERRUPTED_MARKER };
|
|
@@ -0,0 +1,427 @@
|
|
|
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
|
+
if (untilDue >= remaining) {
|
|
363
|
+
// The retry falls after this worker's age ceiling (for example a
|
|
364
|
+
// session parked by --capture-cleanup). Sleeping would only hold a
|
|
365
|
+
// process for the rest of the day; timer recovery owns the retry.
|
|
366
|
+
process.stderr.write(
|
|
367
|
+
`[meko-capture] SessionEnd worker: next retry ${result.next_retry_at} is past the worker ceiling; leaving outbox pending.\n`,
|
|
368
|
+
);
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
await sleep(untilDue);
|
|
372
|
+
delayMs = Math.min(delayMs * 2, 60_000);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (result && Number(result.queued_remaining || 0) > 0) {
|
|
376
|
+
// More work immediately available.
|
|
377
|
+
delayMs = 2000;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
// Ambiguous / empty result — short pause then retry until age ceiling.
|
|
381
|
+
await sleep(Math.min(delayMs, 30_000));
|
|
382
|
+
delayMs = Math.min(delayMs * 2, 60_000);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (Date.now() - started >= WORKER_MAX_AGE_MS) {
|
|
386
|
+
process.stderr.write(
|
|
387
|
+
`[meko-capture] SessionEnd worker hit ${workerMaxAgeSeconds()}s ceiling; leaving outbox pending.\n`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
cleanupJob();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
module.exports = {
|
|
395
|
+
killCheckpointTimer,
|
|
396
|
+
extractSessionId,
|
|
397
|
+
persistClosingIntent,
|
|
398
|
+
enqueueFromStdin,
|
|
399
|
+
runWorker,
|
|
400
|
+
shouldStop,
|
|
401
|
+
WORKER_MAX_AGE_MS,
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
async function main() {
|
|
405
|
+
const mode = process.argv[2];
|
|
406
|
+
if (mode === "enqueue") {
|
|
407
|
+
enqueueFromStdin();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (mode === "worker") {
|
|
411
|
+
await runWorker(process.argv[3]);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
process.stderr.write(
|
|
415
|
+
"Usage: terminal-handoff.js <enqueue|worker> [job-file]\n",
|
|
416
|
+
);
|
|
417
|
+
process.exit(1);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (require.main === module) {
|
|
421
|
+
main().catch((err) => {
|
|
422
|
+
process.stderr.write(
|
|
423
|
+
`[meko-capture] terminal-handoff fatal: ${err.message}\n`,
|
|
424
|
+
);
|
|
425
|
+
process.exit(1);
|
|
426
|
+
});
|
|
427
|
+
}
|
|
@@ -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
|