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/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
- await logDebug("auto-continue skipped: skipNextAuto", { sessionID, source, run })
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
- const lastIdle = lastIdleAt.get(sessionID)
149
- if (lastIdle && now - lastIdle < 1000) {
150
- await logDebug("auto-continue skipped: idle debounce", {
151
- sessionID,
152
- source,
153
- run,
154
- lastIdle,
155
- now,
156
- deltaMs: now - lastIdle,
157
- })
158
- return
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
- lastIdleAt.set(sessionID, now)
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
- await logDebug("auto-continue skipped: no active plan", { sessionID, source, run })
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
- await logDebug("auto-continue skipped: no pending step", { sessionID, source, run, planId: active.plan_id })
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
- await log("warn", "auto-continue stopped: missing user context", { sessionID })
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?.agent ?? undefined,
278
- model: autoContext?.model ?? undefined,
654
+ agent: autoContext.agent ?? undefined,
655
+ model: autoContext.model ?? undefined,
279
656
  parts: [{ type: "text" as const, text: message }],
280
657
  }
281
- if (autoContext?.variant) {
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,
@@ -301,14 +680,33 @@ export const PlanpilotPlugin: Plugin = async (ctx) => {
301
680
  // Without it, the server falls back to process.cwd(), which breaks when OpenCode is
302
681
  // managed by opencode-studio (cwd != active project directory).
303
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
304
693
  })
305
694
 
695
+ recentSends.set(sessionID, {
696
+ signature,
697
+ at: Date.now(),
698
+ })
699
+ clearSendRetry(sessionID)
700
+ pendingTrigger.delete(sessionID)
701
+
306
702
  await log("info", "auto-continue prompt_async accepted", {
307
703
  sessionID,
308
704
  source,
309
705
  run,
310
706
  planId: active.plan_id,
311
707
  stepId: next.id,
708
+ trigger: trigger?.source,
709
+ force,
312
710
  })
313
711
  } catch (err) {
314
712
  await log("warn", "failed to auto-continue plan", {
@@ -322,9 +720,18 @@ export const PlanpilotPlugin: Plugin = async (ctx) => {
322
720
  }
323
721
  }
324
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
+
325
730
  await log("info", "planpilot plugin initialized", {
326
731
  directory: ctx.directory,
327
732
  worktree: ctx.worktree,
733
+ configPath: loadedConfig.path,
734
+ configLoadedFromFile: loadedConfig.loadedFromFile,
328
735
  })
329
736
 
330
737
  return {
@@ -377,12 +784,162 @@ export const PlanpilotPlugin: Plugin = async (ctx) => {
377
784
  output.context.push(PLANPILOT_TOOL_DESCRIPTION)
378
785
  },
379
786
  event: async ({ event }) => {
380
- if (event.type === "session.idle") {
381
- await handleSessionIdle(event.properties.sessionID, "session.idle")
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
+ })
382
860
  return
383
861
  }
384
- if (event.type === "session.status" && event.properties.status.type === "idle") {
385
- await handleSessionIdle(event.properties.sessionID, "session.status")
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
+ })
386
943
  }
387
944
  },
388
945
  }