opencode-planpilot 0.2.2 → 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 +397 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3815 -0
- package/dist/index.js.map +1 -0
- package/dist/studio-bridge.d.ts +2 -0
- package/dist/studio-bridge.js +1985 -0
- package/dist/studio-bridge.js.map +1 -0
- package/dist/studio-web/planpilot-todo-bar.d.ts +33 -0
- package/dist/studio-web/planpilot-todo-bar.js +704 -0
- package/dist/studio-web/planpilot-todo-bar.js.map +1 -0
- package/dist/studio.manifest.json +968 -0
- package/package.json +6 -1
- package/src/command.ts +0 -13
- package/src/index.ts +587 -26
- package/src/lib/config.ts +436 -0
- package/src/prompt.ts +7 -2
- package/src/studio/bridge.ts +746 -0
- package/src/studio-web/main.ts +1030 -0
- package/src/studio-web/planpilot-todo-bar.ts +974 -0
package/src/index.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { tool, type Plugin } from "@opencode-ai/plugin"
|
|
2
2
|
import { runCommand, formatCommandError } from "./command"
|
|
3
3
|
import { PlanpilotApp } from "./lib/app"
|
|
4
|
+
import {
|
|
5
|
+
loadPlanpilotConfig,
|
|
6
|
+
matchesKeywords,
|
|
7
|
+
type EventRule,
|
|
8
|
+
type SendRetryConfig,
|
|
9
|
+
type SessionErrorRule,
|
|
10
|
+
type SessionRetryRule,
|
|
11
|
+
} from "./lib/config"
|
|
4
12
|
import { openDatabase } from "./lib/db"
|
|
5
13
|
import { invalidInput } from "./lib/errors"
|
|
6
14
|
import { formatStepDetail } from "./lib/format"
|
|
@@ -8,12 +16,42 @@ import { parseWaitFromComment } from "./lib/util"
|
|
|
8
16
|
import { PLANPILOT_SYSTEM_INJECTION, PLANPILOT_TOOL_DESCRIPTION, formatPlanpilotAutoContinueMessage } from "./prompt"
|
|
9
17
|
|
|
10
18
|
export const PlanpilotPlugin: Plugin = async (ctx) => {
|
|
19
|
+
const IDLE_DEBOUNCE_MS = 1000
|
|
20
|
+
const RECENT_SEND_DEDUPE_MS = 1500
|
|
21
|
+
const TRIGGER_TTL_MS = 10 * 60 * 1000
|
|
22
|
+
|
|
11
23
|
const inFlight = new Set<string>()
|
|
12
24
|
const skipNextAuto = new Set<string>()
|
|
13
25
|
const lastIdleAt = new Map<string, number>()
|
|
26
|
+
const pendingTrigger = new Map<string, AutoTrigger>()
|
|
27
|
+
const recentSends = new Map<string, { signature: string; at: number }>()
|
|
28
|
+
const sendRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
29
|
+
const sendRetryState = new Map<string, { signature: string; attempt: number }>()
|
|
30
|
+
const manualStop = new Map<string, { at: number; reason: string }>()
|
|
14
31
|
const waitTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
32
|
+
const permissionAsked = new Map<string, { sessionID: string; summary: string }>()
|
|
33
|
+
const questionAsked = new Map<string, { sessionID: string; summary: string }>()
|
|
15
34
|
const runSeq = new Map<string, number>()
|
|
16
35
|
|
|
36
|
+
const loadedConfig = loadPlanpilotConfig()
|
|
37
|
+
const autoConfig = loadedConfig.config.autoContinue
|
|
38
|
+
|
|
39
|
+
type AutoTrigger = {
|
|
40
|
+
source: string
|
|
41
|
+
force: boolean
|
|
42
|
+
detail?: string
|
|
43
|
+
at: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type RetryInput = {
|
|
47
|
+
sessionID: string
|
|
48
|
+
signature: string
|
|
49
|
+
source: string
|
|
50
|
+
force: boolean
|
|
51
|
+
detail?: string
|
|
52
|
+
error: unknown
|
|
53
|
+
}
|
|
54
|
+
|
|
17
55
|
const clearWaitTimer = (sessionID: string) => {
|
|
18
56
|
const existing = waitTimers.get(sessionID)
|
|
19
57
|
if (existing) {
|
|
@@ -41,6 +79,135 @@ export const PlanpilotPlugin: Plugin = async (ctx) => {
|
|
|
41
79
|
await log("debug", message, extra)
|
|
42
80
|
}
|
|
43
81
|
|
|
82
|
+
const clearSendRetryTimer = (sessionID: string) => {
|
|
83
|
+
const timer = sendRetryTimers.get(sessionID)
|
|
84
|
+
if (timer) {
|
|
85
|
+
clearTimeout(timer)
|
|
86
|
+
sendRetryTimers.delete(sessionID)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const clearSendRetry = (sessionID: string) => {
|
|
91
|
+
clearSendRetryTimer(sessionID)
|
|
92
|
+
sendRetryState.delete(sessionID)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const clearManualStop = async (sessionID: string, source: string) => {
|
|
96
|
+
if (!manualStop.has(sessionID)) return
|
|
97
|
+
manualStop.delete(sessionID)
|
|
98
|
+
await logDebug("manual-stop guard cleared", { sessionID, source })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const setManualStop = async (sessionID: string, reason: string, source: string) => {
|
|
102
|
+
manualStop.set(sessionID, {
|
|
103
|
+
at: Date.now(),
|
|
104
|
+
reason,
|
|
105
|
+
})
|
|
106
|
+
pendingTrigger.delete(sessionID)
|
|
107
|
+
clearSendRetry(sessionID)
|
|
108
|
+
await log("info", "manual-stop guard armed", {
|
|
109
|
+
sessionID,
|
|
110
|
+
source,
|
|
111
|
+
reason,
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const stringifyError = (error: unknown): string => {
|
|
116
|
+
if (error instanceof Error) return error.message
|
|
117
|
+
if (typeof error === "string") return error
|
|
118
|
+
return String(error)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const isManualStopError = (error: unknown): boolean => {
|
|
122
|
+
const text = stringifyError(error).toLowerCase()
|
|
123
|
+
return text.includes("aborted") || text.includes("cancel") || text.includes("canceled")
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const buildRetryDetail = (baseDetail: string | undefined, attempt: number, max: number, message: string) =>
|
|
127
|
+
toSummary([
|
|
128
|
+
baseDetail,
|
|
129
|
+
`send-retry=${attempt}/${max}`,
|
|
130
|
+
message ? `error=${message}` : undefined,
|
|
131
|
+
])
|
|
132
|
+
|
|
133
|
+
const scheduleSendRetry = async (input: RetryInput) => {
|
|
134
|
+
const cfg: SendRetryConfig = autoConfig.sendRetry
|
|
135
|
+
if (!cfg.enabled) return
|
|
136
|
+
|
|
137
|
+
const blocked = manualStop.get(input.sessionID)
|
|
138
|
+
if (blocked) {
|
|
139
|
+
await logDebug("send retry skipped: manual-stop guard active", {
|
|
140
|
+
sessionID: input.sessionID,
|
|
141
|
+
source: input.source,
|
|
142
|
+
reason: blocked.reason,
|
|
143
|
+
})
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const text = stringifyError(input.error)
|
|
148
|
+
if (isManualStopError(input.error)) {
|
|
149
|
+
await logDebug("send retry skipped: manual-stop style error", {
|
|
150
|
+
sessionID: input.sessionID,
|
|
151
|
+
source: input.source,
|
|
152
|
+
error: text,
|
|
153
|
+
})
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const previous = sendRetryState.get(input.sessionID)
|
|
158
|
+
const attempt =
|
|
159
|
+
previous && previous.signature === input.signature
|
|
160
|
+
? previous.attempt + 1
|
|
161
|
+
: 1
|
|
162
|
+
if (attempt > cfg.maxAttempts) {
|
|
163
|
+
clearSendRetry(input.sessionID)
|
|
164
|
+
await log("warn", "send retry exhausted", {
|
|
165
|
+
sessionID: input.sessionID,
|
|
166
|
+
source: input.source,
|
|
167
|
+
signature: input.signature,
|
|
168
|
+
maxAttempts: cfg.maxAttempts,
|
|
169
|
+
error: text,
|
|
170
|
+
})
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
sendRetryState.set(input.sessionID, {
|
|
175
|
+
signature: input.signature,
|
|
176
|
+
attempt,
|
|
177
|
+
})
|
|
178
|
+
clearSendRetryTimer(input.sessionID)
|
|
179
|
+
|
|
180
|
+
const index = Math.min(Math.max(attempt - 1, 0), cfg.delaysMs.length - 1)
|
|
181
|
+
const delayMs = cfg.delaysMs[index]
|
|
182
|
+
const retryDetail = buildRetryDetail(input.detail, attempt, cfg.maxAttempts, text)
|
|
183
|
+
|
|
184
|
+
const timer = setTimeout(() => {
|
|
185
|
+
sendRetryTimers.delete(input.sessionID)
|
|
186
|
+
queueTrigger(input.sessionID, {
|
|
187
|
+
source: `${input.source}.send_retry`,
|
|
188
|
+
force: input.force,
|
|
189
|
+
detail: retryDetail,
|
|
190
|
+
}).catch((err) => {
|
|
191
|
+
void log("warn", "send retry trigger failed", {
|
|
192
|
+
sessionID: input.sessionID,
|
|
193
|
+
source: input.source,
|
|
194
|
+
error: stringifyError(err),
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
}, delayMs)
|
|
198
|
+
sendRetryTimers.set(input.sessionID, timer)
|
|
199
|
+
|
|
200
|
+
await log("info", "send retry scheduled", {
|
|
201
|
+
sessionID: input.sessionID,
|
|
202
|
+
source: input.source,
|
|
203
|
+
signature: input.signature,
|
|
204
|
+
attempt,
|
|
205
|
+
maxAttempts: cfg.maxAttempts,
|
|
206
|
+
delayMs,
|
|
207
|
+
error: text,
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
44
211
|
const nextRun = (sessionID: string) => {
|
|
45
212
|
const next = (runSeq.get(sessionID) ?? 0) + 1
|
|
46
213
|
runSeq.set(sessionID, next)
|
|
@@ -63,6 +230,11 @@ export const PlanpilotPlugin: Plugin = async (ctx) => {
|
|
|
63
230
|
}
|
|
64
231
|
error?: {
|
|
65
232
|
name?: string
|
|
233
|
+
data?: {
|
|
234
|
+
message?: string
|
|
235
|
+
statusCode?: number
|
|
236
|
+
isRetryable?: boolean
|
|
237
|
+
}
|
|
66
238
|
}
|
|
67
239
|
finish?: string
|
|
68
240
|
}
|
|
@@ -126,55 +298,225 @@ export const PlanpilotPlugin: Plugin = async (ctx) => {
|
|
|
126
298
|
missingUser: false,
|
|
127
299
|
assistantFinish: finish,
|
|
128
300
|
assistantErrorName: typeof error === "object" && error ? (error as any).name : undefined,
|
|
301
|
+
assistantErrorMessage:
|
|
302
|
+
typeof error === "object" && error && typeof (error as any).data?.message === "string"
|
|
303
|
+
? ((error as any).data.message as string)
|
|
304
|
+
: undefined,
|
|
305
|
+
assistantErrorStatusCode:
|
|
306
|
+
typeof error === "object" &&
|
|
307
|
+
error &&
|
|
308
|
+
typeof (error as any).data?.statusCode === "number" &&
|
|
309
|
+
Number.isFinite((error as any).data.statusCode)
|
|
310
|
+
? Math.trunc((error as any).data.statusCode as number)
|
|
311
|
+
: undefined,
|
|
312
|
+
assistantErrorRetryable:
|
|
313
|
+
typeof error === "object" && error && typeof (error as any).data?.isRetryable === "boolean"
|
|
314
|
+
? ((error as any).data.isRetryable as boolean)
|
|
315
|
+
: undefined,
|
|
129
316
|
}
|
|
130
317
|
}
|
|
131
318
|
|
|
319
|
+
const toSummary = (parts: Array<string | undefined>) =>
|
|
320
|
+
parts
|
|
321
|
+
.filter((part): part is string => typeof part === "string" && part.trim().length > 0)
|
|
322
|
+
.join(" ")
|
|
323
|
+
.trim()
|
|
324
|
+
|
|
325
|
+
const toStringArray = (value: unknown) =>
|
|
326
|
+
Array.isArray(value)
|
|
327
|
+
? value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean)
|
|
328
|
+
: []
|
|
329
|
+
|
|
330
|
+
const summarizePermissionEvent = (properties: any): string => {
|
|
331
|
+
const permission = typeof properties?.permission === "string" ? properties.permission.trim() : ""
|
|
332
|
+
const patterns = toStringArray(properties?.patterns)
|
|
333
|
+
return toSummary([permission, patterns.length ? `patterns=${patterns.join(",")}` : undefined])
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const summarizeQuestionEvent = (properties: any): string => {
|
|
337
|
+
const questions = Array.isArray(properties?.questions) ? properties.questions : []
|
|
338
|
+
const pieces = questions
|
|
339
|
+
.map((item: any) =>
|
|
340
|
+
toSummary([
|
|
341
|
+
typeof item?.header === "string" ? item.header.trim() : undefined,
|
|
342
|
+
typeof item?.question === "string" ? item.question.trim() : undefined,
|
|
343
|
+
]),
|
|
344
|
+
)
|
|
345
|
+
.filter(Boolean)
|
|
346
|
+
return pieces.join(" | ")
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const shouldTriggerEventRule = (rule: EventRule, text: string): boolean => {
|
|
350
|
+
if (!rule.enabled) return false
|
|
351
|
+
return matchesKeywords(text, rule.keywords)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const shouldTriggerSessionError = (rule: SessionErrorRule, error: any): { matched: boolean; detail: string } => {
|
|
355
|
+
if (!rule.enabled) return { matched: false, detail: "" }
|
|
356
|
+
if (!error || typeof error !== "object") return { matched: false, detail: "" }
|
|
357
|
+
|
|
358
|
+
const name = typeof error.name === "string" ? error.name : ""
|
|
359
|
+
const message = typeof error.data?.message === "string" ? error.data.message : ""
|
|
360
|
+
const statusCode =
|
|
361
|
+
typeof error.data?.statusCode === "number" && Number.isFinite(error.data.statusCode)
|
|
362
|
+
? Math.trunc(error.data.statusCode)
|
|
363
|
+
: undefined
|
|
364
|
+
const retryable = typeof error.data?.isRetryable === "boolean" ? error.data.isRetryable : undefined
|
|
365
|
+
|
|
366
|
+
if (rule.errorNames.length > 0 && !rule.errorNames.includes(name)) {
|
|
367
|
+
return { matched: false, detail: "" }
|
|
368
|
+
}
|
|
369
|
+
if (rule.statusCodes.length > 0) {
|
|
370
|
+
if (statusCode === undefined || !rule.statusCodes.includes(statusCode)) {
|
|
371
|
+
return { matched: false, detail: "" }
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (rule.retryableOnly && retryable !== true) {
|
|
375
|
+
return { matched: false, detail: "" }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const detail = toSummary([
|
|
379
|
+
name ? `error=${name}` : undefined,
|
|
380
|
+
statusCode !== undefined ? `status=${statusCode}` : undefined,
|
|
381
|
+
retryable !== undefined ? `retryable=${retryable}` : undefined,
|
|
382
|
+
message,
|
|
383
|
+
])
|
|
384
|
+
if (!matchesKeywords(detail, rule.keywords)) {
|
|
385
|
+
return { matched: false, detail }
|
|
386
|
+
}
|
|
387
|
+
return { matched: true, detail }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const shouldTriggerSessionRetry = (rule: SessionRetryRule, status: any): { matched: boolean; detail: string } => {
|
|
391
|
+
if (!rule.enabled) return { matched: false, detail: "" }
|
|
392
|
+
if (!status || typeof status !== "object") return { matched: false, detail: "" }
|
|
393
|
+
if (status.type !== "retry") return { matched: false, detail: "" }
|
|
394
|
+
|
|
395
|
+
const attempt = typeof status.attempt === "number" && Number.isFinite(status.attempt) ? Math.trunc(status.attempt) : 0
|
|
396
|
+
const message = typeof status.message === "string" ? status.message : ""
|
|
397
|
+
const next = typeof status.next === "number" && Number.isFinite(status.next) ? Math.trunc(status.next) : undefined
|
|
398
|
+
if (attempt < rule.attemptAtLeast) {
|
|
399
|
+
return { matched: false, detail: "" }
|
|
400
|
+
}
|
|
401
|
+
const detail = toSummary([
|
|
402
|
+
`attempt=${attempt}`,
|
|
403
|
+
next !== undefined ? `next=${next}` : undefined,
|
|
404
|
+
message,
|
|
405
|
+
])
|
|
406
|
+
if (!matchesKeywords(detail, rule.keywords)) {
|
|
407
|
+
return { matched: false, detail }
|
|
408
|
+
}
|
|
409
|
+
return { matched: true, detail }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const readTrigger = (sessionID: string): AutoTrigger | undefined => {
|
|
413
|
+
const current = pendingTrigger.get(sessionID)
|
|
414
|
+
if (!current) return undefined
|
|
415
|
+
if (Date.now() - current.at > TRIGGER_TTL_MS) {
|
|
416
|
+
pendingTrigger.delete(sessionID)
|
|
417
|
+
return undefined
|
|
418
|
+
}
|
|
419
|
+
return current
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const queueTrigger = async (sessionID: string, trigger: Omit<AutoTrigger, "at">) => {
|
|
423
|
+
if (manualStop.has(sessionID)) {
|
|
424
|
+
await logDebug("auto-continue trigger skipped: manual-stop guard active", {
|
|
425
|
+
sessionID,
|
|
426
|
+
source: trigger.source,
|
|
427
|
+
})
|
|
428
|
+
return
|
|
429
|
+
}
|
|
430
|
+
pendingTrigger.set(sessionID, {
|
|
431
|
+
...trigger,
|
|
432
|
+
at: Date.now(),
|
|
433
|
+
})
|
|
434
|
+
await logDebug("auto-continue trigger queued", {
|
|
435
|
+
sessionID,
|
|
436
|
+
source: trigger.source,
|
|
437
|
+
force: trigger.force,
|
|
438
|
+
detail: trigger.detail,
|
|
439
|
+
})
|
|
440
|
+
await handleSessionIdle(sessionID, trigger.source)
|
|
441
|
+
}
|
|
442
|
+
|
|
132
443
|
const handleSessionIdle = async (sessionID: string, source: string) => {
|
|
133
444
|
const now = Date.now()
|
|
134
445
|
const run = nextRun(sessionID)
|
|
446
|
+
const trigger = readTrigger(sessionID)
|
|
447
|
+
const force = trigger?.force === true
|
|
448
|
+
const idleSource = source === "session.idle" || source === "session.status"
|
|
135
449
|
|
|
136
450
|
if (inFlight.has(sessionID)) {
|
|
137
|
-
await logDebug("auto-continue skipped: already in-flight", { sessionID, source, run })
|
|
451
|
+
await logDebug("auto-continue skipped: already in-flight", { sessionID, source, run, trigger: trigger?.source })
|
|
138
452
|
return
|
|
139
453
|
}
|
|
454
|
+
|
|
455
|
+
const stopped = manualStop.get(sessionID)
|
|
456
|
+
if (stopped) {
|
|
457
|
+
pendingTrigger.delete(sessionID)
|
|
458
|
+
await logDebug("auto-continue skipped: manual-stop guard active", {
|
|
459
|
+
sessionID,
|
|
460
|
+
source,
|
|
461
|
+
run,
|
|
462
|
+
stopAt: stopped.at,
|
|
463
|
+
stopReason: stopped.reason,
|
|
464
|
+
})
|
|
465
|
+
return
|
|
466
|
+
}
|
|
467
|
+
|
|
140
468
|
inFlight.add(sessionID)
|
|
141
469
|
try {
|
|
142
470
|
if (skipNextAuto.has(sessionID)) {
|
|
143
471
|
skipNextAuto.delete(sessionID)
|
|
144
|
-
|
|
472
|
+
pendingTrigger.delete(sessionID)
|
|
473
|
+
await logDebug("auto-continue skipped: skipNextAuto", { sessionID, source, run, trigger: trigger?.source })
|
|
145
474
|
return
|
|
146
475
|
}
|
|
147
476
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
477
|
+
if (idleSource && !trigger) {
|
|
478
|
+
const lastIdle = lastIdleAt.get(sessionID)
|
|
479
|
+
if (lastIdle && now - lastIdle < IDLE_DEBOUNCE_MS) {
|
|
480
|
+
await logDebug("auto-continue skipped: idle debounce", {
|
|
481
|
+
sessionID,
|
|
482
|
+
source,
|
|
483
|
+
run,
|
|
484
|
+
lastIdle,
|
|
485
|
+
now,
|
|
486
|
+
deltaMs: now - lastIdle,
|
|
487
|
+
})
|
|
488
|
+
return
|
|
489
|
+
}
|
|
159
490
|
}
|
|
160
491
|
|
|
161
|
-
|
|
492
|
+
if (idleSource) {
|
|
493
|
+
lastIdleAt.set(sessionID, now)
|
|
494
|
+
}
|
|
162
495
|
|
|
163
496
|
const app = new PlanpilotApp(openDatabase(), sessionID)
|
|
164
497
|
const active = app.getActivePlan()
|
|
165
498
|
if (!active) {
|
|
166
499
|
clearWaitTimer(sessionID)
|
|
167
|
-
|
|
500
|
+
pendingTrigger.delete(sessionID)
|
|
501
|
+
await logDebug("auto-continue skipped: no active plan", { sessionID, source, run, trigger: trigger?.source })
|
|
168
502
|
return
|
|
169
503
|
}
|
|
170
504
|
const next = app.nextStep(active.plan_id)
|
|
171
505
|
if (!next) {
|
|
172
506
|
clearWaitTimer(sessionID)
|
|
173
|
-
|
|
507
|
+
pendingTrigger.delete(sessionID)
|
|
508
|
+
await logDebug("auto-continue skipped: no pending step", {
|
|
509
|
+
sessionID,
|
|
510
|
+
source,
|
|
511
|
+
run,
|
|
512
|
+
planId: active.plan_id,
|
|
513
|
+
trigger: trigger?.source,
|
|
514
|
+
})
|
|
174
515
|
return
|
|
175
516
|
}
|
|
176
517
|
if (next.executor !== "ai") {
|
|
177
518
|
clearWaitTimer(sessionID)
|
|
519
|
+
pendingTrigger.delete(sessionID)
|
|
178
520
|
await logDebug("auto-continue skipped: next executor is not ai", {
|
|
179
521
|
sessionID,
|
|
180
522
|
source,
|
|
@@ -182,6 +524,7 @@ export const PlanpilotPlugin: Plugin = async (ctx) => {
|
|
|
182
524
|
planId: active.plan_id,
|
|
183
525
|
stepId: next.id,
|
|
184
526
|
executor: next.executor,
|
|
527
|
+
trigger: trigger?.source,
|
|
185
528
|
})
|
|
186
529
|
return
|
|
187
530
|
}
|
|
@@ -197,6 +540,7 @@ export const PlanpilotPlugin: Plugin = async (ctx) => {
|
|
|
197
540
|
stepId: next.id,
|
|
198
541
|
until: wait.until,
|
|
199
542
|
reason: wait.reason,
|
|
543
|
+
trigger: trigger?.source,
|
|
200
544
|
})
|
|
201
545
|
const msUntil = Math.max(0, wait.until - now)
|
|
202
546
|
const timer = setTimeout(() => {
|
|
@@ -218,32 +562,58 @@ export const PlanpilotPlugin: Plugin = async (ctx) => {
|
|
|
218
562
|
const goals = app.goalsForStep(next.id)
|
|
219
563
|
const detail = formatStepDetail(next, goals)
|
|
220
564
|
if (!detail.trim()) {
|
|
565
|
+
pendingTrigger.delete(sessionID)
|
|
221
566
|
await log("warn", "auto-continue stopped: empty step detail", {
|
|
222
567
|
sessionID,
|
|
223
568
|
source,
|
|
224
569
|
run,
|
|
225
570
|
planId: active.plan_id,
|
|
226
571
|
stepId: next.id,
|
|
572
|
+
trigger: trigger?.source,
|
|
573
|
+
})
|
|
574
|
+
return
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const signature = `${active.plan_id}:${next.id}`
|
|
578
|
+
const retryState = sendRetryState.get(sessionID)
|
|
579
|
+
if (retryState && retryState.signature !== signature) {
|
|
580
|
+
clearSendRetry(sessionID)
|
|
581
|
+
}
|
|
582
|
+
const recent = recentSends.get(sessionID)
|
|
583
|
+
if (recent && recent.signature === signature && now - recent.at < RECENT_SEND_DEDUPE_MS) {
|
|
584
|
+
pendingTrigger.delete(sessionID)
|
|
585
|
+
await logDebug("auto-continue skipped: duplicate send window", {
|
|
586
|
+
sessionID,
|
|
587
|
+
source,
|
|
588
|
+
run,
|
|
589
|
+
planId: active.plan_id,
|
|
590
|
+
stepId: next.id,
|
|
591
|
+
trigger: trigger?.source,
|
|
592
|
+
deltaMs: now - recent.at,
|
|
227
593
|
})
|
|
228
594
|
return
|
|
229
595
|
}
|
|
230
596
|
|
|
231
597
|
const autoContext = await resolveAutoContext(sessionID)
|
|
232
598
|
if (autoContext?.missingUser) {
|
|
233
|
-
|
|
599
|
+
pendingTrigger.delete(sessionID)
|
|
600
|
+
await log("warn", "auto-continue stopped: missing user context", { sessionID, source, run, trigger: trigger?.source })
|
|
234
601
|
return
|
|
235
602
|
}
|
|
236
603
|
if (!autoContext) {
|
|
604
|
+
pendingTrigger.delete(sessionID)
|
|
237
605
|
await logDebug("auto-continue stopped: missing autoContext (no recent messages?)", {
|
|
238
606
|
sessionID,
|
|
239
607
|
source,
|
|
240
608
|
run,
|
|
241
609
|
planId: active.plan_id,
|
|
242
610
|
stepId: next.id,
|
|
611
|
+
trigger: trigger?.source,
|
|
243
612
|
})
|
|
244
613
|
return
|
|
245
614
|
}
|
|
246
|
-
if (autoContext.aborted) {
|
|
615
|
+
if (autoContext.aborted && !force) {
|
|
616
|
+
pendingTrigger.delete(sessionID)
|
|
247
617
|
await logDebug("auto-continue skipped: last assistant aborted", {
|
|
248
618
|
sessionID,
|
|
249
619
|
source,
|
|
@@ -251,11 +621,14 @@ export const PlanpilotPlugin: Plugin = async (ctx) => {
|
|
|
251
621
|
planId: active.plan_id,
|
|
252
622
|
stepId: next.id,
|
|
253
623
|
assistantErrorName: autoContext.assistantErrorName,
|
|
624
|
+
assistantErrorMessage: autoContext.assistantErrorMessage,
|
|
254
625
|
assistantFinish: autoContext.assistantFinish,
|
|
626
|
+
trigger: trigger?.source,
|
|
255
627
|
})
|
|
256
628
|
return
|
|
257
629
|
}
|
|
258
|
-
if (autoContext.ready === false) {
|
|
630
|
+
if (autoContext.ready === false && !force) {
|
|
631
|
+
pendingTrigger.delete(sessionID)
|
|
259
632
|
await logDebug("auto-continue skipped: last assistant not ready", {
|
|
260
633
|
sessionID,
|
|
261
634
|
source,
|
|
@@ -263,6 +636,9 @@ export const PlanpilotPlugin: Plugin = async (ctx) => {
|
|
|
263
636
|
planId: active.plan_id,
|
|
264
637
|
stepId: next.id,
|
|
265
638
|
assistantFinish: autoContext.assistantFinish,
|
|
639
|
+
assistantErrorName: autoContext.assistantErrorName,
|
|
640
|
+
assistantErrorMessage: autoContext.assistantErrorMessage,
|
|
641
|
+
trigger: trigger?.source,
|
|
266
642
|
})
|
|
267
643
|
return
|
|
268
644
|
}
|
|
@@ -271,14 +647,15 @@ export const PlanpilotPlugin: Plugin = async (ctx) => {
|
|
|
271
647
|
const message = formatPlanpilotAutoContinueMessage({
|
|
272
648
|
timestamp,
|
|
273
649
|
stepDetail: detail,
|
|
650
|
+
triggerDetail: trigger?.detail,
|
|
274
651
|
})
|
|
275
652
|
|
|
276
653
|
const promptBody: any = {
|
|
277
|
-
agent: autoContext
|
|
278
|
-
model: autoContext
|
|
654
|
+
agent: autoContext.agent ?? undefined,
|
|
655
|
+
model: autoContext.model ?? undefined,
|
|
279
656
|
parts: [{ type: "text" as const, text: message }],
|
|
280
657
|
}
|
|
281
|
-
if (autoContext
|
|
658
|
+
if (autoContext.variant) {
|
|
282
659
|
promptBody.variant = autoContext.variant
|
|
283
660
|
}
|
|
284
661
|
|
|
@@ -288,6 +665,8 @@ export const PlanpilotPlugin: Plugin = async (ctx) => {
|
|
|
288
665
|
run,
|
|
289
666
|
planId: active.plan_id,
|
|
290
667
|
stepId: next.id,
|
|
668
|
+
trigger: trigger?.source,
|
|
669
|
+
force,
|
|
291
670
|
agent: promptBody.agent,
|
|
292
671
|
model: promptBody.model,
|
|
293
672
|
variant: promptBody.variant,
|
|
@@ -297,14 +676,37 @@ export const PlanpilotPlugin: Plugin = async (ctx) => {
|
|
|
297
676
|
await ctx.client.session.promptAsync({
|
|
298
677
|
path: { id: sessionID },
|
|
299
678
|
body: promptBody,
|
|
679
|
+
// OpenCode server routes requests to the correct instance (project) using this header.
|
|
680
|
+
// Without it, the server falls back to process.cwd(), which breaks when OpenCode is
|
|
681
|
+
// managed by opencode-studio (cwd != active project directory).
|
|
682
|
+
headers: ctx.directory ? { "x-opencode-directory": ctx.directory } : undefined,
|
|
683
|
+
}).catch(async (err) => {
|
|
684
|
+
await scheduleSendRetry({
|
|
685
|
+
sessionID,
|
|
686
|
+
signature,
|
|
687
|
+
source,
|
|
688
|
+
force,
|
|
689
|
+
detail: trigger?.detail,
|
|
690
|
+
error: err,
|
|
691
|
+
})
|
|
692
|
+
throw err
|
|
300
693
|
})
|
|
301
694
|
|
|
695
|
+
recentSends.set(sessionID, {
|
|
696
|
+
signature,
|
|
697
|
+
at: Date.now(),
|
|
698
|
+
})
|
|
699
|
+
clearSendRetry(sessionID)
|
|
700
|
+
pendingTrigger.delete(sessionID)
|
|
701
|
+
|
|
302
702
|
await log("info", "auto-continue prompt_async accepted", {
|
|
303
703
|
sessionID,
|
|
304
704
|
source,
|
|
305
705
|
run,
|
|
306
706
|
planId: active.plan_id,
|
|
307
707
|
stepId: next.id,
|
|
708
|
+
trigger: trigger?.source,
|
|
709
|
+
force,
|
|
308
710
|
})
|
|
309
711
|
} catch (err) {
|
|
310
712
|
await log("warn", "failed to auto-continue plan", {
|
|
@@ -318,9 +720,18 @@ export const PlanpilotPlugin: Plugin = async (ctx) => {
|
|
|
318
720
|
}
|
|
319
721
|
}
|
|
320
722
|
|
|
723
|
+
if (loadedConfig.loadError) {
|
|
724
|
+
await log("warn", "failed to load planpilot config, falling back to defaults", {
|
|
725
|
+
path: loadedConfig.path,
|
|
726
|
+
error: loadedConfig.loadError,
|
|
727
|
+
})
|
|
728
|
+
}
|
|
729
|
+
|
|
321
730
|
await log("info", "planpilot plugin initialized", {
|
|
322
731
|
directory: ctx.directory,
|
|
323
732
|
worktree: ctx.worktree,
|
|
733
|
+
configPath: loadedConfig.path,
|
|
734
|
+
configLoadedFromFile: loadedConfig.loadedFromFile,
|
|
324
735
|
})
|
|
325
736
|
|
|
326
737
|
return {
|
|
@@ -373,12 +784,162 @@ export const PlanpilotPlugin: Plugin = async (ctx) => {
|
|
|
373
784
|
output.context.push(PLANPILOT_TOOL_DESCRIPTION)
|
|
374
785
|
},
|
|
375
786
|
event: async ({ event }) => {
|
|
376
|
-
|
|
377
|
-
|
|
787
|
+
const evt = event as any
|
|
788
|
+
|
|
789
|
+
if (evt.type === "message.updated") {
|
|
790
|
+
const info = evt.properties?.info
|
|
791
|
+
const sessionID = typeof info?.sessionID === "string" ? info.sessionID : ""
|
|
792
|
+
if (!sessionID) return
|
|
793
|
+
|
|
794
|
+
if (info.role === "user") {
|
|
795
|
+
pendingTrigger.delete(sessionID)
|
|
796
|
+
clearSendRetry(sessionID)
|
|
797
|
+
await clearManualStop(sessionID, "message.updated.user")
|
|
798
|
+
return
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (info.role === "assistant" && info.error?.name === "MessageAbortedError") {
|
|
802
|
+
await setManualStop(sessionID, "assistant message aborted", "message.updated.assistant")
|
|
803
|
+
}
|
|
804
|
+
return
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if (evt.type === "session.idle") {
|
|
808
|
+
await handleSessionIdle(evt.properties.sessionID, "session.idle")
|
|
809
|
+
return
|
|
810
|
+
}
|
|
811
|
+
if (evt.type === "session.status") {
|
|
812
|
+
if (evt.properties.status.type === "idle") {
|
|
813
|
+
await handleSessionIdle(evt.properties.sessionID, "session.status")
|
|
814
|
+
return
|
|
815
|
+
}
|
|
816
|
+
const retryResult = shouldTriggerSessionRetry(autoConfig.onSessionRetry, evt.properties.status)
|
|
817
|
+
if (!retryResult.matched) return
|
|
818
|
+
await queueTrigger(evt.properties.sessionID, {
|
|
819
|
+
source: "session.status.retry",
|
|
820
|
+
force: autoConfig.onSessionRetry.force,
|
|
821
|
+
detail: retryResult.detail || "session status retry",
|
|
822
|
+
})
|
|
823
|
+
return
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (evt.type === "session.error") {
|
|
827
|
+
const sessionID = typeof evt.properties?.sessionID === "string" ? evt.properties.sessionID : ""
|
|
828
|
+
if (!sessionID) return
|
|
829
|
+
if (evt.properties?.error?.name === "MessageAbortedError") {
|
|
830
|
+
await setManualStop(sessionID, "session aborted", "session.error")
|
|
831
|
+
return
|
|
832
|
+
}
|
|
833
|
+
const errorResult = shouldTriggerSessionError(autoConfig.onSessionError, evt.properties?.error)
|
|
834
|
+
if (!errorResult.matched) return
|
|
835
|
+
await queueTrigger(sessionID, {
|
|
836
|
+
source: "session.error",
|
|
837
|
+
force: autoConfig.onSessionError.force,
|
|
838
|
+
detail: errorResult.detail || "session error",
|
|
839
|
+
})
|
|
840
|
+
return
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
if (evt.type === "permission.asked") {
|
|
844
|
+
const sessionID = typeof evt.properties?.sessionID === "string" ? evt.properties.sessionID : ""
|
|
845
|
+
if (!sessionID) return
|
|
846
|
+
const requestID = typeof evt.properties?.id === "string" ? evt.properties.id : ""
|
|
847
|
+
const summary = summarizePermissionEvent(evt.properties)
|
|
848
|
+
if (requestID) {
|
|
849
|
+
permissionAsked.set(requestID, {
|
|
850
|
+
sessionID,
|
|
851
|
+
summary,
|
|
852
|
+
})
|
|
853
|
+
}
|
|
854
|
+
if (!shouldTriggerEventRule(autoConfig.onPermissionAsked, summary || "permission asked")) return
|
|
855
|
+
await queueTrigger(sessionID, {
|
|
856
|
+
source: "permission.asked",
|
|
857
|
+
force: autoConfig.onPermissionAsked.force,
|
|
858
|
+
detail: summary || "permission asked",
|
|
859
|
+
})
|
|
378
860
|
return
|
|
379
861
|
}
|
|
380
|
-
|
|
381
|
-
|
|
862
|
+
|
|
863
|
+
if (evt.type === "permission.replied") {
|
|
864
|
+
const sessionID = typeof evt.properties?.sessionID === "string" ? evt.properties.sessionID : ""
|
|
865
|
+
if (!sessionID) return
|
|
866
|
+
const requestID =
|
|
867
|
+
typeof evt.properties?.requestID === "string"
|
|
868
|
+
? evt.properties.requestID
|
|
869
|
+
: typeof evt.properties?.permissionID === "string"
|
|
870
|
+
? evt.properties.permissionID
|
|
871
|
+
: ""
|
|
872
|
+
const reply =
|
|
873
|
+
typeof evt.properties?.reply === "string"
|
|
874
|
+
? evt.properties.reply
|
|
875
|
+
: typeof evt.properties?.response === "string"
|
|
876
|
+
? evt.properties.response
|
|
877
|
+
: ""
|
|
878
|
+
const asked = requestID ? permissionAsked.get(requestID) : undefined
|
|
879
|
+
if (requestID) {
|
|
880
|
+
permissionAsked.delete(requestID)
|
|
881
|
+
}
|
|
882
|
+
if (reply !== "reject") return
|
|
883
|
+
const summary = toSummary([
|
|
884
|
+
asked?.summary,
|
|
885
|
+
requestID ? `request=${requestID}` : undefined,
|
|
886
|
+
"reply=reject",
|
|
887
|
+
])
|
|
888
|
+
if (!shouldTriggerEventRule(autoConfig.onPermissionRejected, summary || "permission rejected")) return
|
|
889
|
+
await queueTrigger(sessionID, {
|
|
890
|
+
source: "permission.replied.reject",
|
|
891
|
+
force: autoConfig.onPermissionRejected.force,
|
|
892
|
+
detail: summary || "permission rejected",
|
|
893
|
+
})
|
|
894
|
+
return
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
if (evt.type === "question.asked") {
|
|
898
|
+
const sessionID = typeof evt.properties?.sessionID === "string" ? evt.properties.sessionID : ""
|
|
899
|
+
if (!sessionID) return
|
|
900
|
+
const requestID = typeof evt.properties?.id === "string" ? evt.properties.id : ""
|
|
901
|
+
const summary = summarizeQuestionEvent(evt.properties)
|
|
902
|
+
if (requestID) {
|
|
903
|
+
questionAsked.set(requestID, {
|
|
904
|
+
sessionID,
|
|
905
|
+
summary,
|
|
906
|
+
})
|
|
907
|
+
}
|
|
908
|
+
if (!shouldTriggerEventRule(autoConfig.onQuestionAsked, summary || "question asked")) return
|
|
909
|
+
await queueTrigger(sessionID, {
|
|
910
|
+
source: "question.asked",
|
|
911
|
+
force: autoConfig.onQuestionAsked.force,
|
|
912
|
+
detail: summary || "question asked",
|
|
913
|
+
})
|
|
914
|
+
return
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (evt.type === "question.replied") {
|
|
918
|
+
const requestID = typeof evt.properties?.requestID === "string" ? evt.properties.requestID : ""
|
|
919
|
+
if (!requestID) return
|
|
920
|
+
questionAsked.delete(requestID)
|
|
921
|
+
return
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
if (evt.type === "question.rejected") {
|
|
925
|
+
const sessionID = typeof evt.properties?.sessionID === "string" ? evt.properties.sessionID : ""
|
|
926
|
+
if (!sessionID) return
|
|
927
|
+
const requestID = typeof evt.properties?.requestID === "string" ? evt.properties.requestID : ""
|
|
928
|
+
const asked = requestID ? questionAsked.get(requestID) : undefined
|
|
929
|
+
if (requestID) {
|
|
930
|
+
questionAsked.delete(requestID)
|
|
931
|
+
}
|
|
932
|
+
const summary = toSummary([
|
|
933
|
+
asked?.summary,
|
|
934
|
+
requestID ? `request=${requestID}` : undefined,
|
|
935
|
+
"question=rejected",
|
|
936
|
+
])
|
|
937
|
+
if (!shouldTriggerEventRule(autoConfig.onQuestionRejected, summary || "question rejected")) return
|
|
938
|
+
await queueTrigger(sessionID, {
|
|
939
|
+
source: "question.rejected",
|
|
940
|
+
force: autoConfig.onQuestionRejected.force,
|
|
941
|
+
detail: summary || "question rejected",
|
|
942
|
+
})
|
|
382
943
|
}
|
|
383
944
|
},
|
|
384
945
|
}
|