@bojackduy/opencode-loopd 1.8.1 → 1.8.3
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/README.md +4 -4
- package/commands/goal.md +1 -1
- package/dist/server.js +89 -115
- package/dist/tui.js +7 -7
- package/package.json +2 -2
- package/scripts/install-node.mjs +87 -45
- package/skills/loopd/SKILL.md +2 -2
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|

|
|
13
13
|
|
|
14
|
-
*Modal dashboard (`/loop` / `<leader>
|
|
14
|
+
*Modal dashboard (`/loop` / `<leader>o`): zero chat pollution — keys are trapped inside the dialog, NORMAL vs INSERT modes, vivid per-status coloring.*
|
|
15
15
|
|
|
16
16
|
## Why opencode-loopd
|
|
17
17
|
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
- **Parent ↔ child visibility** — `list/inspect/read_transcript/send_input` give the parent full observability. Bidirectional inbox lets you steer mid-run.
|
|
21
21
|
- **Safe by default** — per-goal artifact isolation (`.opencode/loopd/goals/<id>/`), `maxTurns`/`maxFailures`/`maxNoProgress`, force-finish → semantic `complete_goal` summary → parent notification via wake-up injection.
|
|
22
22
|
- **Scheduled intervals** — `scheduleEveryMs`/`scheduleMaxRuns` auto-requeues the same goal every N ms (e.g., `10s` monitor, `1h` report) without manual `/goal` spam — `5s` poll, `skip-if-running`, `workspaceWrite` serialization, inbox `Scheduled tick N/M`.
|
|
23
|
-
- **Modal TUI dashboard** — `<leader>
|
|
23
|
+
- **Modal TUI dashboard** — `<leader>o` or `/loop` opens a focused dialog (no leak to chat prompt). Vim-style navigation, live running indicator, per-status borders.
|
|
24
24
|
|
|
25
25
|
Keywords: `opencode` `opencode-plugin` `background-agent` `autonomous` `subagent` `loop` `goal` `tui` `codex` `claude-code` `worker`
|
|
26
26
|
|
|
@@ -50,7 +50,7 @@ Add the package to **both** configs.
|
|
|
50
50
|
}
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
Then restart OpenCode. Verify with `/loop` (palette → Loop Dashboard) or `<leader>
|
|
53
|
+
Then restart OpenCode. Verify with `/loop` (palette → Loop Dashboard) or `<leader>o`.
|
|
54
54
|
|
|
55
55
|
For a local checkout:
|
|
56
56
|
|
|
@@ -142,7 +142,7 @@ The agent will clarify (what/where/how to verify) and then call `loopd_create_go
|
|
|
142
142
|
|
|
143
143
|
### 2. Monitor with dashboard — `/loop`
|
|
144
144
|
|
|
145
|
-
Press **`<leader>
|
|
145
|
+
Press **`<leader>o`** or open the command palette → **"Loop Dashboard"** (also `/loop`).
|
|
146
146
|
|
|
147
147
|
Dashboard (NORMAL / INSERT `:`):
|
|
148
148
|
|
package/commands/goal.md
CHANGED
|
@@ -20,6 +20,6 @@ When you have enough to write a concrete contract:
|
|
|
20
20
|
- `checkCwd` — optional directory where `checks` run; writers default to project root, artifact-only jobs default to their `artifactDir`
|
|
21
21
|
- `progressFile` — optional path to a markdown progress file (defaults to `<artifactDir>/progress.md`)
|
|
22
22
|
- limits — optional `maxTurns` (default 50), `maxNoProgress`, `maxFailures`, `compactEvery`, `timeoutMs`
|
|
23
|
-
2. After it returns (`ok:true` with `goalID`/`artifactDir`/`defaultsApplied`, or `ok:false` with `errorCode: "missing_agent"|"missing_checks"|"already active"`), tell the user the goal is running in the background and they can monitor it with `/loop` (or <leader>
|
|
23
|
+
2. After it returns (`ok:true` with `goalID`/`artifactDir`/`defaultsApplied`, or `ok:false` with `errorCode: "missing_agent"|"missing_checks"|"already active"`), tell the user the goal is running in the background and they can monitor it with `/loop` (or <leader>o). On `missing_*`, explain the contract violation and ask for the missing piece; on `already active`, tell them to `pause`/`clear` the current writer or use `workspaceWrite:false`.
|
|
24
24
|
|
|
25
25
|
Important: the worker session runs autonomously — do not try to do the goal's work in this chat. This chat only creates the goal. Completion is host-judged: if `checks` fail, the worker will see `HOST VERDICT: COMPLETION REJECTED` with exact `stderr` and must fix the behavior (not just rewrite evidence) before retrying.
|
package/dist/server.js
CHANGED
|
@@ -1,34 +1,5 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
var __defProp = Object.defineProperty;
|
|
3
|
-
var __returnValue = (v) => v;
|
|
4
|
-
function __exportSetter(name, newValue) {
|
|
5
|
-
this[name] = __returnValue.bind(null, newValue);
|
|
6
|
-
}
|
|
7
|
-
var __export = (target, all) => {
|
|
8
|
-
for (var name in all)
|
|
9
|
-
__defProp(target, name, {
|
|
10
|
-
get: all[name],
|
|
11
|
-
enumerable: true,
|
|
12
|
-
configurable: true,
|
|
13
|
-
set: __exportSetter.bind(all, name)
|
|
14
|
-
});
|
|
15
|
-
};
|
|
16
|
-
|
|
17
2
|
// src/domain/runtime.ts
|
|
18
|
-
var exports_runtime = {};
|
|
19
|
-
__export(exports_runtime, {
|
|
20
|
-
acquireLease: () => acquireLease,
|
|
21
|
-
addToolCall: () => addToolCall,
|
|
22
|
-
createRuntimeState: () => createRuntimeState,
|
|
23
|
-
hasActiveToolCalls: () => hasActiveToolCalls,
|
|
24
|
-
leaseIsValid: () => leaseIsValid,
|
|
25
|
-
markParentNotified: () => markParentNotified,
|
|
26
|
-
markProgress: () => markProgress,
|
|
27
|
-
recordActivity: () => recordActivity,
|
|
28
|
-
releaseLease: () => releaseLease,
|
|
29
|
-
removeToolCall: () => removeToolCall,
|
|
30
|
-
shouldNotifyParent: () => shouldNotifyParent
|
|
31
|
-
});
|
|
32
3
|
function createRuntimeState(goalID) {
|
|
33
4
|
const now = new Date().toISOString();
|
|
34
5
|
return {
|
|
@@ -135,9 +106,6 @@ function removeToolCall(rt, callID) {
|
|
|
135
106
|
updatedAt: new Date().toISOString()
|
|
136
107
|
};
|
|
137
108
|
}
|
|
138
|
-
function hasActiveToolCalls(rt) {
|
|
139
|
-
return (rt.activeToolCallIDs?.length ?? 0) > 0;
|
|
140
|
-
}
|
|
141
109
|
var PARENT_NOTIFY_DEDUPE_MS = 60000;
|
|
142
110
|
|
|
143
111
|
// src/application/control-worker.ts
|
|
@@ -176,8 +144,8 @@ async function acquireLock(directory, key, operation) {
|
|
|
176
144
|
try {
|
|
177
145
|
try {
|
|
178
146
|
const raw = await fs.readFile(lockPath, "utf8");
|
|
179
|
-
const
|
|
180
|
-
const age = Date.now() - Date.parse(
|
|
147
|
+
const meta = JSON.parse(raw);
|
|
148
|
+
const age = Date.now() - Date.parse(meta.acquiredAt);
|
|
181
149
|
if (age > LOCK_STALE_MS) {
|
|
182
150
|
await fs.rm(lockPath, { force: true });
|
|
183
151
|
}
|
|
@@ -712,54 +680,54 @@ function createControlWorker(options) {
|
|
|
712
680
|
ownerSessionID: args.ownerSessionID,
|
|
713
681
|
config: resolution.config
|
|
714
682
|
});
|
|
715
|
-
const
|
|
683
|
+
const state = await readState(directory);
|
|
716
684
|
response = {
|
|
717
685
|
...base,
|
|
718
686
|
message: `goal "${args.name}" created (${goal.id.slice(0, 8)}...)`,
|
|
719
|
-
stateRevision:
|
|
687
|
+
stateRevision: state.revision
|
|
720
688
|
};
|
|
721
689
|
break;
|
|
722
690
|
}
|
|
723
691
|
case "pause": {
|
|
724
692
|
await goalSvc.pause(directory, request.goalID);
|
|
725
|
-
const
|
|
726
|
-
const goal =
|
|
693
|
+
const state = await readState(directory);
|
|
694
|
+
const goal = state.goals.find((g) => g.id === request.goalID);
|
|
727
695
|
response = {
|
|
728
696
|
...base,
|
|
729
697
|
message: `goal "${goal?.name || request.goalID}" paused`,
|
|
730
|
-
stateRevision:
|
|
698
|
+
stateRevision: state.revision
|
|
731
699
|
};
|
|
732
700
|
break;
|
|
733
701
|
}
|
|
734
702
|
case "resume": {
|
|
735
703
|
await goalSvc.resume(directory, request.goalID);
|
|
736
|
-
const
|
|
737
|
-
const goal =
|
|
704
|
+
const state = await readState(directory);
|
|
705
|
+
const goal = state.goals.find((g) => g.id === request.goalID);
|
|
738
706
|
response = {
|
|
739
707
|
...base,
|
|
740
708
|
message: `goal "${goal?.name || request.goalID}" resumed`,
|
|
741
|
-
stateRevision:
|
|
709
|
+
stateRevision: state.revision
|
|
742
710
|
};
|
|
743
711
|
break;
|
|
744
712
|
}
|
|
745
713
|
case "retry": {
|
|
746
714
|
await goalSvc.retry(directory, request.goalID);
|
|
747
|
-
const
|
|
748
|
-
const goal =
|
|
715
|
+
const state = await readState(directory);
|
|
716
|
+
const goal = state.goals.find((g) => g.id === request.goalID);
|
|
749
717
|
response = {
|
|
750
718
|
...base,
|
|
751
719
|
message: `goal "${goal?.name || request.goalID}" retried`,
|
|
752
|
-
stateRevision:
|
|
720
|
+
stateRevision: state.revision
|
|
753
721
|
};
|
|
754
722
|
break;
|
|
755
723
|
}
|
|
756
724
|
case "clear": {
|
|
757
725
|
await goalSvc.clear(directory, request.goalID);
|
|
758
|
-
const
|
|
726
|
+
const state = await readState(directory);
|
|
759
727
|
response = {
|
|
760
728
|
...base,
|
|
761
729
|
message: `goal cleared`,
|
|
762
|
-
stateRevision:
|
|
730
|
+
stateRevision: state.revision
|
|
763
731
|
};
|
|
764
732
|
break;
|
|
765
733
|
}
|
|
@@ -775,25 +743,25 @@ function createControlWorker(options) {
|
|
|
775
743
|
break;
|
|
776
744
|
}
|
|
777
745
|
await appendGoalInbox(directory, request.goalID, "user", text);
|
|
778
|
-
const
|
|
779
|
-
const goal =
|
|
746
|
+
const state = await readState(directory);
|
|
747
|
+
const goal = state.goals.find((g) => g.id === request.goalID);
|
|
780
748
|
response = {
|
|
781
749
|
...base,
|
|
782
750
|
message: `sent to "${goal?.name || request.goalID}"`,
|
|
783
|
-
stateRevision:
|
|
751
|
+
stateRevision: state.revision
|
|
784
752
|
};
|
|
785
753
|
break;
|
|
786
754
|
}
|
|
787
755
|
case "force_complete": {
|
|
788
756
|
const args = request.args;
|
|
789
|
-
const
|
|
790
|
-
const goal =
|
|
757
|
+
const state = await readState(directory);
|
|
758
|
+
const goal = state.goals.find((g) => g.id === request.goalID);
|
|
791
759
|
if (!goal) {
|
|
792
760
|
response = { ...base, ok: false, message: "goal not found", errorCode: "not_found" };
|
|
793
761
|
break;
|
|
794
762
|
}
|
|
795
763
|
if (goal.status === "complete") {
|
|
796
|
-
response = { ...base, message: `goal "${goal.name}" already complete`, stateRevision:
|
|
764
|
+
response = { ...base, message: `goal "${goal.name}" already complete`, stateRevision: state.revision };
|
|
797
765
|
break;
|
|
798
766
|
}
|
|
799
767
|
goal.status = "complete";
|
|
@@ -803,14 +771,14 @@ function createControlWorker(options) {
|
|
|
803
771
|
evidence: String(args.evidence || "Manual override \u2014 no verification checks run."),
|
|
804
772
|
at: new Date().toISOString()
|
|
805
773
|
};
|
|
806
|
-
const runtime =
|
|
774
|
+
const runtime = state.runtimes.find((r) => r.goalID === goal.id);
|
|
807
775
|
if (runtime) {
|
|
808
776
|
Object.assign(runtime, releaseLease(runtime));
|
|
809
777
|
runtime.activeRunID = undefined;
|
|
810
778
|
runtime.lastError = undefined;
|
|
811
779
|
runtime.updatedAt = new Date().toISOString();
|
|
812
780
|
}
|
|
813
|
-
await writeState(directory,
|
|
781
|
+
await writeState(directory, state);
|
|
814
782
|
await appendEvent(directory, {
|
|
815
783
|
version: 1,
|
|
816
784
|
eventID: randomUUID(),
|
|
@@ -819,22 +787,22 @@ function createControlWorker(options) {
|
|
|
819
787
|
summary: goal.completionEvidence.summary,
|
|
820
788
|
evidence: goal.completionEvidence.evidence,
|
|
821
789
|
timestamp: new Date().toISOString(),
|
|
822
|
-
revision:
|
|
790
|
+
revision: state.revision
|
|
823
791
|
});
|
|
824
|
-
response = { ...base, message: `goal "${goal.name}" force-completed`, stateRevision:
|
|
792
|
+
response = { ...base, message: `goal "${goal.name}" force-completed`, stateRevision: state.revision };
|
|
825
793
|
break;
|
|
826
794
|
}
|
|
827
795
|
case "force_block":
|
|
828
796
|
case "block": {
|
|
829
797
|
const args = request.args;
|
|
830
|
-
const
|
|
831
|
-
const goal =
|
|
798
|
+
const state = await readState(directory);
|
|
799
|
+
const goal = state.goals.find((g) => g.id === request.goalID);
|
|
832
800
|
if (!goal) {
|
|
833
801
|
response = { ...base, ok: false, message: "goal not found", errorCode: "not_found" };
|
|
834
802
|
break;
|
|
835
803
|
}
|
|
836
804
|
if (goal.status === "blocked") {
|
|
837
|
-
response = { ...base, message: `goal "${goal.name}" already blocked`, stateRevision:
|
|
805
|
+
response = { ...base, message: `goal "${goal.name}" already blocked`, stateRevision: state.revision };
|
|
838
806
|
break;
|
|
839
807
|
}
|
|
840
808
|
goal.status = "blocked";
|
|
@@ -844,14 +812,14 @@ function createControlWorker(options) {
|
|
|
844
812
|
needed: String(args.needed || "User intervention required."),
|
|
845
813
|
at: new Date().toISOString()
|
|
846
814
|
};
|
|
847
|
-
const runtime =
|
|
815
|
+
const runtime = state.runtimes.find((r) => r.goalID === goal.id);
|
|
848
816
|
if (runtime) {
|
|
849
817
|
Object.assign(runtime, releaseLease(runtime));
|
|
850
818
|
runtime.activeRunID = undefined;
|
|
851
819
|
runtime.lastError = undefined;
|
|
852
820
|
runtime.updatedAt = new Date().toISOString();
|
|
853
821
|
}
|
|
854
|
-
await writeState(directory,
|
|
822
|
+
await writeState(directory, state);
|
|
855
823
|
await appendEvent(directory, {
|
|
856
824
|
version: 1,
|
|
857
825
|
eventID: randomUUID(),
|
|
@@ -860,9 +828,9 @@ function createControlWorker(options) {
|
|
|
860
828
|
reason: goal.blocker.reason,
|
|
861
829
|
needed: goal.blocker.needed,
|
|
862
830
|
timestamp: new Date().toISOString(),
|
|
863
|
-
revision:
|
|
831
|
+
revision: state.revision
|
|
864
832
|
});
|
|
865
|
-
response = { ...base, message: `goal "${goal.name}" blocked`, stateRevision:
|
|
833
|
+
response = { ...base, message: `goal "${goal.name}" blocked`, stateRevision: state.revision };
|
|
866
834
|
break;
|
|
867
835
|
}
|
|
868
836
|
default: {
|
|
@@ -878,8 +846,8 @@ function createControlWorker(options) {
|
|
|
878
846
|
await recordInLedger(directory, request);
|
|
879
847
|
return response;
|
|
880
848
|
}
|
|
881
|
-
async function recordInLedger(
|
|
882
|
-
const state = await readState(
|
|
849
|
+
async function recordInLedger(directory, request) {
|
|
850
|
+
const state = await readState(directory);
|
|
883
851
|
if (!state.commandLedger)
|
|
884
852
|
state.commandLedger = [];
|
|
885
853
|
state.commandLedger.push({
|
|
@@ -892,7 +860,7 @@ function createControlWorker(options) {
|
|
|
892
860
|
if (state.commandLedger.length > MAX_LEDGER_SIZE) {
|
|
893
861
|
state.commandLedger = state.commandLedger.slice(-MAX_LEDGER_SIZE);
|
|
894
862
|
}
|
|
895
|
-
await writeState(
|
|
863
|
+
await writeState(directory, state);
|
|
896
864
|
}
|
|
897
865
|
return { start, stop: async () => {
|
|
898
866
|
await stop();
|
|
@@ -1879,9 +1847,9 @@ function createGoalService(host) {
|
|
|
1879
1847
|
rt.lastScheduleAt = undefined;
|
|
1880
1848
|
}
|
|
1881
1849
|
state.runtimes.push(rt);
|
|
1882
|
-
const
|
|
1883
|
-
if (
|
|
1884
|
-
|
|
1850
|
+
const runtime = state.runtimes.find((r) => r.goalID === id);
|
|
1851
|
+
if (runtime)
|
|
1852
|
+
runtime.phase = "queued";
|
|
1885
1853
|
return state;
|
|
1886
1854
|
});
|
|
1887
1855
|
let runtime = state1.runtimes.find((r) => r.goalID === id);
|
|
@@ -2122,17 +2090,17 @@ function createGoalService(host) {
|
|
|
2122
2090
|
}
|
|
2123
2091
|
async function resumeUnlocked(directory, goalID) {
|
|
2124
2092
|
let resumed = false;
|
|
2125
|
-
const state = await mutateState(directory, `goal.resume:${goalID}`, async (
|
|
2126
|
-
const
|
|
2127
|
-
if (!
|
|
2128
|
-
return
|
|
2129
|
-
if (!canTransition(
|
|
2130
|
-
return
|
|
2131
|
-
assertWorkspaceWriteAvailable(
|
|
2132
|
-
|
|
2133
|
-
|
|
2093
|
+
const state = await mutateState(directory, `goal.resume:${goalID}`, async (state) => {
|
|
2094
|
+
const goal = state.goals.find((g) => g.id === goalID);
|
|
2095
|
+
if (!goal)
|
|
2096
|
+
return state;
|
|
2097
|
+
if (!canTransition(goal.status, "active", "user"))
|
|
2098
|
+
return state;
|
|
2099
|
+
assertWorkspaceWriteAvailable(state, goal);
|
|
2100
|
+
goal.status = "active";
|
|
2101
|
+
goal.updatedAt = new Date().toISOString();
|
|
2134
2102
|
resumed = true;
|
|
2135
|
-
return
|
|
2103
|
+
return state;
|
|
2136
2104
|
});
|
|
2137
2105
|
const goal = state.goals.find((g) => g.id === goalID);
|
|
2138
2106
|
if (!goal || !resumed)
|
|
@@ -2162,15 +2130,15 @@ function createGoalService(host) {
|
|
|
2162
2130
|
}
|
|
2163
2131
|
async function retryUnlocked(directory, goalID) {
|
|
2164
2132
|
let retried = false;
|
|
2165
|
-
const state = await mutateState(directory, `goal.retry:${goalID}`, async (
|
|
2166
|
-
const
|
|
2167
|
-
if (!
|
|
2168
|
-
return
|
|
2169
|
-
assertWorkspaceWriteAvailable(
|
|
2170
|
-
|
|
2171
|
-
|
|
2133
|
+
const state = await mutateState(directory, `goal.retry:${goalID}`, async (state) => {
|
|
2134
|
+
const goal = state.goals.find((g) => g.id === goalID);
|
|
2135
|
+
if (!goal || goal.status !== "blocked")
|
|
2136
|
+
return state;
|
|
2137
|
+
assertWorkspaceWriteAvailable(state, goal);
|
|
2138
|
+
goal.status = "active";
|
|
2139
|
+
goal.updatedAt = new Date().toISOString();
|
|
2172
2140
|
retried = true;
|
|
2173
|
-
const runtime =
|
|
2141
|
+
const runtime = state.runtimes.find((r) => r.goalID === goalID);
|
|
2174
2142
|
if (runtime) {
|
|
2175
2143
|
runtime.consecutiveFailures = 0;
|
|
2176
2144
|
runtime.lastError = undefined;
|
|
@@ -2180,7 +2148,7 @@ function createGoalService(host) {
|
|
|
2180
2148
|
runtime.phase = "idle";
|
|
2181
2149
|
runtime.updatedAt = new Date().toISOString();
|
|
2182
2150
|
}
|
|
2183
|
-
return
|
|
2151
|
+
return state;
|
|
2184
2152
|
});
|
|
2185
2153
|
const goal = state.goals.find((g) => g.id === goalID);
|
|
2186
2154
|
if (!goal || !retried)
|
|
@@ -2541,16 +2509,22 @@ function createRealHost(client, directory) {
|
|
|
2541
2509
|
async sessionStatus(sessionID) {
|
|
2542
2510
|
try {
|
|
2543
2511
|
const result = await client.session.status({});
|
|
2512
|
+
if (result?.error)
|
|
2513
|
+
return "unknown";
|
|
2544
2514
|
const data = result?.data;
|
|
2545
|
-
if (!data || typeof data !== "object")
|
|
2515
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
2546
2516
|
return "unknown";
|
|
2547
2517
|
const status = data[sessionID];
|
|
2548
|
-
if (
|
|
2518
|
+
if (status === undefined || status === null)
|
|
2519
|
+
return "idle";
|
|
2520
|
+
if (typeof status !== "object" || Array.isArray(status))
|
|
2549
2521
|
return "unknown";
|
|
2550
2522
|
const type = status.type;
|
|
2551
2523
|
if (type === "busy" || type === "retry")
|
|
2552
2524
|
return type;
|
|
2553
|
-
|
|
2525
|
+
if (type === "idle")
|
|
2526
|
+
return "idle";
|
|
2527
|
+
return "unknown";
|
|
2554
2528
|
} catch {
|
|
2555
2529
|
return "unknown";
|
|
2556
2530
|
}
|
|
@@ -2861,9 +2835,9 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
|
|
|
2861
2835
|
const cwd = goal.config.checkCwd || goal.config.artifactDir || dir;
|
|
2862
2836
|
const checkResults = await runCompletionChecks(goal.config.checks, cwd);
|
|
2863
2837
|
if (!checkResults.passed) {
|
|
2864
|
-
const
|
|
2865
|
-
if (
|
|
2866
|
-
|
|
2838
|
+
const runtime = state.runtimes.find((r) => r.goalID === goal.id);
|
|
2839
|
+
if (runtime) {
|
|
2840
|
+
runtime.evaluatorRejectionCount = (runtime.evaluatorRejectionCount || 0) + 1;
|
|
2867
2841
|
const failureDetails = checkResults.failures.map((f) => {
|
|
2868
2842
|
const stdoutSnippet = f.stdout ? `
|
|
2869
2843
|
Stdout: ${f.stdout.slice(0, 500)}` : "";
|
|
@@ -2874,7 +2848,7 @@ Exit code: ${f.exitCode}${stdoutSnippet}${stderrSnippet}`;
|
|
|
2874
2848
|
}).join(`
|
|
2875
2849
|
|
|
2876
2850
|
`);
|
|
2877
|
-
|
|
2851
|
+
runtime.lastRejectionDetails = `Rejection #${runtime.evaluatorRejectionCount} at ${new Date().toISOString()}
|
|
2878
2852
|
|
|
2879
2853
|
Working directory: ${cwd}
|
|
2880
2854
|
|
|
@@ -2882,8 +2856,8 @@ ${failureDetails}`;
|
|
|
2882
2856
|
const attemptID = randomUUID5();
|
|
2883
2857
|
const verificationAttempt = {
|
|
2884
2858
|
id: attemptID,
|
|
2885
|
-
sequence:
|
|
2886
|
-
runGeneration:
|
|
2859
|
+
sequence: runtime.evaluatorRejectionCount,
|
|
2860
|
+
runGeneration: runtime.runGeneration,
|
|
2887
2861
|
claimedSummary: args.summary,
|
|
2888
2862
|
claimedEvidence: args.evidence,
|
|
2889
2863
|
startedAt: new Date().toISOString(),
|
|
@@ -2897,15 +2871,15 @@ ${failureDetails}`;
|
|
|
2897
2871
|
stdout: f.stdout
|
|
2898
2872
|
}))
|
|
2899
2873
|
};
|
|
2900
|
-
|
|
2901
|
-
|
|
2874
|
+
runtime.lastVerificationAttempt = verificationAttempt;
|
|
2875
|
+
runtime.recentVerificationAttempts = appendVerificationAttempt(runtime.recentVerificationAttempts || [], verificationAttempt);
|
|
2902
2876
|
const rejectEvent = {
|
|
2903
2877
|
version: 1,
|
|
2904
2878
|
eventID: randomUUID5(),
|
|
2905
2879
|
goalID: goal.id,
|
|
2906
2880
|
type: "goal.completion_rejected",
|
|
2907
2881
|
attemptID,
|
|
2908
|
-
rejectionCount:
|
|
2882
|
+
rejectionCount: runtime.evaluatorRejectionCount,
|
|
2909
2883
|
failedCheckCount: checkResults.failures.length,
|
|
2910
2884
|
failureSummary: failureDetails.slice(0, 500),
|
|
2911
2885
|
timestamp: new Date().toISOString(),
|
|
@@ -2913,16 +2887,16 @@ ${failureDetails}`;
|
|
|
2913
2887
|
};
|
|
2914
2888
|
await appendEvent(dir, rejectEvent);
|
|
2915
2889
|
const maxRejections = goal.config.maxEvaluatorRejections || 3;
|
|
2916
|
-
if (
|
|
2890
|
+
if (runtime.evaluatorRejectionCount >= maxRejections) {
|
|
2917
2891
|
goal.status = "blocked";
|
|
2918
2892
|
goal.updatedAt = new Date().toISOString();
|
|
2919
2893
|
goal.blocker = {
|
|
2920
|
-
reason: `Evaluator rejected ${
|
|
2894
|
+
reason: `Evaluator rejected ${runtime.evaluatorRejectionCount} time(s). Last failure:
|
|
2921
2895
|
${failureDetails.slice(0, 500)}`,
|
|
2922
2896
|
needed: "Fix the failing checks and retry the goal.",
|
|
2923
2897
|
at: new Date().toISOString()
|
|
2924
2898
|
};
|
|
2925
|
-
|
|
2899
|
+
runtime.forceFinishRequested = undefined;
|
|
2926
2900
|
await appendEvent(dir, {
|
|
2927
2901
|
version: 1,
|
|
2928
2902
|
eventID: randomUUID5(),
|
|
@@ -2934,10 +2908,10 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
2934
2908
|
revision: state.revision
|
|
2935
2909
|
});
|
|
2936
2910
|
} else {
|
|
2937
|
-
|
|
2938
|
-
|
|
2911
|
+
runtime.forceFinishRequested = false;
|
|
2912
|
+
runtime.freeRetryPending = true;
|
|
2939
2913
|
}
|
|
2940
|
-
|
|
2914
|
+
runtime.updatedAt = new Date().toISOString();
|
|
2941
2915
|
await writeState(dir, state);
|
|
2942
2916
|
}
|
|
2943
2917
|
return {
|
|
@@ -2946,7 +2920,7 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
2946
2920
|
passed: false,
|
|
2947
2921
|
failedChecks: checkResults.failures,
|
|
2948
2922
|
message: "Evaluator rejected completion. Fix the issues above and try again.",
|
|
2949
|
-
rejectionCount:
|
|
2923
|
+
rejectionCount: runtime?.evaluatorRejectionCount || 0,
|
|
2950
2924
|
status: goal.status
|
|
2951
2925
|
})
|
|
2952
2926
|
};
|
|
@@ -3637,8 +3611,8 @@ var server = async ({ client, directory }, pluginOptions) => {
|
|
|
3637
3611
|
"tool.execute.before": async (input, _output) => {
|
|
3638
3612
|
const activeWorkers = goalService.getActiveWorkers();
|
|
3639
3613
|
let matchedGoalID;
|
|
3640
|
-
for (const [goalID,
|
|
3641
|
-
if (
|
|
3614
|
+
for (const [goalID, worker] of activeWorkers) {
|
|
3615
|
+
if (worker.workerSessionID === input.sessionID) {
|
|
3642
3616
|
matchedGoalID = goalID;
|
|
3643
3617
|
break;
|
|
3644
3618
|
}
|
|
@@ -3661,8 +3635,8 @@ var server = async ({ client, directory }, pluginOptions) => {
|
|
|
3661
3635
|
}
|
|
3662
3636
|
const activeWorkers = goalService.getActiveWorkers();
|
|
3663
3637
|
let matchedGoalID;
|
|
3664
|
-
for (const [goalID,
|
|
3665
|
-
if (
|
|
3638
|
+
for (const [goalID, worker] of activeWorkers) {
|
|
3639
|
+
if (worker.workerSessionID === input.sessionID) {
|
|
3666
3640
|
matchedGoalID = goalID;
|
|
3667
3641
|
break;
|
|
3668
3642
|
}
|
|
@@ -3688,20 +3662,20 @@ var server = async ({ client, directory }, pluginOptions) => {
|
|
|
3688
3662
|
const goalID = parsed.goalID;
|
|
3689
3663
|
if (!goalID)
|
|
3690
3664
|
return;
|
|
3691
|
-
|
|
3665
|
+
await Promise.resolve();
|
|
3692
3666
|
const state = await readState(directory);
|
|
3693
3667
|
const goal = state.goals.find((g) => g.id === goalID);
|
|
3694
3668
|
if (!goal)
|
|
3695
3669
|
return;
|
|
3696
3670
|
const runtime = state.runtimes.find((r) => r.goalID === goalID);
|
|
3697
3671
|
const notifyType = parsed.status === "complete" ? "complete" : "blocked";
|
|
3698
|
-
if (runtime && !
|
|
3672
|
+
if (runtime && !shouldNotifyParent(runtime, notifyType))
|
|
3699
3673
|
return;
|
|
3700
3674
|
if (runtime) {
|
|
3701
3675
|
await mutateState(directory, `notify-parent:${goalID}`, async (s) => {
|
|
3702
3676
|
const rt = s.runtimes.find((r) => r.goalID === goalID);
|
|
3703
3677
|
if (rt)
|
|
3704
|
-
|
|
3678
|
+
markParentNotified(rt, notifyType);
|
|
3705
3679
|
return s;
|
|
3706
3680
|
});
|
|
3707
3681
|
}
|
package/dist/tui.js
CHANGED
|
@@ -574,10 +574,10 @@ function LoopDashboard(props) {
|
|
|
574
574
|
try {
|
|
575
575
|
const s = await client.getState();
|
|
576
576
|
setState(s);
|
|
577
|
-
const
|
|
578
|
-
if (
|
|
579
|
-
setSelected(
|
|
580
|
-
setSelectedGoal(
|
|
577
|
+
const goals = s.goals.filter((g) => showCompleted() || g.status !== "complete");
|
|
578
|
+
if (goals.length > 0 && selected() >= goals.length)
|
|
579
|
+
setSelected(goals.length - 1);
|
|
580
|
+
setSelectedGoal(goals[selected()] || null);
|
|
581
581
|
setEvents(await client.getEvents(20));
|
|
582
582
|
} catch (e) {
|
|
583
583
|
setStatusText(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -1077,7 +1077,7 @@ function LoopDashboard(props) {
|
|
|
1077
1077
|
children: (seg, idx) => {
|
|
1078
1078
|
const hasArrow = seg.includes("\u2192");
|
|
1079
1079
|
if (hasArrow) {
|
|
1080
|
-
const [
|
|
1080
|
+
const [k, d] = seg.split("\u2192").map((s) => s.trim());
|
|
1081
1081
|
return [_$memo(() => _$memo(() => idx() > 0)() && (() => {
|
|
1082
1082
|
var _el$54 = _$createElement("span");
|
|
1083
1083
|
_$insertNode(_el$54, _$createTextNode(` | `));
|
|
@@ -1087,7 +1087,7 @@ function LoopDashboard(props) {
|
|
|
1087
1087
|
return _el$54;
|
|
1088
1088
|
})()), (() => {
|
|
1089
1089
|
var _el$50 = _$createElement("span");
|
|
1090
|
-
_$insert(_el$50,
|
|
1090
|
+
_$insert(_el$50, k);
|
|
1091
1091
|
_$effect((_$p) => _$setProp(_el$50, "style", {
|
|
1092
1092
|
fg: theme().warning,
|
|
1093
1093
|
bold: true
|
|
@@ -1102,7 +1102,7 @@ function LoopDashboard(props) {
|
|
|
1102
1102
|
return _el$51;
|
|
1103
1103
|
})(), (() => {
|
|
1104
1104
|
var _el$53 = _$createElement("span");
|
|
1105
|
-
_$insert(_el$53,
|
|
1105
|
+
_$insert(_el$53, d);
|
|
1106
1106
|
_$effect((_$p) => _$setProp(_el$53, "style", {
|
|
1107
1107
|
fg: theme().text
|
|
1108
1108
|
}, _$p));
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@bojackduy/opencode-loopd",
|
|
4
|
-
"version": "1.8.
|
|
5
|
-
"description": "Codex-inspired background goal engine for OpenCode
|
|
4
|
+
"version": "1.8.3",
|
|
5
|
+
"description": "Codex-inspired background goal engine for OpenCode — autonomous subagents, engine-driven loop, child worker sessions and modal TUI dashboard. Like Claude Code loop for OpenCode.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "AGPL-3.0-or-later",
|
|
8
8
|
"private": false,
|
package/scripts/install-node.mjs
CHANGED
|
@@ -6,14 +6,16 @@ import { fileURLToPath } from "node:url"
|
|
|
6
6
|
|
|
7
7
|
const root = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
8
8
|
const config = process.env.OPENCODE_CONFIG_DIR || join(homedir(), ".config", "opencode")
|
|
9
|
-
const pluginDir = join(config, "plugins")
|
|
10
9
|
const commandDir = join(config, "commands")
|
|
11
10
|
const skillDir = join(config, "skills", "loopd")
|
|
12
11
|
const packagePath = join(config, "package.json")
|
|
13
|
-
const
|
|
14
|
-
const
|
|
12
|
+
const rootPackageJson = JSON.parse(await readFile(join(root, "package.json"), "utf8"))
|
|
13
|
+
const packageName = rootPackageJson.name // "@bojackduy/opencode-loopd"
|
|
14
|
+
const legacyPackageName = "opencode-loopd" // unscoped name used by older installs; matched for cleanup/upgrade only
|
|
15
|
+
const packageVersion = rootPackageJson.version
|
|
15
16
|
const packageSpec = `${packageName}@${packageVersion}`
|
|
16
17
|
const configCandidates = ["opencode.json", "opencode.jsonc", "config.json", "config.jsonc"]
|
|
18
|
+
const tuiConfigName = "tui.json"
|
|
17
19
|
const installerArgs = process.argv.slice(2)
|
|
18
20
|
const uninstallRequested = installerArgs.length === 1 && ["--uninstall", "uninstall", "--remove"].includes(installerArgs[0] || "")
|
|
19
21
|
|
|
@@ -62,32 +64,76 @@ function parseJsonc(input) {
|
|
|
62
64
|
if (!p || typeof p !== "object" || Array.isArray(p)) throw new Error("OpenCode config root must be an object")
|
|
63
65
|
return p
|
|
64
66
|
}
|
|
65
|
-
function isPackageSpec(v) {
|
|
67
|
+
function isPackageSpec(v) {
|
|
68
|
+
const s = String(v||"").trim()
|
|
69
|
+
return s === packageName || s.startsWith(`${packageName}@`) || s === legacyPackageName || s.startsWith(`${legacyPackageName}@`)
|
|
70
|
+
}
|
|
66
71
|
function skipTrivia(s, i) { while (i < s.length) { const c=s[i]||"", n=s[i+1]||""; if (/\s/.test(c)) {i++;continue} if (c==="/"&&n==="/") {i+=2; while(i<s.length&&s[i]!=="\n"&&s[i]!=="\r") i++; continue} if (c==="/"&&n==="*") { const e=s.indexOf("*/",i+2); if(e<0) throw new Error("unterminated block comment"); i=e+2; continue } break } return i }
|
|
67
72
|
function readJsonString(s, i) { if(s[i]!=='"') throw new Error("expected JSON string"); let esc=false; for(let j=i+1;j<s.length;j++){const c=s[j]||""; if(esc){esc=false; continue} if(c==="\\"){esc=true;continue} if(c==='"'){return {value:JSON.parse(s.slice(i,j+1)), end:j+1}}} throw new Error("unterminated JSON string") }
|
|
68
73
|
function skipJsonValue(s,i){ const vs=skipTrivia(s,i), f=s[vs]; if(f==='"') return readJsonString(s,vs).end; if(f==="{"||f==="["){const st=[]; let q=false,esc=false,lc=false,bc=false; for(let j=vs;j<s.length;j++){const c=s[j]||"",n=s[j+1]||""; if(lc){if(c==="\n"||c==="\r") lc=false; continue} if(bc){if(c==="*"&&n==="/"){bc=false;j++} continue} if(q){if(esc) esc=false; else if(c==="\\") esc=true; else if(c==='"') q=false; continue} if(c==='"'){q=true;continue} if(c==="/"&&n==="/"){lc=true;j++;continue} if(c==="/"&&n==="*"){bc=true;j++;continue} if(c==="{"||c==="[") st.push(c); else if(c==="}"||c==="]"){const e=c==="}"?"{":"["; if(st.at(-1)!==e) throw new Error("mismatched delimiters"); st.pop(); if(!st.length) return j+1} } throw new Error("unterminated JSON value") } let j=vs; while(j<s.length&&![",","}","]"].includes(s[j])) j++; return j }
|
|
69
74
|
function findRootProperty(s, name){ let i=skipTrivia(s,0); if(s[i]!=="{") throw new Error("OpenCode config must be root object"); i++; while(true){ i=skipTrivia(s,i); if(s[i]==="}") return null; const k=readJsonString(s,i); i=skipTrivia(s,k.end); if(s[i]!==":") throw new Error(`expected ':' after ${k.value}`); const vs=skipTrivia(s,i+1), ve=skipJsonValue(s,vs); if(k.value===name){ const ls=Math.max(s.lastIndexOf("\n",vs-1),s.lastIndexOf("\r",vs-1))+1; const kls=Math.max(s.lastIndexOf("\n",k.end-1),s.lastIndexOf("\r",k.end-1))+1; const indent=s.slice(kls,k.end-k.value.length-2).match(/^[\t ]*/)?.[0]||" "; return {valueStart:vs,valueEnd:ve,indent,lineStart:ls} } const av=skipTrivia(s,ve); if(s[av]===",") i=av+1; else if(s[av]==="}") return null; else throw new Error(`expected ',' or '}' after ${k.value}`) } }
|
|
70
75
|
function formatPluginArray(vals, indent, eol){ if(!vals.length) return "[]"; const ci=`${indent} `; return `[${eol}${vals.map(v=>`${ci}${JSON.stringify(v)}`).join(`,${eol}`)}${eol}${indent}]` }
|
|
71
76
|
function rewriteExistingPluginArray(source, next){ const prop=findRootProperty(source,"plugin"); if(!prop) return source; const eol=source.includes("\r\n")?"\r\n":"\n"; const rep=formatPluginArray(next,prop.indent,eol); return `${source.slice(0,prop.valueStart)}${rep}${source.slice(prop.valueEnd)}` }
|
|
77
|
+
function insertPluginProperty(source, vals) {
|
|
78
|
+
const eol = source.includes("\r\n") ? "\r\n" : "\n"
|
|
79
|
+
const i = skipTrivia(source, 0)
|
|
80
|
+
if (source[i] !== "{") throw new Error("OpenCode config must be root object")
|
|
81
|
+
const openEnd = i + 1
|
|
82
|
+
const after = skipTrivia(source, openEnd)
|
|
83
|
+
const propLine = ` "plugin": ${formatPluginArray(vals, " ", eol)}`
|
|
84
|
+
if (source[after] === "}") return `${source.slice(0, openEnd)}${eol}${propLine}${eol}${source.slice(after)}`
|
|
85
|
+
return `${source.slice(0, openEnd)}${eol}${propLine},${source.slice(openEnd)}`
|
|
86
|
+
}
|
|
72
87
|
|
|
73
|
-
|
|
74
|
-
|
|
88
|
+
// Adds/updates the plugin entry in `target`, creating the file (with a minimal
|
|
89
|
+
// `{ "plugin": [...] }`) if it doesn't exist yet. Returns true if the file changed.
|
|
90
|
+
async function ensurePluginRegistered(target) {
|
|
91
|
+
let source
|
|
92
|
+
try {
|
|
93
|
+
source = await readFile(target, "utf8")
|
|
94
|
+
} catch (e) {
|
|
95
|
+
if (e?.code !== "ENOENT") throw new Error(`Could not read ${target}: ${e.message}`)
|
|
96
|
+
await mkdir(dirname(target), { recursive: true })
|
|
97
|
+
await writeFile(target, JSON.stringify({ plugin: [packageSpec] }, null, 2) + "\n", "utf8")
|
|
98
|
+
return true
|
|
99
|
+
}
|
|
100
|
+
const parsed = parseJsonc(source)
|
|
101
|
+
if (parsed.plugin !== undefined && !Array.isArray(parsed.plugin)) throw new Error(`plugin must be array in ${target}`)
|
|
102
|
+
const plugins = parsed.plugin || []
|
|
103
|
+
const next = plugins.filter(v => !isPackageSpec(v))
|
|
104
|
+
next.push(packageSpec)
|
|
105
|
+
const updated = parsed.plugin !== undefined ? rewriteExistingPluginArray(source, next) : insertPluginProperty(source, next)
|
|
106
|
+
if (updated === source) return false
|
|
107
|
+
await writeFile(target, updated, "utf8")
|
|
108
|
+
return true
|
|
109
|
+
}
|
|
110
|
+
// Removes the plugin entry from `target` if present. Returns true if the file changed.
|
|
111
|
+
async function removePluginFromFile(target) {
|
|
112
|
+
let source
|
|
113
|
+
try {
|
|
114
|
+
source = await readFile(target, "utf8")
|
|
115
|
+
} catch (e) {
|
|
116
|
+
if (e?.code !== "ENOENT") throw new Error(`Could not inspect ${target}: ${e.message}`)
|
|
117
|
+
return false
|
|
118
|
+
}
|
|
119
|
+
const parsed = parseJsonc(source)
|
|
120
|
+
if (parsed.plugin !== undefined && !Array.isArray(parsed.plugin)) throw new Error(`plugin must be array in ${target}`)
|
|
121
|
+
const plugins = parsed.plugin || []
|
|
122
|
+
const next = plugins.filter(v => !isPackageSpec(v))
|
|
123
|
+
if (next.length === plugins.length) return false
|
|
124
|
+
const updated = rewriteExistingPluginArray(source, next)
|
|
125
|
+
if (updated === source) return false
|
|
126
|
+
await writeFile(target, updated, "utf8")
|
|
127
|
+
return true
|
|
128
|
+
}
|
|
129
|
+
// The server plugin registers into whichever main config file the user already
|
|
130
|
+
// has (first match wins); falls back to opencode.jsonc if none exist yet.
|
|
131
|
+
async function findExistingServerConfig() {
|
|
75
132
|
for (const name of configCandidates) {
|
|
76
133
|
const target = join(config, name)
|
|
77
|
-
try {
|
|
78
|
-
const source = await readFile(target, "utf8")
|
|
79
|
-
const parsed = parseJsonc(source)
|
|
80
|
-
if (parsed.plugin !== undefined && !Array.isArray(parsed.plugin)) throw new Error("plugin must be array")
|
|
81
|
-
const plugins = parsed.plugin || []
|
|
82
|
-
if (!plugins.some(isPackageSpec)) continue
|
|
83
|
-
configured = true
|
|
84
|
-
const next = plugins.filter(v=>!isPackageSpec(v))
|
|
85
|
-
next.push(packageSpec)
|
|
86
|
-
const upd = rewriteExistingPluginArray(source, next)
|
|
87
|
-
if (upd !== source) { await writeFile(target, upd, "utf8"); updatedFiles.push(target) }
|
|
88
|
-
} catch(e){ if(e?.code!=="ENOENT") throw new Error(`Could not inspect ${target}: ${e.message}`) }
|
|
134
|
+
try { await readFile(target, "utf8"); return target } catch (e) { if (e?.code !== "ENOENT") throw new Error(`Could not inspect ${target}: ${e.message}`) }
|
|
89
135
|
}
|
|
90
|
-
return
|
|
136
|
+
return null
|
|
91
137
|
}
|
|
92
138
|
async function ensureDependency(){
|
|
93
139
|
let pkg={}
|
|
@@ -100,52 +146,48 @@ async function removePackagedFiles(srcDir, tgtDir){
|
|
|
100
146
|
try{ for(const n of await readdir(srcDir)) if(n.endsWith(".md")) await rm(join(tgtDir,n),{force:true}) }catch{}
|
|
101
147
|
}
|
|
102
148
|
async function uninstall(){
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const source=await readFile(target,"utf8")
|
|
108
|
-
const parsed=parseJsonc(source)
|
|
109
|
-
if(parsed.plugin!==undefined&&!Array.isArray(parsed.plugin)) throw new Error("plugin must be array")
|
|
110
|
-
const plugins=parsed.plugin||[]
|
|
111
|
-
const next=plugins.filter(v=>!isPackageSpec(v))
|
|
112
|
-
const upd= next.length===plugins.length ? source : rewriteExistingPluginArray(source,next)
|
|
113
|
-
plans.push({target,source,updated:upd})
|
|
114
|
-
}catch(e){ if(e?.code!=="ENOENT") throw new Error(`Could not inspect ${target}: ${e.message}`) }
|
|
149
|
+
const targets = [...configCandidates.map(name => join(config, name)), join(config, tuiConfigName)]
|
|
150
|
+
let changed = 0
|
|
151
|
+
for (const target of targets) {
|
|
152
|
+
if (await removePluginFromFile(target)) changed++
|
|
115
153
|
}
|
|
116
|
-
for(const p of plans) if(p.updated!==p.source) await writeFile(p.target,p.updated,"utf8")
|
|
117
154
|
// remove loopd artifacts
|
|
118
|
-
await rm(join(config,"tui.json"),{force:true}).catch(()=>{}) // legacy if any
|
|
119
155
|
await removePackagedFiles(join(root,"commands"), join(config,"commands"))
|
|
120
156
|
await rm(join(config,"skills","loopd","SKILL.md"),{force:true})
|
|
121
157
|
try{ const d=join(config,"skills","loopd"); const files=await readdir(d); if(!files.length) await rm(d,{force:true}) }catch{}
|
|
122
|
-
const changed=plans.filter(p=>p.updated!==p.source).length
|
|
123
158
|
console.log(changed ? `Removed ${packageName} from ${changed} config file(s).` : `${packageName} was not registered.`)
|
|
124
159
|
console.log("Removed loopd command and skill when present. Project state under .opencode/loopd is preserved.")
|
|
125
160
|
console.log("Restart OpenCode to finish unloading.")
|
|
126
161
|
}
|
|
127
162
|
async function installOrUpdate(){
|
|
128
|
-
await mkdir(
|
|
163
|
+
await mkdir(config,{recursive:true})
|
|
129
164
|
await mkdir(join(config,"commands"),{recursive:true})
|
|
130
165
|
await mkdir(join(config,"skills","loopd"),{recursive:true})
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if
|
|
166
|
+
|
|
167
|
+
// Server engine plugin: registers into whichever main config file the user
|
|
168
|
+
// already has, creating opencode.jsonc if none exist yet.
|
|
169
|
+
const serverTarget = (await findExistingServerConfig()) || join(config, "opencode.jsonc")
|
|
170
|
+
const serverChanged = await ensurePluginRegistered(serverTarget)
|
|
171
|
+
|
|
172
|
+
// TUI dashboard plugin: separate config file, loaded by the TUI process.
|
|
173
|
+
// Without this, `/loop` and the `<leader>o` binding never register.
|
|
174
|
+
const tuiTarget = join(config, tuiConfigName)
|
|
175
|
+
const tuiChanged = await ensurePluginRegistered(tuiTarget)
|
|
176
|
+
|
|
177
|
+
await ensureDependency()
|
|
178
|
+
|
|
134
179
|
// commands
|
|
135
180
|
for(const n of await readdir(join(root,"commands"))){
|
|
136
181
|
if(n.endsWith(".md")) await copyFile(join(root,"commands",n), join(config,"commands",n))
|
|
137
182
|
}
|
|
138
183
|
// skill
|
|
139
184
|
await copyFile(join(root,"skills","loopd","SKILL.md"), join(config,"skills","loopd","SKILL.md"))
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
} else {
|
|
144
|
-
console.log(`Installed opencode-loopd to ${config}`)
|
|
145
|
-
}
|
|
185
|
+
|
|
186
|
+
console.log(serverChanged ? `Registered ${packageSpec} in ${serverTarget}` : `${packageSpec} already registered in ${serverTarget}`)
|
|
187
|
+
console.log(tuiChanged ? `Registered ${packageSpec} in ${tuiTarget}` : `${packageSpec} already registered in ${tuiTarget}`)
|
|
146
188
|
console.log(`Installed ${packageName} command to ${join(config,"commands","goal.md")}`)
|
|
147
189
|
console.log(`Installed ${packageName} skill to ${join(config,"skills","loopd","SKILL.md")}`)
|
|
148
|
-
console.log("Restart OpenCode, then run: /goal or /loop")
|
|
190
|
+
console.log("Restart OpenCode, then run: /goal or /loop (<leader>o)")
|
|
149
191
|
}
|
|
150
192
|
if(uninstallRequested) await uninstall()
|
|
151
193
|
else await installOrUpdate()
|
package/skills/loopd/SKILL.md
CHANGED
|
@@ -83,7 +83,7 @@ loopd_create_goal({
|
|
|
83
83
|
|
|
84
84
|
Returns `ok:true` with `goalID`, `workerSessionID`, `artifactDir`, `agent`, `checks`, `workspaceWrite`, `defaultsApplied:{agent,checks}`. On contract violation you get `ok:false` with `errorCode: "missing_agent"` or `"missing_checks"` or `"already active"` (writer serialization).
|
|
85
85
|
|
|
86
|
-
The goal starts immediately. The user can monitor it via `/loop` (<leader>
|
|
86
|
+
The goal starts immediately. The user can monitor it via `/loop` (<leader>o). Plugin options `defaultAgent` / `defaultChecks` in `opencode.jsonc` can supply defaults so callers don’t have to repeat them.
|
|
87
87
|
|
|
88
88
|
## Worker Tools (Running Inside the Goal)
|
|
89
89
|
|
|
@@ -174,7 +174,7 @@ Force re-prompts a stuck worker even if `sessionStatus` is not `idle`. Clears st
|
|
|
174
174
|
|
|
175
175
|
## Dashboard Commands
|
|
176
176
|
|
|
177
|
-
Open the dashboard with `/loop` or <leader>
|
|
177
|
+
Open the dashboard with `/loop` or <leader>o.
|
|
178
178
|
|
|
179
179
|
### Keyboard Shortcuts (Normal Mode)
|
|
180
180
|
|