@linxiraos/pi-agent-core 1.0.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 (71) hide show
  1. package/CHANGELOG.md +1263 -0
  2. package/README.md +473 -0
  3. package/dist/types/agent-loop.d.ts +137 -0
  4. package/dist/types/agent.d.ts +465 -0
  5. package/dist/types/append-only-context.d.ts +131 -0
  6. package/dist/types/compaction/branch-summarization.d.ts +101 -0
  7. package/dist/types/compaction/compaction-v2-streaming.d.ts +85 -0
  8. package/dist/types/compaction/compaction.d.ts +307 -0
  9. package/dist/types/compaction/entries.d.ts +111 -0
  10. package/dist/types/compaction/errors.d.ts +38 -0
  11. package/dist/types/compaction/index.d.ts +13 -0
  12. package/dist/types/compaction/message-cache.d.ts +23 -0
  13. package/dist/types/compaction/messages.d.ts +79 -0
  14. package/dist/types/compaction/openai.d.ts +117 -0
  15. package/dist/types/compaction/pruning.d.ts +105 -0
  16. package/dist/types/compaction/shake.d.ts +97 -0
  17. package/dist/types/compaction/tool-protection.d.ts +19 -0
  18. package/dist/types/compaction/utils.d.ts +61 -0
  19. package/dist/types/compaction.d.ts +1 -0
  20. package/dist/types/index.d.ts +13 -0
  21. package/dist/types/pause.d.ts +46 -0
  22. package/dist/types/proxy.d.ts +91 -0
  23. package/dist/types/replay-policy.d.ts +5 -0
  24. package/dist/types/run-collector.d.ts +196 -0
  25. package/dist/types/telemetry.d.ts +608 -0
  26. package/dist/types/thinking.d.ts +18 -0
  27. package/dist/types/tokenizer.d.ts +2 -0
  28. package/dist/types/types.d.ts +781 -0
  29. package/dist/types/utils/yield.d.ts +71 -0
  30. package/package.json +79 -0
  31. package/src/agent-loop.ts +2926 -0
  32. package/src/agent.ts +1748 -0
  33. package/src/append-only-context.ts +348 -0
  34. package/src/compaction/branch-summarization.ts +369 -0
  35. package/src/compaction/compaction-v2-streaming.ts +846 -0
  36. package/src/compaction/compaction.ts +1733 -0
  37. package/src/compaction/entries.ts +141 -0
  38. package/src/compaction/errors.ts +47 -0
  39. package/src/compaction/index.ts +14 -0
  40. package/src/compaction/message-cache.ts +92 -0
  41. package/src/compaction/messages.ts +241 -0
  42. package/src/compaction/openai.ts +992 -0
  43. package/src/compaction/prompts/auto-handoff-threshold-focus.md +1 -0
  44. package/src/compaction/prompts/branch-summary-context.md +5 -0
  45. package/src/compaction/prompts/branch-summary-preamble.md +2 -0
  46. package/src/compaction/prompts/branch-summary.md +30 -0
  47. package/src/compaction/prompts/compaction-short-summary.md +9 -0
  48. package/src/compaction/prompts/compaction-summary-context.md +5 -0
  49. package/src/compaction/prompts/compaction-summary.md +38 -0
  50. package/src/compaction/prompts/compaction-turn-prefix.md +17 -0
  51. package/src/compaction/prompts/compaction-update-summary.md +45 -0
  52. package/src/compaction/prompts/context-window-truncated-output.md +1 -0
  53. package/src/compaction/prompts/file-operations.md +5 -0
  54. package/src/compaction/prompts/handoff-document.md +49 -0
  55. package/src/compaction/prompts/snapcompact-archive-context.md +3 -0
  56. package/src/compaction/prompts/summarization-system.md +3 -0
  57. package/src/compaction/pruning.ts +427 -0
  58. package/src/compaction/shake.ts +446 -0
  59. package/src/compaction/tool-protection.ts +65 -0
  60. package/src/compaction/utils.ts +343 -0
  61. package/src/compaction.ts +1 -0
  62. package/src/index.ts +26 -0
  63. package/src/pause.ts +107 -0
  64. package/src/proxy.ts +391 -0
  65. package/src/replay-policy.ts +13 -0
  66. package/src/run-collector.ts +631 -0
  67. package/src/telemetry.ts +2078 -0
  68. package/src/thinking.ts +20 -0
  69. package/src/tokenizer.ts +27 -0
  70. package/src/types.ts +875 -0
  71. package/src/utils/yield.ts +183 -0
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Shared utilities for compaction and branch summarization.
3
+ */
4
+
5
+ import type { Message, ToolCall } from "@linxiraos/pi-ai";
6
+ import { type Dialect, getDialectDefinition } from "@linxiraos/pi-ai/dialect";
7
+ import { escapeHarmonyControlTokens } from "@linxiraos/pi-ai/utils/harmony-leak";
8
+ import { formatGroupedPaths, prompt, stringifyJson } from "@linxiraos/pi-utils";
9
+ import type { AgentMessage } from "../types";
10
+ import fileOperationsTemplate from "./prompts/file-operations.md" with { type: "text" };
11
+ import summarizationSystemPrompt from "./prompts/summarization-system.md" with { type: "text" };
12
+
13
+ // ============================================================================
14
+ // File Operation Tracking
15
+ // ============================================================================
16
+
17
+ export interface FileOperations {
18
+ read: Set<string>;
19
+ written: Set<string>;
20
+ edited: Set<string>;
21
+ }
22
+
23
+ export function createFileOps(): FileOperations {
24
+ return {
25
+ read: new Set(),
26
+ written: new Set(),
27
+ edited: new Set(),
28
+ };
29
+ }
30
+
31
+ // Read-tool selector grammar, mirrored from the conservative filesystem splitter in
32
+ // packages/coding-agent/src/tools/path-utils.ts (splitPathAndSel). Keep in sync.
33
+ // A trailing `:chunk` is a selector only when it is a line-range list
34
+ // (`50`, `50-200`, `50+10`, `5-16,960-973`, `..` alias), `raw`, or `conflicts` —
35
+ // alone or as a `range:raw` / `raw:range` compound.
36
+ const RANGE_CHUNK_SRC = String.raw`L?\d+(?:(?:[-+]|\.\.)L?\d+|-|\.\.)?`;
37
+ const RANGE_LIST_SRC = `${RANGE_CHUNK_SRC}(?:,${RANGE_CHUNK_SRC})*`;
38
+ const READ_SELECTOR_RE = new RegExp(`^(?:${RANGE_LIST_SRC}|raw|conflicts)$`, "i");
39
+ const READ_RANGE_ONLY_RE = new RegExp(`^${RANGE_LIST_SRC}$`, "i");
40
+ const READ_RAW_ONLY_RE = /^raw$/i;
41
+
42
+ /**
43
+ * Split a read-tool path into its base path and trailing selector, mirroring the
44
+ * read tool's own splitter. Single source of the grammar in this package: the
45
+ * file-operations list strips selectors via {@link stripReadSelector}, and the
46
+ * supersede-prune pass keys on both parts via `readToolSupersedeKey`.
47
+ */
48
+ export function splitReadSelector(path: string): { path: string; sel?: string } {
49
+ const colon = path.lastIndexOf(":");
50
+ if (colon <= 0) return { path };
51
+ const candidate = path.slice(colon + 1);
52
+ if (!READ_SELECTOR_RE.test(candidate)) return { path };
53
+ let base = path.slice(0, colon);
54
+ let sel = candidate;
55
+ // Compound trailing selector: `path:1-50:raw` or `path:raw:1-50`.
56
+ const inner = base.lastIndexOf(":");
57
+ if (inner > 0) {
58
+ const innerCandidate = base.slice(inner + 1);
59
+ const innerIsRaw = READ_RAW_ONLY_RE.test(innerCandidate);
60
+ const outerIsRaw = READ_RAW_ONLY_RE.test(candidate);
61
+ const innerIsRange = READ_RANGE_ONLY_RE.test(innerCandidate);
62
+ const outerIsRange = READ_RANGE_ONLY_RE.test(candidate);
63
+ if ((innerIsRaw && outerIsRange) || (innerIsRange && outerIsRaw)) {
64
+ sel = `${innerCandidate}:${candidate}`;
65
+ base = base.slice(0, inner);
66
+ }
67
+ }
68
+ return { path: base, sel };
69
+ }
70
+
71
+ /**
72
+ * Strip a trailing read-tool selector (`:50-200`, `:raw`, `:1-50:raw`, `:conflicts`, …)
73
+ * so the same file read with different line ranges dedupes to one `<files>` entry
74
+ * and matches its write/edit path when computing Read/Write/RW markers.
75
+ */
76
+ export function stripReadSelector(path: string): string {
77
+ return splitReadSelector(path).path;
78
+ }
79
+
80
+ /**
81
+ * A real filesystem path never contains a `scheme://` URL. Tool-call paths that
82
+ * do — `conflict://1`, `artifact://3`, `local://ctx.md`, `history://…`,
83
+ * `issue://12`, `https://…`, and the tolerated `file.ts:conflict://1` prefix
84
+ * form — are session-scoped or remote resources, not files the post-compaction
85
+ * agent can re-ground on. Keep them out of the `<files>` summary.
86
+ */
87
+ const URL_SCHEME_RE = /[a-z][a-z0-9+.-]*:\/\//i;
88
+
89
+ /**
90
+ * Whether `path` references a `scheme://` URL (internal URI or web URL) rather
91
+ * than a filesystem path that belongs in the compaction `<files>` summary.
92
+ */
93
+ export function isUrlSchemePath(path: string): boolean {
94
+ return URL_SCHEME_RE.test(path);
95
+ }
96
+
97
+ /**
98
+ * Extract file operations from tool calls in an assistant message.
99
+ */
100
+ export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void {
101
+ if (message.role !== "assistant") return;
102
+ if (!("content" in message) || !Array.isArray(message.content)) return;
103
+
104
+ for (const block of message.content) {
105
+ if (typeof block !== "object" || block === null) continue;
106
+ if (!("type" in block) || block.type !== "toolCall") continue;
107
+ if (!("arguments" in block) || !("name" in block)) continue;
108
+
109
+ const args = block.arguments as Record<string, unknown> | undefined;
110
+ if (!args) continue;
111
+
112
+ const path = typeof args.path === "string" ? args.path : undefined;
113
+ if (!path) continue;
114
+
115
+ // Internal URIs (conflict://, artifact://, local://, history://, …) and
116
+ // web URLs are not re-groundable files — keep them out of `<files>`.
117
+ if (isUrlSchemePath(path)) continue;
118
+
119
+ switch (block.name) {
120
+ case "read":
121
+ fileOps.read.add(stripReadSelector(path));
122
+ break;
123
+ case "write":
124
+ fileOps.written.add(path);
125
+ break;
126
+ case "edit":
127
+ fileOps.edited.add(path);
128
+ break;
129
+ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Compute final file lists from file operations.
135
+ * Returns readFiles (files only read, not modified) and modifiedFiles.
136
+ */
137
+ export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } {
138
+ // Drop any `scheme://` URLs (e.g. legacy `conflict://`/`artifact://` entries
139
+ // rehydrated straight into `fileOps` from a pre-fix compaction summary) — only
140
+ // real files belong in `<files>`. New tool-call scans are already filtered.
141
+ const modified = new Set([...fileOps.edited, ...fileOps.written].filter(f => !isUrlSchemePath(f)));
142
+ const readOnly = [...fileOps.read].filter(f => !isUrlSchemePath(f) && !modified.has(f)).sort();
143
+ const modifiedFiles = [...modified].sort();
144
+ return { readFiles: readOnly, modifiedFiles };
145
+ }
146
+
147
+ /**
148
+ * Format file operations as one `<files>` tag: a grouped, prefix-folded
149
+ * directory tree (find-tool shape — `# dir/` headers, bare basenames) with a
150
+ * ` (Read)` / ` (Write)` / ` (RW)` marker per file instead of separate
151
+ * read/modified lists. `readSet` is the cumulative read set (`fileOps.read`),
152
+ * used to tell modified files that were also read (RW) from blind writes.
153
+ */
154
+ const FILE_OPERATION_SUMMARY_LIMIT = 20;
155
+
156
+ function stripFileOperationTags(summary: string): string {
157
+ // Legacy <read-files>/<modified-files> tags are still stripped so summaries
158
+ // written before the combined <files> tag self-heal on the next compaction.
159
+ return summary
160
+ .replace(/<files>[\s\S]*?<\/files>\s*/g, "")
161
+ .replace(/<read-files>[\s\S]*?<\/read-files>\s*/g, "")
162
+ .replace(/<modified-files>[\s\S]*?<\/modified-files>\s*/g, "")
163
+ .trimEnd();
164
+ }
165
+ export function formatFileOperations(
166
+ readFiles: string[],
167
+ modifiedFiles: string[],
168
+ readSet?: ReadonlySet<string>,
169
+ ): string {
170
+ if (readFiles.length === 0 && modifiedFiles.length === 0) return "";
171
+ const mode = new Map<string, "Read" | "Write" | "RW">();
172
+ for (const file of readFiles) mode.set(file, "Read");
173
+ for (const file of modifiedFiles) mode.set(file, readSet?.has(file) ? "RW" : "Write");
174
+ const all = [...mode.keys()].sort();
175
+ let files = formatGroupedPaths(all.slice(0, FILE_OPERATION_SUMMARY_LIMIT), path => ` (${mode.get(path)})`);
176
+ if (all.length > FILE_OPERATION_SUMMARY_LIMIT) {
177
+ files += `\n[…${all.length - FILE_OPERATION_SUMMARY_LIMIT} files elided…]`;
178
+ }
179
+ return prompt.render(fileOperationsTemplate, { files });
180
+ }
181
+
182
+ export function upsertFileOperations(
183
+ summary: string,
184
+ readFiles: string[],
185
+ modifiedFiles: string[],
186
+ readSet?: ReadonlySet<string>,
187
+ ): string {
188
+ const baseSummary = stripFileOperationTags(summary);
189
+ const fileOperations = formatFileOperations(readFiles, modifiedFiles, readSet);
190
+ if (!fileOperations) return baseSummary;
191
+ if (!baseSummary) return fileOperations;
192
+ return `${baseSummary}\n\n${fileOperations}`;
193
+ }
194
+
195
+ // ============================================================================
196
+ // Message Serialization
197
+ // ============================================================================
198
+
199
+ /** Maximum characters for a tool result in serialized summaries. */
200
+ const TOOL_RESULT_MAX_CHARS = 2000;
201
+
202
+ /**
203
+ * Truncate tool results to the same representation used in summarization prompts.
204
+ */
205
+ export function truncateToolResultForSummary(text: string): string {
206
+ if (text.length <= TOOL_RESULT_MAX_CHARS) return text;
207
+ const truncatedChars = text.length - TOOL_RESULT_MAX_CHARS;
208
+ return `${text.slice(0, TOOL_RESULT_MAX_CHARS)}\n\n[... ${truncatedChars} more characters truncated]`;
209
+ }
210
+
211
+ /**
212
+ * Serialize LLM messages as plain summary input without provider control tokens.
213
+ */
214
+ export function serializeConversationForSummary(messages: Message[], dialect?: Dialect): string {
215
+ const conversation = serializeConversation(messages, dialect);
216
+ if (dialect !== "harmony") return conversation;
217
+ return escapeHarmonyControlTokens(conversation);
218
+ }
219
+
220
+ /**
221
+ * Serialize LLM messages to transcript text.
222
+ * Call convertToLlm() first to handle custom message types.
223
+ */
224
+ export function serializeConversation(messages: Message[], dialect?: Dialect): string {
225
+ // Tool results flagged contextually useless (and their paired calls) are
226
+ // dropped from the serialized text: the source region is discarded after
227
+ // summarization anyway, so excluding them costs nothing and keeps garbage
228
+ // out of the summary input.
229
+ const uselessCallIds = new Set<string>();
230
+ for (const msg of messages) {
231
+ if (msg.role === "toolResult" && msg.useless === true && msg.isError !== true) {
232
+ uselessCallIds.add(msg.toolCallId);
233
+ }
234
+ }
235
+ if (dialect) {
236
+ // Claude's classifier refuses inputs that reproduce the model's own
237
+ // reasoning as text ("reasoning_extraction"), and the anthropic dialect
238
+ // otherwise renders thinking verbatim inside <thinking> tags. Reasoning is
239
+ // ephemeral and low-signal for a summary, so drop it from Anthropic-target
240
+ // summary input. Other dialects (e.g. Harmony) carry reasoning natively in
241
+ // their transcript format and keep it.
242
+ const dropThinking = dialect === "anthropic";
243
+ const processed: Message[] = [];
244
+ for (const msg of messages) {
245
+ if (msg.role === "assistant") {
246
+ const content = msg.content.filter(
247
+ block =>
248
+ (block.type !== "toolCall" || !uselessCallIds.has(block.id)) &&
249
+ (!dropThinking || block.type !== "thinking"),
250
+ );
251
+ if (content.length > 0) processed.push(content.length === msg.content.length ? msg : { ...msg, content });
252
+ continue;
253
+ }
254
+ if (msg.role === "toolResult") {
255
+ if (uselessCallIds.has(msg.toolCallId)) continue;
256
+ const text = msg.content
257
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
258
+ .map(c => c.text)
259
+ .join("");
260
+ if (!text) continue;
261
+ processed.push({
262
+ ...msg,
263
+ content: [{ type: "text", text: truncateToolResultForSummary(text) }],
264
+ });
265
+ continue;
266
+ }
267
+ processed.push(msg);
268
+ }
269
+ return getDialectDefinition(dialect).renderTranscript(processed);
270
+ }
271
+
272
+ const parts: string[] = [];
273
+ for (const msg of messages) {
274
+ if (msg.role === "user") {
275
+ const content =
276
+ typeof msg.content === "string"
277
+ ? msg.content
278
+ : msg.content
279
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
280
+ .map(c => c.text)
281
+ .join("");
282
+ if (content) parts.push(`[User]: ${content}`);
283
+ } else if (msg.role === "assistant") {
284
+ const textParts: string[] = [];
285
+ const thinkingParts: string[] = [];
286
+ const toolCalls: ToolCall[] = [];
287
+
288
+ for (const block of msg.content) {
289
+ if (block.type === "text") {
290
+ textParts.push(block.text);
291
+ } else if (block.type === "thinking") {
292
+ thinkingParts.push(block.thinking);
293
+ } else if (block.type === "toolCall") {
294
+ if (uselessCallIds.has(block.id)) continue;
295
+ toolCalls.push(block);
296
+ }
297
+ }
298
+
299
+ if (thinkingParts.length > 0) {
300
+ parts.push(`[Think]: ${thinkingParts.join("\n")}`);
301
+ }
302
+ if (textParts.length > 0) {
303
+ parts.push(`[Assistant]: ${textParts.join("\n")}`);
304
+ }
305
+ if (toolCalls.length > 0) {
306
+ parts.push(`[Tool Call]: ${renderToolCalls(toolCalls)}`);
307
+ }
308
+ } else if (msg.role === "toolResult") {
309
+ if (uselessCallIds.has(msg.toolCallId)) continue;
310
+ const content = msg.content
311
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
312
+ .map(c => c.text)
313
+ .join("");
314
+ if (content) {
315
+ const text = truncateToolResultForSummary(content);
316
+ parts.push(`[Tool Result]: ${text}`);
317
+ }
318
+ }
319
+ }
320
+
321
+ return parts.join("\n\n");
322
+ }
323
+
324
+ /**
325
+ * Render an assistant turn's tool calls as a compact `name(args)` list for the
326
+ * legacy serializer.
327
+ */
328
+ function renderToolCalls(calls: ToolCall[]): string {
329
+ return calls
330
+ .map(call => {
331
+ const argsStr = Object.entries(call.arguments as Record<string, unknown>)
332
+ .map(([k, v]) => `${k}=${stringifyJson(v) ?? "null"}`)
333
+ .join(", ");
334
+ return `${call.name}(${argsStr})`;
335
+ })
336
+ .join("; ");
337
+ }
338
+
339
+ // ============================================================================
340
+ // Summarization System Prompt
341
+ // ============================================================================
342
+
343
+ export const SUMMARIZATION_SYSTEM_PROMPT = prompt.render(summarizationSystemPrompt);
@@ -0,0 +1 @@
1
+ export * from "./compaction/index";
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ // Core Agent
2
+ export * from "./agent";
3
+ // Loop functions
4
+ export * from "./agent-loop";
5
+ // Append-only context mode
6
+ export * from "./append-only-context";
7
+ // Compaction
8
+ export * from "./compaction";
9
+ // Process-global pause gate
10
+ export * from "./pause";
11
+ // Proxy utilities
12
+ export * from "./proxy";
13
+ // Replay policy
14
+ export * from "./replay-policy";
15
+ // Run-level telemetry collector + aggregators
16
+ export * from "./run-collector";
17
+ // Telemetry
18
+ export * from "./telemetry";
19
+ // Thinking selectors
20
+ export * from "./thinking";
21
+ // Tokenizer choice
22
+ export * from "./tokenizer";
23
+ // Types
24
+ export * from "./types";
25
+ // Yield utilities for Bun event-loop busy-wait prevention
26
+ export * from "./utils/yield";
package/src/pause.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Process-global pause gate for agent loops.
3
+ *
4
+ * Every agent in a process — main session, in-process subagents, advisor —
5
+ * funnels through {@link ../agent-loop!agentLoop}, which polls this gate at its
6
+ * two action boundaries: before each model call and before each tool call
7
+ * starts. Engaging the gate therefore freezes all of them at the next safe
8
+ * point without aborting anything: in-flight provider streams and
9
+ * already-started tool executions run to completion, then every loop parks
10
+ * until {@link AgentPauseGate.resume}. Queued steering/follow-up messages stay
11
+ * queued and deliver normally after resume.
12
+ *
13
+ * A run's own `AbortSignal` still unwinds a parked loop immediately: the park
14
+ * releases on abort (without releasing the gate), so cancelling one run never
15
+ * requires resuming the whole process.
16
+ *
17
+ * Hosts drive the singleton {@link agentPauseGate} (e.g. the TUI `/pause`
18
+ * command); library code only ever reads it.
19
+ */
20
+
21
+ /** Listener invoked with the new state on every pause/resume transition. */
22
+ export type AgentPauseListener = (paused: boolean) => void;
23
+
24
+ /** Freeze switch shared by every agent loop in the process. See module docs. */
25
+ export class AgentPauseGate {
26
+ /** Pending while paused; resolved and cleared on resume. */
27
+ #gate: PromiseWithResolvers<void> | undefined;
28
+ #pausedAt = 0;
29
+ #listeners = new Set<AgentPauseListener>();
30
+
31
+ /** True while the gate is engaged. */
32
+ get paused(): boolean {
33
+ return this.#gate !== undefined;
34
+ }
35
+
36
+ /** Epoch ms when the current pause began; undefined when running. */
37
+ get pausedAt(): number | undefined {
38
+ return this.#gate ? this.#pausedAt : undefined;
39
+ }
40
+
41
+ /** Engage the gate. Returns false (and does nothing) when already paused. */
42
+ pause(): boolean {
43
+ if (this.#gate) return false;
44
+ this.#gate = Promise.withResolvers<void>();
45
+ this.#pausedAt = Date.now();
46
+ this.#notify(true);
47
+ return true;
48
+ }
49
+
50
+ /**
51
+ * Release the gate, waking every parked loop. Returns the pause duration in
52
+ * ms, or undefined when the gate was not engaged.
53
+ */
54
+ resume(): number | undefined {
55
+ const gate = this.#gate;
56
+ if (!gate) return undefined;
57
+ this.#gate = undefined;
58
+ gate.resolve();
59
+ this.#notify(false);
60
+ return Date.now() - this.#pausedAt;
61
+ }
62
+
63
+ /** Subscribe to pause/resume transitions. Returns an unsubscribe function. */
64
+ onChange(listener: AgentPauseListener): () => void {
65
+ this.#listeners.add(listener);
66
+ return () => this.#listeners.delete(listener);
67
+ }
68
+
69
+ /**
70
+ * Park until the gate is released. Resolves immediately when not paused.
71
+ * An abort on `signal` releases only this wait — the gate stays engaged —
72
+ * so a cancelled run unwinds while the rest of the process stays frozen.
73
+ */
74
+ async waitUntilResumed(signal?: AbortSignal): Promise<void> {
75
+ // Loop: resume() swaps the gate promise, so a pause re-engaged while a
76
+ // waiter is between awaits must re-park instead of slipping through.
77
+ while (this.#gate) {
78
+ if (signal?.aborted) return;
79
+ const gate = this.#gate.promise;
80
+ if (!signal) {
81
+ await gate;
82
+ continue;
83
+ }
84
+ const abort = Promise.withResolvers<void>();
85
+ const onAbort = () => abort.resolve();
86
+ signal.addEventListener("abort", onAbort, { once: true });
87
+ try {
88
+ await Promise.race([gate, abort.promise]);
89
+ } finally {
90
+ signal.removeEventListener("abort", onAbort);
91
+ }
92
+ }
93
+ }
94
+
95
+ #notify(paused: boolean): void {
96
+ for (const listener of this.#listeners) {
97
+ try {
98
+ listener(paused);
99
+ } catch {
100
+ // Host UI listeners must never break the gate.
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ /** The process-wide gate polled by the agent loop. */
107
+ export const agentPauseGate = new AgentPauseGate();