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/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
+ }