opencode-immune 1.0.45 → 1.0.47
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 +243 -191
- package/package.json +3 -2
package/dist/plugin.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Hybrid single-file architecture with factory functions, explicit state, error boundaries
|
|
4
4
|
// See: memory-bank/creative/creative-plugin-architecture.md (Option C)
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const client_1 = require("@opencode-ai/sdk/v2/client");
|
|
6
7
|
const promises_1 = require("fs/promises");
|
|
7
8
|
const path_1 = require("path");
|
|
8
9
|
const crypto_1 = require("crypto");
|
|
@@ -59,10 +60,10 @@ async function checkPluginUpdate(state) {
|
|
|
59
60
|
state.pluginUpdateMessage =
|
|
60
61
|
`[PLUGIN UPDATE] opencode-immune ${currentVersion} → ${latest} is available. ` +
|
|
61
62
|
`Please inform the user: a plugin update is available. They should restart opencode to get the latest version.`;
|
|
62
|
-
|
|
63
|
+
writePluginLog(state, "warn", `[opencode-immune] Plugin update available: ${currentVersion} → ${latest}.`);
|
|
63
64
|
}
|
|
64
65
|
else if (latest) {
|
|
65
|
-
|
|
66
|
+
writePluginLog(state, "info", `[opencode-immune] Plugin version ${currentVersion} is up to date.`);
|
|
66
67
|
}
|
|
67
68
|
}
|
|
68
69
|
catch {
|
|
@@ -70,8 +71,14 @@ async function checkPluginUpdate(state) {
|
|
|
70
71
|
}
|
|
71
72
|
}
|
|
72
73
|
function createState(input) {
|
|
74
|
+
activeLogDirectory = input.directory;
|
|
75
|
+
const { client: _client, ...runtimeInput } = input;
|
|
73
76
|
return {
|
|
74
|
-
input,
|
|
77
|
+
input: runtimeInput,
|
|
78
|
+
client: (0, client_1.createOpencodeClient)({
|
|
79
|
+
baseUrl: input.serverUrl.toString(),
|
|
80
|
+
directory: input.directory,
|
|
81
|
+
}),
|
|
75
82
|
recoveryContext: null,
|
|
76
83
|
managedUltraworkSessions: new Map(),
|
|
77
84
|
sessionRetryTimers: new Map(),
|
|
@@ -92,6 +99,25 @@ function createState(input) {
|
|
|
92
99
|
};
|
|
93
100
|
}
|
|
94
101
|
const ULTRAWORK_AGENT = "0-ultrawork";
|
|
102
|
+
const ULTRAWORK_SESSION_PERMISSION = [
|
|
103
|
+
{ permission: "read", pattern: "*", action: "allow" },
|
|
104
|
+
{ permission: "edit", pattern: "*", action: "allow" },
|
|
105
|
+
{ permission: "glob", pattern: "*", action: "allow" },
|
|
106
|
+
{ permission: "grep", pattern: "*", action: "allow" },
|
|
107
|
+
{ permission: "list", pattern: "*", action: "allow" },
|
|
108
|
+
{ permission: "bash", pattern: "*", action: "allow" },
|
|
109
|
+
{ permission: "task", pattern: "*", action: "allow" },
|
|
110
|
+
{ permission: "external_directory", pattern: "*", action: "allow" },
|
|
111
|
+
{ permission: "todowrite", pattern: "*", action: "allow" },
|
|
112
|
+
{ permission: "question", pattern: "*", action: "allow" },
|
|
113
|
+
{ permission: "webfetch", pattern: "*", action: "allow" },
|
|
114
|
+
{ permission: "websearch", pattern: "*", action: "allow" },
|
|
115
|
+
{ permission: "codesearch", pattern: "*", action: "allow" },
|
|
116
|
+
{ permission: "lsp", pattern: "*", action: "allow" },
|
|
117
|
+
{ permission: "skill", pattern: "*", action: "allow" },
|
|
118
|
+
];
|
|
119
|
+
const DIAGNOSTIC_LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
120
|
+
let activeLogDirectory = null;
|
|
95
121
|
const MANAGED_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
96
122
|
const PROVIDER_RETRY_WATCHDOG_MS = 30_000;
|
|
97
123
|
const CHILD_FALLBACK_REQUEST_TTL_MS = 10 * 60 * 1000;
|
|
@@ -113,6 +139,38 @@ function isManagedRootUltraworkSession(state, sessionID) {
|
|
|
113
139
|
const record = getManagedSession(state, sessionID);
|
|
114
140
|
return !!record && record.kind === "root";
|
|
115
141
|
}
|
|
142
|
+
async function createManagedUltraworkSession(state, title) {
|
|
143
|
+
const result = await state.client.session.create({
|
|
144
|
+
directory: state.input.directory,
|
|
145
|
+
title,
|
|
146
|
+
permission: ULTRAWORK_SESSION_PERMISSION,
|
|
147
|
+
});
|
|
148
|
+
const sessionID = result?.data?.id;
|
|
149
|
+
if (!sessionID)
|
|
150
|
+
return null;
|
|
151
|
+
await addManagedUltraworkSession(state, sessionID);
|
|
152
|
+
return sessionID;
|
|
153
|
+
}
|
|
154
|
+
async function promptManagedSession(state, sessionID, text, options = {}) {
|
|
155
|
+
await state.client.session.promptAsync({
|
|
156
|
+
directory: state.input.directory,
|
|
157
|
+
sessionID,
|
|
158
|
+
...(options.model ? { model: options.model } : {}),
|
|
159
|
+
agent: options.agent ?? ULTRAWORK_AGENT,
|
|
160
|
+
parts: [
|
|
161
|
+
{
|
|
162
|
+
type: "text",
|
|
163
|
+
text,
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
async function abortManagedSession(state, sessionID) {
|
|
169
|
+
await state.client.session.abort({
|
|
170
|
+
directory: state.input.directory,
|
|
171
|
+
sessionID,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
116
174
|
function pruneExpiredManagedSessions(state, now = Date.now()) {
|
|
117
175
|
let removed = 0;
|
|
118
176
|
for (const [sessionID, record] of state.managedUltraworkSessions.entries()) {
|
|
@@ -130,6 +188,7 @@ async function writeDiagnosticLog(state, event, data = {}) {
|
|
|
130
188
|
try {
|
|
131
189
|
const cacheDir = (0, path_1.join)(state.input.directory, ".opencode", "state");
|
|
132
190
|
await (0, promises_1.mkdir)(cacheDir, { recursive: true });
|
|
191
|
+
await rotateDiagnosticLogIfNeeded(state.diagnosticsLogPath);
|
|
133
192
|
const line = JSON.stringify({ ts: new Date().toISOString(), event, ...data });
|
|
134
193
|
await (0, promises_1.appendFile)(state.diagnosticsLogPath, `${line}\n`, "utf-8");
|
|
135
194
|
}
|
|
@@ -137,6 +196,68 @@ async function writeDiagnosticLog(state, event, data = {}) {
|
|
|
137
196
|
// diagnostics must never affect runtime behavior
|
|
138
197
|
}
|
|
139
198
|
}
|
|
199
|
+
async function rotateDiagnosticLogIfNeeded(logPath) {
|
|
200
|
+
try {
|
|
201
|
+
const current = await (0, promises_1.stat)(logPath);
|
|
202
|
+
if (current.size < DIAGNOSTIC_LOG_MAX_BYTES)
|
|
203
|
+
return;
|
|
204
|
+
const rotatedPath = `${logPath}.1`;
|
|
205
|
+
await (0, promises_1.rm)(rotatedPath, { force: true });
|
|
206
|
+
await (0, promises_1.rename)(logPath, rotatedPath);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// missing log or rotation failure must never affect runtime behavior
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function normalizeLogValue(value) {
|
|
213
|
+
if (value instanceof Error) {
|
|
214
|
+
return {
|
|
215
|
+
name: value.name,
|
|
216
|
+
message: value.message,
|
|
217
|
+
stack: value.stack,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
return value;
|
|
221
|
+
}
|
|
222
|
+
function writePluginLog(state, level, message, extra = {}) {
|
|
223
|
+
void writeDiagnosticLog(state, `log:${level}`, {
|
|
224
|
+
message,
|
|
225
|
+
...extra,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
function writePluginLogForDirectory(directory, level, message, extra = {}) {
|
|
229
|
+
const diagnosticsLogPath = (0, path_1.join)(directory, ".opencode", "state", "opencode-immune-debug.log");
|
|
230
|
+
void (async () => {
|
|
231
|
+
try {
|
|
232
|
+
await (0, promises_1.mkdir)((0, path_1.dirname)(diagnosticsLogPath), { recursive: true });
|
|
233
|
+
await rotateDiagnosticLogIfNeeded(diagnosticsLogPath);
|
|
234
|
+
const line = JSON.stringify({
|
|
235
|
+
ts: new Date().toISOString(),
|
|
236
|
+
event: `log:${level}`,
|
|
237
|
+
message,
|
|
238
|
+
...extra,
|
|
239
|
+
});
|
|
240
|
+
await (0, promises_1.appendFile)(diagnosticsLogPath, `${line}\n`, "utf-8");
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// file logging must never affect runtime behavior
|
|
244
|
+
}
|
|
245
|
+
})();
|
|
246
|
+
}
|
|
247
|
+
function writePluginLogFromArgs(level, values) {
|
|
248
|
+
if (!activeLogDirectory)
|
|
249
|
+
return;
|
|
250
|
+
const [first, ...rest] = values;
|
|
251
|
+
const message = typeof first === "string" ? first : JSON.stringify(normalizeLogValue(first));
|
|
252
|
+
writePluginLogForDirectory(activeLogDirectory, level, message, {
|
|
253
|
+
args: rest.map(normalizeLogValue),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
const pluginLog = {
|
|
257
|
+
info: (...values) => writePluginLogFromArgs("info", values),
|
|
258
|
+
warn: (...values) => writePluginLogFromArgs("warn", values),
|
|
259
|
+
error: (...values) => writePluginLogFromArgs("error", values),
|
|
260
|
+
};
|
|
140
261
|
// ── Ultrawork Marker File ──
|
|
141
262
|
async function writeUltraworkMarker(state) {
|
|
142
263
|
try {
|
|
@@ -214,7 +335,7 @@ function cancelPendingSessionRetry(state, sessionID, reason) {
|
|
|
214
335
|
return;
|
|
215
336
|
clearTimeout(timer);
|
|
216
337
|
state.sessionRetryTimers.delete(sessionID);
|
|
217
|
-
|
|
338
|
+
writePluginLog(state, "info", `[opencode-immune] Cancelled pending retry for session ${sessionID}: ${reason}`);
|
|
218
339
|
}
|
|
219
340
|
function cancelProviderRetryWatchdog(state, sessionID, reason) {
|
|
220
341
|
const timer = state.providerRetryWatchdogs.get(sessionID);
|
|
@@ -222,7 +343,7 @@ function cancelProviderRetryWatchdog(state, sessionID, reason) {
|
|
|
222
343
|
return;
|
|
223
344
|
clearTimeout(timer);
|
|
224
345
|
state.providerRetryWatchdogs.delete(sessionID);
|
|
225
|
-
|
|
346
|
+
writePluginLog(state, "info", `[opencode-immune] Cancelled provider retry watchdog for session ${sessionID}: ${reason}`);
|
|
226
347
|
}
|
|
227
348
|
async function removeManagedUltraworkSession(state, sessionID, reason) {
|
|
228
349
|
cancelPendingSessionRetry(state, sessionID, reason);
|
|
@@ -231,7 +352,7 @@ async function removeManagedUltraworkSession(state, sessionID, reason) {
|
|
|
231
352
|
const existed = state.managedUltraworkSessions.delete(sessionID);
|
|
232
353
|
if (!existed)
|
|
233
354
|
return;
|
|
234
|
-
|
|
355
|
+
writePluginLog(state, "info", `[opencode-immune] Removed managed ultrawork session ${sessionID}: ${reason}`);
|
|
235
356
|
}
|
|
236
357
|
async function updateManagedSessionAgent(state, sessionID, agent) {
|
|
237
358
|
const existing = state.managedUltraworkSessions.get(sessionID);
|
|
@@ -468,9 +589,7 @@ async function sendManagedSessionRetryPrompt(state, sessionID, reason, options =
|
|
|
468
589
|
});
|
|
469
590
|
if (options.abortBeforePrompt) {
|
|
470
591
|
try {
|
|
471
|
-
await state
|
|
472
|
-
path: { id: sessionID },
|
|
473
|
-
});
|
|
592
|
+
await abortManagedSession(state, sessionID);
|
|
474
593
|
await writeDiagnosticLog(state, "session-retry:abort-success", { sessionID });
|
|
475
594
|
}
|
|
476
595
|
catch (err) {
|
|
@@ -480,20 +599,11 @@ async function sendManagedSessionRetryPrompt(state, sessionID, reason, options =
|
|
|
480
599
|
});
|
|
481
600
|
}
|
|
482
601
|
}
|
|
483
|
-
await state
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
agent: retryAgent,
|
|
487
|
-
parts: [
|
|
488
|
-
{
|
|
489
|
-
type: "text",
|
|
490
|
-
text: retryText,
|
|
491
|
-
},
|
|
492
|
-
],
|
|
493
|
-
},
|
|
494
|
-
path: { id: sessionID },
|
|
602
|
+
await promptManagedSession(state, sessionID, retryText, {
|
|
603
|
+
agent: retryAgent,
|
|
604
|
+
model: fallbackModel,
|
|
495
605
|
});
|
|
496
|
-
|
|
606
|
+
writePluginLog(state, "info", `[opencode-immune] Retry prompt sent to session ${sessionID} (${reason})` +
|
|
497
607
|
(fallbackModel
|
|
498
608
|
? ` using fallback model ${fallbackModel.providerID}/${fallbackModel.modelID}`
|
|
499
609
|
: ""));
|
|
@@ -510,11 +620,11 @@ function scheduleManagedSessionRetry(state, sessionID, options) {
|
|
|
510
620
|
return false;
|
|
511
621
|
}
|
|
512
622
|
if (state.sessionRetryTimers.has(sessionID)) {
|
|
513
|
-
|
|
623
|
+
writePluginLog(state, "info", `[opencode-immune] Retry already pending for session ${sessionID}, skipping duplicate.`);
|
|
514
624
|
return false;
|
|
515
625
|
}
|
|
516
626
|
const attemptInfo = options.attemptLabel ? ` (${options.attemptLabel})` : "";
|
|
517
|
-
|
|
627
|
+
writePluginLog(state, "info", `[opencode-immune] Scheduling retry for session ${sessionID}${attemptInfo}. ` +
|
|
518
628
|
`Waiting ${options.delayMs / 1000}s before retry...`);
|
|
519
629
|
const timer = setTimeout(async () => {
|
|
520
630
|
state.sessionRetryTimers.delete(sessionID);
|
|
@@ -530,7 +640,7 @@ function scheduleManagedSessionRetry(state, sessionID, options) {
|
|
|
530
640
|
if (options.countAgainstBudget) {
|
|
531
641
|
state.sessionErrorRetryCount.set(sessionID, Math.max((state.sessionErrorRetryCount.get(sessionID) ?? 1) - 1, 0));
|
|
532
642
|
}
|
|
533
|
-
|
|
643
|
+
writePluginLog(state, "warn", `[opencode-immune] Retry prompt failed for session ${sessionID}. ` +
|
|
534
644
|
`Will wait for the next retry signal.`);
|
|
535
645
|
}
|
|
536
646
|
}, options.delayMs);
|
|
@@ -544,13 +654,17 @@ function scheduleManagedSessionRetry(state, sessionID, options) {
|
|
|
544
654
|
* Wraps a hook handler in a try/catch to prevent any single hook failure
|
|
545
655
|
* from crashing the entire agent session.
|
|
546
656
|
*/
|
|
547
|
-
function withErrorBoundary(hookName, handler) {
|
|
657
|
+
function withErrorBoundary(state, hookName, handler) {
|
|
548
658
|
return (async (...args) => {
|
|
549
659
|
try {
|
|
550
660
|
return await handler(...args);
|
|
551
661
|
}
|
|
552
662
|
catch (err) {
|
|
553
|
-
|
|
663
|
+
const hookInput = args.find((arg) => !!arg && typeof arg === "object" && "sessionID" in arg);
|
|
664
|
+
writePluginLog(state, "error", `[opencode-immune] Hook "${hookName}" error.`, {
|
|
665
|
+
error: normalizeLogValue(err),
|
|
666
|
+
sessionID: hookInput?.sessionID,
|
|
667
|
+
});
|
|
554
668
|
// Error is swallowed — hook failure must not crash agent session
|
|
555
669
|
}
|
|
556
670
|
});
|
|
@@ -714,7 +828,7 @@ async function resolveEnvValue(directory, key) {
|
|
|
714
828
|
* Fetch latest release info from the harness GitHub repo.
|
|
715
829
|
* Returns null if token is missing, network fails, or no release found.
|
|
716
830
|
*/
|
|
717
|
-
async function fetchLatestHarnessRelease(repo, token) {
|
|
831
|
+
async function fetchLatestHarnessRelease(directory, repo, token) {
|
|
718
832
|
try {
|
|
719
833
|
const url = `https://api.github.com/repos/${repo}/releases/latest`;
|
|
720
834
|
const resp = await fetch(url, {
|
|
@@ -726,10 +840,10 @@ async function fetchLatestHarnessRelease(repo, token) {
|
|
|
726
840
|
});
|
|
727
841
|
if (!resp.ok) {
|
|
728
842
|
if (resp.status === 404) {
|
|
729
|
-
|
|
843
|
+
writePluginLogForDirectory(directory, "info", `[opencode-immune] Harness sync: no releases found in ${repo}`);
|
|
730
844
|
}
|
|
731
845
|
else {
|
|
732
|
-
|
|
846
|
+
writePluginLogForDirectory(directory, "warn", `[opencode-immune] Harness sync: GitHub API returned ${resp.status} ${resp.statusText}`);
|
|
733
847
|
}
|
|
734
848
|
return null;
|
|
735
849
|
}
|
|
@@ -742,7 +856,7 @@ async function fetchLatestHarnessRelease(repo, token) {
|
|
|
742
856
|
const assetName = isWindows ? "harness-windows.tar.gz" : "harness.tar.gz";
|
|
743
857
|
const asset = data.assets?.find((a) => a.name === assetName);
|
|
744
858
|
if (!asset) {
|
|
745
|
-
|
|
859
|
+
writePluginLogForDirectory(directory, "warn", `[opencode-immune] Harness sync: release ${tagName} has no ${assetName} asset`);
|
|
746
860
|
return null;
|
|
747
861
|
}
|
|
748
862
|
return {
|
|
@@ -752,7 +866,7 @@ async function fetchLatestHarnessRelease(repo, token) {
|
|
|
752
866
|
};
|
|
753
867
|
}
|
|
754
868
|
catch (err) {
|
|
755
|
-
|
|
869
|
+
writePluginLogForDirectory(directory, "warn", `[opencode-immune] Harness sync: failed to fetch release info.`, { error: normalizeLogValue(err) });
|
|
756
870
|
return null;
|
|
757
871
|
}
|
|
758
872
|
}
|
|
@@ -817,7 +931,7 @@ async function copyDirRecursive(src, dest, skipRootFiles, rootDest) {
|
|
|
817
931
|
for (const entry of entries) {
|
|
818
932
|
// Skip files only at the root destination level
|
|
819
933
|
if (skipRootFiles && dest === effectiveRoot && entry.name === ".gitignore") {
|
|
820
|
-
|
|
934
|
+
pluginLog.info(`[opencode-immune] Harness sync: skipping root .gitignore`);
|
|
821
935
|
continue;
|
|
822
936
|
}
|
|
823
937
|
const srcPath = (0, path_1.join)(src, entry.name);
|
|
@@ -868,16 +982,16 @@ async function syncHarness(state) {
|
|
|
868
982
|
|| DEFAULT_HARNESS_REPO;
|
|
869
983
|
try {
|
|
870
984
|
// 1. Fetch latest release
|
|
871
|
-
const release = await fetchLatestHarnessRelease(repo, token);
|
|
985
|
+
const release = await fetchLatestHarnessRelease(state.input.directory, repo, token);
|
|
872
986
|
if (!release)
|
|
873
987
|
return;
|
|
874
988
|
// 2. Compare versions
|
|
875
989
|
const localVersion = await readLocalHarnessVersion(state.input.directory);
|
|
876
990
|
if (localVersion === release.tagName) {
|
|
877
|
-
|
|
991
|
+
pluginLog.info(`[opencode-immune] Harness sync: already up to date (${release.tagName})`);
|
|
878
992
|
return;
|
|
879
993
|
}
|
|
880
|
-
|
|
994
|
+
pluginLog.info(`[opencode-immune] Harness sync: updating ${localVersion ?? "(none)"} → ${release.tagName}`);
|
|
881
995
|
// 3. Hash opencode.json before update
|
|
882
996
|
const configPath = (0, path_1.join)(state.input.directory, "opencode.json");
|
|
883
997
|
const hashBefore = await fileHash(configPath);
|
|
@@ -899,10 +1013,10 @@ async function syncHarness(state) {
|
|
|
899
1013
|
// 8. Check if opencode.json changed
|
|
900
1014
|
const hashAfter = await fileHash(configPath);
|
|
901
1015
|
if (hashBefore && hashAfter && hashBefore !== hashAfter) {
|
|
902
|
-
|
|
1016
|
+
pluginLog.warn(`[opencode-immune] ⚠ Harness sync: opencode.json was updated. ` +
|
|
903
1017
|
`Please restart opencode for the new agent configuration to take effect.`);
|
|
904
1018
|
}
|
|
905
|
-
|
|
1019
|
+
pluginLog.info(`[opencode-immune] Harness sync: successfully updated to ${release.tagName}`);
|
|
906
1020
|
await writeDiagnosticLog(state, "harness-sync:success", {
|
|
907
1021
|
from: localVersion,
|
|
908
1022
|
to: release.tagName,
|
|
@@ -923,7 +1037,7 @@ async function syncHarness(state) {
|
|
|
923
1037
|
}
|
|
924
1038
|
catch (err) {
|
|
925
1039
|
// Sync failure must never prevent plugin from working
|
|
926
|
-
|
|
1040
|
+
pluginLog.warn(`[opencode-immune] Harness sync failed:`, err instanceof Error ? err.message : String(err));
|
|
927
1041
|
await writeDiagnosticLog(state, "harness-sync:error", {
|
|
928
1042
|
error: err instanceof Error ? err.message : String(err),
|
|
929
1043
|
});
|
|
@@ -966,7 +1080,7 @@ function createTodoEnforcerChatMessage(state) {
|
|
|
966
1080
|
// On user message, check previous assistant turn's counters
|
|
967
1081
|
// then reset for next turn
|
|
968
1082
|
if (state.toolCallCount > 3 && !state.todoWriteUsed) {
|
|
969
|
-
|
|
1083
|
+
pluginLog.warn(`[opencode-immune] Todo Enforcer: ${state.toolCallCount} tool calls without TodoWrite. ` +
|
|
970
1084
|
`Consider using todo list for multi-step tasks.`);
|
|
971
1085
|
}
|
|
972
1086
|
// Reset per-message counters for the next assistant turn
|
|
@@ -1008,47 +1122,36 @@ function createSessionRecoveryEvent(state) {
|
|
|
1008
1122
|
}
|
|
1009
1123
|
const markerActive = await isUltraworkMarkerActive(state);
|
|
1010
1124
|
if (!markerActive) {
|
|
1011
|
-
|
|
1125
|
+
pluginLog.info(`[opencode-immune] Root session created (${sessionID}), no ultrawork marker — skipping auto-resume.`);
|
|
1012
1126
|
return;
|
|
1013
1127
|
}
|
|
1014
|
-
|
|
1128
|
+
pluginLog.info(`[opencode-immune] Root session created (${sessionID}), ultrawork marker active — checking tasks.md...`);
|
|
1015
1129
|
const recovery = await parseTasksFile(state.input.directory);
|
|
1016
1130
|
if (recovery) {
|
|
1017
1131
|
state.recoveryContext = recovery;
|
|
1018
|
-
|
|
1132
|
+
pluginLog.info(`[opencode-immune] Active task found: "${recovery.task}" (Level ${recovery.level}, Phase: ${recovery.phase})`);
|
|
1019
1133
|
if (recovery.phase !== "ARCHIVE: DONE") {
|
|
1020
1134
|
// Register this root session as managed so retry/recovery works
|
|
1021
1135
|
await addManagedUltraworkSession(state, sessionID);
|
|
1022
1136
|
// Skip sending AUTO-RESUME if already sent from plugin init
|
|
1023
1137
|
if (state.autoResumeAttempted) {
|
|
1024
|
-
|
|
1138
|
+
pluginLog.info(`[opencode-immune] Auto-resume already sent from plugin init, skipping duplicate for session ${sessionID}.`);
|
|
1025
1139
|
return;
|
|
1026
1140
|
}
|
|
1027
1141
|
setTimeout(async () => {
|
|
1028
1142
|
try {
|
|
1029
|
-
await state.
|
|
1030
|
-
|
|
1031
|
-
agent: ULTRAWORK_AGENT,
|
|
1032
|
-
parts: [
|
|
1033
|
-
{
|
|
1034
|
-
type: "text",
|
|
1035
|
-
text: `[AUTO-RESUME] Previous session was interrupted. Read memory-bank/tasks.md, check the Phase Status block, and continue the pipeline. Use ONLY the Phase Status block to determine the next phase. Do NOT analyze or evaluate the content of tasks.md. Call the appropriate router with the exact neutral prompt from your Step 5 table.`,
|
|
1036
|
-
},
|
|
1037
|
-
],
|
|
1038
|
-
},
|
|
1039
|
-
path: { id: sessionID },
|
|
1040
|
-
});
|
|
1041
|
-
console.log(`[opencode-immune] Auto-resume prompt sent to managed ultrawork session ${sessionID}`);
|
|
1143
|
+
await promptManagedSession(state, sessionID, `[AUTO-RESUME] Previous session was interrupted. Read memory-bank/tasks.md, check the Phase Status block, and continue the pipeline. Use ONLY the Phase Status block to determine the next phase. Do NOT analyze or evaluate the content of tasks.md. Call the appropriate router with the exact neutral prompt from your Step 5 table.`);
|
|
1144
|
+
pluginLog.info(`[opencode-immune] Auto-resume prompt sent to managed ultrawork session ${sessionID}`);
|
|
1042
1145
|
}
|
|
1043
1146
|
catch (err) {
|
|
1044
|
-
|
|
1147
|
+
pluginLog.info(`[opencode-immune] Auto-resume failed (session may have been taken over):`, err);
|
|
1045
1148
|
}
|
|
1046
1149
|
}, 3_000);
|
|
1047
1150
|
}
|
|
1048
1151
|
}
|
|
1049
1152
|
else {
|
|
1050
1153
|
state.recoveryContext = null;
|
|
1051
|
-
|
|
1154
|
+
pluginLog.info("[opencode-immune] No active task found.");
|
|
1052
1155
|
}
|
|
1053
1156
|
}
|
|
1054
1157
|
};
|
|
@@ -1130,7 +1233,7 @@ function createRalphLoopToolAfter(state) {
|
|
|
1130
1233
|
newString: input.args?.newString ?? "",
|
|
1131
1234
|
timestamp: Date.now(),
|
|
1132
1235
|
};
|
|
1133
|
-
|
|
1236
|
+
pluginLog.warn(`[opencode-immune] Ralph Loop: Edit failed for "${state.lastEditAttempt.filePath}". ` +
|
|
1134
1237
|
`Recovery hint will be injected in next system transform.`);
|
|
1135
1238
|
}
|
|
1136
1239
|
else {
|
|
@@ -1157,7 +1260,7 @@ function createContextMonitorChatMessage(state) {
|
|
|
1157
1260
|
if (state.approximateTokens >
|
|
1158
1261
|
ESTIMATED_CONTEXT_LIMIT * CONTEXT_WARNING_THRESHOLD) {
|
|
1159
1262
|
const pct = Math.round((state.approximateTokens / ESTIMATED_CONTEXT_LIMIT) * 100);
|
|
1160
|
-
|
|
1263
|
+
pluginLog.warn(`[opencode-immune] Context Monitor: ~${state.approximateTokens} tokens estimated (${pct}% of ~${ESTIMATED_CONTEXT_LIMIT}). ` +
|
|
1161
1264
|
`Consider compacting the session.`);
|
|
1162
1265
|
}
|
|
1163
1266
|
}
|
|
@@ -1180,7 +1283,7 @@ function createCompactionHandler(state) {
|
|
|
1180
1283
|
"After compaction, the agent should still be able to resume the current phase without re-reading all Memory Bank files.");
|
|
1181
1284
|
// Reset token counter after compaction
|
|
1182
1285
|
state.approximateTokens = 0;
|
|
1183
|
-
|
|
1286
|
+
pluginLog.info("[opencode-immune] Context Monitor: Compaction triggered, token counter reset.");
|
|
1184
1287
|
};
|
|
1185
1288
|
}
|
|
1186
1289
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -1204,13 +1307,13 @@ function createCommentCheckerToolAfter(state) {
|
|
|
1204
1307
|
return;
|
|
1205
1308
|
// Check for emoji
|
|
1206
1309
|
if (EMOJI_PATTERN.test(content)) {
|
|
1207
|
-
|
|
1310
|
+
pluginLog.warn(`[opencode-immune] Comment Checker: Emoji detected in ${input.tool} operation. ` +
|
|
1208
1311
|
`Avoid emojis in code unless the user explicitly requested them.`);
|
|
1209
1312
|
}
|
|
1210
1313
|
// Check for TODO/FIXME/HACK
|
|
1211
1314
|
const todoMatch = content.match(TODO_PATTERN);
|
|
1212
1315
|
if (todoMatch) {
|
|
1213
|
-
|
|
1316
|
+
pluginLog.warn(`[opencode-immune] Comment Checker: "${todoMatch[0]}" comment found in ${input.tool} operation. ` +
|
|
1214
1317
|
`Consider resolving it or tracking it in the todo list.`);
|
|
1215
1318
|
}
|
|
1216
1319
|
};
|
|
@@ -1241,11 +1344,11 @@ function createKeywordDetectorChatMessage(_state) {
|
|
|
1241
1344
|
if (!messageContent)
|
|
1242
1345
|
return;
|
|
1243
1346
|
if (ERROR_KEYWORDS.test(messageContent)) {
|
|
1244
|
-
|
|
1347
|
+
pluginLog.info(`[opencode-immune] Keyword Detector: Error-related keywords found. ` +
|
|
1245
1348
|
`Consider using 1-van to analyze the issue systematically.`);
|
|
1246
1349
|
}
|
|
1247
1350
|
if (DEPLOY_KEYWORDS.test(messageContent)) {
|
|
1248
|
-
|
|
1351
|
+
pluginLog.info(`[opencode-immune] Keyword Detector: Deploy/release keywords found. ` +
|
|
1249
1352
|
`Consider running 5-reflect first to verify implementation quality.`);
|
|
1250
1353
|
}
|
|
1251
1354
|
};
|
|
@@ -1274,28 +1377,17 @@ function createFallbackModels(state) {
|
|
|
1274
1377
|
const recovery = await parseTasksFile(state.input.directory);
|
|
1275
1378
|
if (recovery && recovery.phase !== "ARCHIVE: DONE") {
|
|
1276
1379
|
state.recoveryContext = recovery;
|
|
1277
|
-
|
|
1380
|
+
pluginLog.info(`[opencode-immune] Auto-recovery on existing session: ` +
|
|
1278
1381
|
`task="${recovery.task}", level=${recovery.level}, phase=${recovery.phase}. ` +
|
|
1279
1382
|
`Sending AUTO-RESUME prompt in 3s...`);
|
|
1280
1383
|
const sid = input.sessionID;
|
|
1281
1384
|
setTimeout(async () => {
|
|
1282
1385
|
try {
|
|
1283
|
-
await state.
|
|
1284
|
-
|
|
1285
|
-
agent: ULTRAWORK_AGENT,
|
|
1286
|
-
parts: [
|
|
1287
|
-
{
|
|
1288
|
-
type: "text",
|
|
1289
|
-
text: `[AUTO-RESUME] Previous session was interrupted. Read memory-bank/tasks.md, check the Phase Status block, and continue the pipeline. Use ONLY the Phase Status block to determine the next phase. Do NOT analyze or evaluate the content of tasks.md. Call the appropriate router with the exact neutral prompt from your Step 5 table.`,
|
|
1290
|
-
},
|
|
1291
|
-
],
|
|
1292
|
-
},
|
|
1293
|
-
path: { id: sid },
|
|
1294
|
-
});
|
|
1295
|
-
console.log(`[opencode-immune] Auto-resume prompt sent to session ${sid}`);
|
|
1386
|
+
await promptManagedSession(state, sid, `[AUTO-RESUME] Previous session was interrupted. Read memory-bank/tasks.md, check the Phase Status block, and continue the pipeline. Use ONLY the Phase Status block to determine the next phase. Do NOT analyze or evaluate the content of tasks.md. Call the appropriate router with the exact neutral prompt from your Step 5 table.`);
|
|
1387
|
+
pluginLog.info(`[opencode-immune] Auto-resume prompt sent to session ${sid}`);
|
|
1296
1388
|
}
|
|
1297
1389
|
catch (err) {
|
|
1298
|
-
|
|
1390
|
+
pluginLog.info(`[opencode-immune] Auto-resume prompt failed:`, err);
|
|
1299
1391
|
}
|
|
1300
1392
|
}, 3_000);
|
|
1301
1393
|
}
|
|
@@ -1314,7 +1406,7 @@ function createFallbackModels(state) {
|
|
|
1314
1406
|
const providerId = input.provider?.info && "id" in input.provider.info
|
|
1315
1407
|
? input.provider.info.id
|
|
1316
1408
|
: "unknown";
|
|
1317
|
-
|
|
1409
|
+
pluginLog.info(`[opencode-immune] Model Observer: agent="${input.agent}", ` +
|
|
1318
1410
|
`model="${modelId}", provider="${providerId}"`);
|
|
1319
1411
|
};
|
|
1320
1412
|
}
|
|
@@ -1352,7 +1444,7 @@ function createEventHandler(state) {
|
|
|
1352
1444
|
if (count < MAX_RETRIES && !state.sessionRetryTimers.has(fallbackSessionID)) {
|
|
1353
1445
|
const delay = Math.min(BASE_DELAY_MS * Math.pow(2, count), MAX_DELAY_MS);
|
|
1354
1446
|
state.sessionErrorRetryCount.set(fallbackSessionID, count + 1);
|
|
1355
|
-
|
|
1447
|
+
pluginLog.warn(`[opencode-immune] session.error without sessionID matched retryable error. ` +
|
|
1356
1448
|
`Retrying sole managed root session ${fallbackSessionID}.`);
|
|
1357
1449
|
scheduleManagedSessionRetry(state, fallbackSessionID, {
|
|
1358
1450
|
delayMs: delay,
|
|
@@ -1377,7 +1469,7 @@ function createEventHandler(state) {
|
|
|
1377
1469
|
return;
|
|
1378
1470
|
}
|
|
1379
1471
|
if (state.sessionRetryTimers.has(sessionID)) {
|
|
1380
|
-
|
|
1472
|
+
pluginLog.info(`[opencode-immune] Retry already pending for ${isChild ? "child" : "root"} session ${sessionID}, skipping duplicate.`);
|
|
1381
1473
|
return;
|
|
1382
1474
|
}
|
|
1383
1475
|
const count = state.sessionErrorRetryCount.get(sessionID) ?? 0;
|
|
@@ -1388,7 +1480,7 @@ function createEventHandler(state) {
|
|
|
1388
1480
|
// child after the router advances can create two writers in one pipeline.
|
|
1389
1481
|
if (isChild) {
|
|
1390
1482
|
recordChildFallbackRequest(state, managedSession, sessionID, error);
|
|
1391
|
-
|
|
1483
|
+
pluginLog.info(`[opencode-immune] Child session ${sessionID}: retryable error detected. ` +
|
|
1392
1484
|
`Recorded router-owned fallback request and skipped plugin auto-retry.`);
|
|
1393
1485
|
state.sessionErrorRetryCount.set(sessionID, count);
|
|
1394
1486
|
return;
|
|
@@ -1396,7 +1488,7 @@ function createEventHandler(state) {
|
|
|
1396
1488
|
else if (isRoot && (isRateLimitApiError(error) || isCertificateApiError(error))) {
|
|
1397
1489
|
await setSessionFallbackModel(state, sessionID, RATE_LIMIT_FALLBACK_MODEL);
|
|
1398
1490
|
const errorType = isCertificateApiError(error) ? "certificate error" : "rate limit";
|
|
1399
|
-
|
|
1491
|
+
pluginLog.info(`[opencode-immune] ${errorType} detected for root session ${sessionID}. ` +
|
|
1400
1492
|
`Retry will use fallback model ${RATE_LIMIT_FALLBACK_MODEL.providerID}/${RATE_LIMIT_FALLBACK_MODEL.modelID}.`);
|
|
1401
1493
|
}
|
|
1402
1494
|
const scheduled = scheduleManagedSessionRetry(state, sessionID, {
|
|
@@ -1410,7 +1502,7 @@ function createEventHandler(state) {
|
|
|
1410
1502
|
}
|
|
1411
1503
|
}
|
|
1412
1504
|
else {
|
|
1413
|
-
|
|
1505
|
+
pluginLog.info(`[opencode-immune] Max retries (${MAX_RETRIES}) reached for ${isChild ? "child" : "root"} session ${sessionID}. Not retrying.`);
|
|
1414
1506
|
}
|
|
1415
1507
|
}
|
|
1416
1508
|
// Reset retry counter on successful activity
|
|
@@ -1440,7 +1532,7 @@ function createEventHandler(state) {
|
|
|
1440
1532
|
"file.edited",
|
|
1441
1533
|
];
|
|
1442
1534
|
if (significantEvents.includes(eventType)) {
|
|
1443
|
-
|
|
1535
|
+
pluginLog.info(`[opencode-immune] Event: ${eventType}`);
|
|
1444
1536
|
}
|
|
1445
1537
|
};
|
|
1446
1538
|
}
|
|
@@ -1466,7 +1558,7 @@ async function archiveProgress(directory) {
|
|
|
1466
1558
|
const content = await (0, promises_1.readFile)(progressPath, "utf-8");
|
|
1467
1559
|
// Skip if empty or trivially empty
|
|
1468
1560
|
if (!content.trim() || content.trim() === "# Progress") {
|
|
1469
|
-
|
|
1561
|
+
pluginLog.info("[opencode-immune] Archive progress: nothing to archive (empty).");
|
|
1470
1562
|
return;
|
|
1471
1563
|
}
|
|
1472
1564
|
const archiveDir = (0, path_1.join)(directory, "memory-bank", "archive");
|
|
@@ -1477,13 +1569,13 @@ async function archiveProgress(directory) {
|
|
|
1477
1569
|
const archiveName = `progress-${dateStr}-${ts}.md`;
|
|
1478
1570
|
const archivePath = (0, path_1.join)(archiveDir, archiveName);
|
|
1479
1571
|
await (0, promises_1.rename)(progressPath, archivePath);
|
|
1480
|
-
|
|
1572
|
+
pluginLog.info(`[opencode-immune] Archive progress: moved to ${archiveName}`);
|
|
1481
1573
|
}
|
|
1482
1574
|
catch (err) {
|
|
1483
1575
|
// File doesn't exist or move failed — not critical
|
|
1484
1576
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1485
1577
|
if (!msg.includes("ENOENT")) {
|
|
1486
|
-
|
|
1578
|
+
pluginLog.warn("[opencode-immune] Archive progress failed:", msg);
|
|
1487
1579
|
}
|
|
1488
1580
|
}
|
|
1489
1581
|
}
|
|
@@ -1529,7 +1621,7 @@ function runGitCommit(directory) {
|
|
|
1529
1621
|
// Stage all changes
|
|
1530
1622
|
(0, child_process_1.execFile)("git", ["add", "-A"], { cwd: directory }, (addErr) => {
|
|
1531
1623
|
if (addErr) {
|
|
1532
|
-
|
|
1624
|
+
pluginLog.error("[opencode-immune] git add failed:", addErr.message);
|
|
1533
1625
|
resolve(false);
|
|
1534
1626
|
return;
|
|
1535
1627
|
}
|
|
@@ -1540,15 +1632,15 @@ function runGitCommit(directory) {
|
|
|
1540
1632
|
(0, child_process_1.execFile)("git", ["commit", "-m", message], { cwd: directory }, (commitErr, stdout, stderr) => {
|
|
1541
1633
|
if (commitErr) {
|
|
1542
1634
|
if (stderr?.includes("nothing to commit") || stdout?.includes("nothing to commit")) {
|
|
1543
|
-
|
|
1635
|
+
pluginLog.info("[opencode-immune] git commit: nothing to commit (clean tree).");
|
|
1544
1636
|
resolve(true);
|
|
1545
1637
|
return;
|
|
1546
1638
|
}
|
|
1547
|
-
|
|
1639
|
+
pluginLog.error("[opencode-immune] git commit failed:", commitErr.message, stderr);
|
|
1548
1640
|
resolve(false);
|
|
1549
1641
|
return;
|
|
1550
1642
|
}
|
|
1551
|
-
|
|
1643
|
+
pluginLog.info("[opencode-immune] git commit succeeded:", stdout?.trim());
|
|
1552
1644
|
resolve(true);
|
|
1553
1645
|
});
|
|
1554
1646
|
});
|
|
@@ -1583,25 +1675,25 @@ function createTextCompleteHandler(state) {
|
|
|
1583
1675
|
countAgainstBudget: false,
|
|
1584
1676
|
abortBeforePrompt: true,
|
|
1585
1677
|
});
|
|
1586
|
-
|
|
1678
|
+
pluginLog.info(`[opencode-immune] Provider retry banner detected for session ${sessionID}. ` +
|
|
1587
1679
|
`Fallback model pinned to ${fallbackModel.providerID}/${fallbackModel.modelID}.`);
|
|
1588
1680
|
}
|
|
1589
1681
|
// ── ALL_CYCLES_COMPLETE: clear ultrawork marker ──
|
|
1590
1682
|
if (text.includes(ALL_CYCLES_COMPLETE_MARKER)) {
|
|
1591
1683
|
await clearUltraworkMarker(state);
|
|
1592
|
-
|
|
1684
|
+
pluginLog.info("[opencode-immune] Multi-Cycle: ALL_CYCLES_COMPLETE detected, marker cleared.");
|
|
1593
1685
|
return;
|
|
1594
1686
|
}
|
|
1595
1687
|
// ── PRE_COMMIT only (without CYCLE_COMPLETE in same part): run commit ──
|
|
1596
1688
|
if (text.includes(PRE_COMMIT_MARKER) && !text.includes(CYCLE_COMPLETE_MARKER)) {
|
|
1597
1689
|
if (!state.commitPending) {
|
|
1598
1690
|
state.commitPending = true;
|
|
1599
|
-
|
|
1691
|
+
pluginLog.info("[opencode-immune] Multi-Cycle: PRE_COMMIT detected (standalone), running git commit...");
|
|
1600
1692
|
try {
|
|
1601
1693
|
await runGitCommit(state.input.directory);
|
|
1602
1694
|
}
|
|
1603
1695
|
catch (err) {
|
|
1604
|
-
|
|
1696
|
+
pluginLog.error("[opencode-immune] Multi-Cycle: git commit failed (standalone):", err);
|
|
1605
1697
|
}
|
|
1606
1698
|
finally {
|
|
1607
1699
|
state.commitPending = false;
|
|
@@ -1616,18 +1708,18 @@ function createTextCompleteHandler(state) {
|
|
|
1616
1708
|
await archiveProgress(state.input.directory);
|
|
1617
1709
|
}
|
|
1618
1710
|
catch (err) {
|
|
1619
|
-
|
|
1711
|
+
pluginLog.warn("[opencode-immune] Multi-Cycle: archive progress failed:", err);
|
|
1620
1712
|
}
|
|
1621
1713
|
// Step 1: Always commit first
|
|
1622
1714
|
if (!state.commitPending) {
|
|
1623
1715
|
state.commitPending = true;
|
|
1624
|
-
|
|
1716
|
+
pluginLog.info("[opencode-immune] Multi-Cycle: CYCLE_COMPLETE detected, running git commit first...");
|
|
1625
1717
|
try {
|
|
1626
1718
|
await runGitCommit(state.input.directory);
|
|
1627
|
-
|
|
1719
|
+
pluginLog.info("[opencode-immune] Multi-Cycle: git commit completed before new cycle.");
|
|
1628
1720
|
}
|
|
1629
1721
|
catch (err) {
|
|
1630
|
-
|
|
1722
|
+
pluginLog.error("[opencode-immune] Multi-Cycle: git commit failed (continuing anyway):", err);
|
|
1631
1723
|
}
|
|
1632
1724
|
finally {
|
|
1633
1725
|
state.commitPending = false;
|
|
@@ -1636,43 +1728,25 @@ function createTextCompleteHandler(state) {
|
|
|
1636
1728
|
// Step 2: Create new session
|
|
1637
1729
|
state.cycleCount++;
|
|
1638
1730
|
if (state.cycleCount >= MAX_CYCLES) {
|
|
1639
|
-
|
|
1731
|
+
pluginLog.info(`[opencode-immune] Multi-Cycle: MAX_CYCLES (${MAX_CYCLES}) reached. Not creating new session.`);
|
|
1640
1732
|
await clearUltraworkMarker(state);
|
|
1641
1733
|
return;
|
|
1642
1734
|
}
|
|
1643
1735
|
const taskMatch = text.match(NEXT_TASK_PATTERN);
|
|
1644
1736
|
const nextTask = taskMatch?.[1]?.trim() ?? "Continue processing task backlog";
|
|
1645
|
-
|
|
1737
|
+
pluginLog.info(`[opencode-immune] Multi-Cycle: Creating new session (cycle ${state.cycleCount}/${MAX_CYCLES}) for: "${nextTask}"`);
|
|
1646
1738
|
try {
|
|
1647
|
-
const
|
|
1648
|
-
body: {
|
|
1649
|
-
title: `Ultrawork Cycle ${state.cycleCount + 1}`,
|
|
1650
|
-
},
|
|
1651
|
-
});
|
|
1652
|
-
const newSessionData = createResult?.data;
|
|
1653
|
-
const newSessionID = newSessionData?.id;
|
|
1739
|
+
const newSessionID = await createManagedUltraworkSession(state, `Ultrawork Cycle ${state.cycleCount + 1}`);
|
|
1654
1740
|
if (!newSessionID) {
|
|
1655
|
-
|
|
1741
|
+
pluginLog.error("[opencode-immune] Multi-Cycle: Failed to create new session — no ID returned.");
|
|
1656
1742
|
return;
|
|
1657
1743
|
}
|
|
1658
|
-
|
|
1659
|
-
await
|
|
1660
|
-
|
|
1661
|
-
body: {
|
|
1662
|
-
agent: ULTRAWORK_AGENT,
|
|
1663
|
-
parts: [
|
|
1664
|
-
{
|
|
1665
|
-
type: "text",
|
|
1666
|
-
text: `[AUTO-CYCLE] Continue processing task backlog. Read memory-bank/tasks.md and memory-bank/backlog.md, pick the next pending task, and run the full pipeline.`,
|
|
1667
|
-
},
|
|
1668
|
-
],
|
|
1669
|
-
},
|
|
1670
|
-
path: { id: newSessionID },
|
|
1671
|
-
});
|
|
1672
|
-
console.log(`[opencode-immune] Multi-Cycle: Bootstrap prompt sent to ${newSessionID}`);
|
|
1744
|
+
pluginLog.info(`[opencode-immune] Multi-Cycle: New session created: ${newSessionID}`);
|
|
1745
|
+
await promptManagedSession(state, newSessionID, `[AUTO-CYCLE] Continue processing task backlog. Read memory-bank/tasks.md and memory-bank/backlog.md, pick the next pending task, and run the full pipeline.`);
|
|
1746
|
+
pluginLog.info(`[opencode-immune] Multi-Cycle: Bootstrap prompt sent to ${newSessionID}`);
|
|
1673
1747
|
}
|
|
1674
1748
|
catch (err) {
|
|
1675
|
-
|
|
1749
|
+
pluginLog.error("[opencode-immune] Multi-Cycle: Failed to create session or send prompt:", err);
|
|
1676
1750
|
}
|
|
1677
1751
|
}
|
|
1678
1752
|
};
|
|
@@ -1705,7 +1779,7 @@ function createMultiCycleHandler(state) {
|
|
|
1705
1779
|
RATE_LIMIT_MESSAGE_PATTERN.test(messageContent)) {
|
|
1706
1780
|
if (managedSession && !managedSession.fallbackModel) {
|
|
1707
1781
|
await setSessionFallbackModel(state, sessionID, RATE_LIMIT_FALLBACK_MODEL);
|
|
1708
|
-
|
|
1782
|
+
pluginLog.info(`[opencode-immune] Rate limit message detected in chat output for session ${sessionID}. ` +
|
|
1709
1783
|
`Fallback model pinned to ${RATE_LIMIT_FALLBACK_MODEL.providerID}/${RATE_LIMIT_FALLBACK_MODEL.modelID}.`);
|
|
1710
1784
|
}
|
|
1711
1785
|
if (managedSession) {
|
|
@@ -1719,6 +1793,19 @@ function createMultiCycleHandler(state) {
|
|
|
1719
1793
|
}
|
|
1720
1794
|
};
|
|
1721
1795
|
}
|
|
1796
|
+
function createPermissionAskHandler(state) {
|
|
1797
|
+
return async (input, output) => {
|
|
1798
|
+
const sessionID = input.sessionID;
|
|
1799
|
+
if (!isManagedUltraworkSession(state, sessionID))
|
|
1800
|
+
return;
|
|
1801
|
+
output.status = "allow";
|
|
1802
|
+
await writeDiagnosticLog(state, "permission:auto-allow", {
|
|
1803
|
+
sessionID,
|
|
1804
|
+
permission: input.permission,
|
|
1805
|
+
patterns: input.patterns,
|
|
1806
|
+
});
|
|
1807
|
+
};
|
|
1808
|
+
}
|
|
1722
1809
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1723
1810
|
// PLUGIN MODULE EXPORT
|
|
1724
1811
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -1730,7 +1817,7 @@ async function server(input) {
|
|
|
1730
1817
|
// Runs in background so it doesn't delay plugin initialization.
|
|
1731
1818
|
// If sync fails, plugin continues normally with existing config.
|
|
1732
1819
|
syncHarness(state).catch((err) => {
|
|
1733
|
-
|
|
1820
|
+
pluginLog.warn(`[opencode-immune] Harness sync background error:`, err instanceof Error ? err.message : String(err));
|
|
1734
1821
|
});
|
|
1735
1822
|
// Eagerly load recovery context at plugin init so it's available
|
|
1736
1823
|
// for the very first system.transform call (before chat.params fires).
|
|
@@ -1741,42 +1828,24 @@ async function server(input) {
|
|
|
1741
1828
|
// Active task exists with incomplete phases — resume it
|
|
1742
1829
|
state.recoveryContext = recovery;
|
|
1743
1830
|
state.autoResumeAttempted = true;
|
|
1744
|
-
|
|
1831
|
+
pluginLog.info(`[opencode-immune] Plugin init: ultrawork marker active, recovery context loaded: ` +
|
|
1745
1832
|
`task="${recovery.task}", level=${recovery.level}, phase=${recovery.phase}. ` +
|
|
1746
1833
|
`Will create new session and send AUTO-RESUME.`);
|
|
1747
1834
|
// Create a new session and send AUTO-RESUME prompt (same pattern as CYCLE_COMPLETE).
|
|
1748
1835
|
// Delay to let opencode fully initialize.
|
|
1749
1836
|
setTimeout(async () => {
|
|
1750
1837
|
try {
|
|
1751
|
-
const
|
|
1752
|
-
body: {
|
|
1753
|
-
title: `AUTO-RESUME: ${recovery.task}`,
|
|
1754
|
-
},
|
|
1755
|
-
});
|
|
1756
|
-
const newSessionData = createResult?.data;
|
|
1757
|
-
const newSessionID = newSessionData?.id;
|
|
1838
|
+
const newSessionID = await createManagedUltraworkSession(state, `AUTO-RESUME: ${recovery.task}`);
|
|
1758
1839
|
if (!newSessionID) {
|
|
1759
|
-
|
|
1840
|
+
pluginLog.error("[opencode-immune] Auto-resume: Failed to create session — no session ID returned.");
|
|
1760
1841
|
return;
|
|
1761
1842
|
}
|
|
1762
|
-
|
|
1763
|
-
await
|
|
1764
|
-
|
|
1765
|
-
body: {
|
|
1766
|
-
agent: ULTRAWORK_AGENT,
|
|
1767
|
-
parts: [
|
|
1768
|
-
{
|
|
1769
|
-
type: "text",
|
|
1770
|
-
text: `[AUTO-RESUME] Previous session was interrupted. Read memory-bank/tasks.md, check the Phase Status block, and continue the pipeline. Use ONLY the Phase Status block to determine the next phase. Do NOT analyze or evaluate the content of tasks.md. Call the appropriate router with the exact neutral prompt from your Step 5 table.`,
|
|
1771
|
-
},
|
|
1772
|
-
],
|
|
1773
|
-
},
|
|
1774
|
-
path: { id: newSessionID },
|
|
1775
|
-
});
|
|
1776
|
-
console.log(`[opencode-immune] Auto-resume prompt sent to new session ${newSessionID}`);
|
|
1843
|
+
pluginLog.info(`[opencode-immune] Auto-resume: New session created: ${newSessionID}`);
|
|
1844
|
+
await promptManagedSession(state, newSessionID, `[AUTO-RESUME] Previous session was interrupted. Read memory-bank/tasks.md, check the Phase Status block, and continue the pipeline. Use ONLY the Phase Status block to determine the next phase. Do NOT analyze or evaluate the content of tasks.md. Call the appropriate router with the exact neutral prompt from your Step 5 table.`);
|
|
1845
|
+
pluginLog.info(`[opencode-immune] Auto-resume prompt sent to new session ${newSessionID}`);
|
|
1777
1846
|
}
|
|
1778
1847
|
catch (err) {
|
|
1779
|
-
|
|
1848
|
+
pluginLog.error("[opencode-immune] Auto-resume: Failed to create session or send prompt:", err);
|
|
1780
1849
|
}
|
|
1781
1850
|
}, 5_000);
|
|
1782
1851
|
}
|
|
@@ -1788,56 +1857,38 @@ async function server(input) {
|
|
|
1788
1857
|
const hasPendingTasks = /- \[ \]/.test(backlogContent);
|
|
1789
1858
|
if (hasPendingTasks) {
|
|
1790
1859
|
state.autoResumeAttempted = true;
|
|
1791
|
-
|
|
1860
|
+
pluginLog.info(`[opencode-immune] Plugin init: no active task but backlog has pending items. ` +
|
|
1792
1861
|
`Will create new session to start next cycle.`);
|
|
1793
1862
|
setTimeout(async () => {
|
|
1794
1863
|
try {
|
|
1795
|
-
const
|
|
1796
|
-
body: {
|
|
1797
|
-
title: `AUTO-CYCLE: next backlog task`,
|
|
1798
|
-
},
|
|
1799
|
-
});
|
|
1800
|
-
const newSessionData = createResult?.data;
|
|
1801
|
-
const newSessionID = newSessionData?.id;
|
|
1864
|
+
const newSessionID = await createManagedUltraworkSession(state, `AUTO-CYCLE: next backlog task`);
|
|
1802
1865
|
if (!newSessionID) {
|
|
1803
|
-
|
|
1866
|
+
pluginLog.error("[opencode-immune] Auto-cycle: Failed to create session — no session ID returned.");
|
|
1804
1867
|
return;
|
|
1805
1868
|
}
|
|
1806
|
-
|
|
1807
|
-
await
|
|
1808
|
-
|
|
1809
|
-
body: {
|
|
1810
|
-
agent: ULTRAWORK_AGENT,
|
|
1811
|
-
parts: [
|
|
1812
|
-
{
|
|
1813
|
-
type: "text",
|
|
1814
|
-
text: `[AUTO-CYCLE] Continue processing task backlog. Read memory-bank/tasks.md and memory-bank/backlog.md, pick the next pending task, and run the full pipeline.`,
|
|
1815
|
-
},
|
|
1816
|
-
],
|
|
1817
|
-
},
|
|
1818
|
-
path: { id: newSessionID },
|
|
1819
|
-
});
|
|
1820
|
-
console.log(`[opencode-immune] Auto-cycle prompt sent to new session ${newSessionID}`);
|
|
1869
|
+
pluginLog.info(`[opencode-immune] Auto-cycle: New session created: ${newSessionID}`);
|
|
1870
|
+
await promptManagedSession(state, newSessionID, `[AUTO-CYCLE] Continue processing task backlog. Read memory-bank/tasks.md and memory-bank/backlog.md, pick the next pending task, and run the full pipeline.`);
|
|
1871
|
+
pluginLog.info(`[opencode-immune] Auto-cycle prompt sent to new session ${newSessionID}`);
|
|
1821
1872
|
}
|
|
1822
1873
|
catch (err) {
|
|
1823
|
-
|
|
1874
|
+
pluginLog.error("[opencode-immune] Auto-cycle: Failed to create session or send prompt:", err);
|
|
1824
1875
|
}
|
|
1825
1876
|
}, 5_000);
|
|
1826
1877
|
}
|
|
1827
1878
|
else {
|
|
1828
1879
|
// No active task and no pending backlog — clear marker
|
|
1829
1880
|
await clearUltraworkMarker(state);
|
|
1830
|
-
|
|
1881
|
+
pluginLog.info(`[opencode-immune] Plugin init: no active task and no pending backlog. Marker cleared.`);
|
|
1831
1882
|
}
|
|
1832
1883
|
}
|
|
1833
1884
|
catch {
|
|
1834
1885
|
// backlog.md doesn't exist or can't be read — clear marker
|
|
1835
1886
|
await clearUltraworkMarker(state);
|
|
1836
|
-
|
|
1887
|
+
pluginLog.info(`[opencode-immune] Plugin init: no active task, backlog unreadable. Marker cleared.`);
|
|
1837
1888
|
}
|
|
1838
1889
|
}
|
|
1839
1890
|
}
|
|
1840
|
-
|
|
1891
|
+
pluginLog.info(`[opencode-immune] Plugin initialized. Directory: ${input.directory}`);
|
|
1841
1892
|
// Compose tool.execute.after handlers:
|
|
1842
1893
|
// Todo Enforcer (counter) + Ralph Loop (edit error) + Comment Checker
|
|
1843
1894
|
const toolAfterHandlers = [
|
|
@@ -1854,13 +1905,14 @@ async function server(input) {
|
|
|
1854
1905
|
createMultiCycleHandler(state),
|
|
1855
1906
|
];
|
|
1856
1907
|
return {
|
|
1857
|
-
event: withErrorBoundary("event", createEventHandler(state)),
|
|
1858
|
-
"chat.message": withErrorBoundary("chat.message", compositeChatMessage(chatMessageHandlers)),
|
|
1859
|
-
"chat.params": withErrorBoundary("chat.params", createFallbackModels(state)),
|
|
1860
|
-
"tool.execute.after": withErrorBoundary("tool.execute.after", compositeToolAfter(toolAfterHandlers)),
|
|
1861
|
-
"experimental.chat.system.transform": withErrorBoundary("experimental.chat.system.transform", createSystemTransform(state)),
|
|
1862
|
-
"experimental.session.compacting": withErrorBoundary("experimental.session.compacting", createCompactionHandler(state)),
|
|
1863
|
-
"experimental.text.complete": withErrorBoundary("experimental.text.complete", createTextCompleteHandler(state)),
|
|
1908
|
+
event: withErrorBoundary(state, "event", createEventHandler(state)),
|
|
1909
|
+
"chat.message": withErrorBoundary(state, "chat.message", compositeChatMessage(chatMessageHandlers)),
|
|
1910
|
+
"chat.params": withErrorBoundary(state, "chat.params", createFallbackModels(state)),
|
|
1911
|
+
"tool.execute.after": withErrorBoundary(state, "tool.execute.after", compositeToolAfter(toolAfterHandlers)),
|
|
1912
|
+
"experimental.chat.system.transform": withErrorBoundary(state, "experimental.chat.system.transform", createSystemTransform(state)),
|
|
1913
|
+
"experimental.session.compacting": withErrorBoundary(state, "experimental.session.compacting", createCompactionHandler(state)),
|
|
1914
|
+
"experimental.text.complete": withErrorBoundary(state, "experimental.text.complete", createTextCompleteHandler(state)),
|
|
1915
|
+
"permission.ask": withErrorBoundary(state, "permission.ask", createPermissionAskHandler(state)),
|
|
1864
1916
|
};
|
|
1865
1917
|
}
|
|
1866
1918
|
exports.default = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-immune",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.47",
|
|
4
4
|
"description": "OpenCode plugin: session recovery, auto-retry, multi-cycle automation, context monitoring",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./server": "./dist/plugin.js"
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"prepublishOnly": "npm run build"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@opencode-ai/plugin": "1.
|
|
17
|
+
"@opencode-ai/plugin": "1.14.25",
|
|
18
|
+
"@opencode-ai/sdk": "1.14.25"
|
|
18
19
|
},
|
|
19
20
|
"devDependencies": {
|
|
20
21
|
"@types/node": "^25.5.2",
|