@lore-co/cli 0.1.28 → 0.1.30
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 +48 -9
- package/dist/agents.d.ts +3 -1
- package/dist/agents.d.ts.map +1 -1
- package/dist/agents.js +31 -7
- package/dist/agents.js.map +1 -1
- package/dist/cli.d.ts +8 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +97 -15
- package/dist/cli.js.map +1 -1
- package/dist/command-help.d.ts +2 -1
- package/dist/command-help.d.ts.map +1 -1
- package/dist/command-help.js +33 -6
- package/dist/command-help.js.map +1 -1
- package/dist/generated-assets.d.ts +9 -4
- package/dist/generated-assets.d.ts.map +1 -1
- package/dist/generated-assets.js +5 -4
- package/dist/generated-assets.js.map +1 -1
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +7 -1
- package/dist/guard.js.map +1 -1
- package/dist/image-capture.d.ts.map +1 -1
- package/dist/image-capture.js +8 -3
- package/dist/image-capture.js.map +1 -1
- package/dist/live.d.ts.map +1 -1
- package/dist/live.js +40 -20
- package/dist/live.js.map +1 -1
- package/dist/recovery-cloud-receipt.d.ts +24 -0
- package/dist/recovery-cloud-receipt.d.ts.map +1 -0
- package/dist/recovery-cloud-receipt.js +78 -0
- package/dist/recovery-cloud-receipt.js.map +1 -0
- package/dist/recovery-cloud-workspace.d.ts +11 -0
- package/dist/recovery-cloud-workspace.d.ts.map +1 -0
- package/dist/recovery-cloud-workspace.js +141 -0
- package/dist/recovery-cloud-workspace.js.map +1 -0
- package/dist/recovery-cloud.d.ts +31 -0
- package/dist/recovery-cloud.d.ts.map +1 -0
- package/dist/recovery-cloud.js +242 -0
- package/dist/recovery-cloud.js.map +1 -0
- package/dist/recovery-codex.d.ts +5 -0
- package/dist/recovery-codex.d.ts.map +1 -0
- package/dist/recovery-codex.js +111 -0
- package/dist/recovery-codex.js.map +1 -0
- package/dist/recovery-copilot.d.ts +14 -0
- package/dist/recovery-copilot.d.ts.map +1 -0
- package/dist/recovery-copilot.js +120 -0
- package/dist/recovery-copilot.js.map +1 -0
- package/dist/recovery-desktop.d.ts +7 -0
- package/dist/recovery-desktop.d.ts.map +1 -0
- package/dist/recovery-desktop.js +179 -0
- package/dist/recovery-desktop.js.map +1 -0
- package/dist/recovery-external.d.ts +61 -0
- package/dist/recovery-external.d.ts.map +1 -0
- package/dist/recovery-external.js +98 -0
- package/dist/recovery-external.js.map +1 -0
- package/dist/recovery-hooks.d.ts +5 -0
- package/dist/recovery-hooks.d.ts.map +1 -0
- package/dist/recovery-hooks.js +115 -0
- package/dist/recovery-hooks.js.map +1 -0
- package/dist/recovery-hosts.d.ts +13 -0
- package/dist/recovery-hosts.d.ts.map +1 -0
- package/dist/recovery-hosts.js +326 -0
- package/dist/recovery-hosts.js.map +1 -0
- package/dist/recovery-polytoken.d.ts +14 -0
- package/dist/recovery-polytoken.d.ts.map +1 -0
- package/dist/recovery-polytoken.js +194 -0
- package/dist/recovery-polytoken.js.map +1 -0
- package/dist/recovery-prompt.d.ts +6 -0
- package/dist/recovery-prompt.d.ts.map +1 -0
- package/dist/recovery-prompt.js +53 -0
- package/dist/recovery-prompt.js.map +1 -0
- package/dist/recovery-remote.d.ts +8 -0
- package/dist/recovery-remote.d.ts.map +1 -0
- package/dist/recovery-remote.js +135 -0
- package/dist/recovery-remote.js.map +1 -0
- package/dist/recovery-state.d.ts +25 -1
- package/dist/recovery-state.d.ts.map +1 -1
- package/dist/recovery-state.js +69 -15
- package/dist/recovery-state.js.map +1 -1
- package/dist/recovery-t3.d.ts +17 -0
- package/dist/recovery-t3.d.ts.map +1 -0
- package/dist/recovery-t3.js +58 -0
- package/dist/recovery-t3.js.map +1 -0
- package/dist/recovery-transfer.d.ts +23 -0
- package/dist/recovery-transfer.d.ts.map +1 -0
- package/dist/recovery-transfer.js +234 -0
- package/dist/recovery-transfer.js.map +1 -0
- package/dist/recovery.d.ts +9 -1
- package/dist/recovery.d.ts.map +1 -1
- package/dist/recovery.js +197 -67
- package/dist/recovery.js.map +1 -1
- package/dist/reliability-store.d.ts +1 -0
- package/dist/reliability-store.d.ts.map +1 -1
- package/dist/reliability-store.js +23 -0
- package/dist/reliability-store.js.map +1 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +39 -11
- package/dist/runtime.js.map +1 -1
- package/dist/self-host.d.ts +1 -1
- package/dist/self-host.js +2 -2
- package/dist/uninstall.d.ts +19 -0
- package/dist/uninstall.d.ts.map +1 -0
- package/dist/uninstall.js +135 -0
- package/dist/uninstall.js.map +1 -0
- package/dist/update.js +3 -3
- package/package.json +3 -3
package/dist/recovery.js
CHANGED
|
@@ -7,15 +7,23 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { IS_STANDALONE_BINARY } from "./version.js";
|
|
9
9
|
import { openPty } from "./pty.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { createRecoveryPrompt } from "./recovery-prompt.js";
|
|
11
|
+
import { captureRecoveryHook, createRecoveryRun, recoveryBrief, recoveryMetrics, recoveryRunKey, RecoveryRunSchema, recoveryStore, recordRecoveryMetric, workspaceSnapshot, requestRecoverySwitch, markRecoveryFailure, } from "./recovery-state.js";
|
|
12
|
+
import { AGENTS, LAUNCHABLE_AGENTS, RECOVERY_SOURCES, agentShellCommands, findAgentExecutable, isInteractiveAgent, isLaunchable, isRecoverySource, launchArguments } from "./agents.js";
|
|
12
13
|
import { openCodeRecoveryPluginSource } from "./recovery-opencode.js";
|
|
14
|
+
import { mergeNativeRecoveryHooks, normalizeRecoveryHook } from "./recovery-hooks.js";
|
|
15
|
+
import { observeCodexRecovery } from "./recovery-codex.js";
|
|
16
|
+
import { observeCopilotRecovery } from "./recovery-copilot.js";
|
|
17
|
+
import { observePolytokenRecovery, verifyPolytokenStopped } from "./recovery-polytoken.js";
|
|
18
|
+
import { RemoteDestinationSchema } from "./recovery-transfer.js";
|
|
19
|
+
import { launchRemoteRecovery, runRecoveryWorker, verifyRemoteDestination } from "./recovery-remote.js";
|
|
13
20
|
const Settings = z.object({
|
|
14
21
|
fallback: z.enum(LAUNCHABLE_AGENTS), shellPath: z.string(), shellPaths: z.array(z.string()).optional(),
|
|
15
22
|
claudeSettings: z.string().optional(),
|
|
16
23
|
routes: z.partialRecord(z.enum(RECOVERY_SOURCES), z.enum(LAUNCHABLE_AGENTS)).optional(),
|
|
17
24
|
sourceFiles: z.array(z.object({ agent: z.enum(RECOVERY_SOURCES), path: z.string() })).optional(),
|
|
18
25
|
openCodePlugin: z.string().optional(),
|
|
26
|
+
remotes: z.partialRecord(z.enum(RECOVERY_SOURCES), RemoteDestinationSchema).optional(),
|
|
19
27
|
});
|
|
20
28
|
const routesOf = (settings) => settings.routes ?? { claude: settings.fallback };
|
|
21
29
|
const filesOf = (settings) => settings.sourceFiles ?? (settings.claudeSettings ? [{ agent: "claude", path: settings.claudeSettings }] : []);
|
|
@@ -104,8 +112,13 @@ export function mergeRecoveryHooks(value, command, events = ["UserPromptSubmit",
|
|
|
104
112
|
const handlers = group.hooks.filter((handler) => typeof handler.command !== "string" || !handler.command.includes(OWNER));
|
|
105
113
|
return handlers.length ? [{ ...group, hooks: handlers }] : [];
|
|
106
114
|
});
|
|
107
|
-
if (command)
|
|
108
|
-
|
|
115
|
+
if (command) {
|
|
116
|
+
const added = { ...(event === "StopFailure" ? { matcher: "rate_limit" } : {}), hooks: [{ type: "command", command, timeout: 10 }] };
|
|
117
|
+
// The ordinary connector keeps its owned hooks last. Preserve that order
|
|
118
|
+
// so reconnect does not rewrite the file or invalidate host hook approval.
|
|
119
|
+
const core = remaining.findIndex(group => Array.isArray(group.hooks) && group.hooks.some((hook) => /--owner\s+lore(?:\s|$)/u.test(hook.command ?? "")));
|
|
120
|
+
remaining.splice(core < 0 ? remaining.length : core, 0, added);
|
|
121
|
+
}
|
|
109
122
|
if (remaining.length)
|
|
110
123
|
hooks[event] = remaining;
|
|
111
124
|
else
|
|
@@ -132,11 +145,36 @@ function mergeOpenCodePlugin(value, plugin, enabled) {
|
|
|
132
145
|
delete parsed.plugin;
|
|
133
146
|
return parsed;
|
|
134
147
|
}
|
|
135
|
-
|
|
148
|
+
/** Fresh connects enable both directions; explicit routes and off are durable preferences. */
|
|
149
|
+
export async function prepareAutomaticRecovery(paths, env = process.env) {
|
|
150
|
+
if (await recoveryStore(paths.home).readState("disabled"))
|
|
151
|
+
return;
|
|
152
|
+
const existing = Settings.safeParse(await recoveryStore(paths.home).readState("settings"));
|
|
153
|
+
const available = (await Promise.all(LAUNCHABLE_AGENTS.map(async (agent) => await findAgentExecutable(agent, env).then(() => agent, () => null)))).filter((agent) => agent !== null);
|
|
154
|
+
if (available.length < 2)
|
|
155
|
+
return;
|
|
156
|
+
const priorRoutes = existing.success ? routesOf(existing.data) : {};
|
|
157
|
+
const missing = available.filter(agent => !priorRoutes[agent]);
|
|
158
|
+
if (!missing.length)
|
|
159
|
+
return;
|
|
160
|
+
const fallback = existing.success && available.includes(existing.data.fallback) ? existing.data.fallback : available[0];
|
|
161
|
+
const alternate = available.find(agent => agent !== fallback);
|
|
162
|
+
const firstSources = missing.filter(agent => agent !== fallback);
|
|
163
|
+
const first = firstSources.length ? await prepareRecoveryInstallation(paths, fallback, env, firstSources) : undefined;
|
|
164
|
+
const install = async () => {
|
|
165
|
+
const message = first ? await first() : "";
|
|
166
|
+
return missing.includes(fallback) ? await (await prepareRecoveryInstallation(paths, alternate, env, [fallback]))() : message;
|
|
167
|
+
};
|
|
168
|
+
return Object.assign(install, { sources: missing, fallback });
|
|
169
|
+
}
|
|
170
|
+
export async function prepareRecoveryInstallation(paths, fallback, env = process.env, requestedSources, remote) {
|
|
136
171
|
const { removeDevinShell } = await import("./devin-cli.js");
|
|
137
172
|
if (!isLaunchable(fallback))
|
|
138
173
|
throw new Error(`Choose a local fallback: ${LAUNCHABLE_AGENTS.join(", ")}.`);
|
|
139
|
-
|
|
174
|
+
if (remote)
|
|
175
|
+
await verifyRemoteDestination(remote, fallback, env);
|
|
176
|
+
else
|
|
177
|
+
await findAgentExecutable(fallback, env);
|
|
140
178
|
const sources = requestedSources ? [...new Set(requestedSources)] : (await Promise.all(RECOVERY_SOURCES.map(async (source) => source !== fallback && await findAgentExecutable(source, env).then(() => true, () => false) ? source : null))).filter((source) => source !== null);
|
|
141
179
|
if (!sources.length)
|
|
142
180
|
throw new Error("No supported source client is installed. Sources: " + RECOVERY_SOURCES.join(", "));
|
|
@@ -147,9 +185,16 @@ export async function prepareRecoveryInstallation(paths, fallback, env = process
|
|
|
147
185
|
}
|
|
148
186
|
const previousSettings = Settings.safeParse(await recoveryStore(paths.home).readState("settings"));
|
|
149
187
|
const routes = previousSettings.success ? routesOf(previousSettings.data) : {};
|
|
150
|
-
|
|
188
|
+
const remotes = previousSettings.success ? previousSettings.data.remotes ?? {} : {};
|
|
189
|
+
for (const source of sources) {
|
|
151
190
|
routes[source] = fallback;
|
|
191
|
+
if (remote)
|
|
192
|
+
remotes[source] = remote;
|
|
193
|
+
else
|
|
194
|
+
delete remotes[source];
|
|
195
|
+
}
|
|
152
196
|
const allSources = Object.keys(routes);
|
|
197
|
+
const shellCommands = new Map(await Promise.all(allSources.map(async (source) => [source, await agentShellCommands(source, env)])));
|
|
153
198
|
const shell = basename(env.SHELL ?? "");
|
|
154
199
|
if (shell !== "zsh" && shell !== "bash")
|
|
155
200
|
throw new Error("Automatic recovery setup supports bash and zsh. Set SHELL to your shell before connecting.");
|
|
@@ -176,7 +221,7 @@ export async function prepareRecoveryInstallation(paths, fallback, env = process
|
|
|
176
221
|
const document = await readOptional(path);
|
|
177
222
|
shellDocuments.set(path, document);
|
|
178
223
|
const withoutBlock = removeDevinShell(removeRecoveryShell(document.text));
|
|
179
|
-
for (const source of allSources)
|
|
224
|
+
for (const source of allSources.flatMap(source => shellCommands.get(source)))
|
|
180
225
|
if (new RegExp(`(?:^|\\n)\\s*(?:function\\s+${source}\\b|${source}\\s*\\(\\s*\\)|alias\\s+${source}=)`, "u").test(withoutBlock)) {
|
|
181
226
|
throw new Error(`An existing ${source} shell function or alias needs to be retained. Remove that override before enabling recovery.`);
|
|
182
227
|
}
|
|
@@ -184,29 +229,33 @@ export async function prepareRecoveryInstallation(paths, fallback, env = process
|
|
|
184
229
|
const sourcePaths = {
|
|
185
230
|
claude: paths.claudeSettings, codex: paths.codexHooks, opencode: paths.openCodeConfig,
|
|
186
231
|
devin: resolve(env.XDG_CONFIG_HOME || resolve(paths.home, ".config"), "devin/config.json"),
|
|
232
|
+
cursor: paths.cursorHooks, "copilot-cli": paths.copilotCliHooks, polytoken: paths.polytokenHooks,
|
|
187
233
|
};
|
|
188
234
|
const oldFiles = previousSettings.success ? filesOf(previousSettings.data) : [];
|
|
189
235
|
const sourceFiles = allSources.map(agent => ({ agent, path: oldFiles.find(file => file.agent === agent)?.path ?? sourcePaths[agent] }));
|
|
190
236
|
const openCodePlugin = previousSettings.success && previousSettings.data.openCodePlugin || resolve(paths.home, ".lore/recovery/opencode.mjs");
|
|
191
237
|
for (const { agent, path } of sourceFiles) {
|
|
192
238
|
const existing = await readOptional(path);
|
|
193
|
-
const value = existing.text ? JSON.parse(existing.text) : {};
|
|
239
|
+
const value = existing.text ? JSON.parse(existing.text) : agent === "polytoken" ? [] : {};
|
|
194
240
|
if (agent === "opencode")
|
|
195
241
|
mergeOpenCodePlugin(value, openCodePlugin, false);
|
|
242
|
+
else if (agent === "cursor" || agent === "copilot-cli" || agent === "polytoken")
|
|
243
|
+
mergeNativeRecoveryHooks(agent, value, null);
|
|
196
244
|
else
|
|
197
245
|
mergeRecoveryHooks(value, null, hookEvents(agent));
|
|
198
246
|
}
|
|
199
247
|
if (allSources.includes("opencode"))
|
|
200
248
|
await readOptional(openCodePlugin);
|
|
201
249
|
const invocation = `env -u BUN_OPTIONS -u BUN_BE_BUN LORE_HOME=${quote(paths.home)} ${cliInvocation().map(quote).join(" ")}`;
|
|
202
|
-
const block = `${START}\n${allSources.
|
|
250
|
+
const block = `${START}\n${allSources.flatMap(source => shellCommands.get(source).map(name => `${name}() { ${invocation} run ${source} -- "$@"; }`)).join("\n")}\n${END}\n`;
|
|
203
251
|
const install = async () => {
|
|
204
252
|
for (const { agent, path } of sourceFiles) {
|
|
205
253
|
// Re-read because connect has just merged its ordinary context hooks.
|
|
206
254
|
const previous = await readOptional(path);
|
|
207
|
-
const value = previous.text ? JSON.parse(previous.text) : {};
|
|
255
|
+
const value = previous.text ? JSON.parse(previous.text) : agent === "polytoken" ? [] : {};
|
|
208
256
|
const command = `${invocation} recovery-hook --agent ${agent} ${OWNER}`;
|
|
209
|
-
const merged = agent === "opencode" ? mergeOpenCodePlugin(value, openCodePlugin, true) :
|
|
257
|
+
const merged = agent === "opencode" ? mergeOpenCodePlugin(value, openCodePlugin, true) :
|
|
258
|
+
agent === "cursor" || agent === "copilot-cli" || agent === "polytoken" ? mergeNativeRecoveryHooks(agent, value, command) : mergeRecoveryHooks(value, command, hookEvents(agent));
|
|
210
259
|
if (agent === "opencode")
|
|
211
260
|
await replaceFile(openCodePlugin, openCodeRecoveryPluginSource(cliInvocation(), paths.home), await readOptional(openCodePlugin));
|
|
212
261
|
await replaceFile(path, `${JSON.stringify(merged, null, 2)}\n`, previous);
|
|
@@ -218,8 +267,9 @@ export async function prepareRecoveryInstallation(paths, fallback, env = process
|
|
|
218
267
|
const base = removeRecoveryShell(currentShell.text);
|
|
219
268
|
await replaceFile(path, `${base}${base && !base.endsWith("\n") ? "\n" : ""}${block}`, currentShell);
|
|
220
269
|
}
|
|
221
|
-
await recoveryStore(paths.home).
|
|
222
|
-
|
|
270
|
+
await recoveryStore(paths.home).deleteState("disabled");
|
|
271
|
+
await recoveryStore(paths.home).writeState("settings", { fallback, routes, remotes, shellPath, shellPaths, sourceFiles, openCodePlugin });
|
|
272
|
+
return `Recovery enabled: ${allSources.map(source => `${AGENTS[source].label} → ${AGENTS[routes[source]].label}${remotes[source] ? ` on ${remotes[source].host}` : ""}`).join("; ")}.\nCtrl+] offers a switch from a saved checkpoint. Automatic triggers: native failure events and failed processes during checkpointed turns. Run lore recovery agents for host coverage.\nCodex requires one-time approval of new or changed hooks in /hooks.\nOpen a new terminal or run: source ${quote(shellPath)}`;
|
|
223
273
|
};
|
|
224
274
|
return Object.assign(install, { sources, fallback });
|
|
225
275
|
}
|
|
@@ -227,8 +277,15 @@ export async function recoveryFallback(home, source) {
|
|
|
227
277
|
const settings = Settings.safeParse(await recoveryStore(home).readState("settings"));
|
|
228
278
|
return settings.success ? source ? routesOf(settings.data)[source] : settings.data.fallback : undefined;
|
|
229
279
|
}
|
|
280
|
+
export async function recoveryRoute(home, source) {
|
|
281
|
+
const settings = Settings.safeParse(await recoveryStore(home).readState("settings"));
|
|
282
|
+
const agent = settings.success ? routesOf(settings.data)[source] : undefined;
|
|
283
|
+
return agent && settings.success ? { agent, ...(settings.data.remotes?.[source] ? { remote: settings.data.remotes[source] } : {}) } : undefined;
|
|
284
|
+
}
|
|
230
285
|
export async function disableRecovery(home) {
|
|
231
286
|
const store = recoveryStore(home);
|
|
287
|
+
await store.writeState("disabled", { disabled: true });
|
|
288
|
+
await (await import("./recovery-hosts.js")).disableExternalRecovery(home);
|
|
232
289
|
const result = Settings.safeParse(await store.readState("settings"));
|
|
233
290
|
if (!result.success)
|
|
234
291
|
return;
|
|
@@ -242,7 +299,8 @@ export async function disableRecovery(home) {
|
|
|
242
299
|
if (!previous.text)
|
|
243
300
|
continue;
|
|
244
301
|
const value = JSON.parse(previous.text);
|
|
245
|
-
const merged = agent === "opencode" && settings.openCodePlugin ? mergeOpenCodePlugin(value, settings.openCodePlugin, false) :
|
|
302
|
+
const merged = agent === "opencode" && settings.openCodePlugin ? mergeOpenCodePlugin(value, settings.openCodePlugin, false) :
|
|
303
|
+
agent === "cursor" || agent === "copilot-cli" || agent === "polytoken" ? mergeNativeRecoveryHooks(agent, value, null) : mergeRecoveryHooks(value, null, hookEvents(agent));
|
|
246
304
|
await replaceFile(path, `${JSON.stringify(merged, null, 2)}\n`, previous);
|
|
247
305
|
}
|
|
248
306
|
if (settings.openCodePlugin) {
|
|
@@ -253,11 +311,11 @@ export async function disableRecovery(home) {
|
|
|
253
311
|
await store.deleteState("settings");
|
|
254
312
|
}
|
|
255
313
|
export async function runRecoveryHook(home, agent = "claude") {
|
|
256
|
-
|
|
314
|
+
let id = process.env.LORE_RECOVERY_RUN_ID;
|
|
257
315
|
let body = "";
|
|
258
316
|
let oversized = false;
|
|
259
317
|
for await (const chunk of process.stdin) {
|
|
260
|
-
if (
|
|
318
|
+
if (oversized)
|
|
261
319
|
continue;
|
|
262
320
|
body += String(chunk);
|
|
263
321
|
if (Buffer.byteLength(body) > 1024 * 1024) {
|
|
@@ -265,17 +323,34 @@ export async function runRecoveryHook(home, agent = "claude") {
|
|
|
265
323
|
body = "";
|
|
266
324
|
}
|
|
267
325
|
}
|
|
268
|
-
if (
|
|
326
|
+
if (oversized)
|
|
269
327
|
return;
|
|
270
328
|
try {
|
|
271
|
-
|
|
272
|
-
if (
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
329
|
+
const input = normalizeRecoveryHook(agent, JSON.parse(body));
|
|
330
|
+
if (id)
|
|
331
|
+
await captureRecoveryHook(home, id, input, agent);
|
|
332
|
+
else {
|
|
333
|
+
const { captureDesktopRecovery } = await import("./recovery-desktop.js");
|
|
334
|
+
id = await captureDesktopRecovery(home, agent, input);
|
|
335
|
+
}
|
|
336
|
+
if (agent === "polytoken" && id && ["pre_user_prompt", "pre_model_turn"].includes(process.env.POLYTOKEN_HOOK_EVENT ?? "")) {
|
|
337
|
+
const deadline = Date.now() + 2500;
|
|
338
|
+
let ready = false;
|
|
339
|
+
while (Date.now() < deadline) {
|
|
340
|
+
const state = z.object({ sessionId: z.string(), pid: z.number().int().positive() }).safeParse(await recoveryStore(home).readState(`polytoken-ready-${id}`));
|
|
341
|
+
if (state.success && state.data.sessionId === process.env.POLYTOKEN_SESSION_ID) {
|
|
342
|
+
try {
|
|
343
|
+
process.kill(state.data.pid, 0);
|
|
344
|
+
ready = true;
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
catch { /* stale observer */ }
|
|
348
|
+
}
|
|
349
|
+
await new Promise(done => setTimeout(done, 50));
|
|
276
350
|
}
|
|
351
|
+
if (!ready)
|
|
352
|
+
process.stderr.write("Lore could not subscribe to Polytoken's local failure stream; automatic cap switching is unavailable for this turn.\n");
|
|
277
353
|
}
|
|
278
|
-
await captureRecoveryHook(home, id, input, agent);
|
|
279
354
|
}
|
|
280
355
|
catch {
|
|
281
356
|
process.stderr.write("Lore could not save this recovery checkpoint. Automatic switching may be unavailable.\n");
|
|
@@ -296,6 +371,11 @@ export function isInteractiveClaude(args, stdinIsTty, stdoutIsTty) {
|
|
|
296
371
|
}
|
|
297
372
|
/** The wrapper owns input routing; hooks only checkpoint. No terminal text matching. */
|
|
298
373
|
export async function superviseRecovery(home, command, args, run, env = process.env) {
|
|
374
|
+
if (run.sourceAgent === "polytoken") {
|
|
375
|
+
const flag = args.indexOf("--sessions-dir");
|
|
376
|
+
if (flag >= 0 && args[flag + 1])
|
|
377
|
+
env = { ...env, LORE_POLYTOKEN_SESSIONS_DIR: resolve(run.cwd, args[flag + 1]) };
|
|
378
|
+
}
|
|
299
379
|
const store = recoveryStore(home);
|
|
300
380
|
const sourceLabel = AGENTS[run.sourceAgent].label;
|
|
301
381
|
const targetLabel = AGENTS[run.targetAgent].label;
|
|
@@ -319,7 +399,7 @@ export async function superviseRecovery(home, command, args, run, env = process.
|
|
|
319
399
|
let accepted;
|
|
320
400
|
let finishing = false;
|
|
321
401
|
let polling = false;
|
|
322
|
-
let
|
|
402
|
+
let promptInput;
|
|
323
403
|
const seen = new Set();
|
|
324
404
|
const metric = async (event, current) => {
|
|
325
405
|
await recordRecoveryMetric(home, run.id, current.failure.id, event).catch(() => { });
|
|
@@ -339,9 +419,11 @@ export async function superviseRecovery(home, command, args, run, env = process.
|
|
|
339
419
|
if (!current || finishing)
|
|
340
420
|
return;
|
|
341
421
|
offered = undefined;
|
|
422
|
+
promptInput?.dispose();
|
|
423
|
+
promptInput = undefined;
|
|
342
424
|
if (!yes) {
|
|
343
425
|
await metric("declined", current);
|
|
344
|
-
process.stdout.write(`\r\nStaying with ${sourceLabel}.\r\n`);
|
|
426
|
+
process.stdout.write(exited ? "\r\nRecovery cancelled.\r\n" : `\r\nStaying with ${sourceLabel}.\r\n`);
|
|
345
427
|
pending = false;
|
|
346
428
|
if (exited)
|
|
347
429
|
finish();
|
|
@@ -364,23 +446,7 @@ export async function superviseRecovery(home, command, args, run, env = process.
|
|
|
364
446
|
};
|
|
365
447
|
const onInput = (chunk) => {
|
|
366
448
|
if (offered) {
|
|
367
|
-
|
|
368
|
-
if (text.includes("\u0003") || text.includes("\u001b")) {
|
|
369
|
-
void choose(false);
|
|
370
|
-
return;
|
|
371
|
-
}
|
|
372
|
-
response += text;
|
|
373
|
-
if (/[\r\n]/u.test(response)) {
|
|
374
|
-
const answer = response.split(/[\r\n]/u)[0].trim().toLowerCase();
|
|
375
|
-
if (answer === "" || answer === "y" || answer === "yes")
|
|
376
|
-
void choose(true);
|
|
377
|
-
else if (answer === "n" || answer === "no")
|
|
378
|
-
void choose(false);
|
|
379
|
-
else {
|
|
380
|
-
response = "";
|
|
381
|
-
process.stdout.write("\r\nEnter Y to switch or N to stay: ");
|
|
382
|
-
}
|
|
383
|
-
}
|
|
449
|
+
promptInput?.write(chunk);
|
|
384
450
|
}
|
|
385
451
|
else if (!pending && !finishing && !exited) {
|
|
386
452
|
if (chunk.length === 1 && chunk[0] === 0x1d) {
|
|
@@ -409,24 +475,36 @@ export async function superviseRecovery(home, command, args, run, env = process.
|
|
|
409
475
|
polling = true;
|
|
410
476
|
try {
|
|
411
477
|
const parsed = RecoveryRunSchema.safeParse(await store.readState(recoveryRunKey(run.id)));
|
|
412
|
-
|
|
478
|
+
let current = parsed.success ? parsed.data : undefined;
|
|
479
|
+
if (current && exited && exitCode !== null && exitCode !== 0 && ![130, 143, 137, 129].includes(exitCode)) {
|
|
480
|
+
await markRecoveryFailure(home, current, "process_exit");
|
|
481
|
+
current = RecoveryRunSchema.parse(await store.readState(recoveryRunKey(run.id)));
|
|
482
|
+
}
|
|
413
483
|
if (current?.failure && !seen.has(current.failure.id)) {
|
|
414
484
|
seen.add(current.failure.id);
|
|
415
485
|
// Verify fallback availability before offering a choice.
|
|
416
|
-
|
|
486
|
+
if (run.targetRemote) {
|
|
487
|
+
process.stdout.write(`\r\n${sourceLabel} stopped. Checking ${targetLabel} on ${run.targetRemote.host}…\r\n`);
|
|
488
|
+
await verifyRemoteDestination(run.targetRemote, run.targetAgent, env);
|
|
489
|
+
}
|
|
490
|
+
else
|
|
491
|
+
await findAgentExecutable(run.targetAgent, env);
|
|
417
492
|
pending = true;
|
|
418
493
|
offered = current;
|
|
419
|
-
|
|
494
|
+
promptInput = createRecoveryPrompt(yes => { void choose(yes); }, () => { process.stdout.write("\r\nEnter Y to switch or N to stay: "); });
|
|
420
495
|
await store.writeState(`attempt-${run.id}`, current);
|
|
421
496
|
await metric("offered", current);
|
|
422
497
|
const reason = current.failure.reason === "rate_limit" ? `${sourceLabel} reached a rate limit. ` :
|
|
423
|
-
current.failure.reason === "provider_retry" ? `${sourceLabel} is repeatedly retrying a failed provider request. ` :
|
|
424
|
-
|
|
498
|
+
current.failure.reason === "provider_retry" ? `${sourceLabel} is repeatedly retrying a failed provider request. ` :
|
|
499
|
+
current.failure.reason === "agent_error" ? `${sourceLabel} stopped with an error. ` :
|
|
500
|
+
current.failure.reason === "process_exit" ? `${sourceLabel} exited with code ${exitCode} before completing the turn. ` : "";
|
|
501
|
+
process.stdout.write(`\r\n\u001b[0m${reason}Continue with ${targetLabel}${run.targetRemote ? ` on ${run.targetRemote.host}` : ""}? [Y/n] `);
|
|
425
502
|
}
|
|
426
503
|
if (exited && !offered)
|
|
427
504
|
finish();
|
|
428
505
|
}
|
|
429
|
-
catch {
|
|
506
|
+
catch (error) {
|
|
507
|
+
process.stderr.write(`\r\nLore could not offer recovery: ${error.message}\r\n`);
|
|
430
508
|
if (exited)
|
|
431
509
|
finish();
|
|
432
510
|
}
|
|
@@ -434,6 +512,9 @@ export async function superviseRecovery(home, command, args, run, env = process.
|
|
|
434
512
|
polling = false;
|
|
435
513
|
}
|
|
436
514
|
};
|
|
515
|
+
const stopObserver = run.sourceAgent === "codex" ? observeCodexRecovery(home, run.id, command, env) :
|
|
516
|
+
run.sourceAgent === "copilot-cli" ? observeCopilotRecovery(home, run.id, env) :
|
|
517
|
+
run.sourceAgent === "polytoken" ? observePolytokenRecovery(home, run.id, env) : () => { };
|
|
437
518
|
const timer = setInterval(() => { void poll(); }, 150);
|
|
438
519
|
void sourceExit.then(poll);
|
|
439
520
|
try {
|
|
@@ -441,6 +522,8 @@ export async function superviseRecovery(home, command, args, run, env = process.
|
|
|
441
522
|
}
|
|
442
523
|
finally {
|
|
443
524
|
clearInterval(timer);
|
|
525
|
+
promptInput?.dispose();
|
|
526
|
+
stopObserver();
|
|
444
527
|
process.stdin.off("data", onInput);
|
|
445
528
|
process.stdout.off("resize", onResize);
|
|
446
529
|
process.off("SIGTERM", onSignal);
|
|
@@ -458,31 +541,49 @@ export async function superviseRecovery(home, command, args, run, env = process.
|
|
|
458
541
|
process.exitCode = exitCode ?? 130;
|
|
459
542
|
return;
|
|
460
543
|
}
|
|
461
|
-
|
|
544
|
+
await launchRecoveredRun(home, accepted, env);
|
|
545
|
+
}
|
|
546
|
+
export async function launchRecoveredRun(home, selected, env = process.env, onLaunch = async () => { }, onRestored = async () => { }) {
|
|
547
|
+
const sourceLabel = AGENTS[selected.sourceAgent].label;
|
|
548
|
+
const targetLabel = AGENTS[selected.targetAgent].label;
|
|
549
|
+
const metric = async (event, current) => {
|
|
550
|
+
await recordRecoveryMetric(home, current.id, current.failure.id, event);
|
|
551
|
+
};
|
|
462
552
|
try {
|
|
553
|
+
if (await recoveryStore(home).readState("disabled"))
|
|
554
|
+
throw new Error("Recovery is disabled");
|
|
555
|
+
if (selected.sourceAgent === "polytoken" && selected.failure?.reason !== "process_exit")
|
|
556
|
+
await verifyPolytokenStopped(selected, env);
|
|
463
557
|
const snapshot = await workspaceSnapshot(selected.cwd);
|
|
464
558
|
if (snapshot.root !== selected.failure.snapshot.root || snapshot.branch !== selected.failure.snapshot.branch || snapshot.head !== selected.failure.snapshot.head) {
|
|
465
559
|
throw new Error(`The source branch or checkout changed while the switch was pending. Resume ${sourceLabel} and retry from the correct working tree.`);
|
|
466
560
|
}
|
|
467
|
-
|
|
561
|
+
// Every launch path must preserve the attempt used by feedback and brief.
|
|
562
|
+
await recoveryStore(home).writeState(`attempt-${selected.id}`, selected);
|
|
563
|
+
if (selected.targetRemote) {
|
|
564
|
+
await launchRemoteRecovery(selected, selected.targetRemote, env, async () => { await metric("launched", selected); await onLaunch(); }, onRestored);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const target = await findAgentExecutable(selected.targetAgent, env);
|
|
568
|
+
await onRestored();
|
|
468
569
|
process.stdout.write(`\r\nContinuing in ${targetLabel} with the existing files and recent context.\r\n`);
|
|
469
570
|
// The destination inherits its own permission settings, never source flags.
|
|
470
|
-
const nextRoute = isRecoverySource(
|
|
571
|
+
const nextRoute = isRecoverySource(selected.targetAgent) && await recoveryFallback(home, selected.targetAgent);
|
|
471
572
|
let launched = Promise.resolve();
|
|
472
573
|
await new Promise((done, reject) => {
|
|
473
|
-
const
|
|
474
|
-
delete
|
|
475
|
-
delete
|
|
476
|
-
delete
|
|
477
|
-
const promptArgs = launchArguments(
|
|
478
|
-
const invocation = nextRoute ? [...cliInvocation(), "run",
|
|
479
|
-
const child = spawn(invocation[0], invocation.slice(1), { cwd: snapshot.cwd, env, stdio: "inherit" });
|
|
480
|
-
child.once("spawn", () => { launched = metric("launched", selected); });
|
|
574
|
+
const targetEnv = { ...env, LORE_HOME: home };
|
|
575
|
+
delete targetEnv.LORE_RECOVERY_RUN_ID;
|
|
576
|
+
delete targetEnv.LORE_RECOVERY_SOURCE;
|
|
577
|
+
delete targetEnv.LORE_PARENT_SESSION_ID;
|
|
578
|
+
const promptArgs = launchArguments(selected.targetAgent, recoveryBrief(selected, snapshot));
|
|
579
|
+
const invocation = nextRoute ? [...cliInvocation(), "run", selected.targetAgent, "--", ...promptArgs] : [target, ...promptArgs];
|
|
580
|
+
const child = spawn(invocation[0], invocation.slice(1), { cwd: snapshot.cwd, env: targetEnv, stdio: "inherit" });
|
|
581
|
+
child.once("spawn", () => { launched = metric("launched", selected).then(onLaunch); });
|
|
481
582
|
child.once("error", reject);
|
|
482
583
|
child.once("exit", (code, signal) => { process.exitCode = code ?? (signal ? 130 : 1); done(); });
|
|
483
584
|
});
|
|
484
585
|
await launched;
|
|
485
|
-
process.stdout.write(`\nWas the handoff useful? Record it with: lore recovery feedback ${
|
|
586
|
+
process.stdout.write(`\nWas the handoff useful? Record it with: lore recovery feedback ${selected.id} useful|not-useful\n`);
|
|
486
587
|
}
|
|
487
588
|
catch (error) {
|
|
488
589
|
await metric("launch_failed", selected);
|
|
@@ -491,16 +592,44 @@ export async function superviseRecovery(home, command, args, run, env = process.
|
|
|
491
592
|
}
|
|
492
593
|
}
|
|
493
594
|
export async function runRecoveryCommand(args, paths) {
|
|
595
|
+
if (args[0] === "editor-hook") {
|
|
596
|
+
let body = "";
|
|
597
|
+
for await (const chunk of process.stdin) {
|
|
598
|
+
body += chunk;
|
|
599
|
+
if (Buffer.byteLength(body) > 1024 * 1024)
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
await (await import("./recovery-hosts.js")).invalidateEditorRecovery(paths.home, JSON.parse(body));
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
if (args[0] === "monitor") {
|
|
606
|
+
await (await import("./recovery-hosts.js")).runExternalMonitor(paths.home);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (args[0] === "external-checkpoint") {
|
|
610
|
+
await (await import("./recovery-hosts.js")).externalCheckpointCommand(paths.home);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (args[0] === "offer" || args[0] === "watch" || args[0] === "watch-polytoken" || args[0] === "watch-copilot" || args[0] === "launch-accepted") {
|
|
614
|
+
const { runDesktopRecovery } = await import("./recovery-desktop.js");
|
|
615
|
+
await runDesktopRecovery(args, paths.home);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
if (args[0]?.startsWith("worker-")) {
|
|
619
|
+
await runRecoveryWorker(args, paths.home);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
494
622
|
if (args[0] === "enable") {
|
|
495
|
-
const parsed = z.object({ from: z.enum(RECOVERY_SOURCES), to: z.enum(LAUNCHABLE_AGENTS) });
|
|
623
|
+
const parsed = z.object({ from: z.enum(RECOVERY_SOURCES), to: z.enum(LAUNCHABLE_AGENTS), ssh: z.string().optional(), directory: z.string().optional() });
|
|
496
624
|
const flags = {};
|
|
497
625
|
for (let i = 1; i < args.length; i += 2) {
|
|
498
|
-
if (!["--from", "--to"].includes(args[i]) || !args[i + 1] || flags[args[i].slice(2)])
|
|
626
|
+
if (!["--from", "--to", "--ssh", "--directory"].includes(args[i]) || !args[i + 1] || flags[args[i].slice(2)])
|
|
499
627
|
throw new Error("Usage: lore recovery enable --from <agent> --to <agent>");
|
|
500
628
|
flags[args[i].slice(2)] = args[i + 1];
|
|
501
629
|
}
|
|
502
630
|
const route = parsed.parse(flags);
|
|
503
|
-
|
|
631
|
+
const remote = route.ssh || route.directory ? RemoteDestinationSchema.parse({ host: route.ssh, directory: route.directory }) : undefined;
|
|
632
|
+
process.stdout.write(`${await (await prepareRecoveryInstallation(paths, route.to, process.env, [route.from], remote))()}\n`);
|
|
504
633
|
return;
|
|
505
634
|
}
|
|
506
635
|
if (args[0] === "agents") {
|
|
@@ -508,12 +637,12 @@ export async function runRecoveryCommand(args, paths) {
|
|
|
508
637
|
agent, label: AGENTS[agent].label, executable: await findAgentExecutable(agent).catch(() => null),
|
|
509
638
|
localDestination: true, checkpointSource: isRecoverySource(agent), automaticTrigger: AGENTS[agent].automatic,
|
|
510
639
|
})));
|
|
511
|
-
process.stdout.write(`${JSON.stringify({ agents,
|
|
640
|
+
process.stdout.write(`${JSON.stringify({ agents, desktopAdapters: { t3code: "structured session failures", "copilot-vscode": "saved response errors; editor notification", cowork: "shared-folder checkpoints; cap hook unverified" }, briefOnly: ["devin-cloud"] }, null, 2)}\n`);
|
|
512
641
|
return;
|
|
513
642
|
}
|
|
514
643
|
if (args[0] === "status") {
|
|
515
644
|
const settings = Settings.safeParse(await recoveryStore(paths.home).readState("settings"));
|
|
516
|
-
process.stdout.write(`${JSON.stringify({ fallback: await recoveryFallback(paths.home) ?? null, routes: settings.success ? routesOf(settings.data) : {}, metrics: await recoveryMetrics(paths.home) }, null, 2)}\n`);
|
|
645
|
+
process.stdout.write(`${JSON.stringify({ fallback: await recoveryFallback(paths.home) ?? null, routes: settings.success ? routesOf(settings.data) : {}, remotes: settings.success ? settings.data.remotes ?? {} : {}, hosts: await recoveryStore(paths.home).readState("external-health"), metrics: await recoveryMetrics(paths.home) }, null, 2)}\n`);
|
|
517
646
|
return;
|
|
518
647
|
}
|
|
519
648
|
if (args[0] === "off") {
|
|
@@ -552,7 +681,8 @@ export async function runWrappedAgent(args, paths, captureDevin = false) {
|
|
|
552
681
|
}
|
|
553
682
|
let run;
|
|
554
683
|
try {
|
|
555
|
-
|
|
684
|
+
const settings = Settings.safeParse(await recoveryStore(paths.home).readState("settings"));
|
|
685
|
+
run = await createRecoveryRun(paths.home, process.cwd(), agent, fallback, settings.success ? settings.data.remotes?.[agent] : undefined);
|
|
556
686
|
}
|
|
557
687
|
catch {
|
|
558
688
|
process.stderr.write(`Lore recovery needs a readable git working tree. Starting ${AGENTS[agent].label} normally.\n`);
|