@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.
Files changed (105) hide show
  1. package/README.md +48 -9
  2. package/dist/agents.d.ts +3 -1
  3. package/dist/agents.d.ts.map +1 -1
  4. package/dist/agents.js +31 -7
  5. package/dist/agents.js.map +1 -1
  6. package/dist/cli.d.ts +8 -0
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +97 -15
  9. package/dist/cli.js.map +1 -1
  10. package/dist/command-help.d.ts +2 -1
  11. package/dist/command-help.d.ts.map +1 -1
  12. package/dist/command-help.js +33 -6
  13. package/dist/command-help.js.map +1 -1
  14. package/dist/generated-assets.d.ts +9 -4
  15. package/dist/generated-assets.d.ts.map +1 -1
  16. package/dist/generated-assets.js +5 -4
  17. package/dist/generated-assets.js.map +1 -1
  18. package/dist/guard.d.ts.map +1 -1
  19. package/dist/guard.js +7 -1
  20. package/dist/guard.js.map +1 -1
  21. package/dist/image-capture.d.ts.map +1 -1
  22. package/dist/image-capture.js +8 -3
  23. package/dist/image-capture.js.map +1 -1
  24. package/dist/live.d.ts.map +1 -1
  25. package/dist/live.js +40 -20
  26. package/dist/live.js.map +1 -1
  27. package/dist/recovery-cloud-receipt.d.ts +24 -0
  28. package/dist/recovery-cloud-receipt.d.ts.map +1 -0
  29. package/dist/recovery-cloud-receipt.js +78 -0
  30. package/dist/recovery-cloud-receipt.js.map +1 -0
  31. package/dist/recovery-cloud-workspace.d.ts +11 -0
  32. package/dist/recovery-cloud-workspace.d.ts.map +1 -0
  33. package/dist/recovery-cloud-workspace.js +141 -0
  34. package/dist/recovery-cloud-workspace.js.map +1 -0
  35. package/dist/recovery-cloud.d.ts +31 -0
  36. package/dist/recovery-cloud.d.ts.map +1 -0
  37. package/dist/recovery-cloud.js +242 -0
  38. package/dist/recovery-cloud.js.map +1 -0
  39. package/dist/recovery-codex.d.ts +5 -0
  40. package/dist/recovery-codex.d.ts.map +1 -0
  41. package/dist/recovery-codex.js +111 -0
  42. package/dist/recovery-codex.js.map +1 -0
  43. package/dist/recovery-copilot.d.ts +14 -0
  44. package/dist/recovery-copilot.d.ts.map +1 -0
  45. package/dist/recovery-copilot.js +120 -0
  46. package/dist/recovery-copilot.js.map +1 -0
  47. package/dist/recovery-desktop.d.ts +7 -0
  48. package/dist/recovery-desktop.d.ts.map +1 -0
  49. package/dist/recovery-desktop.js +179 -0
  50. package/dist/recovery-desktop.js.map +1 -0
  51. package/dist/recovery-external.d.ts +61 -0
  52. package/dist/recovery-external.d.ts.map +1 -0
  53. package/dist/recovery-external.js +98 -0
  54. package/dist/recovery-external.js.map +1 -0
  55. package/dist/recovery-hooks.d.ts +5 -0
  56. package/dist/recovery-hooks.d.ts.map +1 -0
  57. package/dist/recovery-hooks.js +115 -0
  58. package/dist/recovery-hooks.js.map +1 -0
  59. package/dist/recovery-hosts.d.ts +13 -0
  60. package/dist/recovery-hosts.d.ts.map +1 -0
  61. package/dist/recovery-hosts.js +326 -0
  62. package/dist/recovery-hosts.js.map +1 -0
  63. package/dist/recovery-polytoken.d.ts +14 -0
  64. package/dist/recovery-polytoken.d.ts.map +1 -0
  65. package/dist/recovery-polytoken.js +194 -0
  66. package/dist/recovery-polytoken.js.map +1 -0
  67. package/dist/recovery-prompt.d.ts +6 -0
  68. package/dist/recovery-prompt.d.ts.map +1 -0
  69. package/dist/recovery-prompt.js +53 -0
  70. package/dist/recovery-prompt.js.map +1 -0
  71. package/dist/recovery-remote.d.ts +8 -0
  72. package/dist/recovery-remote.d.ts.map +1 -0
  73. package/dist/recovery-remote.js +135 -0
  74. package/dist/recovery-remote.js.map +1 -0
  75. package/dist/recovery-state.d.ts +25 -1
  76. package/dist/recovery-state.d.ts.map +1 -1
  77. package/dist/recovery-state.js +69 -15
  78. package/dist/recovery-state.js.map +1 -1
  79. package/dist/recovery-t3.d.ts +17 -0
  80. package/dist/recovery-t3.d.ts.map +1 -0
  81. package/dist/recovery-t3.js +58 -0
  82. package/dist/recovery-t3.js.map +1 -0
  83. package/dist/recovery-transfer.d.ts +23 -0
  84. package/dist/recovery-transfer.d.ts.map +1 -0
  85. package/dist/recovery-transfer.js +234 -0
  86. package/dist/recovery-transfer.js.map +1 -0
  87. package/dist/recovery.d.ts +9 -1
  88. package/dist/recovery.d.ts.map +1 -1
  89. package/dist/recovery.js +197 -67
  90. package/dist/recovery.js.map +1 -1
  91. package/dist/reliability-store.d.ts +1 -0
  92. package/dist/reliability-store.d.ts.map +1 -1
  93. package/dist/reliability-store.js +23 -0
  94. package/dist/reliability-store.js.map +1 -1
  95. package/dist/runtime.d.ts.map +1 -1
  96. package/dist/runtime.js +39 -11
  97. package/dist/runtime.js.map +1 -1
  98. package/dist/self-host.d.ts +1 -1
  99. package/dist/self-host.js +2 -2
  100. package/dist/uninstall.d.ts +19 -0
  101. package/dist/uninstall.d.ts.map +1 -0
  102. package/dist/uninstall.js +135 -0
  103. package/dist/uninstall.js.map +1 -0
  104. package/dist/update.js +3 -3
  105. 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 { captureRecoveryHook, createRecoveryRun, recoveryBrief, recoveryMetrics, recoveryRunKey, RecoveryRunSchema, recoveryStore, recordRecoveryMetric, workspaceSnapshot, requestRecoverySwitch, } from "./recovery-state.js";
11
- import { AGENTS, LAUNCHABLE_AGENTS, RECOVERY_SOURCES, findAgentExecutable, isInteractiveAgent, isLaunchable, isRecoverySource, launchArguments } from "./agents.js";
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
- remaining.push({ ...(event === "StopFailure" ? { matcher: "rate_limit" } : {}), hooks: [{ type: "command", command, timeout: 10 }] });
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
- export async function prepareRecoveryInstallation(paths, fallback, env = process.env, requestedSources) {
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
- await findAgentExecutable(fallback, env);
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
- for (const source of sources)
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.map(source => `${source}() { ${invocation} run ${source} -- "$@"; }`).join("\n")}\n${END}\n`;
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) : mergeRecoveryHooks(value, command, hookEvents(agent));
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).writeState("settings", { fallback, routes, shellPath, shellPaths, sourceFiles, openCodePlugin });
222
- return `Recovery enabled: ${allSources.map(source => `${AGENTS[source].label} → ${AGENTS[routes[source]].label}`).join("; ")}.\nCtrl+] offers a switch from a saved checkpoint. Automatic triggers: Claude rate limits; OpenCode HTTP 429 or repeated provider retries.\nOpen a new terminal or run: source ${quote(shellPath)}`;
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) : mergeRecoveryHooks(value, null, hookEvents(agent));
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
- const id = process.env.LORE_RECOVERY_RUN_ID;
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 (!id || oversized)
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 (!id || oversized)
326
+ if (oversized)
269
327
  return;
270
328
  try {
271
- let input = JSON.parse(body);
272
- if (agent === "devin" && input && typeof input === "object") {
273
- input.cwd ??= process.env.DEVIN_PROJECT_DIR ?? process.cwd();
274
- if (input.hook_event_name === "PostToolUse" || input.hook_event_name === "PostCompaction") {
275
- input = { ...input, hook_event_name: "Checkpoint", last_assistant_message: input.summary ?? `Tool ${String(input.tool_name ?? "result")}: ${JSON.stringify(input.tool_response ?? {})}` };
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 response = "";
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
- const text = chunk.toString("utf8");
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
- const current = parsed.success ? parsed.data : undefined;
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
- await findAgentExecutable(run.targetAgent);
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
- response = "";
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
- process.stdout.write(`\r\n\u001b[0m${reason}Continue with ${targetLabel}? [Y/n] `);
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
- const selected = accepted;
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
- const target = await findAgentExecutable(run.targetAgent);
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(run.targetAgent) && await recoveryFallback(home, run.targetAgent);
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 env = { ...process.env, LORE_HOME: home };
474
- delete env.LORE_RECOVERY_RUN_ID;
475
- delete env.LORE_RECOVERY_SOURCE;
476
- delete env.LORE_PARENT_SESSION_ID;
477
- const promptArgs = launchArguments(run.targetAgent, recoveryBrief(selected, snapshot));
478
- const invocation = nextRoute ? [...cliInvocation(), "run", run.targetAgent, "--", ...promptArgs] : [target, ...promptArgs];
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 ${run.id} useful|not-useful\n`);
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
- process.stdout.write(`${await (await prepareRecoveryInstallation(paths, route.to, process.env, [route.from]))()}\n`);
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, briefOnly: ["t3code", "cowork", "copilot-vscode", "devin-cloud"] }, null, 2)}\n`);
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
- run = await createRecoveryRun(paths.home, process.cwd(), agent, fallback);
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`);