@oh-my-pi/pi-agent-core 16.4.3 → 16.4.5

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/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.4.5] - 2026-07-11
6
+
7
+ ### Added
8
+
9
+ - Added a process-global pause gate (`agentPauseGate`) to safely pause agent loops before model calls or tool executions, allowing them to be resumed later or aborted cleanly.
10
+
5
11
  ## [16.4.3] - 2026-07-11
6
12
 
7
13
  ### Fixed
@@ -2,6 +2,7 @@ export * from "./agent.js";
2
2
  export * from "./agent-loop.js";
3
3
  export * from "./append-only-context.js";
4
4
  export * from "./compaction.js";
5
+ export * from "./pause.js";
5
6
  export * from "./proxy.js";
6
7
  export * from "./replay-policy.js";
7
8
  export * from "./run-collector.js";
@@ -0,0 +1,46 @@
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
+ /** Listener invoked with the new state on every pause/resume transition. */
21
+ export type AgentPauseListener = (paused: boolean) => void;
22
+ /** Freeze switch shared by every agent loop in the process. See module docs. */
23
+ export declare class AgentPauseGate {
24
+ #private;
25
+ /** True while the gate is engaged. */
26
+ get paused(): boolean;
27
+ /** Epoch ms when the current pause began; undefined when running. */
28
+ get pausedAt(): number | undefined;
29
+ /** Engage the gate. Returns false (and does nothing) when already paused. */
30
+ pause(): boolean;
31
+ /**
32
+ * Release the gate, waking every parked loop. Returns the pause duration in
33
+ * ms, or undefined when the gate was not engaged.
34
+ */
35
+ resume(): number | undefined;
36
+ /** Subscribe to pause/resume transitions. Returns an unsubscribe function. */
37
+ onChange(listener: AgentPauseListener): () => void;
38
+ /**
39
+ * Park until the gate is released. Resolves immediately when not paused.
40
+ * An abort on `signal` releases only this wait — the gate stays engaged —
41
+ * so a cancelled run unwinds while the rest of the process stays frozen.
42
+ */
43
+ waitUntilResumed(signal?: AbortSignal): Promise<void>;
44
+ }
45
+ /** The process-wide gate polled by the agent loop. */
46
+ export declare const agentPauseGate: AgentPauseGate;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-agent-core",
4
- "version": "16.4.3",
4
+ "version": "16.4.5",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -35,12 +35,12 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "16.4.3",
39
- "@oh-my-pi/pi-catalog": "16.4.3",
40
- "@oh-my-pi/pi-natives": "16.4.3",
41
- "@oh-my-pi/pi-utils": "16.4.3",
42
- "@oh-my-pi/pi-wire": "16.4.3",
43
- "@oh-my-pi/snapcompact": "16.4.3",
38
+ "@oh-my-pi/pi-ai": "16.4.5",
39
+ "@oh-my-pi/pi-catalog": "16.4.5",
40
+ "@oh-my-pi/pi-natives": "16.4.5",
41
+ "@oh-my-pi/pi-utils": "16.4.5",
42
+ "@oh-my-pi/pi-wire": "16.4.5",
43
+ "@oh-my-pi/snapcompact": "16.4.5",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
41
41
  import { sanitizeText, structuredCloneJSON } from "@oh-my-pi/pi-utils";
42
42
  import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
43
+ import { agentPauseGate } from "./pause";
43
44
  import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
44
45
  import {
45
46
  type AgentTelemetry,
@@ -816,6 +817,10 @@ async function runLoopBody(
816
817
  // Yield at the top of each iteration to prevent busy-wait when
817
818
  // the agent loop is executing tool calls back-to-back.
818
819
  await yieldIfDue();
820
+ // Park at the turn boundary while the process-wide pause gate is
821
+ // engaged (host /pause). An external abort releases the park so a
822
+ // cancelled run still unwinds while everything else stays frozen.
823
+ if (agentPauseGate.paused) await agentPauseGate.waitUntilResumed(signal);
819
824
  if (!firstTurn) {
820
825
  stream.push({ type: "turn_start" });
821
826
  } else {
@@ -1922,6 +1927,10 @@ async function executeToolCalls(
1922
1927
  record.skipped = true;
1923
1928
  return;
1924
1929
  }
1930
+ // Park before starting this tool while the process-wide pause gate is
1931
+ // engaged. Tools already executing are unaffected (pausing never aborts);
1932
+ // a batch interrupted mid-pause unwinds via the signal checks below.
1933
+ if (agentPauseGate.paused) await agentPauseGate.waitUntilResumed(record.signal);
1925
1934
 
1926
1935
  const { toolCall, tool } = record;
1927
1936
  let argsForExecution = toolCall.arguments as Record<string, unknown>;
package/src/index.ts CHANGED
@@ -6,6 +6,8 @@ export * from "./agent-loop";
6
6
  export * from "./append-only-context";
7
7
  // Compaction
8
8
  export * from "./compaction";
9
+ // Process-global pause gate
10
+ export * from "./pause";
9
11
  // Proxy utilities
10
12
  export * from "./proxy";
11
13
  // Replay policy
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();