@matthewfl/pi-jtodo 0.0.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/src/index.ts ADDED
@@ -0,0 +1,873 @@
1
+ /**
2
+ * pi-jtodo (@matthewfl/pi-jtodo) — a pi extension porting jcode's
3
+ * todo quality gates.
4
+ *
5
+ * A real "hill climbing" todo tool: tours through small, observable work
6
+ * items, and keeps the agent honest with
7
+ * - a write-time ownership gate (the only blocking write check),
8
+ * - deferred, budgeted turn-end quality gates (digest, completion
9
+ * confidence, confidence spike), and
10
+ * - an auto-poke cycle that continues the agent until the list settles.
11
+ *
12
+ * Ported with high fidelity from jcode; see the README for the fidelity
13
+ * line, deviations, and the todo-tool lineage (Claude Code → opencode →
14
+ * jcode → pi-jtodo).
15
+ *
16
+ * Files:
17
+ * constants.ts — thresholds + model-visible texts (verbatim from jcode)
18
+ * config.ts — optional user config
19
+ * model.ts — data model + sanitizers
20
+ * schema.ts — tool parameter schema (verbatim calibration text)
21
+ * normalize.ts — lenient input normalization (prepareArguments)
22
+ * gates.ts — pure gate logic (direct jcode ports)
23
+ * viewer.ts — /todos TUI component
24
+ * index.ts — state, tool execute, turn-end machine, wiring (this file)
25
+ */
26
+
27
+ import type {
28
+ ExtensionAPI,
29
+ ExtensionCommandContext,
30
+ ExtensionContext,
31
+ } from "@earendil-works/pi-coding-agent";
32
+ import { Text } from "@earendil-works/pi-tui";
33
+ import {
34
+ CYCLE_CUSTOM_TYPE,
35
+ FOLLOWUP_CUSTOM_TYPE,
36
+ NOTICE_COMPLETION_CHALLENGED,
37
+ NOTICE_DIGEST_QUEUED,
38
+ NOTICE_GATE_STALLED,
39
+ NOTICE_GATE_UNCHANGED,
40
+ NOTICE_POKE_OFF_HINT,
41
+ NOTICE_SPIKE_CHALLENGED,
42
+ QUALITY_GATE_THRESHOLD,
43
+ TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE,
44
+ TODO_TOOL_NAME,
45
+ } from "./constants.js";
46
+ import { loadConfig } from "./config.js";
47
+ import {
48
+ buildAutoPokeMessage,
49
+ completionConfidenceSignature,
50
+ deriveBranchRuntime,
51
+ freshCycleFlags,
52
+ type BranchEvent,
53
+ buildGateDigest,
54
+ buildItem,
55
+ diffItemChanges,
56
+ formatItemChanges,
57
+ formatCompletionLabel,
58
+ goalChanges,
59
+ mergeConfidenceHistory,
60
+ inheritItemFields,
61
+ mergeGoals,
62
+ mergePlan,
63
+ buildCompletionContinuationMessage,
64
+ buildOwnershipContinuationMessage,
65
+ buildSpikeContinuationMessage,
66
+ findOwnershipIssues,
67
+ planChange,
68
+ recordReframeObservations,
69
+ spikeCompletedTodos,
70
+ todoConfidenceSummary,
71
+ todosEqual,
72
+ type CycleFlags,
73
+ } from "./gates.js";
74
+ import {
75
+ defaultState,
76
+ goalGroupKey,
77
+ planIsDefault,
78
+ sanitizeState,
79
+ type GateObservation,
80
+ type TodoDetails,
81
+ type TodoGoal,
82
+ type TodoGoalChange,
83
+ type TodoItem,
84
+ type TodoPlan,
85
+ type TodoPlanChange,
86
+ type TodoState,
87
+ } from "./model.js";
88
+ import { normalizeTodoInput } from "./normalize.js";
89
+ import { TodoParams, type TodoParamsInput } from "./schema.js";
90
+ import { TodoListComponent } from "./viewer.js";
91
+ import { TODO_WIDGET_ID, makeTodoWidget } from "./widget.js";
92
+ import { createWatchdog } from "./watchdog.js";
93
+
94
+ // ============================================================================
95
+ // Tool output (jcode build_todo_output: full stored-state echo + diffs)
96
+ // ============================================================================
97
+
98
+ function buildTodoOutput(
99
+ todos: TodoItem[],
100
+ plan: TodoPlan,
101
+ goals: TodoGoal[],
102
+ planChangeValue: TodoPlanChange | undefined,
103
+ goalChangesValue: TodoGoalChange[] | undefined,
104
+ changesDigest: string | undefined,
105
+ continuations: string[],
106
+ operation: "read" | "write" | "rejected",
107
+ ): { content: { type: "text"; text: string }[]; details: TodoDetails } {
108
+ let text = JSON.stringify(todos, null, 2);
109
+ if (!planIsDefault(plan)) {
110
+ text += `\n\nPlan:\n${JSON.stringify(plan, null, 2)}`;
111
+ }
112
+ if (goals.length > 0) {
113
+ text += `\n\nGoals:\n${JSON.stringify(goals, null, 2)}`;
114
+ }
115
+ if (planChangeValue) {
116
+ text += `\n\nPlan updates:\n${JSON.stringify(planChangeValue, null, 2)}`;
117
+ }
118
+ if (goalChangesValue && goalChangesValue.length > 0) {
119
+ text += `\n\nGoal updates:\n${JSON.stringify(goalChangesValue, null, 2)}`;
120
+ }
121
+ if (changesDigest) {
122
+ text += `\n\nChanges: ${changesDigest}`;
123
+ }
124
+ for (const continuation of continuations) {
125
+ text += `\n\n${continuation}`;
126
+ }
127
+ const details: TodoDetails = { operation, todos, plan, goals };
128
+ if (planChangeValue) details.plan_update = planChangeValue;
129
+ if (goalChangesValue && goalChangesValue.length > 0) details.goal_updates = goalChangesValue;
130
+ if (changesDigest) details.item_changes = changesDigest;
131
+ return { content: [{ type: "text", text }], details };
132
+ }
133
+
134
+ // ============================================================================
135
+ // Extension
136
+ // ============================================================================
137
+
138
+ export default function (pi: ExtensionAPI) {
139
+ const config = loadConfig();
140
+
141
+ // --- Session-scoped state (the extension instance is rebound per session)
142
+ let state = defaultState();
143
+ let stateLoadedForSession: string | undefined;
144
+
145
+ // --- Turn-scoped quality observations (jcode's gate-observations log)
146
+ const pendingObservations: GateObservation[] = [];
147
+
148
+ // --- Auto-poke cycle (jcode App fields)
149
+ let autoPokeArmed = config.autoPoke;
150
+ // pi-specific: once the user has explicitly silenced the poke (poke off,
151
+ // or Esc-interrupting a run), new open work must NOT re-arm it.
152
+ let pokeExplicitlyOff = false;
153
+ // Whether poking is allowed at all. session_start (when emitted — the
154
+ // SDK harness and some embedders never emit it) re-derives this from
155
+ // its ctx.hasUI; elsewhere starts optimistic, matching the module-level
156
+ // autoPokeArmed default so embedders without lifecycle events still
157
+ // behave like an interactive session.
158
+ let pokeUiAllowed = true;
159
+ let cycle: CycleFlags = freshCycleFlags();
160
+ let settledWithoutProgress = 0;
161
+ let lastSettledSignature: string | undefined;
162
+ let lastChallengedSignature: string | undefined;
163
+ let lastPokeTargets: Set<string> | undefined;
164
+ let idleNudgeSent = false;
165
+
166
+ function disarm() {
167
+ autoPokeArmed = false;
168
+ lastPokeTargets = undefined;
169
+ cycle = freshCycleFlags();
170
+ settledWithoutProgress = 0;
171
+ lastSettledSignature = undefined;
172
+ emitCycleMarker(false);
173
+ }
174
+
175
+ /**
176
+ * Persist a cycle transition so the branch walker knows where the gate
177
+ * flags reset (disarm here, fresh arm elsewhere). Without these entries
178
+ * transcript flags would be monotone across every cycle of the session:
179
+ * a /reload or /tree jump in cycle 2 would inherit cycle 1's
180
+ * digestDelivered/spikeChallenged and suppress cycle 2's gates.
181
+ */
182
+ function emitCycleMarker(armed: boolean) {
183
+ try {
184
+ pi.appendEntry(CYCLE_CUSTOM_TYPE, { armed });
185
+ } catch {
186
+ // reconstruction-only; never let it break the gate flow
187
+ }
188
+ }
189
+
190
+ // ------------------------------------------------------------------------
191
+ // Delivery helpers
192
+ // ------------------------------------------------------------------------
193
+
194
+ function notify(
195
+ ctx: { hasUI: boolean; ui: { notify: (m: string, k?: string) => void } },
196
+ message: string,
197
+ ) {
198
+ try {
199
+ if (ctx.hasUI) ctx.ui.notify(message, "info");
200
+ } catch {
201
+ // display-only; never let a notice break gate logic
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Synthetic gate follow-up. Delivered as a custom session message:
207
+ * convertToLlm maps it to an exact role:"user" message for the model
208
+ * (parity with jcode's user-role continuations), while the transcript
209
+ * keeps customType attribution instead of a fake user bubble.
210
+ */
211
+ async function sendGateFollowUp(content: string) {
212
+ await pi.sendMessage(
213
+ { customType: FOLLOWUP_CUSTOM_TYPE, content, display: true },
214
+ { deliverAs: "followUp", triggerTurn: true },
215
+ );
216
+ watchdog.notifyActivity(); // dispatched follow-up restarts the starve window
217
+ }
218
+
219
+ interface SetWidgetUI {
220
+ setWidget: (key: string, content?: unknown) => void;
221
+ }
222
+
223
+ /**
224
+ * Keep the above-editor widget in sync with branch state. The widget
225
+ * component reads state through thunks at render time, so re-setting it
226
+ * here only needs to happen when content may actually have changed.
227
+ */
228
+ function refreshWidget(ctx: { hasUI: boolean; ui: SetWidgetUI }): void {
229
+ if (!ctx.hasUI) return;
230
+ try {
231
+ if (!config.enabled || state.todos.length === 0) {
232
+ ctx.ui.setWidget(TODO_WIDGET_ID, undefined);
233
+ } else {
234
+ ctx.ui.setWidget(
235
+ TODO_WIDGET_ID,
236
+ makeTodoWidget(
237
+ () => state,
238
+ () => ({
239
+ armed: autoPokeArmed,
240
+ gateAttempts: cycle.gateAttempts,
241
+ gateMaxAttempts: config.completionGateMaxAttempts,
242
+ pokeTargets: lastPokeTargets,
243
+ }),
244
+ config.widgetMaxLines,
245
+ ),
246
+ );
247
+ }
248
+ } catch {
249
+ // display-only; never let the widget break gate logic
250
+ }
251
+ }
252
+
253
+ // ------------------------------------------------------------------------
254
+ // Branch-aware state (replaces jcode's per-session JSON files)
255
+ // ------------------------------------------------------------------------
256
+
257
+ /**
258
+ * Replay every todo tool result that lies on the ancestry path of the
259
+ * current leaf. Compaction-safe: the session file retains compacted
260
+ * entries; fork/tree-safe: only ancestors of the current leaf replay.
261
+ */
262
+ function reconstructState(ctx: ExtensionContext): void {
263
+ state = defaultState();
264
+ const entries = ctx.sessionManager.getEntries();
265
+ const byId = new Map(entries.map((e) => [e.id, e]));
266
+ const ancestorIds = new Set<string>();
267
+ let id: string | null = ctx.sessionManager.getLeafId();
268
+ while (id) {
269
+ ancestorIds.add(id);
270
+ const entry = byId.get(id);
271
+ id = (entry as { parentId?: string | null } | undefined)?.parentId ?? null;
272
+ }
273
+ const events: BranchEvent[] = [];
274
+ for (const entry of entries) {
275
+ if (!ancestorIds.has(entry.id)) continue;
276
+ if (entry.type === "message") {
277
+ const message = (
278
+ entry as { message?: { role?: string; toolName?: string; details?: unknown } }
279
+ ).message;
280
+ if (message?.role !== "toolResult" || message.toolName !== TODO_TOOL_NAME) continue;
281
+ const snapshot = sanitizeState(message.details);
282
+ if (!snapshot) continue;
283
+ state = snapshot;
284
+ events.push({
285
+ kind: "snapshot",
286
+ openAny: snapshot.todos.some(
287
+ (t) => t.status !== "completed" && t.status !== "cancelled",
288
+ ),
289
+ intentHistory: snapshot.plan?.understands_user_intent_history ?? [],
290
+ goals: snapshot.goals.map((g) => ({
291
+ key: goalGroupKey(g.group),
292
+ loopHistory: g.closed_feedback_loop_history ?? [],
293
+ })),
294
+ confidenceSignature: completionConfidenceSignature(snapshot.todos),
295
+ });
296
+ continue;
297
+ }
298
+ if (entry.type === "custom_message") {
299
+ const cm = entry as { customType?: string; content?: unknown };
300
+ if (cm.customType === FOLLOWUP_CUSTOM_TYPE && typeof cm.content === "string") {
301
+ events.push({ kind: "followup", content: cm.content });
302
+ }
303
+ }
304
+ if (entry.type === "custom") {
305
+ const ce = entry as { customType?: string; data?: { armed?: unknown } };
306
+ if (ce.customType === CYCLE_CUSTOM_TYPE) {
307
+ events.push({ kind: "cycle", armed: ce.data?.armed === true });
308
+ }
309
+ }
310
+ }
311
+ // The gate transcript is in the log too: rebuild the cycle flags and
312
+ // pending observations so a /reload or /tree jump does not re-litigate
313
+ // an already-answered gate (spike evidence persists in histories; the
314
+ // challenge transcript persists as follow-up entries on the branch).
315
+ const runtime = deriveBranchRuntime(events);
316
+ cycle = runtime.flags;
317
+ lastChallengedSignature = runtime.lastChallengedSignature;
318
+ pendingObservations.length = 0;
319
+ pendingObservations.push(...runtime.observations);
320
+ stateLoadedForSession = ctx.sessionManager.getSessionId();
321
+ }
322
+
323
+ function ensureStateLoaded(ctx: ExtensionContext): void {
324
+ if (stateLoadedForSession !== ctx.sessionManager.getSessionId()) {
325
+ reconstructState(ctx);
326
+ refreshWidget(ctx);
327
+ }
328
+ }
329
+
330
+ // ------------------------------------------------------------------------
331
+ // Tool execution (jcode-app-core TodoTool::execute)
332
+ // ------------------------------------------------------------------------
333
+
334
+ async function execute(params: TodoParamsInput, ctx: ExtensionContext) {
335
+ ensureStateLoaded(ctx);
336
+ const isWrite =
337
+ params.todos !== undefined || params.plan !== undefined || params.goals !== undefined;
338
+ if (!isWrite) {
339
+ return buildTodoOutput(state.todos, state.plan, state.goals, undefined, undefined, undefined, [], "read");
340
+ }
341
+
342
+ const previous = structuredClone(state);
343
+
344
+ // Goals/plan-only writes keep the stored todo list; `todos` replaces it.
345
+ if (params.todos) inheritItemFields(previous.todos, params.todos);
346
+ const todos = params.todos ? params.todos.map(buildItem) : structuredClone(state.todos);
347
+ mergeConfidenceHistory(previous.todos, todos);
348
+ const goals = mergeGoals(state.goals, params.goals);
349
+ const plan = mergePlan(state.plan, params.plan);
350
+
351
+ // Ownership gate: the one write-blocking check. The stored list is
352
+ // returned unchanged, so the gate cannot be routed around.
353
+ const ownershipIssues = findOwnershipIssues(previous.todos, todos, goals);
354
+ if (ownershipIssues.length > 0) {
355
+ const submittedKeys = new Set((params.goals ?? []).map((g) => goalGroupKey(g.group)));
356
+ const omittedGoals = ownershipIssues.some((i) => !submittedKeys.has(i.key));
357
+ return buildTodoOutput(
358
+ previous.todos,
359
+ previous.plan,
360
+ previous.goals,
361
+ undefined,
362
+ undefined,
363
+ undefined,
364
+ [buildOwnershipContinuationMessage(ownershipIssues, omittedGoals)],
365
+ "rejected",
366
+ );
367
+ }
368
+
369
+ const { observations, immediates } = recordReframeObservations(plan, goals, todos, previous.todos);
370
+ appendGateObservations(observations);
371
+
372
+ state = { todos, plan, goals };
373
+ // pi deviation from jcode (jcode re-arms ONLY via the /poke hotkey):
374
+ // a write that opens NEW work re-arms the poke cycle — pi has no
375
+ // hotkey, and a fresh todo batch going unpoked after a finished cycle
376
+ // reads as "the agent just stopped" (live dogfood fail, v1). An
377
+ // explicit off (poke off / Esc) sticks until the user re-arms.
378
+ const nowOpen = todos.some((t) => t.status !== "completed" && t.status !== "cancelled");
379
+ if (nowOpen && !autoPokeArmed && !pokeExplicitlyOff && pokeUiAllowed && config.enabled) {
380
+ autoPokeArmed = true;
381
+ emitCycleMarker(true);
382
+ cycle = freshCycleFlags();
383
+ settledWithoutProgress = 0;
384
+ lastSettledSignature = undefined;
385
+ lastChallengedSignature = undefined;
386
+ lastPokeTargets = undefined;
387
+ }
388
+ refreshWidget(ctx);
389
+
390
+ // Assessment-only writes render the fields that changed instead of
391
+ // repeating an otherwise identical todo plan.
392
+ const assessmentOnly = todosEqual(todos, previous.todos);
393
+ const planChangeValue = assessmentOnly ? planChange(previous.plan, plan) : undefined;
394
+ const goalChangesValue =
395
+ assessmentOnly && previous.goals.length > 0
396
+ ? goalChanges(previous.goals, goals)
397
+ : undefined;
398
+
399
+ // Change digest on accepted writes: what actually shifted in the
400
+ // stored list (post-merge), so accidental drops/clears surface in
401
+ // the write's own result instead of being discovered turns later.
402
+ const changesDigest = params.todos
403
+ ? formatItemChanges(diffItemChanges(previous.todos, todos))
404
+ : undefined;
405
+
406
+ return buildTodoOutput(
407
+ todos,
408
+ plan,
409
+ goals,
410
+ planChangeValue,
411
+ goalChangesValue,
412
+ changesDigest,
413
+ immediates,
414
+ "write",
415
+ );
416
+ }
417
+
418
+ function appendGateObservations(observations: GateObservation[]): void {
419
+ if (observations.length === 0) return;
420
+ pendingObservations.push(...observations);
421
+ if (pendingObservations.length > config.maxGateObservations) {
422
+ pendingObservations.splice(0, pendingObservations.length - config.maxGateObservations);
423
+ }
424
+ }
425
+
426
+ // ------------------------------------------------------------------------
427
+ // Turn-end state machine (jcode-tui input.rs::schedule_auto_poke_followup)
428
+ // ------------------------------------------------------------------------
429
+
430
+ function incompleteTodos(): TodoItem[] {
431
+ return state.todos.filter((t) => t.status !== "completed" && t.status !== "cancelled");
432
+ }
433
+
434
+ /** True when the last assistant message on this branch was aborted (Esc). */
435
+ function wasLastRunAborted(ctx: ExtensionContext): boolean {
436
+ const entries = ctx.sessionManager.getEntries();
437
+ for (let i = entries.length - 1; i >= 0; i--) {
438
+ const entry = entries[i];
439
+ if (entry.type !== "message") continue;
440
+ const message = (entry as { message?: { role?: string; stopReason?: string } }).message;
441
+ if (message?.role === "assistant") return message.stopReason === "aborted";
442
+ if (message?.role === "user") return false;
443
+ }
444
+ return false;
445
+ }
446
+
447
+ function countAssistantTurnsSinceUser(ctx: ExtensionContext): number {
448
+ const entries = ctx.sessionManager.getEntries();
449
+ let count = 0;
450
+ for (let i = entries.length - 1; i >= 0; i--) {
451
+ const entry = entries[i];
452
+ if (entry.type !== "message") continue;
453
+ const message = (entry as { message?: { role?: string } }).message;
454
+ if (message?.role === "user") break;
455
+ if (message?.role === "assistant") count++;
456
+ }
457
+ return count;
458
+ }
459
+
460
+ /**
461
+ * jcode's per-settle ordering: poke open todos first; only once the list
462
+ * is fully settled, deliver the deferred digest (once), then the
463
+ * completion-confidence and spike gates (budgeted), then disarm with a
464
+ * done notice.
465
+ */
466
+ async function runTurnEndCheck(ctx: ExtensionContext): Promise<void> {
467
+ const todos = state.todos;
468
+ if (todos.length === 0) {
469
+ // jcode: a settle with no todos at all silently disarms the poke.
470
+ autoPokeArmed = false;
471
+ await maybeIdleNudge(ctx);
472
+ return;
473
+ }
474
+
475
+ const incomplete = incompleteTodos();
476
+ if (incomplete.length > 0) {
477
+ cycle.gateAttempts = 0;
478
+ if (config.maxConsecutivePokesWithoutProgress > 0) {
479
+ const signature = JSON.stringify(
480
+ todos.map((t) => [t.id, t.status, t.completion_confidence]),
481
+ );
482
+ settledWithoutProgress =
483
+ signature === lastSettledSignature ? settledWithoutProgress + 1 : 0;
484
+ lastSettledSignature = signature;
485
+ if (settledWithoutProgress >= config.maxConsecutivePokesWithoutProgress) {
486
+ notify(
487
+ ctx,
488
+ "🛑 Auto-poke made no progress for several turns; stopped poking. /todos poke on to resume.",
489
+ );
490
+ disarm();
491
+ return;
492
+ }
493
+ }
494
+ notify(
495
+ ctx,
496
+ `👉 ${incomplete.length} incomplete todo${incomplete.length === 1 ? "" : "s"}. We poked it for you. ${NOTICE_POKE_OFF_HINT}`,
497
+ );
498
+ lastPokeTargets = new Set(incomplete.map((t) => t.id));
499
+ await sendGateFollowUp(buildAutoPokeMessage(incomplete.length));
500
+ return;
501
+ }
502
+
503
+ // All todos settled. Deferred quality digest first, once per cycle.
504
+ if (!cycle.digestDelivered && pendingObservations.length > 0) {
505
+ const digest = buildGateDigest(pendingObservations, state.plan, state.goals);
506
+ pendingObservations.length = 0;
507
+ if (digest) {
508
+ cycle.digestDelivered = true;
509
+ notify(ctx, NOTICE_DIGEST_QUEUED);
510
+ await sendGateFollowUp(digest);
511
+ return;
512
+ }
513
+ }
514
+
515
+ const summary = todoConfidenceSummary(todos);
516
+ const needsSpikeChallenge = summary.spike_detected && !cycle.spikeChallenged;
517
+ if (summary.needs_validation || needsSpikeChallenge) {
518
+ // Dead-lock detection: the gate outcome is a pure function of the
519
+ // settled todos' completion confidences. If a challenged turn moved
520
+ // no scores, the next identical prompt would produce the identical
521
+ // resubmission — stop early instead of burning the remaining
522
+ // attempts on a loop the user can only watch.
523
+ const signature = completionConfidenceSignature(todos);
524
+ const unchanged = summary.needs_validation && lastChallengedSignature === signature;
525
+ if (!unchanged && cycle.gateAttempts < config.completionGateMaxAttempts) {
526
+ cycle.gateAttempts += 1;
527
+ lastChallengedSignature = signature;
528
+ let message: string;
529
+ if (summary.needs_validation) {
530
+ notify(ctx, NOTICE_COMPLETION_CHALLENGED);
531
+ // The flagged-ids list doubles as the 👉 fingers and the names in
532
+ // the challenge message, so the agent hears exactly which items
533
+ // the gate is judging instead of re-deriving it.
534
+ const flaggedIds = todos
535
+ .filter(
536
+ (t) =>
537
+ t.status === "completed" &&
538
+ (t.completion_confidence === undefined ||
539
+ t.completion_confidence < QUALITY_GATE_THRESHOLD),
540
+ )
541
+ .map((t) => t.id);
542
+ lastPokeTargets = new Set(flaggedIds);
543
+ message = buildCompletionContinuationMessage(flaggedIds);
544
+ } else {
545
+ cycle.spikeChallenged = true;
546
+ notify(ctx, NOTICE_SPIKE_CHALLENGED);
547
+ const spikedIds = spikeCompletedTodos(todos).map((t) => t.id);
548
+ lastPokeTargets = new Set(spikedIds);
549
+ message = buildSpikeContinuationMessage(spikedIds);
550
+ }
551
+ await sendGateFollowUp(message);
552
+ return;
553
+ }
554
+ // Out of new information or out of budget: stop the cycle.
555
+ notify(ctx, unchanged ? NOTICE_GATE_UNCHANGED : NOTICE_GATE_STALLED);
556
+ lastChallengedSignature = undefined;
557
+ disarm();
558
+ return;
559
+ }
560
+
561
+ lastChallengedSignature = undefined;
562
+ notify(ctx, `✅ All todos done. Completion confidence: ${formatCompletionLabel(summary)}.`);
563
+ disarm();
564
+ }
565
+
566
+ async function maybeIdleNudge(ctx: ExtensionContext): Promise<void> {
567
+ if (!config.idleNudge || idleNudgeSent) return;
568
+ if (state.todos.length > 0) return;
569
+ if (countAssistantTurnsSinceUser(ctx) < config.idleNudgeAfterAssistantTurns) return;
570
+ idleNudgeSent = true;
571
+ await sendGateFollowUp(
572
+ "You have been working for a while without a todo list. If this task has multiple steps, consider using the todo tool to track progress.",
573
+ );
574
+ }
575
+
576
+ // ------------------------------------------------------------------------
577
+ // Idle starvation watchdog (jcode's queued-followup starvation watchdog,
578
+ // shaped like pi-simple-goal's idle watchdog)
579
+ // ------------------------------------------------------------------------
580
+
581
+ let watchdogCtx: ExtensionContext | undefined;
582
+ const watchdog = createWatchdog({
583
+ now: () => Date.now(),
584
+ isArmed: () => autoPokeArmed,
585
+ isIdle: () => watchdogCtx?.isIdle() ?? false,
586
+ incompleteCount: () => incompleteTodos().length,
587
+ wasAborted: () => (watchdogCtx ? wasLastRunAborted(watchdogCtx) : false),
588
+ idleMs: config.watchdogIdleMs,
589
+ maxRePokes: config.watchdogMaxRePokes,
590
+ onFire: () => {
591
+ if (watchdogCtx) {
592
+ notify(
593
+ watchdogCtx,
594
+ "🐕 Todo watchdog: agent went quiet with open todos — poking again.",
595
+ );
596
+ }
597
+ lastPokeTargets = new Set(incompleteTodos().map((t) => t.id));
598
+ void sendGateFollowUp(buildAutoPokeMessage(incompleteTodos().length));
599
+ },
600
+ onStarve: (reason) => {
601
+ disarm();
602
+ if (watchdogCtx) {
603
+ notify(
604
+ watchdogCtx,
605
+ reason === "aborted"
606
+ ? "Todo watchdog stopped (run was interrupted)."
607
+ : "🛑 Todo watchdog: agent kept stalling with open todos; auto-poke stopped. /todos poke on to resume.",
608
+ );
609
+ refreshWidget(watchdogCtx);
610
+ }
611
+ },
612
+ });
613
+ const watchdogTimer = setInterval(() => {
614
+ if (config.enabled && config.watchdog && watchdogCtx) {
615
+ try {
616
+ watchdog.tick();
617
+ } catch {
618
+ // liveness guard must never take the session down
619
+ }
620
+ }
621
+ }, config.watchdogTickMs);
622
+ (watchdogTimer as { unref?: () => void }).unref?.();
623
+
624
+ // ------------------------------------------------------------------------
625
+ // Tool registration
626
+ // ------------------------------------------------------------------------
627
+
628
+ pi.registerTool({
629
+ name: TODO_TOOL_NAME,
630
+ label: "Todo",
631
+ description: "Read or update structured todo items and optional goal-level assessments.",
632
+ promptSnippet:
633
+ "Plan multi-step work as small todos with a plan and goal-level feedback loops.",
634
+ promptGuidelines: [
635
+ "Use the todo tool for any non-trivial, multi-step task: send todos, a plan, and goals on the first write.",
636
+ "Group related todos with the todo tool's group field, one group per coherent goal, and give each goal a concrete feedback_loop.",
637
+ "Update the todo tool as work progresses; it maintains confidence history from your updates.",
638
+ "When completing todos with the todo tool, set completion_confidence to reflect validation you actually performed.",
639
+ "Omit the todo tool's todos field entirely to read or keep the current list; sending an empty list clears every todo.",
640
+ "When a group becomes fully completed in the todo tool, its goal needs an end_to_end_ownership assessment of the full user outcome.",
641
+ "Do not stop with incomplete todos unless the user explicitly cancels the task.",
642
+ ],
643
+ parameters: TodoParams,
644
+ prepareArguments(args: unknown): TodoParamsInput {
645
+ return normalizeTodoInput(args) as TodoParamsInput;
646
+ },
647
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
648
+ watchdog.notifyActivity();
649
+ return execute(params, ctx);
650
+ },
651
+ renderCall(args, theme) {
652
+ let text = theme.fg("toolTitle", theme.bold("todo "));
653
+ if (args.todos) {
654
+ text += theme.fg("muted", `${args.todos.length} todo${args.todos.length === 1 ? "" : "s"}`);
655
+ } else {
656
+ text += theme.fg("muted", "read");
657
+ }
658
+ if (args.plan?.user_intention) {
659
+ text += theme.fg("dim", ` "${args.plan.user_intention}"`);
660
+ }
661
+ if (args.goals) {
662
+ text += theme.fg("muted", `${args.todos ? ", " : ""}${args.goals.length} goal${args.goals.length === 1 ? "" : "s"}`);
663
+ }
664
+ return new Text(text, 0, 0);
665
+ },
666
+ renderResult(result, { expanded }, theme) {
667
+ const details = result.details as TodoDetails | undefined;
668
+ if (!details) {
669
+ const t = result.content[0];
670
+ return new Text(t?.type === "text" ? t.text : "", 0, 0);
671
+ }
672
+ const total = details.todos.length;
673
+ const settled = details.todos.filter(
674
+ (t) => t.status === "completed" || t.status === "cancelled",
675
+ ).length;
676
+ let text =
677
+ details.operation === "rejected"
678
+ ? theme.fg("warning", `write rejected — stored list unchanged (${settled}/${total} settled)`)
679
+ : theme.fg("muted", `${settled}/${total} settled`);
680
+ const display = expanded ? details.todos : details.todos.slice(0, 5);
681
+ for (const t of display) {
682
+ const icon =
683
+ t.status === "completed"
684
+ ? theme.fg("success", "✓")
685
+ : t.status === "cancelled"
686
+ ? theme.fg("error", "✗")
687
+ : t.status === "in_progress"
688
+ ? theme.fg("accent", "▶")
689
+ : theme.fg("dim", "○");
690
+ const item =
691
+ t.status === "completed" || t.status === "cancelled"
692
+ ? theme.fg("dim", t.content)
693
+ : theme.fg("text", t.content);
694
+ text += `\n${icon} ${theme.fg("accent", `#${t.id}`)} ${item}`;
695
+ }
696
+ if (!expanded && details.todos.length > 5) {
697
+ text += `\n${theme.fg("dim", `... ${details.todos.length - 5} more`)}`;
698
+ }
699
+ if (expanded && details.goals.length > 0) {
700
+ text += `\n${theme.fg("muted", "Goals:")}`;
701
+ for (const goal of details.goals) {
702
+ const g = goal.group ?? "(ungrouped)";
703
+ const loop = goal.feedback_loop ? ` – ${goal.feedback_loop}` : "";
704
+ text += `\n ${theme.fg("accent", g)}${theme.fg("dim", loop)}`;
705
+ }
706
+ }
707
+ return new Text(text, 0, 0);
708
+ },
709
+ });
710
+
711
+ // ------------------------------------------------------------------------
712
+ // /todos command
713
+ // ------------------------------------------------------------------------
714
+
715
+ pi.registerCommand("todos", {
716
+ description:
717
+ "Todo viewer and poke control (/todos, /todos export|clear|poke [on|off|status|trigger])",
718
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
719
+ ensureStateLoaded(ctx);
720
+ const [sub, subArg] = args.trim().split(/\s+/);
721
+ if (sub === "export") {
722
+ ctx.ui.notify(JSON.stringify({ ...state, pendingObservations }, null, 2), "info");
723
+ return;
724
+ }
725
+ if (sub === "clear") {
726
+ state = defaultState();
727
+ pendingObservations.length = 0;
728
+ lastPokeTargets = undefined;
729
+ refreshWidget(ctx);
730
+ ctx.ui.notify(
731
+ "Todo list reset (session-local; history replays from branch entries).",
732
+ "info",
733
+ );
734
+ return;
735
+ }
736
+ if (sub === "poke") {
737
+ await handlePokeCommand(subArg, ctx);
738
+ return;
739
+ }
740
+ if (ctx.mode !== "tui") {
741
+ ctx.ui.notify("/todos requires TUI mode (try /todos export)", "error");
742
+ return;
743
+ }
744
+ await ctx.ui.custom(
745
+ (_tui: unknown, theme, _kb: unknown, done: (r?: unknown) => void) => {
746
+ return new TodoListComponent(state, autoPokeArmed, theme, () => done()) as never;
747
+ },
748
+ );
749
+ },
750
+ });
751
+
752
+ async function handlePokeCommand(arg: string | undefined, ctx: ExtensionCommandContext) {
753
+ const action = (arg ?? "status").toLowerCase();
754
+ if (action === "off") {
755
+ disarm();
756
+ pokeExplicitlyOff = true;
757
+ refreshWidget(ctx);
758
+ ctx.ui.notify("Auto-poke disabled.", "info");
759
+ return;
760
+ }
761
+ if (action === "on" || action === "trigger") {
762
+ autoPokeArmed = true;
763
+ pokeExplicitlyOff = false;
764
+ cycle = freshCycleFlags();
765
+ settledWithoutProgress = 0;
766
+ lastChallengedSignature = undefined;
767
+ emitCycleMarker(true);
768
+ refreshWidget(ctx);
769
+ ctx.ui.notify("Poke: ON", "info");
770
+ const incomplete = incompleteTodos();
771
+ if (incomplete.length === 0) {
772
+ ctx.ui.notify(
773
+ "Nothing unfinished right now; we'll poke the agent if it stops with todos left.",
774
+ "info",
775
+ );
776
+ return;
777
+ }
778
+ if (!ctx.isIdle()) {
779
+ ctx.ui.notify("Poke queued. We'll re-check for unfinished todos after this turn.", "info");
780
+ return;
781
+ }
782
+ await runTurnEndCheck(ctx);
783
+ refreshWidget(ctx);
784
+ return;
785
+ }
786
+ ctx.ui.notify(
787
+ `Poke: ${autoPokeArmed ? "ON" : "OFF"}. ${incompleteTodos().length} incomplete todo(s).`,
788
+ "info",
789
+ );
790
+ }
791
+
792
+ // ------------------------------------------------------------------------
793
+ // Events
794
+ // ------------------------------------------------------------------------
795
+
796
+ pi.on("session_start", (_event, ctx) => {
797
+ watchdogCtx = ctx;
798
+ watchdog.notifyActivity();
799
+ idleNudgeSent = false;
800
+ pendingObservations.length = 0;
801
+ cycle = freshCycleFlags();
802
+ settledWithoutProgress = 0;
803
+ lastSettledSignature = undefined;
804
+ lastChallengedSignature = undefined;
805
+ lastPokeTargets = undefined;
806
+ pokeExplicitlyOff = false;
807
+ pokeUiAllowed = ctx.hasUI;
808
+ autoPokeArmed = config.autoPoke && ctx.hasUI;
809
+ reconstructState(ctx);
810
+ refreshWidget(ctx);
811
+ });
812
+
813
+ pi.on("session_tree", (_event, ctx) => {
814
+ // Navigation within the session: rebuild state AND the cycle flags for
815
+ // the new branch from its own log (armed state is kept, like jcode).
816
+ settledWithoutProgress = 0;
817
+ lastSettledSignature = undefined;
818
+ reconstructState(ctx);
819
+ refreshWidget(ctx);
820
+ });
821
+
822
+ pi.on("input", (event) => {
823
+ // Real user input resets the idle nudge. Our own gate follow-ups are
824
+ // custom messages (no input event at all), but guard anyway.
825
+ const source = (event as { source?: string }).source;
826
+ if (source === "interactive" || source === "rpc") {
827
+ idleNudgeSent = false;
828
+ watchdog.notifyActivity();
829
+ }
830
+ });
831
+
832
+ pi.on("agent_start", () => watchdog.notifyActivity());
833
+ pi.on("turn_end", (_event, ctx) => {
834
+ watchdog.notifyActivity();
835
+ // Self-heal the strip: any repaint path pi caches against re-renders
836
+ // at most once per turn regardless of which earlier refresh pi missed.
837
+ refreshWidget(ctx);
838
+ });
839
+
840
+ pi.on("session_shutdown", () => {
841
+ clearInterval(watchdogTimer);
842
+ watchdogCtx = undefined;
843
+ });
844
+
845
+ pi.on("before_agent_start", (event) => {
846
+ if (!config.enabled) return {};
847
+ const reminder =
848
+ "For non-trivial, multi-step work, plan with the todo tool before starting: small todos, grouped by goal when the task has distinct parts, with a plan stating what the user actually wants and a concrete feedback_loop for each goal. " +
849
+ "Update the todo tool as you learn and when you complete steps; the tool's quality checks will hold you to the plan and to evidence of completion. " +
850
+ "For simple one-off questions, skip the todo tool.";
851
+ return { systemPrompt: (event.systemPrompt ?? "") + "\n\n" + reminder };
852
+ });
853
+
854
+ pi.on("agent_settled", async (_event, ctx) => {
855
+ if (!config.enabled) return;
856
+ ensureStateLoaded(ctx);
857
+ if (!ctx.isIdle()) return;
858
+ // jcode's Esc: an interrupted run disarms auto-poke entirely and
859
+ // injects no follow-up of any kind.
860
+ if (wasLastRunAborted(ctx)) {
861
+ disarm();
862
+ pokeExplicitlyOff = true; // Esc = the user said stop; new work must not re-arm.
863
+ refreshWidget(ctx);
864
+ return;
865
+ }
866
+ if (!autoPokeArmed) {
867
+ await maybeIdleNudge(ctx);
868
+ return;
869
+ }
870
+ await runTurnEndCheck(ctx);
871
+ refreshWidget(ctx);
872
+ });
873
+ }