opencode-planpilot 0.1.0 → 0.1.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/docs/planpilot.md CHANGED
@@ -42,6 +42,7 @@ A CLI binary named `planpilot` is available for manual use. If you use the CLI d
42
42
 
43
43
  ## AI Workflow Guidelines
44
44
  - Use Planpilot for all planning, status, and progress tracking; do not use built-in plan/todo tools or other methods to track plan/step/goal status.
45
+ - Do not read plan files from disk or follow plan file placeholders; use the planpilot tool for plan/step/goal info.
45
46
  - Treat tool output as authoritative. Do not invent IDs; only use IDs shown by `list`/`show`.
46
47
  - If the tool is missing or unavailable, ask the user to enable/install the plugin.
47
48
  - Record implementation details using Planpilot comments (plan/step/goal `--comment` or `comment` commands). Before starting a step or goal, think through the next actions and capture that context in comments so the plan stays actionable.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-planpilot",
3
- "version": "0.1.0",
4
- "description": "Planpilot plugin for OpenCode (TypeScript rewrite)",
3
+ "version": "0.1.2",
4
+ "description": "Planpilot plugin for OpenCode",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
package/src/index.ts CHANGED
@@ -9,6 +9,15 @@ import { loadPlanpilotInstructions } from "./lib/instructions"
9
9
 
10
10
  export const PlanpilotPlugin: Plugin = async (ctx) => {
11
11
  const inFlight = new Set<string>()
12
+ const skipNextAuto = new Set<string>()
13
+ const lastIdleAt = new Map<string, number>()
14
+
15
+ const PLANPILOT_GUIDANCE = [
16
+ "Planpilot guidance:",
17
+ "- Do not read plan files from disk or follow plan file placeholders.",
18
+ "- Use the planpilot tool for plan/step/goal info (plan show-active, step show-next, goal list <step_id>).",
19
+ "- If you cannot continue or need human input, insert a new step with executor `human` before the next pending step using planpilot so auto-continue pauses.",
20
+ ].join("\n")
12
21
 
13
22
  const log = async (level: "debug" | "info" | "warn" | "error", message: string, extra?: Record<string, any>) => {
14
23
  try {
@@ -25,8 +34,96 @@ export const PlanpilotPlugin: Plugin = async (ctx) => {
25
34
  }
26
35
  }
27
36
 
37
+ type SessionMessage = {
38
+ info?: {
39
+ role?: string
40
+ agent?: string
41
+ model?: {
42
+ providerID: string
43
+ modelID: string
44
+ }
45
+ modelID?: string
46
+ providerID?: string
47
+ variant?: string
48
+ time?: {
49
+ created?: number
50
+ }
51
+ error?: {
52
+ name?: string
53
+ }
54
+ finish?: string
55
+ }
56
+ }
57
+
58
+ const loadRecentMessages = async (sessionID: string, limit = 200): Promise<SessionMessage[]> => {
59
+ const response = await ctx.client.session.messages({
60
+ path: { id: sessionID },
61
+ query: { limit },
62
+ })
63
+ const data = (response as { data?: unknown }).data ?? response
64
+ if (!Array.isArray(data)) return []
65
+ return data as SessionMessage[]
66
+ }
67
+
68
+ const findLastMessage = (messages: SessionMessage[], predicate: (message: SessionMessage) => boolean) => {
69
+ for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
70
+ const message = messages[idx]
71
+ if (predicate(message)) return message
72
+ }
73
+ return undefined
74
+ }
75
+
76
+ const findLastMessageByRole = (messages: SessionMessage[], role: "user" | "assistant") =>
77
+ findLastMessage(messages, (message) => message?.info?.role === role)
78
+
79
+ const resolveAutoContext = async (sessionID: string) => {
80
+ const messages = await loadRecentMessages(sessionID)
81
+ if (!messages.length) return null
82
+
83
+ const sortedMessages = [...messages].sort((left, right) => {
84
+ const leftTime = left?.info?.time?.created ?? 0
85
+ const rightTime = right?.info?.time?.created ?? 0
86
+ return leftTime - rightTime
87
+ })
88
+
89
+ const lastUser = findLastMessage(sortedMessages, (message) => message?.info?.role === "user")
90
+ if (!lastUser) return { missingUser: true }
91
+
92
+ const lastAssistant = findLastMessageByRole(sortedMessages, "assistant")
93
+ const error = lastAssistant?.info?.error
94
+ const aborted = typeof error === "object" && error?.name === "MessageAbortedError"
95
+ const finish = lastAssistant?.info?.finish
96
+ const ready =
97
+ !lastAssistant || (typeof finish === "string" && finish !== "tool-calls" && finish !== "unknown")
98
+
99
+ const model =
100
+ lastUser?.info?.model ??
101
+ (lastUser?.info?.providerID && lastUser?.info?.modelID
102
+ ? { providerID: lastUser.info.providerID, modelID: lastUser.info.modelID }
103
+ : lastAssistant?.info?.providerID && lastAssistant?.info?.modelID
104
+ ? { providerID: lastAssistant.info.providerID, modelID: lastAssistant.info.modelID }
105
+ : undefined)
106
+
107
+ return {
108
+ agent: lastUser?.info?.agent ?? lastAssistant?.info?.agent,
109
+ model,
110
+ variant: lastUser?.info?.variant,
111
+ aborted,
112
+ ready,
113
+ missingUser: false,
114
+ }
115
+ }
116
+
28
117
  const handleSessionIdle = async (sessionID: string) => {
29
118
  if (inFlight.has(sessionID)) return
119
+ if (skipNextAuto.has(sessionID)) {
120
+ skipNextAuto.delete(sessionID)
121
+ return
122
+ }
123
+ const lastIdle = lastIdleAt.get(sessionID)
124
+ const now = Date.now()
125
+ if (lastIdle && now - lastIdle < 1000) return
126
+ lastIdleAt.set(sessionID, now)
30
127
  inFlight.add(sessionID)
31
128
  try {
32
129
  const app = new PlanpilotApp(openDatabase(), sessionID)
@@ -39,16 +136,32 @@ export const PlanpilotPlugin: Plugin = async (ctx) => {
39
136
  const detail = formatStepDetail(next, goals)
40
137
  if (!detail.trim()) return
41
138
 
139
+ const autoContext = await resolveAutoContext(sessionID)
140
+ if (autoContext?.missingUser) {
141
+ await log("warn", "auto-continue stopped: missing user context", { sessionID })
142
+ return
143
+ }
144
+ if (autoContext?.aborted || autoContext?.ready === false) return
145
+
42
146
  const message =
43
147
  "Planpilot (auto):\n" +
44
148
  "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" +
149
+ PLANPILOT_GUIDANCE +
150
+ "\n\n" +
45
151
  detail.trimEnd()
46
152
 
153
+ const promptBody: any = {
154
+ agent: autoContext?.agent ?? undefined,
155
+ model: autoContext?.model ?? undefined,
156
+ parts: [{ type: "text" as const, text: message }],
157
+ }
158
+ if (autoContext?.variant) {
159
+ promptBody.variant = autoContext.variant
160
+ }
161
+
47
162
  await ctx.client.session.promptAsync({
48
163
  path: { id: sessionID },
49
- body: {
50
- parts: [{ type: "text", text: message }],
51
- },
164
+ body: promptBody,
52
165
  })
53
166
  } catch (err) {
54
167
  await log("warn", "failed to auto-continue plan", { error: err instanceof Error ? err.message : String(err) })
@@ -116,6 +229,18 @@ export const PlanpilotPlugin: Plugin = async (ctx) => {
116
229
  if (instructions && !alreadyInjected) {
117
230
  output.system.push(instructions)
118
231
  }
232
+ const guidanceInjected = output.system.some((entry) => entry.includes("Planpilot guidance:"))
233
+ if (!guidanceInjected) {
234
+ output.system.push(PLANPILOT_GUIDANCE)
235
+ }
236
+ },
237
+ "experimental.session.compacting": async ({ sessionID }, output) => {
238
+ const hasGuidance = output.context.some((entry) => entry.includes("Planpilot guidance:"))
239
+ if (!hasGuidance) {
240
+ output.context.push(PLANPILOT_GUIDANCE)
241
+ }
242
+ skipNextAuto.add(sessionID)
243
+ lastIdleAt.set(sessionID, Date.now())
119
244
  },
120
245
  event: async ({ event }) => {
121
246
  if (event.type === "session.idle") {