@oh-my-pi/pi-coding-agent 17.0.7 → 17.0.9

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 (153) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/cli.js +5025 -4930
  3. package/dist/types/config/__tests__/model-registry.test.d.ts +1 -0
  4. package/dist/types/config/model-registry.d.ts +28 -1
  5. package/dist/types/config/model-resolver.d.ts +5 -1
  6. package/dist/types/config/settings-schema.d.ts +50 -3
  7. package/dist/types/dap/client.d.ts +19 -0
  8. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  9. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  10. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -0
  11. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +10 -0
  12. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +1 -1
  13. package/dist/types/extensibility/typebox.d.ts +4 -0
  14. package/dist/types/hindsight/client.d.ts +10 -0
  15. package/dist/types/hindsight/config.d.ts +8 -0
  16. package/dist/types/internal-urls/registry-helpers.d.ts +11 -0
  17. package/dist/types/lsp/client.d.ts +2 -0
  18. package/dist/types/mcp/manager.d.ts +6 -2
  19. package/dist/types/mcp/render.d.ts +2 -2
  20. package/dist/types/mcp/tool-bridge.d.ts +16 -3
  21. package/dist/types/mcp/types.d.ts +6 -0
  22. package/dist/types/mnemopi/state.d.ts +2 -0
  23. package/dist/types/modes/components/oauth-selector.d.ts +8 -0
  24. package/dist/types/modes/components/settings-defs.d.ts +1 -0
  25. package/dist/types/modes/components/user-message.d.ts +25 -1
  26. package/dist/types/modes/controllers/extension-ui-controller.d.ts +8 -0
  27. package/dist/types/modes/controllers/mcp-command-controller.d.ts +3 -0
  28. package/dist/types/modes/interactive-mode.d.ts +7 -1
  29. package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
  30. package/dist/types/modes/rpc/rpc-frame.d.ts +16 -0
  31. package/dist/types/modes/rpc/rpc-messages.d.ts +26 -0
  32. package/dist/types/modes/rpc/rpc-types.d.ts +40 -0
  33. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  34. package/dist/types/modes/setup-wizard/scenes/types.d.ts +9 -1
  35. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  36. package/dist/types/modes/types.d.ts +14 -1
  37. package/dist/types/modes/utils/ui-helpers.d.ts +12 -8
  38. package/dist/types/sdk.d.ts +1 -0
  39. package/dist/types/session/agent-session-error-log.test.d.ts +1 -0
  40. package/dist/types/session/agent-session.d.ts +86 -3
  41. package/dist/types/session/session-loader.d.ts +6 -4
  42. package/dist/types/task/executor.d.ts +10 -0
  43. package/dist/types/task/index.d.ts +1 -0
  44. package/dist/types/task/prewalk.d.ts +3 -0
  45. package/dist/types/task/types.d.ts +14 -0
  46. package/dist/types/tools/ask.d.ts +10 -0
  47. package/dist/types/tools/hub/jobs.d.ts +1 -1
  48. package/dist/types/tools/index.d.ts +3 -0
  49. package/dist/types/tools/report-tool-issue.d.ts +24 -9
  50. package/dist/types/tools/tool-timeouts.d.ts +8 -2
  51. package/dist/types/utils/changelog.d.ts +4 -6
  52. package/dist/types/web/search/providers/firecrawl.d.ts +9 -0
  53. package/dist/types/web/search/types.d.ts +1 -1
  54. package/package.json +13 -13
  55. package/scripts/legacy-pi-virtual-module.ts +1 -1
  56. package/src/cli/grievances-cli.ts +2 -2
  57. package/src/cli/models-cli.ts +37 -17
  58. package/src/cli/update-cli.ts +14 -1
  59. package/src/cli/usage-cli.ts +1 -1
  60. package/src/config/__tests__/model-registry.test.ts +147 -0
  61. package/src/config/model-registry.ts +87 -24
  62. package/src/config/model-resolver.ts +56 -32
  63. package/src/config/settings-schema.ts +46 -3
  64. package/src/config/settings.ts +68 -5
  65. package/src/dap/client.ts +86 -7
  66. package/src/edit/diff.ts +4 -4
  67. package/src/eval/py/prelude.py +5 -5
  68. package/src/export/html/template.js +23 -9
  69. package/src/extensibility/extensions/runner.ts +9 -4
  70. package/src/extensibility/extensions/types.ts +3 -0
  71. package/src/extensibility/legacy-pi-coding-agent-shim.ts +1 -0
  72. package/src/extensibility/legacy-pi-tui-shim.ts +10 -0
  73. package/src/extensibility/plugins/legacy-pi-compat.ts +851 -310
  74. package/src/extensibility/typebox.ts +22 -9
  75. package/src/hindsight/client.test.ts +42 -0
  76. package/src/hindsight/client.ts +40 -3
  77. package/src/hindsight/config.ts +18 -0
  78. package/src/internal-urls/registry-helpers.ts +40 -0
  79. package/src/launch/broker.ts +31 -5
  80. package/src/lsp/client.ts +23 -0
  81. package/src/lsp/clients/biome-client.ts +3 -4
  82. package/src/lsp/index.ts +26 -8
  83. package/src/main.ts +16 -5
  84. package/src/mcp/manager.ts +39 -8
  85. package/src/mcp/render.ts +94 -35
  86. package/src/mcp/tool-bridge.ts +116 -11
  87. package/src/mcp/types.ts +7 -0
  88. package/src/mnemopi/state.ts +70 -3
  89. package/src/modes/components/agent-dashboard.ts +6 -2
  90. package/src/modes/components/agent-hub.ts +26 -9
  91. package/src/modes/components/chat-transcript-builder.ts +13 -2
  92. package/src/modes/components/diff.ts +2 -2
  93. package/src/modes/components/oauth-selector.ts +24 -7
  94. package/src/modes/components/plan-review-overlay.ts +20 -4
  95. package/src/modes/components/settings-defs.ts +5 -2
  96. package/src/modes/components/settings-selector.ts +9 -5
  97. package/src/modes/components/tool-execution.test.ts +63 -2
  98. package/src/modes/components/user-message.ts +100 -1
  99. package/src/modes/controllers/command-controller.ts +36 -12
  100. package/src/modes/controllers/event-controller.ts +4 -1
  101. package/src/modes/controllers/extension-ui-controller.ts +18 -0
  102. package/src/modes/controllers/input-controller.ts +47 -12
  103. package/src/modes/controllers/mcp-command-controller.ts +70 -40
  104. package/src/modes/controllers/selector-controller.ts +82 -6
  105. package/src/modes/interactive-mode.ts +39 -4
  106. package/src/modes/rpc/rpc-client.ts +94 -3
  107. package/src/modes/rpc/rpc-frame.ts +164 -4
  108. package/src/modes/rpc/rpc-messages.ts +127 -0
  109. package/src/modes/rpc/rpc-mode.ts +79 -7
  110. package/src/modes/rpc/rpc-types.ts +34 -2
  111. package/src/modes/setup-wizard/scenes/model.ts +5 -7
  112. package/src/modes/setup-wizard/scenes/providers.ts +3 -2
  113. package/src/modes/setup-wizard/scenes/sign-in.ts +8 -2
  114. package/src/modes/setup-wizard/scenes/theme.ts +13 -3
  115. package/src/modes/setup-wizard/scenes/types.ts +9 -1
  116. package/src/modes/setup-wizard/scenes/web-search.ts +6 -1
  117. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  118. package/src/modes/types.ts +16 -3
  119. package/src/modes/utils/ui-helpers.ts +40 -20
  120. package/src/prompts/goals/guided-goal-system.md +24 -3
  121. package/src/prompts/system/workflow-notice.md +1 -1
  122. package/src/prompts/tools/task.md +12 -2
  123. package/src/sdk.ts +179 -51
  124. package/src/session/agent-session-error-log.test.ts +59 -0
  125. package/src/session/agent-session.ts +351 -46
  126. package/src/session/session-context.test.ts +224 -1
  127. package/src/session/session-context.ts +41 -2
  128. package/src/session/session-loader.ts +10 -5
  129. package/src/slash-commands/helpers/usage-report.ts +3 -1
  130. package/src/system-prompt.ts +1 -2
  131. package/src/task/executor.ts +44 -27
  132. package/src/task/index.ts +63 -8
  133. package/src/task/prewalk.ts +6 -0
  134. package/src/task/structured-subagent.ts +3 -1
  135. package/src/task/types.ts +16 -0
  136. package/src/task/worktree.ts +25 -11
  137. package/src/tools/ask.ts +15 -0
  138. package/src/tools/bash.ts +14 -6
  139. package/src/tools/browser.ts +1 -1
  140. package/src/tools/debug.ts +1 -1
  141. package/src/tools/eval.ts +4 -5
  142. package/src/tools/fetch.ts +1 -1
  143. package/src/tools/hub/index.ts +1 -1
  144. package/src/tools/hub/jobs.ts +67 -8
  145. package/src/tools/hub/launch.ts +7 -0
  146. package/src/tools/index.ts +3 -0
  147. package/src/tools/report-tool-issue.ts +79 -28
  148. package/src/tools/tool-timeouts.ts +10 -3
  149. package/src/tools/write.ts +31 -0
  150. package/src/utils/changelog.ts +54 -58
  151. package/src/utils/git.ts +57 -49
  152. package/src/web/search/providers/firecrawl.ts +46 -13
  153. package/src/web/search/types.ts +5 -1
@@ -12,6 +12,13 @@ import { isRecord, ptree, readJsonl } from "@oh-my-pi/pi-utils";
12
12
  import type { FileSink } from "bun";
13
13
  import type { BashResult } from "../../exec/bash-executor";
14
14
  import type { AgentSessionEvent, SessionStats } from "../../session/agent-session";
15
+ import { MAX_RPC_FRAME_BYTES, MAX_RPC_REASSEMBLED_BYTES, RpcFrameDecoder, type RpcProtocolVersion } from "./rpc-frame";
16
+ import {
17
+ RPC_MESSAGES_PAGE_BUSY_ERROR,
18
+ RPC_MESSAGES_PAGE_STALE_ERROR,
19
+ type RpcMessagesPage,
20
+ type RpcMessagesPageOptions,
21
+ } from "./rpc-messages";
15
22
  import type {
16
23
  RpcAvailableCommandsUpdateFrame,
17
24
  RpcAvailableSlashCommand,
@@ -136,6 +143,16 @@ function isRpcResponse(value: unknown): value is RpcResponse {
136
143
  return true;
137
144
  }
138
145
 
146
+ function supportsRpcProtocolV2(value: Record<string, unknown>): boolean {
147
+ return (
148
+ value.type === "ready" &&
149
+ Array.isArray(value.supportedProtocolVersions) &&
150
+ value.supportedProtocolVersions.includes(2) &&
151
+ value.maxFrameBytes === MAX_RPC_FRAME_BYTES &&
152
+ value.maxReassembledFrameBytes === MAX_RPC_REASSEMBLED_BYTES
153
+ );
154
+ }
155
+
139
156
  function isAgentEvent(value: unknown): value is AgentEvent {
140
157
  if (!isRecord(value)) return false;
141
158
  const type = value.type;
@@ -200,6 +217,26 @@ function normalizeToolResult<TDetails>(result: RpcClientToolResult<TDetails>): A
200
217
  return result;
201
218
  }
202
219
 
220
+ /** Failed RPC command; `code` mirrors the server's machine-readable error code when present. */
221
+ export class RpcCommandError extends Error {
222
+ constructor(
223
+ message: string,
224
+ readonly command: string,
225
+ readonly code?: string,
226
+ ) {
227
+ super(message);
228
+ this.name = "RpcCommandError";
229
+ }
230
+ }
231
+
232
+ /** True when a high-level `getMessages()` drain should discard partial pages and fall back to `get_messages`. */
233
+ function isPageFallbackError(error: unknown): boolean {
234
+ if (!(error instanceof Error)) return false;
235
+ if (error instanceof RpcCommandError && (error.code === "session_busy" || error.code === "stale_cursor"))
236
+ return true;
237
+ return error.message === RPC_MESSAGES_PAGE_BUSY_ERROR || error.message === RPC_MESSAGES_PAGE_STALE_ERROR;
238
+ }
239
+
203
240
  // ============================================================================
204
241
  // RPC Client
205
242
  // ============================================================================
@@ -218,6 +255,7 @@ export class RpcClient {
218
255
  #customTools: RpcClientCustomTool[] = [];
219
256
  #pendingHostToolCalls = new Map<string, { controller: AbortController }>();
220
257
  #requestId = 0;
258
+ #protocolVersion: RpcProtocolVersion = 1;
221
259
  #extensionUiListeners: Set<(req: RpcExtensionUIRequest) => void> = new Set();
222
260
  #abortController = new AbortController();
223
261
 
@@ -242,6 +280,7 @@ export class RpcClient {
242
280
  // Mint a fresh controller so a previous stop()'s abort does not
243
281
  // short-circuit the new stdout reader (issue #4079).
244
282
  this.#abortController = new AbortController();
283
+ this.#protocolVersion = 1;
245
284
 
246
285
  const cliPath = this.options.cliPath ?? "dist/cli.js";
247
286
  const args = ["--mode", "rpc"];
@@ -269,6 +308,9 @@ export class RpcClient {
269
308
  // Wait for the "ready" signal or process exit
270
309
  const { promise: readyPromise, resolve: readyResolve, reject: readyReject } = Promise.withResolvers<void>();
271
310
  let readySettled = false;
311
+ let protocolV2Supported = false;
312
+ let protocolV2Enabled = false;
313
+ const frameDecoder = new RpcFrameDecoder();
272
314
 
273
315
  const reapAfterOutputFailure = async (error: Error) => {
274
316
  if (this.#process !== child) return;
@@ -294,11 +336,15 @@ export class RpcClient {
294
336
  void (async () => {
295
337
  for await (const line of lines) {
296
338
  if (!readySettled && isRecord(line) && line.type === "ready") {
339
+ protocolV2Supported = supportsRpcProtocolV2(line);
297
340
  readySettled = true;
298
341
  readyResolve();
299
342
  continue;
300
343
  }
301
- this.#handleLine(line);
344
+ if (isRecord(line) && line.type === "rpc_chunk" && !protocolV2Enabled)
345
+ throw new Error("RPC chunk received before protocol negotiation");
346
+ const decoded = frameDecoder.push(line);
347
+ if (decoded) this.#handleLine(decoded);
302
348
  }
303
349
  // A closed stdout is terminal even if the child remains alive. Startup
304
350
  // failures are reaped by the readyPromise catch below; established
@@ -359,6 +405,18 @@ export class RpcClient {
359
405
 
360
406
  try {
361
407
  await readyPromise;
408
+ if (protocolV2Supported) {
409
+ protocolV2Enabled = true;
410
+ const response = await this.#send({ type: "negotiate_protocol", protocolVersion: 2 });
411
+ if (
412
+ !response.success ||
413
+ response.command !== "negotiate_protocol" ||
414
+ !isRecord(response.data) ||
415
+ response.data.protocolVersion !== 2
416
+ )
417
+ throw new Error("RPC protocol v2 negotiation failed");
418
+ this.#protocolVersion = 2;
419
+ }
362
420
  if (this.#customTools.length > 0) {
363
421
  await this.setCustomTools(this.#customTools);
364
422
  }
@@ -744,9 +802,42 @@ export class RpcClient {
744
802
  }
745
803
 
746
804
  /**
747
- * Get all messages in the session.
805
+ * Get one stable, byte-bounded message page.
748
806
  */
807
+ async getMessagesPage(options: RpcMessagesPageOptions = {}): Promise<RpcMessagesPage> {
808
+ const response = await this.#send({ type: "get_messages_page", ...options });
809
+ return this.#getData<RpcMessagesPage>(response);
810
+ }
811
+
812
+ /** Get all messages, draining stable pages when protocol v2 is available. */
749
813
  async getMessages(): Promise<AgentMessage[]> {
814
+ if (this.#protocolVersion === 2) {
815
+ try {
816
+ const messages: AgentMessage[] = [];
817
+ const seenCursors = new Set<string>();
818
+ let totalMessages: number | undefined;
819
+ let cursor: string | undefined;
820
+ do {
821
+ const page = await this.getMessagesPage({ cursor, limit: 256 });
822
+ if (
823
+ !Number.isSafeInteger(page.totalMessages) ||
824
+ page.totalMessages < 0 ||
825
+ (totalMessages !== undefined && page.totalMessages !== totalMessages)
826
+ )
827
+ throw new Error("RPC message pagination returned an inconsistent total");
828
+ totalMessages = page.totalMessages;
829
+ messages.push(...page.messages);
830
+ cursor = page.nextCursor;
831
+ if (cursor && seenCursors.has(cursor)) throw new Error("RPC message pagination repeated a cursor");
832
+ if (cursor) seenCursors.add(cursor);
833
+ } while (cursor);
834
+ if (messages.length !== totalMessages)
835
+ throw new Error("RPC message pagination ended before the advertised total");
836
+ return messages;
837
+ } catch (error) {
838
+ if (!isPageFallbackError(error)) throw error;
839
+ }
840
+ }
750
841
  const response = await this.#send({ type: "get_messages" });
751
842
  return this.#getData<{ messages: AgentMessage[] }>(response).messages;
752
843
  }
@@ -1094,7 +1185,7 @@ export class RpcClient {
1094
1185
  #getData<T>(response: RpcResponse): T {
1095
1186
  if (!response.success) {
1096
1187
  const errorResponse = response as Extract<RpcResponse, { success: false }>;
1097
- throw new Error(errorResponse.error);
1188
+ throw new RpcCommandError(errorResponse.error, errorResponse.command, errorResponse.code);
1098
1189
  }
1099
1190
  // Type assertion: we trust response.data matches T based on the command sent.
1100
1191
  // This is safe because each public method specifies the correct T for its command.
@@ -1,8 +1,24 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
2
  import { isRecord } from "@oh-my-pi/pi-utils";
3
+ import type { RpcChunkFrame } from "./rpc-types";
3
4
 
4
5
  /** Maximum UTF-8 size of one newline-delimited RPC frame, including the newline. */
5
6
  export const MAX_RPC_FRAME_BYTES = 1024 * 1024;
7
+ /** Maximum UTF-8 size of one logical frame reassembled by protocol v2. */
8
+ export const MAX_RPC_REASSEMBLED_BYTES = 64 * 1024 * 1024;
9
+
10
+ const RPC_CHUNK_PAYLOAD_BYTES = 256 * 1024;
11
+
12
+ export type RpcProtocolVersion = 1 | 2;
13
+
14
+ interface PendingRpcChunks {
15
+ chunkId: string;
16
+ count: number;
17
+ byteLength: number;
18
+ nextIndex: number;
19
+ chunks: Buffer[];
20
+ receivedBytes: number;
21
+ }
6
22
 
7
23
  interface ShrinkPass {
8
24
  stringCap: number;
@@ -68,6 +84,110 @@ function encodedMessageSnapshot(encoded: string): { message: unknown } | undefin
68
84
  : undefined;
69
85
  }
70
86
 
87
+ /**
88
+ * Emit protocol v2 chunk frames for one pre-serialized logical frame, one physical
89
+ * JSONL line at a time so callers can write with backpressure instead of holding the
90
+ * whole ~4/3-sized base64 transport in memory. The reassembly ceiling is enforced on
91
+ * `Buffer.byteLength` BEFORE any full-payload allocation.
92
+ */
93
+ function* encodeChunkedRpcFrames(frame: object, json: string, chunkId: string): Generator<string> {
94
+ const byteLength = Buffer.byteLength(json, "utf8");
95
+ if (byteLength > MAX_RPC_REASSEMBLED_BYTES) {
96
+ yield `${JSON.stringify(overflowFrame(frame))}\n`;
97
+ return;
98
+ }
99
+ const bytes = Buffer.from(json, "utf8");
100
+ const count = Math.ceil(byteLength / RPC_CHUNK_PAYLOAD_BYTES);
101
+ for (let index = 0; index < count; index++) {
102
+ const chunk: RpcChunkFrame = {
103
+ type: "rpc_chunk",
104
+ chunkId,
105
+ index,
106
+ count,
107
+ byteLength,
108
+ data: bytes
109
+ .subarray(index * RPC_CHUNK_PAYLOAD_BYTES, (index + 1) * RPC_CHUNK_PAYLOAD_BYTES)
110
+ .toString("base64"),
111
+ };
112
+ const line = `${JSON.stringify(chunk)}\n`;
113
+ if (serializedFrameBytes(line.slice(0, -1)) > MAX_RPC_FRAME_BYTES)
114
+ throw new Error("RPC chunk exceeded the transport limit");
115
+ yield line;
116
+ }
117
+ }
118
+
119
+ function isRpcChunkFrame(value: unknown): value is RpcChunkFrame {
120
+ return isRecord(value) && value.type === "rpc_chunk";
121
+ }
122
+
123
+ function decodeBase64(data: unknown): Buffer {
124
+ if (
125
+ typeof data !== "string" ||
126
+ data.length === 0 ||
127
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(data)
128
+ )
129
+ throw new Error("invalid rpc chunk data");
130
+ const bytes = Buffer.from(data, "base64");
131
+ if (bytes.toString("base64") !== data) throw new Error("invalid rpc chunk data");
132
+ return bytes;
133
+ }
134
+
135
+ /** Reassemble protocol v2 chunk frames after each JSONL line has been parsed. */
136
+ export class RpcFrameDecoder {
137
+ #pending?: PendingRpcChunks;
138
+
139
+ push(value: unknown): object | undefined {
140
+ if (!isRpcChunkFrame(value)) {
141
+ if (this.#pending) throw new Error("rpc chunk sequence interrupted");
142
+ if (!isRecord(value)) throw new Error("rpc frame must be an object");
143
+ return value;
144
+ }
145
+ const { chunkId, index, count, byteLength } = value;
146
+ if (
147
+ typeof chunkId !== "string" ||
148
+ chunkId.length === 0 ||
149
+ chunkId.length > 128 ||
150
+ !Number.isSafeInteger(index) ||
151
+ !Number.isSafeInteger(count) ||
152
+ !Number.isSafeInteger(byteLength) ||
153
+ index < 0 ||
154
+ count < 2 ||
155
+ count > Math.ceil(MAX_RPC_REASSEMBLED_BYTES / RPC_CHUNK_PAYLOAD_BYTES) ||
156
+ index >= count ||
157
+ byteLength < MAX_RPC_FRAME_BYTES ||
158
+ byteLength > MAX_RPC_REASSEMBLED_BYTES
159
+ )
160
+ throw new Error("invalid rpc chunk metadata");
161
+ const bytes = decodeBase64(value.data);
162
+ if (bytes.byteLength > RPC_CHUNK_PAYLOAD_BYTES) throw new Error("rpc chunk payload exceeds the transport limit");
163
+
164
+ if (!this.#pending) {
165
+ if (index !== 0) throw new Error("rpc chunk sequence must start at index 0");
166
+ this.#pending = { chunkId, count, byteLength, nextIndex: 0, chunks: [], receivedBytes: 0 };
167
+ }
168
+ const pending = this.#pending;
169
+ if (
170
+ pending.chunkId !== chunkId ||
171
+ pending.count !== count ||
172
+ pending.byteLength !== byteLength ||
173
+ pending.nextIndex !== index
174
+ )
175
+ throw new Error("rpc chunk sequence mismatch");
176
+ pending.chunks.push(bytes);
177
+ pending.receivedBytes += bytes.byteLength;
178
+ pending.nextIndex++;
179
+ if (pending.receivedBytes > pending.byteLength) throw new Error("rpc chunk sequence exceeds declared length");
180
+ if (pending.nextIndex < pending.count) return undefined;
181
+ if (pending.receivedBytes !== pending.byteLength) throw new Error("rpc chunk sequence length mismatch");
182
+
183
+ this.#pending = undefined;
184
+ const decoded = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(pending.chunks));
185
+ const frame: unknown = JSON.parse(decoded);
186
+ if (!isRecord(frame)) throw new Error("rpc frame must be an object");
187
+ return frame;
188
+ }
189
+ }
190
+
71
191
  function compactTerminalFrame(
72
192
  frame: object,
73
193
  streamedMessageCount: number,
@@ -142,15 +262,55 @@ export function encodeRpcFrame(frame: object, streamedMessageCount = 0, streamed
142
262
  /** Stateful encoder that tracks which messages a client has already received. */
143
263
  export class RpcFrameEncoder {
144
264
  #streamedMessages: unknown[] = [];
265
+ #protocolVersion: RpcProtocolVersion = 1;
266
+ #chunkCounter = 0;
145
267
 
146
- encode(frame: object): string {
268
+ setProtocolVersion(version: number): void {
269
+ if (version !== 1 && version !== 2) throw new Error(`Unsupported RPC protocol version: ${version}`);
270
+ this.#protocolVersion = version;
271
+ }
272
+
273
+ /**
274
+ * Encode one logical frame into physical JSONL lines. Encoder bookkeeping runs
275
+ * eagerly; only chunk emission is lazy, so a chunked result can be streamed to
276
+ * stdout with backpressure without holding the whole transport in memory. The
277
+ * returned iterable MUST be fully consumed exactly once.
278
+ */
279
+ encodeFrames(frame: object): Iterable<string> {
147
280
  if (isRecord(frame) && frame.type === "agent_start") this.#streamedMessages = [];
148
- const encoded = encodeRpcFrame(frame, this.#streamedMessages.length, this.#streamedMessages);
149
- if (!isRecord(frame)) return encoded;
281
+ const json = JSON.stringify(frame);
282
+ let frames: Iterable<string>;
283
+ let singleFrame: string | undefined;
284
+ if (this.#protocolVersion === 2 && serializedFrameBytes(json) > MAX_RPC_FRAME_BYTES) {
285
+ const compacted = compactTerminalFrame(frame, this.#streamedMessages.length, this.#streamedMessages);
286
+ // Reuse the original serialization when compaction was a no-op.
287
+ const compactedJson = compacted === frame ? json : JSON.stringify(compacted);
288
+ if (serializedFrameBytes(compactedJson) > MAX_RPC_FRAME_BYTES) {
289
+ frames = encodeChunkedRpcFrames(compacted, compactedJson, `rpc-${++this.#chunkCounter}`);
290
+ } else {
291
+ singleFrame = `${compactedJson}\n`;
292
+ frames = [singleFrame];
293
+ }
294
+ } else {
295
+ singleFrame = encodeRpcFrame(frame, this.#streamedMessages.length, this.#streamedMessages);
296
+ frames = [singleFrame];
297
+ }
298
+ if (!isRecord(frame)) return frames;
150
299
  if (frame.type === "message_end") {
151
- const snapshot = encodedMessageSnapshot(encoded);
300
+ const snapshot =
301
+ this.#protocolVersion === 2 && Object.hasOwn(frame, "message")
302
+ ? { message: jsonSnapshot(frame.message) }
303
+ : singleFrame !== undefined
304
+ ? encodedMessageSnapshot(singleFrame)
305
+ : undefined;
152
306
  if (snapshot) this.#streamedMessages.push(snapshot.message);
153
307
  } else if (frame.type === "agent_end" && frame.willContinue !== true) this.#streamedMessages = [];
308
+ return frames;
309
+ }
310
+
311
+ encode(frame: object): string {
312
+ let encoded = "";
313
+ for (const line of this.encodeFrames(frame)) encoded += line;
154
314
  return encoded;
155
315
  }
156
316
  }
@@ -0,0 +1,127 @@
1
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
+ import { isRecord } from "@oh-my-pi/pi-utils";
3
+
4
+ const DEFAULT_RPC_MESSAGE_PAGE_LIMIT = 100;
5
+ const MAX_RPC_MESSAGE_PAGE_LIMIT = 256;
6
+ const MAX_RPC_MESSAGE_PAGE_BYTES = 768 * 1024;
7
+ const MAX_RPC_MESSAGE_CURSOR_CHARS = 2048;
8
+
9
+ export const RPC_MESSAGES_PAGE_BUSY_ERROR = "Cannot page messages while the session is changing";
10
+ export const RPC_MESSAGES_PAGE_STALE_ERROR = "RPC message cursor is stale";
11
+
12
+ /** Machine-readable reasons a `get_messages_page` request can fail; carried as `code` on the error response. */
13
+ export type RpcMessagesPageErrorCode = "session_busy" | "stale_cursor";
14
+
15
+ /** Paging failure that maps to a structured wire `code`, so clients can react without matching message text. */
16
+ export class RpcMessagesPageError extends Error {
17
+ constructor(
18
+ message: string,
19
+ readonly code: RpcMessagesPageErrorCode,
20
+ ) {
21
+ super(message);
22
+ this.name = "RpcMessagesPageError";
23
+ }
24
+ }
25
+
26
+ export interface RpcMessageSnapshot {
27
+ sessionId: string;
28
+ leafId: string | null;
29
+ messageCount: number;
30
+ }
31
+
32
+ export interface RpcMessagesPage {
33
+ messages: AgentMessage[];
34
+ nextCursor?: string;
35
+ totalMessages: number;
36
+ }
37
+
38
+ interface RpcMessageCursorPayload extends RpcMessageSnapshot {
39
+ version: 1;
40
+ offset: number;
41
+ }
42
+
43
+ export interface RpcMessagesPageOptions {
44
+ cursor?: string;
45
+ limit?: number;
46
+ }
47
+
48
+ function encodeCursor(snapshot: RpcMessageSnapshot, offset: number): string {
49
+ const payload: RpcMessageCursorPayload = { version: 1, ...snapshot, offset };
50
+ return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
51
+ }
52
+
53
+ function decodeCursor(cursor: string): RpcMessageCursorPayload {
54
+ if (cursor.length === 0 || cursor.length > MAX_RPC_MESSAGE_CURSOR_CHARS || !/^[A-Za-z0-9_-]+$/.test(cursor))
55
+ throw new Error("Invalid RPC message cursor");
56
+ const bytes = Buffer.from(cursor, "base64url");
57
+ if (bytes.toString("base64url") !== cursor) throw new Error("Invalid RPC message cursor");
58
+ let value: unknown;
59
+ try {
60
+ value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
61
+ } catch {
62
+ throw new Error("Invalid RPC message cursor");
63
+ }
64
+ if (!isRecord(value)) throw new Error("Invalid RPC message cursor");
65
+ const { version, sessionId, leafId, messageCount, offset } = value;
66
+ if (
67
+ version !== 1 ||
68
+ typeof sessionId !== "string" ||
69
+ sessionId.length === 0 ||
70
+ sessionId.length > 256 ||
71
+ !((typeof leafId === "string" && leafId.length > 0 && leafId.length <= 256) || leafId === null) ||
72
+ typeof messageCount !== "number" ||
73
+ !Number.isSafeInteger(messageCount) ||
74
+ messageCount < 0 ||
75
+ typeof offset !== "number" ||
76
+ !Number.isSafeInteger(offset) ||
77
+ offset < 0 ||
78
+ offset > messageCount
79
+ )
80
+ throw new Error("Invalid RPC message cursor");
81
+ return { version, sessionId, leafId, messageCount, offset };
82
+ }
83
+
84
+ function sameSnapshot(cursor: RpcMessageCursorPayload, snapshot: RpcMessageSnapshot): boolean {
85
+ return (
86
+ cursor.sessionId === snapshot.sessionId &&
87
+ cursor.leafId === snapshot.leafId &&
88
+ cursor.messageCount === snapshot.messageCount
89
+ );
90
+ }
91
+
92
+ /** Page one stable in-memory message snapshot without crossing the v1 frame budget. */
93
+ export function pageRpcMessages(
94
+ messages: readonly AgentMessage[],
95
+ snapshot: RpcMessageSnapshot,
96
+ options: RpcMessagesPageOptions = {},
97
+ ): RpcMessagesPage {
98
+ if (snapshot.messageCount !== messages.length)
99
+ throw new Error("RPC message snapshot does not match current messages");
100
+ const limit = options.limit ?? DEFAULT_RPC_MESSAGE_PAGE_LIMIT;
101
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_RPC_MESSAGE_PAGE_LIMIT)
102
+ throw new Error(`RPC message page limit must be between 1 and ${MAX_RPC_MESSAGE_PAGE_LIMIT}`);
103
+ let offset = 0;
104
+ if (options.cursor !== undefined) {
105
+ const cursor = decodeCursor(options.cursor);
106
+ if (!sameSnapshot(cursor, snapshot))
107
+ throw new RpcMessagesPageError(RPC_MESSAGES_PAGE_STALE_ERROR, "stale_cursor");
108
+ offset = cursor.offset;
109
+ }
110
+
111
+ const page: AgentMessage[] = [];
112
+ let pageBytes = 2;
113
+ while (offset + page.length < messages.length && page.length < limit) {
114
+ const message = messages[offset + page.length];
115
+ const messageBytes = Buffer.byteLength(JSON.stringify(message), "utf8") + (page.length === 0 ? 0 : 1);
116
+ if (page.length > 0 && pageBytes + messageBytes > MAX_RPC_MESSAGE_PAGE_BYTES) break;
117
+ page.push(message);
118
+ pageBytes += messageBytes;
119
+ }
120
+
121
+ const nextOffset = offset + page.length;
122
+ return {
123
+ messages: page,
124
+ ...(nextOffset < messages.length ? { nextCursor: encodeCursor(snapshot, nextOffset) } : {}),
125
+ totalMessages: messages.length,
126
+ };
127
+ }
@@ -10,6 +10,7 @@
10
10
  * - Events: AgentSessionEvent objects streamed as they occur
11
11
  * - Extension UI: Extension UI requests are emitted, client responds with extension_ui_response
12
12
  */
13
+ import { once } from "node:events";
13
14
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
14
15
  import { isZodSchema, zodToWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
15
16
  import { $env, isRecord, readLines, Snowflake } from "@oh-my-pi/pi-utils";
@@ -34,8 +35,9 @@ import type { EventBus } from "../../utils/event-bus";
34
35
  import { initializeExtensions } from "../runtime-init";
35
36
  import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools";
36
37
  import { isRpcHostUriResult, RpcHostUriBridge } from "./host-uris";
37
- import { RpcFrameEncoder } from "./rpc-frame";
38
+ import { MAX_RPC_FRAME_BYTES, MAX_RPC_REASSEMBLED_BYTES, RpcFrameEncoder } from "./rpc-frame";
38
39
  import { claimRpcInput } from "./rpc-input";
40
+ import { pageRpcMessages, RPC_MESSAGES_PAGE_BUSY_ERROR, RpcMessagesPageError } from "./rpc-messages";
39
41
  import { RpcSubagentRegistry, readRpcSubagentTranscript } from "./rpc-subagents";
40
42
  import type {
41
43
  RpcCommand,
@@ -619,9 +621,33 @@ export async function runRpcMode(
619
621
  process.env.PI_NOTIFICATIONS = "off";
620
622
 
621
623
  const frameEncoder = new RpcFrameEncoder();
622
- process.stdout.write(frameEncoder.encode({ type: "ready" }));
624
+ // Ordered stdout writer honoring backpressure: chunked v2 frames are produced
625
+ // lazily by the encoder and written one physical line at a time, so a near-limit
626
+ // logical frame never materializes its full base64 transport in memory.
627
+ let stdoutQueue: Promise<void> = Promise.resolve();
628
+ const writeFrames = (frames: Iterable<string>) => {
629
+ stdoutQueue = stdoutQueue
630
+ .then(async () => {
631
+ for (const line of frames) {
632
+ if (!process.stdout.write(line)) await once(process.stdout, "drain");
633
+ }
634
+ })
635
+ // stdout gone (host exited) — nothing left to deliver; keep the queue alive.
636
+ .catch(() => {});
637
+ };
638
+ writeFrames(
639
+ frameEncoder.encodeFrames({
640
+ type: "ready",
641
+ protocolVersion: 1,
642
+ supportedProtocolVersions: [1, 2],
643
+ maxFrameBytes: MAX_RPC_FRAME_BYTES,
644
+ maxReassembledFrameBytes: MAX_RPC_REASSEMBLED_BYTES,
645
+ }),
646
+ );
623
647
  const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => {
624
- process.stdout.write(frameEncoder.encode(obj));
648
+ writeFrames(frameEncoder.encodeFrames(obj));
649
+ if (isRecord(obj) && obj.type === "response" && obj.command === "negotiate_protocol" && obj.success === true)
650
+ frameEncoder.setProtocolVersion(2);
625
651
  };
626
652
  const emitRpcTitles = shouldEmitRpcTitles();
627
653
 
@@ -636,8 +662,8 @@ export async function runRpcMode(
636
662
  return { id, type: "response", command, success: true, data } as RpcResponse;
637
663
  };
638
664
 
639
- const error = (id: string | undefined, command: string, message: string): RpcResponse => {
640
- return { id, type: "response", command, success: false, error: message };
665
+ const error = (id: string | undefined, command: string, message: string, code?: string): RpcResponse => {
666
+ return { id, type: "response", command, success: false, error: message, ...(code ? { code } : {}) };
641
667
  };
642
668
 
643
669
  const extensionUserMessageTracker = new RpcExtensionUserMessageTracker();
@@ -936,6 +962,12 @@ export async function runRpcMode(
936
962
  const id = command.id;
937
963
 
938
964
  switch (command.type) {
965
+ case "negotiate_protocol": {
966
+ if (command.protocolVersion !== 2)
967
+ return error(id, "negotiate_protocol", `Unsupported RPC protocol version: ${command.protocolVersion}`);
968
+ return success(id, "negotiate_protocol", { protocolVersion: 2 });
969
+ }
970
+
939
971
  // =================================================================
940
972
  // Prompting
941
973
  // =================================================================
@@ -1122,8 +1154,19 @@ export async function runRpcMode(
1122
1154
  // =================================================================
1123
1155
 
1124
1156
  case "set_model": {
1125
- const models = session.getAvailableModels();
1126
- const model = models.find(m => m.provider === command.provider && m.id === command.modelId);
1157
+ let models = session.getAvailableModels();
1158
+ let model = models.find(m => m.provider === command.provider && m.id === command.modelId);
1159
+ if (!model) {
1160
+ // Model not in the current catalog. Wait for in-flight
1161
+ // background discovery before declaring it missing: on cold
1162
+ // start, discovery-backed providers (proxy / ollama / etc.)
1163
+ // populate seconds after session ready. Models already in
1164
+ // the bundled catalog skip this await entirely so the RPC
1165
+ // queue is not stalled behind unrelated discovery.
1166
+ await session.modelRegistry.awaitBackgroundRefresh();
1167
+ models = session.getAvailableModels();
1168
+ model = models.find(m => m.provider === command.provider && m.id === command.modelId);
1169
+ }
1127
1170
  if (!model) {
1128
1171
  return error(id, "set_model", `Model not found: ${command.provider}/${command.modelId}`);
1129
1172
  }
@@ -1140,6 +1183,7 @@ export async function runRpcMode(
1140
1183
  }
1141
1184
 
1142
1185
  case "get_available_models": {
1186
+ await session.modelRegistry.awaitBackgroundRefresh();
1143
1187
  const models = session.getAvailableModels();
1144
1188
  return success(id, "get_available_models", { models });
1145
1189
  }
@@ -1277,6 +1321,34 @@ export async function runRpcMode(
1277
1321
  return success(id, "get_messages", { messages: session.messages });
1278
1322
  }
1279
1323
 
1324
+ case "get_messages_page": {
1325
+ if (session.isStreaming || session.isCompacting)
1326
+ return error(id, "get_messages_page", RPC_MESSAGES_PAGE_BUSY_ERROR, "session_busy");
1327
+ const messages = session.messages;
1328
+ try {
1329
+ return success(
1330
+ id,
1331
+ "get_messages_page",
1332
+ pageRpcMessages(
1333
+ messages,
1334
+ {
1335
+ sessionId: session.sessionId,
1336
+ leafId: session.sessionManager.getLeafId(),
1337
+ messageCount: messages.length,
1338
+ },
1339
+ { cursor: command.cursor, limit: command.limit },
1340
+ ),
1341
+ );
1342
+ } catch (pageError) {
1343
+ return error(
1344
+ id,
1345
+ "get_messages_page",
1346
+ pageError instanceof Error ? pageError.message : String(pageError),
1347
+ pageError instanceof RpcMessagesPageError ? pageError.code : undefined,
1348
+ );
1349
+ }
1350
+ }
1351
+
1280
1352
  // =================================================================
1281
1353
  // Login
1282
1354
  // =================================================================