opencode-immune 1.0.48 → 1.0.51
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/dist/plugin.js +119 -76
- package/package.json +2 -1
package/dist/plugin.js
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
"use strict";
|
|
2
1
|
// .opencode/plugin.ts — opencode-immune plugin
|
|
3
2
|
// Hybrid single-file architecture with factory functions, explicit state, error boundaries
|
|
4
3
|
// See: memory-bank/creative/creative-plugin-architecture.md (Option C)
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
4
|
+
import { createOpencodeClient as createOpencodeClientV2 } from "@opencode-ai/sdk/v2/client";
|
|
5
|
+
import { appendFile, mkdir, readFile, unlink, writeFile, stat, rm, rename, readdir, copyFile } from "fs/promises";
|
|
6
|
+
import { join, dirname } from "path";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
import { createHash } from "crypto";
|
|
9
|
+
import { tmpdir } from "os";
|
|
10
|
+
import { execFile } from "child_process";
|
|
12
11
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
13
12
|
// PLUGIN VERSION CHECK
|
|
14
13
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
15
|
-
const PLUGIN_VERSION = "1.0.
|
|
14
|
+
const PLUGIN_VERSION = "1.0.51";
|
|
16
15
|
const PLUGIN_PACKAGE_NAME = "opencode-immune";
|
|
16
|
+
const PLUGIN_DIRNAME = dirname(fileURLToPath(import.meta.url));
|
|
17
17
|
/**
|
|
18
18
|
* Read plugin version from package.json at runtime.
|
|
19
19
|
* Falls back to PLUGIN_VERSION constant if read fails.
|
|
@@ -24,12 +24,12 @@ async function getPluginVersion() {
|
|
|
24
24
|
// dist/plugin.js → ../package.json
|
|
25
25
|
// Also try direct path for when loaded from npm cache.
|
|
26
26
|
const candidates = [
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
join(PLUGIN_DIRNAME, "..", "package.json"),
|
|
28
|
+
join(PLUGIN_DIRNAME, "package.json"),
|
|
29
29
|
];
|
|
30
30
|
for (const pkgPath of candidates) {
|
|
31
31
|
try {
|
|
32
|
-
const content = await
|
|
32
|
+
const content = await readFile(pkgPath, "utf-8");
|
|
33
33
|
const pkg = JSON.parse(content);
|
|
34
34
|
if (pkg.version)
|
|
35
35
|
return pkg.version;
|
|
@@ -77,7 +77,7 @@ function createState(input) {
|
|
|
77
77
|
const { client: _client, ...runtimeInput } = input;
|
|
78
78
|
return {
|
|
79
79
|
input: runtimeInput,
|
|
80
|
-
client: (
|
|
80
|
+
client: createOpencodeClientV2({
|
|
81
81
|
baseUrl: input.serverUrl.toString(),
|
|
82
82
|
directory: input.directory,
|
|
83
83
|
}),
|
|
@@ -87,8 +87,8 @@ function createState(input) {
|
|
|
87
87
|
providerRetryWatchdogs: new Map(),
|
|
88
88
|
childFallbackRequests: new Map(),
|
|
89
89
|
sessionErrorRetryCount: new Map(),
|
|
90
|
-
ultraworkMarkerPath:
|
|
91
|
-
diagnosticsLogPath:
|
|
90
|
+
ultraworkMarkerPath: join(input.directory, ".opencode", "state", "ultrawork-active.json"),
|
|
91
|
+
diagnosticsLogPath: join(input.directory, ".opencode", "state", "opencode-immune-debug.log"),
|
|
92
92
|
lastEditAttempt: null,
|
|
93
93
|
toolCallCount: 0,
|
|
94
94
|
todoWriteUsed: false,
|
|
@@ -188,11 +188,11 @@ function pruneExpiredManagedSessions(state, now = Date.now()) {
|
|
|
188
188
|
}
|
|
189
189
|
async function writeDiagnosticLog(state, event, data = {}) {
|
|
190
190
|
try {
|
|
191
|
-
const cacheDir =
|
|
192
|
-
await
|
|
191
|
+
const cacheDir = join(state.input.directory, ".opencode", "state");
|
|
192
|
+
await mkdir(cacheDir, { recursive: true });
|
|
193
193
|
await rotateDiagnosticLogIfNeeded(state.diagnosticsLogPath);
|
|
194
194
|
const line = JSON.stringify({ ts: new Date().toISOString(), event, ...data });
|
|
195
|
-
await
|
|
195
|
+
await appendFile(state.diagnosticsLogPath, `${line}\n`, "utf-8");
|
|
196
196
|
}
|
|
197
197
|
catch {
|
|
198
198
|
// diagnostics must never affect runtime behavior
|
|
@@ -200,12 +200,12 @@ async function writeDiagnosticLog(state, event, data = {}) {
|
|
|
200
200
|
}
|
|
201
201
|
async function rotateDiagnosticLogIfNeeded(logPath) {
|
|
202
202
|
try {
|
|
203
|
-
const current = await
|
|
203
|
+
const current = await stat(logPath);
|
|
204
204
|
if (current.size < DIAGNOSTIC_LOG_MAX_BYTES)
|
|
205
205
|
return;
|
|
206
206
|
const rotatedPath = `${logPath}.1`;
|
|
207
|
-
await
|
|
208
|
-
await
|
|
207
|
+
await rm(rotatedPath, { force: true });
|
|
208
|
+
await rename(logPath, rotatedPath);
|
|
209
209
|
}
|
|
210
210
|
catch {
|
|
211
211
|
// missing log or rotation failure must never affect runtime behavior
|
|
@@ -228,10 +228,10 @@ function writePluginLog(state, level, message, extra = {}) {
|
|
|
228
228
|
});
|
|
229
229
|
}
|
|
230
230
|
function writePluginLogForDirectory(directory, level, message, extra = {}) {
|
|
231
|
-
const diagnosticsLogPath =
|
|
231
|
+
const diagnosticsLogPath = join(directory, ".opencode", "state", "opencode-immune-debug.log");
|
|
232
232
|
void (async () => {
|
|
233
233
|
try {
|
|
234
|
-
await
|
|
234
|
+
await mkdir(dirname(diagnosticsLogPath), { recursive: true });
|
|
235
235
|
await rotateDiagnosticLogIfNeeded(diagnosticsLogPath);
|
|
236
236
|
const line = JSON.stringify({
|
|
237
237
|
ts: new Date().toISOString(),
|
|
@@ -239,7 +239,7 @@ function writePluginLogForDirectory(directory, level, message, extra = {}) {
|
|
|
239
239
|
message,
|
|
240
240
|
...extra,
|
|
241
241
|
});
|
|
242
|
-
await
|
|
242
|
+
await appendFile(diagnosticsLogPath, `${line}\n`, "utf-8");
|
|
243
243
|
}
|
|
244
244
|
catch {
|
|
245
245
|
// file logging must never affect runtime behavior
|
|
@@ -263,13 +263,13 @@ const pluginLog = {
|
|
|
263
263
|
// ── Ultrawork Marker File ──
|
|
264
264
|
async function writeUltraworkMarker(state) {
|
|
265
265
|
try {
|
|
266
|
-
const dir =
|
|
267
|
-
await
|
|
266
|
+
const dir = join(state.input.directory, ".opencode", "state");
|
|
267
|
+
await mkdir(dir, { recursive: true });
|
|
268
268
|
const payload = JSON.stringify({
|
|
269
269
|
active: true,
|
|
270
270
|
updatedAt: new Date().toISOString(),
|
|
271
271
|
});
|
|
272
|
-
await
|
|
272
|
+
await writeFile(state.ultraworkMarkerPath, payload, "utf-8");
|
|
273
273
|
}
|
|
274
274
|
catch {
|
|
275
275
|
// marker write must never affect runtime
|
|
@@ -277,7 +277,7 @@ async function writeUltraworkMarker(state) {
|
|
|
277
277
|
}
|
|
278
278
|
async function clearUltraworkMarker(state) {
|
|
279
279
|
try {
|
|
280
|
-
await
|
|
280
|
+
await unlink(state.ultraworkMarkerPath);
|
|
281
281
|
}
|
|
282
282
|
catch {
|
|
283
283
|
// file may not exist — that's fine
|
|
@@ -285,7 +285,7 @@ async function clearUltraworkMarker(state) {
|
|
|
285
285
|
}
|
|
286
286
|
async function isUltraworkMarkerActive(state) {
|
|
287
287
|
try {
|
|
288
|
-
const raw = await
|
|
288
|
+
const raw = await readFile(state.ultraworkMarkerPath, "utf-8");
|
|
289
289
|
const parsed = JSON.parse(raw);
|
|
290
290
|
return parsed?.active === true;
|
|
291
291
|
}
|
|
@@ -390,7 +390,7 @@ function isRetryableApiError(error) {
|
|
|
390
390
|
return true;
|
|
391
391
|
}
|
|
392
392
|
// HTTP status code based detection (retryable server/gateway errors + rate limits)
|
|
393
|
-
const status = maybeError.status ?? maybeError.statusCode ?? maybeError.data?.status;
|
|
393
|
+
const status = maybeError.status ?? maybeError.statusCode ?? maybeError.data?.status ?? maybeError.error?.status;
|
|
394
394
|
if (status && (status === 404 || // transient endpoint not found (API gateway issues)
|
|
395
395
|
status === 429 || // rate limit
|
|
396
396
|
status === 500 || // internal server error
|
|
@@ -402,8 +402,18 @@ function isRetryableApiError(error) {
|
|
|
402
402
|
}
|
|
403
403
|
// Text-based detection for model access errors (not marked as retryable
|
|
404
404
|
// by the API but retryable with a fallback model)
|
|
405
|
-
const message =
|
|
406
|
-
|
|
405
|
+
const message = [
|
|
406
|
+
maybeError.message,
|
|
407
|
+
maybeError.code,
|
|
408
|
+
maybeError.data?.message,
|
|
409
|
+
maybeError.data?.type,
|
|
410
|
+
maybeError.data?.code,
|
|
411
|
+
maybeError.error?.message,
|
|
412
|
+
maybeError.error?.type,
|
|
413
|
+
maybeError.error?.code,
|
|
414
|
+
].filter((value) => value !== undefined && value !== null).join(" ").toLowerCase();
|
|
415
|
+
if (message.includes("api_error") ||
|
|
416
|
+
message.includes("не разрешен") ||
|
|
407
417
|
message.includes("not allowed") ||
|
|
408
418
|
message.includes("internal error") ||
|
|
409
419
|
message.includes("internal server error") ||
|
|
@@ -439,6 +449,7 @@ function isRetryableApiError(error) {
|
|
|
439
449
|
// Certificate/TLS provider failures must pass this primary retry gate
|
|
440
450
|
// before the managed-session fallback model branch can run.
|
|
441
451
|
message.includes("unknown certificate verification error") ||
|
|
452
|
+
message.includes("unknown_certificate_verification_error") ||
|
|
442
453
|
message.includes("certificate has expired") ||
|
|
443
454
|
message.includes("certificate verification") ||
|
|
444
455
|
message.includes("tls") ||
|
|
@@ -468,23 +479,25 @@ function isRateLimitApiError(error) {
|
|
|
468
479
|
if (!error || typeof error !== "object")
|
|
469
480
|
return false;
|
|
470
481
|
const maybeError = error;
|
|
471
|
-
const message = `${maybeError.message ?? ""} ${maybeError.data?.message ?? ""}`.toLowerCase();
|
|
472
|
-
const type = `${maybeError.data?.type ?? ""}`.toLowerCase();
|
|
482
|
+
const message = `${maybeError.message ?? ""} ${maybeError.data?.message ?? ""} ${maybeError.error?.message ?? ""}`.toLowerCase();
|
|
483
|
+
const type = `${maybeError.data?.type ?? ""} ${maybeError.error?.type ?? ""}`.toLowerCase();
|
|
473
484
|
return ((type.includes("rate_limit") || message.includes("too many requests") || message.includes("rate limit")));
|
|
474
485
|
}
|
|
475
486
|
function isCertificateApiError(error) {
|
|
476
487
|
if (!error || typeof error !== "object")
|
|
477
488
|
return false;
|
|
478
489
|
const maybeError = error;
|
|
479
|
-
const message = `${maybeError.message ?? ""} ${maybeError.data?.message ?? ""}`.toLowerCase();
|
|
480
|
-
const code = `${maybeError.code ?? ""} ${maybeError.data?.code ?? ""}`.toLowerCase();
|
|
481
|
-
const type = `${maybeError.data?.type ?? ""}`.toLowerCase();
|
|
490
|
+
const message = `${maybeError.message ?? ""} ${maybeError.data?.message ?? ""} ${maybeError.error?.message ?? ""}`.toLowerCase();
|
|
491
|
+
const code = `${maybeError.code ?? ""} ${maybeError.data?.code ?? ""} ${maybeError.error?.code ?? ""}`.toLowerCase();
|
|
492
|
+
const type = `${maybeError.data?.type ?? ""} ${maybeError.error?.type ?? ""}`.toLowerCase();
|
|
482
493
|
return (message.includes("unknown certificate verification error") ||
|
|
494
|
+
message.includes("unknown_certificate_verification_error") ||
|
|
483
495
|
message.includes("certificate has expired") ||
|
|
484
496
|
message.includes("certificate verification") ||
|
|
485
497
|
message.includes("tls") ||
|
|
486
498
|
message.includes("ssl") ||
|
|
487
499
|
code.includes("cert_has_expired") ||
|
|
500
|
+
code.includes("unknown_certificate_verification_error") ||
|
|
488
501
|
code.includes("unable_to_verify_leaf_signature") ||
|
|
489
502
|
code.includes("self_signed_cert") ||
|
|
490
503
|
code.includes("tls") ||
|
|
@@ -560,6 +573,28 @@ async function setSessionFallbackModel(state, sessionID, model) {
|
|
|
560
573
|
fallbackModel: model,
|
|
561
574
|
});
|
|
562
575
|
}
|
|
576
|
+
async function recoverUntrackedRootSessionForRetry(state, sessionID, error) {
|
|
577
|
+
if (!isRetryableApiError(error))
|
|
578
|
+
return undefined;
|
|
579
|
+
const markerActive = await isUltraworkMarkerActive(state);
|
|
580
|
+
if (!markerActive)
|
|
581
|
+
return undefined;
|
|
582
|
+
const recovery = await parseTasksFile(state.input.directory);
|
|
583
|
+
if (!recovery || recovery.phase === "ARCHIVE: DONE")
|
|
584
|
+
return undefined;
|
|
585
|
+
state.recoveryContext = recovery;
|
|
586
|
+
await addManagedUltraworkSession(state, sessionID);
|
|
587
|
+
const recovered = getManagedSession(state, sessionID);
|
|
588
|
+
await writeDiagnosticLog(state, "session-retry:recovered-untracked-root", {
|
|
589
|
+
sessionID,
|
|
590
|
+
task: recovery.task,
|
|
591
|
+
level: recovery.level,
|
|
592
|
+
phase: recovery.phase,
|
|
593
|
+
errorType: getRetryableErrorType(error),
|
|
594
|
+
});
|
|
595
|
+
writePluginLog(state, "warn", `[opencode-immune] Recovered untracked root session ${sessionID} for retry after plugin restart or state loss.`);
|
|
596
|
+
return recovered;
|
|
597
|
+
}
|
|
563
598
|
function getManagedSessionRetryContext(state, sessionID) {
|
|
564
599
|
const managedSession = state.managedUltraworkSessions.get(sessionID);
|
|
565
600
|
if (!managedSession)
|
|
@@ -707,8 +742,8 @@ function compositeChatMessage(handlers) {
|
|
|
707
742
|
*/
|
|
708
743
|
async function parseTasksFile(directory) {
|
|
709
744
|
try {
|
|
710
|
-
const tasksPath =
|
|
711
|
-
const content = await
|
|
745
|
+
const tasksPath = join(directory, "memory-bank", "tasks.md");
|
|
746
|
+
const content = await readFile(tasksPath, "utf-8");
|
|
712
747
|
// Check for active task
|
|
713
748
|
if (!content.includes("## Active Task") ||
|
|
714
749
|
content.includes("No active tasks")) {
|
|
@@ -780,7 +815,7 @@ const DEFAULT_HARNESS_REPO = "gendoor/opencode-immune-harness";
|
|
|
780
815
|
async function parseDotEnv(filePath) {
|
|
781
816
|
const result = {};
|
|
782
817
|
try {
|
|
783
|
-
const content = await
|
|
818
|
+
const content = await readFile(filePath, "utf-8");
|
|
784
819
|
for (const line of content.split("\n")) {
|
|
785
820
|
const trimmed = line.trim();
|
|
786
821
|
if (!trimmed || trimmed.startsWith("#"))
|
|
@@ -814,13 +849,13 @@ async function resolveEnvValue(directory, key) {
|
|
|
814
849
|
if (process.env[key])
|
|
815
850
|
return process.env[key];
|
|
816
851
|
// 2. Per-project .env
|
|
817
|
-
const projectEnv = await parseDotEnv(
|
|
852
|
+
const projectEnv = await parseDotEnv(join(directory, ".env"));
|
|
818
853
|
if (projectEnv[key])
|
|
819
854
|
return projectEnv[key];
|
|
820
855
|
// 3. Global config
|
|
821
856
|
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
822
857
|
if (home) {
|
|
823
|
-
const globalEnv = await parseDotEnv(
|
|
858
|
+
const globalEnv = await parseDotEnv(join(home, ".config", "opencode-immune", ".env"));
|
|
824
859
|
if (globalEnv[key])
|
|
825
860
|
return globalEnv[key];
|
|
826
861
|
}
|
|
@@ -878,8 +913,8 @@ async function fetchLatestHarnessRelease(directory, repo, token) {
|
|
|
878
913
|
*/
|
|
879
914
|
async function readLocalHarnessVersion(directory) {
|
|
880
915
|
try {
|
|
881
|
-
const versionPath =
|
|
882
|
-
const content = await
|
|
916
|
+
const versionPath = join(directory, ".opencode", HARNESS_VERSION_FILE);
|
|
917
|
+
const content = await readFile(versionPath, "utf-8");
|
|
883
918
|
return content.trim() || null;
|
|
884
919
|
}
|
|
885
920
|
catch {
|
|
@@ -903,8 +938,8 @@ async function downloadHarnessAsset(assetUrl, token) {
|
|
|
903
938
|
throw new Error(`Download failed: ${resp.status} ${resp.statusText}`);
|
|
904
939
|
}
|
|
905
940
|
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
906
|
-
const tempPath =
|
|
907
|
-
await
|
|
941
|
+
const tempPath = join(tmpdir(), `harness-${Date.now()}.tar.gz`);
|
|
942
|
+
await writeFile(tempPath, buffer);
|
|
908
943
|
return tempPath;
|
|
909
944
|
}
|
|
910
945
|
/**
|
|
@@ -913,7 +948,7 @@ async function downloadHarnessAsset(assetUrl, token) {
|
|
|
913
948
|
*/
|
|
914
949
|
function extractTarGz(archivePath, destDir) {
|
|
915
950
|
return new Promise((resolve, reject) => {
|
|
916
|
-
|
|
951
|
+
execFile("tar", ["xzf", archivePath, "-C", destDir], (err) => {
|
|
917
952
|
if (err)
|
|
918
953
|
reject(err);
|
|
919
954
|
else
|
|
@@ -928,22 +963,22 @@ function extractTarGz(archivePath, destDir) {
|
|
|
928
963
|
*/
|
|
929
964
|
async function copyDirRecursive(src, dest, skipRootFiles, rootDest) {
|
|
930
965
|
const effectiveRoot = rootDest ?? dest;
|
|
931
|
-
const entries = await
|
|
932
|
-
await
|
|
966
|
+
const entries = await readdir(src, { withFileTypes: true });
|
|
967
|
+
await mkdir(dest, { recursive: true });
|
|
933
968
|
for (const entry of entries) {
|
|
934
969
|
// Skip files only at the root destination level
|
|
935
970
|
if (skipRootFiles && dest === effectiveRoot && entry.name === ".gitignore") {
|
|
936
971
|
pluginLog.info(`[opencode-immune] Harness sync: skipping root .gitignore`);
|
|
937
972
|
continue;
|
|
938
973
|
}
|
|
939
|
-
const srcPath =
|
|
940
|
-
const destPath =
|
|
974
|
+
const srcPath = join(src, entry.name);
|
|
975
|
+
const destPath = join(dest, entry.name);
|
|
941
976
|
if (entry.isDirectory()) {
|
|
942
977
|
await copyDirRecursive(srcPath, destPath, skipRootFiles, effectiveRoot);
|
|
943
978
|
}
|
|
944
979
|
else {
|
|
945
|
-
await
|
|
946
|
-
await
|
|
980
|
+
await mkdir(dirname(destPath), { recursive: true });
|
|
981
|
+
await copyFile(srcPath, destPath);
|
|
947
982
|
}
|
|
948
983
|
}
|
|
949
984
|
}
|
|
@@ -952,8 +987,8 @@ async function copyDirRecursive(src, dest, skipRootFiles, rootDest) {
|
|
|
952
987
|
*/
|
|
953
988
|
async function fileHash(filePath) {
|
|
954
989
|
try {
|
|
955
|
-
const content = await
|
|
956
|
-
return
|
|
990
|
+
const content = await readFile(filePath);
|
|
991
|
+
return createHash("sha256").update(content).digest("hex");
|
|
957
992
|
}
|
|
958
993
|
catch {
|
|
959
994
|
return "";
|
|
@@ -995,13 +1030,13 @@ async function syncHarness(state) {
|
|
|
995
1030
|
}
|
|
996
1031
|
pluginLog.info(`[opencode-immune] Harness sync: updating ${localVersion ?? "(none)"} → ${release.tagName}`);
|
|
997
1032
|
// 3. Hash opencode.json before update
|
|
998
|
-
const configPath =
|
|
1033
|
+
const configPath = join(state.input.directory, "opencode.json");
|
|
999
1034
|
const hashBefore = await fileHash(configPath);
|
|
1000
1035
|
// 4. Download asset
|
|
1001
1036
|
const archivePath = await downloadHarnessAsset(release.assetUrl, token);
|
|
1002
1037
|
// 5. Extract to temp dir
|
|
1003
|
-
const extractDir =
|
|
1004
|
-
await
|
|
1038
|
+
const extractDir = join(tmpdir(), `harness-extract-${Date.now()}`);
|
|
1039
|
+
await mkdir(extractDir, { recursive: true });
|
|
1005
1040
|
try {
|
|
1006
1041
|
await extractTarGz(archivePath, extractDir);
|
|
1007
1042
|
// 6. Copy extracted files to project root (skip .gitignore — project owns its own)
|
|
@@ -1009,9 +1044,9 @@ async function syncHarness(state) {
|
|
|
1009
1044
|
const SKIP_ROOT_FILES = new Set([".gitignore"]);
|
|
1010
1045
|
await copyDirRecursive(extractDir, state.input.directory, SKIP_ROOT_FILES);
|
|
1011
1046
|
// 7. Write version marker
|
|
1012
|
-
const versionDir =
|
|
1013
|
-
await
|
|
1014
|
-
await
|
|
1047
|
+
const versionDir = join(state.input.directory, ".opencode");
|
|
1048
|
+
await mkdir(versionDir, { recursive: true });
|
|
1049
|
+
await writeFile(join(versionDir, HARNESS_VERSION_FILE), release.tagName + "\n", "utf-8");
|
|
1015
1050
|
// 8. Check if opencode.json changed
|
|
1016
1051
|
const hashAfter = await fileHash(configPath);
|
|
1017
1052
|
if (hashBefore && hashAfter && hashBefore !== hashAfter) {
|
|
@@ -1028,11 +1063,11 @@ async function syncHarness(state) {
|
|
|
1028
1063
|
finally {
|
|
1029
1064
|
// 9. Cleanup temp files
|
|
1030
1065
|
try {
|
|
1031
|
-
await
|
|
1066
|
+
await unlink(archivePath);
|
|
1032
1067
|
}
|
|
1033
1068
|
catch { /* ignore */ }
|
|
1034
1069
|
try {
|
|
1035
|
-
await
|
|
1070
|
+
await rm(extractDir, { recursive: true, force: true });
|
|
1036
1071
|
}
|
|
1037
1072
|
catch { /* ignore */ }
|
|
1038
1073
|
}
|
|
@@ -1459,10 +1494,18 @@ function createEventHandler(state) {
|
|
|
1459
1494
|
}
|
|
1460
1495
|
// ── Auto-retry on retryable API error for managed ultrawork sessions ──
|
|
1461
1496
|
if (eventType === "session.error" && sessionID) {
|
|
1462
|
-
const managedSession = getManagedSession(state, sessionID)
|
|
1497
|
+
const managedSession = getManagedSession(state, sessionID) ??
|
|
1498
|
+
await recoverUntrackedRootSessionForRetry(state, sessionID, error);
|
|
1463
1499
|
const isRoot = managedSession?.kind === "root";
|
|
1464
1500
|
const isChild = managedSession?.kind === "child";
|
|
1465
1501
|
if (!managedSession) {
|
|
1502
|
+
if (isRetryableApiError(error)) {
|
|
1503
|
+
await writeDiagnosticLog(state, "session-retry:unmanaged-retryable-error", {
|
|
1504
|
+
sessionID,
|
|
1505
|
+
eventType,
|
|
1506
|
+
errorType: getRetryableErrorType(error),
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1466
1509
|
return;
|
|
1467
1510
|
}
|
|
1468
1511
|
if (!isRetryableApiError(error)) {
|
|
@@ -1555,22 +1598,22 @@ const ALL_CYCLES_COMPLETE_MARKER = "0-ULTRAWORK: ALL_CYCLES_COMPLETE";
|
|
|
1555
1598
|
* Skips silently if progress.md doesn't exist or is trivially empty.
|
|
1556
1599
|
*/
|
|
1557
1600
|
async function archiveProgress(directory) {
|
|
1558
|
-
const progressPath =
|
|
1601
|
+
const progressPath = join(directory, "memory-bank", "progress.md");
|
|
1559
1602
|
try {
|
|
1560
|
-
const content = await
|
|
1603
|
+
const content = await readFile(progressPath, "utf-8");
|
|
1561
1604
|
// Skip if empty or trivially empty
|
|
1562
1605
|
if (!content.trim() || content.trim() === "# Progress") {
|
|
1563
1606
|
pluginLog.info("[opencode-immune] Archive progress: nothing to archive (empty).");
|
|
1564
1607
|
return;
|
|
1565
1608
|
}
|
|
1566
|
-
const archiveDir =
|
|
1567
|
-
await
|
|
1609
|
+
const archiveDir = join(directory, "memory-bank", "archive");
|
|
1610
|
+
await mkdir(archiveDir, { recursive: true });
|
|
1568
1611
|
const now = new Date();
|
|
1569
1612
|
const dateStr = now.toISOString().slice(0, 10); // YYYY-MM-DD
|
|
1570
1613
|
const ts = Math.floor(now.getTime() / 1000);
|
|
1571
1614
|
const archiveName = `progress-${dateStr}-${ts}.md`;
|
|
1572
|
-
const archivePath =
|
|
1573
|
-
await
|
|
1615
|
+
const archivePath = join(archiveDir, archiveName);
|
|
1616
|
+
await rename(progressPath, archivePath);
|
|
1574
1617
|
pluginLog.info(`[opencode-immune] Archive progress: moved to ${archiveName}`);
|
|
1575
1618
|
}
|
|
1576
1619
|
catch (err) {
|
|
@@ -1621,17 +1664,17 @@ async function buildCommitMessage(directory, diffStat) {
|
|
|
1621
1664
|
function runGitCommit(directory) {
|
|
1622
1665
|
return new Promise((resolve) => {
|
|
1623
1666
|
// Stage all changes
|
|
1624
|
-
|
|
1667
|
+
execFile("git", ["add", "-A"], { cwd: directory }, (addErr) => {
|
|
1625
1668
|
if (addErr) {
|
|
1626
1669
|
pluginLog.error("[opencode-immune] git add failed:", addErr.message);
|
|
1627
1670
|
resolve(false);
|
|
1628
1671
|
return;
|
|
1629
1672
|
}
|
|
1630
1673
|
// Get diff stat for commit body
|
|
1631
|
-
|
|
1674
|
+
execFile("git", ["diff", "--cached", "--stat"], { cwd: directory }, async (_diffErr, diffOut) => {
|
|
1632
1675
|
const stat = (diffOut ?? "").trim();
|
|
1633
1676
|
const message = await buildCommitMessage(directory, stat);
|
|
1634
|
-
|
|
1677
|
+
execFile("git", ["commit", "-m", message], { cwd: directory }, (commitErr, stdout, stderr) => {
|
|
1635
1678
|
if (commitErr) {
|
|
1636
1679
|
if (stderr?.includes("nothing to commit") || stdout?.includes("nothing to commit")) {
|
|
1637
1680
|
pluginLog.info("[opencode-immune] git commit: nothing to commit (clean tree).");
|
|
@@ -1854,8 +1897,8 @@ async function server(input) {
|
|
|
1854
1897
|
else {
|
|
1855
1898
|
// No active task — check if backlog has pending work to start a new cycle
|
|
1856
1899
|
try {
|
|
1857
|
-
const backlogPath =
|
|
1858
|
-
const backlogContent = await
|
|
1900
|
+
const backlogPath = join(state.input.directory, "memory-bank", "backlog.md");
|
|
1901
|
+
const backlogContent = await readFile(backlogPath, "utf-8");
|
|
1859
1902
|
const hasPendingTasks = /- \[ \]/.test(backlogContent);
|
|
1860
1903
|
if (hasPendingTasks) {
|
|
1861
1904
|
state.autoResumeAttempted = true;
|
|
@@ -1917,7 +1960,7 @@ async function server(input) {
|
|
|
1917
1960
|
"permission.ask": withErrorBoundary(state, "permission.ask", createPermissionAskHandler(state)),
|
|
1918
1961
|
};
|
|
1919
1962
|
}
|
|
1920
|
-
|
|
1963
|
+
export default {
|
|
1921
1964
|
id: "opencode-immune",
|
|
1922
1965
|
server,
|
|
1923
1966
|
};
|
package/package.json
CHANGED