@oh-my-pi/pi-coding-agent 17.0.8 → 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.
- package/CHANGELOG.md +33 -1
- package/dist/cli.js +4850 -4774
- package/dist/types/config/__tests__/model-registry.test.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +27 -0
- package/dist/types/config/settings-schema.d.ts +34 -3
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -0
- package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +10 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +1 -1
- package/dist/types/internal-urls/registry-helpers.d.ts +11 -0
- package/dist/types/lsp/client.d.ts +2 -0
- package/dist/types/mcp/manager.d.ts +6 -2
- package/dist/types/mcp/render.d.ts +2 -2
- package/dist/types/mcp/tool-bridge.d.ts +7 -3
- package/dist/types/mcp/types.d.ts +6 -0
- package/dist/types/modes/components/oauth-selector.d.ts +8 -0
- package/dist/types/modes/components/settings-defs.d.ts +1 -0
- package/dist/types/modes/controllers/mcp-command-controller.d.ts +3 -0
- package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
- package/dist/types/modes/rpc/rpc-frame.d.ts +16 -0
- package/dist/types/modes/rpc/rpc-messages.d.ts +26 -0
- package/dist/types/modes/rpc/rpc-types.d.ts +40 -0
- package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
- package/dist/types/modes/setup-wizard/scenes/types.d.ts +9 -1
- package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +8 -1
- package/dist/types/session/session-loader.d.ts +6 -4
- package/dist/types/task/types.d.ts +14 -0
- package/dist/types/tools/hub/jobs.d.ts +1 -1
- package/dist/types/tools/index.d.ts +3 -0
- package/dist/types/tools/report-tool-issue.d.ts +24 -9
- package/dist/types/web/search/providers/firecrawl.d.ts +9 -0
- package/dist/types/web/search/types.d.ts +1 -1
- package/package.json +13 -12
- package/scripts/legacy-pi-virtual-module.ts +1 -1
- package/src/cli/grievances-cli.ts +2 -2
- package/src/cli/usage-cli.ts +1 -1
- package/src/config/__tests__/model-registry.test.ts +147 -0
- package/src/config/model-registry.ts +40 -0
- package/src/config/model-resolver.ts +0 -1
- package/src/config/settings-schema.ts +41 -3
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +1 -0
- package/src/extensibility/legacy-pi-tui-shim.ts +10 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +851 -310
- package/src/internal-urls/registry-helpers.ts +40 -0
- package/src/lsp/client.ts +23 -0
- package/src/lsp/clients/biome-client.ts +3 -4
- package/src/lsp/index.ts +25 -7
- package/src/main.ts +1 -0
- package/src/mcp/manager.ts +39 -8
- package/src/mcp/render.ts +94 -35
- package/src/mcp/tool-bridge.ts +107 -11
- package/src/mcp/types.ts +7 -0
- package/src/modes/components/agent-hub.ts +26 -9
- package/src/modes/components/oauth-selector.ts +24 -7
- package/src/modes/components/settings-defs.ts +5 -2
- package/src/modes/components/settings-selector.ts +9 -5
- package/src/modes/components/tool-execution.test.ts +63 -2
- package/src/modes/controllers/command-controller.ts +14 -7
- package/src/modes/controllers/mcp-command-controller.ts +70 -40
- package/src/modes/interactive-mode.ts +3 -0
- package/src/modes/rpc/rpc-client.ts +94 -3
- package/src/modes/rpc/rpc-frame.ts +164 -4
- package/src/modes/rpc/rpc-messages.ts +127 -0
- package/src/modes/rpc/rpc-mode.ts +79 -7
- package/src/modes/rpc/rpc-types.ts +34 -2
- package/src/modes/setup-wizard/scenes/model.ts +5 -7
- package/src/modes/setup-wizard/scenes/providers.ts +3 -2
- package/src/modes/setup-wizard/scenes/sign-in.ts +8 -2
- package/src/modes/setup-wizard/scenes/theme.ts +13 -3
- package/src/modes/setup-wizard/scenes/types.ts +9 -1
- package/src/modes/setup-wizard/scenes/web-search.ts +6 -1
- package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
- package/src/prompts/goals/guided-goal-system.md +24 -3
- package/src/prompts/tools/task.md +12 -2
- package/src/sdk.ts +45 -3
- package/src/session/agent-session.ts +63 -32
- package/src/session/session-context.test.ts +224 -1
- package/src/session/session-context.ts +41 -2
- package/src/session/session-loader.ts +10 -5
- package/src/slash-commands/helpers/usage-report.ts +3 -1
- package/src/task/executor.ts +3 -0
- package/src/task/index.ts +52 -8
- package/src/task/structured-subagent.ts +3 -1
- package/src/task/types.ts +16 -0
- package/src/tools/hub/index.ts +1 -1
- package/src/tools/hub/jobs.ts +67 -8
- package/src/tools/index.ts +3 -0
- package/src/tools/report-tool-issue.ts +79 -28
- package/src/web/search/providers/firecrawl.ts +46 -13
- 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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
149
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1126
|
-
|
|
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
|
// =================================================================
|