opencode-immune 1.0.20 → 1.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/plugin.js +347 -52
  2. 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
  /**
@@ -914,9 +1186,12 @@ const ALL_CYCLES_COMPLETE_MARKER = "0-ULTRAWORK: ALL_CYCLES_COMPLETE";
914
1186
  /**
915
1187
  * experimental.text.complete: scans completed assistant text for signal markers.
916
1188
  *
917
- * PRE_COMMIT executes /commit via client.session.command()
918
- * CYCLE_COMPLETE creates a new session and sends bootstrap prompt
919
- * ALL_CYCLES_COMPLETE clears ultrawork marker
1189
+ * KEY INSIGHT: text.complete fires per text-part, not per message.
1190
+ * PRE_COMMIT and CYCLE_COMPLETE are in DIFFERENT text parts.
1191
+ * Therefore CYCLE_COMPLETE handler must be self-contained:
1192
+ * it always runs /commit first, then creates a new session.
1193
+ *
1194
+ * ALL_CYCLES_COMPLETE → clears ultrawork marker, no new session.
920
1195
  */
921
1196
  function createTextCompleteHandler(state) {
922
1197
  return async (input, output) => {
@@ -927,33 +1202,51 @@ function createTextCompleteHandler(state) {
927
1202
  // ── ALL_CYCLES_COMPLETE: clear ultrawork marker ──
928
1203
  if (text.includes(ALL_CYCLES_COMPLETE_MARKER)) {
929
1204
  await clearUltraworkMarker(state);
930
- console.log("[opencode-immune] Multi-Cycle: ALL_CYCLES_COMPLETE detected in text.complete, ultrawork marker cleared.");
1205
+ console.log("[opencode-immune] Multi-Cycle: ALL_CYCLES_COMPLETE detected, marker cleared.");
1206
+ return;
931
1207
  }
932
- // ── PRE_COMMIT: execute /commit ──
933
- if (text.includes(PRE_COMMIT_MARKER) && !state.commitPending && sessionID) {
934
- state.commitPending = true;
935
- console.log("[opencode-immune] Multi-Cycle: PRE_COMMIT detected in text.complete, executing /commit...");
936
- setTimeout(async () => {
1208
+ // ── PRE_COMMIT only (without CYCLE_COMPLETE in same part): run commit ──
1209
+ if (text.includes(PRE_COMMIT_MARKER) && !text.includes(CYCLE_COMPLETE_MARKER)) {
1210
+ if (!state.commitPending && sessionID) {
1211
+ state.commitPending = true;
1212
+ console.log("[opencode-immune] Multi-Cycle: PRE_COMMIT detected (standalone), executing /commit...");
937
1213
  try {
938
1214
  await state.input.client.session.command({
939
- body: {
940
- command: "/commit",
941
- arguments: "",
942
- },
1215
+ body: { command: "/commit", arguments: "" },
943
1216
  path: { id: sessionID },
944
1217
  });
945
- console.log("[opencode-immune] Multi-Cycle: /commit executed successfully.");
1218
+ console.log("[opencode-immune] Multi-Cycle: /commit completed (standalone).");
946
1219
  }
947
1220
  catch (err) {
948
- console.error("[opencode-immune] Multi-Cycle: /commit failed:", err);
1221
+ console.error("[opencode-immune] Multi-Cycle: /commit failed (standalone):", err);
949
1222
  }
950
1223
  finally {
951
1224
  state.commitPending = false;
952
1225
  }
953
- }, 2_000);
1226
+ }
1227
+ return;
954
1228
  }
955
- // ── CYCLE_COMPLETE: create new session ──
1229
+ // ── CYCLE_COMPLETE: self-contained sequence: commit → new session ──
956
1230
  if (text.includes(CYCLE_COMPLETE_MARKER)) {
1231
+ // Step 1: Always commit first (CYCLE_COMPLETE implies end of cycle)
1232
+ if (sessionID && !state.commitPending) {
1233
+ state.commitPending = true;
1234
+ console.log("[opencode-immune] Multi-Cycle: CYCLE_COMPLETE detected, running /commit first...");
1235
+ try {
1236
+ await state.input.client.session.command({
1237
+ body: { command: "/commit", arguments: "" },
1238
+ path: { id: sessionID },
1239
+ });
1240
+ console.log("[opencode-immune] Multi-Cycle: /commit completed before new cycle.");
1241
+ }
1242
+ catch (err) {
1243
+ console.error("[opencode-immune] Multi-Cycle: /commit failed (continuing anyway):", err);
1244
+ }
1245
+ finally {
1246
+ state.commitPending = false;
1247
+ }
1248
+ }
1249
+ // Step 2: Create new session
957
1250
  state.cycleCount++;
958
1251
  if (state.cycleCount >= MAX_CYCLES) {
959
1252
  console.log(`[opencode-immune] Multi-Cycle: MAX_CYCLES (${MAX_CYCLES}) reached. Not creating new session.`);
@@ -962,42 +1255,38 @@ function createTextCompleteHandler(state) {
962
1255
  }
963
1256
  const taskMatch = text.match(NEXT_TASK_PATTERN);
964
1257
  const nextTask = taskMatch?.[1]?.trim() ?? "Continue processing task backlog";
965
- console.log(`[opencode-immune] Multi-Cycle: CYCLE_COMPLETE detected in text.complete (cycle ${state.cycleCount}/${MAX_CYCLES}). ` +
966
- `Creating new session for: "${nextTask}"`);
967
- // Delay to let commit finish
968
- setTimeout(async () => {
969
- try {
970
- const createResult = await state.input.client.session.create({
971
- body: {
972
- title: `Ultrawork Cycle ${state.cycleCount + 1}`,
973
- },
974
- });
975
- const newSessionData = createResult?.data;
976
- const newSessionID = newSessionData?.id;
977
- if (!newSessionID) {
978
- console.error("[opencode-immune] Multi-Cycle: Failed to create new session — no session ID returned.");
979
- return;
980
- }
981
- console.log(`[opencode-immune] Multi-Cycle: New session created: ${newSessionID}`);
982
- await addManagedUltraworkSession(state, newSessionID);
983
- await state.input.client.session.promptAsync({
984
- body: {
985
- agent: ULTRAWORK_AGENT,
986
- parts: [
987
- {
988
- type: "text",
989
- 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.`,
990
- },
991
- ],
992
- },
993
- path: { id: newSessionID },
994
- });
995
- console.log(`[opencode-immune] Multi-Cycle: Bootstrap prompt sent to new session ${newSessionID}`);
996
- }
997
- catch (err) {
998
- console.error("[opencode-immune] Multi-Cycle: Failed to create new session or send prompt:", err);
1258
+ console.log(`[opencode-immune] Multi-Cycle: Creating new session (cycle ${state.cycleCount}/${MAX_CYCLES}) for: "${nextTask}"`);
1259
+ try {
1260
+ const createResult = await state.input.client.session.create({
1261
+ body: {
1262
+ title: `Ultrawork Cycle ${state.cycleCount + 1}`,
1263
+ },
1264
+ });
1265
+ const newSessionData = createResult?.data;
1266
+ const newSessionID = newSessionData?.id;
1267
+ if (!newSessionID) {
1268
+ console.error("[opencode-immune] Multi-Cycle: Failed to create new session — no ID returned.");
1269
+ return;
999
1270
  }
1000
- }, 8_000);
1271
+ console.log(`[opencode-immune] Multi-Cycle: New session created: ${newSessionID}`);
1272
+ await addManagedUltraworkSession(state, newSessionID);
1273
+ await state.input.client.session.promptAsync({
1274
+ body: {
1275
+ agent: ULTRAWORK_AGENT,
1276
+ parts: [
1277
+ {
1278
+ type: "text",
1279
+ 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.`,
1280
+ },
1281
+ ],
1282
+ },
1283
+ path: { id: newSessionID },
1284
+ });
1285
+ console.log(`[opencode-immune] Multi-Cycle: Bootstrap prompt sent to ${newSessionID}`);
1286
+ }
1287
+ catch (err) {
1288
+ console.error("[opencode-immune] Multi-Cycle: Failed to create session or send prompt:", err);
1289
+ }
1001
1290
  }
1002
1291
  };
1003
1292
  }
@@ -1048,6 +1337,12 @@ function createMultiCycleHandler(state) {
1048
1337
  // ═══════════════════════════════════════════════════════════════════════════════
1049
1338
  async function server(input) {
1050
1339
  const state = createState(input);
1340
+ // ── Harness auto-sync (non-blocking, fire-and-forget) ──
1341
+ // Runs in background so it doesn't delay plugin initialization.
1342
+ // If sync fails, plugin continues normally with existing config.
1343
+ syncHarness(state).catch((err) => {
1344
+ console.warn(`[opencode-immune] Harness sync background error:`, err instanceof Error ? err.message : String(err));
1345
+ });
1051
1346
  // Eagerly load recovery context at plugin init so it's available
1052
1347
  // for the very first system.transform call (before chat.params fires).
1053
1348
  const markerActive = await isUltraworkMarkerActive(state);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-immune",
3
- "version": "1.0.20",
3
+ "version": "1.0.22",
4
4
  "description": "OpenCode plugin: session recovery, auto-retry, multi-cycle automation, context monitoring",
5
5
  "exports": {
6
6
  "./server": "./dist/plugin.js"