@bojackduy/opencode-loopd 1.0.0
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 +31 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/commands/goal.md +20 -0
- package/dist/server.js +2402 -0
- package/dist/tui.js +1480 -0
- package/package.json +82 -0
- package/scripts/build-tui.ts +20 -0
- package/scripts/install-node.mjs +151 -0
- package/skills/loopd/SKILL.md +206 -0
package/package.json
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/package.json",
|
|
3
|
+
"name": "@bojackduy/opencode-loopd",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "Codex-inspired background goal engine for OpenCode — autonomous child sessions, engine-driven loop, and modal TUI dashboard",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "AGPL-3.0-only",
|
|
8
|
+
"private": false,
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/bojackduy/opencode-loopd.git"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/bojackduy/opencode-loopd#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/bojackduy/opencode-loopd/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"opencode",
|
|
19
|
+
"opencode-plugin",
|
|
20
|
+
"tui",
|
|
21
|
+
"loop",
|
|
22
|
+
"goal",
|
|
23
|
+
"background",
|
|
24
|
+
"automation",
|
|
25
|
+
"worker",
|
|
26
|
+
"subagent",
|
|
27
|
+
"opencode-loopd"
|
|
28
|
+
],
|
|
29
|
+
"bin": {
|
|
30
|
+
"opencode-loopd": "./scripts/install-node.mjs"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"scripts",
|
|
35
|
+
"skills",
|
|
36
|
+
"commands",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"CHANGELOG.md"
|
|
40
|
+
],
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"bun": ">=1.1.0"
|
|
46
|
+
},
|
|
47
|
+
"exports": {
|
|
48
|
+
"./server": {
|
|
49
|
+
"import": "./dist/server.js"
|
|
50
|
+
},
|
|
51
|
+
"./tui": {
|
|
52
|
+
"import": "./dist/tui.js"
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
57
|
+
"build:server": "bun build src/server/plugin.ts --outfile dist/server.js --target bun --external @opencode-ai/plugin --external @opencode-ai/plugin/tool",
|
|
58
|
+
"build:tui": "bun scripts/build-tui.ts",
|
|
59
|
+
"build": "bun run build:server && bun run build:tui",
|
|
60
|
+
"clean": "rm -rf dist",
|
|
61
|
+
"prepack": "bun run typecheck && bun test && bun run build",
|
|
62
|
+
"test": "bun test",
|
|
63
|
+
"release:patch": "bun run typecheck && bun test && npm version patch -m \"chore: release %s\" && git push && git push origin $(git describe --tags --abbrev=0)",
|
|
64
|
+
"release:minor": "bun run typecheck && bun test && npm version minor -m \"chore: release %s\" && git push && git push origin $(git describe --tags --abbrev=0)",
|
|
65
|
+
"release:major": "bun run typecheck && bun test && npm version major -m \"chore: release %s\" && git push && git push origin $(git describe --tags --abbrev=0)"
|
|
66
|
+
},
|
|
67
|
+
"peerDependencies": {
|
|
68
|
+
"@opencode-ai/plugin": ">=1.4.0 <2",
|
|
69
|
+
"@opentui/core": "*",
|
|
70
|
+
"@opentui/solid": "*",
|
|
71
|
+
"solid-js": "*"
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"@opencode-ai/plugin": "^1.18.15",
|
|
75
|
+
"@opentui/core": "^0.4.1",
|
|
76
|
+
"@opentui/solid": "^0.4.1",
|
|
77
|
+
"@types/bun": "^1.3.14",
|
|
78
|
+
"@types/node": "^26.2.0",
|
|
79
|
+
"solid-js": "^1.9.13",
|
|
80
|
+
"typescript": "^5.7.0"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import solidPlugin from "@opentui/solid/bun-plugin"
|
|
2
|
+
|
|
3
|
+
const result = await Bun.build({
|
|
4
|
+
entrypoints: ["src/tui/plugin.tsx"],
|
|
5
|
+
outdir: "dist",
|
|
6
|
+
naming: "tui.js",
|
|
7
|
+
target: "bun",
|
|
8
|
+
external: [
|
|
9
|
+
"@opencode-ai/plugin/tui",
|
|
10
|
+
"@opentui/core",
|
|
11
|
+
"@opentui/solid",
|
|
12
|
+
"solid-js",
|
|
13
|
+
],
|
|
14
|
+
plugins: [solidPlugin],
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
if (!result.success) {
|
|
18
|
+
for (const log of result.logs) console.error(log)
|
|
19
|
+
process.exit(1)
|
|
20
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"
|
|
3
|
+
import { homedir } from "node:os"
|
|
4
|
+
import { dirname, join } from "node:path"
|
|
5
|
+
import { fileURLToPath } from "node:url"
|
|
6
|
+
|
|
7
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
8
|
+
const config = process.env.OPENCODE_CONFIG_DIR || join(homedir(), ".config", "opencode")
|
|
9
|
+
const pluginDir = join(config, "plugins")
|
|
10
|
+
const commandDir = join(config, "commands")
|
|
11
|
+
const skillDir = join(config, "skills", "loopd")
|
|
12
|
+
const packagePath = join(config, "package.json")
|
|
13
|
+
const packageName = "opencode-loopd"
|
|
14
|
+
const packageVersion = JSON.parse(await readFile(join(root, "package.json"), "utf8")).version
|
|
15
|
+
const packageSpec = `${packageName}@${packageVersion}`
|
|
16
|
+
const configCandidates = ["opencode.json", "opencode.jsonc", "config.json", "config.jsonc"]
|
|
17
|
+
const installerArgs = process.argv.slice(2)
|
|
18
|
+
const uninstallRequested = installerArgs.length === 1 && ["--uninstall", "uninstall", "--remove"].includes(installerArgs[0] || "")
|
|
19
|
+
|
|
20
|
+
if (installerArgs.includes("--help") || installerArgs.includes("-h")) {
|
|
21
|
+
console.log(`opencode-loopd installer/updater\n\nUsage:\n opencode-loopd\n npx -y opencode-loopd@latest\n npx -y opencode-loopd@latest --uninstall\n\nInstall/update registers the plugin, installs /goal command and loopd skill.\nUninstall removes the plugin registration, command and skill.\n\nSet OPENCODE_CONFIG_DIR to target a non-default OpenCode config directory.`)
|
|
22
|
+
process.exit(0)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (installerArgs.includes("--version") || installerArgs.includes("-v")) {
|
|
26
|
+
console.log(packageVersion)
|
|
27
|
+
process.exit(0)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (installerArgs.length && !uninstallRequested) {
|
|
31
|
+
console.error(`Unknown installer option: ${installerArgs[0]}`)
|
|
32
|
+
process.exit(2)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stripJsonComments(input) {
|
|
36
|
+
let out = "", quote = "", esc = false, lc = false, bc = false
|
|
37
|
+
for (let i = 0; i < input.length; i++) {
|
|
38
|
+
const c = input[i], n = input[i + 1]
|
|
39
|
+
if (lc) { if (c === "\n" || c === "\r") { lc = false; out += c } continue }
|
|
40
|
+
if (bc) { if (c === "*" && n === "/") { bc = false; i++ } else if (c === "\n" || c === "\r") out += c; continue }
|
|
41
|
+
if (quote) { out += c; if (esc) esc = false; else if (c === "\\") esc = true; else if (c === quote) quote = ""; continue }
|
|
42
|
+
if (c === '"') { quote = c; out += c; continue }
|
|
43
|
+
if (c === "/" && n === "/") { lc = true; i++; continue }
|
|
44
|
+
if (c === "/" && n === "*") { bc = true; i++; continue }
|
|
45
|
+
out += c
|
|
46
|
+
}
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
function stripTrailingCommas(input) {
|
|
50
|
+
let out = "", quote = "", esc = false
|
|
51
|
+
for (let i = 0; i < input.length; i++) {
|
|
52
|
+
const c = input[i]
|
|
53
|
+
if (quote) { out += c; if (esc) esc = false; else if (c === "\\") esc = true; else if (c === quote) quote = ""; continue }
|
|
54
|
+
if (c === '"') { quote = c; out += c; continue }
|
|
55
|
+
if (c === ",") { let j = i + 1; while (/\s/.test(input[j] || "")) j++; if (input[j] === "]" || input[j] === "}") continue }
|
|
56
|
+
out += c
|
|
57
|
+
}
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
function parseJsonc(input) {
|
|
61
|
+
const p = JSON.parse(stripTrailingCommas(stripJsonComments(input)))
|
|
62
|
+
if (!p || typeof p !== "object" || Array.isArray(p)) throw new Error("OpenCode config root must be an object")
|
|
63
|
+
return p
|
|
64
|
+
}
|
|
65
|
+
function isPackageSpec(v) { const s = String(v||"").trim(); return s === packageName || s.startsWith(`${packageName}@`) }
|
|
66
|
+
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
|
+
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
|
+
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
|
+
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
|
+
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
|
+
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)}` }
|
|
72
|
+
|
|
73
|
+
async function configurePackagePlugin() {
|
|
74
|
+
let configured = false; const updatedFiles=[]
|
|
75
|
+
for (const name of configCandidates) {
|
|
76
|
+
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}`) }
|
|
89
|
+
}
|
|
90
|
+
return {configured, updatedFiles}
|
|
91
|
+
}
|
|
92
|
+
async function ensureDependency(){
|
|
93
|
+
let pkg={}
|
|
94
|
+
try{ pkg=JSON.parse(await readFile(packagePath,"utf8")) }catch(e){ if(e?.code!=="ENOENT"){ console.warn(`Could not update ${packagePath}: ${e.message}`); return } }
|
|
95
|
+
if(!pkg||typeof pkg!=="object"||Array.isArray(pkg)) pkg={}
|
|
96
|
+
pkg.dependencies = pkg.dependencies && typeof pkg.dependencies==="object" && !Array.isArray(pkg.dependencies) ? pkg.dependencies : {}
|
|
97
|
+
if(!pkg.dependencies["@opencode-ai/plugin"]){ pkg.dependencies["@opencode-ai/plugin"]=">=1.4.0"; await writeFile(packagePath, JSON.stringify(pkg,null,2)+"\n","utf8") }
|
|
98
|
+
}
|
|
99
|
+
async function removePackagedFiles(srcDir, tgtDir){
|
|
100
|
+
try{ for(const n of await readdir(srcDir)) if(n.endsWith(".md")) await rm(join(tgtDir,n),{force:true}) }catch{}
|
|
101
|
+
}
|
|
102
|
+
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}`) }
|
|
115
|
+
}
|
|
116
|
+
for(const p of plans) if(p.updated!==p.source) await writeFile(p.target,p.updated,"utf8")
|
|
117
|
+
// remove loopd artifacts
|
|
118
|
+
await rm(join(config,"tui.json"),{force:true}).catch(()=>{}) // legacy if any
|
|
119
|
+
await removePackagedFiles(join(root,"commands"), join(config,"commands"))
|
|
120
|
+
await rm(join(config,"skills","loopd","SKILL.md"),{force:true})
|
|
121
|
+
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
|
+
console.log(changed ? `Removed ${packageName} from ${changed} config file(s).` : `${packageName} was not registered.`)
|
|
124
|
+
console.log("Removed loopd command and skill when present. Project state under .opencode/loopd is preserved.")
|
|
125
|
+
console.log("Restart OpenCode to finish unloading.")
|
|
126
|
+
}
|
|
127
|
+
async function installOrUpdate(){
|
|
128
|
+
await mkdir(join(config,"plugins"),{recursive:true})
|
|
129
|
+
await mkdir(join(config,"commands"),{recursive:true})
|
|
130
|
+
await mkdir(join(config,"skills","loopd"),{recursive:true})
|
|
131
|
+
const pc=await configurePackagePlugin()
|
|
132
|
+
const usePkg=pc.configured
|
|
133
|
+
if(!usePkg){ await ensureDependency() }
|
|
134
|
+
// commands
|
|
135
|
+
for(const n of await readdir(join(root,"commands"))){
|
|
136
|
+
if(n.endsWith(".md")) await copyFile(join(root,"commands",n), join(config,"commands",n))
|
|
137
|
+
}
|
|
138
|
+
// skill
|
|
139
|
+
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
|
+
}
|
|
146
|
+
console.log(`Installed ${packageName} command to ${join(config,"commands","goal.md")}`)
|
|
147
|
+
console.log(`Installed ${packageName} skill to ${join(config,"skills","loopd","SKILL.md")}`)
|
|
148
|
+
console.log("Restart OpenCode, then run: /goal or /loop")
|
|
149
|
+
}
|
|
150
|
+
if(uninstallRequested) await uninstall()
|
|
151
|
+
else await installOrUpdate()
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: loopd
|
|
3
|
+
description: "Agent skill for loopd background goals. Teaches when to create goals, how to inspect/steer/pause them, and how to send instructions to workers."
|
|
4
|
+
metadata:
|
|
5
|
+
version: "1.0.0"
|
|
6
|
+
status: active
|
|
7
|
+
tags: [opencode, loop, goal, background, automation]
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Loopd Background Goals
|
|
11
|
+
|
|
12
|
+
Loopd runs long-running or autonomous tasks as background goals, each with a dedicated worker session. The parent chat stays interactive while work happens behind the scenes.
|
|
13
|
+
|
|
14
|
+
## When to Use Loopd
|
|
15
|
+
|
|
16
|
+
Use loopd when:
|
|
17
|
+
|
|
18
|
+
- A task takes many turns and would block the main chat
|
|
19
|
+
- You want autonomous work that continues while the user does other things
|
|
20
|
+
- A task needs progress tracking, completion checks, or blocking
|
|
21
|
+
- You want a dashboard to monitor and steer background work
|
|
22
|
+
|
|
23
|
+
Do not use loopd when:
|
|
24
|
+
|
|
25
|
+
- The task is trivial (1-3 turns)
|
|
26
|
+
- The user needs to answer questions at every step
|
|
27
|
+
- The task modifies production systems without review gates
|
|
28
|
+
|
|
29
|
+
## Goal Lifecycle
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
created → active → complete
|
|
33
|
+
→ blocked (needs user intervention)
|
|
34
|
+
paused (user-initiated, can resume)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Creating a Goal
|
|
38
|
+
|
|
39
|
+
Use `loopd_create_goal` after clarifying the objective with the user:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
loopd_create_goal({
|
|
43
|
+
name: "short-name",
|
|
44
|
+
objective: "Detailed description of what the goal should accomplish.",
|
|
45
|
+
checks: ["npm test"], // optional: shell commands for completion verification
|
|
46
|
+
progressFile: ".opencode/loopd/progress.md", // optional: worker reads/writes this
|
|
47
|
+
maxTurns: 50, // optional: safety budget
|
|
48
|
+
maxNoProgress: 5, // optional: auto-block without progress
|
|
49
|
+
maxFailures: 3, // optional: auto-block on failures
|
|
50
|
+
compactEvery: 3, // optional: compact worker session every N turns
|
|
51
|
+
})
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The goal starts immediately. The user can monitor it via `/loop` (Ctrl+L).
|
|
55
|
+
|
|
56
|
+
## Worker Tools (Running Inside the Goal)
|
|
57
|
+
|
|
58
|
+
These tools are available to the worker session doing the actual work:
|
|
59
|
+
|
|
60
|
+
### get_goal
|
|
61
|
+
|
|
62
|
+
Call at the start of every turn to read the objective, config, and current state. Always call this first.
|
|
63
|
+
|
|
64
|
+
### report_goal_progress
|
|
65
|
+
|
|
66
|
+
Call after durable state changes (file writes, verifications). Resets failure and no-progress counters.
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
report_goal_progress({
|
|
70
|
+
summary: "What was accomplished this turn.",
|
|
71
|
+
next: "The next concrete step.",
|
|
72
|
+
evidence: "Optional proof (file path, test output, etc.)."
|
|
73
|
+
})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### complete_goal
|
|
77
|
+
|
|
78
|
+
Call only when ALL acceptance criteria pass with concrete evidence. Runs configured completion checks before accepting.
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
complete_goal({
|
|
82
|
+
summary: "What was completed.",
|
|
83
|
+
evidence: "Concrete evidence (test output, file existence, etc.)."
|
|
84
|
+
})
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### block_goal
|
|
88
|
+
|
|
89
|
+
Call only for a real external blocker requiring user intervention (e.g., missing credentials, ambiguous requirements).
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
block_goal({
|
|
93
|
+
reason: "Why the goal is blocked.",
|
|
94
|
+
needed: "What is needed to unblock."
|
|
95
|
+
})
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Worker questions vs send_goal_input
|
|
99
|
+
|
|
100
|
+
| Tool | Who calls it | Direction | When to use |
|
|
101
|
+
|------|-------------|-----------|-------------|
|
|
102
|
+
| `question` (OpenCode builtin) | Worker (inside goal) | Worker → User | Worker needs info only the user can provide. Question appears in the TUI footer as a blocker tab. |
|
|
103
|
+
| `send_goal_input` | Owner (parent chat) | User → Worker | Owner sends an instruction, answer, or redirect to the worker. |
|
|
104
|
+
|
|
105
|
+
**question** — The worker uses OpenCode's built-in `question` tool when it's genuinely stuck or ambiguous. The question appears in the TUI footer (blocker tab) and the user answers there. No goal status change needed — the worker stays busy until answered.
|
|
106
|
+
|
|
107
|
+
**send_goal_input** — The owner calls this to steer the worker. Messages are injected into the worker's next continuation prompt. Use it to:
|
|
108
|
+
- Redirect the worker to a different approach
|
|
109
|
+
- Refine scope or add constraints
|
|
110
|
+
- Provide missing context
|
|
111
|
+
|
|
112
|
+
## Owner Tools (Parent Chat)
|
|
113
|
+
|
|
114
|
+
These tools are available in the parent chat that created the goal:
|
|
115
|
+
|
|
116
|
+
### list_background_goals
|
|
117
|
+
|
|
118
|
+
Lists all active goals owned by this session. Shows status and progress.
|
|
119
|
+
|
|
120
|
+
### inspect_background_goal
|
|
121
|
+
|
|
122
|
+
Shows detailed info: objective, config, progress, blocker, runtime state.
|
|
123
|
+
|
|
124
|
+
### read_goal_transcript
|
|
125
|
+
|
|
126
|
+
Reads the last N messages from the worker session. Useful for debugging what the worker is doing.
|
|
127
|
+
|
|
128
|
+
### send_goal_input
|
|
129
|
+
|
|
130
|
+
Sends a message to the worker's next turn. See table above.
|
|
131
|
+
|
|
132
|
+
## Dashboard Commands
|
|
133
|
+
|
|
134
|
+
Open the dashboard with `/loop` or Ctrl+L.
|
|
135
|
+
|
|
136
|
+
### Keyboard Shortcuts (Normal Mode)
|
|
137
|
+
|
|
138
|
+
| Key | Action |
|
|
139
|
+
|-----|--------|
|
|
140
|
+
| `j` / `k` | Move selection down / up |
|
|
141
|
+
| `g` / `G` | Jump to top / bottom |
|
|
142
|
+
| `o` | Open child (view details) |
|
|
143
|
+
| `L` | Toggle log view |
|
|
144
|
+
| `?` | Toggle help |
|
|
145
|
+
| `Ctrl+N` | Switch to normal mode |
|
|
146
|
+
| `:` | Enter command mode |
|
|
147
|
+
|
|
148
|
+
### Command Mode (`:` prefix)
|
|
149
|
+
|
|
150
|
+
| Command | Description |
|
|
151
|
+
|---------|-------------|
|
|
152
|
+
| `:send <message>` | Send instruction to the selected goal's worker |
|
|
153
|
+
| `:open` | Open the selected goal's child session |
|
|
154
|
+
| `:force <summary> --evidence <text>` | Force-complete (bypass verification checks) |
|
|
155
|
+
| `:block <reason> --needed <text>` | Force-block the selected goal |
|
|
156
|
+
| `:pause` | Pause the selected goal |
|
|
157
|
+
| `:resume` | Resume the paused goal |
|
|
158
|
+
| `:retry` | Retry the blocked goal |
|
|
159
|
+
| `:clear` | Clear the selected goal |
|
|
160
|
+
| `:logs` | Toggle log view |
|
|
161
|
+
| `:help` | Show help |
|
|
162
|
+
| `:q` / `:close` | Close dashboard |
|
|
163
|
+
|
|
164
|
+
> Goal creation (`:goal start`) was removed from the dashboard — create goals via `/goal` in the parent chat so the agent can clarify the objective first.
|
|
165
|
+
|
|
166
|
+
## Safety Patterns
|
|
167
|
+
|
|
168
|
+
1. **Always call `get_goal` first** — Read the objective before doing any work.
|
|
169
|
+
2. **Progress after durable changes** — Call `report_goal_progress` after file writes or verifications, not after thinking.
|
|
170
|
+
3. **Complete with evidence** — Never call `complete_goal` without concrete proof (test output, file checks passing).
|
|
171
|
+
4. **Block for real blockers only** — Don't block for things you can figure out. Block when you genuinely need user input.
|
|
172
|
+
5. **Use checks for verification** — Set `checks` on goal creation to auto-verify completion (e.g., `["npm test", "test -f README.md"]`).
|
|
173
|
+
6. **Set safety budgets** — Use `maxTurns`, `maxNoProgress`, and `maxFailures` to prevent runaway goals.
|
|
174
|
+
7. **Compact periodically** — Use `compactEvery` to keep the worker session's context manageable.
|
|
175
|
+
|
|
176
|
+
## Example: Creating and Monitoring a Goal
|
|
177
|
+
|
|
178
|
+
**Parent chat:**
|
|
179
|
+
```
|
|
180
|
+
User: Write a README for this project and create a changelog.
|
|
181
|
+
|
|
182
|
+
Agent: I'll set up a background goal for this.
|
|
183
|
+
|
|
184
|
+
loopd_create_goal({
|
|
185
|
+
name: "docs-write",
|
|
186
|
+
objective: "Write a README.md covering: what it is, install, usage, architecture, and examples. Then create CHANGELOG.md with a v1.0.0 entry.",
|
|
187
|
+
checks: ["test -f README.md", "test -f CHANGELOG.md"],
|
|
188
|
+
progressFile: ".opencode/loopd/docs-progress.md",
|
|
189
|
+
maxTurns: 20
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
# Goal starts. User can monitor with /loop.
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
**Worker session (automatic):**
|
|
196
|
+
```
|
|
197
|
+
# Turn 1
|
|
198
|
+
get_goal() → reads objective
|
|
199
|
+
# ... explores codebase, writes README.md
|
|
200
|
+
report_goal_progress({ summary: "README.md written", next: "Create CHANGELOG.md" })
|
|
201
|
+
|
|
202
|
+
# Turn 2
|
|
203
|
+
get_goal() → reads objective
|
|
204
|
+
# ... writes CHANGELOG.md
|
|
205
|
+
complete_goal({ summary: "Docs complete", evidence: "README.md and CHANGELOG.md exist" })
|
|
206
|
+
```
|