@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/LICENSE +19 -0
- package/README.md +83 -0
- package/package.json +48 -0
- package/src/config.ts +71 -0
- package/src/constants.ts +78 -0
- package/src/gates.ts +771 -0
- package/src/index.ts +873 -0
- package/src/model.ts +155 -0
- package/src/normalize.ts +122 -0
- package/src/schema.ts +101 -0
- package/src/viewer.ts +136 -0
- package/src/watchdog.ts +71 -0
- package/src/widget.ts +378 -0
- package/tests/test-todo.cjs +1040 -0
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
|
+
}
|