@bybrawe/opencode-loop 0.6.6 → 0.6.8
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/CHANGELOG.md +20 -0
- package/README.md +7 -3
- package/examples/OPENCODE2-0.6.7.md +26 -0
- package/examples/OPENCODE2-0.6.8.md +18 -0
- package/package.json +3 -3
- package/scripts/comprehensive-test.mjs +3 -3
- package/scripts/desktop-command-diagnostics-test.mjs +92 -0
- package/scripts/desktop-command-diagnostics.mjs +237 -0
- package/scripts/host-goal-bootstrap-test.mjs +46 -0
- package/scripts/host-goal-bootstrap.mjs +71 -0
- package/scripts/host-goal-coexistence-canary.mjs +11 -2
- package/scripts/install-with-goals.mjs +11 -0
- package/scripts/native-plugin-lifetime-test.mjs +39 -6
- package/scripts/native-release.mjs +3 -3
- package/scripts/native-v2-test.mjs +64 -2
- package/scripts/opencode2-loop-canary.mjs +55 -1
- package/scripts/package-native-smoke.mjs +40 -5
- package/scripts/publish-workflow-test.mjs +6 -2
- package/scripts/state-exfat-lock-test.mjs +99 -0
- package/scripts/state-integrity-test.mjs +47 -0
- package/scripts/state-process-test.mjs +172 -0
- package/src/index.js +249 -11
- package/src/native.js +268 -19
- package/src/server.js +268 -19
- package/src/source/core/state.js +236 -9
- package/src/source/opencode2/events.js +1 -1
- package/src/source/opencode2/native-plugin.js +4 -6
- package/src/source/opencode2/native-runtime.js +30 -4
package/src/server.js
CHANGED
|
@@ -547,6 +547,7 @@ var init_jobs = __esm(() => {
|
|
|
547
547
|
|
|
548
548
|
// src/source/core/state.js
|
|
549
549
|
import { promises as fs } from "fs";
|
|
550
|
+
import { randomUUID as stateLockRandomUUID } from "crypto";
|
|
550
551
|
import os from "os";
|
|
551
552
|
import path3 from "path";
|
|
552
553
|
function stateDir(directory) {
|
|
@@ -558,6 +559,224 @@ function statePath(directory, sessionID) {
|
|
|
558
559
|
async function ensureDir(directory) {
|
|
559
560
|
await fs.mkdir(directory, { recursive: true });
|
|
560
561
|
}
|
|
562
|
+
function stateLockError(kind, detail) {
|
|
563
|
+
const error = new Error(`OpenCode Loop state lock ${kind}: ${detail}`);
|
|
564
|
+
error.code = "OPENCODE_LOOP_STATE_LOCK";
|
|
565
|
+
return error;
|
|
566
|
+
}
|
|
567
|
+
async function assertStatePathSafe(directory, target) {
|
|
568
|
+
const root = path3.resolve(directory);
|
|
569
|
+
const relative = path3.relative(root, path3.resolve(target));
|
|
570
|
+
if (relative === ".." || relative.startsWith(`..${path3.sep}`) || path3.isAbsolute(relative)) {
|
|
571
|
+
throw stateLockError("unsafe_path", `path escapes project: ${target}`);
|
|
572
|
+
}
|
|
573
|
+
let current = root;
|
|
574
|
+
for (const part of relative.split(path3.sep).filter(Boolean)) {
|
|
575
|
+
current = path3.join(current, part);
|
|
576
|
+
let info;
|
|
577
|
+
try {
|
|
578
|
+
info = await fs.lstat(current);
|
|
579
|
+
} catch (error) {
|
|
580
|
+
if (error?.code === "ENOENT")
|
|
581
|
+
return;
|
|
582
|
+
throw error;
|
|
583
|
+
}
|
|
584
|
+
if (info.isSymbolicLink())
|
|
585
|
+
throw stateLockError("unsafe_path", `symbolic link or junction: ${current}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
function stateProcessLockOwner() {
|
|
589
|
+
const token = stateLockRandomUUID();
|
|
590
|
+
return {
|
|
591
|
+
schemaVersion: 1,
|
|
592
|
+
pid: process.pid,
|
|
593
|
+
token,
|
|
594
|
+
acquiredAt: Date.now(),
|
|
595
|
+
candidateName: `.loop-lock-${process.pid}-${token}.json`
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
function stateProcessLockOwnerValid(owner) {
|
|
599
|
+
return owner?.schemaVersion === 1 && Number.isSafeInteger(owner.pid) && owner.pid > 0 && typeof owner.token === "string" && /^[0-9a-f-]{36}$/i.test(owner.token) && Number.isFinite(owner.acquiredAt) && owner.acquiredAt > 0 && owner.candidateName === `.loop-lock-${owner.pid}-${owner.token}.json` && path3.basename(owner.candidateName) === owner.candidateName;
|
|
600
|
+
}
|
|
601
|
+
async function readStateProcessOwner(file) {
|
|
602
|
+
let raw;
|
|
603
|
+
for (let attempt = 0;; attempt++) {
|
|
604
|
+
try {
|
|
605
|
+
raw = await fs.readFile(file, "utf8");
|
|
606
|
+
break;
|
|
607
|
+
} catch (error) {
|
|
608
|
+
if (error?.code === "ENOENT")
|
|
609
|
+
return null;
|
|
610
|
+
if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
|
|
611
|
+
throw error;
|
|
612
|
+
await delay(10 * (attempt + 1));
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (raw.length === 0) {
|
|
616
|
+
for (let attempt = 0;attempt < 8 && raw.length === 0; attempt++) {
|
|
617
|
+
await delay(10 * (attempt + 1));
|
|
618
|
+
try {
|
|
619
|
+
raw = await fs.readFile(file, "utf8");
|
|
620
|
+
} catch (error) {
|
|
621
|
+
if (error?.code === "ENOENT")
|
|
622
|
+
return null;
|
|
623
|
+
throw error;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
let owner;
|
|
628
|
+
try {
|
|
629
|
+
owner = JSON.parse(raw);
|
|
630
|
+
} catch {
|
|
631
|
+
throw stateLockError("invalid_owner", `corrupt lock metadata at ${file}`);
|
|
632
|
+
}
|
|
633
|
+
if (!stateProcessLockOwnerValid(owner))
|
|
634
|
+
throw stateLockError("invalid_owner", `invalid lock metadata at ${file}`);
|
|
635
|
+
return owner;
|
|
636
|
+
}
|
|
637
|
+
function stateProcessAlive(pid) {
|
|
638
|
+
if (pid === process.pid)
|
|
639
|
+
return true;
|
|
640
|
+
try {
|
|
641
|
+
process.kill(pid, 0);
|
|
642
|
+
return true;
|
|
643
|
+
} catch (error) {
|
|
644
|
+
return error?.code !== "ESRCH";
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
async function removeStateProcessFile(file) {
|
|
648
|
+
for (let attempt = 0;; attempt++) {
|
|
649
|
+
try {
|
|
650
|
+
await fs.rm(file, { force: true });
|
|
651
|
+
return;
|
|
652
|
+
} catch (error) {
|
|
653
|
+
if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
|
|
654
|
+
throw error;
|
|
655
|
+
await delay(10 * (attempt + 1));
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
async function claimStateProcessLock(candidate, canonical) {
|
|
660
|
+
for (let attempt = 0;; attempt++) {
|
|
661
|
+
try {
|
|
662
|
+
await fs.link(candidate, canonical);
|
|
663
|
+
return true;
|
|
664
|
+
} catch (error) {
|
|
665
|
+
if (error?.code === "EEXIST")
|
|
666
|
+
return false;
|
|
667
|
+
if (["EISDIR", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES"].includes(error?.code)) {
|
|
668
|
+
const contents = await fs.readFile(candidate);
|
|
669
|
+
let handle;
|
|
670
|
+
try {
|
|
671
|
+
handle = await fs.open(canonical, "wx", 384);
|
|
672
|
+
} catch (failure) {
|
|
673
|
+
if (failure?.code === "EEXIST")
|
|
674
|
+
return false;
|
|
675
|
+
throw failure;
|
|
676
|
+
}
|
|
677
|
+
try {
|
|
678
|
+
await handle.writeFile(contents);
|
|
679
|
+
} catch (failure) {
|
|
680
|
+
await handle.close().catch(() => {});
|
|
681
|
+
await removeStateProcessFile(canonical).catch(() => {});
|
|
682
|
+
throw failure;
|
|
683
|
+
}
|
|
684
|
+
await handle.close();
|
|
685
|
+
return true;
|
|
686
|
+
}
|
|
687
|
+
if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
|
|
688
|
+
throw error;
|
|
689
|
+
await delay(10 * (attempt + 1));
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
async function createStateLockCandidate(directory, owner) {
|
|
694
|
+
const candidate = path3.join(stateDir(directory), owner.candidateName);
|
|
695
|
+
await assertStatePathSafe(directory, candidate);
|
|
696
|
+
await fs.writeFile(candidate, `${JSON.stringify(owner)}
|
|
697
|
+
`, { flag: "wx", mode: 384 });
|
|
698
|
+
return candidate;
|
|
699
|
+
}
|
|
700
|
+
async function recoverDeadStateProcessLock(directory, canonical, stale, depth = 0) {
|
|
701
|
+
const cleanupFile = `${canonical}.cleanup`;
|
|
702
|
+
const cleanupOwner = stateProcessLockOwner();
|
|
703
|
+
const cleanupCandidate = await createStateLockCandidate(directory, cleanupOwner);
|
|
704
|
+
let elected = false;
|
|
705
|
+
try {
|
|
706
|
+
await assertStatePathSafe(directory, cleanupFile);
|
|
707
|
+
elected = await claimStateProcessLock(cleanupCandidate, cleanupFile);
|
|
708
|
+
if (!elected) {
|
|
709
|
+
const existing = await readStateProcessOwner(cleanupFile);
|
|
710
|
+
if (existing && !stateProcessAlive(existing.pid)) {
|
|
711
|
+
if (depth >= 6)
|
|
712
|
+
throw stateLockError("recovery_depth", `too many orphaned cleanup elections for ${canonical}`);
|
|
713
|
+
await recoverDeadStateProcessLock(directory, cleanupFile, existing, depth + 1);
|
|
714
|
+
}
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
const current = await readStateProcessOwner(canonical);
|
|
718
|
+
if (!current || current.token !== stale.token || current.pid !== stale.pid || stateProcessAlive(current.pid))
|
|
719
|
+
return false;
|
|
720
|
+
await assertStatePathSafe(directory, canonical);
|
|
721
|
+
await removeStateProcessFile(canonical);
|
|
722
|
+
const staleCandidate = path3.join(stateDir(directory), current.candidateName);
|
|
723
|
+
await assertStatePathSafe(directory, staleCandidate);
|
|
724
|
+
await removeStateProcessFile(staleCandidate).catch(() => {});
|
|
725
|
+
return true;
|
|
726
|
+
} finally {
|
|
727
|
+
if (elected) {
|
|
728
|
+
const current = await readStateProcessOwner(cleanupFile).catch(() => null);
|
|
729
|
+
if (current?.token === cleanupOwner.token)
|
|
730
|
+
await removeStateProcessFile(cleanupFile);
|
|
731
|
+
}
|
|
732
|
+
await removeStateProcessFile(cleanupCandidate).catch(() => {});
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
async function acquireStateProcessLock(directory, sessionID) {
|
|
736
|
+
const targetDirectory = stateDir(directory);
|
|
737
|
+
await assertStatePathSafe(directory, targetDirectory);
|
|
738
|
+
await ensureDir(targetDirectory);
|
|
739
|
+
await assertStatePathSafe(directory, targetDirectory);
|
|
740
|
+
const target = statePath(directory, sessionID);
|
|
741
|
+
const canonical = `${target}.lock`;
|
|
742
|
+
await assertStatePathSafe(directory, target);
|
|
743
|
+
await assertStatePathSafe(directory, canonical);
|
|
744
|
+
const owner = stateProcessLockOwner();
|
|
745
|
+
const candidate = await createStateLockCandidate(directory, owner);
|
|
746
|
+
const started = Date.now();
|
|
747
|
+
let acquired = false;
|
|
748
|
+
try {
|
|
749
|
+
while (true) {
|
|
750
|
+
await assertStatePathSafe(directory, canonical);
|
|
751
|
+
if (await claimStateProcessLock(candidate, canonical)) {
|
|
752
|
+
acquired = true;
|
|
753
|
+
break;
|
|
754
|
+
}
|
|
755
|
+
const current = await readStateProcessOwner(canonical);
|
|
756
|
+
if (current && !stateProcessAlive(current.pid))
|
|
757
|
+
await recoverDeadStateProcessLock(directory, canonical, current);
|
|
758
|
+
if (Date.now() - started >= STATE_PROCESS_LOCK_TIMEOUT_MS) {
|
|
759
|
+
throw stateLockError("timeout", `session ${sessionID} remained locked for ${STATE_PROCESS_LOCK_TIMEOUT_MS}ms`);
|
|
760
|
+
}
|
|
761
|
+
await delay(STATE_PROCESS_LOCK_POLL_MS);
|
|
762
|
+
}
|
|
763
|
+
return async () => {
|
|
764
|
+
const current = await readStateProcessOwner(canonical);
|
|
765
|
+
if (current?.token !== owner.token || current.pid !== owner.pid) {
|
|
766
|
+
throw stateLockError("lost", `session ${sessionID} lock ownership changed`);
|
|
767
|
+
}
|
|
768
|
+
await assertStatePathSafe(directory, canonical);
|
|
769
|
+
await removeStateProcessFile(canonical);
|
|
770
|
+
await removeStateProcessFile(candidate);
|
|
771
|
+
};
|
|
772
|
+
} catch (error) {
|
|
773
|
+
if (acquired && (await readStateProcessOwner(canonical).catch(() => null))?.token === owner.token) {
|
|
774
|
+
await removeStateProcessFile(canonical).catch(() => {});
|
|
775
|
+
}
|
|
776
|
+
await removeStateProcessFile(candidate).catch(() => {});
|
|
777
|
+
throw error;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
561
780
|
function stateLockKey(directory, sessionID) {
|
|
562
781
|
return `${path3.resolve(directory)}:${safeID(sessionID)}`;
|
|
563
782
|
}
|
|
@@ -571,21 +790,32 @@ async function withStateWriteLock(directory, sessionID, fn) {
|
|
|
571
790
|
const next = previous.catch(() => {}).then(() => current);
|
|
572
791
|
stateWriteLocks.set(key, next);
|
|
573
792
|
await previous.catch(() => {});
|
|
793
|
+
let unlock;
|
|
574
794
|
try {
|
|
795
|
+
unlock = await acquireStateProcessLock(directory, sessionID);
|
|
575
796
|
return await fn();
|
|
576
797
|
} finally {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
798
|
+
try {
|
|
799
|
+
if (unlock)
|
|
800
|
+
await unlock();
|
|
801
|
+
} finally {
|
|
802
|
+
release();
|
|
803
|
+
if (stateWriteLocks.get(key) === next)
|
|
804
|
+
stateWriteLocks.delete(key);
|
|
805
|
+
}
|
|
580
806
|
}
|
|
581
807
|
}
|
|
582
808
|
async function readStateFile(directory, sessionID) {
|
|
583
809
|
const target = statePath(directory, sessionID);
|
|
810
|
+
await assertStatePathSafe(directory, target);
|
|
584
811
|
const attempts = 5;
|
|
585
812
|
for (let attempt = 0;attempt < attempts; attempt++) {
|
|
586
813
|
try {
|
|
587
814
|
const parsed = JSON.parse(await fs.readFile(target, "utf8"));
|
|
588
|
-
|
|
815
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.jobs)) {
|
|
816
|
+
throw new SyntaxError("invalid Loop state schema: jobs must be an array");
|
|
817
|
+
}
|
|
818
|
+
return { version: 4, jobs: parsed.jobs };
|
|
589
819
|
} catch (error) {
|
|
590
820
|
if (error?.code === "ENOENT")
|
|
591
821
|
return { version: 4, jobs: [] };
|
|
@@ -599,7 +829,7 @@ async function readStateFile(directory, sessionID) {
|
|
|
599
829
|
await ensureDir(stateDir(directory));
|
|
600
830
|
await fs.copyFile(target, `${target}.corrupt-${Date.now()}`);
|
|
601
831
|
} catch {}
|
|
602
|
-
|
|
832
|
+
throw new Error(`OpenCode Loop state at ${target} could not be read; refusing to replace persisted jobs.`);
|
|
603
833
|
}
|
|
604
834
|
async function readState(directory, sessionID) {
|
|
605
835
|
const state = await readStateFile(directory, sessionID);
|
|
@@ -729,28 +959,34 @@ function mergeStateJobs(baseJobs, intendedJobs, currentJobs) {
|
|
|
729
959
|
}
|
|
730
960
|
async function writeState(directory, sessionID, state) {
|
|
731
961
|
await withStateWriteLock(directory, sessionID, async () => {
|
|
732
|
-
await ensureDir(stateDir(directory));
|
|
733
962
|
const target = statePath(directory, sessionID);
|
|
963
|
+
await assertStatePathSafe(directory, target);
|
|
964
|
+
const current = await readStateFile(directory, sessionID);
|
|
734
965
|
const baseline = state?.[STATE_BASELINE];
|
|
735
966
|
let jobs = structuredClone(state.jobs || []);
|
|
736
967
|
if (Array.isArray(baseline)) {
|
|
737
|
-
const current = await readStateFile(directory, sessionID);
|
|
738
968
|
jobs = mergeStateJobs(baseline, jobs, current.jobs || []);
|
|
739
|
-
state.jobs = structuredClone(jobs);
|
|
740
|
-
state[STATE_BASELINE] = structuredClone(jobs);
|
|
741
969
|
}
|
|
742
970
|
const payload = JSON.stringify({ version: 4, jobs }, null, 2);
|
|
743
971
|
await writeFileAtomically(target, payload);
|
|
972
|
+
state.jobs = structuredClone(jobs);
|
|
973
|
+
if (Array.isArray(baseline))
|
|
974
|
+
state[STATE_BASELINE] = structuredClone(jobs);
|
|
744
975
|
});
|
|
745
976
|
}
|
|
746
977
|
async function removeState(directory, sessionID) {
|
|
747
978
|
await withStateWriteLock(directory, sessionID, async () => {
|
|
979
|
+
const target = statePath(directory, sessionID);
|
|
980
|
+
await assertStatePathSafe(directory, target);
|
|
748
981
|
try {
|
|
749
|
-
await fs.unlink(
|
|
750
|
-
} catch {
|
|
982
|
+
await fs.unlink(target);
|
|
983
|
+
} catch (error) {
|
|
984
|
+
if (error?.code !== "ENOENT")
|
|
985
|
+
throw error;
|
|
986
|
+
}
|
|
751
987
|
});
|
|
752
988
|
}
|
|
753
|
-
var STATE_DIR = ".opencode/opencode-loop", STATE_BASELINE, stateWriteLocks;
|
|
989
|
+
var STATE_DIR = ".opencode/opencode-loop", STATE_BASELINE, stateWriteLocks, STATE_PROCESS_LOCK_TIMEOUT_MS = 5000, STATE_PROCESS_LOCK_POLL_MS = 20;
|
|
754
990
|
var init_state = __esm(() => {
|
|
755
991
|
STATE_BASELINE = Symbol("opencode-loop-state-baseline");
|
|
756
992
|
stateWriteLocks = new Map;
|
|
@@ -1824,7 +2060,7 @@ function createNativeLoopRuntime(options = {}) {
|
|
|
1824
2060
|
const run = scope.active;
|
|
1825
2061
|
if (!run || scope.compaction || scope.busy)
|
|
1826
2062
|
return result({ reason: "no-terminal-boundary" });
|
|
1827
|
-
if (run.kind === "prompt" && !run.delivered || run.kind === "command" && !run.commandFinished || run.kind === "shell" && !run.shellFinished || ["compact", "cadence"].includes(run.kind) && !run.compactFinished) {
|
|
2063
|
+
if (run.kind === "prompt" && !run.delivered || run.kind === "command" && !run.commandFinished || run.kind === "shell" && !run.shellFinished || ["compact", "cadence"].includes(run.kind) && (!run.compactionStarted || !run.compactFinished)) {
|
|
1828
2064
|
return result({ reason: "awaiting-owned-completion" });
|
|
1829
2065
|
}
|
|
1830
2066
|
clear(scope, "deadline");
|
|
@@ -2100,6 +2336,8 @@ ${await policy.buildPrompt(scope.directory, job)}`, delivery: "queue", metadata:
|
|
|
2100
2336
|
}
|
|
2101
2337
|
if (event.kind === "compaction" && event.action === "started") {
|
|
2102
2338
|
scope.compaction ||= { ended: false, terminal: false };
|
|
2339
|
+
if (["compact", "cadence"].includes(scope.active?.kind) && event.reason === "manual" && event.inputID && event.inputID === scope.active.inboxID)
|
|
2340
|
+
scope.active.compactionStarted = true;
|
|
2103
2341
|
scope.busy = true;
|
|
2104
2342
|
clear(scope, "timer");
|
|
2105
2343
|
return result({ reason: "compacting" });
|
|
@@ -2160,10 +2398,21 @@ ${await policy.buildPrompt(scope.directory, job)}`, delivery: "queue", metadata:
|
|
|
2160
2398
|
return finish(scope);
|
|
2161
2399
|
}
|
|
2162
2400
|
if (event.kind === "session" && ["error", "failed", "interrupted"].includes(event.action) || event.kind === "compaction" && event.action === "failed") {
|
|
2401
|
+
const run = scope.active;
|
|
2402
|
+
if (event.kind === "compaction" && event.action === "failed") {
|
|
2403
|
+
const compactRun = ["compact", "cadence"].includes(run?.kind);
|
|
2404
|
+
const owned = compactRun && event.reason === "manual" && (event.inputID && event.inputID === run.inboxID || !event.inputID && run.compactionStarted);
|
|
2405
|
+
if (!run || compactRun && !owned) {
|
|
2406
|
+
delete scope.compaction;
|
|
2407
|
+
scope.busy = false;
|
|
2408
|
+
return result({ reason: "unowned-compaction-failed" });
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2163
2411
|
delete scope.compaction;
|
|
2164
2412
|
scope.busy = false;
|
|
2165
|
-
const
|
|
2166
|
-
const
|
|
2413
|
+
const detail = String(event.error || "").replace(/\s+/g, " ").trim().slice(0, 160);
|
|
2414
|
+
const reason = event.kind === "compaction" ? `compaction-failed${detail ? `: ${detail}` : ""}` : event.reason || `${event.kind}-${event.action}`;
|
|
2415
|
+
const paused = await pauseActive(scope, reason);
|
|
2167
2416
|
if (run && (run.delivered || ["compact", "cadence", "shell"].includes(run.kind))) {
|
|
2168
2417
|
const state = await read(scope);
|
|
2169
2418
|
const job = (state.jobs || []).find((entry) => entry.id === run.jobID);
|
|
@@ -2179,7 +2428,7 @@ ${await policy.buildPrompt(scope.directory, job)}`, delivery: "queue", metadata:
|
|
|
2179
2428
|
if (event.kind === "compaction" && event.action === "ended") {
|
|
2180
2429
|
scope.compaction ||= { ended: false, terminal: false };
|
|
2181
2430
|
scope.compaction.ended = true;
|
|
2182
|
-
if (["compact", "cadence"].includes(scope.active?.kind))
|
|
2431
|
+
if (["compact", "cadence"].includes(scope.active?.kind) && scope.active.compactionStarted && event.reason === "manual")
|
|
2183
2432
|
scope.active.compactFinished = true;
|
|
2184
2433
|
if (!scope.compaction.terminal)
|
|
2185
2434
|
return result({ reason: "awaiting-execution-terminal" });
|
|
@@ -2663,7 +2912,7 @@ function normalizeOpenCode2NativeEvent(raw) {
|
|
|
2663
2912
|
if (["session.compaction.started", "session.compaction.ended", "session.compaction.failed"].includes(type)) {
|
|
2664
2913
|
if (!sessionID)
|
|
2665
2914
|
return;
|
|
2666
|
-
return Object.freeze({ kind: "compaction", action: type.split(".").at(-1), sessionID, directory });
|
|
2915
|
+
return Object.freeze({ kind: "compaction", action: type.split(".").at(-1), sessionID, directory, reason: text2(data.reason), inputID: text2(data.inputID), error: text2(record2(data.error)?.message) });
|
|
2667
2916
|
}
|
|
2668
2917
|
if (["session.execution.failed", "session.execution.interrupted"].includes(type)) {
|
|
2669
2918
|
if (!sessionID)
|
|
@@ -2964,7 +3213,7 @@ Presets: /loop-dev, /loop-testfix, /loop-progress, /loop-safe-dev, /loop-ask, /l
|
|
|
2964
3213
|
Controls: /loop-now, /loop-pause, /loop-resume, /loop-stop, /loop-remove, /loop-clear, /loop-status, /loop-export.
|
|
2965
3214
|
Preflight, postrun, notifications, stop files, completion markers, runtime/failure/run limits, branches and checkpoints are supported.
|
|
2966
3215
|
Scheduled shell commands run as bounded local child processes managed by Loop; OpenCode 2's ctx.shell surface is a hook API, not a shell-execution method.
|
|
2967
|
-
|
|
3216
|
+
Manual /loop-compact, --compact and --compact-every use public session.compact when the V2 host exposes it. Loop matches the admitted manual compaction inputID and waits for host completion; older hosts without this capability reject the job before creation. Automatic compaction stays host-owned.
|
|
2968
3217
|
An unfinished dedicated Goal reserves its session; Loop will not override it.
|
|
2969
3218
|
--timeout pauses future iterations without aborting the current native model/tool/compaction operation.
|
|
2970
3219
|
--safe is a command heuristic, not a sandbox. --dry-run does not dispatch or run hooks.
|
|
@@ -3010,7 +3259,7 @@ var OpenCodeLoopNativePlugin = Object.freeze({
|
|
|
3010
3259
|
prompt,
|
|
3011
3260
|
command: typeof ctx.session.command === "function" ? (request) => ctx.session.command(request) : undefined,
|
|
3012
3261
|
wait: typeof ctx.session.wait === "function" ? (request) => ctx.session.wait(request) : undefined,
|
|
3013
|
-
compact: undefined,
|
|
3262
|
+
compact: typeof ctx.session.compact === "function" ? (request) => ctx.session.compact(request) : undefined,
|
|
3014
3263
|
shell: (request) => shellHost.dispatch(request),
|
|
3015
3264
|
cancel: undefined,
|
|
3016
3265
|
onError: (error) => {
|