opencode-planpilot 0.1.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/LICENSE +21 -0
- package/README.md +31 -0
- package/docs/planpilot.md +219 -0
- package/package.json +62 -0
- package/src/cli.ts +1483 -0
- package/src/index.ts +140 -0
- package/src/lib/app.ts +1072 -0
- package/src/lib/argv.ts +58 -0
- package/src/lib/db.ts +108 -0
- package/src/lib/errors.ts +36 -0
- package/src/lib/format.ts +239 -0
- package/src/lib/instructions.ts +26 -0
- package/src/lib/models.ts +144 -0
- package/src/lib/util.ts +76 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { tool, type Plugin } from "@opencode-ai/plugin"
|
|
2
|
+
import { runCLI, formatCliError } from "./cli"
|
|
3
|
+
import { PlanpilotApp } from "./lib/app"
|
|
4
|
+
import { parseCommandArgs } from "./lib/argv"
|
|
5
|
+
import { openDatabase } from "./lib/db"
|
|
6
|
+
import { invalidInput } from "./lib/errors"
|
|
7
|
+
import { formatStepDetail } from "./lib/format"
|
|
8
|
+
import { loadPlanpilotInstructions } from "./lib/instructions"
|
|
9
|
+
|
|
10
|
+
export const PlanpilotPlugin: Plugin = async (ctx) => {
|
|
11
|
+
const inFlight = new Set<string>()
|
|
12
|
+
|
|
13
|
+
const log = async (level: "debug" | "info" | "warn" | "error", message: string, extra?: Record<string, any>) => {
|
|
14
|
+
try {
|
|
15
|
+
await ctx.client.app.log({
|
|
16
|
+
body: {
|
|
17
|
+
service: "opencode-planpilot",
|
|
18
|
+
level,
|
|
19
|
+
message,
|
|
20
|
+
extra,
|
|
21
|
+
},
|
|
22
|
+
})
|
|
23
|
+
} catch {
|
|
24
|
+
// ignore logging failures
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const handleSessionIdle = async (sessionID: string) => {
|
|
29
|
+
if (inFlight.has(sessionID)) return
|
|
30
|
+
inFlight.add(sessionID)
|
|
31
|
+
try {
|
|
32
|
+
const app = new PlanpilotApp(openDatabase(), sessionID)
|
|
33
|
+
const active = app.getActivePlan()
|
|
34
|
+
if (!active) return
|
|
35
|
+
const next = app.nextStep(active.plan_id)
|
|
36
|
+
if (!next) return
|
|
37
|
+
if (next.executor !== "ai") return
|
|
38
|
+
const goals = app.goalsForStep(next.id)
|
|
39
|
+
const detail = formatStepDetail(next, goals)
|
|
40
|
+
if (!detail.trim()) return
|
|
41
|
+
|
|
42
|
+
const message =
|
|
43
|
+
"Planpilot (auto):\n" +
|
|
44
|
+
"Before acting, think through the next step and its goals. Record implementation details using Planpilot comments (plan/step/goal --comment or comment commands). Continue with the next step (executor: ai). Do not ask for confirmation; proceed and report results.\n\n" +
|
|
45
|
+
detail.trimEnd()
|
|
46
|
+
|
|
47
|
+
await ctx.client.session.promptAsync({
|
|
48
|
+
path: { id: sessionID },
|
|
49
|
+
body: {
|
|
50
|
+
parts: [{ type: "text", text: message }],
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
} catch (err) {
|
|
54
|
+
await log("warn", "failed to auto-continue plan", { error: err instanceof Error ? err.message : String(err) })
|
|
55
|
+
} finally {
|
|
56
|
+
inFlight.delete(sessionID)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
tool: {
|
|
62
|
+
planpilot: tool({
|
|
63
|
+
description:
|
|
64
|
+
"Planpilot planner. Use for all plan/step/goal operations. Provide either argv (array) or command (string). " +
|
|
65
|
+
"Do not include --session-id/--cwd; they are injected automatically from the current session.",
|
|
66
|
+
args: {
|
|
67
|
+
argv: tool.schema.array(tool.schema.string()).optional(),
|
|
68
|
+
command: tool.schema.string().min(1),
|
|
69
|
+
},
|
|
70
|
+
async execute(args, toolCtx) {
|
|
71
|
+
let argv: string[] = []
|
|
72
|
+
if (Array.isArray(args.argv) && args.argv.length) {
|
|
73
|
+
argv = args.argv
|
|
74
|
+
} else if (typeof args.command === "string" && args.command.trim()) {
|
|
75
|
+
argv = parseCommandArgs(args.command)
|
|
76
|
+
} else {
|
|
77
|
+
return formatCliError(invalidInput("missing command"))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const cwd = (ctx.directory ?? "").trim()
|
|
81
|
+
if (!cwd) {
|
|
82
|
+
return formatCliError(invalidInput(`${"--cwd"} is required`))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (containsForbiddenFlags(argv)) {
|
|
86
|
+
return formatCliError(invalidInput("do not pass --cwd or --session-id"))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const finalArgv = [...argv]
|
|
90
|
+
if (!finalArgv.includes("--cwd")) {
|
|
91
|
+
finalArgv.unshift("--cwd", cwd)
|
|
92
|
+
}
|
|
93
|
+
if (!finalArgv.includes("--session-id")) {
|
|
94
|
+
finalArgv.unshift("--session-id", toolCtx.sessionID)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const output: string[] = []
|
|
98
|
+
const io = {
|
|
99
|
+
log: (...parts: any[]) => output.push(parts.map(String).join(" ")),
|
|
100
|
+
error: (...parts: any[]) => output.push(parts.map(String).join(" ")),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
await runCLI(finalArgv, io)
|
|
105
|
+
} catch (err) {
|
|
106
|
+
return formatCliError(err)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return output.join("\n").trimEnd()
|
|
110
|
+
},
|
|
111
|
+
}),
|
|
112
|
+
},
|
|
113
|
+
"experimental.chat.system.transform": async (_input, output) => {
|
|
114
|
+
const instructions = loadPlanpilotInstructions().trim()
|
|
115
|
+
const alreadyInjected = output.system.some((entry) => entry.includes("Planpilot (OpenCode Tool)"))
|
|
116
|
+
if (instructions && !alreadyInjected) {
|
|
117
|
+
output.system.push(instructions)
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
event: async ({ event }) => {
|
|
121
|
+
if (event.type === "session.idle") {
|
|
122
|
+
await handleSessionIdle(event.properties.sessionID)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
if (event.type === "session.status" && event.properties.status.type === "idle") {
|
|
126
|
+
await handleSessionIdle(event.properties.sessionID)
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export default PlanpilotPlugin
|
|
133
|
+
|
|
134
|
+
function containsForbiddenFlags(argv: string[]): boolean {
|
|
135
|
+
return argv.some((token) => {
|
|
136
|
+
if (token === "--cwd" || token === "--session-id") return true
|
|
137
|
+
if (token.startsWith("--cwd=") || token.startsWith("--session-id=")) return true
|
|
138
|
+
return false
|
|
139
|
+
})
|
|
140
|
+
}
|