opencode-immune 1.0.21 → 1.0.23
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 +378 -65
- package/package.json +1 -1
package/dist/plugin.js
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
const promises_1 = require("fs/promises");
|
|
7
7
|
const path_1 = require("path");
|
|
8
|
+
const crypto_1 = require("crypto");
|
|
9
|
+
const os_1 = require("os");
|
|
10
|
+
const child_process_1 = require("child_process");
|
|
8
11
|
function createState(input) {
|
|
9
12
|
return {
|
|
10
13
|
input,
|
|
@@ -440,6 +443,275 @@ async function parseTasksFile(directory) {
|
|
|
440
443
|
}
|
|
441
444
|
}
|
|
442
445
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
446
|
+
// HARNESS SYNC — auto-update opencode.json / prompts / rules from GitHub Release
|
|
447
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
448
|
+
const HARNESS_VERSION_FILE = ".harness-version";
|
|
449
|
+
const HARNESS_TOKEN_ENV = "OPENCODE_IMMUNE_TOKEN";
|
|
450
|
+
// Default harness repo — override via OPENCODE_IMMUNE_HARNESS_REPO env or .env
|
|
451
|
+
const DEFAULT_HARNESS_REPO = "gendoor/opencode-immune-harness";
|
|
452
|
+
/**
|
|
453
|
+
* Parse a .env file and return key-value pairs.
|
|
454
|
+
* Supports KEY=VALUE, KEY="VALUE", KEY='VALUE', comments (#), empty lines.
|
|
455
|
+
*/
|
|
456
|
+
async function parseDotEnv(filePath) {
|
|
457
|
+
const result = {};
|
|
458
|
+
try {
|
|
459
|
+
const content = await (0, promises_1.readFile)(filePath, "utf-8");
|
|
460
|
+
for (const line of content.split("\n")) {
|
|
461
|
+
const trimmed = line.trim();
|
|
462
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
463
|
+
continue;
|
|
464
|
+
const eqIndex = trimmed.indexOf("=");
|
|
465
|
+
if (eqIndex === -1)
|
|
466
|
+
continue;
|
|
467
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
468
|
+
let value = trimmed.slice(eqIndex + 1).trim();
|
|
469
|
+
// Strip surrounding quotes
|
|
470
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
471
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
472
|
+
value = value.slice(1, -1);
|
|
473
|
+
}
|
|
474
|
+
result[key] = value;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
// .env doesn't exist — that's fine
|
|
479
|
+
}
|
|
480
|
+
return result;
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Resolve a config value with priority chain:
|
|
484
|
+
* 1. process.env (shell-level override)
|
|
485
|
+
* 2. .env in project root (per-project)
|
|
486
|
+
* 3. ~/.config/opencode-immune/.env (global, shared across all projects)
|
|
487
|
+
*/
|
|
488
|
+
async function resolveEnvValue(directory, key) {
|
|
489
|
+
// 1. process.env takes priority
|
|
490
|
+
if (process.env[key])
|
|
491
|
+
return process.env[key];
|
|
492
|
+
// 2. Per-project .env
|
|
493
|
+
const projectEnv = await parseDotEnv((0, path_1.join)(directory, ".env"));
|
|
494
|
+
if (projectEnv[key])
|
|
495
|
+
return projectEnv[key];
|
|
496
|
+
// 3. Global config
|
|
497
|
+
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
498
|
+
if (home) {
|
|
499
|
+
const globalEnv = await parseDotEnv((0, path_1.join)(home, ".config", "opencode-immune", ".env"));
|
|
500
|
+
if (globalEnv[key])
|
|
501
|
+
return globalEnv[key];
|
|
502
|
+
}
|
|
503
|
+
return undefined;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Fetch latest release info from the harness GitHub repo.
|
|
507
|
+
* Returns null if token is missing, network fails, or no release found.
|
|
508
|
+
*/
|
|
509
|
+
async function fetchLatestHarnessRelease(repo, token) {
|
|
510
|
+
try {
|
|
511
|
+
const url = `https://api.github.com/repos/${repo}/releases/latest`;
|
|
512
|
+
const resp = await fetch(url, {
|
|
513
|
+
headers: {
|
|
514
|
+
Authorization: `Bearer ${token}`,
|
|
515
|
+
Accept: "application/vnd.github+json",
|
|
516
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
if (!resp.ok) {
|
|
520
|
+
if (resp.status === 404) {
|
|
521
|
+
console.log(`[opencode-immune] Harness sync: no releases found in ${repo}`);
|
|
522
|
+
}
|
|
523
|
+
else {
|
|
524
|
+
console.warn(`[opencode-immune] Harness sync: GitHub API returned ${resp.status} ${resp.statusText}`);
|
|
525
|
+
}
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
const data = (await resp.json());
|
|
529
|
+
const tagName = data.tag_name;
|
|
530
|
+
if (!tagName)
|
|
531
|
+
return null;
|
|
532
|
+
// Find the harness.tar.gz asset
|
|
533
|
+
const asset = data.assets?.find((a) => a.name === "harness.tar.gz");
|
|
534
|
+
if (!asset) {
|
|
535
|
+
console.warn(`[opencode-immune] Harness sync: release ${tagName} has no harness.tar.gz asset`);
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
return {
|
|
539
|
+
tagName,
|
|
540
|
+
// Use the API URL (not browser_download_url) so auth header works
|
|
541
|
+
assetUrl: asset.url,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
catch (err) {
|
|
545
|
+
console.warn(`[opencode-immune] Harness sync: failed to fetch release info:`, err instanceof Error ? err.message : String(err));
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Read the currently installed harness version from .opencode/.harness-version.
|
|
551
|
+
* Returns null if file doesn't exist.
|
|
552
|
+
*/
|
|
553
|
+
async function readLocalHarnessVersion(directory) {
|
|
554
|
+
try {
|
|
555
|
+
const versionPath = (0, path_1.join)(directory, ".opencode", HARNESS_VERSION_FILE);
|
|
556
|
+
const content = await (0, promises_1.readFile)(versionPath, "utf-8");
|
|
557
|
+
return content.trim() || null;
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Download a GitHub release asset (tar.gz) using the API URL with auth.
|
|
565
|
+
* Returns the path to the downloaded temp file.
|
|
566
|
+
*/
|
|
567
|
+
async function downloadHarnessAsset(assetUrl, token) {
|
|
568
|
+
const resp = await fetch(assetUrl, {
|
|
569
|
+
headers: {
|
|
570
|
+
Authorization: `Bearer ${token}`,
|
|
571
|
+
Accept: "application/octet-stream",
|
|
572
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
573
|
+
},
|
|
574
|
+
redirect: "follow",
|
|
575
|
+
});
|
|
576
|
+
if (!resp.ok) {
|
|
577
|
+
throw new Error(`Download failed: ${resp.status} ${resp.statusText}`);
|
|
578
|
+
}
|
|
579
|
+
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
580
|
+
const tempPath = (0, path_1.join)((0, os_1.tmpdir)(), `harness-${Date.now()}.tar.gz`);
|
|
581
|
+
await (0, promises_1.writeFile)(tempPath, buffer);
|
|
582
|
+
return tempPath;
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Extract tar.gz to a temporary directory.
|
|
586
|
+
* Returns the path to the temp extraction directory.
|
|
587
|
+
*/
|
|
588
|
+
function extractTarGz(archivePath, destDir) {
|
|
589
|
+
return new Promise((resolve, reject) => {
|
|
590
|
+
(0, child_process_1.execFile)("tar", ["xzf", archivePath, "-C", destDir], (err) => {
|
|
591
|
+
if (err)
|
|
592
|
+
reject(err);
|
|
593
|
+
else
|
|
594
|
+
resolve();
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Recursively copy all files from src to dest, creating directories as needed.
|
|
600
|
+
* Overwrites existing files.
|
|
601
|
+
*/
|
|
602
|
+
async function copyDirRecursive(src, dest) {
|
|
603
|
+
const entries = await (0, promises_1.readdir)(src, { withFileTypes: true });
|
|
604
|
+
await (0, promises_1.mkdir)(dest, { recursive: true });
|
|
605
|
+
for (const entry of entries) {
|
|
606
|
+
const srcPath = (0, path_1.join)(src, entry.name);
|
|
607
|
+
const destPath = (0, path_1.join)(dest, entry.name);
|
|
608
|
+
if (entry.isDirectory()) {
|
|
609
|
+
await copyDirRecursive(srcPath, destPath);
|
|
610
|
+
}
|
|
611
|
+
else {
|
|
612
|
+
await (0, promises_1.mkdir)((0, path_1.dirname)(destPath), { recursive: true });
|
|
613
|
+
await (0, promises_1.copyFile)(srcPath, destPath);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Compute SHA-256 hash of a file. Returns empty string if file doesn't exist.
|
|
619
|
+
*/
|
|
620
|
+
async function fileHash(filePath) {
|
|
621
|
+
try {
|
|
622
|
+
const content = await (0, promises_1.readFile)(filePath);
|
|
623
|
+
return (0, crypto_1.createHash)("sha256").update(content).digest("hex");
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
return "";
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Main harness sync function. Called once during plugin initialization.
|
|
631
|
+
*
|
|
632
|
+
* Flow:
|
|
633
|
+
* 1. Read OPENCODE_IMMUNE_TOKEN from env — skip if missing
|
|
634
|
+
* 2. Fetch latest release from harness repo
|
|
635
|
+
* 3. Compare tag with local .harness-version — skip if same
|
|
636
|
+
* 4. Download tar.gz asset
|
|
637
|
+
* 5. Extract to temp dir
|
|
638
|
+
* 6. Hash opencode.json before overwrite
|
|
639
|
+
* 7. Copy files to project root (overwrite)
|
|
640
|
+
* 8. Write new .harness-version
|
|
641
|
+
* 9. Hash opencode.json after — warn if changed (restart needed)
|
|
642
|
+
* 10. Cleanup temp files
|
|
643
|
+
*/
|
|
644
|
+
async function syncHarness(state) {
|
|
645
|
+
const token = await resolveEnvValue(state.input.directory, HARNESS_TOKEN_ENV);
|
|
646
|
+
if (!token) {
|
|
647
|
+
// No token — graceful degradation, no sync
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
const repo = (await resolveEnvValue(state.input.directory, "OPENCODE_IMMUNE_HARNESS_REPO"))
|
|
651
|
+
|| DEFAULT_HARNESS_REPO;
|
|
652
|
+
try {
|
|
653
|
+
// 1. Fetch latest release
|
|
654
|
+
const release = await fetchLatestHarnessRelease(repo, token);
|
|
655
|
+
if (!release)
|
|
656
|
+
return;
|
|
657
|
+
// 2. Compare versions
|
|
658
|
+
const localVersion = await readLocalHarnessVersion(state.input.directory);
|
|
659
|
+
if (localVersion === release.tagName) {
|
|
660
|
+
console.log(`[opencode-immune] Harness sync: already up to date (${release.tagName})`);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
console.log(`[opencode-immune] Harness sync: updating ${localVersion ?? "(none)"} → ${release.tagName}`);
|
|
664
|
+
// 3. Hash opencode.json before update
|
|
665
|
+
const configPath = (0, path_1.join)(state.input.directory, "opencode.json");
|
|
666
|
+
const hashBefore = await fileHash(configPath);
|
|
667
|
+
// 4. Download asset
|
|
668
|
+
const archivePath = await downloadHarnessAsset(release.assetUrl, token);
|
|
669
|
+
// 5. Extract to temp dir
|
|
670
|
+
const extractDir = (0, path_1.join)((0, os_1.tmpdir)(), `harness-extract-${Date.now()}`);
|
|
671
|
+
await (0, promises_1.mkdir)(extractDir, { recursive: true });
|
|
672
|
+
try {
|
|
673
|
+
await extractTarGz(archivePath, extractDir);
|
|
674
|
+
// 6. Copy extracted files to project root
|
|
675
|
+
// The archive contains: opencode.json, .opencode/prompts/**, rules/**, .gitignore, .opencode/.gitignore
|
|
676
|
+
await copyDirRecursive(extractDir, state.input.directory);
|
|
677
|
+
// 7. Write version marker
|
|
678
|
+
const versionDir = (0, path_1.join)(state.input.directory, ".opencode");
|
|
679
|
+
await (0, promises_1.mkdir)(versionDir, { recursive: true });
|
|
680
|
+
await (0, promises_1.writeFile)((0, path_1.join)(versionDir, HARNESS_VERSION_FILE), release.tagName + "\n", "utf-8");
|
|
681
|
+
// 8. Check if opencode.json changed
|
|
682
|
+
const hashAfter = await fileHash(configPath);
|
|
683
|
+
if (hashBefore && hashAfter && hashBefore !== hashAfter) {
|
|
684
|
+
console.warn(`[opencode-immune] ⚠ Harness sync: opencode.json was updated. ` +
|
|
685
|
+
`Please restart opencode for the new agent configuration to take effect.`);
|
|
686
|
+
}
|
|
687
|
+
console.log(`[opencode-immune] Harness sync: successfully updated to ${release.tagName}`);
|
|
688
|
+
await writeDiagnosticLog(state, "harness-sync:success", {
|
|
689
|
+
from: localVersion,
|
|
690
|
+
to: release.tagName,
|
|
691
|
+
configChanged: hashBefore !== hashAfter,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
finally {
|
|
695
|
+
// 9. Cleanup temp files
|
|
696
|
+
try {
|
|
697
|
+
await (0, promises_1.unlink)(archivePath);
|
|
698
|
+
}
|
|
699
|
+
catch { /* ignore */ }
|
|
700
|
+
try {
|
|
701
|
+
await (0, promises_1.rm)(extractDir, { recursive: true, force: true });
|
|
702
|
+
}
|
|
703
|
+
catch { /* ignore */ }
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
catch (err) {
|
|
707
|
+
// Sync failure must never prevent plugin from working
|
|
708
|
+
console.warn(`[opencode-immune] Harness sync failed:`, err instanceof Error ? err.message : String(err));
|
|
709
|
+
await writeDiagnosticLog(state, "harness-sync:error", {
|
|
710
|
+
error: err instanceof Error ? err.message : String(err),
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
443
715
|
// HOOK 1: TODO ENFORCER
|
|
444
716
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
445
717
|
/**
|
|
@@ -911,13 +1183,46 @@ const PRE_COMMIT_MARKER = "0-ULTRAWORK: PRE_COMMIT";
|
|
|
911
1183
|
const CYCLE_COMPLETE_MARKER = "0-ULTRAWORK: CYCLE_COMPLETE";
|
|
912
1184
|
const NEXT_TASK_PATTERN = /Next task:\s*(.+)/;
|
|
913
1185
|
const ALL_CYCLES_COMPLETE_MARKER = "0-ULTRAWORK: ALL_CYCLES_COMPLETE";
|
|
1186
|
+
/**
|
|
1187
|
+
* Helper: run git commit in the project directory.
|
|
1188
|
+
* Uses execFile for safety (no shell injection).
|
|
1189
|
+
* Returns true if commit succeeded, false otherwise.
|
|
1190
|
+
*/
|
|
1191
|
+
function runGitCommit(directory, message) {
|
|
1192
|
+
return new Promise((resolve) => {
|
|
1193
|
+
// Stage all changes first
|
|
1194
|
+
(0, child_process_1.execFile)("git", ["add", "-A"], { cwd: directory }, (addErr) => {
|
|
1195
|
+
if (addErr) {
|
|
1196
|
+
console.error("[opencode-immune] git add failed:", addErr.message);
|
|
1197
|
+
resolve(false);
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
// Then commit
|
|
1201
|
+
(0, child_process_1.execFile)("git", ["commit", "-m", message], { cwd: directory }, (commitErr, stdout, stderr) => {
|
|
1202
|
+
if (commitErr) {
|
|
1203
|
+
// "nothing to commit" is not a real error
|
|
1204
|
+
if (stderr?.includes("nothing to commit") || stdout?.includes("nothing to commit")) {
|
|
1205
|
+
console.log("[opencode-immune] git commit: nothing to commit (clean tree).");
|
|
1206
|
+
resolve(true);
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
console.error("[opencode-immune] git commit failed:", commitErr.message, stderr);
|
|
1210
|
+
resolve(false);
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
console.log("[opencode-immune] git commit succeeded:", stdout?.trim());
|
|
1214
|
+
resolve(true);
|
|
1215
|
+
});
|
|
1216
|
+
});
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
914
1219
|
/**
|
|
915
1220
|
* experimental.text.complete: scans completed assistant text for signal markers.
|
|
916
1221
|
*
|
|
917
|
-
*
|
|
918
|
-
*
|
|
919
|
-
*
|
|
920
|
-
*
|
|
1222
|
+
* KEY INSIGHT: text.complete fires per text-part, not per message.
|
|
1223
|
+
* PRE_COMMIT and CYCLE_COMPLETE are in DIFFERENT text parts.
|
|
1224
|
+
* Therefore CYCLE_COMPLETE handler must be self-contained:
|
|
1225
|
+
* it always runs git commit first, then creates a new session.
|
|
921
1226
|
*
|
|
922
1227
|
* ALL_CYCLES_COMPLETE → clears ultrawork marker, no new session.
|
|
923
1228
|
*/
|
|
@@ -930,83 +1235,85 @@ function createTextCompleteHandler(state) {
|
|
|
930
1235
|
// ── ALL_CYCLES_COMPLETE: clear ultrawork marker ──
|
|
931
1236
|
if (text.includes(ALL_CYCLES_COMPLETE_MARKER)) {
|
|
932
1237
|
await clearUltraworkMarker(state);
|
|
933
|
-
console.log("[opencode-immune] Multi-Cycle: ALL_CYCLES_COMPLETE detected
|
|
1238
|
+
console.log("[opencode-immune] Multi-Cycle: ALL_CYCLES_COMPLETE detected, marker cleared.");
|
|
934
1239
|
return;
|
|
935
1240
|
}
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
return;
|
|
940
|
-
// Run the full sequence in one async flow (no timers):
|
|
941
|
-
// commit → wait → new session
|
|
942
|
-
// Use setTimeout(0) only to not block the hook return.
|
|
943
|
-
setTimeout(async () => {
|
|
944
|
-
// ── Step 1: PRE_COMMIT → execute /commit and await result ──
|
|
945
|
-
if (hasPRECOMMIT && !state.commitPending && sessionID) {
|
|
1241
|
+
// ── PRE_COMMIT only (without CYCLE_COMPLETE in same part): run commit ──
|
|
1242
|
+
if (text.includes(PRE_COMMIT_MARKER) && !text.includes(CYCLE_COMPLETE_MARKER)) {
|
|
1243
|
+
if (!state.commitPending) {
|
|
946
1244
|
state.commitPending = true;
|
|
947
|
-
console.log("[opencode-immune] Multi-Cycle: PRE_COMMIT detected,
|
|
1245
|
+
console.log("[opencode-immune] Multi-Cycle: PRE_COMMIT detected (standalone), running git commit...");
|
|
948
1246
|
try {
|
|
949
|
-
|
|
950
|
-
body: {
|
|
951
|
-
command: "/commit",
|
|
952
|
-
arguments: "",
|
|
953
|
-
},
|
|
954
|
-
path: { id: sessionID },
|
|
955
|
-
});
|
|
956
|
-
console.log("[opencode-immune] Multi-Cycle: /commit completed.", commitResult?.response?.status ?? "");
|
|
1247
|
+
await runGitCommit(state.input.directory, "chore: ultrawork cycle auto-commit");
|
|
957
1248
|
}
|
|
958
1249
|
catch (err) {
|
|
959
|
-
console.error("[opencode-immune] Multi-Cycle:
|
|
1250
|
+
console.error("[opencode-immune] Multi-Cycle: git commit failed (standalone):", err);
|
|
960
1251
|
}
|
|
961
1252
|
finally {
|
|
962
1253
|
state.commitPending = false;
|
|
963
1254
|
}
|
|
964
1255
|
}
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
const taskMatch = text.match(NEXT_TASK_PATTERN);
|
|
974
|
-
const nextTask = taskMatch?.[1]?.trim() ?? "Continue processing task backlog";
|
|
975
|
-
console.log(`[opencode-immune] Multi-Cycle: CYCLE_COMPLETE detected (cycle ${state.cycleCount}/${MAX_CYCLES}). ` +
|
|
976
|
-
`Creating new session for: "${nextTask}"`);
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
// ── CYCLE_COMPLETE: self-contained sequence: commit → new session ──
|
|
1259
|
+
if (text.includes(CYCLE_COMPLETE_MARKER)) {
|
|
1260
|
+
// Step 1: Always commit first (CYCLE_COMPLETE implies end of cycle)
|
|
1261
|
+
if (!state.commitPending) {
|
|
1262
|
+
state.commitPending = true;
|
|
1263
|
+
console.log("[opencode-immune] Multi-Cycle: CYCLE_COMPLETE detected, running git commit first...");
|
|
977
1264
|
try {
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
title: `Ultrawork Cycle ${state.cycleCount + 1}`,
|
|
981
|
-
},
|
|
982
|
-
});
|
|
983
|
-
const newSessionData = createResult?.data;
|
|
984
|
-
const newSessionID = newSessionData?.id;
|
|
985
|
-
if (!newSessionID) {
|
|
986
|
-
console.error("[opencode-immune] Multi-Cycle: Failed to create new session — no session ID returned.");
|
|
987
|
-
return;
|
|
988
|
-
}
|
|
989
|
-
console.log(`[opencode-immune] Multi-Cycle: New session created: ${newSessionID}`);
|
|
990
|
-
await addManagedUltraworkSession(state, newSessionID);
|
|
991
|
-
await state.input.client.session.promptAsync({
|
|
992
|
-
body: {
|
|
993
|
-
agent: ULTRAWORK_AGENT,
|
|
994
|
-
parts: [
|
|
995
|
-
{
|
|
996
|
-
type: "text",
|
|
997
|
-
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.`,
|
|
998
|
-
},
|
|
999
|
-
],
|
|
1000
|
-
},
|
|
1001
|
-
path: { id: newSessionID },
|
|
1002
|
-
});
|
|
1003
|
-
console.log(`[opencode-immune] Multi-Cycle: Bootstrap prompt sent to new session ${newSessionID}`);
|
|
1265
|
+
await runGitCommit(state.input.directory, "chore: ultrawork cycle auto-commit");
|
|
1266
|
+
console.log("[opencode-immune] Multi-Cycle: git commit completed before new cycle.");
|
|
1004
1267
|
}
|
|
1005
1268
|
catch (err) {
|
|
1006
|
-
console.error("[opencode-immune] Multi-Cycle:
|
|
1269
|
+
console.error("[opencode-immune] Multi-Cycle: git commit failed (continuing anyway):", err);
|
|
1270
|
+
}
|
|
1271
|
+
finally {
|
|
1272
|
+
state.commitPending = false;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
// Step 2: Create new session
|
|
1276
|
+
state.cycleCount++;
|
|
1277
|
+
if (state.cycleCount >= MAX_CYCLES) {
|
|
1278
|
+
console.log(`[opencode-immune] Multi-Cycle: MAX_CYCLES (${MAX_CYCLES}) reached. Not creating new session.`);
|
|
1279
|
+
await clearUltraworkMarker(state);
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
const taskMatch = text.match(NEXT_TASK_PATTERN);
|
|
1283
|
+
const nextTask = taskMatch?.[1]?.trim() ?? "Continue processing task backlog";
|
|
1284
|
+
console.log(`[opencode-immune] Multi-Cycle: Creating new session (cycle ${state.cycleCount}/${MAX_CYCLES}) for: "${nextTask}"`);
|
|
1285
|
+
try {
|
|
1286
|
+
const createResult = await state.input.client.session.create({
|
|
1287
|
+
body: {
|
|
1288
|
+
title: `Ultrawork Cycle ${state.cycleCount + 1}`,
|
|
1289
|
+
},
|
|
1290
|
+
});
|
|
1291
|
+
const newSessionData = createResult?.data;
|
|
1292
|
+
const newSessionID = newSessionData?.id;
|
|
1293
|
+
if (!newSessionID) {
|
|
1294
|
+
console.error("[opencode-immune] Multi-Cycle: Failed to create new session — no ID returned.");
|
|
1295
|
+
return;
|
|
1007
1296
|
}
|
|
1297
|
+
console.log(`[opencode-immune] Multi-Cycle: New session created: ${newSessionID}`);
|
|
1298
|
+
await addManagedUltraworkSession(state, newSessionID);
|
|
1299
|
+
await state.input.client.session.promptAsync({
|
|
1300
|
+
body: {
|
|
1301
|
+
agent: ULTRAWORK_AGENT,
|
|
1302
|
+
parts: [
|
|
1303
|
+
{
|
|
1304
|
+
type: "text",
|
|
1305
|
+
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.`,
|
|
1306
|
+
},
|
|
1307
|
+
],
|
|
1308
|
+
},
|
|
1309
|
+
path: { id: newSessionID },
|
|
1310
|
+
});
|
|
1311
|
+
console.log(`[opencode-immune] Multi-Cycle: Bootstrap prompt sent to ${newSessionID}`);
|
|
1312
|
+
}
|
|
1313
|
+
catch (err) {
|
|
1314
|
+
console.error("[opencode-immune] Multi-Cycle: Failed to create session or send prompt:", err);
|
|
1008
1315
|
}
|
|
1009
|
-
}
|
|
1316
|
+
}
|
|
1010
1317
|
};
|
|
1011
1318
|
}
|
|
1012
1319
|
/**
|
|
@@ -1056,6 +1363,12 @@ function createMultiCycleHandler(state) {
|
|
|
1056
1363
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1057
1364
|
async function server(input) {
|
|
1058
1365
|
const state = createState(input);
|
|
1366
|
+
// ── Harness auto-sync (non-blocking, fire-and-forget) ──
|
|
1367
|
+
// Runs in background so it doesn't delay plugin initialization.
|
|
1368
|
+
// If sync fails, plugin continues normally with existing config.
|
|
1369
|
+
syncHarness(state).catch((err) => {
|
|
1370
|
+
console.warn(`[opencode-immune] Harness sync background error:`, err instanceof Error ? err.message : String(err));
|
|
1371
|
+
});
|
|
1059
1372
|
// Eagerly load recovery context at plugin init so it's available
|
|
1060
1373
|
// for the very first system.transform call (before chat.params fires).
|
|
1061
1374
|
const markerActive = await isUltraworkMarkerActive(state);
|