@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,738 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Durable Kiro hook adapter.
|
|
4
|
+
*
|
|
5
|
+
* Kiro 2.x emits inline custom-agent events (`agentSpawn`,
|
|
6
|
+
* `userPromptSubmit`, `stop`). Kiro CLI 3.x and IDE 1.x emit standalone
|
|
7
|
+
* lifecycle events (`SessionStart`, `UserPromptSubmit`, `Stop`). Both formats
|
|
8
|
+
* arrive here and are normalized into one append-only Claude-shaped JSONL
|
|
9
|
+
* journal. capture.js then owns conversation creation, acknowledgement,
|
|
10
|
+
* cursor advancement, retry, and recovery exactly as it does for other
|
|
11
|
+
* clients.
|
|
12
|
+
*
|
|
13
|
+
* Stop is intentionally silent and non-blocking: the final response is fsynced
|
|
14
|
+
* locally, then a detached Capture v2 drain is launched. The recovery timer
|
|
15
|
+
* retries anything the detached drain cannot deliver.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
"use strict";
|
|
19
|
+
|
|
20
|
+
const childProcess = require("child_process");
|
|
21
|
+
const crypto = require("crypto");
|
|
22
|
+
const fs = require("fs");
|
|
23
|
+
const path = require("path");
|
|
24
|
+
|
|
25
|
+
const DEFAULT_ENV_PATH = path.join(__dirname, "..", "kiro-hook-env.json");
|
|
26
|
+
const ACTIVE_MARKER = "MEKO_CAPTURE_SESSION_STARTED";
|
|
27
|
+
|
|
28
|
+
function loadHookEnvironment(filePath) {
|
|
29
|
+
const target = filePath || process.env.MEKO_KIRO_ENV_FILE || DEFAULT_ENV_PATH;
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(fs.readFileSync(target, "utf8"));
|
|
32
|
+
for (const key of [
|
|
33
|
+
"MEKO_MCP_URL",
|
|
34
|
+
"MEKO_API_KEY",
|
|
35
|
+
"MEKO_AGENT_ID",
|
|
36
|
+
"MEKO_WATERMARK_DIR",
|
|
37
|
+
"MEKO_DATAPACK_ID",
|
|
38
|
+
"MEKO_DATAPACK_NAME",
|
|
39
|
+
]) {
|
|
40
|
+
if (typeof parsed[key] === "string" && parsed[key] !== "") {
|
|
41
|
+
process.env[key] = parsed[key];
|
|
42
|
+
} else if (key === "MEKO_API_KEY" && parsed[key] === "") {
|
|
43
|
+
delete process.env[key];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (!err || err.code !== "ENOENT") {
|
|
48
|
+
process.stderr.write(`[meko-kiro] Could not read hook environment: ${err.message}\n`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
process.env.MEKO_HOOK_CLIENT = "kiro";
|
|
52
|
+
return target;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
loadHookEnvironment();
|
|
56
|
+
|
|
57
|
+
const captureState = require("./capture-state");
|
|
58
|
+
const {
|
|
59
|
+
deriveAgentId,
|
|
60
|
+
INTERRUPTED_ASSISTANT_OUTPUT,
|
|
61
|
+
} = require("./capture");
|
|
62
|
+
|
|
63
|
+
const EVENT_DIR = path.join(captureState.watermarkDir(), "kiro-events");
|
|
64
|
+
const LOCK_RETRIES = 100;
|
|
65
|
+
const LOCK_RETRY_MS = 10;
|
|
66
|
+
const CORRELATION_MAX_IDLE_MS = 2 * 60 * 60 * 1000;
|
|
67
|
+
const CORRELATION_START_DEDUP_MS = 30 * 1000;
|
|
68
|
+
const DEFAULT_CONVERSATION_BIND_TIMEOUT_MS = 4_000;
|
|
69
|
+
|
|
70
|
+
function firstString(...values) {
|
|
71
|
+
for (const value of values) {
|
|
72
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
73
|
+
}
|
|
74
|
+
return "";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeEventName(value) {
|
|
78
|
+
const compact = String(value || "").replace(/[^A-Za-z]/g, "").toLowerCase();
|
|
79
|
+
if (compact === "agentspawn" || compact === "sessionstart") return "session-start";
|
|
80
|
+
if (compact === "userpromptsubmit" || compact === "promptsubmit") return "user-prompt";
|
|
81
|
+
if (compact === "stop" || compact === "agentstop") return "stop";
|
|
82
|
+
return "";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function safeSessionId(value) {
|
|
86
|
+
const raw = String(value || "").trim();
|
|
87
|
+
if (/^[A-Za-z0-9_.-]+$/.test(raw)) return raw;
|
|
88
|
+
const base = raw.replace(/[^A-Za-z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "") || "kiro";
|
|
89
|
+
return `${base.slice(0, 80)}-${sha(raw).slice(0, 12)}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function sha(value) {
|
|
93
|
+
return crypto.createHash("sha256").update(String(value)).digest("hex");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function normalizeHookInput(rawInput, explicitEvent, options = {}) {
|
|
97
|
+
const raw = rawInput && typeof rawInput === "object" ? rawInput : {};
|
|
98
|
+
const nested = raw.hookSpecificInput && typeof raw.hookSpecificInput === "object"
|
|
99
|
+
? raw.hookSpecificInput
|
|
100
|
+
: {};
|
|
101
|
+
const env = options.env || process.env;
|
|
102
|
+
const event = normalizeEventName(
|
|
103
|
+
explicitEvent || raw.hook_event_name || raw.hookEventName || raw.event || raw.trigger,
|
|
104
|
+
);
|
|
105
|
+
const cwd = firstString(
|
|
106
|
+
raw.cwd,
|
|
107
|
+
raw.workspace_path,
|
|
108
|
+
raw.workspacePath,
|
|
109
|
+
raw.project_root,
|
|
110
|
+
nested.cwd,
|
|
111
|
+
nested.workspace_path,
|
|
112
|
+
nested.workspacePath,
|
|
113
|
+
nested.project_root,
|
|
114
|
+
options.cwd,
|
|
115
|
+
process.cwd(),
|
|
116
|
+
);
|
|
117
|
+
const suppliedSessionId = firstString(
|
|
118
|
+
raw.session_id,
|
|
119
|
+
raw.sessionId,
|
|
120
|
+
raw.conversation_id,
|
|
121
|
+
raw.conversationId,
|
|
122
|
+
nested.session_id,
|
|
123
|
+
nested.sessionId,
|
|
124
|
+
);
|
|
125
|
+
const hostPidRaw =
|
|
126
|
+
raw.host_pid ?? raw.hostPid ?? raw.process_id ?? raw.processId ?? raw.pid ??
|
|
127
|
+
nested.host_pid ?? nested.hostPid ?? nested.process_id ?? nested.processId ?? nested.pid ??
|
|
128
|
+
env.KIRO_HOST_PID ?? options.parentPid ?? process.ppid;
|
|
129
|
+
const hostPid = Number.isInteger(Number(hostPidRaw)) && Number(hostPidRaw) > 0
|
|
130
|
+
? Number(hostPidRaw)
|
|
131
|
+
: 0;
|
|
132
|
+
const fallbackId = `kiro-pid-${hostPid || "unknown"}-${sha(cwd).slice(0, 12)}`;
|
|
133
|
+
const sessionId = safeSessionId(suppliedSessionId || fallbackId);
|
|
134
|
+
const userPrompt = firstString(
|
|
135
|
+
raw.user_prompt,
|
|
136
|
+
raw.userPrompt,
|
|
137
|
+
raw.prompt,
|
|
138
|
+
nested.user_prompt,
|
|
139
|
+
nested.userPrompt,
|
|
140
|
+
nested.prompt,
|
|
141
|
+
env.USER_PROMPT,
|
|
142
|
+
);
|
|
143
|
+
const assistantResponse = firstString(
|
|
144
|
+
raw.assistant_response,
|
|
145
|
+
raw.assistantResponse,
|
|
146
|
+
raw.response,
|
|
147
|
+
raw.output,
|
|
148
|
+
nested.assistant_response,
|
|
149
|
+
nested.assistantResponse,
|
|
150
|
+
nested.response,
|
|
151
|
+
env.ASSISTANT_RESPONSE,
|
|
152
|
+
);
|
|
153
|
+
const eventId = firstString(
|
|
154
|
+
raw.event_id,
|
|
155
|
+
raw.eventId,
|
|
156
|
+
raw.request_id,
|
|
157
|
+
raw.requestId,
|
|
158
|
+
nested.event_id,
|
|
159
|
+
nested.eventId,
|
|
160
|
+
);
|
|
161
|
+
return {
|
|
162
|
+
event,
|
|
163
|
+
rawEvent: firstString(explicitEvent, raw.hook_event_name, raw.event, raw.trigger),
|
|
164
|
+
sessionId,
|
|
165
|
+
suppliedSessionId: Boolean(suppliedSessionId),
|
|
166
|
+
suppliedSessionValue: suppliedSessionId ? safeSessionId(suppliedSessionId) : "",
|
|
167
|
+
fallbackSessionId: fallbackId,
|
|
168
|
+
cwd,
|
|
169
|
+
hostPid,
|
|
170
|
+
userPrompt,
|
|
171
|
+
assistantResponse,
|
|
172
|
+
eventId,
|
|
173
|
+
timestamp: firstString(raw.timestamp, nested.timestamp) || new Date().toISOString(),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function eventPaths(sessionId) {
|
|
178
|
+
return {
|
|
179
|
+
journal: path.join(EVENT_DIR, `${sessionId}.jsonl`),
|
|
180
|
+
state: path.join(EVENT_DIR, `${sessionId}.state.json`),
|
|
181
|
+
lock: path.join(EVENT_DIR, `${sessionId}.lock`),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function correlationPaths(fallbackSessionId) {
|
|
186
|
+
const key = sha(fallbackSessionId).slice(0, 24);
|
|
187
|
+
return {
|
|
188
|
+
state: path.join(EVENT_DIR, `kiro-correlation-${key}.json`),
|
|
189
|
+
lock: path.join(EVENT_DIR, `kiro-correlation-${key}.lock`),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function ensureEventDir() {
|
|
194
|
+
fs.mkdirSync(EVENT_DIR, { recursive: true, mode: 0o700 });
|
|
195
|
+
try { fs.chmodSync(EVENT_DIR, 0o700); } catch { /* best effort */ }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function sleepSync(ms) {
|
|
199
|
+
const cell = new Int32Array(new SharedArrayBuffer(4));
|
|
200
|
+
Atomics.wait(cell, 0, 0, ms);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function pidAlive(pid) {
|
|
204
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
205
|
+
try {
|
|
206
|
+
process.kill(pid, 0);
|
|
207
|
+
return true;
|
|
208
|
+
} catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function acquireEventLock(lockPath) {
|
|
214
|
+
ensureEventDir();
|
|
215
|
+
for (let attempt = 0; attempt < LOCK_RETRIES; attempt++) {
|
|
216
|
+
try {
|
|
217
|
+
const fd = fs.openSync(lockPath, "wx", 0o600);
|
|
218
|
+
try {
|
|
219
|
+
fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, created_at: Date.now() }));
|
|
220
|
+
} finally {
|
|
221
|
+
fs.closeSync(fd);
|
|
222
|
+
}
|
|
223
|
+
return true;
|
|
224
|
+
} catch (err) {
|
|
225
|
+
if (!err || err.code !== "EEXIST") throw err;
|
|
226
|
+
let existing = null;
|
|
227
|
+
try { existing = JSON.parse(fs.readFileSync(lockPath, "utf8")); } catch { /* reclaim */ }
|
|
228
|
+
if (!existing || !pidAlive(Number(existing.pid))) {
|
|
229
|
+
try { fs.unlinkSync(lockPath); } catch { /* another process won */ }
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
sleepSync(LOCK_RETRY_MS);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
throw new Error(`timed out acquiring event lock ${lockPath}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function releaseEventLock(lockPath) {
|
|
239
|
+
try {
|
|
240
|
+
const current = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
241
|
+
if (Number(current.pid) === process.pid) fs.unlinkSync(lockPath);
|
|
242
|
+
} catch { /* already gone */ }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function emptyEventState(sessionId) {
|
|
246
|
+
return {
|
|
247
|
+
schema_version: 1,
|
|
248
|
+
session_id: sessionId,
|
|
249
|
+
turn_index: 0,
|
|
250
|
+
pending: null,
|
|
251
|
+
updated_at: new Date().toISOString(),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function rebuildEventState(journalPath, sessionId) {
|
|
256
|
+
const rebuilt = emptyEventState(sessionId);
|
|
257
|
+
let contents;
|
|
258
|
+
try {
|
|
259
|
+
contents = fs.readFileSync(journalPath, "utf8");
|
|
260
|
+
} catch {
|
|
261
|
+
return rebuilt;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
for (const line of contents.split("\n")) {
|
|
265
|
+
if (!line.trim()) continue;
|
|
266
|
+
let record;
|
|
267
|
+
try {
|
|
268
|
+
record = JSON.parse(line);
|
|
269
|
+
} catch {
|
|
270
|
+
// A damaged record must not make every later fsynced turn invisible.
|
|
271
|
+
// appendJournal repairs torn tails before adding new records; skipping
|
|
272
|
+
// here also lets recovery make progress after unrelated disk corruption.
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (!record || record.session_id !== sessionId) continue;
|
|
276
|
+
if (record.type === "user") {
|
|
277
|
+
const content = record.message && record.message.content;
|
|
278
|
+
const text = Array.isArray(content) && content[0] && typeof content[0].text === "string"
|
|
279
|
+
? content[0].text
|
|
280
|
+
: "";
|
|
281
|
+
rebuilt.turn_index += 1;
|
|
282
|
+
rebuilt.pending = {
|
|
283
|
+
prompt_hash: sha(text),
|
|
284
|
+
user_uuid: String(record.uuid || ""),
|
|
285
|
+
turn_index: rebuilt.turn_index,
|
|
286
|
+
};
|
|
287
|
+
} else if (record.type === "assistant" && rebuilt.pending) {
|
|
288
|
+
rebuilt.pending = null;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return rebuilt;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function readEventState(statePath, journalPath, sessionId) {
|
|
295
|
+
let persisted = null;
|
|
296
|
+
try {
|
|
297
|
+
const value = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
298
|
+
if (value && value.schema_version === 1 && value.session_id === sessionId) {
|
|
299
|
+
persisted = value;
|
|
300
|
+
}
|
|
301
|
+
} catch { /* fresh or corrupt rebuildable sidecar */ }
|
|
302
|
+
|
|
303
|
+
// The fsynced append-only journal is authoritative. Re-derive pairing on
|
|
304
|
+
// every hook so either crash window (journal append before sidecar commit)
|
|
305
|
+
// heals without duplicating or dropping a turn.
|
|
306
|
+
const rebuilt = rebuildEventState(journalPath, sessionId);
|
|
307
|
+
return {
|
|
308
|
+
...(persisted || emptyEventState(sessionId)),
|
|
309
|
+
turn_index: rebuilt.turn_index,
|
|
310
|
+
pending: rebuilt.pending,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function atomicWriteJson(filePath, value) {
|
|
315
|
+
ensureEventDir();
|
|
316
|
+
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
317
|
+
const fd = fs.openSync(tmp, "w", 0o600);
|
|
318
|
+
try {
|
|
319
|
+
fs.writeFileSync(fd, `${JSON.stringify(value)}\n`, "utf8");
|
|
320
|
+
fs.fsyncSync(fd);
|
|
321
|
+
} finally {
|
|
322
|
+
fs.closeSync(fd);
|
|
323
|
+
}
|
|
324
|
+
fs.renameSync(tmp, filePath);
|
|
325
|
+
try { fs.chmodSync(filePath, 0o600); } catch { /* best effort */ }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function appendJournal(journalPath, record) {
|
|
329
|
+
ensureEventDir();
|
|
330
|
+
try {
|
|
331
|
+
const contents = fs.readFileSync(journalPath);
|
|
332
|
+
if (contents.length > 0 && contents[contents.length - 1] !== 0x0a) {
|
|
333
|
+
const lastNewline = contents.lastIndexOf(0x0a);
|
|
334
|
+
fs.truncateSync(journalPath, lastNewline < 0 ? 0 : lastNewline + 1);
|
|
335
|
+
}
|
|
336
|
+
} catch (err) {
|
|
337
|
+
if (!err || err.code !== "ENOENT") throw err;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const data = Buffer.from(`${JSON.stringify(record)}\n`, "utf8");
|
|
341
|
+
const fd = fs.openSync(journalPath, "a", 0o600);
|
|
342
|
+
try {
|
|
343
|
+
let offset = 0;
|
|
344
|
+
while (offset < data.length) {
|
|
345
|
+
const written = fs.writeSync(fd, data, offset, data.length - offset, null);
|
|
346
|
+
if (!Number.isInteger(written) || written <= 0) {
|
|
347
|
+
throw new Error(`could not append complete Kiro journal record to ${journalPath}`);
|
|
348
|
+
}
|
|
349
|
+
offset += written;
|
|
350
|
+
}
|
|
351
|
+
fs.fsyncSync(fd);
|
|
352
|
+
} finally {
|
|
353
|
+
fs.closeSync(fd);
|
|
354
|
+
}
|
|
355
|
+
try { fs.chmodSync(journalPath, 0o600); } catch { /* best effort */ }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function resolveCorrelatedSession(event, options = {}) {
|
|
359
|
+
const fallbackSessionId = event.fallbackSessionId || event.sessionId;
|
|
360
|
+
const paths = correlationPaths(fallbackSessionId);
|
|
361
|
+
const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
|
|
362
|
+
const isHostPidAlive = options.isHostPidAlive || pidAlive;
|
|
363
|
+
acquireEventLock(paths.lock);
|
|
364
|
+
try {
|
|
365
|
+
let existing = null;
|
|
366
|
+
try {
|
|
367
|
+
const value = JSON.parse(fs.readFileSync(paths.state, "utf8"));
|
|
368
|
+
if (value?.schema_version === 1 && value?.fallback_session_id === fallbackSessionId) {
|
|
369
|
+
existing = value;
|
|
370
|
+
}
|
|
371
|
+
} catch { /* first event or rebuildable correlation sidecar */ }
|
|
372
|
+
|
|
373
|
+
const supplied = event.suppliedSessionValue || "";
|
|
374
|
+
const previous = existing;
|
|
375
|
+
if (existing) {
|
|
376
|
+
const updatedAtMs = Date.parse(existing.updated_at || "");
|
|
377
|
+
const ageMs = Number.isFinite(updatedAtMs) ? Math.max(0, nowMs - updatedAtMs) : Infinity;
|
|
378
|
+
const sameInitialPrompt = Boolean(
|
|
379
|
+
event.userPrompt &&
|
|
380
|
+
existing.last_prompt_hash &&
|
|
381
|
+
existing.last_prompt_hash === sha(event.userPrompt),
|
|
382
|
+
);
|
|
383
|
+
const duplicateInitialStart = Boolean(
|
|
384
|
+
event.event === "session-start" &&
|
|
385
|
+
ageMs <= CORRELATION_START_DEDUP_MS &&
|
|
386
|
+
(
|
|
387
|
+
existing.last_event === "session-start" ||
|
|
388
|
+
(existing.last_event === "user-prompt" && sameInitialPrompt)
|
|
389
|
+
),
|
|
390
|
+
);
|
|
391
|
+
const newFallbackLifecycle = Boolean(
|
|
392
|
+
!supplied && event.event === "session-start" && !duplicateInitialStart,
|
|
393
|
+
);
|
|
394
|
+
const hostStillOwnsCorrelation = Boolean(
|
|
395
|
+
Number(existing.host_pid) === event.hostPid && isHostPidAlive(Number(existing.host_pid)),
|
|
396
|
+
);
|
|
397
|
+
if (
|
|
398
|
+
!hostStillOwnsCorrelation ||
|
|
399
|
+
ageMs > CORRELATION_MAX_IDLE_MS ||
|
|
400
|
+
newFallbackLifecycle
|
|
401
|
+
) {
|
|
402
|
+
existing = null;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
let canonicalSessionId;
|
|
407
|
+
if (supplied && existing?.supplied_session_id === supplied) {
|
|
408
|
+
canonicalSessionId = existing.canonical_session_id;
|
|
409
|
+
} else if (supplied && existing && !existing.supplied_session_id) {
|
|
410
|
+
// The standalone format arrived first without a session ID. Bind the
|
|
411
|
+
// inline format to that already-created journal so delivery order cannot
|
|
412
|
+
// create a second conversation.
|
|
413
|
+
canonicalSessionId = existing.canonical_session_id;
|
|
414
|
+
} else if (supplied) {
|
|
415
|
+
// A different real Kiro session on the same long-lived host starts a new
|
|
416
|
+
// correlation rather than inheriting the previous PID+cwd session.
|
|
417
|
+
canonicalSessionId = supplied;
|
|
418
|
+
} else if (existing?.canonical_session_id) {
|
|
419
|
+
canonicalSessionId = existing.canonical_session_id;
|
|
420
|
+
} else if (previous) {
|
|
421
|
+
// A dead host, idle record, or new fallback-only SessionStart must not
|
|
422
|
+
// append to the prior journal. The sidecar binds concurrent delivery to
|
|
423
|
+
// this generation after the first process writes it.
|
|
424
|
+
canonicalSessionId = safeSessionId(
|
|
425
|
+
`${fallbackSessionId}-g-${sha(`${previous.canonical_session_id || ""}:${nowMs}:${event.timestamp}:${event.eventId || ""}`).slice(0, 12)}`,
|
|
426
|
+
);
|
|
427
|
+
} else {
|
|
428
|
+
canonicalSessionId = fallbackSessionId;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
atomicWriteJson(paths.state, {
|
|
432
|
+
schema_version: 1,
|
|
433
|
+
fallback_session_id: fallbackSessionId,
|
|
434
|
+
canonical_session_id: canonicalSessionId,
|
|
435
|
+
supplied_session_id: supplied || existing?.supplied_session_id || null,
|
|
436
|
+
host_pid: event.hostPid || null,
|
|
437
|
+
last_event: event.event,
|
|
438
|
+
last_prompt_hash: event.userPrompt
|
|
439
|
+
? sha(event.userPrompt)
|
|
440
|
+
: existing?.last_prompt_hash || null,
|
|
441
|
+
updated_at: new Date(nowMs).toISOString(),
|
|
442
|
+
});
|
|
443
|
+
return { ...event, sessionId: canonicalSessionId };
|
|
444
|
+
} finally {
|
|
445
|
+
releaseEventLock(paths.lock);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function journalEmpty(journalPath) {
|
|
450
|
+
try { return fs.statSync(journalPath).size === 0; } catch { return true; }
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function userRecord(event, turnIndex, userUuid) {
|
|
454
|
+
return {
|
|
455
|
+
type: "user",
|
|
456
|
+
uuid: userUuid,
|
|
457
|
+
session_id: event.sessionId,
|
|
458
|
+
cwd: event.cwd,
|
|
459
|
+
timestamp: event.timestamp,
|
|
460
|
+
message: { role: "user", content: [{ type: "text", text: event.userPrompt }] },
|
|
461
|
+
kiro: { event_id: event.eventId || null, source_event: event.rawEvent || event.event },
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function assistantRecord(event, userUuid, text, interrupted = false) {
|
|
466
|
+
return {
|
|
467
|
+
type: "assistant",
|
|
468
|
+
uuid: `kiro-assistant-${sha(`${event.sessionId}:${userUuid}:${text}`).slice(0, 24)}`,
|
|
469
|
+
session_id: event.sessionId,
|
|
470
|
+
cwd: event.cwd,
|
|
471
|
+
timestamp: event.timestamp,
|
|
472
|
+
message: { role: "assistant", content: [{ type: "text", text }] },
|
|
473
|
+
kiro: {
|
|
474
|
+
event_id: event.eventId || null,
|
|
475
|
+
source_event: event.rawEvent || event.event,
|
|
476
|
+
interrupted,
|
|
477
|
+
},
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function journalEvent(event) {
|
|
482
|
+
const paths = eventPaths(event.sessionId);
|
|
483
|
+
acquireEventLock(paths.lock);
|
|
484
|
+
try {
|
|
485
|
+
let sessionCreated = false;
|
|
486
|
+
if (journalEmpty(paths.journal)) {
|
|
487
|
+
appendJournal(paths.journal, {
|
|
488
|
+
type: "kiro_session",
|
|
489
|
+
session_id: event.sessionId,
|
|
490
|
+
cwd: event.cwd,
|
|
491
|
+
host_pid: event.hostPid || null,
|
|
492
|
+
timestamp: event.timestamp,
|
|
493
|
+
client: "kiro",
|
|
494
|
+
});
|
|
495
|
+
sessionCreated = true;
|
|
496
|
+
}
|
|
497
|
+
const state = readEventState(paths.state, paths.journal, event.sessionId);
|
|
498
|
+
|
|
499
|
+
let appended = false;
|
|
500
|
+
let interrupted = false;
|
|
501
|
+
if ((event.event === "session-start" || event.event === "user-prompt") && event.userPrompt) {
|
|
502
|
+
const promptHash = sha(event.userPrompt);
|
|
503
|
+
if (!(state.pending && state.pending.prompt_hash === promptHash)) {
|
|
504
|
+
if (state.pending) {
|
|
505
|
+
appendJournal(
|
|
506
|
+
paths.journal,
|
|
507
|
+
assistantRecord(event, state.pending.user_uuid, INTERRUPTED_ASSISTANT_OUTPUT, true),
|
|
508
|
+
);
|
|
509
|
+
interrupted = true;
|
|
510
|
+
}
|
|
511
|
+
state.turn_index += 1;
|
|
512
|
+
const userUuid = `kiro-user-${sha(`${event.sessionId}:${state.turn_index}:${event.userPrompt}`).slice(0, 24)}`;
|
|
513
|
+
appendJournal(paths.journal, userRecord(event, state.turn_index, userUuid));
|
|
514
|
+
state.pending = {
|
|
515
|
+
prompt_hash: promptHash,
|
|
516
|
+
user_uuid: userUuid,
|
|
517
|
+
turn_index: state.turn_index,
|
|
518
|
+
};
|
|
519
|
+
appended = true;
|
|
520
|
+
}
|
|
521
|
+
} else if (event.event === "stop" && event.assistantResponse && state.pending) {
|
|
522
|
+
appendJournal(
|
|
523
|
+
paths.journal,
|
|
524
|
+
assistantRecord(event, state.pending.user_uuid, event.assistantResponse, false),
|
|
525
|
+
);
|
|
526
|
+
state.pending = null;
|
|
527
|
+
appended = true;
|
|
528
|
+
}
|
|
529
|
+
state.updated_at = new Date().toISOString();
|
|
530
|
+
atomicWriteJson(paths.state, state);
|
|
531
|
+
return { paths, state, appended, interrupted, sessionCreated };
|
|
532
|
+
} finally {
|
|
533
|
+
releaseEventLock(paths.lock);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function ensureCaptureIntent(event, journalPath) {
|
|
538
|
+
const agentId = deriveAgentId({
|
|
539
|
+
client: "kiro",
|
|
540
|
+
cwd: event.cwd,
|
|
541
|
+
envOverride: process.env.MEKO_AGENT_ID,
|
|
542
|
+
});
|
|
543
|
+
const intent = captureState.writeSessionIntent(event.sessionId, {
|
|
544
|
+
client: "kiro",
|
|
545
|
+
agent_id: agentId,
|
|
546
|
+
agent_id_source: (process.env.MEKO_AGENT_ID || "").trim() ? "explicit" : "derived",
|
|
547
|
+
transcript_path: journalPath,
|
|
548
|
+
datapack_id: process.env.MEKO_DATAPACK_ID || null,
|
|
549
|
+
datapack_name: process.env.MEKO_DATAPACK_NAME || null,
|
|
550
|
+
});
|
|
551
|
+
return { ...intent, agentId };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function launchCapture(mode, payload) {
|
|
555
|
+
const captureScript = path.join(__dirname, "capture.js");
|
|
556
|
+
const child = childProcess.spawn(process.execPath, [captureScript, mode], {
|
|
557
|
+
detached: true,
|
|
558
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
559
|
+
env: { ...process.env, MEKO_HOOK_CLIENT: "kiro" },
|
|
560
|
+
});
|
|
561
|
+
child.stdin.on("error", () => {});
|
|
562
|
+
child.stdin.end(JSON.stringify(payload));
|
|
563
|
+
child.unref();
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function initializeCapture(mode, payload, timeoutMs) {
|
|
567
|
+
const captureScript = path.join(__dirname, "capture.js");
|
|
568
|
+
const parsed = Number(timeoutMs);
|
|
569
|
+
const timeout = Number.isFinite(parsed)
|
|
570
|
+
? Math.max(1, parsed)
|
|
571
|
+
: DEFAULT_CONVERSATION_BIND_TIMEOUT_MS;
|
|
572
|
+
const result = childProcess.spawnSync(process.execPath, [captureScript, mode], {
|
|
573
|
+
input: JSON.stringify(payload),
|
|
574
|
+
encoding: "utf8",
|
|
575
|
+
timeout,
|
|
576
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
577
|
+
env: { ...process.env, MEKO_HOOK_CLIENT: "kiro" },
|
|
578
|
+
});
|
|
579
|
+
if (result.error) {
|
|
580
|
+
process.stderr.write(`[meko-kiro] Initial conversation bind failed: ${result.error.message}\n`);
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
if (result.status !== 0) {
|
|
584
|
+
const detail = String(result.stderr || "").trim().split("\n").at(-1) || `exit ${result.status}`;
|
|
585
|
+
process.stderr.write(`[meko-kiro] Initial conversation bind failed: ${detail}\n`);
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function launchRecoveryTimer(journalPath) {
|
|
592
|
+
const timerScript = path.join(__dirname, "checkpoint-timer.js");
|
|
593
|
+
const child = childProcess.spawn(process.execPath, [timerScript, journalPath], {
|
|
594
|
+
detached: true,
|
|
595
|
+
stdio: "ignore",
|
|
596
|
+
env: { ...process.env, MEKO_HOOK_CLIENT: "kiro" },
|
|
597
|
+
});
|
|
598
|
+
child.unref();
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function checkpointPayload(event, journalPath) {
|
|
602
|
+
return {
|
|
603
|
+
session_id: event.sessionId,
|
|
604
|
+
transcript_path: journalPath,
|
|
605
|
+
lifecycle: "active",
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function conversationContext(sessionId) {
|
|
610
|
+
const result = captureState.readState(sessionId);
|
|
611
|
+
const conversationId = result.ok ? result.state.conversation_id : "";
|
|
612
|
+
if (typeof conversationId !== "string" || !/^[0-9a-f-]{32,36}$/i.test(conversationId)) {
|
|
613
|
+
return "";
|
|
614
|
+
}
|
|
615
|
+
return (
|
|
616
|
+
`MEKO_CONVERSATION_ID=${conversationId}\n` +
|
|
617
|
+
"Use this exact conversation_id for Meko tool calls that accept it.\n"
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function handleNormalizedEvent(event, options = {}) {
|
|
622
|
+
if (!event.event) return { stdout: "", status: "ignored", event };
|
|
623
|
+
event = resolveCorrelatedSession(event, options);
|
|
624
|
+
const journaled = journalEvent(event);
|
|
625
|
+
const intent = ensureCaptureIntent(event, journaled.paths.journal);
|
|
626
|
+
if (!intent.ok) {
|
|
627
|
+
process.stderr.write(`[meko-kiro] Capture intent failed: ${intent.error}\n`);
|
|
628
|
+
return { stdout: "", status: "intent-failed", event, journaled, intent };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const launch = options.launch !== false;
|
|
632
|
+
const startCapture = options.launchCapture || launchCapture;
|
|
633
|
+
const bindConversation = options.initializeCapture || initializeCapture;
|
|
634
|
+
const startRecoveryTimer = options.launchRecoveryTimer || launchRecoveryTimer;
|
|
635
|
+
if (event.event === "session-start") {
|
|
636
|
+
if (launch && journaled.sessionCreated) {
|
|
637
|
+
// Kiro does not consume capture.js SessionStart context. Initialize via
|
|
638
|
+
// a bounded checkpoint so conversation create + bind completes before
|
|
639
|
+
// the immediately-following first prompt can invoke tools. Capture v2's
|
|
640
|
+
// per-session drain lease still serializes all initialization.
|
|
641
|
+
bindConversation(
|
|
642
|
+
"checkpoint",
|
|
643
|
+
checkpointPayload(event, journaled.paths.journal),
|
|
644
|
+
options.conversationBindTimeoutMs,
|
|
645
|
+
);
|
|
646
|
+
startRecoveryTimer(journaled.paths.journal);
|
|
647
|
+
}
|
|
648
|
+
const context = conversationContext(event.sessionId);
|
|
649
|
+
return {
|
|
650
|
+
stdout:
|
|
651
|
+
`${ACTIVE_MARKER}\n` +
|
|
652
|
+
"Meko Capture v2 is active for this Kiro session. Follow the hook-capture path in Meko steering; do not proactively duplicate captured conversational facts.\n" +
|
|
653
|
+
context,
|
|
654
|
+
status: "active",
|
|
655
|
+
event,
|
|
656
|
+
journaled,
|
|
657
|
+
intent,
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (event.event === "user-prompt") {
|
|
662
|
+
if (launch && journaled.sessionCreated) {
|
|
663
|
+
// A standalone UserPromptSubmit can be the first trusted hook seen (for
|
|
664
|
+
// example after an upgrade). The journal header is the durable once-per-
|
|
665
|
+
// session launch claim, so dual-format delivery cannot fan out creators.
|
|
666
|
+
bindConversation(
|
|
667
|
+
"checkpoint",
|
|
668
|
+
checkpointPayload(event, journaled.paths.journal),
|
|
669
|
+
options.conversationBindTimeoutMs,
|
|
670
|
+
);
|
|
671
|
+
startRecoveryTimer(journaled.paths.journal);
|
|
672
|
+
}
|
|
673
|
+
let context = conversationContext(event.sessionId);
|
|
674
|
+
if (launch && !context && !journaled.sessionCreated) {
|
|
675
|
+
// The prior agentSpawn may have timed out or been delivered concurrently.
|
|
676
|
+
// Retry through the same leased checkpoint before this prompt can use
|
|
677
|
+
// tools; recovery remains responsible if the endpoint is unavailable.
|
|
678
|
+
bindConversation(
|
|
679
|
+
"checkpoint",
|
|
680
|
+
checkpointPayload(event, journaled.paths.journal),
|
|
681
|
+
options.conversationBindTimeoutMs,
|
|
682
|
+
);
|
|
683
|
+
context = conversationContext(event.sessionId);
|
|
684
|
+
}
|
|
685
|
+
return {
|
|
686
|
+
stdout: context,
|
|
687
|
+
status: "journaled",
|
|
688
|
+
event,
|
|
689
|
+
journaled,
|
|
690
|
+
intent,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (event.event === "stop" && launch && journaled.appended) {
|
|
695
|
+
startCapture("checkpoint", checkpointPayload(event, journaled.paths.journal));
|
|
696
|
+
}
|
|
697
|
+
return { stdout: "", status: "journaled", event, journaled, intent };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function handleHookInput(rawInput, explicitEvent, options = {}) {
|
|
701
|
+
return handleNormalizedEvent(
|
|
702
|
+
normalizeHookInput(rawInput, explicitEvent, options),
|
|
703
|
+
options,
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function main() {
|
|
708
|
+
let input = {};
|
|
709
|
+
try {
|
|
710
|
+
input = JSON.parse(fs.readFileSync(0, "utf8") || "{}");
|
|
711
|
+
} catch (err) {
|
|
712
|
+
process.stderr.write(`[meko-kiro] Invalid hook input: ${err.message}\n`);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
try {
|
|
716
|
+
const result = handleHookInput(input, process.argv[2] || "");
|
|
717
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
718
|
+
} catch (err) {
|
|
719
|
+
// Hook commands are observability plumbing, not a reason to fail the Kiro
|
|
720
|
+
// lifecycle event. Keep stdout protocol-clean and leave a concise local
|
|
721
|
+
// diagnostic while the durable recovery path handles prior records.
|
|
722
|
+
process.stderr.write(`[meko-kiro] Hook failed: ${err.message}\n`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
module.exports = {
|
|
727
|
+
ACTIVE_MARKER,
|
|
728
|
+
normalizeEventName,
|
|
729
|
+
normalizeHookInput,
|
|
730
|
+
safeSessionId,
|
|
731
|
+
eventPaths,
|
|
732
|
+
journalEvent,
|
|
733
|
+
handleNormalizedEvent,
|
|
734
|
+
handleHookInput,
|
|
735
|
+
loadHookEnvironment,
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
if (require.main === module) main();
|