@astrosheep/pi-context 0.22.1 → 0.23.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.
@@ -12,7 +12,16 @@ export function registerResetLifecycle(pi: ExtensionAPI, options: {
12
12
  isCurrentReset: (entryId: string, ctx: ExtensionContext) => boolean;
13
13
  onReset: (entryId: string) => void;
14
14
  }) {
15
- type Attempt = { completed: boolean; sessionId: string; explicit: boolean };
15
+ type Attempt = {
16
+ completed: boolean;
17
+ explicit: boolean;
18
+ nextRequested: boolean;
19
+ continuationStarted: boolean;
20
+ sessionId: string;
21
+ settled: boolean;
22
+ wait: Promise<void>;
23
+ release: () => void;
24
+ };
16
25
  type Request =
17
26
  | { phase: "idle" }
18
27
  | { phase: "requested" }
@@ -21,38 +30,39 @@ export function registerResetLifecycle(pi: ExtensionAPI, options: {
21
30
  let handledEntry: string | undefined;
22
31
  let active = true;
23
32
 
33
+ const release = (attempt: Attempt) => {
34
+ if (attempt.settled) return;
35
+ attempt.settled = true;
36
+ if (state.phase === "compacting" && state.attempt === attempt) {
37
+ state = { phase: "idle" };
38
+ handledEntry = undefined;
39
+ }
40
+ attempt.release();
41
+ };
24
42
  const clear = () => {
43
+ if (state.phase === "compacting") release(state.attempt);
25
44
  state = { phase: "idle" };
26
45
  handledEntry = undefined;
27
46
  };
28
47
  const valid = (request: Attempt, ctx: ExtensionContext) =>
29
48
  active && options.isEnabled() && state.phase === "compacting" && state.attempt === request && ctx.sessionManager.getSessionId() === request.sessionId;
30
49
 
31
- // State is intentionally not resumed from a pending request: a loaded session must
32
- // not execute work from a tool that belonged to a previous runtime or tree branch.
33
- pi.on("session_start", () => { clear(); active = true; });
34
- pi.on("session_shutdown", () => { clear(); active = false; });
35
- pi.on("session_tree", clear);
36
-
37
- pi.on("agent_end", (_event, ctx) => {
38
- if (!active || !options.isEnabled()) return;
39
- if (ctx.signal?.aborted) {
40
- // Esc cancels the user's run. Do not reset or resurrect it at settled.
41
- state = { phase: "idle" };
42
- return;
43
- }
44
- });
45
-
46
- pi.on("agent_settled", (_event, ctx) => {
47
- if (!active || !options.isEnabled() || state.phase === "compacting" || !ctx.isIdle()) return;
48
- if (state.phase !== "requested") return;
49
- // One owner for requested resets. Consume the request before any external call;
50
- // repeated settled events and reentrant callbacks are harmless.
51
- const request: Attempt = { completed: false, sessionId: ctx.sessionManager.getSessionId(), explicit: true };
50
+ const begin = (ctx: ExtensionContext) => {
51
+ let releaseWait!: () => void;
52
+ const request: Attempt = {
53
+ completed: false,
54
+ explicit: true,
55
+ nextRequested: false,
56
+ continuationStarted: false,
57
+ sessionId: ctx.sessionManager.getSessionId(),
58
+ settled: false,
59
+ wait: new Promise<void>((resolve) => { releaseWait = resolve; }),
60
+ release: () => releaseWait(),
61
+ };
52
62
  state = { phase: "compacting", attempt: request };
53
63
  const onError = (error: Error) => {
54
64
  if (!valid(request, ctx)) return;
55
- state = { phase: "idle" };
65
+ release(request);
56
66
  // Do not retry from settled in a tight loop. A later prompt may trigger a
57
67
  // native reset or explicitly request one.
58
68
  ctx.ui.notify(`pi-context: reset did not complete (${error.message}). The conversation is retained; resume with another prompt.`, "warning");
@@ -61,19 +71,64 @@ export function registerResetLifecycle(pi: ExtensionAPI, options: {
61
71
  ctx.compact({
62
72
  onComplete: () => {
63
73
  if (!valid(request, ctx)) return;
64
- state = { phase: "idle" };
65
74
  // session_compact only confirms the boundary. onComplete runs after
66
75
  // Pi clears compaction state; sending inside the hook starts too early.
67
76
  // A queued user prompt may already have started at compaction_end.
68
77
  if (request.completed && ctx.isIdle() && !ctx.hasPendingMessages()) {
69
- pi.sendMessage(options.continuation, { triggerTurn: true });
78
+ // The SDK detaches sendMessage, so own the next settled event before
79
+ // starting it. The originating agent_settled handler awaits wait.
80
+ if (request.continuationStarted) return;
81
+ request.continuationStarted = true;
82
+ try {
83
+ pi.sendMessage(options.continuation, { triggerTurn: true });
84
+ } catch (error) {
85
+ onError(error instanceof Error ? error : new Error(String(error)));
86
+ }
87
+ return;
70
88
  }
89
+ release(request);
71
90
  },
72
91
  onError,
73
92
  });
74
93
  } catch (error) {
75
94
  onError(error instanceof Error ? error : new Error(String(error)));
76
95
  }
96
+ return request;
97
+ };
98
+
99
+ // State is intentionally not resumed from a pending request: a loaded session must
100
+ // not execute work from a tool that belonged to a previous runtime or tree branch.
101
+ pi.on("session_start", () => { clear(); active = true; });
102
+ pi.on("session_shutdown", () => { clear(); active = false; });
103
+ pi.on("session_tree", clear);
104
+
105
+ pi.on("agent_end", (_event, ctx) => {
106
+ if (!active || !options.isEnabled()) return;
107
+ if (ctx.signal?.aborted) {
108
+ // Esc cancels the user's run. Do not reset or resurrect it at settled.
109
+ clear();
110
+ }
111
+ });
112
+
113
+ pi.on("agent_settled", (_event, ctx) => {
114
+ if (!active || !options.isEnabled() || !ctx.isIdle()) return;
115
+ if (state.phase === "compacting" && state.attempt.continuationStarted) {
116
+ const preceding = state.attempt;
117
+ if (!preceding.nextRequested) {
118
+ release(preceding);
119
+ return;
120
+ }
121
+ // This settled event belongs to the continuation started by preceding.
122
+ // If it requested another reset, retain preceding until that reset's own
123
+ // continuation settles. Its eventual nested handler only releases its own
124
+ // waiter, so it never awaits itself.
125
+ const next = begin(ctx);
126
+ return next.wait.then(() => release(preceding));
127
+ }
128
+ if (state.phase !== "requested") return;
129
+ // One owner for requested resets. Consume the request before any external call;
130
+ // repeated settled events and reentrant callbacks are harmless.
131
+ return begin(ctx).wait;
77
132
  });
78
133
 
79
134
  pi.on("session_before_compact", (event, ctx) => {
@@ -103,9 +158,15 @@ export function registerResetLifecycle(pi: ExtensionAPI, options: {
103
158
 
104
159
  return {
105
160
  request() {
106
- const pending = state.phase !== "idle";
107
- if (!pending) state = { phase: "requested" };
108
- return pending ? "rollover_already_pending" : "rollover_requested";
161
+ if (state.phase === "idle") {
162
+ state = { phase: "requested" };
163
+ return "rollover_requested";
164
+ }
165
+ if (state.phase === "compacting" && state.attempt.continuationStarted && !state.attempt.nextRequested) {
166
+ state.attempt.nextRequested = true;
167
+ return "rollover_requested";
168
+ }
169
+ return "rollover_already_pending";
109
170
  },
110
171
  clear,
111
172
  };
@@ -115,16 +115,14 @@ export function readCharacterWindow<T>(text: string, offsetChars: number | undef
115
115
  }
116
116
 
117
117
  /**
118
- * One-line bracketed header preceding a raw character-window payload: the identity, the
119
- * delivered char range, and either the resume cursor or `end`. `tail` appends extra
120
- * metadata (notes add their timestamps) inside the same brackets.
118
+ * Fixed metadata block preceding any raw character-window payload. Callers supply their
119
+ * source identity fields in wire order; range and continuation semantics are shared.
121
120
  */
122
- export function characterWindowHeader(identity: string, window: CharacterWindow, tail = ""): string {
123
- // The range end is offset + delivered count, never `total_chars`: a read resolved past the
124
- // end delivers zero characters there, and the header must not render an inverted range.
121
+ export function readWindowBlock(identity: ReadonlyArray<readonly [string, string]>, window: CharacterWindow): string {
125
122
  const end = window.offset_chars + Array.from(window.content).length;
126
- const resume = window.next_offset_chars === null ? "end" : `continue at offset_chars=${window.next_offset_chars}`;
127
- return `[${identity} · chars ${window.offset_chars}-${end} of ${window.total_chars} · ${resume}${tail}]`;
123
+ const next = window.next_offset_chars === null ? "null" : String(window.next_offset_chars);
124
+ const fields = identity.map(([name, value]) => `${name}: ${value}`).join("\n");
125
+ return `--- READ WINDOW ---\n${fields}\nchars: [${window.offset_chars},${end}) of ${window.total_chars}\nnext_offset_chars: ${next}\n`;
128
126
  }
129
127
 
130
128
  /**
@@ -183,9 +181,8 @@ export function output(value: unknown, details?: unknown, terminate = false) {
183
181
  }
184
182
 
185
183
  /**
186
- * Encode a prose payload as raw text: a one-line bracketed metadata header, then the payload
187
- * verbatim. The model reads the note or history item itself instead of a JSON envelope;
188
- * `details` carries the slim metadata object and never duplicates the payload.
184
+ * Encode a prose payload as raw text: metadata prefix, a blank line, then the payload
185
+ * verbatim. `details` carries the slim metadata object and never duplicates the payload.
189
186
  */
190
187
  export function outputRaw(header: string, content: string, details: unknown, terminate = false) {
191
188
  return { content: [{ type: "text" as const, text: `${header}\n${content}` }], details, terminate };