@bojackduy/opencode-loopd 1.8.1 → 1.8.2
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/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/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.2",
|
|
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
|
|