@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +1 -1
  3. package/dist/extension/build-info.js +1 -1
  4. package/dist/pi-intercom/bridge-broadcast.js +60 -0
  5. package/dist/pi-intercom/bridge-catalog.js +372 -0
  6. package/dist/pi-intercom/bridge-config.js +47 -26
  7. package/dist/pi-intercom/bridge-control.js +104 -0
  8. package/dist/pi-intercom/bridge-files.js +47 -0
  9. package/dist/pi-intercom/bridge-groups.js +135 -0
  10. package/dist/pi-intercom/bridge-http.js +8 -1
  11. package/dist/pi-intercom/bridge-launch-journal.js +84 -0
  12. package/dist/pi-intercom/bridge-launch-policy.js +71 -5
  13. package/dist/pi-intercom/bridge-launch.js +186 -76
  14. package/dist/pi-intercom/bridge-mailbox.js +324 -0
  15. package/dist/pi-intercom/bridge-operations.js +127 -0
  16. package/dist/pi-intercom/bridge-presence.js +100 -0
  17. package/dist/pi-intercom/bridge-protocol.js +7 -0
  18. package/dist/pi-intercom/bridge-routes.js +190 -17
  19. package/dist/pi-intercom/bridge-rpc-process.js +43 -15
  20. package/dist/pi-intercom/bridge-session.js +83 -85
  21. package/dist/pi-intercom/bridge-target.js +22 -0
  22. package/dist/pi-intercom/bridge-wake.js +158 -0
  23. package/dist/pi-intercom/bridge.js +84 -44
  24. package/dist/pi-intercom/broker/client.js +1 -0
  25. package/dist/pi-intercom/connection.js +2 -3
  26. package/dist/pi-intercom/inbound.js +2 -4
  27. package/dist/pi-intercom/index.js +7 -1
  28. package/dist/pi-intercom/native-control-auth.js +80 -0
  29. package/dist/pi-intercom/native-control-protocol.js +111 -0
  30. package/dist/pi-intercom/native-control.js +130 -0
  31. package/dist/pi-intercom/saved-transcript.js +135 -0
  32. package/dist/pi-intercom/session-targets.js +3 -0
  33. package/dist/pi-intercom/transcript.js +175 -0
  34. package/docs/intercom-bridge.md +293 -161
  35. package/docs/intercom.md +1 -1
  36. package/docs/reference.md +1 -1
  37. package/package.json +1 -1
  38. package/scripts/intercom-bridge-credentials.mjs +54 -12
  39. package/scripts/intercom-remote.mjs +196 -31
  40. package/scripts/package-smoke.mjs +1 -1
  41. package/src/pi-intercom/bridge-broadcast.ts +70 -0
  42. package/src/pi-intercom/bridge-catalog.ts +489 -0
  43. package/src/pi-intercom/bridge-config.ts +64 -37
  44. package/src/pi-intercom/bridge-control.ts +179 -0
  45. package/src/pi-intercom/bridge-files.ts +59 -0
  46. package/src/pi-intercom/bridge-groups.ts +169 -0
  47. package/src/pi-intercom/bridge-http.ts +15 -1
  48. package/src/pi-intercom/bridge-launch-journal.ts +122 -0
  49. package/src/pi-intercom/bridge-launch-policy.ts +88 -7
  50. package/src/pi-intercom/bridge-launch.ts +296 -107
  51. package/src/pi-intercom/bridge-mailbox.ts +395 -0
  52. package/src/pi-intercom/bridge-operations.ts +169 -0
  53. package/src/pi-intercom/bridge-presence.ts +128 -0
  54. package/src/pi-intercom/bridge-protocol.ts +8 -0
  55. package/src/pi-intercom/bridge-routes.ts +281 -32
  56. package/src/pi-intercom/bridge-rpc-process.ts +53 -16
  57. package/src/pi-intercom/bridge-session.ts +106 -105
  58. package/src/pi-intercom/bridge-target.ts +35 -0
  59. package/src/pi-intercom/bridge-wake.ts +188 -0
  60. package/src/pi-intercom/bridge.ts +88 -54
  61. package/src/pi-intercom/broker/client.ts +3 -0
  62. package/src/pi-intercom/connection.ts +2 -3
  63. package/src/pi-intercom/inbound.ts +2 -4
  64. package/src/pi-intercom/index.ts +7 -1
  65. package/src/pi-intercom/native-control-auth.ts +89 -0
  66. package/src/pi-intercom/native-control-protocol.ts +180 -0
  67. package/src/pi-intercom/native-control.ts +160 -0
  68. package/src/pi-intercom/saved-transcript.ts +175 -0
  69. package/src/pi-intercom/session-targets.ts +4 -0
  70. package/src/pi-intercom/transcript.ts +226 -0
  71. 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 Operation =
7
- | { readonly route: "GET /v1/list" | "GET /v1/inbox" | "POST /v1/register" }
8
- | { readonly route: "GET /v1/sessions" }
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 MessagingOperation = Exclude<
21
- Operation,
22
- { readonly route: "POST /v1/start" | "POST /v1/stop" | "GET /v1/sessions" }
23
- >;
24
- type Route = Operation["route"];
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
- function timeout(value: unknown): number {
64
- const ms = value === undefined ? ASK_TIMEOUT : value;
65
- if (typeof ms !== "number" || !Number.isInteger(ms) || ms < 100 || ms > ASK_TIMEOUT) {
66
- throw new Fault(400, "invalid_input", "timeoutMs must be an integer from 100 to 120000.");
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
- export async function operationFor(
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<Operation> {
76
- const input = req.method === "POST" ? object(await body(req, signal), fields[route]) : {};
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/sessions":
279
+ case "GET /v1/groups":
82
280
  return { route };
83
- case "POST /v1/start":
84
- return { route, name: label(input.name, "session name", 64).trim() };
85
- case "POST /v1/stop":
86
- return { route, sessionId: label(input.sessionId, "session ID", 128) };
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 only get_state and dialog cancellation, never a remote RPC proxy.
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 (data.success === true && data.command === "get_state" && isRecord(data.data)) {
195
- pending?.resolve(data.data);
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 state(signal: AbortSignal): Promise<UnknownRecord> {
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
- const id = `state-${++this.sequence}`;
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: "get_state", id });
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.");