@oh-my-pi/pi-utils 18.12.4 → 18.12.6

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
+ ## [18.12.6] - 2026-10-21
6
+
7
+ ### Added
8
+
9
+ - Added `RpcConnection.requestCancellable()` and `Client.requestPermission(params, { signal })`; client connections abort a handler and answer `-32800` when the peer sends `$/cancel_request`, while agent connections keep running the request and return its real result ([#15885](https://github.com/can1357/oh-my-pi/pull/15885) by [@marcintustin](https://github.com/marcintustin))
10
+
5
11
  ## [18.12.4] - 2026-10-20
6
12
 
7
13
  ### Breaking Changes
@@ -7,7 +7,15 @@ export interface CreateTerminalResponse {
7
7
  }
8
8
  /** Client callbacks handled by a client-side connection. */
9
9
  export interface Client {
10
- requestPermission(params: RequestPermissionRequest): MaybePromise<RequestPermissionResponse>;
10
+ /**
11
+ * Serve a permission elicit. `options.signal` aborts when the agent
12
+ * cancels the request (`$/cancel_request`) — e.g. user steering superseded
13
+ * the dialog — so a held handler can end instead of stranding the
14
+ * connection's clean-EOF drain.
15
+ */
16
+ requestPermission(params: RequestPermissionRequest, options?: {
17
+ signal?: AbortSignal;
18
+ }): MaybePromise<RequestPermissionResponse>;
11
19
  sessionUpdate(params: SessionNotification): MaybePromise<void>;
12
20
  readTextFile?(params: ReadTextFileRequest): MaybePromise<ReadTextFileResponse>;
13
21
  writeTextFile?(params: WriteTextFileRequest): MaybePromise<WriteTextFileResponse | void>;
@@ -45,6 +53,15 @@ export declare class AgentSideConnection {
45
53
  sessionUpdate(params: SessionNotification): Promise<void>;
46
54
  /** Requests permission from the client. */
47
55
  requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse>;
56
+ /**
57
+ * Requests permission from the client, returning a handle that cancels the
58
+ * pending wire request (`$/cancel_request`) — used when user steering
59
+ * supersedes an open permission dialog instead of waiting behind it.
60
+ */
61
+ requestPermissionWithCancel(params: RequestPermissionRequest): {
62
+ promise: Promise<RequestPermissionResponse>;
63
+ cancel(): void;
64
+ };
48
65
  /** Reads a client-side text file. */
49
66
  readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse>;
50
67
  /** Writes a client-side text file. */
@@ -69,17 +69,41 @@ export declare class RequestError extends Error {
69
69
  /** Converts this error into a JSON-RPC error payload. */
70
70
  toErrorResponse(): ErrorResponse;
71
71
  }
72
- type Dispatcher = (method: string, params: unknown, notification: boolean) => MaybePromise<unknown>;
72
+ type Dispatcher = (method: string, params: unknown, notification: boolean,
73
+ /** Abort signal for an inbound *request*: fires when the peer cancels it via `$/cancel_request`. */
74
+ signal?: AbortSignal) => MaybePromise<unknown>;
75
+ /** Options for {@link RpcConnection}. */
76
+ export interface RpcConnectionOptions {
77
+ /**
78
+ * The dispatcher honors the abort signal it receives for inbound requests.
79
+ * Only then does a peer `$/cancel_request` abort the handler and answer the
80
+ * request `-32800` immediately; otherwise the handler runs to completion and
81
+ * its real result is the response, since answering "cancelled" while the
82
+ * work keeps going would hide its outcome from the peer.
83
+ */
84
+ honorsInboundCancellation?: boolean;
85
+ }
73
86
  /** Correlated bidirectional JSON-RPC connection. */
74
87
  export declare class RpcConnection {
75
88
  #private;
76
- constructor(stream: Stream, dispatcher: Dispatcher);
89
+ constructor(stream: Stream, dispatcher: Dispatcher, options?: RpcConnectionOptions);
77
90
  /** Signal aborted when the stream closes. */
78
91
  get signal(): AbortSignal;
79
92
  /** Promise resolved when the stream closes. */
80
93
  get closed(): Promise<void>;
81
94
  /** Sends a correlated JSON-RPC request. */
82
95
  request<Response>(method: string, params?: unknown): Promise<Response>;
96
+ /**
97
+ * Sends a JSON-RPC request whose pending entry can be cancelled locally:
98
+ * `cancel()` rejects the promise with a request-cancelled error, notifies
99
+ * the peer via `$/cancel_request`, and drops the pending entry so a late
100
+ * response is ignored. Used for agent→client requests a mid-turn event can
101
+ * supersede (e.g. a permission elicit cancelled when user steering arrives).
102
+ */
103
+ requestCancellable<Response>(method: string, params?: unknown): {
104
+ promise: Promise<Response>;
105
+ cancel(): void;
106
+ };
83
107
  /** Sends a JSON-RPC notification. */
84
108
  notify(method: string, params?: unknown): Promise<void>;
85
109
  /** Closes the connection and rejects outstanding requests. */
@@ -2,7 +2,8 @@
2
2
  * `fs.openSync` with close-on-exec, matching Node's default.
3
3
  *
4
4
  * libuv adds `O_CLOEXEC` inside `uv__fs_open`, so on Node every `fs` descriptor
5
- * dies at `exec`. Bun's `fs.open*` does not, and descriptors it returns survive
5
+ * dies at `exec`. Bun's `fs.open*` did not before 1.4.3 (still allowed by the
6
+ * `bun@>=1.4` pin), and descriptors it returns survive
6
7
  * into any child spawned through a path that does not close strays itself — the
7
8
  * bash tool's shell `fork`/`exec`s user commands and hands them the lot. Use
8
9
  * this for every descriptor kept open past such a spawn (session transcripts,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-utils",
3
- "version": "18.12.4",
3
+ "version": "18.12.6",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "keywords": [
6
6
  "cli",
@@ -57,7 +57,7 @@
57
57
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
58
58
  },
59
59
  "dependencies": {
60
- "@oh-my-pi/pi-natives": "18.12.4"
60
+ "@oh-my-pi/pi-natives": "18.12.6"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/bun": "^1.3.14"
@@ -47,7 +47,16 @@ export interface CreateTerminalResponse {
47
47
  }
48
48
  /** Client callbacks handled by a client-side connection. */
49
49
  export interface Client {
50
- requestPermission(params: RequestPermissionRequest): MaybePromise<RequestPermissionResponse>;
50
+ /**
51
+ * Serve a permission elicit. `options.signal` aborts when the agent
52
+ * cancels the request (`$/cancel_request`) — e.g. user steering superseded
53
+ * the dialog — so a held handler can end instead of stranding the
54
+ * connection's clean-EOF drain.
55
+ */
56
+ requestPermission(
57
+ params: RequestPermissionRequest,
58
+ options?: { signal?: AbortSignal },
59
+ ): MaybePromise<RequestPermissionResponse>;
51
60
  sessionUpdate(params: SessionNotification): MaybePromise<void>;
52
61
  readTextFile?(params: ReadTextFileRequest): MaybePromise<ReadTextFileResponse>;
53
62
  writeTextFile?(params: WriteTextFileRequest): MaybePromise<WriteTextFileResponse | void>;
@@ -90,6 +99,17 @@ export class AgentSideConnection {
90
99
  requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
91
100
  return this.#connection.request("session/request_permission", params);
92
101
  }
102
+ /**
103
+ * Requests permission from the client, returning a handle that cancels the
104
+ * pending wire request (`$/cancel_request`) — used when user steering
105
+ * supersedes an open permission dialog instead of waiting behind it.
106
+ */
107
+ requestPermissionWithCancel(params: RequestPermissionRequest): {
108
+ promise: Promise<RequestPermissionResponse>;
109
+ cancel(): void;
110
+ } {
111
+ return this.#connection.requestCancellable<RequestPermissionResponse>("session/request_permission", params);
112
+ }
93
113
  /** Reads a client-side text file. */
94
114
  readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
95
115
  return this.#connection.request("fs/read_text_file", params);
@@ -168,10 +188,18 @@ export class ClientSideConnection {
168
188
  #connection: RpcConnection;
169
189
 
170
190
  constructor(toClient: (connection: ClientSideConnection) => Client, stream: Stream) {
171
- this.#connection = new RpcConnection(stream, async (method, params, notification) => {
172
- if (!client) throw RequestError.internalError(undefined, "Client is not initialized");
173
- return dispatchClient(client, method, params, notification);
174
- });
191
+ // Client handlers receive the cancel signal (`requestPermission`'s
192
+ // `options.signal`), so a peer `$/cancel_request` may answer `-32800`
193
+ // at once. The agent side keeps the default: its handlers ignore the
194
+ // signal, and `session/cancel` is ACP's cancellation path for prompts.
195
+ this.#connection = new RpcConnection(
196
+ stream,
197
+ async (method, params, notification, signal) => {
198
+ if (!client) throw RequestError.internalError(undefined, "Client is not initialized");
199
+ return dispatchClient(client, method, params, notification, signal);
200
+ },
201
+ { honorsInboundCancellation: true },
202
+ );
175
203
  const client = toClient(this);
176
204
  }
177
205
  /** Signal aborted when the transport closes. */
@@ -272,10 +300,11 @@ async function dispatchClient(
272
300
  method: string,
273
301
  params: unknown,
274
302
  notification: boolean,
303
+ signal?: AbortSignal,
275
304
  ): Promise<unknown> {
276
305
  switch (method) {
277
306
  case "session/request_permission":
278
- return client.requestPermission(params as RequestPermissionRequest);
307
+ return client.requestPermission(params as RequestPermissionRequest, { signal });
279
308
  case "session/update":
280
309
  return client.sessionUpdate(params as SessionNotification);
281
310
  case "fs/read_text_file":
@@ -322,6 +351,9 @@ async function dispatchClient(
322
351
  params as CompleteElicitationNotification,
323
352
  );
324
353
  default:
354
+ // The transport already aborted the matching inbound handler;
355
+ // like the agent side, the notification itself carries no work.
356
+ if (method === "$/cancel_request") return;
325
357
  if (notification && client.extNotification)
326
358
  return client.extNotification(method, params as Record<string, unknown>);
327
359
  if (!notification && client.extMethod) return client.extMethod(method, params as Record<string, unknown>);
@@ -105,9 +105,27 @@ function createStandardError(
105
105
  return new RequestError(code, additionalMessage ? `${message}: ${additionalMessage}` : message, data);
106
106
  }
107
107
 
108
- type Dispatcher = (method: string, params: unknown, notification: boolean) => MaybePromise<unknown>;
108
+ type Dispatcher = (
109
+ method: string,
110
+ params: unknown,
111
+ notification: boolean,
112
+ /** Abort signal for an inbound *request*: fires when the peer cancels it via `$/cancel_request`. */
113
+ signal?: AbortSignal,
114
+ ) => MaybePromise<unknown>;
109
115
  type Pending = { resolve(value: unknown): void; reject(reason: unknown): void };
110
116
 
117
+ /** Options for {@link RpcConnection}. */
118
+ export interface RpcConnectionOptions {
119
+ /**
120
+ * The dispatcher honors the abort signal it receives for inbound requests.
121
+ * Only then does a peer `$/cancel_request` abort the handler and answer the
122
+ * request `-32800` immediately; otherwise the handler runs to completion and
123
+ * its real result is the response, since answering "cancelled" while the
124
+ * work keeps going would hide its outcome from the peer.
125
+ */
126
+ honorsInboundCancellation?: boolean;
127
+ }
128
+
111
129
  /** Bound on clean-EOF inbound drain so `closed` cannot hang if a handler never settles. */
112
130
  const INBOUND_DRAIN_TIMEOUT_MS = 30_000;
113
131
 
@@ -117,15 +135,18 @@ export class RpcConnection {
117
135
  #pending = new Map<JsonRpcId, Pending>();
118
136
  #inbound = new Set<Promise<void>>();
119
137
  #openInboundIds = new Set<JsonRpcId>();
138
+ /** Abort controller per inbound request, fired by a peer `$/cancel_request` (opt-in). */
139
+ #inboundCancellers: Map<JsonRpcId, AbortController> | undefined;
120
140
  #writable: WritableStream<AnyMessage>;
121
141
  #writeTail: Promise<void> = Promise.resolve();
122
142
  #abort = new AbortController();
123
143
  #closed = Promise.withResolvers<void>();
124
144
  #dispatcher: Dispatcher;
125
145
 
126
- constructor(stream: Stream, dispatcher: Dispatcher) {
146
+ constructor(stream: Stream, dispatcher: Dispatcher, options?: RpcConnectionOptions) {
127
147
  this.#writable = stream.writable;
128
148
  this.#dispatcher = dispatcher;
149
+ this.#inboundCancellers = options?.honorsInboundCancellation ? new Map() : undefined;
129
150
  void this.#read(stream.readable);
130
151
  }
131
152
 
@@ -141,6 +162,48 @@ export class RpcConnection {
141
162
  /** Sends a correlated JSON-RPC request. */
142
163
  request<Response>(method: string, params?: unknown): Promise<Response> {
143
164
  if (this.signal.aborted) return Promise.reject(new Error("Connection closed"));
165
+ return this.#beginRequest(method, params).deferred.promise as Promise<Response>;
166
+ }
167
+
168
+ /**
169
+ * Sends a JSON-RPC request whose pending entry can be cancelled locally:
170
+ * `cancel()` rejects the promise with a request-cancelled error, notifies
171
+ * the peer via `$/cancel_request`, and drops the pending entry so a late
172
+ * response is ignored. Used for agent→client requests a mid-turn event can
173
+ * supersede (e.g. a permission elicit cancelled when user steering arrives).
174
+ */
175
+ requestCancellable<Response>(
176
+ method: string,
177
+ params?: unknown,
178
+ ): {
179
+ promise: Promise<Response>;
180
+ cancel(): void;
181
+ } {
182
+ if (this.signal.aborted) {
183
+ return {
184
+ promise: Promise.reject(new Error("Connection closed")),
185
+ cancel: () => {},
186
+ };
187
+ }
188
+ const { id, deferred } = this.#beginRequest(method, params);
189
+ return {
190
+ promise: deferred.promise as Promise<Response>,
191
+ cancel: () => {
192
+ if (!this.#pending.delete(id)) return;
193
+ deferred.reject(RequestError.requestCancelled({ requestId: id, method }));
194
+ void this.notify("$/cancel_request", { requestId: id }).catch(() => undefined);
195
+ },
196
+ };
197
+ }
198
+
199
+ /** Shared request plumbing: allocate an id, register the pending entry, write the frame. */
200
+ #beginRequest(
201
+ method: string,
202
+ params: unknown,
203
+ ): {
204
+ id: number;
205
+ deferred: { promise: Promise<unknown>; resolve: (value?: unknown) => void; reject: (reason?: unknown) => void };
206
+ } {
144
207
  const id = this.#nextId++;
145
208
  const deferred = Promise.withResolvers<unknown>();
146
209
  this.#pending.set(id, deferred);
@@ -148,7 +211,7 @@ export class RpcConnection {
148
211
  this.#pending.delete(id);
149
212
  deferred.reject(error);
150
213
  });
151
- return deferred.promise as Promise<Response>;
214
+ return { id, deferred };
152
215
  }
153
216
 
154
217
  /** Sends a JSON-RPC notification. */
@@ -235,6 +298,18 @@ export class RpcConnection {
235
298
  }
236
299
  if (!("method" in message)) return;
237
300
  if (!("id" in message)) {
301
+ // ACP v1 names the cancelled request `requestId`. A cancelled
302
+ // inbound request must still be answered (spec: result or
303
+ // `-32800`): the handler is aborted and the error response is
304
+ // written here, so a late-settling handler lands in `#respond`'s
305
+ // closed-id no-op instead of answering a request the peer gave
306
+ // up on.
307
+ if (message.method === "$/cancel_request" && this.#inboundCancellers) {
308
+ this.#cancelInbound(
309
+ this.#inboundCancellers,
310
+ (message.params as { requestId?: JsonRpcId } | undefined)?.requestId,
311
+ );
312
+ }
238
313
  try {
239
314
  await this.#dispatcher(message.method, message.params, true);
240
315
  } catch {
@@ -242,8 +317,11 @@ export class RpcConnection {
242
317
  }
243
318
  return;
244
319
  }
320
+ const cancellers = this.#inboundCancellers;
321
+ const canceller = cancellers ? new AbortController() : undefined;
322
+ if (canceller) cancellers?.set(message.id, canceller);
245
323
  try {
246
- const result = await this.#dispatcher(message.method, message.params, false);
324
+ const result = await this.#dispatcher(message.method, message.params, false, canceller?.signal);
247
325
  await this.#respond(message.id, { result: result ?? {} });
248
326
  } catch (error) {
249
327
  const protocolError =
@@ -251,6 +329,25 @@ export class RpcConnection {
251
329
  ? error
252
330
  : RequestError.internalError({ details: error instanceof Error ? error.message : String(error) });
253
331
  await this.#respond(message.id, { error: protocolError.toErrorResponse() });
332
+ } finally {
333
+ cancellers?.delete(message.id);
254
334
  }
255
335
  }
336
+
337
+ #cancelInbound(cancellers: Map<JsonRpcId, AbortController>, id: JsonRpcId | undefined): void {
338
+ if (id === undefined) return;
339
+ const canceller = cancellers.get(id);
340
+ if (canceller) {
341
+ cancellers.delete(id);
342
+ canceller.abort(RequestError.requestCancelled({ requestId: id }));
343
+ }
344
+ // Spec: a cancelled request still gets a response. Answer `-32800`
345
+ // here — synchronously with the notification — so the peer is never
346
+ // left waiting on a handler that may never settle. `#respond` closes
347
+ // the id, so a late handler result (a handler that ignored its abort
348
+ // signal) lands in the closed-id no-op instead of answering twice.
349
+ void this.#respond(id, { error: RequestError.requestCancelled({ requestId: id }).toErrorResponse() }).catch(
350
+ () => undefined,
351
+ );
352
+ }
256
353
  }
package/src/fs-open.ts CHANGED
@@ -15,7 +15,8 @@ const O_CLOEXEC = process.platform === "linux" ? 0o2000000 : process.platform ==
15
15
  * `fs.openSync` with close-on-exec, matching Node's default.
16
16
  *
17
17
  * libuv adds `O_CLOEXEC` inside `uv__fs_open`, so on Node every `fs` descriptor
18
- * dies at `exec`. Bun's `fs.open*` does not, and descriptors it returns survive
18
+ * dies at `exec`. Bun's `fs.open*` did not before 1.4.3 (still allowed by the
19
+ * `bun@>=1.4` pin), and descriptors it returns survive
19
20
  * into any child spawned through a path that does not close strays itself — the
20
21
  * bash tool's shell `fork`/`exec`s user commands and hands them the lot. Use
21
22
  * this for every descriptor kept open past such a spawn (session transcripts,