@yugabytedb/meko-mcp 2.2.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 +106 -38
- package/assets/hooks-handlers/lib/capture-cleanup-cli.js +1473 -0
- package/assets/hooks-handlers/lib/capture-notices.js +131 -14
- package/assets/hooks-handlers/lib/capture-state.js +95 -1
- package/assets/hooks-handlers/lib/capture-status-cli.js +6 -2
- package/assets/hooks-handlers/lib/capture.js +1237 -137
- package/assets/hooks-handlers/lib/checkpoint-timer.js +51 -10
- package/assets/hooks-handlers/lib/journal-bridge.js +218 -0
- package/assets/hooks-handlers/lib/kiro-handler.js +26 -4
- 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 +10 -1
- package/assets/opencode-plugin/meko.js +213 -0
- package/assets/scripts/meko-statusline.sh +85 -18
- package/assets/skills/meko-mcp-tools/SKILL.md +57 -14
- package/assets/skills/meko-mcp-tools/references/_sections.md +2 -2
- package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +15 -7
- package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +86 -14
- package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +1 -1
- package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +29 -6
- package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +8 -4
- package/assets/skills/meko-mcp-tools/references/tools-overview.md +32 -22
- package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +14 -8
- package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +45 -1
- package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +6 -39
- package/assets/skills/meko-mcp-tools-desktop/SKILL.md +54 -18
- 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 +8 -6
- package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +95 -33
- 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 +14 -10
- package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +35 -25
- 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 +5 -5
- 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 +70 -7
- package/lib/capture-cleanup.mjs +63 -0
- package/lib/clients/claude-code.mjs +1 -0
- package/lib/clients/codex.mjs +80 -35
- package/lib/clients/common.mjs +7 -0
- package/lib/clients/index.mjs +2 -0
- package/lib/clients/kiro.mjs +5 -5
- package/lib/clients/opencode.mjs +532 -0
- package/lib/config.mjs +68 -1
- package/lib/installer.mjs +18 -2
- package/lib/migrate/adapters/claude-code.mjs +11 -2
- package/lib/migrate/adapters/codex.mjs +9 -2
- 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 +195 -13
- package/lib/migrate/mcp-client.mjs +25 -1
- package/lib/migrate/opencode-normalizer.mjs +132 -0
- package/lib/migrate/orchestrator.mjs +293 -77
- package/lib/migrate/progress.mjs +239 -5
- package/lib/migrate/registry.mjs +72 -0
- package/lib/migrate.mjs +96 -8
- package/package.json +1 -1
- package/scripts/bundle-assets.mjs +4 -0
package/lib/migrate/progress.mjs
CHANGED
|
@@ -1,19 +1,66 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* progress.mjs — Lightweight progress snapshots for `--migrate-status`.
|
|
3
|
+
*
|
|
4
|
+
* A run's status is a small state machine. Four states are written to
|
|
5
|
+
* `progress.json`:
|
|
6
|
+
*
|
|
7
|
+
* running: written once at daemon start.
|
|
8
|
+
* succeeded: written from the normal completion path.
|
|
9
|
+
* failed: written from the top-level exception handler, with the
|
|
10
|
+
* error class and message.
|
|
11
|
+
* cancelled: written from the SIGINT/SIGTERM handler before exit.
|
|
12
|
+
*
|
|
13
|
+
* A fifth state, `crashed`, is never written. The reader infers it for a
|
|
14
|
+
* `running` record whose PID is dead or whose heartbeat has gone stale,
|
|
15
|
+
* which is how a `kill -9`, an OOM kill, or a laptop sleep gets reported
|
|
16
|
+
* honestly instead of as a live migration. See `classify()`.
|
|
17
|
+
*
|
|
18
|
+
* `heartbeatAt` is refreshed on a timer for the same reason: a silently
|
|
19
|
+
* killed daemon runs no handler, so the only evidence of death is the
|
|
20
|
+
* absence of new writes.
|
|
3
21
|
*/
|
|
4
22
|
|
|
5
23
|
import fs from "node:fs";
|
|
6
24
|
import path from "node:path";
|
|
7
25
|
|
|
26
|
+
/** How often the daemon refreshes `heartbeatAt`. */
|
|
27
|
+
export const HEARTBEAT_INTERVAL_MS = 10_000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* How long a heartbeat may go unrefreshed before the reader calls the run
|
|
31
|
+
* dead. Three intervals of slack absorbs a paused event loop (a long
|
|
32
|
+
* `memory_add` extraction, a suspended laptop) without flapping.
|
|
33
|
+
*/
|
|
34
|
+
export const HEARTBEAT_STALE_AFTER_MS = HEARTBEAT_INTERVAL_MS * 3;
|
|
35
|
+
|
|
36
|
+
/** Terminal states: a run in one of these will never advance again. */
|
|
37
|
+
export const TERMINAL_STATUSES = ["succeeded", "failed", "cancelled"];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Pre-2.3 runs on disk used `done` for success and `interrupted` for a
|
|
41
|
+
* signal. The reader normalizes them so a status read against a run
|
|
42
|
+
* started by an older installer still lands in the documented state set.
|
|
43
|
+
*/
|
|
44
|
+
const LEGACY_STATUS_ALIASES = {
|
|
45
|
+
done: "succeeded",
|
|
46
|
+
interrupted: "cancelled",
|
|
47
|
+
stopped: "cancelled",
|
|
48
|
+
};
|
|
49
|
+
|
|
8
50
|
/**
|
|
9
51
|
* @typedef {Object} Progress
|
|
10
52
|
* @property {string} runId
|
|
11
53
|
* @property {string} scope
|
|
12
54
|
* @property {string} startedAt
|
|
13
55
|
* @property {string|null} finishedAt
|
|
14
|
-
* @property {
|
|
56
|
+
* @property {string|null} heartbeatAt ISO timestamp, refreshed on a timer
|
|
57
|
+
* while the run is `running`.
|
|
58
|
+
* @property {number|null} pid PID of the daemon that owns the run.
|
|
59
|
+
* @property {"running"|"succeeded"|"failed"|"cancelled"} status
|
|
60
|
+
* @property {string|null} error Set alongside `failed`.
|
|
15
61
|
* @property {string|null} currentAgent
|
|
16
|
-
* @property {string|null} currentSession
|
|
62
|
+
* @property {string|null} currentSession Source session UUID being replayed.
|
|
63
|
+
* @property {string|null} currentMessage Source message (turn) id being sent.
|
|
17
64
|
* @property {Counts} counts
|
|
18
65
|
*
|
|
19
66
|
* @typedef {Object} Counts
|
|
@@ -24,20 +71,26 @@ import path from "node:path";
|
|
|
24
71
|
* @property {number} memoriesFromMD
|
|
25
72
|
* @property {number} warnings
|
|
26
73
|
* @property {number} memoryErrors
|
|
74
|
+
* @property {number} authSkipped Calls dropped on a mid-run 401/403.
|
|
27
75
|
*/
|
|
28
76
|
|
|
29
77
|
/**
|
|
30
78
|
* @returns {Progress}
|
|
31
79
|
*/
|
|
32
|
-
export function initial(runId, scope) {
|
|
80
|
+
export function initial(runId, scope, opts = {}) {
|
|
81
|
+
const now = new Date().toISOString();
|
|
33
82
|
return {
|
|
34
83
|
runId,
|
|
35
84
|
scope,
|
|
36
|
-
startedAt:
|
|
85
|
+
startedAt: now,
|
|
37
86
|
finishedAt: null,
|
|
87
|
+
heartbeatAt: now,
|
|
88
|
+
pid: opts.pid ?? process.pid,
|
|
38
89
|
status: "running",
|
|
90
|
+
error: null,
|
|
39
91
|
currentAgent: null,
|
|
40
92
|
currentSession: null,
|
|
93
|
+
currentMessage: null,
|
|
41
94
|
counts: {
|
|
42
95
|
sessionsPlanned: 0,
|
|
43
96
|
sessionsDone: 0,
|
|
@@ -46,17 +99,26 @@ export function initial(runId, scope) {
|
|
|
46
99
|
memoriesFromMD: 0,
|
|
47
100
|
warnings: 0,
|
|
48
101
|
memoryErrors: 0,
|
|
102
|
+
authSkipped: 0,
|
|
49
103
|
},
|
|
50
104
|
};
|
|
51
105
|
}
|
|
52
106
|
|
|
53
107
|
/**
|
|
54
|
-
* Atomic write.
|
|
108
|
+
* Atomic write. Refreshes `heartbeatAt` on every write of a `running`
|
|
109
|
+
* record, so ordinary progress writes double as heartbeats and the timer
|
|
110
|
+
* only matters during long gaps between them.
|
|
55
111
|
*
|
|
56
112
|
* @param {string} progressPath
|
|
57
113
|
* @param {Progress} progress
|
|
58
114
|
*/
|
|
59
115
|
export function write(progressPath, progress) {
|
|
116
|
+
if (progress && progress.status === "running") {
|
|
117
|
+
// Once `finalize()` has closed this run, a `running` write can only be
|
|
118
|
+
// stale: the live object a caller still holds, or a heartbeat tick.
|
|
119
|
+
if (sealedPaths.has(progressPath)) return;
|
|
120
|
+
progress.heartbeatAt = new Date().toISOString();
|
|
121
|
+
}
|
|
60
122
|
fs.mkdirSync(path.dirname(progressPath), { recursive: true });
|
|
61
123
|
const tmp = `${progressPath}.tmp.${process.pid}.${Date.now()}`;
|
|
62
124
|
fs.writeFileSync(tmp, JSON.stringify(progress, null, 2));
|
|
@@ -74,3 +136,175 @@ export function read(progressPath) {
|
|
|
74
136
|
return null;
|
|
75
137
|
}
|
|
76
138
|
}
|
|
139
|
+
|
|
140
|
+
// --- Heartbeat -------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
// Module singleton. The orchestrator starts the heartbeat and the daemon
|
|
143
|
+
// entrypoint stops it from its catch/signal handlers, which run in the same
|
|
144
|
+
// process after `runMigration` has stopped writing. Keeping the handle here
|
|
145
|
+
// rather than threading it through a return value means the handlers can
|
|
146
|
+
// stop it even on the path where `runMigration` threw and returned nothing.
|
|
147
|
+
let heartbeatTimer = null;
|
|
148
|
+
|
|
149
|
+
// Progress files that `finalize()` has moved to a terminal status. The
|
|
150
|
+
// catch and signal handlers finalize by read-modify-write, so the
|
|
151
|
+
// orchestrator's live `prog` object still says `running`. Sealing the path
|
|
152
|
+
// makes any later write of that object a no-op instead of a resurrection.
|
|
153
|
+
const sealedPaths = new Set();
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Refresh `heartbeatAt` every `HEARTBEAT_INTERVAL_MS` while the run is in
|
|
157
|
+
* flight. The timer is `unref`'d so it never keeps the process alive on its
|
|
158
|
+
* own; `stopHeartbeat()` is still the correct way to end it, because a live
|
|
159
|
+
* timer that fires after a terminal write would resurrect `running`.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} progressPath
|
|
162
|
+
* @param {Progress} prog The live object the orchestrator mutates.
|
|
163
|
+
* @returns {() => void} Stop function; same effect as `stopHeartbeat()`.
|
|
164
|
+
*/
|
|
165
|
+
export function startHeartbeat(progressPath, prog) {
|
|
166
|
+
stopHeartbeat();
|
|
167
|
+
heartbeatTimer = setInterval(() => {
|
|
168
|
+
if (!prog || prog.status !== "running") return;
|
|
169
|
+
try {
|
|
170
|
+
write(progressPath, prog);
|
|
171
|
+
} catch {
|
|
172
|
+
/* best effort; a missed heartbeat only costs a stale-looking read */
|
|
173
|
+
}
|
|
174
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
175
|
+
if (typeof heartbeatTimer.unref === "function") heartbeatTimer.unref();
|
|
176
|
+
return stopHeartbeat;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function stopHeartbeat() {
|
|
180
|
+
if (heartbeatTimer) {
|
|
181
|
+
clearInterval(heartbeatTimer);
|
|
182
|
+
heartbeatTimer = null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Whether a heartbeat timer is live. Lets tests prove the run stopped it. */
|
|
187
|
+
export function isHeartbeatRunning() {
|
|
188
|
+
return heartbeatTimer !== null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Write a terminal status by read-modify-write, preserving whatever counts
|
|
193
|
+
* the orchestrator last flushed.
|
|
194
|
+
*
|
|
195
|
+
* Used by the daemon entrypoint's catch and signal handlers, which don't
|
|
196
|
+
* hold the orchestrator's live `prog` object. Stops the heartbeat and seals
|
|
197
|
+
* the path first, so neither the timer nor a late write of the live object
|
|
198
|
+
* can overwrite the terminal record.
|
|
199
|
+
*
|
|
200
|
+
* @param {string} progressPath
|
|
201
|
+
* @param {object} opts
|
|
202
|
+
* @param {"succeeded"|"failed"|"cancelled"} opts.status
|
|
203
|
+
* @param {string|null} [opts.error]
|
|
204
|
+
* @returns {{record: Progress|null, wrote: boolean}} `record` is null when
|
|
205
|
+
* there was no progress file. `wrote` is false when there was nothing to
|
|
206
|
+
* finalize or the record was already terminal, so callers only mirror a
|
|
207
|
+
* transition into the event log when it actually happened.
|
|
208
|
+
*/
|
|
209
|
+
export function finalize(progressPath, opts) {
|
|
210
|
+
stopHeartbeat();
|
|
211
|
+
sealedPaths.add(progressPath);
|
|
212
|
+
const prog = read(progressPath);
|
|
213
|
+
if (!prog) return { record: null, wrote: false };
|
|
214
|
+
// Don't overwrite an already-terminal record: a signal that arrives while
|
|
215
|
+
// the process is on its way out shouldn't rewrite `succeeded` as
|
|
216
|
+
// `cancelled`.
|
|
217
|
+
if (TERMINAL_STATUSES.includes(normalizeStatus(prog.status))) {
|
|
218
|
+
return { record: prog, wrote: false };
|
|
219
|
+
}
|
|
220
|
+
prog.status = opts.status;
|
|
221
|
+
prog.error = opts.error ?? null;
|
|
222
|
+
prog.finishedAt = new Date().toISOString();
|
|
223
|
+
prog.currentAgent = null;
|
|
224
|
+
prog.currentSession = null;
|
|
225
|
+
prog.currentMessage = null;
|
|
226
|
+
try {
|
|
227
|
+
write(progressPath, prog);
|
|
228
|
+
} catch {
|
|
229
|
+
return { record: prog, wrote: false };
|
|
230
|
+
}
|
|
231
|
+
return { record: prog, wrote: true };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// --- Reader-side classification --------------------------------------------
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Map a legacy on-disk status onto the documented state set.
|
|
238
|
+
*
|
|
239
|
+
* @param {string|undefined} status
|
|
240
|
+
* @returns {string}
|
|
241
|
+
*/
|
|
242
|
+
export function normalizeStatus(status) {
|
|
243
|
+
if (!status) return "unknown";
|
|
244
|
+
return LEGACY_STATUS_ALIASES[status] ?? status;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Decide what a progress record actually means, given process liveness.
|
|
249
|
+
*
|
|
250
|
+
* A `running` record is only believable if the daemon is still alive. Two
|
|
251
|
+
* independent checks, either of which condemns it:
|
|
252
|
+
*
|
|
253
|
+
* - the PID it names has no live process (`isPidAlive` is supplied by the
|
|
254
|
+
* caller, normally `lockfile.isStale` inverted), or
|
|
255
|
+
* - its heartbeat is older than `HEARTBEAT_STALE_AFTER_MS`.
|
|
256
|
+
*
|
|
257
|
+
* The heartbeat check is what catches a run whose PID has been recycled by
|
|
258
|
+
* an unrelated process; the PID check is what reports a `kill -9` within
|
|
259
|
+
* milliseconds instead of waiting out the stale window.
|
|
260
|
+
*
|
|
261
|
+
* @param {Progress} prog
|
|
262
|
+
* @param {object} [opts]
|
|
263
|
+
* @param {number|null} [opts.pid] PID to test; defaults to `prog.pid`.
|
|
264
|
+
* @param {(pid: number) => boolean} [opts.isPidAlive]
|
|
265
|
+
* @param {boolean} [opts.holdsLock] Whether the migrate lockfile still
|
|
266
|
+
* names this run. A live daemon always
|
|
267
|
+
* holds it; it is released on the way
|
|
268
|
+
* out. Pass this only when the caller
|
|
269
|
+
* actually read the lockfile.
|
|
270
|
+
* @param {number} [opts.now] Epoch ms; injectable for tests.
|
|
271
|
+
* @returns {{status: string, crashed: boolean, reason: string|null}}
|
|
272
|
+
*/
|
|
273
|
+
export function classify(prog, opts = {}) {
|
|
274
|
+
const status = normalizeStatus(prog?.status);
|
|
275
|
+
if (status !== "running") return { status, crashed: false, reason: null };
|
|
276
|
+
|
|
277
|
+
const now = opts.now ?? Date.now();
|
|
278
|
+
const pid = opts.pid ?? prog?.pid ?? null;
|
|
279
|
+
|
|
280
|
+
if (typeof pid === "number" && pid > 0 && typeof opts.isPidAlive === "function") {
|
|
281
|
+
if (!opts.isPidAlive(pid)) {
|
|
282
|
+
return { status: "crashed", crashed: true, reason: `pid ${pid} not alive` };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const beat = Date.parse(prog?.heartbeatAt ?? "");
|
|
287
|
+
if (Number.isFinite(beat)) {
|
|
288
|
+
const age = now - beat;
|
|
289
|
+
if (age > HEARTBEAT_STALE_AFTER_MS) {
|
|
290
|
+
return {
|
|
291
|
+
status: "crashed",
|
|
292
|
+
crashed: true,
|
|
293
|
+
reason: `no heartbeat for ${Math.round(age / 1000)}s`,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
} else if (pid === null) {
|
|
297
|
+
// A pre-heartbeat run record: no PID to test, no parseable heartbeat.
|
|
298
|
+
// The lockfile is the last piece of evidence. A daemon holds it for the
|
|
299
|
+
// whole run and releases it on the way out, so a `running` record that
|
|
300
|
+
// no longer owns the lock is a run that ended without saying so:
|
|
301
|
+
// exactly the stuck-forever records this fix exists to explain.
|
|
302
|
+
if (opts.holdsLock === false) {
|
|
303
|
+
return { status: "crashed", crashed: true, reason: "no lock holder" };
|
|
304
|
+
}
|
|
305
|
+
// Otherwise report it as running, but say why we can't vouch for it.
|
|
306
|
+
return { status: "running", crashed: false, reason: "no liveness data" };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return { status: "running", crashed: false, reason: null };
|
|
310
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Session migration adapters: id, displayName, listSessions, parseSession. */
|
|
2
|
+
import * as claudeCodeAdapter from "./adapters/claude-code.mjs";
|
|
3
|
+
import * as cursorAdapter from "./adapters/cursor.mjs";
|
|
4
|
+
import * as codexAdapter from "./adapters/codex.mjs";
|
|
5
|
+
import * as openCodeAdapter from "./adapters/opencode.mjs";
|
|
6
|
+
/** @type {Array<{id: string, displayName: string, listSessions: Function, parseSession: Function}>} */
|
|
7
|
+
export const SESSION_ADAPTERS = [
|
|
8
|
+
{
|
|
9
|
+
id: "claude-code",
|
|
10
|
+
displayName: "Claude Code",
|
|
11
|
+
listSessions: claudeCodeAdapter.listSessions,
|
|
12
|
+
parseSession: (ref) => claudeCodeAdapter.parseSession(ref.path),
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: "cursor",
|
|
16
|
+
displayName: "Cursor",
|
|
17
|
+
listSessions: cursorAdapter.listSessions,
|
|
18
|
+
parseSession: (ref) => cursorAdapter.parseSession(ref.path),
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: "codex",
|
|
22
|
+
displayName: "Codex",
|
|
23
|
+
listSessions: codexAdapter.listSessions,
|
|
24
|
+
parseSession: (ref) => codexAdapter.parseSession(ref.path),
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: "opencode",
|
|
28
|
+
displayName: "OpenCode",
|
|
29
|
+
listSessions: openCodeAdapter.listSessions,
|
|
30
|
+
parseSession: (ref, opts) => openCodeAdapter.parseSession(ref, opts),
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
/** @returns {Promise<Array<{ ref: object, source: string }>>} */
|
|
34
|
+
export async function enumerateSessionRefs(opts) {
|
|
35
|
+
const out = [];
|
|
36
|
+
const adapters = opts.sessionAdapters ?? SESSION_ADAPTERS;
|
|
37
|
+
for (const adapter of adapters) {
|
|
38
|
+
let refs;
|
|
39
|
+
try {
|
|
40
|
+
refs = await adapter.listSessions(opts);
|
|
41
|
+
if (!Array.isArray(refs)) {
|
|
42
|
+
throw new Error("listSessions did not return an array");
|
|
43
|
+
}
|
|
44
|
+
for (const ref of refs) {
|
|
45
|
+
out.push({ ref, source: adapter.id, adapter });
|
|
46
|
+
}
|
|
47
|
+
} catch (error) {
|
|
48
|
+
opts.onSourceError?.({
|
|
49
|
+
source: adapter.id,
|
|
50
|
+
displayName: adapter.displayName,
|
|
51
|
+
error,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
/** @returns {Promise<object>|object} */
|
|
58
|
+
export function parseTaggedSession(tagged, opts = {}) {
|
|
59
|
+
const adapter =
|
|
60
|
+
tagged.adapter ?? SESSION_ADAPTERS.find((a) => a.id === tagged.source);
|
|
61
|
+
if (!adapter) throw new Error(`unknown migration source: ${tagged.source}`);
|
|
62
|
+
return adapter.parseSession(tagged.ref, opts);
|
|
63
|
+
}
|
|
64
|
+
/** Legacy summary field names keyed by adapter id. */
|
|
65
|
+
export function sessionsByAdapter(tagged) {
|
|
66
|
+
/** @type {Record<string, number>} */
|
|
67
|
+
const counts = {};
|
|
68
|
+
for (const { source } of tagged) {
|
|
69
|
+
counts[source] = (counts[source] || 0) + 1;
|
|
70
|
+
}
|
|
71
|
+
return counts;
|
|
72
|
+
}
|
package/lib/migrate.mjs
CHANGED
|
@@ -56,7 +56,13 @@ function paths(opts = {}) {
|
|
|
56
56
|
statePath,
|
|
57
57
|
lockPath: path.join(root, "lock"),
|
|
58
58
|
runDir: (runId) => path.join(root, runId),
|
|
59
|
-
|
|
59
|
+
// Where the child's stdio is redirected. Everything the daemon has to
|
|
60
|
+
// say goes through the NDJSON logger instead, so this file is normally
|
|
61
|
+
// empty; it only catches process-level output that escapes the logger
|
|
62
|
+
// (a stack trace from before it opens, a native crash). Don't point
|
|
63
|
+
// users here; point them at `eventLogFile`.
|
|
64
|
+
stdioFile: (runId) => path.join(root, runId, "stdout.log"),
|
|
65
|
+
eventLogFile: (runId) => path.join(root, runId, "log.ndjson"),
|
|
60
66
|
progressFile: (runId) => path.join(root, runId, "progress.json"),
|
|
61
67
|
};
|
|
62
68
|
}
|
|
@@ -231,9 +237,29 @@ export async function startMigration(opts) {
|
|
|
231
237
|
dryRun: true,
|
|
232
238
|
noExtract: opts.noExtract,
|
|
233
239
|
concurrency: opts.rate,
|
|
240
|
+
sessionAdapters: opts.sessionAdapters,
|
|
241
|
+
opencodeBin: opts.opencodeBin,
|
|
242
|
+
runExport: opts.runExport,
|
|
234
243
|
});
|
|
235
244
|
const s = summary.summary;
|
|
245
|
+
if (!summary.ok) {
|
|
246
|
+
return {
|
|
247
|
+
ok: false,
|
|
248
|
+
runId,
|
|
249
|
+
message: `Dry run failed: ${s.detail || s.error || "unknown migration error"}`,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
236
252
|
const planned = s.plannedCalls ?? {};
|
|
253
|
+
const warningLines = [
|
|
254
|
+
...(s.sourceErrors ?? []).map(
|
|
255
|
+
({ source, error }) => ` Warning: ${source} enumeration failed: ${error}\n`,
|
|
256
|
+
),
|
|
257
|
+
...(s.sessionErrors ?? []).map(
|
|
258
|
+
({ source, sessionUuid, error }) =>
|
|
259
|
+
` Warning: ${source} session ${sessionUuid} parse failed: ${error}\n`,
|
|
260
|
+
),
|
|
261
|
+
]
|
|
262
|
+
.join("");
|
|
237
263
|
// Dry-run never opens an MCP session, so we can't resolve names→UUIDs
|
|
238
264
|
// here. Echo what the user asked for; the real run will resolve and
|
|
239
265
|
// print the canonical destination before doing any work.
|
|
@@ -249,8 +275,11 @@ export async function startMigration(opts) {
|
|
|
249
275
|
destLine +
|
|
250
276
|
` Claude Code sessions: ${s.claudeSessions}\n` +
|
|
251
277
|
` Cursor sessions: ${s.cursorSessions}\n` +
|
|
278
|
+
` Codex sessions: ${s.codexSessions}\n` +
|
|
279
|
+
` OpenCode sessions: ${s.openCodeSessions}\n` +
|
|
252
280
|
` Paired user↔asst turns: ${s.pairedTurns}\n` +
|
|
253
281
|
` Memory items (MD): ${s.memoryItems}\n` +
|
|
282
|
+
warningLines +
|
|
254
283
|
` Planned MCP calls:\n` +
|
|
255
284
|
` conversation_create: ${planned.conversation_create ?? 0}\n` +
|
|
256
285
|
` conversation_add_message: ${planned.conversation_add_message ?? 0}\n` +
|
|
@@ -375,7 +404,7 @@ export async function startMigration(opts) {
|
|
|
375
404
|
scriptPath: opts.scriptPath,
|
|
376
405
|
runId,
|
|
377
406
|
args: passthrough,
|
|
378
|
-
logFilePath: P.
|
|
407
|
+
logFilePath: P.stdioFile(runId),
|
|
379
408
|
env,
|
|
380
409
|
cwd: opts.projectRoot,
|
|
381
410
|
});
|
|
@@ -395,7 +424,7 @@ export async function startMigration(opts) {
|
|
|
395
424
|
message:
|
|
396
425
|
`${datapackBanner}\n` +
|
|
397
426
|
`Migration started. run-id=${runId}, pid=${pid}\n` +
|
|
398
|
-
`Logs: ${P.
|
|
427
|
+
`Logs: ${P.eventLogFile(runId)} (one JSON object per line)\n` +
|
|
399
428
|
`Status: ${selfInvocation()} --migrate-status`,
|
|
400
429
|
};
|
|
401
430
|
}
|
|
@@ -520,11 +549,21 @@ export async function runChildMigration(opts) {
|
|
|
520
549
|
log.warn("legacy_state_migrate_failed", { error: err.message });
|
|
521
550
|
}
|
|
522
551
|
|
|
552
|
+
const progressPath = P.progressFile(opts.runId);
|
|
553
|
+
|
|
523
554
|
let shuttingDown = false;
|
|
524
555
|
const onSignal = (sig) => {
|
|
525
556
|
if (shuttingDown) return;
|
|
526
557
|
shuttingDown = true;
|
|
527
558
|
log.warn("interrupted", { signal: sig });
|
|
559
|
+
// Write the terminal state before releasing the lock. Skipping this is
|
|
560
|
+
// what left a clean `--migrate-stop` reading as `running` forever.
|
|
561
|
+
try {
|
|
562
|
+
const { wrote } = progress.finalize(progressPath, { status: "cancelled", error: `signal ${sig}` });
|
|
563
|
+
// A signal after the orchestrator's own terminal write leaves that
|
|
564
|
+
// status in place, so only log a transition that actually happened.
|
|
565
|
+
if (wrote) log.info("status", { status: "cancelled", signal: sig, runId: opts.runId });
|
|
566
|
+
} catch { /* best effort; we're on our way out */ }
|
|
528
567
|
try { lockfile.release(P.lockPath); } catch { /* ignore */ }
|
|
529
568
|
log.close();
|
|
530
569
|
process.exit(130);
|
|
@@ -554,18 +593,37 @@ export async function runChildMigration(opts) {
|
|
|
554
593
|
return result.ok ? 0 : 1;
|
|
555
594
|
} catch (err) {
|
|
556
595
|
log.error("child_crashed", { error: err.message, stack: err.stack });
|
|
596
|
+
// The orchestrator threw before reaching its own terminal write, so the
|
|
597
|
+
// progress file is still on `running`. Close the state machine here.
|
|
598
|
+
try {
|
|
599
|
+
const { wrote } = progress.finalize(progressPath, {
|
|
600
|
+
status: "failed",
|
|
601
|
+
error: `${err.name ?? "Error"}: ${err.message}`,
|
|
602
|
+
});
|
|
603
|
+
if (wrote) log.info("status", { status: "failed", error: err.message, runId: opts.runId });
|
|
604
|
+
} catch { /* best effort */ }
|
|
557
605
|
log.close();
|
|
558
606
|
try { lockfile.release(P.lockPath); } catch { /* ignore */ }
|
|
559
607
|
return 1;
|
|
608
|
+
} finally {
|
|
609
|
+
// Whatever path we left on, no timer should outlive the run and rewrite
|
|
610
|
+
// a terminal record back to `running`.
|
|
611
|
+
progress.stopHeartbeat();
|
|
560
612
|
}
|
|
561
613
|
}
|
|
562
614
|
|
|
563
615
|
/**
|
|
564
616
|
* Print and return the status of the latest (or requested) migration run.
|
|
565
617
|
*
|
|
618
|
+
* The on-disk `status` field is not taken at face value. A `running` record
|
|
619
|
+
* whose daemon is gone reports as `crashed`, which is how a `kill -9`, an
|
|
620
|
+
* OOM kill, or a reboot shows up. None of them run a handler that could
|
|
621
|
+
* have written a terminal state. `progress.status` stays as written;
|
|
622
|
+
* the returned `status` is the effective one.
|
|
623
|
+
*
|
|
566
624
|
* @param {object} opts
|
|
567
625
|
* @param {string} [opts.runId]
|
|
568
|
-
* @returns {{ok: boolean, runId: string|null, progress: object|null, message: string}}
|
|
626
|
+
* @returns {{ok: boolean, runId: string|null, progress: object|null, status?: string, crashed?: boolean, message: string}}
|
|
569
627
|
*/
|
|
570
628
|
export function readStatus(opts = {}) {
|
|
571
629
|
const P = paths();
|
|
@@ -620,23 +678,53 @@ export function readStatus(opts = {}) {
|
|
|
620
678
|
const RESET = useColor ? "\x1b[0m" : "";
|
|
621
679
|
const note = (s) => (s ? ` ${GRAY}${s}${RESET}` : "");
|
|
622
680
|
|
|
681
|
+
// A `running` record is only worth believing if the daemon is still there.
|
|
682
|
+
// The lockfile names the owning PID; prefer it over the PID the run
|
|
683
|
+
// recorded, since --force can hand the lock to a newer process.
|
|
684
|
+
const holder = lockfile.read(P.lockPath);
|
|
685
|
+
const holdsLock = !!holder && holder.runId === runId;
|
|
686
|
+
const livePid = holdsLock ? holder.pid : (prog.pid ?? null);
|
|
687
|
+
const verdict = progress.classify(prog, {
|
|
688
|
+
pid: livePid,
|
|
689
|
+
holdsLock,
|
|
690
|
+
isPidAlive: (pid) => !lockfile.isStale({ pid }),
|
|
691
|
+
});
|
|
692
|
+
const statusLabel = verdict.reason
|
|
693
|
+
? `${verdict.status} (${verdict.reason})`
|
|
694
|
+
: verdict.status;
|
|
695
|
+
|
|
623
696
|
const lines = [
|
|
624
697
|
`run-id: ${prog.runId}`,
|
|
625
|
-
`status: ${
|
|
698
|
+
`status: ${statusLabel}${note("(running → succeeded | failed | cancelled; crashed is inferred)")}`,
|
|
626
699
|
`scope: ${prog.scope}`,
|
|
627
700
|
`datapack: ${datapackKeyLabel}${note("(resume state lives under state/<key>.json)")}`,
|
|
628
701
|
`target pack: ${datapackTargetLabel}${note("(ingestion destination resolved at run start)")}`,
|
|
629
702
|
`started: ${prog.startedAt}`,
|
|
630
|
-
`finished: ${prog.finishedAt ?? "(in progress)"}`,
|
|
631
|
-
`
|
|
703
|
+
`finished: ${prog.finishedAt ?? (verdict.crashed ? "(never, daemon died)" : "(in progress)")}`,
|
|
704
|
+
`heartbeat: ${prog.heartbeatAt ?? "-"}${note("(refreshed while running; staleness implies crashed)")}`,
|
|
705
|
+
`current: ${prog.currentAgent ?? "-"} / ${prog.currentSession ?? "-"} / ${prog.currentMessage ?? "-"}${note("(<agent-id> / <source-session> / <message-id>)")}`,
|
|
632
706
|
`sessions: ${prog.counts.sessionsDone} / ${prog.counts.sessionsPlanned}${note("(processed / discovered)")}`,
|
|
633
707
|
`messages: ${prog.counts.messagesSent}`,
|
|
634
708
|
`memory pairs: ${prog.counts.memoryPairsSent}${note("(memory_add from Mem0 extractor)")}`,
|
|
635
709
|
`memory (MD): ${prog.counts.memoriesFromMD}${note("(memory_add from CLAUDE.md / memory files)")}`,
|
|
636
710
|
`warnings: ${prog.counts.warnings}${note("(non-fatal; run continues)")}`,
|
|
637
711
|
`memory errs: ${prog.counts.memoryErrors}${note("(failed memory_add calls; re-run with --force to retry)")}`,
|
|
712
|
+
`auth skipped: ${prog.counts.authSkipped ?? 0}${note("(calls dropped on a mid-run 401/403; re-run to retry them)")}`,
|
|
638
713
|
];
|
|
639
|
-
|
|
714
|
+
if (prog.error) {
|
|
715
|
+
lines.push(`error: ${prog.error}`);
|
|
716
|
+
}
|
|
717
|
+
return {
|
|
718
|
+
ok: true,
|
|
719
|
+
runId,
|
|
720
|
+
progress: prog,
|
|
721
|
+
// The effective status, after the liveness check. `progress.status` is
|
|
722
|
+
// the raw on-disk field and can still read `running` for a dead daemon;
|
|
723
|
+
// callers that care whether the run is actually alive want this one.
|
|
724
|
+
status: verdict.status,
|
|
725
|
+
crashed: verdict.crashed,
|
|
726
|
+
message: lines.join("\n"),
|
|
727
|
+
};
|
|
640
728
|
}
|
|
641
729
|
|
|
642
730
|
/**
|
package/package.json
CHANGED
|
@@ -30,6 +30,10 @@ const PAYLOAD = [
|
|
|
30
30
|
from: join(repoRoot, "skills", "hooks-handlers"),
|
|
31
31
|
to: join(assetsDir, "hooks-handlers"),
|
|
32
32
|
},
|
|
33
|
+
{
|
|
34
|
+
from: join(repoRoot, "skills", "opencode-plugin"),
|
|
35
|
+
to: join(assetsDir, "opencode-plugin"),
|
|
36
|
+
},
|
|
33
37
|
{
|
|
34
38
|
from: join(repoRoot, "skills", "scripts", "package-desktop-skill.sh"),
|
|
35
39
|
to: join(assetsDir, "scripts", "package-desktop-skill.sh"),
|