@matthewfl/pi-jtodo 0.0.1 → 0.0.3

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/README.md CHANGED
@@ -41,6 +41,8 @@ Deliberately **not** ported from jcode: the `/overnight` mission mode (presumes
41
41
  - **Write-time gate**: a write that closes a group (or the implicit ungrouped list) without an honest `end_to_end_ownership` claim is **rejected whole** — stored state is returned unchanged with an actionable message naming the failing groups. A first plan write with severely low intent gets an immediate in-band continuation.
42
42
  - **Turn-end gates** (when the agent settles): incomplete todos → auto-poke ("You have N incomplete todos. Continue working, or update the todo tool."). Fully settled → the deferred quality digest (intent/feedback-loop weak points), then the completion-confidence and confidence-spike gates — each naming the flagged `#id`s — with an attempt budget, an unchanged-signature early stop, and a done notice when validation passes.
43
43
  - **Follow-ups are attributed**: pokes, digests, and gate challenges travel as custom messages (LLM-visible as user-role text, transcript-visible as `pi-jtodo/followup`), so reload/resume never re-renders them as user prompts.
44
+ - **The poke waits `pokeDelayMs` before firing** (default 20s, pi-simple-goal's restart-countdown pattern): when the agent stops with open todos the settle schedules the poke instead of sending it, and the widget shows `· poke in Ns` counting down. **If the user starts typing during the window, the poke is canceled and poking pauses** until the next agent run, so a user who is about to take over is never fought by the machine. The completion/spike gates, the deferred digest, the watchdog re-fire, and manual `/todos poke on|trigger` stay immediate — gates respond to the agent's own claims, manual pokes are explicit.
45
+ - **Escape pauses, it does not silence** (pi-simple-goal pattern): a raw `\x1b` keypress within 5s of an aborted run marks it as the user's stop — the settle is quiet and the poke pauses until the user re-engages; the next agent run lifts the pause and re-arms. Aborts with no recent Escape behind them are machinery (compaction, `ctx.abort()`, transport, provider stacks mislabeling the interrupt as an error): their settle is quiet but the cycle stays armed and the starvation watchdog covers a lost continuation. The only sticky off is `/todos poke off`.
44
46
 
45
47
  ## Display
46
48
 
@@ -55,6 +57,7 @@ Deliberately **not** ported from jcode: the `/overnight` mission mode (presumes
55
57
  {
56
58
  "enabled": true,
57
59
  "autoPoke": true,
60
+ "pokeDelayMs": 20000,
58
61
  "completionGateMaxAttempts": 5,
59
62
  "maxGateObservations": 256,
60
63
  "maxConsecutivePokesWithoutProgress": 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matthewfl/pi-jtodo",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Quality-gated todo tool for Pi: confidence scores with tool-owned histories, plan and per-goal assessments, write-time ownership rejection, turn-end completion/spike gates, deferred quality digests, auto-poke continuation, starvation watchdog, and a live todo strip.",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/config.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * Optional per-user configuration: ~/.pi/agent/state/pi-jtodo/config.json
3
3
  * Missing or unreadable config falls back to defaults. Gate thresholds are
4
4
  * calibration constants (see constants.ts) and deliberately NOT configurable.
5
+ * PI_JTODO_STATE_DIR overrides the state directory (embedding/tests).
5
6
  */
6
7
 
7
8
  import * as fs from "node:fs";
@@ -13,6 +14,13 @@ export interface TodoConfig {
13
14
  enabled: boolean;
14
15
  /** Arm the auto-poke cycle when each session starts (TUI/RPC only). */
15
16
  autoPoke: boolean;
17
+ /**
18
+ * Delay before the settle poke fires when the agent stops with open
19
+ * todos (pi-simple-goal restartDelayMs pattern). While the poke waits,
20
+ * any terminal input (the user starts typing) cancels it and pauses
21
+ * poking until the next agent run. 0 = immediate (pure jcode).
22
+ */
23
+ pokeDelayMs: number;
16
24
  /** Turn-end re-validation budget before the gate stalls and disarms. */
17
25
  completionGateMaxAttempts: number;
18
26
  /** Cap on the turn-scoped observation log; oldest dropped first. */
@@ -38,6 +46,7 @@ export interface TodoConfig {
38
46
  export const DEFAULT_CONFIG: TodoConfig = {
39
47
  enabled: true,
40
48
  autoPoke: true,
49
+ pokeDelayMs: 20_000,
41
50
  completionGateMaxAttempts: TODO_COMPLETION_GATE_MAX_ATTEMPTS,
42
51
  maxGateObservations: MAX_GATE_OBSERVATIONS,
43
52
  maxConsecutivePokesWithoutProgress: 0,
@@ -52,14 +61,10 @@ export const DEFAULT_CONFIG: TodoConfig = {
52
61
 
53
62
  export function loadConfig(): TodoConfig {
54
63
  try {
55
- const p = path.join(
56
- os.homedir(),
57
- ".pi",
58
- "agent",
59
- "state",
60
- "pi-jtodo",
61
- "config.json",
62
- );
64
+ const stateDir =
65
+ process.env.PI_JTODO_STATE_DIR ??
66
+ path.join(os.homedir(), ".pi", "agent", "state", "pi-jtodo");
67
+ const p = path.join(stateDir, "config.json");
63
68
  const parsed = JSON.parse(fs.readFileSync(p, "utf8"));
64
69
  if (typeof parsed === "object" && parsed !== null) {
65
70
  return { ...DEFAULT_CONFIG, ...parsed };
package/src/constants.ts CHANGED
@@ -26,6 +26,12 @@ export const TODO_COMPLETION_GATE_MAX_ATTEMPTS = 5;
26
26
  /** Upper bound on retained turn-scoped observations; oldest dropped first. */
27
27
  export const MAX_GATE_OBSERVATIONS = 256;
28
28
 
29
+ // Window in which a raw Escape keypress is considered the cause of an aborted
30
+ // run (user stopped it on purpose). Outside this window an abort is machinery
31
+ // (compaction, ctx.abort(), transport) and the poke cycle stays armed.
32
+ // Same value and rationale as pi-simple-goal's ESCAPE_ABORT_WINDOW_MS.
33
+ export const ESCAPE_ABORT_WINDOW_MS = 5_000;
34
+
29
35
  /** customType for all synthetic gate follow-ups (custom session messages). */
30
36
  export const FOLLOWUP_CUSTOM_TYPE = "pi-jtodo/followup";
31
37
 
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ import type {
32
32
  import { Text } from "@earendil-works/pi-tui";
33
33
  import {
34
34
  CYCLE_CUSTOM_TYPE,
35
+ ESCAPE_ABORT_WINDOW_MS,
35
36
  FOLLOWUP_CUSTOM_TYPE,
36
37
  NOTICE_COMPLETION_CHALLENGED,
37
38
  NOTICE_DIGEST_QUEUED,
@@ -89,6 +90,7 @@ import { normalizeTodoInput } from "./normalize.js";
89
90
  import { TodoParams, type TodoParamsInput } from "./schema.js";
90
91
  import { TodoListComponent } from "./viewer.js";
91
92
  import { TODO_WIDGET_ID, makeTodoWidget } from "./widget.js";
93
+ import { createPokeScheduler } from "./poke-scheduler.js";
92
94
  import { createWatchdog } from "./watchdog.js";
93
95
 
94
96
  // ============================================================================
@@ -150,6 +152,31 @@ export default function (pi: ExtensionAPI) {
150
152
  // pi-specific: once the user has explicitly silenced the poke (poke off,
151
153
  // or Esc-interrupting a run), new open work must NOT re-arm it.
152
154
  let pokeExplicitlyOff = false;
155
+ // Whether the most recent completed agent run was Esc-aborted, recorded
156
+ // from the agent_end event itself (the run's own messages). Entry-scanning
157
+ // heuristics misclassify aborts when a queued user message (compaction
158
+ // resume, steer) lands after the aborted assistant message; an
159
+ // Esc-aborted run must never restart the agent via a poke.
160
+ let lastRunAborted = false;
161
+ // Goal-plugin-style Escape correlation (pi-simple-goal): a raw Escape
162
+ // keypress observed via terminal input marks the NEXT aborted run as the
163
+ // user's stop. Aborts with no recent Escape behind them are machinery
164
+ // (compaction, ctx.abort(), transport) and keep the poke cycle armed.
165
+ let lastEscapeAt = 0;
166
+ // Set when an Escape-classified abort pauses the poke. NOT sticky: the
167
+ // next agent run (user re-engaged, or machinery resumed) lifts it and
168
+ // re-arms, per the user's "stop the poke for the next while" rule.
169
+ let escPokePaused = false;
170
+ let unsubscribeTerminalInput: (() => void) | undefined;
171
+ // pi-simple-goal restartDelayMs pattern (see sendSettlePoke): the settle
172
+ // poke can wait pokeDelayMs before firing; any terminal input during the
173
+ // window cancels it and pauses poking until the next agent run.
174
+ // 0 = immediate (pure jcode).
175
+ const pokeScheduler = createPokeScheduler(config.pokeDelayMs);
176
+ let pokeTickTimer: NodeJS.Timeout | undefined;
177
+ // Whether the raw-input listener is subscribed (TUI only). A delayed
178
+ // poke is only safe when typing can cancel it; otherwise fire immediately.
179
+ let terminalInputActive = false;
153
180
  // Whether poking is allowed at all. session_start (when emitted — the
154
181
  // SDK harness and some embedders never emit it) re-derives this from
155
182
  // its ctx.hasUI; elsewhere starts optimistic, matching the module-level
@@ -164,6 +191,7 @@ export default function (pi: ExtensionAPI) {
164
191
  let idleNudgeSent = false;
165
192
 
166
193
  function disarm() {
194
+ cancelPendingPoke();
167
195
  autoPokeArmed = false;
168
196
  lastPokeTargets = undefined;
169
197
  cycle = freshCycleFlags();
@@ -237,6 +265,8 @@ export default function (pi: ExtensionAPI) {
237
265
  () => state,
238
266
  () => ({
239
267
  armed: autoPokeArmed,
268
+ escPaused: escPokePaused,
269
+ pokePendingAt: pokeScheduler.deadlineAt(),
240
270
  gateAttempts: cycle.gateAttempts,
241
271
  gateMaxAttempts: config.completionGateMaxAttempts,
242
272
  pokeTargets: lastPokeTargets,
@@ -250,6 +280,55 @@ export default function (pi: ExtensionAPI) {
250
280
  }
251
281
  }
252
282
 
283
+ /** Cancel a pending delayed poke (if any) and stop its countdown tick. */
284
+ function cancelPendingPoke(): void {
285
+ pokeScheduler.cancel();
286
+ if (pokeTickTimer !== undefined) {
287
+ clearInterval(pokeTickTimer);
288
+ pokeTickTimer = undefined;
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Send the settle poke, honoring pokeDelayMs (pi-simple-goal's restart
294
+ * countdown pattern): schedule it so the user can take over during the
295
+ * window — any typing cancels the poke and pauses the cycle — or fire
296
+ * immediately when no delay is configured, the raw-input listener is not
297
+ * subscribed (nothing could cancel it), or this is a manual trigger.
298
+ */
299
+ function sendSettlePoke(ctx: ExtensionContext, immediate: boolean): void {
300
+ const fire = (): void => {
301
+ cancelPendingPoke();
302
+ const todos = incompleteTodos();
303
+ if (!config.enabled || !autoPokeArmed || escPokePaused || todos.length === 0) {
304
+ return; // state changed while the poke waited
305
+ }
306
+ if (watchdogCtx && !watchdogCtx.isIdle()) {
307
+ return; // something already restarted the agent
308
+ }
309
+ watchdog.notifyActivity();
310
+ lastPokeTargets = new Set(todos.map((t) => t.id));
311
+ notify(
312
+ ctx,
313
+ `👉 ${todos.length} incomplete todo${todos.length === 1 ? "" : "s"}. We poked it for you. ${NOTICE_POKE_OFF_HINT}`,
314
+ );
315
+ void sendGateFollowUp(buildAutoPokeMessage(todos.length));
316
+ refreshWidget(ctx);
317
+ };
318
+ if (!immediate && config.pokeDelayMs > 0 && terminalInputActive) {
319
+ pokeScheduler.schedule(fire);
320
+ if (pokeTickTimer === undefined) {
321
+ pokeTickTimer = setInterval(() => {
322
+ if (watchdogCtx) refreshWidget(watchdogCtx);
323
+ }, 1000);
324
+ if (typeof pokeTickTimer.unref === "function") pokeTickTimer.unref();
325
+ }
326
+ refreshWidget(ctx); // surface "poke in Ns" immediately
327
+ } else {
328
+ fire();
329
+ }
330
+ }
331
+
253
332
  // ------------------------------------------------------------------------
254
333
  // Branch-aware state (replaces jcode's per-session JSON files)
255
334
  // ------------------------------------------------------------------------
@@ -432,16 +511,16 @@ export default function (pi: ExtensionAPI) {
432
511
  }
433
512
 
434
513
  /** 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;
514
+ function wasLastRunAborted(): boolean {
515
+ return lastRunAborted;
516
+ }
517
+
518
+ // Some provider stacks (pi-robust-provider observed live: errorMessage
519
+ // "This operation was aborted") finalize an Esc-aborted request as a
520
+ // generic error whose text still names the abort. Those are user
521
+ // interrupts, not provider failures, and must never restart the agent.
522
+ function looksInterrupted(stopReason: string | undefined, errorMessage: string | undefined): boolean {
523
+ return stopReason === "error" && typeof errorMessage === "string" && /abort/i.test(errorMessage);
445
524
  }
446
525
 
447
526
  function countAssistantTurnsSinceUser(ctx: ExtensionContext): number {
@@ -463,7 +542,7 @@ export default function (pi: ExtensionAPI) {
463
542
  * completion-confidence and spike gates (budgeted), then disarm with a
464
543
  * done notice.
465
544
  */
466
- async function runTurnEndCheck(ctx: ExtensionContext): Promise<void> {
545
+ async function runTurnEndCheck(ctx: ExtensionContext, immediatePoke = false): Promise<void> {
467
546
  const todos = state.todos;
468
547
  if (todos.length === 0) {
469
548
  // jcode: a settle with no todos at all silently disarms the poke.
@@ -474,6 +553,12 @@ export default function (pi: ExtensionAPI) {
474
553
 
475
554
  const incomplete = incompleteTodos();
476
555
  if (incomplete.length > 0) {
556
+ if (escPokePaused) {
557
+ // The user paused poking (Escape, or typing during the poke
558
+ // window): quiet until the next agent run lifts the pause.
559
+ refreshWidget(ctx);
560
+ return;
561
+ }
477
562
  cycle.gateAttempts = 0;
478
563
  if (config.maxConsecutivePokesWithoutProgress > 0) {
479
564
  const signature = JSON.stringify(
@@ -491,12 +576,10 @@ export default function (pi: ExtensionAPI) {
491
576
  return;
492
577
  }
493
578
  }
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));
579
+ // The poke itself may be delayed (pokeDelayMs): sendSettlePoke
580
+ // schedules it so the user can take over by typing, or fires
581
+ // immediately for manual /todos poke triggers.
582
+ sendSettlePoke(ctx, immediatePoke);
500
583
  return;
501
584
  }
502
585
 
@@ -584,7 +667,9 @@ export default function (pi: ExtensionAPI) {
584
667
  isArmed: () => autoPokeArmed,
585
668
  isIdle: () => watchdogCtx?.isIdle() ?? false,
586
669
  incompleteCount: () => incompleteTodos().length,
587
- wasAborted: () => (watchdogCtx ? wasLastRunAborted(watchdogCtx) : false),
670
+ wasAborted: () => lastRunAborted,
671
+ // The watchdog must not inject through an Esc- or typing-pause either.
672
+ isPaused: () => escPokePaused,
588
673
  idleMs: config.watchdogIdleMs,
589
674
  maxRePokes: config.watchdogMaxRePokes,
590
675
  onFire: () => {
@@ -761,6 +846,7 @@ export default function (pi: ExtensionAPI) {
761
846
  if (action === "on" || action === "trigger") {
762
847
  autoPokeArmed = true;
763
848
  pokeExplicitlyOff = false;
849
+ escPokePaused = false;
764
850
  cycle = freshCycleFlags();
765
851
  settledWithoutProgress = 0;
766
852
  lastChallengedSignature = undefined;
@@ -779,7 +865,7 @@ export default function (pi: ExtensionAPI) {
779
865
  ctx.ui.notify("Poke queued. We'll re-check for unfinished todos after this turn.", "info");
780
866
  return;
781
867
  }
782
- await runTurnEndCheck(ctx);
868
+ await runTurnEndCheck(ctx, true);
783
869
  refreshWidget(ctx);
784
870
  return;
785
871
  }
@@ -793,19 +879,100 @@ export default function (pi: ExtensionAPI) {
793
879
  // Events
794
880
  // ------------------------------------------------------------------------
795
881
 
882
+ pi.on("agent_end", (event) => {
883
+ // Record the most recent run's abort status straight from the run's
884
+ // own messages; agent_settled reads this (it fires after agent_end).
885
+ const messages = event.messages as Array<{ role?: string; stopReason?: string; errorMessage?: string }>;
886
+ for (let i = messages.length - 1; i >= 0; i--) {
887
+ const m = messages[i];
888
+ if (m.role === "assistant") {
889
+ lastRunAborted = m.stopReason === "aborted" || looksInterrupted(m.stopReason, m.errorMessage);
890
+ break;
891
+ }
892
+ }
893
+ watchdog.notifyActivity();
894
+ });
895
+
896
+ pi.on("agent_start", (_event, ctx) => {
897
+ // A pending delayed poke is moot once a new run starts: this run's
898
+ // settle will schedule a fresh one if todos remain open.
899
+ cancelPendingPoke();
900
+ // A new run means the user (or machinery) re-engaged: an Esc pause
901
+ // lifts outside the abort-correlation window where the just-triggered
902
+ // teardown is still settling (pi-simple-goal's maybeResumeOnActivity).
903
+ if (escPokePaused && Date.now() - lastEscapeAt > ESCAPE_ABORT_WINDOW_MS) {
904
+ escPokePaused = false;
905
+ if (config.enabled && config.autoPoke && !pokeExplicitlyOff && pokeUiAllowed) {
906
+ autoPokeArmed = true;
907
+ cycle = freshCycleFlags();
908
+ }
909
+ refreshWidget(ctx);
910
+ }
911
+ watchdog.notifyActivity();
912
+ });
913
+
914
+ pi.on("session_before_compact", () => {
915
+ watchdog.notifyActivity(); // compaction can run long; never starve the watchdog
916
+ });
917
+ pi.on("session_compact", () => {
918
+ watchdog.notifyActivity();
919
+ });
920
+ pi.on("session_compact_failed", () => {
921
+ watchdog.notifyActivity();
922
+ });
923
+
796
924
  pi.on("session_start", (_event, ctx) => {
797
925
  watchdogCtx = ctx;
798
926
  watchdog.notifyActivity();
927
+ cancelPendingPoke(); // a stale delayed poke never crosses sessions
799
928
  idleNudgeSent = false;
800
929
  pendingObservations.length = 0;
801
930
  cycle = freshCycleFlags();
931
+ lastRunAborted = false;
802
932
  settledWithoutProgress = 0;
803
933
  lastSettledSignature = undefined;
804
934
  lastChallengedSignature = undefined;
805
935
  lastPokeTargets = undefined;
806
936
  pokeExplicitlyOff = false;
937
+ escPokePaused = false;
938
+ lastEscapeAt = 0;
807
939
  pokeUiAllowed = ctx.hasUI;
808
940
  autoPokeArmed = config.autoPoke && ctx.hasUI;
941
+ // TUI only: watch raw terminal input for the Escape key so an aborted
942
+ // run can be correlated with the user's actual keypress (pi-simple-goal
943
+ // pattern). stopReason alone is unreliable: provider stacks can
944
+ // finalize an abort as a generic error naming the abort.
945
+ try {
946
+ const uictx = ctx as { mode?: string; ui: { onTerminalInput?: (cb: (data: string) => unknown) => () => void } };
947
+ if (uictx.mode === "tui" && typeof uictx.ui.onTerminalInput === "function") {
948
+ unsubscribeTerminalInput = uictx.ui.onTerminalInput((data) => {
949
+ watchdog.notifyActivity(); // any keystroke is user activity
950
+ if (data === "\x1b" || data === "\x1b\x1b" || data === "\x1b[27u") {
951
+ lastEscapeAt = Date.now();
952
+ // Escape = manual control (goal-plugin pattern): cancel any
953
+ // pending delayed poke and hold poking until the next run.
954
+ cancelPendingPoke();
955
+ if (!escPokePaused) {
956
+ escPokePaused = true;
957
+ refreshWidget(ctx);
958
+ }
959
+ return undefined;
960
+ }
961
+ if (pokeScheduler.isPending()) {
962
+ // Typing during the poke window cancels the poke and pauses
963
+ // the cycle (pi-simple-goal restart-countdown behavior).
964
+ cancelPendingPoke();
965
+ escPokePaused = true;
966
+ refreshWidget(ctx);
967
+ }
968
+ return undefined;
969
+ });
970
+ terminalInputActive = true;
971
+ }
972
+ } catch {
973
+ // Non-TUI or older pi: aborts fall back to machinery classification.
974
+ terminalInputActive = false;
975
+ }
809
976
  reconstructState(ctx);
810
977
  refreshWidget(ctx);
811
978
  });
@@ -839,6 +1006,12 @@ export default function (pi: ExtensionAPI) {
839
1006
 
840
1007
  pi.on("session_shutdown", () => {
841
1008
  clearInterval(watchdogTimer);
1009
+ cancelPendingPoke();
1010
+ if (unsubscribeTerminalInput) {
1011
+ unsubscribeTerminalInput();
1012
+ unsubscribeTerminalInput = undefined;
1013
+ }
1014
+ terminalInputActive = false;
842
1015
  watchdogCtx = undefined;
843
1016
  });
844
1017
 
@@ -857,10 +1030,19 @@ export default function (pi: ExtensionAPI) {
857
1030
  if (!ctx.isIdle()) return;
858
1031
  // jcode's Esc: an interrupted run disarms auto-poke entirely and
859
1032
  // 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);
1033
+ if (wasLastRunAborted()) {
1034
+ // Escape correlation (pi-simple-goal pattern): only an aborted run
1035
+ // with a RECENT raw Escape keypress behind it is the user's stop
1036
+ // that quiets the settle and pauses the poke until the next run.
1037
+ // Any other abort (compaction, ctx.abort(), transport, provider
1038
+ // stacks mislabeling the interrupt) is machinery: stay quiet and keep
1039
+ // the cycle armed; the watchdog covers a genuinely lost continuation.
1040
+ if (lastEscapeAt > 0 && Date.now() - lastEscapeAt <= ESCAPE_ABORT_WINDOW_MS) {
1041
+ disarm();
1042
+ escPokePaused = true; // not sticky: the next agent run lifts it
1043
+ refreshWidget(ctx);
1044
+ return;
1045
+ }
864
1046
  return;
865
1047
  }
866
1048
  if (!autoPokeArmed) {
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Pure delayed-poke scheduler (pi-simple-goal restartDelayMs pattern).
3
+ *
4
+ * The settle poke can be delayed (config.pokeDelayMs): while a poke is
5
+ * pending, any terminal input cancels it and pauses poking until the next
6
+ * agent run (see index.ts). This module owns only the timer lifecycle so it
7
+ * can be unit-tested without pi. A delayMs of 0 fires synchronously, which
8
+ * preserves the immediate (pure jcode) behavior with no branching at the
9
+ * call sites.
10
+ */
11
+
12
+ export interface PokeScheduler {
13
+ /** Schedule a delayed poke, replacing any pending one. */
14
+ schedule(fire: () => void): void;
15
+ /** Cancel the pending poke (if any). */
16
+ cancel(): void;
17
+ /** Whether a poke is currently scheduled and waiting. */
18
+ isPending(): boolean;
19
+ /** Epoch ms when the pending poke fires; 0 when none pending. */
20
+ deadlineAt(): number;
21
+ }
22
+
23
+ export function createPokeScheduler(delayMs: number): PokeScheduler {
24
+ let timer: NodeJS.Timeout | undefined;
25
+ let deadline = 0;
26
+
27
+ const clear = (): void => {
28
+ if (timer !== undefined) {
29
+ clearTimeout(timer);
30
+ timer = undefined;
31
+ }
32
+ deadline = 0;
33
+ };
34
+
35
+ return {
36
+ schedule(fire: () => void): void {
37
+ if (delayMs <= 0) {
38
+ fire();
39
+ return;
40
+ }
41
+ clear();
42
+ deadline = Date.now() + delayMs;
43
+ timer = setTimeout(() => {
44
+ timer = undefined;
45
+ deadline = 0;
46
+ fire();
47
+ }, delayMs);
48
+ if (typeof timer.unref === "function") timer.unref();
49
+ },
50
+ cancel: clear,
51
+ isPending: (): boolean => timer !== undefined,
52
+ deadlineAt: (): number => deadline,
53
+ };
54
+ }
package/src/watchdog.ts CHANGED
@@ -19,6 +19,8 @@ export interface WatchdogDeps {
19
19
  isIdle: () => boolean;
20
20
  incompleteCount: () => number;
21
21
  wasAborted: () => boolean;
22
+ /** Esc- or typing-paused cycle: the watchdog must not inject either. */
23
+ isPaused: () => boolean;
22
24
  idleMs: number;
23
25
  maxRePokes: number;
24
26
  /** Re-send the auto-poke follow-up (fire-and-forget is fine). */
@@ -49,6 +51,13 @@ export function createWatchdog(deps: WatchdogDeps): Watchdog {
49
51
  lastActivity = now;
50
52
  return;
51
53
  }
54
+ if (deps.isPaused()) {
55
+ // A paused cycle (Escape, or typing during the poke window) means
56
+ // the user took control; the watchdog stays quiet and the window
57
+ // restarts so unpausing (next agent run) gets a fresh idle period.
58
+ lastActivity = now;
59
+ return;
60
+ }
52
61
  if (deps.incompleteCount() === 0) {
53
62
  lastActivity = now;
54
63
  rePokes = 0;
package/src/widget.ts CHANGED
@@ -30,6 +30,10 @@ export interface TodoWidgetComponent {
30
30
  /** Machine state the header tail should surface. */
31
31
  export interface WidgetRuntime {
32
32
  armed: boolean;
33
+ /** An Escape-paused cycle: suppressed until the user re-engages */
34
+ escPaused?: boolean;
35
+ /** Epoch ms when a delayed poke fires; 0 = none pending (pokeDelayMs). */
36
+ pokePendingAt?: number;
33
37
  gateAttempts: number;
34
38
  gateMaxAttempts: number;
35
39
  /** Ids of the todos that caused the most recent poke/gate challenge */
@@ -105,7 +109,20 @@ function buildLeftColumn(
105
109
  // user has been seeing.
106
110
  let status: string;
107
111
  if (!allSettled) {
108
- status = runtime.armed ? "· auto-poke" : "· poke off";
112
+ const pendingIn =
113
+ runtime.pokePendingAt && runtime.pokePendingAt > 0
114
+ ? Math.max(1, Math.ceil((runtime.pokePendingAt - Date.now()) / 1000))
115
+ : 0;
116
+ // A pause (Escape, or typing during the poke window) outranks the armed
117
+ // state: the cycle stays armed underneath but is held, so the user must
118
+ // see "poke paused", not a misleading "auto-poke".
119
+ status = runtime.escPaused
120
+ ? "· poke paused"
121
+ : runtime.armed
122
+ ? pendingIn > 0
123
+ ? `· poke in ${pendingIn}s`
124
+ : "· auto-poke"
125
+ : "· poke off";
109
126
  } else if (summary.needs_validation) {
110
127
  status =
111
128
  runtime.gateAttempts > 0
@@ -17,6 +17,13 @@ const os = require("node:os");
17
17
  const path = require("node:path");
18
18
  const fs = require("node:fs");
19
19
 
20
+ // Point the extension's config at a scratch state dir BEFORE its module
21
+ // loads: the e2e suite runs with a fast pokeDelayMs (400ms) so delayed-poke
22
+ // scenarios exercise the real scheduler without waiting the real 20s.
23
+ const TEST_STATE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "pi-jtodo-cfg-"));
24
+ fs.writeFileSync(path.join(TEST_STATE_DIR, "config.json"), JSON.stringify({ pokeDelayMs: 400 }));
25
+ process.env.PI_JTODO_STATE_DIR = TEST_STATE_DIR;
26
+
20
27
  let PI_PKG;
21
28
  try {
22
29
  PI_PKG = path.dirname(require.resolve("@earendil-works/pi-coding-agent/package.json"));
@@ -39,6 +46,31 @@ function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
39
46
 
40
47
  async function freshSession(extraLoaderOptions) {
41
48
  const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "pi-jtodo-test-"));
49
+ // Recording UI context: bindExtensions() fires session_start (arming +
50
+ // branch replay + raw-input listener), widgets/notices are recorded, and
51
+ // tests can inject synthetic keystrokes (the Esc/typing paths are
52
+ // TUI-only otherwise — this closes the harness blind spot).
53
+ const rec = { inputHandler: undefined, widgets: [], notifies: [] };
54
+ const uiContext = {
55
+ select: async () => undefined,
56
+ confirm: async () => false,
57
+ input: async () => undefined,
58
+ editor: async () => undefined,
59
+ notify: (message, type) => { rec.notifies.push({ message, type }); },
60
+ onTerminalInput: (handler) => {
61
+ rec.inputHandler = handler;
62
+ return () => { if (rec.inputHandler === handler) rec.inputHandler = undefined; };
63
+ },
64
+ setStatus: () => {},
65
+ setWorkingMessage: () => {},
66
+ setWorkingVisible: () => {},
67
+ setWorkingIndicator: () => {},
68
+ setWidget: (key, content) => {
69
+ const i = rec.widgets.findIndex((w) => w.key === key);
70
+ if (i >= 0) rec.widgets.splice(i, 1);
71
+ if (content !== undefined) rec.widgets.push({ key, content });
72
+ },
73
+ };
42
74
  const loader = new pi.DefaultResourceLoader({
43
75
  cwd,
44
76
  agentDir: pi.getAgentDir(),
@@ -53,7 +85,10 @@ async function freshSession(extraLoaderOptions) {
53
85
  resourceLoader: loader,
54
86
  tools: ["todo"],
55
87
  });
88
+ await session.bindExtensions({ uiContext, mode: "tui" });
56
89
  return {
90
+ rec,
91
+ sendKeys: (data) => { if (rec.inputHandler) rec.inputHandler(data); },
57
92
  session,
58
93
  exec: (params, signal) =>
59
94
  session
@@ -389,7 +424,7 @@ async function suiteA() {
389
424
  alias: { "@earendil-works/pi-tui": `${PI_PKG}/node_modules/@earendil-works/pi-tui/dist/index.js` },
390
425
  });
391
426
  const { createWatchdog } = await jiti.import(path.join(EXT_DIR, "watchdog.ts"));
392
- let t = 0, armed = true, idle = true, open = 3, aborted = false;
427
+ let t = 0, armed = true, idle = true, open = 3, aborted = false, paused = false;
393
428
  const fires = [], starves = [];
394
429
  const wd = createWatchdog({
395
430
  now: () => t,
@@ -397,6 +432,7 @@ async function suiteA() {
397
432
  isIdle: () => idle,
398
433
  incompleteCount: () => open,
399
434
  wasAborted: () => aborted,
435
+ isPaused: () => paused,
400
436
  idleMs: 90_000,
401
437
  maxRePokes: 3,
402
438
  onFire: (n) => fires.push(n),
@@ -428,10 +464,47 @@ async function suiteA() {
428
464
  // unarmed never fires
429
465
  armed = false; aborted = false; t += 400_000; wd.tick();
430
466
  const unarmedQuiet = fires.length === 4;
431
- if (fires[0] === 1 && fires[1] === 2 && postponed && capped && resetWorks && settledQuiet && abortWins && unarmedQuiet) {
432
- ok("T14 watchdog", "fire/postpone/cap/reset/settled-quiet/abort/unarmed all correct");
467
+ // paused cycle (Esc or typing took over): watchdog stays quiet and the
468
+ // idle window restarts each paused tick, so unpausing gets a fresh window
469
+ armed = true; paused = true; t += 400_000; wd.tick();
470
+ const pausedQuiet = fires.length === 4;
471
+ paused = false; t += 50_000; wd.tick(); // window restarted by the paused tick
472
+ const pausedWindowRestarted = fires.length === 4;
473
+ t += 91_000; wd.tick(); // fresh full window after unpausing -> 5th fire
474
+ const resumedAfterPause = fires.length === 5 && fires.at(-1) === 1;
475
+ if (fires[0] === 1 && fires[1] === 2 && postponed && capped && resetWorks && settledQuiet && abortWins && unarmedQuiet && pausedQuiet && pausedWindowRestarted && resumedAfterPause) {
476
+ ok("T14 watchdog", "fire/postpone/cap/reset/settled-quiet/abort/unarmed/paused-quiet all correct");
433
477
  } else {
434
- bad("T14 watchdog", JSON.stringify({ fires, starves, postponed, capped, resetWorks, settledQuiet, abortWins, unarmedQuiet }));
478
+ bad("T14 watchdog", JSON.stringify({ fires, starves, postponed, capped, resetWorks, settledQuiet, abortWins, unarmedQuiet, pausedQuiet, pausedWindowRestarted, resumedAfterPause }));
479
+ }
480
+ });
481
+
482
+ // T20: delayed-poke scheduler (pure timer module, real clock).
483
+ await run("T20 poke scheduler", async () => {
484
+ const { createJiti } = require(`${PI_PKG}/node_modules/jiti/lib/jiti.cjs`);
485
+ const jiti = createJiti(__filename, {
486
+ alias: { "@earendil-works/pi-tui": `${PI_PKG}/node_modules/@earendil-works/pi-tui/dist/index.js` },
487
+ });
488
+ const { createPokeScheduler } = await jiti.import(path.join(EXT_DIR, "poke-scheduler.ts"));
489
+ const fired = [];
490
+ const s = createPokeScheduler(80);
491
+ const idleStart = !s.isPending() && s.deadlineAt() === 0;
492
+ s.schedule(() => fired.push("a"));
493
+ const pending = s.isPending() && s.deadlineAt() > Date.now();
494
+ s.cancel();
495
+ const cancelWorks = !s.isPending() && fired.length === 0 && s.deadlineAt() === 0;
496
+ s.schedule(() => fired.push("a"));
497
+ s.schedule(() => fired.push("b")); // replaces the pending poke
498
+ await sleep(200);
499
+ const firedOnceLatest = fired.join(",") === "b" && !s.isPending();
500
+ const zeroDelay = createPokeScheduler(0);
501
+ const syncFired = [];
502
+ zeroDelay.schedule(() => syncFired.push("now"));
503
+ const immediate = syncFired.join(",") === "now" && !zeroDelay.isPending();
504
+ if (idleStart && pending && cancelWorks && firedOnceLatest && immediate) {
505
+ ok("T20 poke scheduler", "schedule/cancel/deadline/replacement/zero-delay all correct");
506
+ } else {
507
+ bad("T20 poke scheduler", JSON.stringify({ idleStart, pending, cancelWorks, firedOnceLatest, immediate, fired }));
435
508
  }
436
509
  });
437
510
 
@@ -590,6 +663,12 @@ async function suiteA() {
590
663
  const empty = widget.renderTodoWidgetLines({ todos: [], plan: {}, goals: [] }, rtOn, 6, 60, stubTheme);
591
664
  const unarmed = widget.renderTodoWidgetLines(state, rtOff, 6, 60, stubTheme);
592
665
  const offMarker = unescape(unarmed.at(-1)).includes("poke off");
666
+ // Esc-pause label must be distinct from the sticky /todos poke off state.
667
+ const rtEscPaused = { armed: false, escPaused: true, gateAttempts: 0, gateMaxAttempts: 5 };
668
+ const escPausedLine = widget.renderTodoWidgetLines(state, rtEscPaused, 6, 60, stubTheme);
669
+ const escPausedMarker =
670
+ unescape(escPausedLine.at(-1)).includes("poke paused") &&
671
+ !unescape(escPausedLine.at(-1)).includes("poke off");
593
672
  // value-rank: shown item lines are exactly a(in_progress), then pending
594
673
  // in declaration order; settled items are pushed into the overflow.
595
674
  const shownIds = lines.slice(1, -1).map((l) => (unescape(l).match(/#(\w+)/) || [])[1]);
@@ -682,10 +761,10 @@ async function suiteA() {
682
761
  markRow("g-warn")?.includes("🟡") && markRow("g-ok")?.includes("✔") &&
683
762
  markRow("g-act")?.includes("🚧") && markRow("g-wait")?.includes("🔲") &&
684
763
  markRow("g-nogoal")?.includes("–");
685
- if (firstIsHeader && bottomStatus && inProgressOnTop && capped && openBreakdown && withinWidth && armedMarker && ranked && lateLeads && indented && empty.length === 0 && !unescape(unarmed.at(-1)).includes("auto-poke") && offMarker && confTails && gateShown && doneShown && intentShown && multiClean && icons && finger && tableHeader && activeFirst && verifyCells && builderCells && tableFooter && tableDropped && aligned && allAligned) {
764
+ if (firstIsHeader && bottomStatus && inProgressOnTop && capped && openBreakdown && withinWidth && armedMarker && ranked && lateLeads && indented && empty.length === 0 && !unescape(unarmed.at(-1)).includes("auto-poke") && offMarker && escPausedMarker && confTails && gateShown && doneShown && intentShown && multiClean && icons && finger && tableHeader && activeFirst && verifyCells && builderCells && tableFooter && tableDropped && aligned && allAligned) {
686
765
  ok("T13 widget", "intention header + bottom status + table (Todo Goal) correct");
687
766
  } else {
688
- bad("T13 widget", JSON.stringify({ firstIsHeader, bottomStatus, inProgressOnTop, capped, withinWidth, offMarker, confTails, gateShown, doneShown, intentShown, multiClean, icons, finger, tableHeader, activeFirst, verifyCells, builderCells, tableFooter, tableDropped, aligned, allAligned, intentLines, mixedTbl }, null, 1));
767
+ bad("T13 widget", JSON.stringify({ firstIsHeader, bottomStatus, inProgressOnTop, capped, withinWidth, offMarker, escPausedMarker, confTails, gateShown, doneShown, intentShown, multiClean, icons, finger, tableHeader, activeFirst, verifyCells, builderCells, tableFooter, tableDropped, aligned, allAligned, intentLines, mixedTbl }, null, 1));
689
768
  }
690
769
  });
691
770
  }
@@ -699,6 +778,7 @@ function makeMockStreamSimple(calls, script) {
699
778
  return function streamSimple(model, context, options) {
700
779
  callIndex += 1;
701
780
  calls.push({ index: callIndex, context });
781
+ if (typeof script.onRequest === "function") script.onRequest(context);
702
782
  const step = callIndex;
703
783
  const scriptCall = script.call;
704
784
  const scriptText = script.text;
@@ -717,8 +797,22 @@ function makeMockStreamSimple(calls, script) {
717
797
  stopReason: "pending",
718
798
  timestamp: Date.now(),
719
799
  };
720
- try {
800
+ try {
721
801
  stream.push({ type: "start", partial: output });
802
+ if (script.gateAt === step) {
803
+ // Hold the stream open so the test can abort mid-flight; honor the
804
+ // abort signal exactly like pi's own providers do (openai-completions.js).
805
+ const signal = options?.signal;
806
+ if (signal?.aborted) throw new Error("Request was aborted");
807
+ await new Promise((resolve, reject) => {
808
+ const onAbort = () => reject(new Error("Request was aborted"));
809
+ signal?.addEventListener("abort", onAbort, { once: true });
810
+ (script.gatePromise || Promise.resolve()).then(
811
+ () => { signal?.removeEventListener("abort", onAbort); resolve(); },
812
+ reject,
813
+ );
814
+ });
815
+ }
722
816
  const toolCall = scriptCall(step);
723
817
  if (toolCall) {
724
818
  output.content.push(toolCall);
@@ -737,9 +831,12 @@ function makeMockStreamSimple(calls, script) {
737
831
  stream.push({ type: "done", reason: output.stopReason, message: output });
738
832
  stream.end();
739
833
  } catch (err) {
740
- output.stopReason = "error";
834
+ output.stopReason = options?.signal?.aborted ? "aborted" : "error";
835
+ // Simulate provider stacks (pi-robust-provider observed live) that
836
+ // finalize an abort as a generic error naming the abort.
837
+ if (script.misclassifiedAbortAt === step) output.stopReason = "error";
741
838
  output.errorMessage = err.message;
742
- stream.push({ type: "error", reason: "error", error: output });
839
+ stream.push({ type: "error", reason: output.stopReason, error: output });
743
840
  stream.end();
744
841
  }
745
842
  })();
@@ -909,6 +1006,14 @@ const SCRIPT_B3 = {
909
1006
 
910
1007
  async function runScenario(name, script, assertions) {
911
1008
  const calls = [];
1009
+ if (script.abortAtCall) {
1010
+ // Gate the abortAtCall'th provider call mid-stream so the test can abort
1011
+ // the run deterministically while it is in flight.
1012
+ script.gateAt = script.abortAtCall;
1013
+ script.gatePromise = new Promise((resolve) => {
1014
+ script.releaseGate = resolve;
1015
+ });
1016
+ }
912
1017
  const mockFactory = {
913
1018
  name: "mock-provider",
914
1019
  factory: (api) => {
@@ -922,20 +1027,86 @@ async function runScenario(name, script, assertions) {
922
1027
  });
923
1028
  },
924
1029
  };
925
- await run(name, async ({ session }) => {
1030
+ await run(name, async ({ session, rec }) => {
926
1031
  await session.setModel(MOCK_MODEL);
1032
+ let abortTimerFired = false;
1033
+ if (script.abortAtCall) {
1034
+ // prompt() resolves only after every queued follow-up run finishes, so
1035
+ // the poll loop below can never reach the gated call. Simulate Esc from
1036
+ // a timer: abort once the gated provider call has actually started.
1037
+ const startedAt = Date.now();
1038
+ const abortWhenStarted = () => {
1039
+ if (calls.length >= script.abortAtCall) {
1040
+ abortTimerFired = true;
1041
+ // Raw Escape first (TUI Esc = user stop): this is exactly what the
1042
+ // real terminal delivers to the listener before pi aborts the run.
1043
+ if (script.escBeforeAbort && rec.inputHandler) {
1044
+ rec.inputHandler("\x1b");
1045
+ try {
1046
+ const w = rec.widgets.find((x) => x.key === "pi-jtodo");
1047
+ const th = { fg: (_c, s) => String(s), bold: (s) => String(s) };
1048
+ const component = typeof w?.content === "function" ? w.content(null, th) : w?.content;
1049
+ script.escPaint = (component?.render?.(120) ?? []).map(String);
1050
+ } catch { script.escPaint = []; }
1051
+ }
1052
+ if (script.compactInsteadOfAbort) session.compact();
1053
+ else session.abort();
1054
+ // Safety net: if the abort somehow never reaches the mock, release
1055
+ // the gate so the scenario fails with diagnostics instead of hanging.
1056
+ setTimeout(() => { if (script.releaseGate) script.releaseGate(); }, 3000);
1057
+ } else if (Date.now() - startedAt < 12_000) {
1058
+ setTimeout(abortWhenStarted, 100);
1059
+ }
1060
+ };
1061
+ setTimeout(abortWhenStarted, 300);
1062
+ }
927
1063
  await session.prompt("begin");
928
1064
 
929
1065
  const deadline = Date.now() + 20_000;
930
1066
  let prompted = false;
1067
+ let aborted = false;
1068
+ let resumed = false;
1069
+ let inputArmed = false;
1070
+ let inputSent = false;
931
1071
  while (Date.now() < deadline) {
932
1072
  if (!prompted && script.promptAfter && calls.length >= script.promptAfter) {
933
1073
  prompted = true;
934
1074
  void session.prompt(script.promptText ?? "continue");
935
1075
  }
1076
+ if (!inputArmed && script.inputAfter && calls.length >= script.inputAfter.call) {
1077
+ inputArmed = true;
1078
+ // Synthetic keystroke lands while the session is idle with a poke
1079
+ // pending (the typing/Esc paths are TUI-only otherwise).
1080
+ setTimeout(() => {
1081
+ inputSent = true;
1082
+ if (rec.inputHandler) rec.inputHandler(script.inputAfter.data);
1083
+ // Snapshot the paint synchronously: the widget is a live thunk
1084
+ // factory, so building + rendering it later would show the final
1085
+ // state instead of the paused moment.
1086
+ try {
1087
+ const w = rec.widgets.find((x) => x.key === "pi-jtodo");
1088
+ const th = { fg: (_c, s) => String(s), bold: (s) => String(s) };
1089
+ const component = typeof w?.content === "function" ? w.content(null, th) : w?.content;
1090
+ script.inputAfter.paint = (component?.render?.(120) ?? []).map(String);
1091
+ } catch { script.inputAfter.paint = []; }
1092
+ }, script.inputAfter.atMs);
1093
+ }
1094
+ if (inputSent && script.promptAfterInput && !prompted) {
1095
+ prompted = true;
1096
+ void session.prompt(script.promptText ?? "resume");
1097
+ }
1098
+ if (!aborted && script.abortAtCall && calls.length >= script.abortAtCall) {
1099
+ aborted = true; // bookkeeping only; the timer above fired the Esc
1100
+ if (!abortTimerFired) session.abort();
1101
+ }
1102
+ if (aborted && !resumed) {
1103
+ resumed = true;
1104
+ await sleep(1500); // let the abort settle (disarm + quiet) run first
1105
+ void session.prompt(script.resumeText ?? "resume after the interrupt");
1106
+ }
936
1107
  if (calls.length >= script.calls) {
937
1108
  await sleep(800);
938
- if (calls.length === script.calls) break; // no further provider activity
1109
+ if (calls.length >= script.calls && calls.length <= (script.callsMax ?? script.calls)) break; // done
939
1110
  }
940
1111
  await sleep(100);
941
1112
  }
@@ -943,8 +1114,9 @@ async function runScenario(name, script, assertions) {
943
1114
  const customs = entries
944
1115
  .filter((e) => e.type === "custom_message" && e.customType === "pi-jtodo/followup")
945
1116
  .map((e) => (typeof e.content === "string" ? e.content : ""));
946
- if (calls.length !== script.calls) {
947
- bad(`${name} flow`, `expected ${script.calls} provider calls, got ${calls.length}; customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
1117
+ const inRange = calls.length >= script.calls && calls.length <= (script.callsMax ?? script.calls);
1118
+ if (!inRange) {
1119
+ bad(`${name} flow`, `expected ${script.calls}${script.callsMax ? `..${script.callsMax}` : ""} provider calls, got ${calls.length}; customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
948
1120
  return;
949
1121
  }
950
1122
 
@@ -952,7 +1124,7 @@ async function runScenario(name, script, assertions) {
952
1124
  (e) => e.type === "message" && e.message?.role === "toolResult" && e.message?.toolName === "todo",
953
1125
  );
954
1126
  const lastDetails = todoResults.at(-1)?.message?.details;
955
- await assertions({ calls, customs, lastDetails });
1127
+ await assertions({ calls, customs, lastDetails, entries, widgets: rec.widgets });
956
1128
  }, { extensionFactories: [mockFactory] });
957
1129
  }
958
1130
 
@@ -1021,6 +1193,308 @@ async function suiteB() {
1021
1193
  if (done) ok("B3 final state", "both waves completed");
1022
1194
  else bad("B3 final state", JSON.stringify(lastDetails?.todos));
1023
1195
  });
1196
+
1197
+ // B4: Esc (session.abort) on the poke-driven run must disarm and inject
1198
+ // NOTHING afterwards, even though open todos remain. Flow: run A writes an
1199
+ // open todo and ends (settle #1 pokes), the poke-driven run B is aborted
1200
+ // mid-stream (the user's exact complaint), and both the abort settle and
1201
+ // the resumed run's settle stay quiet via the agent_end abort flag.
1202
+ // B4: an abort with NO raw Escape behind it is machinery (ctx.abort(),
1203
+ // extension-initiated, transport). Under the pi-simple-goal Escape model
1204
+ // the poke cycle stays armed: the aborted settle is quiet, but a resumed
1205
+ // run that still leaves todos open pokes normally. The real user-Esc pause
1206
+ // (raw \x1b keypress) is TUI-only and verified live, not in the SDK harness.
1207
+ const SCRIPT_B4 = {
1208
+ call(step) {
1209
+ if (step === 1) {
1210
+ return {
1211
+ type: "toolCall", id: "call-open", name: "todo",
1212
+ arguments: {
1213
+ todos: [{ content: "machinery abort guard", status: "in_progress", priority: "high", id: "e1", group: "esc", confidence: 96 }],
1214
+ plan: { user_intention: "machinery abort e2e", understands_user_intent: 97 },
1215
+ goals: [{ group: "esc", closed_feedback_loop: 97, feedback_loop: "abort keeps armed" }],
1216
+ },
1217
+ };
1218
+ }
1219
+ if (step === 5) {
1220
+ return {
1221
+ type: "toolCall", id: "call-done", name: "todo",
1222
+ arguments: {
1223
+ todos: [{ content: "machinery abort guard", status: "completed", priority: "high", id: "e1", group: "esc", confidence: 96, completion_confidence: 96 }],
1224
+ goals: [{ group: "esc", closed_feedback_loop: 97, feedback_loop: "abort keeps armed", end_to_end_ownership: 96 }],
1225
+ },
1226
+ };
1227
+ }
1228
+ return null;
1229
+ },
1230
+ text(step) {
1231
+ if (step === 2) return "Todos written.";
1232
+ if (step === 3) return "Poked: working on it, mid-stream.";
1233
+ if (step === 4) return "Resumed after the interrupt, still working.";
1234
+ return "All done now.";
1235
+ },
1236
+ abortAtCall: 3, // abort run B, the poke-driven continuation
1237
+ resumeText: "resume after the interrupt",
1238
+ calls: 6,
1239
+ };
1240
+ await runScenario("B4 machinery abort keeps the poke cycle armed", SCRIPT_B4, async ({ customs, entries }) => {
1241
+ const pokes = customs.filter((t) => t.includes("You have 1 incomplete todo."));
1242
+ if (pokes.length === 2) ok("B4 poke before and after abort", "poke #1 at the write settle, poke #2 after the resumed run — cycle stayed armed");
1243
+ else bad("B4 poke before and after abort", `pokes=${pokes.length} customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
1244
+ if (customs.length === 2) ok("B4 aborted settle quiet", "the abort settle itself injected nothing");
1245
+ else bad("B4 aborted settle quiet", `customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
1246
+ const stopReasons = entries
1247
+ .filter((e) => e.type === "message" && e.message?.role === "assistant")
1248
+ .map((e) => e.message.stopReason);
1249
+ if (stopReasons.includes("aborted")) ok("B4 harness abort recorded", `assistant stopReasons=${JSON.stringify(stopReasons)}`);
1250
+ else bad("B4 harness abort recorded", `no aborted stopReason; stopReasons=${JSON.stringify(stopReasons)}`);
1251
+ });
1252
+
1253
+ // B5: pi-robust-provider observed live finalizing an Esc abort as a plain
1254
+ // error whose text names the abort ("This operation was aborted"). The
1255
+ // error-text fallback must classify it as a user interrupt and stay quiet.
1256
+ // B5: pi-robust-provider observed live finalizing an Esc abort as a plain
1257
+ // error whose text names the abort ("This operation was aborted"). Under
1258
+ // the raw-Escape classification model a stopReason mislabel changes
1259
+ // NOTHING: with no Escape keypress observed this is machinery, the settle is
1260
+ // quiet, and the cycle stays armed. The real-Esc correlation is live-only.
1261
+ const SCRIPT_B5 = {
1262
+ call(step) {
1263
+ if (step === 1) {
1264
+ return {
1265
+ type: "toolCall", id: "call-open", name: "todo",
1266
+ arguments: {
1267
+ todos: [{ content: "misclassified abort guard", status: "in_progress", priority: "high", id: "m1", group: "esc", confidence: 96 }],
1268
+ plan: { user_intention: "misclassified abort e2e", understands_user_intent: 97 },
1269
+ goals: [{ group: "esc", closed_feedback_loop: 97, feedback_loop: "error-naming-abort keeps armed" }],
1270
+ },
1271
+ };
1272
+ }
1273
+ if (step === 5) {
1274
+ return {
1275
+ type: "toolCall", id: "call-done", name: "todo",
1276
+ arguments: {
1277
+ todos: [{ content: "misclassified abort guard", status: "completed", priority: "high", id: "m1", group: "esc", confidence: 96, completion_confidence: 96 }],
1278
+ goals: [{ group: "esc", closed_feedback_loop: 97, feedback_loop: "error-naming-abort keeps armed", end_to_end_ownership: 96 }],
1279
+ },
1280
+ };
1281
+ }
1282
+ return null;
1283
+ },
1284
+ text(step) {
1285
+ if (step === 2) return "Todos written.";
1286
+ if (step === 3) return "Poked: working, mid-stream.";
1287
+ if (step === 4) return "Resumed after the interrupt, still working.";
1288
+ return "All done now.";
1289
+ },
1290
+ abortAtCall: 3,
1291
+ misclassifiedAbortAt: 3, // the abort finalizes as stopReason "error" naming the abort
1292
+ resumeText: "resume after the interrupt",
1293
+ calls: 6,
1294
+ };
1295
+ await runScenario("B5 error-naming-abort (robust-provider) is machinery", SCRIPT_B5, async ({ customs, entries }) => {
1296
+ const pokes = customs.filter((t) => t.includes("You have 1 incomplete todo."));
1297
+ if (pokes.length === 2) ok("B5 poke before and after abort", "misclassified abort did not suppress the cycle");
1298
+ else bad("B5 poke before and after abort", `pokes=${pokes.length} customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
1299
+ if (customs.length === 2) ok("B5 aborted settle quiet", "the error-naming-abort settle injected nothing");
1300
+ else bad("B5 aborted settle quiet", `customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
1301
+ const interrupted = entries
1302
+ .filter((e) => e.type === "message" && e.message?.role === "assistant")
1303
+ .find((e) => /abort/i.test(e.message?.errorMessage ?? ""));
1304
+ if (interrupted?.message?.stopReason === "error") ok("B5 interrupt finalized as error", "errorMessage names the abort, stopReason is error");
1305
+ else bad("B5 interrupt finalized as error", `stopReason=${interrupted?.message?.stopReason} err=${interrupted?.message?.errorMessage}`);
1306
+ });
1307
+
1308
+ // B6: compaction aborts the in-flight run but guarantees a continuation —
1309
+ // a system interrupt. Its settle must stay quiet WITHOUT disarming and
1310
+ // WITHOUT the sticky Esc off, so the resumed run's settle pokes normally.
1311
+ let b6DoneSent = false;
1312
+ let b6PokesSeen = 0;
1313
+ const SCRIPT_B6 = {
1314
+ onRequest(context) {
1315
+ // Count auto-poke messages already in the request history; robust
1316
+ // to compaction consuming 1 or 2 provider calls. User-role content may
1317
+ // be a string or a block array (same extraction as contextUserTexts).
1318
+ const texts = (context?.messages ?? [])
1319
+ .filter((m) => m?.role === "user")
1320
+ .map((m) =>
1321
+ typeof m?.content === "string"
1322
+ ? m.content
1323
+ : (m?.content ?? []).map((c) => c?.text ?? "").join(""),
1324
+ );
1325
+ b6PokesSeen = texts.filter((t) => t.includes("You have 1 incomplete todo.")).length;
1326
+ },
1327
+ call(step) {
1328
+ if (step === 1) {
1329
+ return {
1330
+ type: "toolCall", id: "call-open", name: "todo",
1331
+ arguments: {
1332
+ todos: [{ content: "compaction window guard", status: "in_progress", priority: "high", id: "c1", group: "esc", confidence: 96 }],
1333
+ plan: { user_intention: "compaction window e2e", understands_user_intent: 97 },
1334
+ goals: [{ group: "esc", closed_feedback_loop: 97, feedback_loop: "armed survives compaction" }],
1335
+ },
1336
+ };
1337
+ }
1338
+ // Only the run driven by poke #2 completes the todo, so poke #2
1339
+ // provably fired (the resumed run before it must leave it open).
1340
+ if (step >= 5 && b6PokesSeen >= 2 && !b6DoneSent) {
1341
+ b6DoneSent = true;
1342
+ return {
1343
+ type: "toolCall", id: "call-done", name: "todo",
1344
+ arguments: {
1345
+ todos: [{ content: "compaction window guard", status: "completed", priority: "high", id: "c1", group: "esc", confidence: 96, completion_confidence: 96 }],
1346
+ goals: [{ group: "esc", closed_feedback_loop: 97, feedback_loop: "armed survives compaction", end_to_end_ownership: 96 }],
1347
+ },
1348
+ };
1349
+ }
1350
+ return null;
1351
+ },
1352
+ text(step) {
1353
+ if (step === 2) return "Todos written. " + "Detailed work log entries accumulate here. ".repeat(2600);
1354
+ if (step === 3) return "Poked: working, mid-stream.";
1355
+ return "Continuing after compaction.";
1356
+ },
1357
+ abortAtCall: 3,
1358
+ compactInsteadOfAbort: true, // session.compact() aborts the gated run (system interrupt)
1359
+ promptAfter: 4, // after the compaction summarization call, resume the agent
1360
+ promptText: "resume the work",
1361
+ calls: 7,
1362
+ callsMax: 9, // compaction may consume 1 or 2 provider calls
1363
+ };
1364
+ await runScenario("B6 compaction abort keeps the poke cycle armed", SCRIPT_B6, async ({ customs, entries }) => {
1365
+ const pokes = customs.filter((t) => t.includes("You have 1 incomplete todo."));
1366
+ if (pokes.length === 2) ok("B6 armed preserved", "poke #1 pre-abort, poke #2 after the resumed run — no sticky-off");
1367
+ else bad("B6 armed preserved", `pokes=${pokes.length} customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
1368
+ const noisy = customs.filter((t) => t.includes("rose too sharply") || t.includes("not high enough") || t.includes("todo quality review"));
1369
+ if (noisy.length === 0) ok("B6 no spurious gates", "clean scores produced no challenges/digest");
1370
+ else bad("B6 no spurious gates", JSON.stringify(noisy));
1371
+ const stopReasons = entries
1372
+ .filter((e) => e.type === "message" && e.message?.role === "assistant")
1373
+ .map((e) => e.message.stopReason);
1374
+ if (stopReasons.includes("aborted")) ok("B6 compaction abort recorded", `stopReasons=${JSON.stringify(stopReasons)}`);
1375
+ else bad("B6 compaction abort recorded", `no aborted stopReason; stopReasons=${JSON.stringify(stopReasons)}`);
1376
+ });
1377
+
1378
+ // B7: typing during the pokeDelayMs window cancels the pending poke and
1379
+ // pauses the cycle; the pause lifts at the next agent run (goal-plugin
1380
+ // pattern) and the resumed run finishes cleanly.
1381
+ const SCRIPT_B7 = {
1382
+ call(step) {
1383
+ if (step === 1) {
1384
+ return {
1385
+ type: "toolCall", id: "call-open", name: "todo",
1386
+ arguments: {
1387
+ todos: [{ content: "typing-cancel guard", status: "in_progress", priority: "high", id: "t1", group: "tc", confidence: 96 }],
1388
+ plan: { user_intention: "typing cancel e2e", understands_user_intent: 97 },
1389
+ goals: [{ group: "tc", closed_feedback_loop: 97, feedback_loop: "typing cancels the pending poke" }],
1390
+ },
1391
+ };
1392
+ }
1393
+ if (step === 3) {
1394
+ return {
1395
+ type: "toolCall", id: "call-done", name: "todo",
1396
+ arguments: {
1397
+ todos: [{ content: "typing-cancel guard", status: "completed", priority: "high", id: "t1", group: "tc", confidence: 96, completion_confidence: 96 }],
1398
+ goals: [{ group: "tc", closed_feedback_loop: 97, feedback_loop: "typing cancels the pending poke", end_to_end_ownership: 96 }],
1399
+ },
1400
+ };
1401
+ }
1402
+ return null;
1403
+ },
1404
+ text(step) {
1405
+ if (step === 2) return "Todos written.";
1406
+ return "Done after the resume.";
1407
+ },
1408
+ inputAfter: { call: 2, atMs: 150, data: "x" },
1409
+ promptAfterInput: true,
1410
+ promptText: "resume working on the todos",
1411
+ calls: 4,
1412
+ };
1413
+ await runScenario("B7 typing cancels the pending poke", SCRIPT_B7, async ({ customs, widgets, lastDetails }) => {
1414
+ if (customs.length === 0) ok("B7 typing cancel + pause", "the user keystroke canceled the pending poke; the resume run lifted the pause and finished quietly");
1415
+ else bad("B7 typing cancel + pause", `customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
1416
+ const pausedPaint = SCRIPT_B7.inputAfter.paint?.some((l) => l.includes("poke paused"));
1417
+ if (pausedPaint) ok("B7 widget pause paint", "the widget bottom line rendered 'poke paused' while held");
1418
+ else bad("B7 widget pause paint", JSON.stringify(SCRIPT_B7.inputAfter.paint ?? null, (k, v) => (typeof v === "string" ? v.replace(/\x1b\[[0-9;]*m/g, "") : v)));
1419
+ const done = lastDetails?.todos?.[0]?.status === "completed";
1420
+ if (done) ok("B7 resume completed the todo", "the resumed run closed the item and the cycle ended clean");
1421
+ else bad("B7 resume completed the todo", JSON.stringify(lastDetails?.todos));
1422
+ });
1423
+
1424
+ // B8: Escape during the poke window cancels the pending poke and holds the
1425
+ // pause for the Esc window; a resume inside that window stays quiet.
1426
+ const SCRIPT_B8 = {
1427
+ call(step) {
1428
+ if (step === 1) {
1429
+ return {
1430
+ type: "toolCall", id: "call-open", name: "todo",
1431
+ arguments: {
1432
+ todos: [{ content: "esc-window guard", status: "in_progress", priority: "high", id: "e1", group: "ew", confidence: 96 }],
1433
+ plan: { user_intention: "esc window e2e", understands_user_intent: 97 },
1434
+ goals: [{ group: "ew", closed_feedback_loop: 97, feedback_loop: "esc holds the pause inside the window" }],
1435
+ },
1436
+ };
1437
+ }
1438
+ return null;
1439
+ },
1440
+ text(step) {
1441
+ if (step === 2) return "Todos written.";
1442
+ return "Resumed inside the Esc window, still working.";
1443
+ },
1444
+ inputAfter: { call: 2, atMs: 150, data: "\x1b" },
1445
+ promptAfterInput: true,
1446
+ promptText: "resume while the Esc window is open",
1447
+ calls: 3,
1448
+ };
1449
+ await runScenario("B8 Esc during poke window holds the pause", SCRIPT_B8, async ({ customs, widgets }) => {
1450
+ if (customs.length === 0) ok("B8 esc cancel + held pause", "Escape canceled the pending poke and the in-window resume settle stayed quiet");
1451
+ else bad("B8 esc cancel + held pause", `customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
1452
+ const pausedPaint = SCRIPT_B8.inputAfter.paint?.some((l) => l.includes("poke paused"));
1453
+ if (pausedPaint) ok("B8 widget pause paint", "'poke paused' rendered during the held pause");
1454
+ else bad("B8 widget pause paint", JSON.stringify(SCRIPT_B8.inputAfter.paint ?? null, (k, v) => (typeof v === "string" ? v.replace(/\x1b\[[0-9;]*m/g, "") : v)));
1455
+ });
1456
+
1457
+ // B9: a raw Escape observed right before an abort classifies the run as the
1458
+ // user's stop — the settle disarms and pauses instead of re-poking. This is
1459
+ // the regression pin for the missing-constant crash the old suite missed
1460
+ // (the Esc path only runs when terminal input has been seen).
1461
+ const SCRIPT_B9 = {
1462
+ call(step) {
1463
+ if (step === 1) {
1464
+ return {
1465
+ type: "toolCall", id: "call-open", name: "todo",
1466
+ arguments: {
1467
+ todos: [{ content: "user-esc guard", status: "in_progress", priority: "high", id: "u1", group: "ue", confidence: 96 }],
1468
+ plan: { user_intention: "user esc e2e", understands_user_intent: 97 },
1469
+ goals: [{ group: "ue", closed_feedback_loop: 97, feedback_loop: "user Esc pauses the poke" }],
1470
+ },
1471
+ };
1472
+ }
1473
+ return null;
1474
+ },
1475
+ text(step) {
1476
+ if (step === 2) return "Todos written.";
1477
+ if (step === 3) return "Poked: working on it, mid-stream.";
1478
+ return "Resumed inside the Esc window.";
1479
+ },
1480
+ abortAtCall: 3,
1481
+ escBeforeAbort: true,
1482
+ resumeText: "resume after the user interrupt",
1483
+ calls: 4,
1484
+ };
1485
+ await runScenario("B9 user Esc abort pauses the poke", SCRIPT_B9, async ({ customs, entries, widgets }) => {
1486
+ const pokes = customs.filter((t) => t.includes("You have 1 incomplete todo."));
1487
+ if (pokes.length === 1) ok("B9 user-Esc settle quiet", "poke #1 at the write settle; the Esc-aborted settle and the in-window resume settle injected nothing");
1488
+ else bad("B9 user-Esc settle quiet", `pokes=${pokes.length} customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
1489
+ const stopReasons = entries
1490
+ .filter((e) => e.type === "message" && e.message?.role === "assistant")
1491
+ .map((e) => e.message.stopReason);
1492
+ if (stopReasons.includes("aborted")) ok("B9 aborted run recorded", `stopReasons=${JSON.stringify(stopReasons)}`);
1493
+ else bad("B9 aborted run recorded", `stopReasons=${JSON.stringify(stopReasons)}`);
1494
+ const pausedPaint = SCRIPT_B9.escPaint?.some((l) => l.includes("poke paused"));
1495
+ if (pausedPaint) ok("B9 widget pause paint", "'poke paused' rendered right after the user Esc");
1496
+ else bad("B9 widget pause paint", JSON.stringify(SCRIPT_B9.escPaint ?? null, (k, v) => (typeof v === "string" ? v.replace(/\x1b\[[0-9;]*m/g, "") : v)));
1497
+ });
1024
1498
  }
1025
1499
 
1026
1500
  // ============================================================================