@fitchmultz/pi-subagents 0.46.2 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +1 -1
- package/dist/extension/build-info.js +1 -1
- package/dist/pi-intercom/bridge-broadcast.js +60 -0
- package/dist/pi-intercom/bridge-catalog.js +372 -0
- package/dist/pi-intercom/bridge-config.js +47 -26
- package/dist/pi-intercom/bridge-control.js +104 -0
- package/dist/pi-intercom/bridge-files.js +47 -0
- package/dist/pi-intercom/bridge-groups.js +135 -0
- package/dist/pi-intercom/bridge-http.js +8 -1
- package/dist/pi-intercom/bridge-launch-journal.js +84 -0
- package/dist/pi-intercom/bridge-launch-policy.js +71 -5
- package/dist/pi-intercom/bridge-launch.js +186 -76
- package/dist/pi-intercom/bridge-mailbox.js +324 -0
- package/dist/pi-intercom/bridge-operations.js +127 -0
- package/dist/pi-intercom/bridge-presence.js +100 -0
- package/dist/pi-intercom/bridge-protocol.js +7 -0
- package/dist/pi-intercom/bridge-routes.js +190 -17
- package/dist/pi-intercom/bridge-rpc-process.js +43 -15
- package/dist/pi-intercom/bridge-session.js +83 -85
- package/dist/pi-intercom/bridge-target.js +22 -0
- package/dist/pi-intercom/bridge-wake.js +158 -0
- package/dist/pi-intercom/bridge.js +84 -44
- package/dist/pi-intercom/broker/client.js +1 -0
- package/dist/pi-intercom/connection.js +2 -3
- package/dist/pi-intercom/inbound.js +2 -4
- package/dist/pi-intercom/index.js +7 -1
- package/dist/pi-intercom/native-control-auth.js +80 -0
- package/dist/pi-intercom/native-control-protocol.js +111 -0
- package/dist/pi-intercom/native-control.js +130 -0
- package/dist/pi-intercom/saved-transcript.js +135 -0
- package/dist/pi-intercom/session-targets.js +3 -0
- package/dist/pi-intercom/transcript.js +175 -0
- package/docs/intercom-bridge.md +293 -161
- package/docs/intercom.md +1 -1
- package/docs/reference.md +1 -1
- package/package.json +1 -1
- package/scripts/intercom-bridge-credentials.mjs +54 -12
- package/scripts/intercom-remote.mjs +196 -31
- package/scripts/package-smoke.mjs +1 -1
- package/src/pi-intercom/bridge-broadcast.ts +70 -0
- package/src/pi-intercom/bridge-catalog.ts +489 -0
- package/src/pi-intercom/bridge-config.ts +64 -37
- package/src/pi-intercom/bridge-control.ts +179 -0
- package/src/pi-intercom/bridge-files.ts +59 -0
- package/src/pi-intercom/bridge-groups.ts +169 -0
- package/src/pi-intercom/bridge-http.ts +15 -1
- package/src/pi-intercom/bridge-launch-journal.ts +122 -0
- package/src/pi-intercom/bridge-launch-policy.ts +88 -7
- package/src/pi-intercom/bridge-launch.ts +296 -107
- package/src/pi-intercom/bridge-mailbox.ts +395 -0
- package/src/pi-intercom/bridge-operations.ts +169 -0
- package/src/pi-intercom/bridge-presence.ts +128 -0
- package/src/pi-intercom/bridge-protocol.ts +8 -0
- package/src/pi-intercom/bridge-routes.ts +281 -32
- package/src/pi-intercom/bridge-rpc-process.ts +53 -16
- package/src/pi-intercom/bridge-session.ts +106 -105
- package/src/pi-intercom/bridge-target.ts +35 -0
- package/src/pi-intercom/bridge-wake.ts +188 -0
- package/src/pi-intercom/bridge.ts +88 -54
- package/src/pi-intercom/broker/client.ts +3 -0
- package/src/pi-intercom/connection.ts +2 -3
- package/src/pi-intercom/inbound.ts +2 -4
- package/src/pi-intercom/index.ts +7 -1
- package/src/pi-intercom/native-control-auth.ts +89 -0
- package/src/pi-intercom/native-control-protocol.ts +180 -0
- package/src/pi-intercom/native-control.ts +160 -0
- package/src/pi-intercom/saved-transcript.ts +175 -0
- package/src/pi-intercom/session-targets.ts +4 -0
- package/src/pi-intercom/transcript.ts +226 -0
- package/src/pi-intercom/types.ts +2 -0
|
@@ -2,12 +2,14 @@ import type { IncomingMessage } from "node:http";
|
|
|
2
2
|
import { isUnknownArray } from "../shared/unknown.ts";
|
|
3
3
|
import { ASK_TIMEOUT, Fault, INBOX_COUNT, label, object, text } from "./bridge-protocol.ts";
|
|
4
4
|
import { body } from "./bridge-http.ts";
|
|
5
|
+
import type { CatalogQuery } from "./bridge-catalog.ts";
|
|
6
|
+
import { groupDefinition, type GroupDefinition } from "./bridge-groups.ts";
|
|
7
|
+
import { isControlVerb, type NativeControlVerb } from "./native-control-protocol.ts";
|
|
8
|
+
import { transcriptOptions, type TranscriptOptions } from "./transcript.ts";
|
|
5
9
|
|
|
6
|
-
export type
|
|
7
|
-
| { readonly route: "GET /v1/list" | "
|
|
8
|
-
| { readonly route: "GET /v1/
|
|
9
|
-
| { readonly route: "POST /v1/start"; readonly name: string }
|
|
10
|
-
| { readonly route: "POST /v1/stop"; readonly sessionId: string }
|
|
10
|
+
export type MessagingOperation =
|
|
11
|
+
| { readonly route: "GET /v1/list" | "POST /v1/register" }
|
|
12
|
+
| { readonly route: "GET /v1/inbox"; readonly after: number; readonly limit: number }
|
|
11
13
|
| { readonly route: "POST /v1/ack"; readonly ids: readonly string[] }
|
|
12
14
|
| { readonly route: "POST /v1/send"; readonly to: string; readonly message: string }
|
|
13
15
|
| {
|
|
@@ -17,12 +19,55 @@ export type Operation =
|
|
|
17
19
|
readonly timeoutMs: number;
|
|
18
20
|
}
|
|
19
21
|
| { readonly route: "POST /v1/reply"; readonly replyTo: string; readonly message: string };
|
|
20
|
-
export type
|
|
21
|
-
|
|
22
|
-
{ readonly route: "
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
export type Operation =
|
|
23
|
+
| MessagingOperation
|
|
24
|
+
| { readonly route: "GET /v1/sessions"; readonly query?: CatalogQuery }
|
|
25
|
+
| { readonly route: "POST /v1/start"; readonly name: string }
|
|
26
|
+
| {
|
|
27
|
+
readonly route: "POST /v1/spawn";
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly cwd?: string;
|
|
30
|
+
readonly provider?: string;
|
|
31
|
+
readonly model?: string;
|
|
32
|
+
readonly task?: string;
|
|
33
|
+
}
|
|
34
|
+
| {
|
|
35
|
+
readonly route: "POST /v1/resume";
|
|
36
|
+
readonly sessionId?: string;
|
|
37
|
+
readonly target?: string;
|
|
38
|
+
readonly name?: string;
|
|
39
|
+
readonly task?: string;
|
|
40
|
+
}
|
|
41
|
+
| {
|
|
42
|
+
readonly route: "POST /v1/stop" | "POST /v1/cancel";
|
|
43
|
+
readonly sessionId: string;
|
|
44
|
+
readonly timeoutMs: number;
|
|
45
|
+
}
|
|
46
|
+
| { readonly route: "GET /v1/groups" }
|
|
47
|
+
| { readonly route: "POST /v1/groups"; readonly group: GroupDefinition }
|
|
48
|
+
| { readonly route: "POST /v1/broadcast"; readonly message: string; readonly group?: string }
|
|
49
|
+
| {
|
|
50
|
+
readonly route: "POST /v1/control";
|
|
51
|
+
readonly to: string;
|
|
52
|
+
readonly verb: NativeControlVerb;
|
|
53
|
+
readonly options?: TranscriptOptions;
|
|
54
|
+
readonly timeoutMs: number;
|
|
55
|
+
}
|
|
56
|
+
| {
|
|
57
|
+
readonly route: "GET /v1/transcript";
|
|
58
|
+
readonly target: string;
|
|
59
|
+
readonly options: TranscriptOptions;
|
|
60
|
+
readonly timeoutMs: number;
|
|
61
|
+
};
|
|
62
|
+
type Route = Operation["route"] | "POST /v1/transcript" | "POST /v1/capabilities";
|
|
63
|
+
type MessagingRoute =
|
|
64
|
+
| MessagingOperation["route"]
|
|
65
|
+
| "GET /v1/groups"
|
|
66
|
+
| "GET /v1/sessions"
|
|
67
|
+
| "GET /v1/transcript"
|
|
68
|
+
| "POST /v1/control"
|
|
69
|
+
| "POST /v1/transcript"
|
|
70
|
+
| "POST /v1/capabilities";
|
|
26
71
|
const fields = {
|
|
27
72
|
"GET /v1/list": [],
|
|
28
73
|
"GET /v1/inbox": [],
|
|
@@ -32,10 +77,19 @@ const fields = {
|
|
|
32
77
|
"POST /v1/ask": ["to", "message", "timeoutMs"],
|
|
33
78
|
"POST /v1/reply": ["replyTo", "message"],
|
|
34
79
|
"POST /v1/start": ["name"],
|
|
80
|
+
"POST /v1/spawn": ["name", "cwd", "task", "provider", "model"],
|
|
81
|
+
"POST /v1/resume": ["sessionId", "name", "target", "task"],
|
|
35
82
|
"GET /v1/sessions": [],
|
|
36
|
-
"POST /v1/stop": ["sessionId"],
|
|
83
|
+
"POST /v1/stop": ["sessionId", "target", "to", "mode", "timeoutMs"],
|
|
84
|
+
"POST /v1/cancel": ["sessionId", "target", "to", "timeoutMs"],
|
|
85
|
+
"GET /v1/groups": [],
|
|
86
|
+
"POST /v1/groups": ["name", "members", "namePattern", "cwdPattern", "match"],
|
|
87
|
+
"POST /v1/broadcast": ["message", "group"],
|
|
88
|
+
"POST /v1/control": ["to", "verb", "options", "timeoutMs"],
|
|
89
|
+
"POST /v1/capabilities": ["to", "timeoutMs"],
|
|
90
|
+
"POST /v1/transcript": ["to", "limit", "maxBytes", "cursor", "timeoutMs"],
|
|
91
|
+
"GET /v1/transcript": ["target", "limit", "maxBytes", "cursor", "timeoutMs"],
|
|
37
92
|
};
|
|
38
|
-
|
|
39
93
|
function isRoute(route: string): route is Route {
|
|
40
94
|
return Object.hasOwn(fields, route);
|
|
41
95
|
}
|
|
@@ -46,10 +100,24 @@ export function routeFor(req: IncomingMessage): Route {
|
|
|
46
100
|
) {
|
|
47
101
|
throw new Fault(403, "browser_forbidden", "Browser access is not supported.");
|
|
48
102
|
}
|
|
49
|
-
const route = `${req.method ?? ""} ${req.url ?? ""}`;
|
|
103
|
+
const route = `${req.method ?? ""} ${(req.url ?? "").split("?")[0]}`;
|
|
50
104
|
if (!isRoute(route)) {
|
|
51
105
|
throw new Fault(404, "not_found", "Unknown method or path.");
|
|
52
106
|
}
|
|
107
|
+
if (
|
|
108
|
+
req.url?.includes("?") === true &&
|
|
109
|
+
!["GET /v1/inbox", "GET /v1/sessions", "GET /v1/transcript"].includes(route)
|
|
110
|
+
) {
|
|
111
|
+
throw new Fault(
|
|
112
|
+
400,
|
|
113
|
+
"invalid_input",
|
|
114
|
+
"Query parameters are only supported for inbox, sessions and transcript.",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
checkGetBody(req);
|
|
118
|
+
return route;
|
|
119
|
+
}
|
|
120
|
+
function checkGetBody(req: IncomingMessage): void {
|
|
53
121
|
if (
|
|
54
122
|
req.method === "GET" &&
|
|
55
123
|
(req.headers["transfer-encoding"] !== undefined ||
|
|
@@ -57,39 +125,168 @@ export function routeFor(req: IncomingMessage): Route {
|
|
|
57
125
|
) {
|
|
58
126
|
throw new Fault(400, "invalid_input", "GET requests must not have a body.");
|
|
59
127
|
}
|
|
60
|
-
return route;
|
|
61
128
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
129
|
+
function queryFor(req: IncomingMessage, allowed: readonly string[]): URLSearchParams {
|
|
130
|
+
const query = new URL(req.url ?? "", "https://127.0.0.1").searchParams;
|
|
131
|
+
if ([...query.keys()].some((key) => !allowed.includes(key) || query.getAll(key).length !== 1)) {
|
|
132
|
+
throw new Fault(400, "invalid_input", "Unknown or repeated query parameter.");
|
|
133
|
+
}
|
|
134
|
+
return query;
|
|
135
|
+
}
|
|
136
|
+
function inboxQuery(req: IncomingMessage): Extract<Operation, { readonly route: "GET /v1/inbox" }> {
|
|
137
|
+
const query = queryFor(req, ["after", "limit"]);
|
|
138
|
+
const after = query.get("after") ?? "0";
|
|
139
|
+
const limit = query.get("limit") ?? String(INBOX_COUNT);
|
|
140
|
+
if (
|
|
141
|
+
!/^(0|[1-9]\d{0,15})$/.test(after) ||
|
|
142
|
+
!Number.isSafeInteger(Number(after)) ||
|
|
143
|
+
!/^[1-9]\d{0,2}$/.test(limit) ||
|
|
144
|
+
Number(limit) > INBOX_COUNT
|
|
145
|
+
) {
|
|
146
|
+
throw new Fault(
|
|
147
|
+
400,
|
|
148
|
+
"invalid_input",
|
|
149
|
+
"after must be a nonnegative safe integer cursor; limit must be 1..256.",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return { route: "GET /v1/inbox", after: Number(after), limit: Number(limit) };
|
|
153
|
+
}
|
|
154
|
+
function pageNumber(value: string | null, fallback: number, min: number, max: number): number {
|
|
155
|
+
const number = value === null ? fallback : Number(value);
|
|
156
|
+
if (value === "" || !Number.isSafeInteger(number) || number < min || number > max) {
|
|
157
|
+
throw new Fault(400, "invalid_input", "Invalid catalog pagination.");
|
|
158
|
+
}
|
|
159
|
+
return number;
|
|
160
|
+
}
|
|
161
|
+
function queryBoolean(value: string | null): boolean {
|
|
162
|
+
if (value !== null && value !== "true" && value !== "false") {
|
|
163
|
+
throw new Fault(400, "invalid_input", "Catalog switches must be true or false.");
|
|
164
|
+
}
|
|
165
|
+
return value === "true";
|
|
166
|
+
}
|
|
167
|
+
function catalogQuery(req: IncomingMessage): CatalogQuery | undefined {
|
|
168
|
+
if (req.url?.includes("?") !== true) {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
const params = queryFor(req, ["live", "saved", "q", "offset", "limit"]);
|
|
172
|
+
return {
|
|
173
|
+
live: queryBoolean(params.get("live")),
|
|
174
|
+
saved: queryBoolean(params.get("saved")),
|
|
175
|
+
q: params.has("q") ? label(params.get("q"), "catalog filter", 128) : undefined,
|
|
176
|
+
offset: pageNumber(params.get("offset"), 0, 0, Number.MAX_SAFE_INTEGER),
|
|
177
|
+
limit: pageNumber(params.get("limit"), 50, 1, 100),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
function timeout(value: unknown, control = false): number {
|
|
181
|
+
const defaultMs = control ? 3000 : ASK_TIMEOUT;
|
|
182
|
+
const ms = value === undefined ? defaultMs : value;
|
|
183
|
+
const max = control ? 10000 : ASK_TIMEOUT;
|
|
184
|
+
if (typeof ms !== "number" || !Number.isInteger(ms) || ms < 100 || ms > max) {
|
|
185
|
+
throw new Fault(400, "invalid_input", `timeoutMs must be an integer from 100 to ${max}.`);
|
|
67
186
|
}
|
|
68
187
|
return ms;
|
|
69
188
|
}
|
|
70
|
-
|
|
71
|
-
|
|
189
|
+
function resume(
|
|
190
|
+
input: Readonly<Record<string, unknown>>,
|
|
191
|
+
): Extract<Operation, { readonly route: "POST /v1/resume" }> {
|
|
192
|
+
if (["sessionId", "name", "target"].filter((key) => input[key] !== undefined).length !== 1) {
|
|
193
|
+
throw new Fault(
|
|
194
|
+
400,
|
|
195
|
+
"invalid_input",
|
|
196
|
+
"Resume requires exactly one of sessionId, name or target.",
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
const sessionId =
|
|
200
|
+
input.sessionId === undefined ? undefined : label(input.sessionId, "native session ID", 128);
|
|
201
|
+
if (sessionId !== undefined && !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(sessionId)) {
|
|
202
|
+
throw new Fault(400, "invalid_input", "Resume takes a native ID, never a journal path.");
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
route: "POST /v1/resume",
|
|
206
|
+
sessionId,
|
|
207
|
+
target:
|
|
208
|
+
input.target === undefined ? undefined : label(input.target, "resume target", 256).trim(),
|
|
209
|
+
name:
|
|
210
|
+
input.name === undefined ? undefined : label(input.name, "saved session name", 256).trim(),
|
|
211
|
+
task: input.task === undefined ? undefined : text(input.task),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
function stop(
|
|
215
|
+
route: "POST /v1/stop" | "POST /v1/cancel",
|
|
216
|
+
input: Readonly<Record<string, unknown>>,
|
|
217
|
+
): Extract<Operation, { readonly route: "POST /v1/stop" | "POST /v1/cancel" }> {
|
|
218
|
+
if (["sessionId", "target", "to"].filter((key) => input[key] !== undefined).length !== 1) {
|
|
219
|
+
throw new Fault(400, "invalid_input", "Control requires exactly one sessionId, target or to.");
|
|
220
|
+
}
|
|
221
|
+
const mode = input.mode ?? "kill";
|
|
222
|
+
if (mode !== "kill" && mode !== "cancel") {
|
|
223
|
+
throw new Fault(400, "invalid_input", "Stop mode must be cancel or kill.");
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
route: mode === "cancel" ? "POST /v1/cancel" : route,
|
|
227
|
+
sessionId: label(input.sessionId ?? input.target ?? input.to, "session target", 256).trim(),
|
|
228
|
+
timeoutMs: timeout(input.timeoutMs, true),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
function nativeControl(
|
|
232
|
+
route: Route,
|
|
233
|
+
input: Readonly<Record<string, unknown>>,
|
|
234
|
+
): Extract<Operation, { readonly route: "POST /v1/control" | "GET /v1/transcript" }> {
|
|
235
|
+
const verb = route === "POST /v1/control" ? input.verb : route.split("/").at(-1);
|
|
236
|
+
if (!isControlVerb(verb)) {
|
|
237
|
+
throw new Fault(400, "invalid_input", "Unknown native control verb.");
|
|
238
|
+
}
|
|
239
|
+
const timeoutMs = timeout(input.timeoutMs, true);
|
|
240
|
+
const to = label(input.to ?? input.target, "session target").trim();
|
|
241
|
+
if (verb === "transcript") {
|
|
242
|
+
const options = transcriptOptions(
|
|
243
|
+
route === "POST /v1/control"
|
|
244
|
+
? input.options
|
|
245
|
+
: { limit: input.limit, maxBytes: input.maxBytes, cursor: input.cursor },
|
|
246
|
+
);
|
|
247
|
+
return { route: "GET /v1/transcript", target: to, options, timeoutMs };
|
|
248
|
+
}
|
|
249
|
+
if (input.options !== undefined) {
|
|
250
|
+
throw new Fault(400, "invalid_input", "Only transcript accepts options.");
|
|
251
|
+
}
|
|
252
|
+
return { route: "POST /v1/control", verb, to, timeoutMs };
|
|
253
|
+
}
|
|
254
|
+
async function operationInput(
|
|
72
255
|
req: IncomingMessage,
|
|
73
256
|
route: Route,
|
|
74
257
|
signal: AbortSignal,
|
|
75
|
-
): Promise<
|
|
76
|
-
|
|
258
|
+
): Promise<Record<string, unknown>> {
|
|
259
|
+
if (req.method === "POST") {
|
|
260
|
+
return object(await body(req, signal), fields[route]);
|
|
261
|
+
}
|
|
262
|
+
if (route !== "GET /v1/transcript") {
|
|
263
|
+
return {};
|
|
264
|
+
}
|
|
265
|
+
const input: Record<string, unknown> = {};
|
|
266
|
+
for (const [key, value] of queryFor(req, fields[route])) {
|
|
267
|
+
input[key] = ["limit", "maxBytes", "timeoutMs"].includes(key) ? Number(value) : value;
|
|
268
|
+
}
|
|
269
|
+
return input;
|
|
270
|
+
}
|
|
271
|
+
function messagingOperation(
|
|
272
|
+
route: MessagingRoute,
|
|
273
|
+
input: Readonly<Record<string, unknown>>,
|
|
274
|
+
req: IncomingMessage,
|
|
275
|
+
): Operation {
|
|
77
276
|
switch (route) {
|
|
78
277
|
case "GET /v1/list":
|
|
79
|
-
case "GET /v1/inbox":
|
|
80
278
|
case "POST /v1/register":
|
|
81
|
-
case "GET /v1/
|
|
279
|
+
case "GET /v1/groups":
|
|
82
280
|
return { route };
|
|
83
|
-
case "
|
|
84
|
-
return
|
|
85
|
-
case "
|
|
86
|
-
return { route,
|
|
87
|
-
case "POST /v1/ack":
|
|
281
|
+
case "GET /v1/inbox":
|
|
282
|
+
return inboxQuery(req);
|
|
283
|
+
case "GET /v1/sessions":
|
|
284
|
+
return { route, query: catalogQuery(req) };
|
|
285
|
+
case "POST /v1/ack":
|
|
88
286
|
if (!isUnknownArray(input.ids) || input.ids.length > INBOX_COUNT) {
|
|
89
287
|
throw new Fault(400, "invalid_input", "ids must be an array of at most 256 message IDs.");
|
|
90
288
|
}
|
|
91
289
|
return { route, ids: [...new Set(input.ids.map((id) => label(id, "message ID")))] };
|
|
92
|
-
}
|
|
93
290
|
case "POST /v1/send":
|
|
94
291
|
return { route, message: text(input.message), to: label(input.to, "to").trim() };
|
|
95
292
|
case "POST /v1/ask":
|
|
@@ -101,5 +298,57 @@ export async function operationFor(
|
|
|
101
298
|
};
|
|
102
299
|
case "POST /v1/reply":
|
|
103
300
|
return { route, message: text(input.message), replyTo: label(input.replyTo, "replyTo") };
|
|
301
|
+
case "GET /v1/transcript":
|
|
302
|
+
case "POST /v1/control":
|
|
303
|
+
case "POST /v1/transcript":
|
|
304
|
+
case "POST /v1/capabilities":
|
|
305
|
+
return nativeControl(route, input);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
export async function operationFor(
|
|
309
|
+
req: IncomingMessage,
|
|
310
|
+
route: Route,
|
|
311
|
+
signal: AbortSignal,
|
|
312
|
+
): Promise<Operation> {
|
|
313
|
+
const input = await operationInput(req, route, signal);
|
|
314
|
+
switch (route) {
|
|
315
|
+
case "POST /v1/start":
|
|
316
|
+
return { route, name: label(input.name, "session name", 64).trim() };
|
|
317
|
+
case "POST /v1/spawn":
|
|
318
|
+
return {
|
|
319
|
+
route,
|
|
320
|
+
name: label(input.name, "session name", 64).trim(),
|
|
321
|
+
cwd: input.cwd === undefined ? undefined : label(input.cwd, "cwd", 4096),
|
|
322
|
+
provider: input.provider === undefined ? undefined : label(input.provider, "provider", 128),
|
|
323
|
+
model: input.model === undefined ? undefined : label(input.model, "model reference", 512),
|
|
324
|
+
task: input.task === undefined ? undefined : text(input.task),
|
|
325
|
+
};
|
|
326
|
+
case "POST /v1/resume":
|
|
327
|
+
return resume(input);
|
|
328
|
+
case "POST /v1/stop":
|
|
329
|
+
case "POST /v1/cancel":
|
|
330
|
+
return stop(route, input);
|
|
331
|
+
case "POST /v1/groups":
|
|
332
|
+
return { route, group: groupDefinition(input) };
|
|
333
|
+
case "POST /v1/broadcast":
|
|
334
|
+
return {
|
|
335
|
+
route,
|
|
336
|
+
message: text(input.message),
|
|
337
|
+
group: input.group === undefined ? undefined : label(input.group, "group", 64).trim(),
|
|
338
|
+
};
|
|
339
|
+
case "GET /v1/groups":
|
|
340
|
+
case "GET /v1/inbox":
|
|
341
|
+
case "GET /v1/list":
|
|
342
|
+
case "GET /v1/sessions":
|
|
343
|
+
case "GET /v1/transcript":
|
|
344
|
+
case "POST /v1/ack":
|
|
345
|
+
case "POST /v1/ask":
|
|
346
|
+
case "POST /v1/capabilities":
|
|
347
|
+
case "POST /v1/control":
|
|
348
|
+
case "POST /v1/register":
|
|
349
|
+
case "POST /v1/reply":
|
|
350
|
+
case "POST /v1/send":
|
|
351
|
+
case "POST /v1/transcript":
|
|
352
|
+
return messagingOperation(route, input, req);
|
|
104
353
|
}
|
|
105
354
|
}
|
|
@@ -3,7 +3,7 @@ import { StringDecoder } from "node:string_decoder";
|
|
|
3
3
|
import type { RpcCommand, RpcExtensionUIResponse } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { trySignalChildTree } from "../shared/post-exit-stdio-guard.ts";
|
|
5
5
|
import { isRecord, type UnknownRecord } from "../shared/unknown.ts";
|
|
6
|
-
import { Fault, log, wait } from "./bridge-protocol.ts";
|
|
6
|
+
import { Fault, cancelled, log, wait } from "./bridge-protocol.ts";
|
|
7
7
|
import type { LaunchPolicy } from "./bridge-launch-policy.ts";
|
|
8
8
|
|
|
9
9
|
const FRAME_LIMIT = 4 * 1024 * 1024;
|
|
@@ -15,7 +15,7 @@ function launchEnvironment(packageRoot: string): NodeJS.ProcessEnv {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
// Native RpcClient lacks public EOF/exit/UI control and retains/reprints unbounded stderr.
|
|
18
|
-
// This owner implements
|
|
18
|
+
// This owner implements state, owned-run cancellation and dialog dismissal, never a raw RPC proxy.
|
|
19
19
|
export class RpcProcess {
|
|
20
20
|
private readonly child: ChildProcessWithoutNullStreams;
|
|
21
21
|
readonly exited: Promise<void>;
|
|
@@ -27,15 +27,17 @@ export class RpcProcess {
|
|
|
27
27
|
private sequence = 0;
|
|
28
28
|
private pending?: {
|
|
29
29
|
readonly id: string;
|
|
30
|
+
readonly command: "get_state" | "abort";
|
|
30
31
|
readonly resolve: (data: UnknownRecord) => void;
|
|
31
32
|
readonly reject: (error: Readonly<Error>) => void;
|
|
32
33
|
};
|
|
33
34
|
private stopping?: Promise<void>;
|
|
34
35
|
private stderrBytes = 0;
|
|
35
36
|
private exitObserved = false;
|
|
37
|
+
private cancelling = false;
|
|
36
38
|
|
|
37
|
-
constructor(policy: LaunchPolicy, cwd: string, sessionFile: string) {
|
|
38
|
-
this.child = spawn(process.execPath, this.arguments(policy, sessionFile), {
|
|
39
|
+
constructor(policy: LaunchPolicy, cwd: string, sessionFile: string, resuming = false) {
|
|
40
|
+
this.child = spawn(process.execPath, this.arguments(policy, sessionFile, resuming), {
|
|
39
41
|
cwd,
|
|
40
42
|
detached: true,
|
|
41
43
|
env: launchEnvironment(policy.packageRoot),
|
|
@@ -78,19 +80,19 @@ export class RpcProcess {
|
|
|
78
80
|
get pid(): number | undefined {
|
|
79
81
|
return this.child.pid;
|
|
80
82
|
}
|
|
81
|
-
private arguments(policy: LaunchPolicy, sessionFile: string): string[] {
|
|
83
|
+
private arguments(policy: LaunchPolicy, sessionFile: string, resuming: boolean): string[] {
|
|
82
84
|
const args = [
|
|
83
85
|
policy.cli,
|
|
84
86
|
"--mode",
|
|
85
87
|
"rpc",
|
|
86
88
|
"--session",
|
|
87
89
|
sessionFile,
|
|
88
|
-
"--provider",
|
|
89
|
-
policy.provider,
|
|
90
|
-
"--model",
|
|
91
|
-
policy.model,
|
|
92
90
|
policy.trustProject ? "--approve" : "--no-approve",
|
|
93
91
|
];
|
|
92
|
+
// Resume leaves native model/thinking restoration authoritative; never force the launch default.
|
|
93
|
+
if (!resuming && policy.provider !== undefined && policy.model !== undefined) {
|
|
94
|
+
args.push("--provider", policy.provider, "--model", policy.model);
|
|
95
|
+
}
|
|
94
96
|
if (!policy.discoverResources) {
|
|
95
97
|
args.push(
|
|
96
98
|
"--no-extensions",
|
|
@@ -191,8 +193,12 @@ export class RpcProcess {
|
|
|
191
193
|
private response(data: UnknownRecord): void {
|
|
192
194
|
const pending = this.pending;
|
|
193
195
|
this.pending = undefined;
|
|
194
|
-
if (
|
|
195
|
-
|
|
196
|
+
if (
|
|
197
|
+
data.success === true &&
|
|
198
|
+
data.command === pending?.command &&
|
|
199
|
+
(pending?.command !== "get_state" || isRecord(data.data))
|
|
200
|
+
) {
|
|
201
|
+
pending?.resolve(isRecord(data.data) ? data.data : {});
|
|
196
202
|
} else {
|
|
197
203
|
pending?.reject(new Fault(503, "rpc_failed", "Pi RPC state request failed."));
|
|
198
204
|
}
|
|
@@ -218,7 +224,7 @@ export class RpcProcess {
|
|
|
218
224
|
}
|
|
219
225
|
private write(
|
|
220
226
|
command: Readonly<
|
|
221
|
-
| Extract<RpcCommand, { type: "get_state" }>
|
|
227
|
+
| Extract<RpcCommand, { type: "get_state" | "abort" }>
|
|
222
228
|
| Extract<RpcExtensionUIResponse, { cancelled: true }>
|
|
223
229
|
>,
|
|
224
230
|
): void {
|
|
@@ -228,14 +234,25 @@ export class RpcProcess {
|
|
|
228
234
|
this.cleanup();
|
|
229
235
|
}
|
|
230
236
|
}
|
|
231
|
-
async
|
|
237
|
+
private async command(
|
|
238
|
+
command: "get_state" | "abort",
|
|
239
|
+
signal: AbortSignal,
|
|
240
|
+
): Promise<UnknownRecord> {
|
|
241
|
+
cancelled(signal);
|
|
232
242
|
if (this.failure) {
|
|
233
243
|
throw this.failure;
|
|
234
244
|
}
|
|
235
|
-
|
|
245
|
+
if (this.pending) {
|
|
246
|
+
throw new Fault(
|
|
247
|
+
409,
|
|
248
|
+
"control_in_progress",
|
|
249
|
+
"A native control operation is already in flight.",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
const id = `control-${++this.sequence}`;
|
|
236
253
|
const result = new Promise<UnknownRecord>((resolve, reject) => {
|
|
237
|
-
this.pending = { id, resolve, reject };
|
|
238
|
-
this.write({ type:
|
|
254
|
+
this.pending = { id, command, resolve, reject };
|
|
255
|
+
this.write({ type: command, id });
|
|
239
256
|
});
|
|
240
257
|
try {
|
|
241
258
|
return await wait(result, signal);
|
|
@@ -243,6 +260,26 @@ export class RpcProcess {
|
|
|
243
260
|
this.pending = undefined;
|
|
244
261
|
}
|
|
245
262
|
}
|
|
263
|
+
async state(signal: AbortSignal): Promise<UnknownRecord> {
|
|
264
|
+
return this.command("get_state", signal);
|
|
265
|
+
}
|
|
266
|
+
async cancel(signal: AbortSignal): Promise<UnknownRecord> {
|
|
267
|
+
if (this.cancelling) {
|
|
268
|
+
throw new Fault(409, "control_in_progress", "Native cancellation is already in flight.");
|
|
269
|
+
}
|
|
270
|
+
this.cancelling = true;
|
|
271
|
+
try {
|
|
272
|
+
await this.command("abort", signal);
|
|
273
|
+
const state = await this.state(signal);
|
|
274
|
+
if (state.isStreaming !== false) {
|
|
275
|
+
throw new Fault(409, "cancel_unconfirmed", "Native Pi has not reached idle after abort.");
|
|
276
|
+
}
|
|
277
|
+
this.status = "idle";
|
|
278
|
+
return { isStreaming: false, pendingMessageCount: state.pendingMessageCount };
|
|
279
|
+
} finally {
|
|
280
|
+
this.cancelling = false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
246
283
|
ready(): void {
|
|
247
284
|
if (this.failure || this.exitObserved) {
|
|
248
285
|
throw this.failure ?? new Fault(503, "session_exited", "Pi exited during startup.");
|