@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 CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  ![Loop Dashboard — modal TUI with goals, running indicator, and insert mode](assets/demo.png)
13
13
 
14
- *Modal dashboard (`/loop` / `<leader>d`): zero chat pollution — keys are trapped inside the dialog, NORMAL vs INSERT modes, vivid per-status coloring.*
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>d` or `/loop` opens a focused dialog (no leak to chat prompt). Vim-style navigation, live running indicator, per-status borders.
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>d`.
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>d`** or open the command palette → **"Loop Dashboard"** (also `/loop`).
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>d). 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`.
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.1",
5
- "description": "Codex-inspired background goal engine for OpenCode \u2014 autonomous subagents, engine-driven loop, child worker sessions and modal TUI dashboard. Like Claude Code loop 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,
@@ -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 packageName = "opencode-loopd"
14
- const packageVersion = JSON.parse(await readFile(join(root, "package.json"), "utf8")).version
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) { const s = String(v||"").trim(); return s === packageName || s.startsWith(`${packageName}@`) }
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
- async function configurePackagePlugin() {
74
- let configured = false; const updatedFiles=[]
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 {configured, updatedFiles}
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 plans=[]
104
- for(const name of configCandidates){
105
- const target=join(config,name)
106
- try{
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(join(config,"plugins"),{recursive:true})
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
- const pc=await configurePackagePlugin()
132
- const usePkg=pc.configured
133
- if(!usePkg){ await ensureDependency() }
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
- if(usePkg){
141
- const pin=pc.updatedFiles.length ? `pinned to ${packageSpec}` : `already pinned to ${packageSpec}`
142
- console.log(`OpenCode Loopd is already configured as package in ${config}; ${pin}.`)
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()
@@ -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>d). Plugin options `defaultAgent` / `defaultChecks` in `opencode.jsonc` can supply defaults so callers don’t have to repeat them.
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>d.
177
+ Open the dashboard with `/loop` or <leader>o.
178
178
 
179
179
  ### Keyboard Shortcuts (Normal Mode)
180
180