@hacky.software/opencode-todo-supervisor 0.0.0-stage → 0.3.1

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.
@@ -0,0 +1,1269 @@
1
+ // todo-supervisor — the core state machine (PLAN.md §5.4).
2
+ //
3
+ // One instance per plugin location. Everything is driven by the public event
4
+ // stream; the registry is fed exclusively by events + a startup reconcile.
5
+ // The scheduler only ever holds `setTimeout`s per session; every entry point
6
+ // is wrapped so a single throwing handler can never kill the loop.
7
+ //
8
+ // Hot reloads can leave an older plugin generation alive for a while (the
9
+ // runtime may not call the previous cleanup). Two defences keep that from
10
+ // causing duplicate nudges:
11
+ // 1. per-session *ownership claims* in storage — every registration writes
12
+ // `{tag, at}` and a nudge is only sent when no strictly newer claim
13
+ // exists (the newest generation owns the session);
14
+ // 2. the event loop stops on its abort signal, so superseded generations
15
+ // stop consuming events as soon as they see one.
16
+ import { statSync } from "node:fs"
17
+ import { effectiveFiles, MarkdownCache, openMarkdownItems, renderFileSection, type MarkdownItem, type ParsedFile } from "./markdown"
18
+ import type { Options } from "./options"
19
+ import { openItems, type TodoItem, type TodoStore } from "./storage"
20
+ import { renderList } from "./tools"
21
+
22
+ export type NormalizedEvent = {
23
+ type: string
24
+ data: Record<string, unknown>
25
+ }
26
+
27
+ export function normalizeEvent(raw: unknown): NormalizedEvent | undefined {
28
+ if (!raw || typeof raw !== "object") return undefined
29
+ const source = raw as Record<string, unknown>
30
+ const type = typeof source.type === "string" ? source.type : undefined
31
+ if (!type) return undefined
32
+
33
+ const rawData = source.data
34
+ const data =
35
+ rawData && typeof rawData === "object" && !Array.isArray(rawData)
36
+ ? (rawData as Record<string, unknown>)
37
+ : source
38
+ return { type, data }
39
+ }
40
+
41
+ export function truncate(value: string, max = 500): string {
42
+ return value.length <= max ? value : `${value.slice(0, max)}…(${value.length - max} more)`
43
+ }
44
+
45
+ // "running" is fed by open activity spans and `session.status` `{type:"busy"}`;
46
+ // "requires_action" by pending permission/form blockers; the event status union
47
+ // is idle|retry|busy (§12).
48
+ type SStatus = "idle" | "running" | "requires_action" | "unknown"
49
+
50
+ /**
51
+ * Activity spans (§12 idle model): anything the runtime is *currently doing*.
52
+ * A span is opened by a `*.started` (or `.called`) event and closed by its
53
+ * matching `*.ended`/`.success`/`.failed`. The session is idle only when no
54
+ * span, tool, shell or child is open — thinking (`reasoning`), preparing a tool
55
+ * (`tool-input`), a running tool call (`tools`) and a running turn all keep it
56
+ * busy, independent of the (unreliable here) `session.status` stream.
57
+ */
58
+ type SpanKind = "turn" | "step" | "reasoning" | "text" | "tool-input" | "inbox"
59
+
60
+ type Span = {
61
+ kind: SpanKind
62
+ startedAt: number
63
+ lastActivityAt: number
64
+ }
65
+
66
+ type RunningTool = {
67
+ /** Tool name from `session.tool.input.started` (e.g. `task`, `shell`). */
68
+ name: string
69
+ /** Best-effort label from `session.tool.called` input (`description`/`command`). */
70
+ label?: string
71
+ startedAt: number
72
+ lastActivityAt: number
73
+ }
74
+
75
+ type Shell = {
76
+ startedAt: number
77
+ lastActivityAt: number
78
+ command?: string
79
+ /** Stdout capture file (`shell.created.info.file`): its growth is the only
80
+ * progress signal a silent shell produces — no events fire for output. */
81
+ file?: string
82
+ size?: number
83
+ mtimeMs?: number
84
+ }
85
+
86
+ type Stalled = {
87
+ children: string[]
88
+ shells: string[]
89
+ tools: string[]
90
+ spans: string[]
91
+ quietMs?: number
92
+ }
93
+
94
+ type SessionState = {
95
+ sessionID: string
96
+ parentID?: string
97
+ projectID: string
98
+ status: SStatus
99
+ lastActivityAt: number
100
+ blocked: Set<"permission" | "form">
101
+ children: Set<string>
102
+ shells: Map<string, Shell>
103
+ /** Open activity spans, keyed by kind (one per kind). */
104
+ spans: Map<SpanKind, Span>
105
+ /** In-flight tool calls, keyed by call id. */
106
+ tools: Map<string, RunningTool>
107
+ /**
108
+ * Timestamp of the last confirmed end-of-turn signal (`step.ended` with
109
+ * `finish:"stop"`, `execution.succeeded/failed/interrupted`, `status idle`).
110
+ * Cleared as soon as a new turn/step starts. When a runtime never emits such
111
+ * a signal the idle rule falls back to the stall window (§12).
112
+ */
113
+ terminalAt?: number
114
+ evaluateTimer?: ReturnType<typeof setTimeout>
115
+ retryTimer?: ReturnType<typeof setTimeout>
116
+ sending: boolean
117
+ nudges: number
118
+ noProgress: number
119
+ lastNudgedHash?: string
120
+ nextEligibleAt: number
121
+ paused: boolean
122
+ pauseReason?: "interrupt" | "no-progress" | "disabled"
123
+ /** Timestamp of this instance's ownership claim (see claims below). */
124
+ claimedAt: number
125
+ }
126
+
127
+ export type SessionRef = {
128
+ projectID: string
129
+ parentID?: string
130
+ }
131
+
132
+ /** Minimal storage shape used for ownership claims (`ctx.storage` satisfies it). */
133
+ export interface ClaimsStore {
134
+ get(key: string): Promise<unknown>
135
+ set(key: string, value: unknown): Promise<void>
136
+ remove(key: string): Promise<void>
137
+ }
138
+
139
+ export interface SupervisorDeps {
140
+ options: Options
141
+ store: TodoStore
142
+ /** Project of the plugin instance's location (`ctx.location.project.id`). */
143
+ projectID: string
144
+ /** Unique tag of this plugin instance, used in logs and ownership claims. */
145
+ tag: string
146
+ /** Prefixed logger (bound by the plugin entry point). */
147
+ log(...args: unknown[]): void
148
+ /** `ctx.session.synthetic` — durably admit synthetic input. */
149
+ synthetic(input: {
150
+ sessionID: string
151
+ text: string
152
+ description?: string
153
+ metadata?: Record<string, unknown>
154
+ }): Promise<void>
155
+ /** `ctx.session.get` — used by startup reconcile. */
156
+ getSession(sessionID: string): Promise<SessionRef | undefined>
157
+ /** Ownership claims; when absent, claim checks are skipped (unit tests). */
158
+ claims?: ClaimsStore
159
+ /** Busy/409 retry tuning (tests shrink this). Defaults: 5 s, 5 attempts. */
160
+ retry?: { delayMs?: number; attempts?: number }
161
+ /** Aspect 11 — emit `updated`/`nudged` RPC events. */
162
+ emitRpcEvent?(name: "updated" | "nudged", payload: Record<string, unknown>): void
163
+ /** Markdown mode (§5.8): shared per-file parse cache. */
164
+ markdown: MarkdownCache
165
+ /** Location directory: relative subscription paths resolve against it. */
166
+ directory: string
167
+ }
168
+
169
+ const RETRY_MS = 5000
170
+ const RETRY_ATTEMPTS = 5
171
+ const TIMER_EPSILON_MS = 500
172
+
173
+ /** Human labels for still-open activity spans in stall nudges (§5.4). */
174
+ const SPAN_LABELS: Record<string, string> = {
175
+ turn: "open turn",
176
+ step: "open step",
177
+ reasoning: "thinking (no deltas)",
178
+ text: "text stream",
179
+ "tool-input": "preparing a tool call",
180
+ inbox: "queued input",
181
+ }
182
+
183
+ /** Merged supervision sources (§5.8): the session list plus subscribed markdown files. */
184
+ type Sources = {
185
+ sessionItems: TodoItem[]
186
+ sessionOpen: TodoItem[]
187
+ files: ParsedFile[]
188
+ fileOpen: MarkdownItem[]
189
+ paths: string[]
190
+ total: number
191
+ open: number
192
+ supervised: boolean
193
+ }
194
+
195
+ /** No-progress hash: open texts only, line numbers excluded (§5.8). */
196
+ function hashSources(sources: Sources): string {
197
+ return [
198
+ ...sources.sessionOpen.map((item) => item.content),
199
+ ...sources.fileOpen.map((item) => item.text),
200
+ ].join("\u0000")
201
+ }
202
+
203
+ function str(value: unknown): string | undefined {
204
+ return typeof value === "string" ? value : undefined
205
+ }
206
+
207
+ function rec(value: unknown): Record<string, unknown> | undefined {
208
+ return value && typeof value === "object" && !Array.isArray(value)
209
+ ? (value as Record<string, unknown>)
210
+ : undefined
211
+ }
212
+
213
+ function num(value: unknown): number | undefined {
214
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined
215
+ }
216
+
217
+ function formatQuiet(quietMs: number | undefined, stallMinutes: number): string {
218
+ if (quietMs === undefined) return `~${stallMinutes} min`
219
+ if (quietMs < 60_000) return "less than a minute"
220
+ const minutes = quietMs / 60_000
221
+ return `~${minutes < 10 ? minutes.toFixed(1) : Math.round(minutes)} min`
222
+ }
223
+
224
+ function parseClaim(value: unknown): { tag: string; at: number } | undefined {
225
+ const record = rec(value)
226
+ const tag = str(record?.["tag"])
227
+ const at = record?.["at"]
228
+ if (!tag || typeof at !== "number") return undefined
229
+ return { tag, at }
230
+ }
231
+
232
+ const claimKey = (sessionID: string) => `claims/${sessionID}`
233
+
234
+ export class Supervisor {
235
+ private readonly registry = new Map<string, SessionState>()
236
+ /** shellID → owning sessionID; shell events are location-scoped (§12). */
237
+ private readonly shellSessions = new Map<string, string>()
238
+ /** Paths already reported missing (§5.8: log once on the transition). */
239
+ private readonly missingFiles = new Set<string>()
240
+ private readonly log: (...args: unknown[]) => void
241
+ private disposed = false
242
+
243
+ constructor(private readonly deps: SupervisorDeps) {
244
+ this.log = deps.log ?? (() => {})
245
+ }
246
+
247
+ // ---- registry ------------------------------------------------------------
248
+
249
+ registerSession(sessionID: string, ref: SessionRef): SessionState {
250
+ let st = this.registry.get(sessionID)
251
+ let fresh = false
252
+ if (!st) {
253
+ st = {
254
+ sessionID,
255
+ parentID: ref.parentID,
256
+ projectID: ref.projectID,
257
+ status: "unknown",
258
+ lastActivityAt: Date.now(),
259
+ blocked: new Set(),
260
+ children: new Set(),
261
+ shells: new Map(),
262
+ spans: new Map(),
263
+ tools: new Map(),
264
+ sending: false,
265
+ nudges: 0,
266
+ noProgress: 0,
267
+ nextEligibleAt: 0,
268
+ paused: false,
269
+ claimedAt: Date.now(),
270
+ }
271
+ this.registry.set(sessionID, st)
272
+ fresh = true
273
+ this.log(`register ${sessionID} (project=${ref.projectID}${ref.parentID ? ` parent=${ref.parentID}` : ""})`)
274
+ } else {
275
+ if (ref.parentID) st.parentID = ref.parentID
276
+ st.projectID = ref.projectID
277
+ }
278
+ if (ref.parentID) {
279
+ const parent = this.registry.get(ref.parentID)
280
+ if (parent) parent.children.add(sessionID)
281
+ }
282
+ // Claim ownership so overlapping plugin generations agree on one nudger.
283
+ void this.deps.claims
284
+ ?.set(claimKey(sessionID), { tag: this.deps.tag, at: st.claimedAt })
285
+ .catch((err) => this.log(`claim write failed (${sessionID}): ${String(err)}`))
286
+ if (fresh) this.log(`claim ${sessionID} (tag=${this.deps.tag})`)
287
+ return st
288
+ }
289
+
290
+ private async ownsSession(sessionID: string): Promise<boolean> {
291
+ if (!this.deps.claims) return true
292
+ let claim: { tag: string; at: number } | undefined
293
+ try {
294
+ claim = parseClaim(await this.deps.claims.get(claimKey(sessionID)))
295
+ } catch (err) {
296
+ // Fail open: a storage blip must not silence supervision entirely.
297
+ this.log(`claim read failed (${sessionID}) — assuming ownership: ${String(err)}`)
298
+ return true
299
+ }
300
+ if (!claim || claim.tag === this.deps.tag) return true
301
+ const st = this.registry.get(sessionID)
302
+ const mine = st?.claimedAt ?? 0
303
+ const newer = claim.at > mine || (claim.at === mine && claim.tag > this.deps.tag)
304
+ return !newer
305
+ }
306
+
307
+ private unregisterSession(sessionID: string): void {
308
+ const st = this.registry.get(sessionID)
309
+ if (!st) return
310
+ if (st.evaluateTimer) clearTimeout(st.evaluateTimer)
311
+ if (st.retryTimer) clearTimeout(st.retryTimer)
312
+ if (st.parentID) {
313
+ const parent = this.registry.get(st.parentID)
314
+ if (parent) parent.children.delete(sessionID)
315
+ }
316
+ this.registry.delete(sessionID)
317
+ this.log(`unregister ${sessionID}`)
318
+ }
319
+
320
+ private inScope(projectID: string): boolean {
321
+ return this.deps.options.scope === "all" || projectID === this.deps.projectID
322
+ }
323
+
324
+ getState(sessionID: string): SessionState | undefined {
325
+ return this.registry.get(sessionID)
326
+ }
327
+
328
+ /** Per-session kill switch (RPC `setEnabled`, aspect 11). */
329
+ setEnabled(sessionID: string, enabled: boolean): void {
330
+ const st = this.registry.get(sessionID)
331
+ if (!st) return
332
+ if (enabled) {
333
+ st.paused = false
334
+ st.pauseReason = undefined
335
+ this.scheduleEvaluate(sessionID, this.deps.options.settleMs)
336
+ } else {
337
+ st.paused = true
338
+ st.pauseReason = "disabled"
339
+ if (st.evaluateTimer) clearTimeout(st.evaluateTimer)
340
+ st.evaluateTimer = undefined
341
+ }
342
+ }
343
+
344
+ /** Read-only view used by RPC `list` (aspect 11). */
345
+ snapshot(sessionID: string): Record<string, unknown> {
346
+ const st = this.registry.get(sessionID)
347
+ if (!st) return { registered: false }
348
+ let runningChildren = 0
349
+ for (const childID of st.children) {
350
+ if (this.registry.get(childID)?.status === "running") runningChildren += 1
351
+ }
352
+ return {
353
+ registered: true,
354
+ status: st.status,
355
+ paused: st.paused,
356
+ pauseReason: st.pauseReason ?? null,
357
+ blocked: st.blocked.size,
358
+ runningChildren,
359
+ runningShells: st.shells.size,
360
+ runningTools: st.tools.size,
361
+ spans: [...st.spans.keys()],
362
+ quietMs: this.quietMs(st, undefined),
363
+ terminal: st.terminalAt !== undefined,
364
+ nudges: st.nudges,
365
+ noProgress: st.noProgress,
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Manual wake-up (RPC `nudge`, aspect 11): explicit user intent bypasses the
371
+ * quiet-time and busy-state checks, but a disabled or paused session stays
372
+ * quiet. Ownership claims still apply.
373
+ */
374
+ forceNudge(sessionID: string): void {
375
+ if (this.disposed) return
376
+ if (!this.registry.has(sessionID)) return
377
+ void this.nudge(sessionID, "open-todos", undefined, true)
378
+ }
379
+
380
+ dispose(): void {
381
+ if (this.disposed) return
382
+ this.disposed = true
383
+ for (const st of this.registry.values()) {
384
+ if (st.evaluateTimer) clearTimeout(st.evaluateTimer)
385
+ if (st.retryTimer) clearTimeout(st.retryTimer)
386
+ }
387
+ this.log(`disposed (sessions=${this.registry.size})`)
388
+ this.registry.clear()
389
+ this.shellSessions.clear()
390
+ }
391
+
392
+ // ---- scheduling ----------------------------------------------------------
393
+
394
+ private scheduleEvaluate(sessionID: string, delayMs: number): void {
395
+ if (this.disposed) return
396
+ const st = this.registry.get(sessionID)
397
+ if (!st) return
398
+ if (st.evaluateTimer) clearTimeout(st.evaluateTimer)
399
+ st.evaluateTimer = setTimeout(() => {
400
+ st.evaluateTimer = undefined
401
+ void this.evaluate(sessionID).catch((err) => this.log(`evaluate failed (${sessionID}): ${String(err)}`))
402
+ }, Math.max(0, delayMs))
403
+ }
404
+
405
+ private cancelEvaluate(sessionID: string): void {
406
+ const st = this.registry.get(sessionID)
407
+ if (st?.evaluateTimer) {
408
+ clearTimeout(st.evaluateTimer)
409
+ st.evaluateTimer = undefined
410
+ }
411
+ }
412
+
413
+ // ---- activity spans (§12 idle model) -------------------------------------
414
+
415
+ /**
416
+ * Opens (or refreshes) an activity span. Starting a turn/step invalidates the
417
+ * last end-of-turn signal, and any span marks the session as running.
418
+ */
419
+ private openSpan(st: SessionState, kind: SpanKind): void {
420
+ const now = Date.now()
421
+ const span = st.spans.get(kind)
422
+ if (span) span.lastActivityAt = now
423
+ else st.spans.set(kind, { kind, startedAt: now, lastActivityAt: now })
424
+ if (kind === "turn" || kind === "step") st.terminalAt = undefined
425
+ st.lastActivityAt = now
426
+ st.status = "running"
427
+ }
428
+
429
+ /** Progress inside a span (deltas, streamed steps). */
430
+ private touchSpan(st: SessionState, kind: SpanKind): void {
431
+ const now = Date.now()
432
+ const span = st.spans.get(kind)
433
+ if (span) span.lastActivityAt = now
434
+ st.lastActivityAt = now
435
+ }
436
+
437
+ private closeSpan(st: SessionState, kind: SpanKind): void {
438
+ const now = Date.now()
439
+ const span = st.spans.get(kind)
440
+ if (span) span.lastActivityAt = now
441
+ st.spans.delete(kind)
442
+ st.lastActivityAt = now
443
+ }
444
+
445
+ /** Registers/refreshes an in-flight tool call (`task`, `shell`, …). */
446
+ private startTool(st: SessionState, id: string | undefined, name: string | undefined, label?: string): void {
447
+ if (!id) {
448
+ st.lastActivityAt = Date.now()
449
+ return
450
+ }
451
+ const now = Date.now()
452
+ const tool = st.tools.get(id)
453
+ if (tool) {
454
+ tool.lastActivityAt = now
455
+ if (name) tool.name = name
456
+ if (label) tool.label = label
457
+ } else {
458
+ st.tools.set(id, { name: name ?? "tool", label, startedAt: now, lastActivityAt: now })
459
+ }
460
+ st.lastActivityAt = now
461
+ }
462
+
463
+ private touchTool(st: SessionState, id: string | undefined): void {
464
+ const now = Date.now()
465
+ if (id) {
466
+ const tool = st.tools.get(id)
467
+ if (tool) tool.lastActivityAt = now
468
+ }
469
+ st.lastActivityAt = now
470
+ }
471
+
472
+ private endTool(st: SessionState, id: string): void {
473
+ st.tools.delete(id)
474
+ st.lastActivityAt = Date.now()
475
+ }
476
+
477
+ /** Human label for a running tool in stall nudges (§5.4). */
478
+ private toolLabel(tool: RunningTool): string {
479
+ return tool.label ? `${tool.name} "${tool.label}"` : tool.name
480
+ }
481
+
482
+ /**
483
+ * Confirmed end of turn: close every span, drop in-flight tool calls, mark the
484
+ * session idle and record the terminal timestamp. Spans can outlive their close
485
+ * event when a turn is interrupted or a generation reloads mid-call, so this
486
+ * signal is also what heals leaks (§12).
487
+ */
488
+ private endTurn(st: SessionState): void {
489
+ const now = Date.now()
490
+ st.spans.clear()
491
+ st.tools.clear()
492
+ st.status = "idle"
493
+ st.terminalAt = now
494
+ st.lastActivityAt = now
495
+ }
496
+
497
+ /** Called by the tools after `todo_write` (§5.2): reset counters, re-evaluate. */
498
+ notifyTodosChanged(sessionID: string): void {
499
+ const st = this.registry.get(sessionID)
500
+ if (!st) {
501
+ // Session existed before the plugin loaded (or was out of scope until now):
502
+ // register lazily, then evaluate.
503
+ void this.deps
504
+ .getSession(sessionID)
505
+ .then((ref) => {
506
+ if (!ref || !this.inScope(ref.projectID)) return
507
+ this.registerSession(sessionID, ref)
508
+ this.scheduleEvaluate(sessionID, this.deps.options.settleMs)
509
+ })
510
+ .catch((err) => this.log(`lazy register failed (${sessionID}): ${String(err)}`))
511
+ return
512
+ }
513
+ st.noProgress = 0
514
+ if (st.paused && st.pauseReason !== "disabled") {
515
+ this.log(`resume ${sessionID}: list changed — supervision re-enabled (was ${st.pauseReason})`)
516
+ st.paused = false
517
+ st.pauseReason = undefined
518
+ }
519
+ this.scheduleEvaluate(sessionID, this.deps.options.settleMs)
520
+ }
521
+
522
+ // ---- startup reconcile ---------------------------------------------------
523
+
524
+ async reconcile(): Promise<void> {
525
+ const states = await this.deps.store.scanAll()
526
+ for (const state of states) {
527
+ try {
528
+ const ref = await this.deps.getSession(state.sessionID)
529
+ if (!ref) {
530
+ this.log(`reconcile: session ${state.sessionID} not found — dropping stored list`)
531
+ await this.deps.store.removeTodos(state.sessionID)
532
+ continue
533
+ }
534
+ if (!this.inScope(ref.projectID)) continue
535
+ this.registerSession(state.sessionID, ref)
536
+ this.scheduleEvaluate(state.sessionID, this.deps.options.settleMs)
537
+ } catch (err) {
538
+ this.log(`reconcile: lookup failed for ${state.sessionID} — keeping list: ${String(err)}`)
539
+ }
540
+ }
541
+ }
542
+
543
+ // ---- event ingestion -----------------------------------------------------
544
+
545
+ /** Entry point for the subscription loop. Never throws. */
546
+ async handle(raw: unknown): Promise<void> {
547
+ if (this.disposed) return
548
+ const event = normalizeEvent(raw)
549
+ if (!event) return
550
+
551
+ const { type, data } = event
552
+ if (type === "shell.created" || type === "shell.exited" || type === "shell.deleted") {
553
+ try {
554
+ this.handleShellEvent(type, data)
555
+ } catch (err) {
556
+ this.log(`event handler error (${type}): ${String(err)}`)
557
+ }
558
+ return
559
+ }
560
+ const sessionID = str(data.sessionID) ?? str(rec(data.form)?.sessionID)
561
+ if (!sessionID) return
562
+
563
+ try {
564
+ await this.dispatch(type, sessionID, data)
565
+ } catch (err) {
566
+ this.log(`event handler error (${type} ${sessionID}): ${String(err)}`)
567
+ }
568
+ }
569
+
570
+ /** Shell events have no sessionID in `data`; the owner is in `info.metadata` (§12). */
571
+ private handleShellEvent(type: string, data: Record<string, unknown>): void {
572
+ if (type === "shell.created") {
573
+ const info = rec(data.info)
574
+ const shellID = str(info?.id)
575
+ const owner = str(rec(info?.metadata)?.["sessionID"])
576
+ if (!shellID || !owner || str(info?.status) !== "running") return
577
+ const st = this.registry.get(owner)
578
+ if (!st) return
579
+ st.shells.set(shellID, {
580
+ startedAt: num(rec(info?.time)?.["started"]) ?? Date.now(),
581
+ lastActivityAt: Date.now(),
582
+ command: str(info?.command),
583
+ file: str(info?.file),
584
+ })
585
+ this.shellSessions.set(shellID, owner)
586
+ return
587
+ }
588
+ const shellID = str(data.id)
589
+ if (!shellID) return
590
+ const owner = this.shellSessions.get(shellID)
591
+ if (!owner) return
592
+ this.registry.get(owner)?.shells.delete(shellID)
593
+ this.shellSessions.delete(shellID)
594
+ }
595
+
596
+ private async dispatch(type: string, sessionID: string, data: Record<string, unknown>): Promise<void> {
597
+ const options = this.deps.options
598
+
599
+ switch (type) {
600
+ case "session.created": {
601
+ const projectID = str(data.projectID)
602
+ if (!projectID || !this.inScope(projectID)) return
603
+ const parentID = str(data.parentID)
604
+ this.registerSession(sessionID, { projectID, parentID })
605
+ if (this.deps.store.getTodos(sessionID)) this.scheduleEvaluate(sessionID, options.settleMs)
606
+ return
607
+ }
608
+
609
+ case "session.deleted": {
610
+ const st = this.registry.get(sessionID)
611
+ if (!st) return
612
+ this.unregisterSession(sessionID)
613
+ for (const [shellID, owner] of this.shellSessions) {
614
+ if (owner === sessionID) this.shellSessions.delete(shellID)
615
+ }
616
+ void this.deps.claims
617
+ ?.remove(claimKey(sessionID))
618
+ .catch((err) => this.log(`claim remove failed (${sessionID}): ${String(err)}`))
619
+ await this.deps.store.removeTodos(sessionID)
620
+ await this.deps.store.removeSubs(sessionID)
621
+ this.deps.emitRpcEvent?.("updated", { sessionID })
622
+ return
623
+ }
624
+
625
+ case "session.status": {
626
+ const st = this.registry.get(sessionID)
627
+ if (!st) return
628
+ const statusType = str(rec(data.status)?.type)
629
+ if (statusType === "idle") {
630
+ // Confirmed end of turn (§12): arm the idle evaluation.
631
+ this.endTurn(st)
632
+ this.scheduleEvaluate(sessionID, options.settleMs)
633
+ } else {
634
+ // busy | retry — the runtime is actively working.
635
+ this.openSpan(st, "turn")
636
+ }
637
+ return
638
+ }
639
+
640
+ case "session.idle": {
641
+ // Deprecated in 2.0.24 but still emitted; stays idempotent with session.status.
642
+ const st = this.registry.get(sessionID)
643
+ if (!st) return
644
+ this.endTurn(st)
645
+ this.scheduleEvaluate(sessionID, options.settleMs)
646
+ return
647
+ }
648
+
649
+ case "session.execution.started": {
650
+ const st = this.registry.get(sessionID)
651
+ if (!st) return
652
+ this.openSpan(st, "turn")
653
+ return
654
+ }
655
+
656
+ case "session.execution.succeeded":
657
+ case "session.execution.failed": {
658
+ const st = this.registry.get(sessionID)
659
+ if (!st) return
660
+ this.endTurn(st)
661
+ this.scheduleEvaluate(sessionID, options.settleMs)
662
+ return
663
+ }
664
+
665
+ case "session.execution.interrupted": {
666
+ const st = this.registry.get(sessionID)
667
+ if (!st) return
668
+ this.endTurn(st)
669
+ if (options.pauseOnInterrupt) {
670
+ st.paused = true
671
+ st.pauseReason = "interrupt"
672
+ this.log(`pause ${sessionID}: execution interrupted — waiting for user input`)
673
+ }
674
+ this.scheduleEvaluate(sessionID, options.settleMs)
675
+ return
676
+ }
677
+
678
+ case "session.step.started": {
679
+ const st = this.registry.get(sessionID)
680
+ if (!st) return
681
+ this.openSpan(st, "step")
682
+ return
683
+ }
684
+
685
+ case "session.step.streamed": {
686
+ const st = this.registry.get(sessionID)
687
+ if (st) this.touchSpan(st, "step")
688
+ return
689
+ }
690
+
691
+ case "session.step.ended": {
692
+ const st = this.registry.get(sessionID)
693
+ if (!st) return
694
+ if (str(data.finish) === "stop") {
695
+ // Final step of the turn — the authoritative idle signal (§12).
696
+ this.endTurn(st)
697
+ this.scheduleEvaluate(sessionID, options.settleMs)
698
+ return
699
+ }
700
+ // `tool-calls` (or an unknown finish): the turn continues, a tool call
701
+ // is expected next, so this must never arm the idle evaluation (§12).
702
+ this.closeSpan(st, "step")
703
+ st.terminalAt = undefined
704
+ st.status = "running"
705
+ return
706
+ }
707
+
708
+ case "session.step.failed": {
709
+ const st = this.registry.get(sessionID)
710
+ if (!st) return
711
+ this.closeSpan(st, "step")
712
+ return
713
+ }
714
+
715
+ case "session.reasoning.started": {
716
+ const st = this.registry.get(sessionID)
717
+ if (st) this.openSpan(st, "reasoning")
718
+ return
719
+ }
720
+
721
+ case "session.reasoning.delta": {
722
+ const st = this.registry.get(sessionID)
723
+ if (st) this.touchSpan(st, "reasoning")
724
+ return
725
+ }
726
+
727
+ case "session.reasoning.ended": {
728
+ const st = this.registry.get(sessionID)
729
+ if (st) this.closeSpan(st, "reasoning")
730
+ return
731
+ }
732
+
733
+ case "session.text.started": {
734
+ const st = this.registry.get(sessionID)
735
+ if (st) this.openSpan(st, "text")
736
+ return
737
+ }
738
+
739
+ case "session.text.delta": {
740
+ const st = this.registry.get(sessionID)
741
+ if (st) this.touchSpan(st, "text")
742
+ return
743
+ }
744
+
745
+ case "session.text.ended": {
746
+ const st = this.registry.get(sessionID)
747
+ if (st) this.closeSpan(st, "text")
748
+ return
749
+ }
750
+
751
+ case "session.tool.input.started": {
752
+ const st = this.registry.get(sessionID)
753
+ if (!st) return
754
+ this.openSpan(st, "tool-input")
755
+ this.startTool(st, str(data.id), str(data.name))
756
+ return
757
+ }
758
+
759
+ case "session.tool.input.delta": {
760
+ const st = this.registry.get(sessionID)
761
+ if (!st) return
762
+ this.touchSpan(st, "tool-input")
763
+ this.touchTool(st, str(data.id))
764
+ return
765
+ }
766
+
767
+ case "session.tool.input.ended": {
768
+ const st = this.registry.get(sessionID)
769
+ if (!st) return
770
+ this.closeSpan(st, "tool-input")
771
+ this.touchTool(st, str(data.id))
772
+ return
773
+ }
774
+
775
+ case "message.part.updated": {
776
+ const st = this.registry.get(sessionID)
777
+ if (st) st.lastActivityAt = Date.now()
778
+ return
779
+ }
780
+
781
+ case "session.tool.progress": {
782
+ const st = this.registry.get(sessionID)
783
+ if (!st) return
784
+ st.lastActivityAt = Date.now()
785
+ const shellID = str(rec(data.metadata)?.["shellID"])
786
+ if (shellID) {
787
+ const shell = st.shells.get(shellID)
788
+ if (shell) shell.lastActivityAt = Date.now()
789
+ }
790
+ this.touchTool(st, str(data.id))
791
+ return
792
+ }
793
+
794
+ case "session.tool.called": {
795
+ const st = this.registry.get(sessionID)
796
+ if (!st) return
797
+ // `input` is the only place a tool self-describes here (no `name` field):
798
+ // the task tool carries `description`, the shell tool `command`.
799
+ const input = rec(data.input)
800
+ this.startTool(st, str(data.id), str(data.name), str(input?.["description"]) ?? str(input?.["command"]))
801
+ return
802
+ }
803
+
804
+ case "session.tool.success":
805
+ case "session.tool.failed": {
806
+ const st = this.registry.get(sessionID)
807
+ if (!st) return
808
+ const id = str(data.id)
809
+ if (id) this.endTool(st, id)
810
+ else st.lastActivityAt = Date.now()
811
+ return
812
+ }
813
+
814
+ case "permission.asked": {
815
+ const st = this.registry.get(sessionID)
816
+ if (!st) return
817
+ st.blocked.add("permission")
818
+ st.status = "requires_action"
819
+ return
820
+ }
821
+
822
+ case "permission.replied": {
823
+ const st = this.registry.get(sessionID)
824
+ if (!st) return
825
+ st.blocked.delete("permission")
826
+ if (st.blocked.size === 0 && st.status === "requires_action") st.status = "idle"
827
+ this.scheduleEvaluate(sessionID, options.settleMs)
828
+ return
829
+ }
830
+
831
+ case "form.created": {
832
+ const st = this.registry.get(sessionID)
833
+ if (!st) return
834
+ st.blocked.add("form")
835
+ st.status = "requires_action"
836
+ return
837
+ }
838
+
839
+ case "form.replied":
840
+ case "form.cancelled": {
841
+ const st = this.registry.get(sessionID)
842
+ if (!st) return
843
+ st.blocked.delete("form")
844
+ if (st.blocked.size === 0 && st.status === "requires_action") st.status = "idle"
845
+ this.scheduleEvaluate(sessionID, options.settleMs)
846
+ return
847
+ }
848
+
849
+ case "session.inbox.enqueued": {
850
+ const st = this.registry.get(sessionID)
851
+ if (!st) return
852
+ // A queued item is pending work, whether or not it is ours (§12).
853
+ this.openSpan(st, "inbox")
854
+ const item = rec(data.item)
855
+ const payload = rec(item?.["payload"])
856
+ const metadata = rec(payload?.["metadata"])
857
+ const synthetic =
858
+ item?.["type"] === "synthetic" || metadata?.["source"] === "todo-supervisor"
859
+ if (synthetic) return
860
+ // Real user input resumes a paused session and resets the backstop.
861
+ st.paused = false
862
+ st.pauseReason = undefined
863
+ st.noProgress = 0
864
+ this.scheduleEvaluate(sessionID, options.settleMs)
865
+ return
866
+ }
867
+
868
+ case "session.inbox.delivered": {
869
+ const st = this.registry.get(sessionID)
870
+ if (st) this.closeSpan(st, "inbox")
871
+ return
872
+ }
873
+
874
+ default:
875
+ // Unknown/unused events are ignored by design (§11 Aspect 6).
876
+ return
877
+ }
878
+ }
879
+
880
+ // ---- evaluation ----------------------------------------------------------
881
+
882
+ /** Debug-gated decision log (aspect 9/13): why an evaluation stopped early. */
883
+ private debugSkip(sessionID: string, reason: string): void {
884
+ if (this.deps.options.debugEvents) this.log(`evaluate ${sessionID}: skipped (${reason})`)
885
+ }
886
+
887
+ /**
888
+ * Merged sources (§5.8): the session list (fresh from storage) plus every
889
+ * subscribed markdown file (parsed via the shared cache). Never throws.
890
+ */
891
+ private async collectSources(sessionID: string): Promise<Sources> {
892
+ const sessionItems = this.deps.store.getTodos(sessionID)?.items ?? []
893
+ let files: ParsedFile[] = []
894
+ let paths: string[] = []
895
+ try {
896
+ const subs = await this.deps.store.loadSubs(sessionID)
897
+ paths = effectiveFiles(this.deps.options, this.deps.directory, subs)
898
+ files = paths.map((path) => this.deps.markdown.parse(path))
899
+ for (const file of files) {
900
+ if (!file.exists && !this.missingFiles.has(file.path)) {
901
+ this.missingFiles.add(file.path)
902
+ this.log(`markdown file missing (${file.path}) — 0 tasks until it exists`)
903
+ } else if (file.exists) {
904
+ this.missingFiles.delete(file.path)
905
+ }
906
+ }
907
+ } catch (err) {
908
+ this.log(`markdown sources failed (${sessionID}): ${String(err)}`)
909
+ }
910
+ const sessionOpen = openItems(sessionItems)
911
+ const fileOpen = openMarkdownItems(files)
912
+ return {
913
+ sessionItems,
914
+ sessionOpen,
915
+ files,
916
+ fileOpen,
917
+ paths,
918
+ total: sessionItems.length + files.reduce((sum, file) => sum + file.items.length, 0),
919
+ open: sessionOpen.length + fileOpen.length,
920
+ supervised: sessionItems.length > 0 || paths.length > 0,
921
+ }
922
+ }
923
+
924
+ async evaluate(sessionID: string): Promise<void> {
925
+ if (this.disposed) return
926
+ const st = this.registry.get(sessionID)
927
+ if (!st) return
928
+
929
+ const options = this.deps.options
930
+ // Writes from the role that hosts tool execution are invisible to this cache:
931
+ // re-read shared storage before deciding (§12 stale-cache fix).
932
+ try {
933
+ await this.deps.store.refreshTodos(sessionID)
934
+ } catch (err) {
935
+ this.log(`refresh failed (${sessionID}): ${String(err)}`)
936
+ }
937
+ const sources = await this.collectSources(sessionID)
938
+ if (!sources.supervised || sources.open === 0) {
939
+ this.debugSkip(sessionID, sources.supervised ? "done" : "no sources")
940
+ return
941
+ }
942
+ if (!options.enabled) {
943
+ this.debugSkip(sessionID, "plugin disabled")
944
+ return
945
+ }
946
+ if (st.paused) {
947
+ this.debugSkip(sessionID, `paused (${st.pauseReason ?? "paused"})`)
948
+ return
949
+ }
950
+ if (st.blocked.size > 0) {
951
+ this.debugSkip(sessionID, `blocked (${st.blocked.size})`)
952
+ return
953
+ }
954
+ if (st.status === "requires_action") {
955
+ this.debugSkip(sessionID, "requires_action")
956
+ return
957
+ }
958
+
959
+ const now = Date.now()
960
+ const stallMs = options.stallMinutes * 60_000
961
+
962
+ const runningChildren = this.runningChildren(st)
963
+ // Name the work that is actually running, including inside busy children.
964
+ const activeShells = [
965
+ ...[...st.shells.values()],
966
+ ...runningChildren.flatMap((child) => [...child.shells.values()]),
967
+ ]
968
+ const runningTools = [
969
+ ...[...st.tools.values()],
970
+ ...runningChildren.flatMap((child) => [...child.tools.values()]),
971
+ ]
972
+ const openSpans = [...st.spans.values()]
973
+ const quiet = this.quietMs(st, runningChildren)
974
+
975
+ // Anything the runtime is still doing keeps the session busy (§12): a running
976
+ // turn/step, thinking (`reasoning`), preparing a tool (`tool-input`), an
977
+ // in-flight tool call (a `task` subagent, a foreground shell), a background
978
+ // shell, a child session or a queued inbox item. Only after that work has been
979
+ // quiet for the whole stall window is a "check on it" nudge sent.
980
+ if (
981
+ runningChildren.length > 0 ||
982
+ activeShells.length > 0 ||
983
+ runningTools.length > 0 ||
984
+ openSpans.length > 0
985
+ ) {
986
+ if (quiet < stallMs) {
987
+ this.debugSkip(sessionID, `work quiet ${(quiet / 1000).toFixed(1)}s < ${options.stallMinutes}min`)
988
+ this.scheduleEvaluate(sessionID, stallMs - quiet + TIMER_EPSILON_MS)
989
+ return
990
+ }
991
+ const stalled = {
992
+ children: runningChildren.map((child) => child.sessionID),
993
+ shells: activeShells.map((shell) => shell.command ?? "(background shell)"),
994
+ tools: runningTools.map((tool) => this.toolLabel(tool)),
995
+ spans: openSpans.map((span) => span.kind),
996
+ quietMs: quiet,
997
+ }
998
+ await this.nudge(sessionID, "stalled-task", stalled)
999
+ return
1000
+ }
1001
+
1002
+ // Nothing is open. A confirmed end of turn arms a short debounce; a runtime
1003
+ // that never emits one (no `session.status`/`execution.*` — see §12) falls
1004
+ // back to the stall window, so supervision still works, just conservatively.
1005
+ const threshold = st.terminalAt !== undefined ? options.settleMs : stallMs
1006
+ if (quiet < threshold) {
1007
+ this.debugSkip(sessionID, `recent activity (${quiet}ms < ${threshold}ms)`)
1008
+ this.scheduleEvaluate(sessionID, threshold - quiet + TIMER_EPSILON_MS)
1009
+ return
1010
+ }
1011
+
1012
+ if (now < st.nextEligibleAt) {
1013
+ this.scheduleEvaluate(sessionID, st.nextEligibleAt - now + TIMER_EPSILON_MS)
1014
+ return
1015
+ }
1016
+ await this.nudge(sessionID, "open-todos")
1017
+ }
1018
+
1019
+ /** Running children of a session that still look busy (registry subset). */
1020
+ private runningChildren(st: SessionState): SessionState[] {
1021
+ return [...st.children]
1022
+ .map((id) => this.registry.get(id))
1023
+ .filter((child): child is SessionState => !!child && this.childLooksBusy(child))
1024
+ }
1025
+
1026
+ /**
1027
+ * Is this child still doing something? Open spans/tools/shells count, as does
1028
+ * a `running` status; a child with nothing open counts only while it showed
1029
+ * activity inside the stall window — otherwise a subagent that finished (and
1030
+ * whose session persists) would keep its parent from ever being nudged (§12.19).
1031
+ */
1032
+ private childLooksBusy(child: SessionState): boolean {
1033
+ if (child.spans.size > 0 || child.tools.size > 0 || child.shells.size > 0) return true
1034
+ if (child.status === "running") return true
1035
+ if (child.terminalAt !== undefined) return false
1036
+ return this.quietMs(child) < this.deps.options.stallMinutes * 60_000
1037
+ }
1038
+
1039
+ /**
1040
+ * A shell's stdout is a capture file (`shell.created.info.file`) and writing it
1041
+ * emits **no events**, so real progress is measured by stat-ing it (§12.22).
1042
+ * `shellActivity` then uses the file's own mtime, so "quiet" is the true time
1043
+ * since the last output rather than since the last evaluation.
1044
+ */
1045
+ private refreshShellActivity(st: SessionState): void {
1046
+ if (st.shells.size === 0) return
1047
+ for (const shell of st.shells.values()) {
1048
+ if (!shell.file) continue
1049
+ try {
1050
+ const stat = statSync(shell.file)
1051
+ shell.size = stat.size
1052
+ shell.mtimeMs = stat.mtimeMs
1053
+ } catch {
1054
+ // missing/unreadable capture: keep the previous reading
1055
+ }
1056
+ }
1057
+ }
1058
+
1059
+ /** Newest activity for one shell (start, last event, capture-file write). */
1060
+ private shellActivity(shell: Shell): number {
1061
+ return Math.max(shell.startedAt, shell.lastActivityAt, shell.mtimeMs ?? 0)
1062
+ }
1063
+
1064
+ /**
1065
+ * Milliseconds since the newest activity across the session, the children that
1066
+ * still look busy (transitively, including their shells and tools), its tracked
1067
+ * shells and its in-flight tool calls — the "is this really idle?" clock (§12).
1068
+ */
1069
+ private quietMs(st: SessionState, runningChildren?: SessionState[]): number {
1070
+ const active = runningChildren ?? this.runningChildren(st)
1071
+ this.refreshShellActivity(st)
1072
+ const points: number[] = [st.lastActivityAt]
1073
+ for (const shell of st.shells.values()) points.push(this.shellActivity(shell))
1074
+ for (const tool of st.tools.values()) points.push(Math.max(tool.startedAt, tool.lastActivityAt))
1075
+ for (const child of active) {
1076
+ this.refreshShellActivity(child)
1077
+ points.push(child.lastActivityAt)
1078
+ for (const shell of child.shells.values()) points.push(this.shellActivity(shell))
1079
+ for (const tool of child.tools.values()) points.push(Math.max(tool.startedAt, tool.lastActivityAt))
1080
+ }
1081
+ return Date.now() - Math.max(0, ...points)
1082
+ }
1083
+
1084
+ // ---- nudging -------------------------------------------------------------
1085
+
1086
+ private renderNudge(
1087
+ reason: "open-todos" | "stalled-task",
1088
+ nudgeNumber: number,
1089
+ sources: Sources,
1090
+ stalled?: Stalled,
1091
+ ): string {
1092
+ const inProgress = sources.sessionItems.filter((item) => item.status === "in_progress").length
1093
+ const status =
1094
+ `${sources.open} of ${sources.total} todo${sources.total === 1 ? "" : "s"} left` +
1095
+ (inProgress > 0 ? `, ${inProgress} in progress` : "")
1096
+
1097
+ // Group by source: session list first, then one `path:line` section per file.
1098
+ const blocks: string[] = []
1099
+ if (sources.sessionOpen.length > 0) blocks.push(renderList(sources.sessionOpen))
1100
+ for (const file of sources.files) {
1101
+ if (!file.exists || file.items.some((item) => item.state === "open")) {
1102
+ blocks.push(renderFileSection(file, this.deps.directory, { openOnly: true }))
1103
+ }
1104
+ }
1105
+ const list = blocks.join("\n\n")
1106
+
1107
+ const markdownNote =
1108
+ sources.fileOpen.length > 0 ? " Markdown items are checked off by editing the file (`- [x]`)." : ""
1109
+ const footer =
1110
+ sources.sessionItems.length > 0
1111
+ ? "Continue working through the list (start with the [~] item when present). Keep it updated with " +
1112
+ "todo_write, and only end your turn once every item is completed or canceled." +
1113
+ markdownNote
1114
+ : "Continue with the open checklist items; edit the subscribed file and mark items `- [x]` when done."
1115
+
1116
+ if (reason === "stalled-task" && stalled) {
1117
+ const who = [
1118
+ ...stalled.children.map((id) => `subagent ${id}`),
1119
+ ...stalled.shells.map((cmd) => `background shell "${cmd}"`),
1120
+ ...stalled.tools.map((tool) => `tool ${tool}`),
1121
+ ...stalled.spans.map((span) => SPAN_LABELS[span] ?? span),
1122
+ ]
1123
+ return (
1124
+ `[todo-supervisor] Stall nudge #${nudgeNumber} — no activity for ${formatQuiet(stalled.quietMs, this.deps.options.stallMinutes)} ` +
1125
+ `from: ${who.length > 0 ? who.join(" / ") : "open work"} (stall threshold ${this.deps.options.stallMinutes} min).\n` +
1126
+ `Status: ${status}. Check its status, recover or restart it if stuck, then continue:\n${list}\n\n` +
1127
+ `Keep the list updated with todo_write; only end your turn once every item is completed or canceled.` +
1128
+ markdownNote
1129
+ )
1130
+ }
1131
+ return (
1132
+ `[todo-supervisor] Idle nudge #${nudgeNumber} — the session went idle with ${status}.\n${list}\n\n${footer}`
1133
+ )
1134
+ }
1135
+
1136
+ /**
1137
+ * One-line label rendered under the synthetic bubble (§12). Everything the
1138
+ * model needs is in the message text; this only names what the nudge is about,
1139
+ * so the TUI shows more than `◈ todo-supervisor`.
1140
+ */
1141
+ private describeNudge(
1142
+ reason: "open-todos" | "stalled-task",
1143
+ nudgeNumber: number,
1144
+ stalled: Stalled | undefined,
1145
+ ): string {
1146
+ if (reason === "stalled-task" && stalled) {
1147
+ const what = [...stalled.tools, ...stalled.children, ...stalled.shells, ...stalled.spans][0]
1148
+ return what ? `todo-supervisor · stalled: ${what}` : "todo-supervisor · stalled"
1149
+ }
1150
+ return `todo-supervisor · nudge #${nudgeNumber}`
1151
+ }
1152
+
1153
+ private async nudge(
1154
+ sessionID: string,
1155
+ reason: "open-todos" | "stalled-task",
1156
+ stalled?: Stalled,
1157
+ manual = false,
1158
+ ): Promise<void> {
1159
+ await this.sendNudge(sessionID, reason, stalled, 1, manual)
1160
+ }
1161
+
1162
+ private async sendNudge(
1163
+ sessionID: string,
1164
+ reason: "open-todos" | "stalled-task",
1165
+ stalled: Stalled | undefined,
1166
+ attempt: number,
1167
+ manual = false,
1168
+ ): Promise<void> {
1169
+ if (this.disposed) return
1170
+ const st = this.registry.get(sessionID)
1171
+ if (!st || st.sending) return
1172
+
1173
+ // Re-validate at send time: an evaluation or a retry can be in flight while
1174
+ // the session becomes busy, disabled, paused or blocked (§12 busy-state fix).
1175
+ const options = this.deps.options
1176
+ if (!options.enabled || (st.paused && st.pauseReason === "disabled")) {
1177
+ this.debugSkip(sessionID, "nudge aborted (disabled)")
1178
+ return
1179
+ }
1180
+ if (!manual) {
1181
+ if (st.paused) {
1182
+ this.debugSkip(sessionID, `nudge aborted (paused (${st.pauseReason ?? "paused"}))`)
1183
+ return
1184
+ }
1185
+ if (st.blocked.size > 0 || st.status === "requires_action") {
1186
+ this.debugSkip(sessionID, "nudge aborted (blocked)")
1187
+ return
1188
+ }
1189
+ // An idle nudge must never land mid-turn; a stall nudge is *about* long
1190
+ // running work, so it is allowed while a span/tool is still open (§12).
1191
+ if (reason === "open-todos" && st.status === "running") {
1192
+ this.debugSkip(sessionID, "nudge aborted (running)")
1193
+ return
1194
+ }
1195
+ const threshold = reason === "stalled-task" ? options.stallMinutes * 60_000 : options.settleMs
1196
+ const quiet = this.quietMs(st)
1197
+ if (quiet < threshold) {
1198
+ this.debugSkip(sessionID, `nudge aborted (quiet ${quiet}ms < ${threshold}ms)`)
1199
+ return
1200
+ }
1201
+ }
1202
+
1203
+ // Retries and manual nudges must also render the latest list (§12 stale-cache fix).
1204
+ try {
1205
+ await this.deps.store.refreshTodos(sessionID)
1206
+ } catch (err) {
1207
+ this.log(`refresh failed (${sessionID}): ${String(err)}`)
1208
+ }
1209
+ const sources = await this.collectSources(sessionID)
1210
+ if (!sources.supervised || sources.open === 0) return
1211
+
1212
+ // A newer plugin generation owns this session — stay silent.
1213
+ if (!(await this.ownsSession(sessionID))) {
1214
+ this.log(`nudge skipped (${sessionID} ${reason}) — newer supervisor owns the session`)
1215
+ return
1216
+ }
1217
+
1218
+ const hash = hashSources(sources)
1219
+ if (hash === st.lastNudgedHash) {
1220
+ st.noProgress += 1
1221
+ if (st.noProgress >= this.deps.options.maxNoProgressNudges) {
1222
+ st.paused = true
1223
+ st.pauseReason = "no-progress"
1224
+ this.log(
1225
+ `pause ${sessionID}: ${st.noProgress} nudges with no list change — backing off until the list changes`,
1226
+ )
1227
+ return
1228
+ }
1229
+ } else {
1230
+ st.noProgress = 0
1231
+ }
1232
+
1233
+ const nudgeNumber = st.nudges + 1
1234
+ const text = this.renderNudge(reason, nudgeNumber, sources, stalled)
1235
+ st.sending = true
1236
+ try {
1237
+ await this.deps.synthetic({
1238
+ sessionID,
1239
+ text,
1240
+ description: this.describeNudge(reason, nudgeNumber, stalled),
1241
+ metadata: { source: "todo-supervisor", reason },
1242
+ })
1243
+ } catch (err) {
1244
+ st.sending = false
1245
+ const attempts = this.deps.retry?.attempts ?? RETRY_ATTEMPTS
1246
+ const delayMs = this.deps.retry?.delayMs ?? RETRY_MS
1247
+ if (attempt < attempts) {
1248
+ this.log(`nudge busy (${sessionID} ${reason}) — retry ${attempt}/${attempts} in ${delayMs / 1000}s: ${String(err)}`)
1249
+ if (st.retryTimer) clearTimeout(st.retryTimer)
1250
+ st.retryTimer = setTimeout(() => {
1251
+ st.retryTimer = undefined
1252
+ void this.sendNudge(sessionID, reason, stalled, attempt + 1, manual).catch((e) =>
1253
+ this.log(`nudge retry failed (${sessionID}): ${String(e)}`),
1254
+ )
1255
+ }, delayMs)
1256
+ } else {
1257
+ this.log(`nudge dropped (${sessionID} ${reason}) after ${attempts} attempts: ${String(err)}`)
1258
+ }
1259
+ return
1260
+ }
1261
+ st.sending = false
1262
+
1263
+ st.nudges += 1
1264
+ st.lastNudgedHash = hash
1265
+ st.nextEligibleAt = Date.now() + this.deps.options.minNudgeIntervalMs
1266
+ this.log(`nudged ${sessionID} (${reason}) #${st.nudges} noProgress=${st.noProgress}`)
1267
+ this.deps.emitRpcEvent?.("nudged", { sessionID, reason })
1268
+ }
1269
+ }