opencode-planpilot 0.2.3 → 0.2.4

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
@@ -7,6 +7,7 @@ Planpilot for OpenCode. Provides plan/step/goal workflow with auto-continue for
7
7
  - SQLite storage with markdown plan snapshots
8
8
  - Native OpenCode tool for plan/step/goal operations
9
9
  - Auto-continue on `session.idle` when next step is assigned to `ai`
10
+ - Configurable auto-continue triggers for stop/pause/error events (with keyword filters)
10
11
 
11
12
  ## Install
12
13
 
@@ -20,5 +21,401 @@ Add to `opencode.json`:
20
21
 
21
22
  OpenCode installs npm plugins automatically at startup.
22
23
 
24
+ ## Studio Integration
25
+
26
+ - Studio manifest is generated at `dist/studio.manifest.json`.
27
+ - Bridge entrypoint is generated at `dist/studio-bridge.js` and is invoked by the manifest.
28
+ - Web mount assets are generated under `dist/studio-web/`.
29
+
30
+ The Studio bridge accepts JSON on stdin and returns a JSON envelope on stdout (`{ ok, data | error }`).
31
+
32
+ Key actions:
33
+
34
+ - `config.get`, `config.set`
35
+ - `runtime.snapshot`, `runtime.next`, `runtime.pause`, `runtime.resume`
36
+ - `plan.*`, `step.*`, `goal.*` (including `plan.createTree` / `plan.addTree`)
37
+ - `events.poll` for change cursors + event envelopes
38
+
39
+ Studio UI integration includes:
40
+
41
+ - `chat.sidebar` mount for runtime + next-step context
42
+ - `settings.panel` capability backed by plugin settings schema
43
+
44
+ ## Configuration
45
+
46
+ Planpilot configuration comes from:
47
+
48
+ - Environment variables (to control where data/config is stored)
49
+ - An optional JSON config file (to control auto-continue behavior)
50
+
51
+ ### Paths and environment variables
52
+
53
+ Planpilot stores all local state under a single directory (database, plan markdown snapshots, and the default config file).
54
+
55
+ - OpenCode config root
56
+ - Default: `~/.config/opencode` (XDG config)
57
+ - Override: `OPENCODE_CONFIG_DIR=/abs/path`
58
+ - Planpilot data directory
59
+ - Default: `~/.config/opencode/.planpilot`
60
+ - If `OPENCODE_CONFIG_DIR` is set, the default becomes `${OPENCODE_CONFIG_DIR}/.planpilot`.
61
+ - Override: `OPENCODE_PLANPILOT_DIR=/abs/path` (or the legacy alias `OPENCODE_PLANPILOT_HOME`)
62
+ - Planpilot config file
63
+ - Default: `~/.config/opencode/.planpilot/config.json`
64
+ - If `OPENCODE_PLANPILOT_DIR` is set, the default becomes `${OPENCODE_PLANPILOT_DIR}/config.json`.
65
+ - Override: `OPENCODE_PLANPILOT_CONFIG=/abs/path/to/config.json`
66
+ - If a relative path is provided, it is resolved to an absolute path.
67
+
68
+ Data layout under the Planpilot data directory:
69
+
70
+ - `planpilot.db` - SQLite database (plans/steps/goals + active plan pointer)
71
+ - `plans/plan_<id>.md` - Markdown plan snapshots (kept in sync on write operations)
72
+
73
+ ### Config load + validation behavior
74
+
75
+ - Missing config file: Planpilot falls back to defaults (idle-only triggers).
76
+ - Invalid JSON / invalid shape: Planpilot falls back to defaults and logs a load warning.
77
+ - Normalization:
78
+ - Unknown fields are ignored.
79
+ - String arrays are trimmed, empty strings removed, and duplicates de-duped.
80
+ - Number arrays are filtered to finite numbers, truncated to integers, and de-duped.
81
+ - Integers that must be positive (e.g. `maxAttempts`) fall back to defaults if invalid.
82
+
83
+ Note on live changes:
84
+
85
+ - The core plugin currently loads `autoContinue.*` once at initialization. If you change `autoContinue.*`, restart OpenCode (or reload the plugin) for the new values to take effect.
86
+ - `runtime.paused` is exposed via Studio and persisted to the config file, but the core auto-continue loop does not currently consult it.
87
+
88
+ ### Config file schema
89
+
90
+ All fields are optional; missing values fall back to safe defaults.
91
+
92
+ Default config:
93
+
94
+ ```json
95
+ {
96
+ "autoContinue": {
97
+ "sendRetry": {
98
+ "enabled": true,
99
+ "maxAttempts": 3,
100
+ "delaysMs": [1500, 5000, 15000]
101
+ },
102
+ "onSessionError": {
103
+ "enabled": false,
104
+ "force": true,
105
+ "keywords": {
106
+ "any": [],
107
+ "all": [],
108
+ "none": [],
109
+ "matchCase": false
110
+ },
111
+ "errorNames": [],
112
+ "statusCodes": [],
113
+ "retryableOnly": false
114
+ },
115
+ "onSessionRetry": {
116
+ "enabled": false,
117
+ "force": false,
118
+ "keywords": {
119
+ "any": [],
120
+ "all": [],
121
+ "none": [],
122
+ "matchCase": false
123
+ },
124
+ "attemptAtLeast": 1
125
+ },
126
+ "onPermissionAsked": {
127
+ "enabled": false,
128
+ "force": false,
129
+ "keywords": {
130
+ "any": [],
131
+ "all": [],
132
+ "none": [],
133
+ "matchCase": false
134
+ }
135
+ },
136
+ "onPermissionRejected": {
137
+ "enabled": false,
138
+ "force": true,
139
+ "keywords": {
140
+ "any": [],
141
+ "all": [],
142
+ "none": [],
143
+ "matchCase": false
144
+ }
145
+ },
146
+ "onQuestionAsked": {
147
+ "enabled": false,
148
+ "force": false,
149
+ "keywords": {
150
+ "any": [],
151
+ "all": [],
152
+ "none": [],
153
+ "matchCase": false
154
+ }
155
+ },
156
+ "onQuestionRejected": {
157
+ "enabled": false,
158
+ "force": true,
159
+ "keywords": {
160
+ "any": [],
161
+ "all": [],
162
+ "none": [],
163
+ "matchCase": false
164
+ }
165
+ }
166
+ },
167
+ "runtime": {
168
+ "paused": false
169
+ }
170
+ }
171
+ ```
172
+
173
+ Notes:
174
+
175
+ - `session.idle` and `session.status=idle` are always auto-continue triggers and cannot be disabled.
176
+ - Additional triggers are optional and configured in:
177
+
178
+ - Default: `~/.config/opencode/.planpilot/config.json`
179
+ - Override path: `OPENCODE_PLANPILOT_CONFIG=/abs/path/to/config.json`
180
+
181
+ Example:
182
+
183
+ ```json
184
+ {
185
+ "autoContinue": {
186
+ "sendRetry": {
187
+ "enabled": true,
188
+ "maxAttempts": 3,
189
+ "delaysMs": [1500, 5000, 15000]
190
+ },
191
+ "onSessionError": {
192
+ "enabled": true,
193
+ "force": true,
194
+ "errorNames": ["APIError", "UnknownError"],
195
+ "statusCodes": [408, 429, 500, 502, 503, 504],
196
+ "retryableOnly": true,
197
+ "keywords": {
198
+ "any": ["rate", "overload", "timeout"],
199
+ "all": [],
200
+ "none": ["aborted"],
201
+ "matchCase": false
202
+ }
203
+ },
204
+ "onSessionRetry": {
205
+ "enabled": false,
206
+ "force": false,
207
+ "attemptAtLeast": 2,
208
+ "keywords": {
209
+ "any": ["overloaded", "rate"],
210
+ "all": [],
211
+ "none": [],
212
+ "matchCase": false
213
+ }
214
+ },
215
+ "onPermissionAsked": {
216
+ "enabled": false,
217
+ "force": false,
218
+ "keywords": {
219
+ "any": [],
220
+ "all": [],
221
+ "none": [],
222
+ "matchCase": false
223
+ }
224
+ },
225
+ "onPermissionRejected": {
226
+ "enabled": true,
227
+ "force": true,
228
+ "keywords": {
229
+ "any": ["write"],
230
+ "all": [],
231
+ "none": [],
232
+ "matchCase": false
233
+ }
234
+ },
235
+ "onQuestionAsked": {
236
+ "enabled": false,
237
+ "force": false,
238
+ "keywords": {
239
+ "any": [],
240
+ "all": [],
241
+ "none": [],
242
+ "matchCase": false
243
+ }
244
+ },
245
+ "onQuestionRejected": {
246
+ "enabled": true,
247
+ "force": true,
248
+ "keywords": {
249
+ "any": ["confirm"],
250
+ "all": [],
251
+ "none": [],
252
+ "matchCase": false
253
+ }
254
+ }
255
+ }
256
+ }
257
+ ```
258
+
259
+ ### Field reference
260
+
261
+ #### `runtime`
262
+
263
+ - `runtime.paused` (boolean)
264
+ - Persisted to the config file and surfaced in Studio runtime snapshots.
265
+ - Currently not used by the core auto-continue loop (see note above).
266
+
267
+ #### `autoContinue.sendRetry`
268
+
269
+ Controls retrying a failed auto-continue send (`session.promptAsync`).
270
+
271
+ - `enabled` (boolean): enable/disable retries.
272
+ - `maxAttempts` (integer, min 1): maximum number of attempts per plan/step signature.
273
+ - `delaysMs` (integer[], min 1): backoff delays in milliseconds.
274
+ - Attempt 1 uses `delaysMs[0]`, attempt 2 uses `delaysMs[1]`, etc.
275
+ - If attempts exceed the array length, the last delay is used.
276
+
277
+ #### Event rules (`autoContinue.on*`)
278
+
279
+ Event rules let Planpilot attempt an auto-continue send when a specific OpenCode event occurs.
280
+ Each rule has the same base shape:
281
+
282
+ - `enabled` (boolean): enable/disable the rule.
283
+ - `force` (boolean): bypass default safety guards when the rule matches.
284
+ - Guards that `force=true` can bypass include:
285
+ - last assistant message was aborted (`MessageAbortedError`)
286
+ - last assistant message is not "ready" (e.g. finished with tool-calls)
287
+ - `keywords` (object): substring match rules against the event summary text.
288
+ - `any`: if non-empty, at least one term must appear.
289
+ - `all`: all terms must appear.
290
+ - `none`: no terms may appear.
291
+ - `matchCase`: case-sensitive matching when true.
292
+ - If all arrays are empty, the keyword rule matches everything.
293
+
294
+ Event summary text is what keyword matching runs against:
295
+
296
+ - `onSessionError`: summary includes `error=<name>`, optional `status=<code>`, optional `retryable=<bool>`, plus message text.
297
+ - `onSessionRetry`: summary includes `attempt=<n>`, optional `next=<ms>`, plus message text.
298
+ - `onPermissionAsked`: summary includes `<permission>` and optional `patterns=a,b,c`.
299
+ - `onPermissionRejected`: summary includes the asked summary (if available), plus `request=<id> reply=reject`.
300
+ - `onQuestionAsked`: summary concatenates question `header` + `question` items (joined with ` | `).
301
+ - `onQuestionRejected`: summary includes the asked summary (if available), plus `request=<id> question=rejected`.
302
+
303
+ Event-specific fields:
304
+
305
+ - `autoContinue.onSessionError`
306
+ - `errorNames` (string[]): if non-empty, only match when `error.name` is in the list.
307
+ - `statusCodes` (integer[]): if non-empty, only match when `error.data.statusCode` is in the list.
308
+ - `retryableOnly` (boolean): when true, only match when `error.data.isRetryable === true`.
309
+ - `autoContinue.onSessionRetry`
310
+ - `attemptAtLeast` (integer, min 1): only match when `status.attempt >= attemptAtLeast`.
311
+
312
+ ### Operational notes (non-configurable behavior)
313
+
314
+ - `session.idle` / `session.status=idle` always triggers an auto-continue check and cannot be disabled.
315
+ - Auto-continue only sends when an active plan exists and its next pending step has `executor="ai"`.
316
+ - Step wait annotations
317
+ - `planpilot step wait <step_id> --delay <ms> [--reason <text>]` stores wait markers in the step comment:
318
+ - `@wait-until=<epoch_ms>`
319
+ - `@wait-reason=<text>`
320
+ - When present, Planpilot will delay auto-sending that step until the timestamp.
321
+ - Manual-stop protection
322
+ - If OpenCode emits `MessageAbortedError`, Planpilot arms a manual-stop guard.
323
+ - While active: queued triggers/retries are canceled and auto-sends are suppressed.
324
+ - The guard is cleared when a new user message arrives.
325
+
326
+ ### How to modify configuration
327
+
328
+ - Edit the JSON file directly
329
+ - Default path: `~/.config/opencode/.planpilot/config.json`
330
+ - Or set `OPENCODE_PLANPILOT_CONFIG` to use a different file.
331
+ - Use OpenCode Studio
332
+ - The Studio settings panel uses bridge actions `config.get` / `config.set`.
333
+ - The runtime toggle uses `runtime.pause` / `runtime.resume` (persists `runtime.paused`).
334
+
335
+ ### Studio settings panel mapping (settingsSchema)
336
+
337
+ Planpilot advertises the `settings.panel` capability and ships a JSON Schema in `dist/studio.manifest.json` under `settingsSchema`.
338
+ OpenCode Studio uses this schema to render the settings form.
339
+
340
+ The schema is authored in `tsup.config.ts` and written into the generated Studio manifest at build time.
341
+
342
+ The settings panel fields map 1:1 to the config file object. The UI group/label corresponds to a config path like `autoContinue.onSessionError.keywords.any`.
343
+
344
+ Top-level groups:
345
+
346
+ - `Runtime` -> `runtime`
347
+ - `Auto continue` -> `autoContinue`
348
+
349
+ #### Runtime
350
+
351
+ - `Runtime > Paused` -> `runtime.paused` (boolean, default `false`)
352
+
353
+ #### Auto continue
354
+
355
+ Retry failed sends:
356
+
357
+ - `Auto continue > Retry failed sends > Enabled` -> `autoContinue.sendRetry.enabled` (boolean, default `true`)
358
+ - `Auto continue > Retry failed sends > Max attempts` -> `autoContinue.sendRetry.maxAttempts` (integer, minimum `1`, default `3`)
359
+ - `Auto continue > Retry failed sends > Delays (ms)` -> `autoContinue.sendRetry.delaysMs` (integer[], items minimum `1`, default `[1500, 5000, 15000]`)
360
+
361
+ Session error trigger:
362
+
363
+ - `Auto continue > On session error > Enabled` -> `autoContinue.onSessionError.enabled` (boolean, default `false`)
364
+ - `Auto continue > On session error > Force` -> `autoContinue.onSessionError.force` (boolean, default `true`)
365
+ - `Auto continue > On session error > Keywords > Any` -> `autoContinue.onSessionError.keywords.any` (string[], default `[]`)
366
+ - `Auto continue > On session error > Keywords > All` -> `autoContinue.onSessionError.keywords.all` (string[], default `[]`)
367
+ - `Auto continue > On session error > Keywords > None` -> `autoContinue.onSessionError.keywords.none` (string[], default `[]`)
368
+ - `Auto continue > On session error > Keywords > Match case` -> `autoContinue.onSessionError.keywords.matchCase` (boolean, default `false`)
369
+ - `Auto continue > On session error > Error names` -> `autoContinue.onSessionError.errorNames` (string[], default `[]`)
370
+ - `Auto continue > On session error > Status codes` -> `autoContinue.onSessionError.statusCodes` (integer[], default `[]`)
371
+ - `Auto continue > On session error > Retryable only` -> `autoContinue.onSessionError.retryableOnly` (boolean, default `false`)
372
+
373
+ Session retry trigger:
374
+
375
+ - `Auto continue > On session retry > Enabled` -> `autoContinue.onSessionRetry.enabled` (boolean, default `false`)
376
+ - `Auto continue > On session retry > Force` -> `autoContinue.onSessionRetry.force` (boolean, default `false`)
377
+ - `Auto continue > On session retry > Keywords > Any` -> `autoContinue.onSessionRetry.keywords.any` (string[], default `[]`)
378
+ - `Auto continue > On session retry > Keywords > All` -> `autoContinue.onSessionRetry.keywords.all` (string[], default `[]`)
379
+ - `Auto continue > On session retry > Keywords > None` -> `autoContinue.onSessionRetry.keywords.none` (string[], default `[]`)
380
+ - `Auto continue > On session retry > Keywords > Match case` -> `autoContinue.onSessionRetry.keywords.matchCase` (boolean, default `false`)
381
+ - `Auto continue > On session retry > Attempt at least` -> `autoContinue.onSessionRetry.attemptAtLeast` (integer, minimum `1`, default `1`)
382
+
383
+ Permission triggers:
384
+
385
+ - `Auto continue > On permission asked > Enabled` -> `autoContinue.onPermissionAsked.enabled` (boolean, default `false`)
386
+ - `Auto continue > On permission asked > Force` -> `autoContinue.onPermissionAsked.force` (boolean, default `false`)
387
+ - `Auto continue > On permission asked > Keywords > Any` -> `autoContinue.onPermissionAsked.keywords.any` (string[], default `[]`)
388
+ - `Auto continue > On permission asked > Keywords > All` -> `autoContinue.onPermissionAsked.keywords.all` (string[], default `[]`)
389
+ - `Auto continue > On permission asked > Keywords > None` -> `autoContinue.onPermissionAsked.keywords.none` (string[], default `[]`)
390
+ - `Auto continue > On permission asked > Keywords > Match case` -> `autoContinue.onPermissionAsked.keywords.matchCase` (boolean, default `false`)
391
+
392
+ - `Auto continue > On permission rejected > Enabled` -> `autoContinue.onPermissionRejected.enabled` (boolean, default `false`)
393
+ - `Auto continue > On permission rejected > Force` -> `autoContinue.onPermissionRejected.force` (boolean, default `true`)
394
+ - `Auto continue > On permission rejected > Keywords > Any` -> `autoContinue.onPermissionRejected.keywords.any` (string[], default `[]`)
395
+ - `Auto continue > On permission rejected > Keywords > All` -> `autoContinue.onPermissionRejected.keywords.all` (string[], default `[]`)
396
+ - `Auto continue > On permission rejected > Keywords > None` -> `autoContinue.onPermissionRejected.keywords.none` (string[], default `[]`)
397
+ - `Auto continue > On permission rejected > Keywords > Match case` -> `autoContinue.onPermissionRejected.keywords.matchCase` (boolean, default `false`)
398
+
399
+ Question triggers:
400
+
401
+ - `Auto continue > On question asked > Enabled` -> `autoContinue.onQuestionAsked.enabled` (boolean, default `false`)
402
+ - `Auto continue > On question asked > Force` -> `autoContinue.onQuestionAsked.force` (boolean, default `false`)
403
+ - `Auto continue > On question asked > Keywords > Any` -> `autoContinue.onQuestionAsked.keywords.any` (string[], default `[]`)
404
+ - `Auto continue > On question asked > Keywords > All` -> `autoContinue.onQuestionAsked.keywords.all` (string[], default `[]`)
405
+ - `Auto continue > On question asked > Keywords > None` -> `autoContinue.onQuestionAsked.keywords.none` (string[], default `[]`)
406
+ - `Auto continue > On question asked > Keywords > Match case` -> `autoContinue.onQuestionAsked.keywords.matchCase` (boolean, default `false`)
407
+
408
+ - `Auto continue > On question rejected > Enabled` -> `autoContinue.onQuestionRejected.enabled` (boolean, default `false`)
409
+ - `Auto continue > On question rejected > Force` -> `autoContinue.onQuestionRejected.force` (boolean, default `true`)
410
+ - `Auto continue > On question rejected > Keywords > Any` -> `autoContinue.onQuestionRejected.keywords.any` (string[], default `[]`)
411
+ - `Auto continue > On question rejected > Keywords > All` -> `autoContinue.onQuestionRejected.keywords.all` (string[], default `[]`)
412
+ - `Auto continue > On question rejected > Keywords > None` -> `autoContinue.onQuestionRejected.keywords.none` (string[], default `[]`)
413
+ - `Auto continue > On question rejected > Keywords > Match case` -> `autoContinue.onQuestionRejected.keywords.matchCase` (boolean, default `false`)
414
+
415
+ Notes:
416
+
417
+ - Studio persists settings by calling `config.set` and Planpilot writes the normalized config to the resolved config path.
418
+ - `runtime.pause` / `runtime.resume` also persist `runtime.paused` (and are used by the bundled runtime UI), but are separate from the settings panel.
419
+
23
420
  ## License
24
421
  MIT
@@ -0,0 +1,5 @@
1
+ import { Plugin } from '@opencode-ai/plugin';
2
+
3
+ declare const PlanpilotPlugin: Plugin;
4
+
5
+ export { PlanpilotPlugin, PlanpilotPlugin as default };