@matthewfl/pi-jtodo 0.0.1 → 0.0.2

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,7 @@ 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
+ - **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
45
 
45
46
  ## Display
46
47
 
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.2",
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/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,
@@ -150,6 +151,22 @@ export default function (pi: ExtensionAPI) {
150
151
  // pi-specific: once the user has explicitly silenced the poke (poke off,
151
152
  // or Esc-interrupting a run), new open work must NOT re-arm it.
152
153
  let pokeExplicitlyOff = false;
154
+ // Whether the most recent completed agent run was Esc-aborted, recorded
155
+ // from the agent_end event itself (the run's own messages). Entry-scanning
156
+ // heuristics misclassify aborts when a queued user message (compaction
157
+ // resume, steer) lands after the aborted assistant message; an
158
+ // Esc-aborted run must never restart the agent via a poke.
159
+ let lastRunAborted = false;
160
+ // Goal-plugin-style Escape correlation (pi-simple-goal): a raw Escape
161
+ // keypress observed via terminal input marks the NEXT aborted run as the
162
+ // user's stop. Aborts with no recent Escape behind them are machinery
163
+ // (compaction, ctx.abort(), transport) and keep the poke cycle armed.
164
+ let lastEscapeAt = 0;
165
+ // Set when an Escape-classified abort pauses the poke. NOT sticky: the
166
+ // next agent run (user re-engaged, or machinery resumed) lifts it and
167
+ // re-arms, per the user's "stop the poke for the next while" rule.
168
+ let escPokePaused = false;
169
+ let unsubscribeTerminalInput: (() => void) | undefined;
153
170
  // Whether poking is allowed at all. session_start (when emitted — the
154
171
  // SDK harness and some embedders never emit it) re-derives this from
155
172
  // its ctx.hasUI; elsewhere starts optimistic, matching the module-level
@@ -237,6 +254,7 @@ export default function (pi: ExtensionAPI) {
237
254
  () => state,
238
255
  () => ({
239
256
  armed: autoPokeArmed,
257
+ escPaused: escPokePaused,
240
258
  gateAttempts: cycle.gateAttempts,
241
259
  gateMaxAttempts: config.completionGateMaxAttempts,
242
260
  pokeTargets: lastPokeTargets,
@@ -432,16 +450,16 @@ export default function (pi: ExtensionAPI) {
432
450
  }
433
451
 
434
452
  /** 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;
453
+ function wasLastRunAborted(): boolean {
454
+ return lastRunAborted;
455
+ }
456
+
457
+ // Some provider stacks (pi-robust-provider observed live: errorMessage
458
+ // "This operation was aborted") finalize an Esc-aborted request as a
459
+ // generic error whose text still names the abort. Those are user
460
+ // interrupts, not provider failures, and must never restart the agent.
461
+ function looksInterrupted(stopReason: string | undefined, errorMessage: string | undefined): boolean {
462
+ return stopReason === "error" && typeof errorMessage === "string" && /abort/i.test(errorMessage);
445
463
  }
446
464
 
447
465
  function countAssistantTurnsSinceUser(ctx: ExtensionContext): number {
@@ -584,7 +602,7 @@ export default function (pi: ExtensionAPI) {
584
602
  isArmed: () => autoPokeArmed,
585
603
  isIdle: () => watchdogCtx?.isIdle() ?? false,
586
604
  incompleteCount: () => incompleteTodos().length,
587
- wasAborted: () => (watchdogCtx ? wasLastRunAborted(watchdogCtx) : false),
605
+ wasAborted: () => lastRunAborted,
588
606
  idleMs: config.watchdogIdleMs,
589
607
  maxRePokes: config.watchdogMaxRePokes,
590
608
  onFire: () => {
@@ -761,6 +779,7 @@ export default function (pi: ExtensionAPI) {
761
779
  if (action === "on" || action === "trigger") {
762
780
  autoPokeArmed = true;
763
781
  pokeExplicitlyOff = false;
782
+ escPokePaused = false;
764
783
  cycle = freshCycleFlags();
765
784
  settledWithoutProgress = 0;
766
785
  lastChallengedSignature = undefined;
@@ -793,19 +812,78 @@ export default function (pi: ExtensionAPI) {
793
812
  // Events
794
813
  // ------------------------------------------------------------------------
795
814
 
815
+ pi.on("agent_end", (event) => {
816
+ // Record the most recent run's abort status straight from the run's
817
+ // own messages; agent_settled reads this (it fires after agent_end).
818
+ const messages = event.messages as Array<{ role?: string; stopReason?: string; errorMessage?: string }>;
819
+ for (let i = messages.length - 1; i >= 0; i--) {
820
+ const m = messages[i];
821
+ if (m.role === "assistant") {
822
+ lastRunAborted = m.stopReason === "aborted" || looksInterrupted(m.stopReason, m.errorMessage);
823
+ break;
824
+ }
825
+ }
826
+ watchdog.notifyActivity();
827
+ });
828
+
829
+ pi.on("agent_start", (_event, ctx) => {
830
+ // A new run means the user (or machinery) re-engaged: an Esc pause
831
+ // lifts outside the abort-correlation window where the just-triggered
832
+ // teardown is still settling (pi-simple-goal's maybeResumeOnActivity).
833
+ if (escPokePaused && Date.now() - lastEscapeAt > ESCAPE_ABORT_WINDOW_MS) {
834
+ escPokePaused = false;
835
+ if (config.enabled && config.autoPoke && !pokeExplicitlyOff && pokeUiAllowed) {
836
+ autoPokeArmed = true;
837
+ cycle = freshCycleFlags();
838
+ }
839
+ refreshWidget(ctx);
840
+ }
841
+ watchdog.notifyActivity();
842
+ });
843
+
844
+ pi.on("session_before_compact", () => {
845
+ watchdog.notifyActivity(); // compaction can run long; never starve the watchdog
846
+ });
847
+ pi.on("session_compact", () => {
848
+ watchdog.notifyActivity();
849
+ });
850
+ pi.on("session_compact_failed", () => {
851
+ watchdog.notifyActivity();
852
+ });
853
+
796
854
  pi.on("session_start", (_event, ctx) => {
797
855
  watchdogCtx = ctx;
798
856
  watchdog.notifyActivity();
799
857
  idleNudgeSent = false;
800
858
  pendingObservations.length = 0;
801
859
  cycle = freshCycleFlags();
860
+ lastRunAborted = false;
802
861
  settledWithoutProgress = 0;
803
862
  lastSettledSignature = undefined;
804
863
  lastChallengedSignature = undefined;
805
864
  lastPokeTargets = undefined;
806
865
  pokeExplicitlyOff = false;
866
+ escPokePaused = false;
867
+ lastEscapeAt = 0;
807
868
  pokeUiAllowed = ctx.hasUI;
808
869
  autoPokeArmed = config.autoPoke && ctx.hasUI;
870
+ // TUI only: watch raw terminal input for the Escape key so an aborted
871
+ // run can be correlated with the user's actual keypress (pi-simple-goal
872
+ // pattern). stopReason alone is unreliable: provider stacks can
873
+ // finalize an abort as a generic error naming the abort.
874
+ try {
875
+ const uictx = ctx as { mode?: string; ui: { onTerminalInput?: (cb: (data: string) => unknown) => () => void } };
876
+ if (uictx.mode === "tui" && typeof uictx.ui.onTerminalInput === "function") {
877
+ unsubscribeTerminalInput = uictx.ui.onTerminalInput((data) => {
878
+ if (data === "\x1b" || data === "\x1b\x1b" || data === "\x1b[27u") {
879
+ lastEscapeAt = Date.now();
880
+ }
881
+ return undefined;
882
+ });
883
+ }
884
+ } catch {
885
+ // Non-TUI or older pi: aborts fall back to machinery classification.
886
+ }
809
887
  reconstructState(ctx);
810
888
  refreshWidget(ctx);
811
889
  });
@@ -857,10 +935,19 @@ export default function (pi: ExtensionAPI) {
857
935
  if (!ctx.isIdle()) return;
858
936
  // jcode's Esc: an interrupted run disarms auto-poke entirely and
859
937
  // 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);
938
+ if (wasLastRunAborted()) {
939
+ // Escape correlation (pi-simple-goal pattern): only an aborted run
940
+ // with a RECENT raw Escape keypress behind it is the user's stop
941
+ // that quiets the settle and pauses the poke until the next run.
942
+ // Any other abort (compaction, ctx.abort(), transport, provider
943
+ // stacks mislabeling the interrupt) is machinery: stay quiet and keep
944
+ // the cycle armed; the watchdog covers a genuinely lost continuation.
945
+ if (lastEscapeAt > 0 && Date.now() - lastEscapeAt <= ESCAPE_ABORT_WINDOW_MS) {
946
+ disarm();
947
+ escPokePaused = true; // not sticky: the next agent run lifts it
948
+ refreshWidget(ctx);
949
+ return;
950
+ }
864
951
  return;
865
952
  }
866
953
  if (!autoPokeArmed) {
package/src/widget.ts CHANGED
@@ -30,6 +30,8 @@ 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;
33
35
  gateAttempts: number;
34
36
  gateMaxAttempts: number;
35
37
  /** Ids of the todos that caused the most recent poke/gate challenge */
@@ -105,7 +107,11 @@ function buildLeftColumn(
105
107
  // user has been seeing.
106
108
  let status: string;
107
109
  if (!allSettled) {
108
- status = runtime.armed ? "· auto-poke" : "· poke off";
110
+ status = runtime.armed
111
+ ? "· auto-poke"
112
+ : runtime.escPaused
113
+ ? "· poke paused"
114
+ : "· poke off";
109
115
  } else if (summary.needs_validation) {
110
116
  status =
111
117
  runtime.gateAttempts > 0
@@ -590,6 +590,12 @@ async function suiteA() {
590
590
  const empty = widget.renderTodoWidgetLines({ todos: [], plan: {}, goals: [] }, rtOn, 6, 60, stubTheme);
591
591
  const unarmed = widget.renderTodoWidgetLines(state, rtOff, 6, 60, stubTheme);
592
592
  const offMarker = unescape(unarmed.at(-1)).includes("poke off");
593
+ // Esc-pause label must be distinct from the sticky /todos poke off state.
594
+ const rtEscPaused = { armed: false, escPaused: true, gateAttempts: 0, gateMaxAttempts: 5 };
595
+ const escPausedLine = widget.renderTodoWidgetLines(state, rtEscPaused, 6, 60, stubTheme);
596
+ const escPausedMarker =
597
+ unescape(escPausedLine.at(-1)).includes("poke paused") &&
598
+ !unescape(escPausedLine.at(-1)).includes("poke off");
593
599
  // value-rank: shown item lines are exactly a(in_progress), then pending
594
600
  // in declaration order; settled items are pushed into the overflow.
595
601
  const shownIds = lines.slice(1, -1).map((l) => (unescape(l).match(/#(\w+)/) || [])[1]);
@@ -682,10 +688,10 @@ async function suiteA() {
682
688
  markRow("g-warn")?.includes("🟡") && markRow("g-ok")?.includes("✔") &&
683
689
  markRow("g-act")?.includes("🚧") && markRow("g-wait")?.includes("🔲") &&
684
690
  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) {
691
+ 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
692
  ok("T13 widget", "intention header + bottom status + table (Todo Goal) correct");
687
693
  } 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));
694
+ 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
695
  }
690
696
  });
691
697
  }
@@ -699,6 +705,7 @@ function makeMockStreamSimple(calls, script) {
699
705
  return function streamSimple(model, context, options) {
700
706
  callIndex += 1;
701
707
  calls.push({ index: callIndex, context });
708
+ if (typeof script.onRequest === "function") script.onRequest(context);
702
709
  const step = callIndex;
703
710
  const scriptCall = script.call;
704
711
  const scriptText = script.text;
@@ -717,8 +724,22 @@ function makeMockStreamSimple(calls, script) {
717
724
  stopReason: "pending",
718
725
  timestamp: Date.now(),
719
726
  };
720
- try {
727
+ try {
721
728
  stream.push({ type: "start", partial: output });
729
+ if (script.gateAt === step) {
730
+ // Hold the stream open so the test can abort mid-flight; honor the
731
+ // abort signal exactly like pi's own providers do (openai-completions.js).
732
+ const signal = options?.signal;
733
+ if (signal?.aborted) throw new Error("Request was aborted");
734
+ await new Promise((resolve, reject) => {
735
+ const onAbort = () => reject(new Error("Request was aborted"));
736
+ signal?.addEventListener("abort", onAbort, { once: true });
737
+ (script.gatePromise || Promise.resolve()).then(
738
+ () => { signal?.removeEventListener("abort", onAbort); resolve(); },
739
+ reject,
740
+ );
741
+ });
742
+ }
722
743
  const toolCall = scriptCall(step);
723
744
  if (toolCall) {
724
745
  output.content.push(toolCall);
@@ -737,9 +758,12 @@ function makeMockStreamSimple(calls, script) {
737
758
  stream.push({ type: "done", reason: output.stopReason, message: output });
738
759
  stream.end();
739
760
  } catch (err) {
740
- output.stopReason = "error";
761
+ output.stopReason = options?.signal?.aborted ? "aborted" : "error";
762
+ // Simulate provider stacks (pi-robust-provider observed live) that
763
+ // finalize an abort as a generic error naming the abort.
764
+ if (script.misclassifiedAbortAt === step) output.stopReason = "error";
741
765
  output.errorMessage = err.message;
742
- stream.push({ type: "error", reason: "error", error: output });
766
+ stream.push({ type: "error", reason: output.stopReason, error: output });
743
767
  stream.end();
744
768
  }
745
769
  })();
@@ -909,6 +933,14 @@ const SCRIPT_B3 = {
909
933
 
910
934
  async function runScenario(name, script, assertions) {
911
935
  const calls = [];
936
+ if (script.abortAtCall) {
937
+ // Gate the abortAtCall'th provider call mid-stream so the test can abort
938
+ // the run deterministically while it is in flight.
939
+ script.gateAt = script.abortAtCall;
940
+ script.gatePromise = new Promise((resolve) => {
941
+ script.releaseGate = resolve;
942
+ });
943
+ }
912
944
  const mockFactory = {
913
945
  name: "mock-provider",
914
946
  factory: (api) => {
@@ -924,18 +956,49 @@ async function runScenario(name, script, assertions) {
924
956
  };
925
957
  await run(name, async ({ session }) => {
926
958
  await session.setModel(MOCK_MODEL);
959
+ let abortTimerFired = false;
960
+ if (script.abortAtCall) {
961
+ // prompt() resolves only after every queued follow-up run finishes, so
962
+ // the poll loop below can never reach the gated call. Simulate Esc from
963
+ // a timer: abort once the gated provider call has actually started.
964
+ const startedAt = Date.now();
965
+ const abortWhenStarted = () => {
966
+ if (calls.length >= script.abortAtCall) {
967
+ abortTimerFired = true;
968
+ if (script.compactInsteadOfAbort) session.compact();
969
+ else session.abort();
970
+ // Safety net: if the abort somehow never reaches the mock, release
971
+ // the gate so the scenario fails with diagnostics instead of hanging.
972
+ setTimeout(() => { if (script.releaseGate) script.releaseGate(); }, 3000);
973
+ } else if (Date.now() - startedAt < 12_000) {
974
+ setTimeout(abortWhenStarted, 100);
975
+ }
976
+ };
977
+ setTimeout(abortWhenStarted, 300);
978
+ }
927
979
  await session.prompt("begin");
928
980
 
929
981
  const deadline = Date.now() + 20_000;
930
982
  let prompted = false;
983
+ let aborted = false;
984
+ let resumed = false;
931
985
  while (Date.now() < deadline) {
932
986
  if (!prompted && script.promptAfter && calls.length >= script.promptAfter) {
933
987
  prompted = true;
934
988
  void session.prompt(script.promptText ?? "continue");
935
989
  }
990
+ if (!aborted && script.abortAtCall && calls.length >= script.abortAtCall) {
991
+ aborted = true; // bookkeeping only; the timer above fired the Esc
992
+ if (!abortTimerFired) session.abort();
993
+ }
994
+ if (aborted && !resumed) {
995
+ resumed = true;
996
+ await sleep(1500); // let the abort settle (disarm + quiet) run first
997
+ void session.prompt(script.resumeText ?? "resume after the interrupt");
998
+ }
936
999
  if (calls.length >= script.calls) {
937
1000
  await sleep(800);
938
- if (calls.length === script.calls) break; // no further provider activity
1001
+ if (calls.length >= script.calls && calls.length <= (script.callsMax ?? script.calls)) break; // done
939
1002
  }
940
1003
  await sleep(100);
941
1004
  }
@@ -943,8 +1006,9 @@ async function runScenario(name, script, assertions) {
943
1006
  const customs = entries
944
1007
  .filter((e) => e.type === "custom_message" && e.customType === "pi-jtodo/followup")
945
1008
  .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)))}`);
1009
+ const inRange = calls.length >= script.calls && calls.length <= (script.callsMax ?? script.calls);
1010
+ if (!inRange) {
1011
+ 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
1012
  return;
949
1013
  }
950
1014
 
@@ -952,7 +1016,7 @@ async function runScenario(name, script, assertions) {
952
1016
  (e) => e.type === "message" && e.message?.role === "toolResult" && e.message?.toolName === "todo",
953
1017
  );
954
1018
  const lastDetails = todoResults.at(-1)?.message?.details;
955
- await assertions({ calls, customs, lastDetails });
1019
+ await assertions({ calls, customs, lastDetails, entries });
956
1020
  }, { extensionFactories: [mockFactory] });
957
1021
  }
958
1022
 
@@ -1021,6 +1085,187 @@ async function suiteB() {
1021
1085
  if (done) ok("B3 final state", "both waves completed");
1022
1086
  else bad("B3 final state", JSON.stringify(lastDetails?.todos));
1023
1087
  });
1088
+
1089
+ // B4: Esc (session.abort) on the poke-driven run must disarm and inject
1090
+ // NOTHING afterwards, even though open todos remain. Flow: run A writes an
1091
+ // open todo and ends (settle #1 pokes), the poke-driven run B is aborted
1092
+ // mid-stream (the user's exact complaint), and both the abort settle and
1093
+ // the resumed run's settle stay quiet via the agent_end abort flag.
1094
+ // B4: an abort with NO raw Escape behind it is machinery (ctx.abort(),
1095
+ // extension-initiated, transport). Under the pi-simple-goal Escape model
1096
+ // the poke cycle stays armed: the aborted settle is quiet, but a resumed
1097
+ // run that still leaves todos open pokes normally. The real user-Esc pause
1098
+ // (raw \x1b keypress) is TUI-only and verified live, not in the SDK harness.
1099
+ const SCRIPT_B4 = {
1100
+ call(step) {
1101
+ if (step === 1) {
1102
+ return {
1103
+ type: "toolCall", id: "call-open", name: "todo",
1104
+ arguments: {
1105
+ todos: [{ content: "machinery abort guard", status: "in_progress", priority: "high", id: "e1", group: "esc", confidence: 96 }],
1106
+ plan: { user_intention: "machinery abort e2e", understands_user_intent: 97 },
1107
+ goals: [{ group: "esc", closed_feedback_loop: 97, feedback_loop: "abort keeps armed" }],
1108
+ },
1109
+ };
1110
+ }
1111
+ if (step === 5) {
1112
+ return {
1113
+ type: "toolCall", id: "call-done", name: "todo",
1114
+ arguments: {
1115
+ todos: [{ content: "machinery abort guard", status: "completed", priority: "high", id: "e1", group: "esc", confidence: 96, completion_confidence: 96 }],
1116
+ goals: [{ group: "esc", closed_feedback_loop: 97, feedback_loop: "abort keeps armed", end_to_end_ownership: 96 }],
1117
+ },
1118
+ };
1119
+ }
1120
+ return null;
1121
+ },
1122
+ text(step) {
1123
+ if (step === 2) return "Todos written.";
1124
+ if (step === 3) return "Poked: working on it, mid-stream.";
1125
+ if (step === 4) return "Resumed after the interrupt, still working.";
1126
+ return "All done now.";
1127
+ },
1128
+ abortAtCall: 3, // abort run B, the poke-driven continuation
1129
+ resumeText: "resume after the interrupt",
1130
+ calls: 6,
1131
+ };
1132
+ await runScenario("B4 machinery abort keeps the poke cycle armed", SCRIPT_B4, async ({ customs, entries }) => {
1133
+ const pokes = customs.filter((t) => t.includes("You have 1 incomplete todo."));
1134
+ 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");
1135
+ else bad("B4 poke before and after abort", `pokes=${pokes.length} customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
1136
+ if (customs.length === 2) ok("B4 aborted settle quiet", "the abort settle itself injected nothing");
1137
+ else bad("B4 aborted settle quiet", `customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
1138
+ const stopReasons = entries
1139
+ .filter((e) => e.type === "message" && e.message?.role === "assistant")
1140
+ .map((e) => e.message.stopReason);
1141
+ if (stopReasons.includes("aborted")) ok("B4 harness abort recorded", `assistant stopReasons=${JSON.stringify(stopReasons)}`);
1142
+ else bad("B4 harness abort recorded", `no aborted stopReason; stopReasons=${JSON.stringify(stopReasons)}`);
1143
+ });
1144
+
1145
+ // B5: pi-robust-provider observed live finalizing an Esc abort as a plain
1146
+ // error whose text names the abort ("This operation was aborted"). The
1147
+ // error-text fallback must classify it as a user interrupt and stay quiet.
1148
+ // B5: pi-robust-provider observed live finalizing an Esc abort as a plain
1149
+ // error whose text names the abort ("This operation was aborted"). Under
1150
+ // the raw-Escape classification model a stopReason mislabel changes
1151
+ // NOTHING: with no Escape keypress observed this is machinery, the settle is
1152
+ // quiet, and the cycle stays armed. The real-Esc correlation is live-only.
1153
+ const SCRIPT_B5 = {
1154
+ call(step) {
1155
+ if (step === 1) {
1156
+ return {
1157
+ type: "toolCall", id: "call-open", name: "todo",
1158
+ arguments: {
1159
+ todos: [{ content: "misclassified abort guard", status: "in_progress", priority: "high", id: "m1", group: "esc", confidence: 96 }],
1160
+ plan: { user_intention: "misclassified abort e2e", understands_user_intent: 97 },
1161
+ goals: [{ group: "esc", closed_feedback_loop: 97, feedback_loop: "error-naming-abort keeps armed" }],
1162
+ },
1163
+ };
1164
+ }
1165
+ if (step === 5) {
1166
+ return {
1167
+ type: "toolCall", id: "call-done", name: "todo",
1168
+ arguments: {
1169
+ todos: [{ content: "misclassified abort guard", status: "completed", priority: "high", id: "m1", group: "esc", confidence: 96, completion_confidence: 96 }],
1170
+ goals: [{ group: "esc", closed_feedback_loop: 97, feedback_loop: "error-naming-abort keeps armed", end_to_end_ownership: 96 }],
1171
+ },
1172
+ };
1173
+ }
1174
+ return null;
1175
+ },
1176
+ text(step) {
1177
+ if (step === 2) return "Todos written.";
1178
+ if (step === 3) return "Poked: working, mid-stream.";
1179
+ if (step === 4) return "Resumed after the interrupt, still working.";
1180
+ return "All done now.";
1181
+ },
1182
+ abortAtCall: 3,
1183
+ misclassifiedAbortAt: 3, // the abort finalizes as stopReason "error" naming the abort
1184
+ resumeText: "resume after the interrupt",
1185
+ calls: 6,
1186
+ };
1187
+ await runScenario("B5 error-naming-abort (robust-provider) is machinery", SCRIPT_B5, async ({ customs, entries }) => {
1188
+ const pokes = customs.filter((t) => t.includes("You have 1 incomplete todo."));
1189
+ if (pokes.length === 2) ok("B5 poke before and after abort", "misclassified abort did not suppress the cycle");
1190
+ else bad("B5 poke before and after abort", `pokes=${pokes.length} customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
1191
+ if (customs.length === 2) ok("B5 aborted settle quiet", "the error-naming-abort settle injected nothing");
1192
+ else bad("B5 aborted settle quiet", `customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
1193
+ const interrupted = entries
1194
+ .filter((e) => e.type === "message" && e.message?.role === "assistant")
1195
+ .find((e) => /abort/i.test(e.message?.errorMessage ?? ""));
1196
+ if (interrupted?.message?.stopReason === "error") ok("B5 interrupt finalized as error", "errorMessage names the abort, stopReason is error");
1197
+ else bad("B5 interrupt finalized as error", `stopReason=${interrupted?.message?.stopReason} err=${interrupted?.message?.errorMessage}`);
1198
+ });
1199
+
1200
+ // B6: compaction aborts the in-flight run but guarantees a continuation —
1201
+ // a system interrupt. Its settle must stay quiet WITHOUT disarming and
1202
+ // WITHOUT the sticky Esc off, so the resumed run's settle pokes normally.
1203
+ let b6DoneSent = false;
1204
+ let b6PokesSeen = 0;
1205
+ const SCRIPT_B6 = {
1206
+ onRequest(context) {
1207
+ // Count auto-poke messages already in the request history; robust
1208
+ // to compaction consuming 1 or 2 provider calls. User-role content may
1209
+ // be a string or a block array (same extraction as contextUserTexts).
1210
+ const texts = (context?.messages ?? [])
1211
+ .filter((m) => m?.role === "user")
1212
+ .map((m) =>
1213
+ typeof m?.content === "string"
1214
+ ? m.content
1215
+ : (m?.content ?? []).map((c) => c?.text ?? "").join(""),
1216
+ );
1217
+ b6PokesSeen = texts.filter((t) => t.includes("You have 1 incomplete todo.")).length;
1218
+ },
1219
+ call(step) {
1220
+ if (step === 1) {
1221
+ return {
1222
+ type: "toolCall", id: "call-open", name: "todo",
1223
+ arguments: {
1224
+ todos: [{ content: "compaction window guard", status: "in_progress", priority: "high", id: "c1", group: "esc", confidence: 96 }],
1225
+ plan: { user_intention: "compaction window e2e", understands_user_intent: 97 },
1226
+ goals: [{ group: "esc", closed_feedback_loop: 97, feedback_loop: "armed survives compaction" }],
1227
+ },
1228
+ };
1229
+ }
1230
+ // Only the run driven by poke #2 completes the todo, so poke #2
1231
+ // provably fired (the resumed run before it must leave it open).
1232
+ if (step >= 5 && b6PokesSeen >= 2 && !b6DoneSent) {
1233
+ b6DoneSent = true;
1234
+ return {
1235
+ type: "toolCall", id: "call-done", name: "todo",
1236
+ arguments: {
1237
+ todos: [{ content: "compaction window guard", status: "completed", priority: "high", id: "c1", group: "esc", confidence: 96, completion_confidence: 96 }],
1238
+ goals: [{ group: "esc", closed_feedback_loop: 97, feedback_loop: "armed survives compaction", end_to_end_ownership: 96 }],
1239
+ },
1240
+ };
1241
+ }
1242
+ return null;
1243
+ },
1244
+ text(step) {
1245
+ if (step === 2) return "Todos written. " + "Detailed work log entries accumulate here. ".repeat(2600);
1246
+ if (step === 3) return "Poked: working, mid-stream.";
1247
+ return "Continuing after compaction.";
1248
+ },
1249
+ abortAtCall: 3,
1250
+ compactInsteadOfAbort: true, // session.compact() aborts the gated run (system interrupt)
1251
+ promptAfter: 4, // after the compaction summarization call, resume the agent
1252
+ promptText: "resume the work",
1253
+ calls: 7,
1254
+ callsMax: 9, // compaction may consume 1 or 2 provider calls
1255
+ };
1256
+ await runScenario("B6 compaction abort keeps the poke cycle armed", SCRIPT_B6, async ({ customs, entries }) => {
1257
+ const pokes = customs.filter((t) => t.includes("You have 1 incomplete todo."));
1258
+ if (pokes.length === 2) ok("B6 armed preserved", "poke #1 pre-abort, poke #2 after the resumed run — no sticky-off");
1259
+ else bad("B6 armed preserved", `pokes=${pokes.length} customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
1260
+ const noisy = customs.filter((t) => t.includes("rose too sharply") || t.includes("not high enough") || t.includes("todo quality review"));
1261
+ if (noisy.length === 0) ok("B6 no spurious gates", "clean scores produced no challenges/digest");
1262
+ else bad("B6 no spurious gates", JSON.stringify(noisy));
1263
+ const stopReasons = entries
1264
+ .filter((e) => e.type === "message" && e.message?.role === "assistant")
1265
+ .map((e) => e.message.stopReason);
1266
+ if (stopReasons.includes("aborted")) ok("B6 compaction abort recorded", `stopReasons=${JSON.stringify(stopReasons)}`);
1267
+ else bad("B6 compaction abort recorded", `no aborted stopReason; stopReasons=${JSON.stringify(stopReasons)}`);
1268
+ });
1024
1269
  }
1025
1270
 
1026
1271
  // ============================================================================