@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.
- package/LICENSE +21 -0
- package/README.md +366 -2
- package/index.ts +3 -0
- package/package.json +61 -4
- package/src/index.ts +186 -0
- package/src/log.ts +77 -0
- package/src/machine.test.ts +779 -0
- package/src/markdown.test.ts +135 -0
- package/src/markdown.ts +159 -0
- package/src/options.test.ts +42 -0
- package/src/options.ts +100 -0
- package/src/panel.test.ts +193 -0
- package/src/panel.ts +289 -0
- package/src/rpc.test.ts +226 -0
- package/src/rpc.ts +290 -0
- package/src/storage.test.ts +101 -0
- package/src/storage.ts +186 -0
- package/src/supervisor.test.ts +37 -0
- package/src/supervisor.ts +1269 -0
- package/src/tools.test.ts +260 -0
- package/src/tools.ts +405 -0
- package/src/tui.tsx +658 -0
- package/tui.ts +2 -0
|
@@ -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
|
+
}
|