@astrosheep/pi-context 0.25.2 → 0.26.0

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.
Files changed (112) hide show
  1. package/README.md +88 -7
  2. package/dist/build-info.json +2 -2
  3. package/dist/extension.js +613 -367
  4. package/dist/src/context/boot.d.ts +24 -0
  5. package/dist/src/context/boot.js +33 -24
  6. package/dist/src/context/budget.d.ts +9 -0
  7. package/dist/src/context/budget.js +16 -12
  8. package/dist/src/context/context-window.d.ts +41 -0
  9. package/dist/src/context/context-window.js +16 -1
  10. package/dist/src/context/prompts.d.ts +20 -0
  11. package/dist/src/context/prompts.js +1 -1
  12. package/dist/src/context/reset-artifacts.d.ts +26 -0
  13. package/dist/src/context/reset-artifacts.js +18 -17
  14. package/dist/src/context/reset-lifecycle.d.ts +89 -0
  15. package/dist/src/context/reset-lifecycle.js +103 -75
  16. package/dist/src/context/runtime.d.ts +3 -0
  17. package/dist/src/context/runtime.js +53 -21
  18. package/dist/src/context/thresholds.d.ts +33 -0
  19. package/dist/src/context/thresholds.js +1 -1
  20. package/dist/src/dream/cli.d.ts +10 -0
  21. package/dist/src/dream/cli.js +1 -1
  22. package/dist/src/dream/doctor.d.ts +2 -0
  23. package/dist/src/dream/doctor.js +6 -2
  24. package/dist/src/dream/gates.d.ts +10 -0
  25. package/dist/src/dream/git.d.ts +21 -0
  26. package/dist/src/dream/lock.d.ts +31 -0
  27. package/dist/src/dream/runner.d.ts +30 -0
  28. package/dist/src/dream/settings.d.ts +16 -0
  29. package/dist/src/history/history-tools.d.ts +2 -0
  30. package/dist/src/history/history.d.ts +57 -0
  31. package/dist/src/index.d.ts +39 -0
  32. package/dist/src/index.js +4 -4
  33. package/dist/src/notes/address.d.ts +26 -0
  34. package/dist/src/notes/address.js +8 -14
  35. package/dist/src/notes/constants.d.ts +3 -0
  36. package/dist/src/notes/constants.js +3 -0
  37. package/dist/src/notes/context.d.ts +10 -0
  38. package/dist/src/notes/context.js +33 -0
  39. package/dist/src/notes/frontmatter.d.ts +46 -0
  40. package/dist/src/notes/frontmatter.js +10 -5
  41. package/dist/src/notes/index.d.ts +4 -0
  42. package/dist/src/notes/index.js +2 -0
  43. package/dist/src/notes/paths.d.ts +21 -0
  44. package/dist/src/notes/paths.js +72 -76
  45. package/dist/src/notes/store.d.ts +94 -0
  46. package/dist/src/notes/store.js +298 -242
  47. package/dist/src/pi/notes/adapter.d.ts +12 -0
  48. package/dist/src/pi/notes/adapter.js +39 -0
  49. package/dist/src/pi/notes/session-replay.d.ts +16 -0
  50. package/dist/src/{notes → pi/notes}/session-replay.js +2 -2
  51. package/dist/src/pi/notes/snapshot.d.ts +33 -0
  52. package/dist/src/{notes/notes-snapshot.js → pi/notes/snapshot.js} +11 -3
  53. package/dist/src/pi/notes/tools.d.ts +2 -0
  54. package/dist/src/{notes → pi/notes}/tools.js +24 -21
  55. package/dist/src/protocol.d.ts +41 -0
  56. package/dist/src/protocol.js +4 -6
  57. package/dist/src/session-reader.d.ts +5 -0
  58. package/dist/src/settings.d.ts +6 -0
  59. package/dist/src/tool-output.d.ts +101 -0
  60. package/dist/src/tool-schema.d.ts +17 -0
  61. package/dist/test/agent-loop.test.d.ts +1 -0
  62. package/dist/test/agent-loop.test.js +309 -10
  63. package/dist/test/boot.integration.test.d.ts +1 -0
  64. package/dist/test/boot.integration.test.js +55 -29
  65. package/dist/test/budget-settings.integration.test.d.ts +1 -0
  66. package/dist/test/budget-settings.integration.test.js +8 -7
  67. package/dist/test/doctor.test.d.ts +1 -0
  68. package/dist/test/doctor.test.js +10 -2
  69. package/dist/test/dream-skill.test.d.ts +1 -0
  70. package/dist/test/dream-skill.test.js +69 -0
  71. package/dist/test/dream.test.d.ts +1 -0
  72. package/dist/test/helpers/extension.d.ts +115 -0
  73. package/dist/test/helpers/extension.js +6 -6
  74. package/dist/test/helpers/notes.d.ts +6 -0
  75. package/dist/test/helpers/notes.js +13 -0
  76. package/dist/test/history.integration.test.d.ts +1 -0
  77. package/dist/test/notes-library.test.d.ts +1 -0
  78. package/dist/test/notes-library.test.js +111 -0
  79. package/dist/test/notes.integration.test.d.ts +1 -0
  80. package/dist/test/notes.integration.test.js +22 -24
  81. package/dist/test/notes.test.d.ts +1 -0
  82. package/dist/test/notes.test.js +137 -7
  83. package/dist/test/reset-lifecycle.test.d.ts +1 -0
  84. package/dist/test/reset-lifecycle.test.js +142 -85
  85. package/docs/architecture.md +8 -8
  86. package/docs/reset-lifecycle.md +63 -79
  87. package/package.json +35 -2
  88. package/playbook.md +33 -32
  89. package/skills/dream/SKILL.md +12 -0
  90. package/src/context/boot.ts +44 -25
  91. package/src/context/budget.ts +19 -11
  92. package/src/context/context-window.ts +16 -1
  93. package/src/context/prompts.ts +2 -2
  94. package/src/context/reset-artifacts.ts +26 -24
  95. package/src/context/reset-lifecycle.ts +117 -111
  96. package/src/context/runtime.ts +50 -22
  97. package/src/context/thresholds.ts +1 -1
  98. package/src/dream/cli.ts +1 -1
  99. package/src/dream/doctor.ts +5 -2
  100. package/src/index.ts +4 -4
  101. package/src/notes/address.ts +9 -15
  102. package/src/notes/constants.ts +3 -0
  103. package/src/notes/context.ts +40 -0
  104. package/src/notes/frontmatter.ts +18 -12
  105. package/src/notes/index.ts +22 -0
  106. package/src/notes/paths.ts +64 -78
  107. package/src/notes/store.ts +308 -244
  108. package/src/pi/notes/adapter.ts +44 -0
  109. package/src/{notes → pi/notes}/session-replay.ts +3 -3
  110. package/src/{notes/notes-snapshot.ts → pi/notes/snapshot.ts} +13 -4
  111. package/src/{notes → pi/notes}/tools.ts +25 -23
  112. package/src/protocol.ts +5 -6
@@ -24,6 +24,16 @@ export function currentReset(ctx: SessionReader): WindowMarker | undefined {
24
24
  return undefined;
25
25
  }
26
26
 
27
+ /** A reset is checkpoint-backed only when its marker directly follows a native compaction. */
28
+ export function isCheckpointBackedReset(ctx: SessionReader, marker = currentReset(ctx)): boolean {
29
+ if (!marker?.parentId) return false;
30
+ const branch = ctx.sessionManager.getBranch();
31
+ const markerIndex = branch.findIndex((entry) => entry.id === marker.id);
32
+ const checkpoint = branch[markerIndex - 1];
33
+ return markerIndex > 0 && checkpoint?.id === marker.parentId && checkpoint.type === "compaction" &&
34
+ checkpoint.summary === "" && checkpoint.firstKeptEntryId === checkpoint.id;
35
+ }
36
+
27
37
  /** Mint the durable identity of a session's root history window. */
28
38
  export function rootWindowId(sessionId: string): string {
29
39
  return `pcw:${sessionId.slice(0, 8)}:root`;
@@ -106,7 +116,12 @@ export function windowUsage(ctx: Pick<ExtensionContext, "sessionManager" | "getC
106
116
  if (!contextWindow) return undefined;
107
117
  const windowId = reset.data.windowId;
108
118
  try {
109
- const messages: AgentMessage[] = projectWindow(ctx.sessionManager.buildSessionProjection().messages, windowId);
119
+ const canonicalMessages = ctx.sessionManager.buildSessionProjection().messages;
120
+ // New resets already cut the durable Pi projection. Marker slicing is retained only
121
+ // for sessions written before native reset checkpoints existed.
122
+ const messages: AgentMessage[] = isCheckpointBackedReset(ctx, reset)
123
+ ? canonicalMessages
124
+ : projectWindow(canonicalMessages, windowId);
110
125
  const { tokens } = estimateContextTokens(convertToLlm(messages));
111
126
  return { tokens, contextWindow, percent: tokens / contextWindow * 100 };
112
127
  } catch {
@@ -1,4 +1,4 @@
1
- import type { NotesHome, NotesSnapshot } from "../notes/notes-snapshot.js";
1
+ import type { NotesHome, NotesSnapshot } from "../pi/notes/snapshot.js";
2
2
  import { CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, POCKET_AGENT_LIMIT, POCKET_HUMAN_LIMIT, POCKET_MODEL_LIMIT, POCKET_PROJECT_LIMIT, POCKET_SESSION_LIMIT, PROTOCOL_BLOCK, GUIDANCE_OPEN_TAG, GUIDANCE_CLOSE_TAG } from "../protocol.js";
3
3
 
4
4
  /** Codex-style <context_window> identity block: the resolved agent and model names plus first/current/previous window ids. */
@@ -62,7 +62,7 @@ function notesIndex(snapshot: NotesSnapshot): string {
62
62
  if (recentNotes.length > 0) {
63
63
  const lines = [`You find ${recentNotes.length} crumpled note${recentNotes.length === 1 ? "" : "s"} in your pocket (by prefix, most recent first within each: up to ${POCKET_SESSION_LIMIT} from this session, ${POCKET_PROJECT_LIMIT} from @project, ${POCKET_HUMAN_LIMIT} from @human, ${POCKET_AGENT_LIMIT} from @self, ${POCKET_MODEL_LIMIT} from @model). A note's content never appears here, so its name has to say what the note is about:`];
64
64
  for (const row of recentNotes) {
65
- lines.push(`- ${row.address} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${relativeTime(row.meta.updated_at, snapshot.openedAt)})`);
65
+ lines.push(`- ${row.address} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${relativeTime(row.meta.updatedAt, snapshot.openedAt)})`);
66
66
  }
67
67
  sections.push(lines.join("\n"));
68
68
  }
@@ -20,11 +20,17 @@ export function sendContinuation(pi: ExtensionAPI): void {
20
20
  }
21
21
 
22
22
  /**
23
- * The closed, ordered reset shape: marker, matching boot, continuation. This is the one
24
- * source of the reset message and the one place that mints the new window identity.
23
+ * The closed, ordered reset shape: retain-none native checkpoint, marker, matching boot,
24
+ * continuation. The boot snapshot is acquired asynchronously after capturing this identity.
25
25
  */
26
- export function buildResetDrafts(ctx: ExtensionContext, notifyIncompleteNotes?: IncompleteNotesNotifier) {
27
- const sessionPrefix = ctx.sessionManager.getSessionId().slice(0, 8);
26
+ export async function buildResetDrafts(
27
+ ctx: ExtensionContext,
28
+ notifyIncompleteNotes?: IncompleteNotesNotifier,
29
+ isCurrent: () => boolean = () => true,
30
+ ): Promise<[SessionBoundaryDraft, SessionBoundaryDraft, SessionBoundaryDraft, SessionBoundaryDraft]> {
31
+ const sessionId = ctx.sessionManager.getSessionId();
32
+ const previousId = currentWindowId(ctx);
33
+ const sessionPrefix = sessionId.slice(0, 8);
28
34
  const usedWindowIds = new Set(
29
35
  ctx.sessionManager.getBranch().filter(isWindowMarker).map((entry) => entry.data.windowId),
30
36
  );
@@ -32,27 +38,13 @@ export function buildResetDrafts(ctx: ExtensionContext, notifyIncompleteNotes?:
32
38
  do {
33
39
  windowId = `pcw:${sessionPrefix}:${randomUUID().slice(0, 8)}`;
34
40
  } while (usedWindowIds.has(windowId));
35
- const boot = buildBootMessage(ctx, windowId, currentWindowId(ctx), notifyIncompleteNotes);
41
+ const boot = await buildBootMessage(ctx, windowId, previousId, notifyIncompleteNotes, isCurrent);
36
42
  return [
43
+ { type: "compaction", summary: "", firstKeptEntryId: null },
37
44
  { type: "custom", customType: RESET_MARKER_TYPE, data: { windowId } },
38
45
  { type: "custom_message", customType: BOOT_TYPE, content: boot.content, display: false, details: { windowId } },
39
46
  { type: "custom_message", customType: CONTINUATION_TYPE, content: CONTINUATION, display: false },
40
- ] satisfies [SessionBoundaryDraft, SessionBoundaryDraft, SessionBoundaryDraft];
41
- }
42
-
43
- /** Persist the marker and send both hidden reset messages; returns the new window id. */
44
- export function persistManualReset(pi: ExtensionAPI, ctx: ExtensionContext, notifyIncompleteNotes?: IncompleteNotesNotifier): string {
45
- const [marker, boot, continuation] = buildResetDrafts(ctx, notifyIncompleteNotes);
46
- pi.appendEntry(marker.customType, marker.data);
47
- pi.sendMessage(
48
- { customType: boot.customType, content: boot.content, display: boot.display, details: boot.details },
49
- { triggerTurn: false },
50
- );
51
- pi.sendMessage(
52
- { customType: continuation.customType, content: continuation.content, display: continuation.display },
53
- { triggerTurn: false },
54
- );
55
- return boot.details.windowId;
47
+ ];
56
48
  }
57
49
 
58
50
  /**
@@ -93,9 +85,19 @@ export function resetTailCommitted(ctx: ExtensionContext, markerId: string, wind
93
85
  }
94
86
 
95
87
  /** Emit only the reset artifacts an incomplete tail is missing, in the closed order. */
96
- export function repairResetTail(pi: ExtensionAPI, ctx: ExtensionContext, marker: WindowMarker, notifyIncompleteNotes?: IncompleteNotesNotifier): void {
88
+ export async function repairResetTail(
89
+ pi: ExtensionAPI,
90
+ ctx: ExtensionContext,
91
+ marker: WindowMarker,
92
+ notifyIncompleteNotes?: IncompleteNotesNotifier,
93
+ isCurrent: () => boolean = () => true,
94
+ ): Promise<void> {
97
95
  const tail = inspectResetTail(ctx, marker.id, marker.data.windowId);
98
96
  if (!tail || (tail.boot && tail.continuation)) return;
99
- if (!tail.boot) sendBoot(pi, buildBootMessage(ctx, marker.data.windowId, previousWindowId(ctx, marker.id), notifyIncompleteNotes));
100
- if (!tail.continuation) sendContinuation(pi);
97
+ const boot = tail.boot ? undefined : await buildBootMessage(ctx, marker.data.windowId, previousWindowId(ctx, marker.id), notifyIncompleteNotes, isCurrent);
98
+ if (!isCurrent()) return;
99
+ const latest = inspectResetTail(ctx, marker.id, marker.data.windowId);
100
+ if (!latest) return;
101
+ if (!latest.boot && boot) sendBoot(pi, boot);
102
+ if (!latest.continuation && (latest.boot || boot)) sendContinuation(pi);
101
103
  }
@@ -1,11 +1,11 @@
1
1
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
2
  import { isContextOverflow, isRecoverableLength } from "@earendil-works/pi-ai";
3
3
  import type { AgentBeforeSettleEvent, ExtensionAPI, ExtensionContext, SessionBoundaryDraft } from "@earendil-works/pi-coding-agent";
4
- import { currentReset } from "./context-window.js";
4
+ import { currentReset, currentWindowId } from "./context-window.js";
5
5
 
6
6
  type BudgetOwner = {
7
7
  automaticResetEnabled: (ctx: ExtensionContext) => boolean;
8
- resetDue: (ctx: ExtensionContext) => boolean;
8
+ hardReserveDue: (ctx: ExtensionContext) => boolean;
9
9
  consumeTurnEnd: (ctx: ExtensionContext) => SessionBoundaryDraft[];
10
10
  clear: () => void;
11
11
  };
@@ -13,7 +13,9 @@ type BudgetOwner = {
13
13
  type ResetOptions = {
14
14
  isEnabled: () => boolean;
15
15
  budget: BudgetOwner;
16
- buildReset: (ctx: ExtensionContext) => SessionBoundaryDraft[];
16
+ buildReset: (ctx: ExtensionContext, isCurrent: () => boolean) => SessionBoundaryDraft[] | Promise<SessionBoundaryDraft[]>;
17
+ getLifecycleGeneration?: () => number;
18
+ onResetReady?: (ctx: ExtensionContext, drafts: readonly SessionBoundaryDraft[]) => void;
17
19
  };
18
20
 
19
21
  function isAbort(message: AgentMessage, outcome: string | undefined, ctx: ExtensionContext): boolean {
@@ -26,79 +28,59 @@ function isOverflowLike(message: AgentMessage, ctx: ExtensionContext): boolean {
26
28
  (ctx.model !== undefined && isRecoverableLength(message, ctx.model.maxTokens));
27
29
  }
28
30
 
29
- /**
30
- * Reset-control state. The concern is split into two independent, explicitly typed axes so
31
- * that no combination of independently combinable lifecycle booleans is legal by accident:
32
- *
33
- * - `request` is the single pending explicit wipe request ("none" or "explicit"). Repeated
34
- * requests deduplicate while one is pending.
35
- * - `overflow` is the bounded provider-overflow recovery chain:
36
- * - "idle" no overflow failure is pending; recovery is available for a later chain.
37
- * - "pending" an overflow failure is pending; recovery is still available.
38
- * - "pending-spent" an overflow failure is pending, but recovery was already spent this chain.
39
- * - "spent" recovery was spent and the failure is no longer pending.
40
- *
41
- * All eight combinations of the two axes are reachable (a request may arrive while an overflow
42
- * chain is pending, spent, or settled), and each has a defined transition. There is no state
43
- * whose fields silently contradict one another.
44
- */
45
- export type ResetRequestPhase = "none" | "explicit";
31
+ export type ResetRequestSource = "manual" | "automatic";
32
+ export type ResetRequest =
33
+ | { readonly phase: "none" }
34
+ | { readonly phase: "close-out"; readonly windowId: string; readonly source: ResetRequestSource }
35
+ | { readonly phase: "tool-requested"; readonly windowId: string };
46
36
  export type ResetOverflowPhase = "idle" | "pending" | "pending-spent" | "spent";
47
37
 
48
38
  export interface ResetControlState {
49
- readonly request: ResetRequestPhase;
39
+ readonly request: ResetRequest;
50
40
  readonly overflow: ResetOverflowPhase;
51
41
  }
52
42
 
43
+ const NO_REQUEST: ResetRequest = { phase: "none" };
44
+
53
45
  export function initialResetControl(): ResetControlState {
54
- return { request: "none", overflow: "idle" };
46
+ return { request: NO_REQUEST, overflow: "idle" };
55
47
  }
56
48
 
57
- /**
58
- * Facts for a completed turn. The three `get`-supplied fields are guards the adapter resolves
59
- * lazily: the reducer reads each one only in the branch where the previous adapter consulted it,
60
- * so policy resolution and pending-message probes keep their original call ordering.
61
- */
49
+ /** Facts for a completed turn. Guard values stay lazy to preserve policy resolution order. */
62
50
  export interface ResetTurnEndFacts {
51
+ readonly windowId: string;
63
52
  readonly aborted: boolean;
64
53
  readonly overflow: boolean;
65
54
  readonly failed: boolean;
66
55
  readonly enabled: boolean;
67
56
  readonly queued: boolean;
68
57
  readonly automaticResetEnabled: boolean;
69
- readonly thresholdDue: boolean;
58
+ readonly hardReserveDue: boolean;
70
59
  }
71
60
 
72
61
  /** Facts for the pre-settlement boundary; policy guards stay lazy for the same reason. */
73
62
  export interface ResetBeforeSettleFacts {
63
+ readonly windowId: string;
74
64
  readonly queued: boolean;
75
65
  readonly enabled: boolean;
76
66
  readonly automaticResetEnabled: boolean;
77
67
  readonly aborted: boolean;
68
+ readonly failed: boolean;
78
69
  }
79
70
 
80
71
  export type ResetControlEvent =
81
- | { readonly type: "request" }
72
+ | { readonly type: "close_out"; readonly windowId: string; readonly source: ResetRequestSource }
73
+ | { readonly type: "tool_request"; readonly windowId: string }
82
74
  | { readonly type: "turn_end"; readonly facts: ResetTurnEndFacts }
83
75
  | { readonly type: "before_settle"; readonly facts: ResetBeforeSettleFacts }
84
76
  | { readonly type: "settled" }
85
77
  | { readonly type: "abort" }
86
78
  | { readonly type: "clear" };
87
79
 
88
- /**
89
- * The requested effect of a transition. Effects are named, not performed: marker/boot/
90
- * continuation writes, continuation requests, and UI notices stay in the adapter.
91
- *
92
- * - "none" keep any return value to the drafts already collected for this event.
93
- * - "requested" a new explicit reset request was recorded.
94
- * - "already-requested" a request was already pending and was deduplicated.
95
- * - "commit-boundary" build and commit the reset boundary now (turn_end).
96
- * - "recover-overflow" build and commit the one bounded overflow recovery now (settle).
97
- */
98
80
  export type ResetControlEffect =
99
81
  | "none"
100
- | "requested"
101
- | "already-requested"
82
+ | "close-out-armed"
83
+ | "already-pending"
102
84
  | "commit-boundary"
103
85
  | "recover-overflow";
104
86
 
@@ -107,59 +89,77 @@ export interface ResetControlResult {
107
89
  readonly effect: ResetControlEffect;
108
90
  }
109
91
 
110
- /**
111
- * Pure reset-control transition. It performs no writes, no policy resolution of its own, and
112
- * no UI work; every fact it reads is supplied by the caller. Callers can therefore drive the
113
- * full transition table without a live Pi session.
114
- */
92
+ function requestForWindow(request: ResetRequest, windowId: string): ResetRequest {
93
+ return request.phase !== "none" && request.windowId === windowId ? request : NO_REQUEST;
94
+ }
95
+
96
+ /** Pure reset-control transitions: request phases own close-out, tool commit, and fallback. */
115
97
  export function reduceResetControl(state: ResetControlState, event: ResetControlEvent): ResetControlResult {
116
98
  switch (event.type) {
117
- case "request": {
118
- if (state.request === "explicit") return { state, effect: "already-requested" };
119
- return { state: { ...state, request: "explicit" }, effect: "requested" };
99
+ case "close_out": {
100
+ const request = state.request;
101
+ if (request.phase === "tool-requested" && request.windowId === event.windowId) {
102
+ return { state, effect: "already-pending" };
103
+ }
104
+ if (request.phase === "close-out" && request.windowId === event.windowId) {
105
+ if (request.source === "manual" || event.source === "automatic") return { state, effect: "already-pending" };
106
+ return { state: { ...state, request: { ...request, source: "manual" } }, effect: "close-out-armed" };
107
+ }
108
+ return { state: { ...state, request: { phase: "close-out", windowId: event.windowId, source: event.source } }, effect: "close-out-armed" };
109
+ }
110
+ case "tool_request": {
111
+ const request = state.request;
112
+ if (request.phase === "tool-requested" && request.windowId === event.windowId) return { state, effect: "already-pending" };
113
+ return { state: { ...state, request: { phase: "tool-requested", windowId: event.windowId } }, effect: "close-out-armed" };
120
114
  }
121
115
  case "turn_end": {
122
116
  const facts = event.facts;
123
- const requested = state.request === "explicit";
124
- // The explicit request is consumed by the turn boundary whether or not it commits.
125
- const request: ResetRequestPhase = "none";
126
- if (facts.aborted) {
127
- // An aborted turn drops the whole boundary: no explicit request, no overflow chain.
128
- return { state: { request, overflow: "idle" }, effect: "none" };
129
- }
117
+ const request = requestForWindow(state.request, facts.windowId);
118
+ if (facts.aborted) return { state: initialResetControl(), effect: "none" };
130
119
  if (facts.overflow) {
131
- // A failure that Pi may recover natively: arm (or re-arm) the bounded settle path.
132
- // A queued message, disabled mode, or disabled automatic reset leaves it disarmed.
133
120
  const pending = !facts.queued && facts.enabled && facts.automaticResetEnabled;
134
121
  const spent = state.overflow === "pending-spent" || state.overflow === "spent";
135
122
  const overflow: ResetOverflowPhase = pending
136
123
  ? (spent ? "pending-spent" : "pending")
137
124
  : (spent ? "spent" : "idle");
138
- return { state: { request, overflow }, effect: "none" };
125
+ return { state: { request: NO_REQUEST, overflow }, effect: "none" };
126
+ }
127
+ if (facts.failed) return { state: { request: NO_REQUEST, overflow: state.overflow }, effect: "none" };
128
+ if (!facts.enabled) {
129
+ return { state: { request: NO_REQUEST, overflow: "idle" }, effect: "none" };
130
+ }
131
+ // A direct tool request commits after the whole tool batch. The budget cutoff is
132
+ // a separate hard-reserve safety path; ordinary close-out waits for settlement.
133
+ if (request.phase === "tool-requested" || facts.hardReserveDue) {
134
+ return { state: { request: NO_REQUEST, overflow: "idle" }, effect: "commit-boundary" };
139
135
  }
140
- // Any non-overflow completed turn supersedes an older overflow failure. A non-overflow
141
- // error leaves the armed overflow chain untouched for the settle boundary.
142
- const overflow: ResetOverflowPhase = facts.failed ? state.overflow : "idle";
143
- if (!facts.enabled || facts.failed) return { state: { request, overflow }, effect: "none" };
144
- // Explicit and threshold resets both commit at turn_end, after incoming and budget drafts.
145
- const commit = requested || facts.thresholdDue;
146
- return { state: { request, overflow }, effect: commit ? "commit-boundary" : "none" };
136
+ return { state: { request, overflow: "idle" }, effect: "none" };
147
137
  }
148
138
  case "before_settle": {
149
139
  const facts = event.facts;
150
- if (state.overflow !== "pending" && state.overflow !== "pending-spent") return { state, effect: "none" };
151
- // Let a queued turn run first; its own turn_end settles or clears this chain.
152
- if (facts.queued) return { state, effect: "none" };
153
- const spent = state.overflow === "pending-spent";
154
- if (!facts.enabled || !facts.automaticResetEnabled || facts.aborted) {
155
- return { state: { ...state, overflow: spent ? "spent" : "idle" }, effect: "none" };
140
+ if (facts.aborted) return { state: initialResetControl(), effect: "none" };
141
+ const request = requestForWindow(state.request, facts.windowId);
142
+ if (state.overflow === "pending" || state.overflow === "pending-spent") {
143
+ if (facts.queued) {
144
+ return { state: { ...state, request: facts.failed ? NO_REQUEST : request }, effect: "none" };
145
+ }
146
+ const spent = state.overflow === "pending-spent";
147
+ if (!facts.enabled || !facts.automaticResetEnabled) {
148
+ return { state: { request: facts.failed ? NO_REQUEST : request, overflow: spent ? "spent" : "idle" }, effect: "none" };
149
+ }
150
+ return { state: { request: NO_REQUEST, overflow: "spent" }, effect: spent ? "none" : "recover-overflow" };
156
151
  }
157
- // The recovery is one-use per failure chain. Committing it spends the attempt.
158
- return { state: { ...state, overflow: "spent" }, effect: spent ? "none" : "recover-overflow" };
152
+ if (facts.failed || !facts.enabled || request.phase !== "close-out") {
153
+ return { state: { ...state, request: NO_REQUEST }, effect: "none" };
154
+ }
155
+ if (facts.queued) return { state: { ...state, request }, effect: "none" };
156
+ if (request.source === "automatic" && !facts.automaticResetEnabled) {
157
+ return { state: { ...state, request: NO_REQUEST }, effect: "none" };
158
+ }
159
+ return { state: { request: NO_REQUEST, overflow: "idle" }, effect: "commit-boundary" };
159
160
  }
160
161
  case "settled":
161
- // Settlement ends the failure chain but leaves a pending explicit request armed.
162
- return { state: { ...state, overflow: "idle" }, effect: "none" };
162
+ return { state: initialResetControl(), effect: "none" };
163
163
  case "abort":
164
164
  case "clear":
165
165
  return { state: initialResetControl(), effect: "none" };
@@ -167,71 +167,76 @@ export function reduceResetControl(state: ResetControlState, event: ResetControl
167
167
  }
168
168
 
169
169
  /**
170
- * Own reset requests at Pi 0.87 boundaries. Persisted windows are custom entries, not
171
- * compaction summaries: turn_end commits explicit/threshold resets after a complete tool
172
- * batch, while agent_before_settle commits the one bounded overflow recovery after Pi's
173
- * native recovery attempt has been cancelled.
174
- *
175
- * This function is the effect adapter: it captures Pi events, translates them into pure
176
- * reset-control transitions, and performs the resulting marker/boot/continuation writes and
177
- * notifications. The decision of what to do lives entirely in `reduceResetControl`.
170
+ * Own close-out requests at Pi's public turn and pre-settlement boundaries. Tool-requested
171
+ * resets commit at turn_end; manual and budget close-outs remain armed across note/tool turns
172
+ * and fall back at a successful agent_before_settle. Overflow recovery remains bounded.
178
173
  */
179
174
  export function registerResetLifecycle(pi: ExtensionAPI, options: ResetOptions) {
180
175
  let sessionActive = true;
176
+ let lifecycleGeneration = 0;
181
177
  let control = initialResetControl();
182
178
 
183
179
  const clear = () => { control = reduceResetControl(control, { type: "clear" }).state; };
184
- const resetBoundaryResult = (entries: SessionBoundaryDraft[], ctx: ExtensionContext) => {
180
+ const resetBoundaryResult = async (entries: SessionBoundaryDraft[], ctx: ExtensionContext) => {
181
+ const generation = lifecycleGeneration;
182
+ const outerGeneration = options.getLifecycleGeneration?.();
183
+ const sessionId = ctx.sessionManager.getSessionId();
184
+ const windowId = currentWindowId(ctx);
185
+ const isCurrent = () => sessionActive && lifecycleGeneration === generation &&
186
+ (outerGeneration === undefined || options.getLifecycleGeneration?.() === outerGeneration) && options.isEnabled() &&
187
+ ctx.signal?.aborted !== true && ctx.sessionManager.getSessionId() === sessionId && currentWindowId(ctx) === windowId;
188
+ let resetDrafts: SessionBoundaryDraft[];
185
189
  try {
186
- return { entries: [...entries, ...options.buildReset(ctx)], continue: true as const };
190
+ resetDrafts = await options.buildReset(ctx, isCurrent);
187
191
  } catch (error) {
188
- ctx.ui.notify(`pi-context: could not build reset (${String(error)}).`, "warning");
189
- // The incoming drafts and budget drafts are already valid work from this
190
- // boundary. Preserve them, but do not claim a continuation when reset
191
- // construction failed.
192
+ if (isCurrent()) ctx.ui.notify(`pi-context: could not build reset (${String(error)}).`, "warning");
192
193
  return entries.length > 0 ? { entries } : undefined;
193
194
  }
195
+ if (!isCurrent()) return entries.length > 0 ? { entries } : undefined;
196
+ options.onResetReady?.(ctx, resetDrafts);
197
+ return { entries: [...entries, ...resetDrafts], continue: true as const };
194
198
  };
195
199
 
196
- pi.on("turn_end", (event, ctx) => {
200
+ pi.on("turn_end", async (event, ctx) => {
197
201
  if (!sessionActive) return undefined;
198
202
  const aborted = isAbort(event.message, event.outcome, ctx);
199
203
  const stagedBudgetEntries = options.budget.consumeTurnEnd(ctx);
200
- // Lifecycle owns whether drafts are acceptable for this turn. Budget only
201
- // drains its instance-local staging, so aborts and disabled mode cannot commit it.
202
- const budgetEntries = options.isEnabled() && !aborted ? stagedBudgetEntries : [];
204
+ const budgetEntries = options.isEnabled() && !aborted && event.outcome !== "error" ? stagedBudgetEntries : [];
203
205
  const entries = [...(event.entries ?? []), ...budgetEntries];
204
206
  const decision = reduceResetControl(control, {
205
207
  type: "turn_end",
206
208
  facts: {
209
+ windowId: currentWindowId(ctx),
207
210
  aborted,
208
211
  overflow: aborted ? false : isOverflowLike(event.message, ctx),
209
212
  failed: event.outcome === "error",
210
213
  enabled: options.isEnabled(),
211
214
  get queued() { return event.context.pendingMessages.length > 0 || ctx.hasPendingMessages(); },
212
215
  get automaticResetEnabled() { return options.budget.automaticResetEnabled(ctx); },
213
- get thresholdDue() { return options.budget.resetDue(ctx); },
216
+ get hardReserveDue() { return options.budget.hardReserveDue(ctx); },
214
217
  },
215
218
  });
216
219
  control = decision.state;
217
- if (decision.effect === "commit-boundary") return resetBoundaryResult(entries, ctx);
220
+ if (decision.effect === "commit-boundary") return await resetBoundaryResult(entries, ctx);
218
221
  return entries.length > 0 ? { entries } : undefined;
219
222
  });
220
223
 
221
- pi.on("agent_before_settle", (event: AgentBeforeSettleEvent, ctx) => {
224
+ pi.on("agent_before_settle", async (event: AgentBeforeSettleEvent, ctx) => {
222
225
  if (!sessionActive) return undefined;
223
226
  const decision = reduceResetControl(control, {
224
227
  type: "before_settle",
225
228
  facts: {
229
+ windowId: currentWindowId(ctx),
226
230
  get queued() { return event.context.pendingMessages.length > 0 || ctx.hasPendingMessages(); },
227
231
  get enabled() { return options.isEnabled(); },
228
232
  get automaticResetEnabled() { return options.budget.automaticResetEnabled(ctx); },
229
- get aborted() { return event.outcome === "aborted" || ctx.signal?.aborted === true; },
233
+ aborted: event.outcome === "aborted" || ctx.signal?.aborted === true,
234
+ failed: event.outcome === "error",
230
235
  },
231
236
  });
232
237
  control = decision.state;
233
- if (decision.effect !== "recover-overflow") return undefined;
234
- return resetBoundaryResult(event.entries, ctx);
238
+ if (decision.effect !== "commit-boundary" && decision.effect !== "recover-overflow") return undefined;
239
+ return await resetBoundaryResult(event.entries, ctx);
235
240
  });
236
241
 
237
242
  pi.on("session_before_compact", (event, ctx) => {
@@ -242,30 +247,31 @@ export function registerResetLifecycle(pi: ExtensionAPI, options: ResetOptions)
242
247
  if (event.reason === "manual") {
243
248
  ctx.ui.notify("pi-context: /compact is disabled while context windows are active; use /wipe-memory to start a fresh window.", "warning");
244
249
  }
245
- // Native compaction is cancelled here. Threshold resets are decided solely from
246
- // completed-turn usage at turn_end, never from canonical pre-request history.
247
250
  return { cancel: true };
248
251
  }
249
252
  return undefined;
250
253
  });
251
254
 
252
255
  pi.on("agent_end", (_event, ctx) => {
253
- if (ctx.signal?.aborted) clear();
256
+ if (ctx.signal?.aborted) control = reduceResetControl(control, { type: "abort" }).state;
254
257
  });
255
258
  pi.on("agent_settled", () => {
256
- // A failed recovery chain is bounded to one reset/retry. Once Pi settles, a later
257
- // user prompt starts a new chain; successful continuations clear this earlier.
258
259
  control = reduceResetControl(control, { type: "settled" }).state;
259
260
  });
260
- pi.on("session_start", () => { clear(); sessionActive = true; });
261
- pi.on("session_tree", clear);
262
- pi.on("session_shutdown", () => { clear(); options.budget.clear(); sessionActive = false; });
261
+ pi.on("session_start", () => { lifecycleGeneration++; clear(); sessionActive = true; });
262
+ pi.on("session_tree", () => { lifecycleGeneration++; clear(); });
263
+ pi.on("session_shutdown", () => { lifecycleGeneration++; clear(); options.budget.clear(); sessionActive = false; });
263
264
 
264
265
  return {
265
- request() {
266
- const decision = reduceResetControl(control, { type: "request" });
266
+ closeOut(windowId: string, source: ResetRequestSource) {
267
+ const decision = reduceResetControl(control, { type: "close_out", windowId, source });
268
+ control = decision.state;
269
+ return decision.effect;
270
+ },
271
+ request(windowId: string) {
272
+ const decision = reduceResetControl(control, { type: "tool_request", windowId });
267
273
  control = decision.state;
268
- return decision.effect === "already-requested" ? "rollover_already_pending" : "rollover_requested";
274
+ return decision.effect === "already-pending" ? "rollover_already_pending" : "rollover_requested";
269
275
  },
270
276
  clear,
271
277
  };