@tea-agent/loop-agent 0.25.1 → 0.25.3

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.
@@ -0,0 +1,657 @@
1
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ export const CLIENT_RECOVERY_MODES = ["auto", "project", "user", "off"];
5
+ export const OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH = ".opencode/plugins/loop-agent-transient-retry.js";
6
+ export const PERMANENT_ERROR_INTERACTION = "plugin-ignore-permanent-error";
7
+ export const BACKOFF_MS = [2000, 4000, 8000, 16000, 30000];
8
+ export const MAX_SESSION_RETRIES = 5;
9
+ export const PI_RECOMMENDED_RETRY = {
10
+ enabled: true,
11
+ maxRetries: 5,
12
+ baseDelayMs: 3000,
13
+ provider: {
14
+ maxRetries: 0,
15
+ maxRetryDelayMs: 60000,
16
+ },
17
+ };
18
+ const PERMANENT_MESSAGE_PATTERNS = [
19
+ { reason: "auth", pattern: /\b(401|unauthorized|invalid api key|authentication)\b/i },
20
+ { reason: "permission", pattern: /\b(403|forbidden|permission denied|not allowed)\b/i },
21
+ { reason: "quota", pattern: /\b(quota|rate.?limit|billing|insufficient.?credit)\b/i },
22
+ {
23
+ reason: "context-overflow",
24
+ pattern: /\b(context (length )?overflow|too many tokens|maximum context|context window)\b/i,
25
+ },
26
+ { reason: "cancelled", pattern: /\b(cancelled|canceled|aborted by user|user cancel)\b/i },
27
+ {
28
+ reason: "business-validation",
29
+ pattern: /\b(business validation|invalid task|schema validation)\b/i,
30
+ },
31
+ ];
32
+ const TRANSIENT_MESSAGE_PATTERNS = [
33
+ /\bcode[:\s]*502\b/i,
34
+ /\b502\b/,
35
+ /\bLLMRequestError\b/i,
36
+ /\bTypeValidationError\b/i,
37
+ /\bnetwork fluctuation\b/i,
38
+ /\btimeout\b/i,
39
+ /\btransient provider failure\b/i,
40
+ /\bnon-standard (payload|response)\b/i,
41
+ /\bupstream returned\b/i,
42
+ /\bmodel processing timeout\b/i,
43
+ ];
44
+ export function isClientRecoveryMode(value) {
45
+ return (typeof value === "string" &&
46
+ CLIENT_RECOVERY_MODES.includes(value));
47
+ }
48
+ export function parseClientRecoveryMode(value) {
49
+ if (value === undefined || value === null || value === "")
50
+ return "auto";
51
+ if (isClientRecoveryMode(value))
52
+ return value;
53
+ throw new Error(`init --client-recovery must be one of ${CLIENT_RECOVERY_MODES.join("|")}`);
54
+ }
55
+ function asText(value) {
56
+ if (typeof value === "string")
57
+ return value;
58
+ if (value == null)
59
+ return "";
60
+ try {
61
+ return JSON.stringify(value);
62
+ }
63
+ catch {
64
+ return String(value);
65
+ }
66
+ }
67
+ function collectErrorText(error) {
68
+ if (typeof error === "string")
69
+ return error;
70
+ if (!error || typeof error !== "object")
71
+ return asText(error);
72
+ const record = error;
73
+ const parts = [
74
+ asText(record.name),
75
+ asText(record.message),
76
+ asText(record.status),
77
+ asText(record.code),
78
+ asText(record.data),
79
+ ];
80
+ return parts.filter(Boolean).join(" ");
81
+ }
82
+ function hasUnknownErrorShape(error) {
83
+ const text = collectErrorText(error);
84
+ if (/\bUnknownError\b/i.test(text))
85
+ return true;
86
+ if (error && typeof error === "object") {
87
+ const record = error;
88
+ if (typeof record.name === "string" && /UnknownError/i.test(record.name))
89
+ return true;
90
+ }
91
+ return false;
92
+ }
93
+ function isStandardApiError(error) {
94
+ if (!error || typeof error !== "object")
95
+ return false;
96
+ const record = error;
97
+ if (typeof record.name === "string" && /^APIError$/i.test(record.name))
98
+ return true;
99
+ if (typeof record.name === "string" && /^(AuthError|PermissionError)$/i.test(record.name)) {
100
+ return true;
101
+ }
102
+ return false;
103
+ }
104
+ function permanentReason(error) {
105
+ if (isStandardApiError(error)) {
106
+ const record = error;
107
+ if (typeof record.name === "string" && /AuthError/i.test(record.name))
108
+ return "auth";
109
+ if (typeof record.name === "string" && /PermissionError/i.test(record.name)) {
110
+ return "permission";
111
+ }
112
+ return "api-error";
113
+ }
114
+ const text = collectErrorText(error);
115
+ for (const entry of PERMANENT_MESSAGE_PATTERNS) {
116
+ if (entry.pattern.test(text))
117
+ return entry.reason;
118
+ }
119
+ return undefined;
120
+ }
121
+ function looksTransient(error) {
122
+ const text = collectErrorText(error);
123
+ if (TRANSIENT_MESSAGE_PATTERNS.some((pattern) => pattern.test(text)))
124
+ return true;
125
+ if (error && typeof error === "object") {
126
+ const record = error;
127
+ if (record.code === 502 || record.status === 502)
128
+ return true;
129
+ if (record.data && typeof record.data === "object") {
130
+ const data = record.data;
131
+ if (data.code === 502 || data.status === 502)
132
+ return true;
133
+ }
134
+ }
135
+ return false;
136
+ }
137
+ /**
138
+ * Decide whether an OpenCode error should be resumed by the project plugin.
139
+ * Only compensates transient UnknownError paths that built-in APIError retry misses.
140
+ */
141
+ export function classifyTransientUnknownError(error) {
142
+ const permanent = permanentReason(error);
143
+ if (permanent) {
144
+ return {
145
+ resumable: false,
146
+ reason: permanent,
147
+ interaction: PERMANENT_ERROR_INTERACTION,
148
+ };
149
+ }
150
+ if (!hasUnknownErrorShape(error)) {
151
+ return {
152
+ resumable: false,
153
+ reason: "not-unknown-error",
154
+ interaction: PERMANENT_ERROR_INTERACTION,
155
+ };
156
+ }
157
+ if (!looksTransient(error)) {
158
+ return {
159
+ resumable: false,
160
+ reason: "unknown-non-transient",
161
+ interaction: PERMANENT_ERROR_INTERACTION,
162
+ };
163
+ }
164
+ return { resumable: true, reason: "transient-unknown-error" };
165
+ }
166
+ export function nextBackoffMs(attempt) {
167
+ if (!Number.isInteger(attempt) || attempt < 0 || attempt >= BACKOFF_MS.length) {
168
+ return undefined;
169
+ }
170
+ return BACKOFF_MS[attempt];
171
+ }
172
+ export function canResumeSession(input) {
173
+ if (input.sessionStatus === "retry") {
174
+ return { allowed: false, reason: "builtin-retry-active" };
175
+ }
176
+ if (input.locked) {
177
+ return { allowed: false, reason: "session-locked" };
178
+ }
179
+ if (!input.idle || input.sessionStatus === "busy" || input.sessionStatus === "running") {
180
+ return { allowed: false, reason: "session-busy" };
181
+ }
182
+ if (input.attempt >= MAX_SESSION_RETRIES) {
183
+ return { allowed: false, reason: "max-retries" };
184
+ }
185
+ return { allowed: true, reason: "ready" };
186
+ }
187
+ export function buildResumePrompt(input) {
188
+ return [
189
+ `[loop-agent transient recovery] session=${input.sessionId} attempt=${input.attempt}`,
190
+ `Previous model turn failed with a transient UnknownError: ${input.errorSummary}`,
191
+ "Before continuing any work, first inspect already completed tool calls and existing file modifications in this session.",
192
+ "Do not re-run side-effecting tools or rewrite files that already reflect successful prior work.",
193
+ "Resume only the remaining unfinished work after that check.",
194
+ "续接前请先检查本 session 已有工具调用与文件改动,避免重复执行有副作用的操作。",
195
+ ].join("\n");
196
+ }
197
+ export function createSessionRecoveryTracker() {
198
+ const states = new Map();
199
+ function getOrCreate(sessionId) {
200
+ let state = states.get(sessionId);
201
+ if (!state) {
202
+ state = { attempt: 0, locked: false };
203
+ states.set(sessionId, state);
204
+ }
205
+ return state;
206
+ }
207
+ return {
208
+ getOrCreate,
209
+ isLocked(sessionId) {
210
+ return getOrCreate(sessionId).locked;
211
+ },
212
+ tryAcquire(sessionId) {
213
+ const state = getOrCreate(sessionId);
214
+ if (state.locked)
215
+ return false;
216
+ state.locked = true;
217
+ return true;
218
+ },
219
+ release(sessionId) {
220
+ const state = getOrCreate(sessionId);
221
+ state.locked = false;
222
+ },
223
+ markSuccess(sessionId) {
224
+ const state = getOrCreate(sessionId);
225
+ state.attempt = 0;
226
+ state.locked = false;
227
+ },
228
+ incrementAttempt(sessionId) {
229
+ const state = getOrCreate(sessionId);
230
+ state.attempt += 1;
231
+ return state.attempt;
232
+ },
233
+ };
234
+ }
235
+ function isRecord(value) {
236
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
237
+ }
238
+ function readRetryEnabled(existing) {
239
+ if (!existing || !isRecord(existing.retry))
240
+ return undefined;
241
+ if (typeof existing.retry.enabled !== "boolean")
242
+ return undefined;
243
+ return existing.retry.enabled;
244
+ }
245
+ /**
246
+ * Field-level plan for ~/.pi/agent/settings.json retry recommendations.
247
+ * Never clobbers existing values; never overrides enabled:false.
248
+ */
249
+ export function planPiRetryMerge(existing) {
250
+ const settingsPath = "~/.pi/agent/settings.json";
251
+ if (existing === undefined) {
252
+ return {
253
+ action: "write",
254
+ reason: "missing-file",
255
+ path: settingsPath,
256
+ next: { retry: { ...PI_RECOMMENDED_RETRY, provider: { ...PI_RECOMMENDED_RETRY.provider } } },
257
+ };
258
+ }
259
+ if (!isRecord(existing)) {
260
+ return {
261
+ action: "report",
262
+ reason: "invalid-json",
263
+ path: settingsPath,
264
+ message: "Pi settings root must be a JSON object",
265
+ };
266
+ }
267
+ const enabled = readRetryEnabled(existing);
268
+ if (enabled === false) {
269
+ return {
270
+ action: "report",
271
+ reason: "enabled-false",
272
+ path: settingsPath,
273
+ next: existing,
274
+ message: "retry.enabled=false is preserved; human decision required to enable recovery",
275
+ };
276
+ }
277
+ const retry = isRecord(existing.retry) ? { ...existing.retry } : {};
278
+ const provider = isRecord(retry.provider) ? { ...retry.provider } : {};
279
+ let changed = !isRecord(existing.retry) || !isRecord(existing.retry.provider);
280
+ if (retry.enabled === undefined) {
281
+ retry.enabled = PI_RECOMMENDED_RETRY.enabled;
282
+ changed = true;
283
+ }
284
+ if (retry.maxRetries === undefined) {
285
+ retry.maxRetries = PI_RECOMMENDED_RETRY.maxRetries;
286
+ changed = true;
287
+ }
288
+ if (retry.baseDelayMs === undefined) {
289
+ retry.baseDelayMs = PI_RECOMMENDED_RETRY.baseDelayMs;
290
+ changed = true;
291
+ }
292
+ if (provider.maxRetries === undefined) {
293
+ provider.maxRetries = PI_RECOMMENDED_RETRY.provider.maxRetries;
294
+ changed = true;
295
+ }
296
+ if (provider.maxRetryDelayMs === undefined) {
297
+ provider.maxRetryDelayMs = PI_RECOMMENDED_RETRY.provider.maxRetryDelayMs;
298
+ changed = true;
299
+ }
300
+ retry.provider = provider;
301
+ const next = { ...existing, retry };
302
+ if (!changed) {
303
+ return {
304
+ action: "noop",
305
+ reason: "matches",
306
+ path: settingsPath,
307
+ next,
308
+ };
309
+ }
310
+ return {
311
+ action: "write",
312
+ reason: "missing-fields",
313
+ path: settingsPath,
314
+ next,
315
+ };
316
+ }
317
+ export function piSettingsPath(homeDir) {
318
+ return path.join(homeDir, ".pi", "agent", "settings.json");
319
+ }
320
+ async function writeJsonAtomicHome(filePath, value) {
321
+ await mkdir(path.dirname(filePath), { recursive: true });
322
+ const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
323
+ try {
324
+ await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf-8");
325
+ await rename(tempPath, filePath);
326
+ }
327
+ catch (error) {
328
+ await rm(tempPath, { force: true }).catch(() => undefined);
329
+ throw error;
330
+ }
331
+ }
332
+ function isMissingFileError(error) {
333
+ return (error instanceof Error &&
334
+ "code" in error &&
335
+ error.code === "ENOENT");
336
+ }
337
+ export async function applyPiRetryMerge(input) {
338
+ const settingsPath = piSettingsPath(input.homeDir);
339
+ let raw;
340
+ try {
341
+ raw = await readFile(settingsPath, "utf-8");
342
+ }
343
+ catch (error) {
344
+ if (!isMissingFileError(error))
345
+ throw error;
346
+ raw = undefined;
347
+ }
348
+ if (raw === undefined) {
349
+ const plan = planPiRetryMerge(undefined);
350
+ if (plan.action === "write" && plan.next) {
351
+ await writeJsonAtomicHome(settingsPath, plan.next);
352
+ return { ...plan, path: settingsPath, wrote: true };
353
+ }
354
+ return { ...plan, path: settingsPath, wrote: false };
355
+ }
356
+ let parsed;
357
+ try {
358
+ parsed = JSON.parse(raw);
359
+ }
360
+ catch {
361
+ return {
362
+ action: "report",
363
+ reason: "invalid-json",
364
+ path: settingsPath,
365
+ wrote: false,
366
+ message: "Pi settings.json is not valid JSON; refusing to write",
367
+ };
368
+ }
369
+ if (!isRecord(parsed)) {
370
+ return {
371
+ action: "report",
372
+ reason: "invalid-json",
373
+ path: settingsPath,
374
+ wrote: false,
375
+ message: "Pi settings.json root must be a JSON object; refusing to write",
376
+ };
377
+ }
378
+ const plan = planPiRetryMerge(parsed);
379
+ if (plan.action === "write" && plan.next) {
380
+ await writeJsonAtomicHome(settingsPath, plan.next);
381
+ // Best-effort cleanup of any stray temp files from interrupted prior runs.
382
+ try {
383
+ const dir = path.dirname(settingsPath);
384
+ const entries = await readdir(dir);
385
+ await Promise.all(entries
386
+ .filter((name) => name.startsWith(`.${path.basename(settingsPath)}.`) && name.endsWith(".tmp"))
387
+ .map((name) => rm(path.join(dir, name), { force: true })));
388
+ }
389
+ catch {
390
+ // ignore cleanup failures
391
+ }
392
+ return { ...plan, path: settingsPath, wrote: true };
393
+ }
394
+ return { ...plan, path: settingsPath, wrote: false };
395
+ }
396
+ /**
397
+ * Stable generated source for the OpenCode project plugin.
398
+ * The plugin compensates only transient UnknownError after built-in retry is idle.
399
+ */
400
+ export function buildOpenCodeTransientRetryPluginSource() {
401
+ // Keep this template deterministic: no timestamps, no random ids.
402
+ return `/**
403
+ * loop-agent OpenCode transient recovery plugin
404
+ * Path: ${OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH}
405
+ *
406
+ * Compensates transient UnknownError cases that OpenCode built-in APIError
407
+ * retry does not cover. Never runs concurrently with session.status === "retry".
408
+ * Permanent failures use interaction "${PERMANENT_ERROR_INTERACTION}".
409
+ */
410
+ const PLUGIN_PATH = ${JSON.stringify(OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH)};
411
+ const PERMANENT_INTERACTION = ${JSON.stringify(PERMANENT_ERROR_INTERACTION)};
412
+ const BACKOFF_MS = ${JSON.stringify([...BACKOFF_MS])};
413
+ const MAX_RETRIES = ${MAX_SESSION_RETRIES};
414
+
415
+ const sessionState = new Map();
416
+ const sessionStatus = new Map();
417
+
418
+ function getState(sessionId) {
419
+ let state = sessionState.get(sessionId);
420
+ if (!state) {
421
+ state = { attempt: 0, locked: false };
422
+ sessionState.set(sessionId, state);
423
+ }
424
+ return state;
425
+ }
426
+
427
+ function textOf(error) {
428
+ if (!error) return "";
429
+ if (typeof error === "string") return error;
430
+ try {
431
+ return JSON.stringify(error);
432
+ } catch {
433
+ return String(error);
434
+ }
435
+ }
436
+
437
+ function isPermanent(error) {
438
+ const text = textOf(error);
439
+ if (error && typeof error === "object" && typeof error.name === "string") {
440
+ if (/^APIError$/i.test(error.name)) return true;
441
+ if (/^(AuthError|PermissionError)$/i.test(error.name)) return true;
442
+ }
443
+ return /\\b(401|403|unauthorized|forbidden|quota|rate.?limit|context (length )?overflow|too many tokens|cancelled|canceled|business validation|invalid task|schema validation)\\b/i.test(
444
+ text,
445
+ );
446
+ }
447
+
448
+ function isTransientUnknown(error) {
449
+ if (isPermanent(error)) return false;
450
+ const text = textOf(error);
451
+ const hasUnknown = /UnknownError/i.test(text) || (error && /UnknownError/i.test(String(error.name || "")));
452
+ if (!hasUnknown) return false;
453
+ return (
454
+ /\\b(code[:\\s]*502|502|LLMRequestError|TypeValidationError|Type validation failed|network fluctuation|timeout|transient provider failure|non-standard)\\b/i.test(
455
+ text,
456
+ ) ||
457
+ (error && (error.code === 502 || error.status === 502))
458
+ );
459
+ }
460
+
461
+ function buildResumePrompt(sessionId, error, attempt) {
462
+ return [
463
+ "[loop-agent transient recovery] session=" + sessionId + " attempt=" + attempt,
464
+ "Previous model turn failed with a transient UnknownError: " + textOf(error),
465
+ "Before continuing any work, first inspect already completed tool calls and existing file modifications in this session.",
466
+ "Do not re-run side-effecting tools or rewrite files that already reflect successful prior work.",
467
+ "Resume only the remaining unfinished work after that check.",
468
+ "续接前请先检查本 session 已有工具调用与文件改动,避免重复执行有副作用的操作。",
469
+ ].join("\\n");
470
+ }
471
+
472
+ async function wait(ms) {
473
+ return new Promise((resolve) => setTimeout(resolve, ms));
474
+ }
475
+
476
+ function statusType(status) {
477
+ return status && typeof status.type === "string" ? status.type : undefined;
478
+ }
479
+
480
+ async function readSessionStatus(client, sessionId) {
481
+ const cached = sessionStatus.get(sessionId);
482
+ if (cached === "retry") return cached;
483
+ try {
484
+ const response = await client.session.status();
485
+ const current = statusType(response?.data?.[sessionId]);
486
+ if (current) sessionStatus.set(sessionId, current);
487
+ return current || cached || "idle";
488
+ } catch {
489
+ return cached || "idle";
490
+ }
491
+ }
492
+
493
+ export default async function loopAgentTransientRetryPlugin({ client, $ }) {
494
+ void $;
495
+ void PLUGIN_PATH;
496
+
497
+ async function maybeResume(sessionId, error) {
498
+ if (!sessionId) return { interaction: PERMANENT_INTERACTION, reason: "missing-session" };
499
+ if (!isTransientUnknown(error)) {
500
+ return { interaction: PERMANENT_INTERACTION, reason: "plugin-ignore-permanent-error" };
501
+ }
502
+
503
+ const state = getState(sessionId);
504
+ if (state.locked) {
505
+ return { interaction: PERMANENT_INTERACTION, reason: "session-locked" };
506
+ }
507
+ if (state.attempt >= MAX_RETRIES) {
508
+ return { interaction: PERMANENT_INTERACTION, reason: "max-retries" };
509
+ }
510
+
511
+ state.locked = true;
512
+ try {
513
+ let status = await readSessionStatus(client, sessionId);
514
+ // Never race OpenCode built-in retry.
515
+ if (status === "retry") {
516
+ return { interaction: PERMANENT_INTERACTION, reason: "builtin-retry-active" };
517
+ }
518
+ if (status === "busy" || status === "running") {
519
+ return { interaction: PERMANENT_INTERACTION, reason: "session-busy" };
520
+ }
521
+
522
+ const delay = BACKOFF_MS[state.attempt];
523
+ const nextAttempt = state.attempt + 1;
524
+ if (typeof delay === "number") await wait(delay);
525
+ // Re-check idle after backoff.
526
+ status = await readSessionStatus(client, sessionId);
527
+ if (status === "retry" || status === "busy" || status === "running") {
528
+ return { interaction: PERMANENT_INTERACTION, reason: "session-not-idle" };
529
+ }
530
+ const prompt = buildResumePrompt(sessionId, error, nextAttempt);
531
+ state.attempt = nextAttempt;
532
+ await client.session.promptAsync({
533
+ path: { id: sessionId },
534
+ body: { parts: [{ type: "text", text: prompt }] },
535
+ });
536
+ return { resumed: true, attempt: state.attempt };
537
+ } finally {
538
+ state.locked = false;
539
+ }
540
+ }
541
+
542
+ return {
543
+ event: async ({ event }) => {
544
+ if (!event || typeof event !== "object") return;
545
+ const properties = event.properties || {};
546
+ const sessionId =
547
+ properties.sessionID || properties.sessionId || properties.id;
548
+
549
+ if (event.type === "session.status") {
550
+ const current = statusType(properties.status);
551
+ if (sessionId && current) sessionStatus.set(sessionId, current);
552
+ return;
553
+ }
554
+
555
+ if (event.type === "message.updated") {
556
+ const info = properties.info;
557
+ if (
558
+ info?.role === "assistant" &&
559
+ info.sessionID &&
560
+ info.time?.completed != null &&
561
+ info.error == null
562
+ ) {
563
+ getState(info.sessionID).attempt = 0;
564
+ }
565
+ return;
566
+ }
567
+
568
+ if (event.type === "session.error") {
569
+ return maybeResume(sessionId, properties.error || properties);
570
+ }
571
+ },
572
+ };
573
+ }
574
+ `;
575
+ }
576
+ export async function installProjectOpenCodePlugin(input) {
577
+ const relativePath = OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH;
578
+ const target = path.join(input.repoRoot, relativePath);
579
+ const content = buildOpenCodeTransientRetryPluginSource();
580
+ if (input.onlyIfMissing) {
581
+ try {
582
+ await readFile(target, "utf-8");
583
+ return { path: relativePath, written: false, reason: "unchanged" };
584
+ }
585
+ catch {
586
+ // missing → write
587
+ }
588
+ }
589
+ await mkdir(path.dirname(target), { recursive: true });
590
+ await writeFile(target, content, "utf-8");
591
+ return { path: relativePath, written: true, reason: "written" };
592
+ }
593
+ export async function inspectPiRetryConfig(input) {
594
+ const homeDir = input.homeDir ?? os.homedir();
595
+ const settingsPath = piSettingsPath(homeDir);
596
+ let raw;
597
+ try {
598
+ raw = await readFile(settingsPath, "utf-8");
599
+ }
600
+ catch (error) {
601
+ if (!isMissingFileError(error))
602
+ throw error;
603
+ return {
604
+ action: "report",
605
+ reason: "missing-file",
606
+ path: settingsPath,
607
+ wrote: false,
608
+ };
609
+ }
610
+ try {
611
+ const parsed = JSON.parse(raw);
612
+ if (!isRecord(parsed)) {
613
+ return {
614
+ action: "report",
615
+ reason: "invalid-json",
616
+ path: settingsPath,
617
+ wrote: false,
618
+ };
619
+ }
620
+ const plan = planPiRetryMerge(parsed);
621
+ return { ...plan, path: settingsPath, wrote: false };
622
+ }
623
+ catch {
624
+ return {
625
+ action: "report",
626
+ reason: "invalid-json",
627
+ path: settingsPath,
628
+ wrote: false,
629
+ };
630
+ }
631
+ }
632
+ /**
633
+ * Install project OpenCode plugin and optionally merge Pi user settings.
634
+ * - auto/project: project plugin only (no home writes)
635
+ * - user: project plugin + explicit Pi merge
636
+ * - off: skip all
637
+ */
638
+ export async function runClientRecovery(input) {
639
+ const mode = input.mode ?? "auto";
640
+ if (mode === "off") {
641
+ return {
642
+ mode,
643
+ plugin: {
644
+ path: OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH,
645
+ written: false,
646
+ reason: "skipped",
647
+ },
648
+ };
649
+ }
650
+ const plugin = await installProjectOpenCodePlugin({ repoRoot: input.repoRoot });
651
+ if (mode !== "user") {
652
+ return { mode, plugin };
653
+ }
654
+ const homeDir = input.homeDir ?? os.homedir();
655
+ const pi = await applyPiRetryMerge({ homeDir });
656
+ return { mode, plugin, pi };
657
+ }