@fitchmultz/pi-subagents 0.47.0 → 0.49.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 +29 -3
- 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 +15 -3
- package/dist/pi-intercom/bridge-control.js +104 -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 +3 -55
- package/dist/pi-intercom/bridge-operations.js +127 -0
- package/dist/pi-intercom/bridge-presence.js +5 -1
- package/dist/pi-intercom/bridge-protocol.js +7 -0
- package/dist/pi-intercom/bridge-routes.js +195 -27
- package/dist/pi-intercom/bridge-rpc-process.js +43 -15
- package/dist/pi-intercom/bridge-session.js +83 -66
- package/dist/pi-intercom/bridge-storage.js +73 -0
- package/dist/pi-intercom/bridge-target.js +22 -0
- package/dist/pi-intercom/bridge-wake-owner.js +57 -0
- package/dist/pi-intercom/bridge-wake-state.js +293 -0
- package/dist/pi-intercom/bridge-wake.js +190 -99
- package/dist/pi-intercom/bridge.js +39 -39
- 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 +315 -174
- package/docs/intercom.md +1 -1
- package/docs/reference.md +1 -1
- package/package.json +1 -1
- package/scripts/intercom-bridge-wake-preflight.mjs +112 -0
- package/scripts/intercom-remote.mjs +213 -38
- 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 +20 -3
- package/src/pi-intercom/bridge-control.ts +179 -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 +3 -59
- package/src/pi-intercom/bridge-operations.ts +169 -0
- package/src/pi-intercom/bridge-presence.ts +7 -1
- package/src/pi-intercom/bridge-protocol.ts +8 -0
- package/src/pi-intercom/bridge-routes.ts +291 -49
- package/src/pi-intercom/bridge-rpc-process.ts +53 -16
- package/src/pi-intercom/bridge-session.ts +98 -96
- package/src/pi-intercom/bridge-storage.ts +83 -0
- package/src/pi-intercom/bridge-target.ts +35 -0
- package/src/pi-intercom/bridge-wake-owner.ts +58 -0
- package/src/pi-intercom/bridge-wake-state.ts +321 -0
- package/src/pi-intercom/bridge-wake.ts +224 -130
- package/src/pi-intercom/bridge.ts +41 -49
- 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
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
import { isUnknownArray } from "../shared/unknown.js";
|
|
2
2
|
import { ASK_TIMEOUT, Fault, INBOX_COUNT, label, object, text } from "./bridge-protocol.js";
|
|
3
3
|
import { body } from "./bridge-http.js";
|
|
4
|
+
import { groupDefinition } from "./bridge-groups.js";
|
|
5
|
+
import { isControlVerb } from "./native-control-protocol.js";
|
|
6
|
+
import { transcriptOptions } from "./transcript.js";
|
|
4
7
|
const fields = {
|
|
5
8
|
"GET /v1/list": [],
|
|
6
9
|
"GET /v1/inbox": [],
|
|
7
10
|
"POST /v1/register": [],
|
|
8
11
|
"POST /v1/ack": ["ids"],
|
|
9
|
-
"POST /v1/send": ["to", "message"],
|
|
12
|
+
"POST /v1/send": ["to", "message", "passive", "messageId"],
|
|
10
13
|
"POST /v1/ask": ["to", "message", "timeoutMs"],
|
|
11
14
|
"POST /v1/reply": ["replyTo", "message"],
|
|
12
15
|
"POST /v1/start": ["name"],
|
|
16
|
+
"POST /v1/spawn": ["name", "cwd", "task", "provider", "model"],
|
|
17
|
+
"POST /v1/resume": ["sessionId", "name", "target", "task"],
|
|
13
18
|
"GET /v1/sessions": [],
|
|
14
|
-
"POST /v1/stop": ["sessionId"],
|
|
19
|
+
"POST /v1/stop": ["sessionId", "target", "to", "mode", "timeoutMs"],
|
|
20
|
+
"POST /v1/cancel": ["sessionId", "target", "to", "timeoutMs"],
|
|
21
|
+
"GET /v1/groups": [],
|
|
22
|
+
"POST /v1/groups": ["name", "members", "namePattern", "cwdPattern", "match"],
|
|
23
|
+
"POST /v1/broadcast": ["message", "group"],
|
|
24
|
+
"POST /v1/control": ["to", "verb", "options", "timeoutMs"],
|
|
25
|
+
"POST /v1/capabilities": ["to", "timeoutMs"],
|
|
26
|
+
"POST /v1/transcript": ["to", "limit", "maxBytes", "cursor", "timeoutMs"],
|
|
27
|
+
"GET /v1/transcript": ["target", "limit", "maxBytes", "cursor", "timeoutMs"],
|
|
15
28
|
};
|
|
16
29
|
function isRoute(route) {
|
|
17
30
|
return Object.hasOwn(fields, route);
|
|
@@ -25,8 +38,9 @@ export function routeFor(req) {
|
|
|
25
38
|
if (!isRoute(route)) {
|
|
26
39
|
throw new Fault(404, "not_found", "Unknown method or path.");
|
|
27
40
|
}
|
|
28
|
-
if (req.url?.includes("?") === true &&
|
|
29
|
-
|
|
41
|
+
if (req.url?.includes("?") === true &&
|
|
42
|
+
!["GET /v1/inbox", "GET /v1/sessions", "GET /v1/transcript"].includes(route)) {
|
|
43
|
+
throw new Fault(400, "invalid_input", "Query parameters are only supported for inbox, sessions and transcript.");
|
|
30
44
|
}
|
|
31
45
|
checkGetBody(req);
|
|
32
46
|
return route;
|
|
@@ -38,51 +52,145 @@ function checkGetBody(req) {
|
|
|
38
52
|
throw new Fault(400, "invalid_input", "GET requests must not have a body.");
|
|
39
53
|
}
|
|
40
54
|
}
|
|
41
|
-
function
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if ([...query.keys()].some((key) => !["after", "limit"].includes(key) || query.getAll(key).length !== 1)) {
|
|
46
|
-
throw new Fault(400, "invalid_input", "Unknown or repeated inbox query parameter.");
|
|
55
|
+
function queryFor(req, allowed) {
|
|
56
|
+
const query = new URL(req.url ?? "", "https://127.0.0.1").searchParams;
|
|
57
|
+
if ([...query.keys()].some((key) => !allowed.includes(key) || query.getAll(key).length !== 1)) {
|
|
58
|
+
throw new Fault(400, "invalid_input", "Unknown or repeated query parameter.");
|
|
47
59
|
}
|
|
60
|
+
return query;
|
|
61
|
+
}
|
|
62
|
+
function inboxQuery(req) {
|
|
63
|
+
const query = queryFor(req, ["after", "limit", "waitMs"]);
|
|
48
64
|
const after = query.get("after") ?? "0";
|
|
49
65
|
const limit = query.get("limit") ?? String(INBOX_COUNT);
|
|
66
|
+
const waitMs = query.get("waitMs") ?? "0";
|
|
50
67
|
if (!/^(0|[1-9]\d{0,15})$/.test(after) ||
|
|
51
68
|
!Number.isSafeInteger(Number(after)) ||
|
|
52
69
|
!/^[1-9]\d{0,2}$/.test(limit) ||
|
|
53
|
-
Number(limit) > INBOX_COUNT
|
|
54
|
-
|
|
70
|
+
Number(limit) > INBOX_COUNT ||
|
|
71
|
+
!/^(0|[1-9]\d{0,3}|10000)$/.test(waitMs)) {
|
|
72
|
+
throw new Fault(400, "invalid_input", "after must be a nonnegative safe integer cursor; limit must be 1..256; waitMs must be 0..10000.");
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
route: "GET /v1/inbox",
|
|
76
|
+
after: Number(after),
|
|
77
|
+
limit: Number(limit),
|
|
78
|
+
waitMs: Number(waitMs),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function pageNumber(value, fallback, min, max) {
|
|
82
|
+
const number = value === null ? fallback : Number(value);
|
|
83
|
+
if (value === "" || !Number.isSafeInteger(number) || number < min || number > max) {
|
|
84
|
+
throw new Fault(400, "invalid_input", "Invalid catalog pagination.");
|
|
85
|
+
}
|
|
86
|
+
return number;
|
|
87
|
+
}
|
|
88
|
+
function queryBoolean(value) {
|
|
89
|
+
if (value !== null && value !== "true" && value !== "false") {
|
|
90
|
+
throw new Fault(400, "invalid_input", "Catalog switches must be true or false.");
|
|
55
91
|
}
|
|
56
|
-
return
|
|
92
|
+
return value === "true";
|
|
57
93
|
}
|
|
58
|
-
function
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
94
|
+
function catalogQuery(req) {
|
|
95
|
+
if (req.url?.includes("?") !== true) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
const params = queryFor(req, ["live", "saved", "q", "offset", "limit"]);
|
|
99
|
+
return {
|
|
100
|
+
live: queryBoolean(params.get("live")),
|
|
101
|
+
saved: queryBoolean(params.get("saved")),
|
|
102
|
+
q: params.has("q") ? label(params.get("q"), "catalog filter", 128) : undefined,
|
|
103
|
+
offset: pageNumber(params.get("offset"), 0, 0, Number.MAX_SAFE_INTEGER),
|
|
104
|
+
limit: pageNumber(params.get("limit"), 50, 1, 100),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function timeout(value, control = false) {
|
|
108
|
+
const defaultMs = control ? 3000 : ASK_TIMEOUT;
|
|
109
|
+
const ms = value === undefined ? defaultMs : value;
|
|
110
|
+
const max = control ? 10000 : ASK_TIMEOUT;
|
|
111
|
+
if (typeof ms !== "number" || !Number.isInteger(ms) || ms < 100 || ms > max) {
|
|
112
|
+
throw new Fault(400, "invalid_input", `timeoutMs must be an integer from 100 to ${max}.`);
|
|
62
113
|
}
|
|
63
114
|
return ms;
|
|
64
115
|
}
|
|
65
|
-
|
|
66
|
-
|
|
116
|
+
function resume(input) {
|
|
117
|
+
if (["sessionId", "name", "target"].filter((key) => input[key] !== undefined).length !== 1) {
|
|
118
|
+
throw new Fault(400, "invalid_input", "Resume requires exactly one of sessionId, name or target.");
|
|
119
|
+
}
|
|
120
|
+
const sessionId = input.sessionId === undefined ? undefined : label(input.sessionId, "native session ID", 128);
|
|
121
|
+
if (sessionId !== undefined && !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(sessionId)) {
|
|
122
|
+
throw new Fault(400, "invalid_input", "Resume takes a native ID, never a journal path.");
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
route: "POST /v1/resume",
|
|
126
|
+
sessionId,
|
|
127
|
+
target: input.target === undefined ? undefined : label(input.target, "resume target", 256).trim(),
|
|
128
|
+
name: input.name === undefined ? undefined : label(input.name, "saved session name", 256).trim(),
|
|
129
|
+
task: input.task === undefined ? undefined : text(input.task),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function stop(route, input) {
|
|
133
|
+
if (["sessionId", "target", "to"].filter((key) => input[key] !== undefined).length !== 1) {
|
|
134
|
+
throw new Fault(400, "invalid_input", "Control requires exactly one sessionId, target or to.");
|
|
135
|
+
}
|
|
136
|
+
const mode = input.mode ?? "kill";
|
|
137
|
+
if (mode !== "kill" && mode !== "cancel") {
|
|
138
|
+
throw new Fault(400, "invalid_input", "Stop mode must be cancel or kill.");
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
route: mode === "cancel" ? "POST /v1/cancel" : route,
|
|
142
|
+
sessionId: label(input.sessionId ?? input.target ?? input.to, "session target", 256).trim(),
|
|
143
|
+
timeoutMs: timeout(input.timeoutMs, true),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function nativeControl(route, input) {
|
|
147
|
+
const verb = route === "POST /v1/control" ? input.verb : route.split("/").at(-1);
|
|
148
|
+
if (!isControlVerb(verb)) {
|
|
149
|
+
throw new Fault(400, "invalid_input", "Unknown native control verb.");
|
|
150
|
+
}
|
|
151
|
+
const timeoutMs = timeout(input.timeoutMs, true);
|
|
152
|
+
const to = label(input.to ?? input.target, "session target").trim();
|
|
153
|
+
if (verb === "transcript") {
|
|
154
|
+
const options = transcriptOptions(route === "POST /v1/control"
|
|
155
|
+
? input.options
|
|
156
|
+
: { limit: input.limit, maxBytes: input.maxBytes, cursor: input.cursor });
|
|
157
|
+
return { route: "GET /v1/transcript", target: to, options, timeoutMs };
|
|
158
|
+
}
|
|
159
|
+
if (input.options !== undefined) {
|
|
160
|
+
throw new Fault(400, "invalid_input", "Only transcript accepts options.");
|
|
161
|
+
}
|
|
162
|
+
return { route: "POST /v1/control", verb, to, timeoutMs };
|
|
163
|
+
}
|
|
164
|
+
async function operationInput(req, route, signal) {
|
|
165
|
+
if (req.method === "POST") {
|
|
166
|
+
return object(await body(req, signal), fields[route]);
|
|
167
|
+
}
|
|
168
|
+
if (route !== "GET /v1/transcript") {
|
|
169
|
+
return {};
|
|
170
|
+
}
|
|
171
|
+
const input = {};
|
|
172
|
+
for (const [key, value] of queryFor(req, fields[route])) {
|
|
173
|
+
input[key] = ["limit", "maxBytes", "timeoutMs"].includes(key) ? Number(value) : value;
|
|
174
|
+
}
|
|
175
|
+
return input;
|
|
176
|
+
}
|
|
177
|
+
function messagingOperation(route, input, req) {
|
|
67
178
|
switch (route) {
|
|
68
179
|
case "GET /v1/list":
|
|
69
180
|
case "POST /v1/register":
|
|
70
|
-
case "GET /v1/
|
|
181
|
+
case "GET /v1/groups":
|
|
71
182
|
return { route };
|
|
72
183
|
case "GET /v1/inbox":
|
|
73
184
|
return inboxQuery(req);
|
|
74
|
-
case "
|
|
75
|
-
return { route,
|
|
76
|
-
case "POST /v1/
|
|
77
|
-
return { route, sessionId: label(input.sessionId, "session ID", 128) };
|
|
78
|
-
case "POST /v1/ack": {
|
|
185
|
+
case "GET /v1/sessions":
|
|
186
|
+
return { route, query: catalogQuery(req) };
|
|
187
|
+
case "POST /v1/ack":
|
|
79
188
|
if (!isUnknownArray(input.ids) || input.ids.length > INBOX_COUNT) {
|
|
80
189
|
throw new Fault(400, "invalid_input", "ids must be an array of at most 256 message IDs.");
|
|
81
190
|
}
|
|
82
191
|
return { route, ids: [...new Set(input.ids.map((id) => label(id, "message ID")))] };
|
|
83
|
-
}
|
|
84
192
|
case "POST /v1/send":
|
|
85
|
-
return
|
|
193
|
+
return send(input);
|
|
86
194
|
case "POST /v1/ask":
|
|
87
195
|
return {
|
|
88
196
|
route,
|
|
@@ -92,5 +200,65 @@ export async function operationFor(req, route, signal) {
|
|
|
92
200
|
};
|
|
93
201
|
case "POST /v1/reply":
|
|
94
202
|
return { route, message: text(input.message), replyTo: label(input.replyTo, "replyTo") };
|
|
203
|
+
case "GET /v1/transcript":
|
|
204
|
+
case "POST /v1/control":
|
|
205
|
+
case "POST /v1/transcript":
|
|
206
|
+
case "POST /v1/capabilities":
|
|
207
|
+
return nativeControl(route, input);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function send(input) {
|
|
211
|
+
if (input.passive !== undefined && typeof input.passive !== "boolean") {
|
|
212
|
+
throw new Fault(400, "invalid_input", "passive must be a boolean.");
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
route: "POST /v1/send",
|
|
216
|
+
message: text(input.message),
|
|
217
|
+
to: label(input.to, "to").trim(),
|
|
218
|
+
passive: input.passive,
|
|
219
|
+
messageId: input.messageId === undefined ? undefined : label(input.messageId, "message ID"),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
export async function operationFor(req, route, signal) {
|
|
223
|
+
const input = await operationInput(req, route, signal);
|
|
224
|
+
switch (route) {
|
|
225
|
+
case "POST /v1/start":
|
|
226
|
+
return { route, name: label(input.name, "session name", 64).trim() };
|
|
227
|
+
case "POST /v1/spawn":
|
|
228
|
+
return {
|
|
229
|
+
route,
|
|
230
|
+
name: label(input.name, "session name", 64).trim(),
|
|
231
|
+
cwd: input.cwd === undefined ? undefined : label(input.cwd, "cwd", 4096),
|
|
232
|
+
provider: input.provider === undefined ? undefined : label(input.provider, "provider", 128),
|
|
233
|
+
model: input.model === undefined ? undefined : label(input.model, "model reference", 512),
|
|
234
|
+
task: input.task === undefined ? undefined : text(input.task),
|
|
235
|
+
};
|
|
236
|
+
case "POST /v1/resume":
|
|
237
|
+
return resume(input);
|
|
238
|
+
case "POST /v1/stop":
|
|
239
|
+
case "POST /v1/cancel":
|
|
240
|
+
return stop(route, input);
|
|
241
|
+
case "POST /v1/groups":
|
|
242
|
+
return { route, group: groupDefinition(input) };
|
|
243
|
+
case "POST /v1/broadcast":
|
|
244
|
+
return {
|
|
245
|
+
route,
|
|
246
|
+
message: text(input.message),
|
|
247
|
+
group: input.group === undefined ? undefined : label(input.group, "group", 64).trim(),
|
|
248
|
+
};
|
|
249
|
+
case "GET /v1/groups":
|
|
250
|
+
case "GET /v1/inbox":
|
|
251
|
+
case "GET /v1/list":
|
|
252
|
+
case "GET /v1/sessions":
|
|
253
|
+
case "GET /v1/transcript":
|
|
254
|
+
case "POST /v1/ack":
|
|
255
|
+
case "POST /v1/ask":
|
|
256
|
+
case "POST /v1/capabilities":
|
|
257
|
+
case "POST /v1/control":
|
|
258
|
+
case "POST /v1/register":
|
|
259
|
+
case "POST /v1/reply":
|
|
260
|
+
case "POST /v1/send":
|
|
261
|
+
case "POST /v1/transcript":
|
|
262
|
+
return messagingOperation(route, input, req);
|
|
95
263
|
}
|
|
96
264
|
}
|
|
@@ -2,14 +2,14 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { StringDecoder } from "node:string_decoder";
|
|
3
3
|
import { trySignalChildTree } from "../shared/post-exit-stdio-guard.js";
|
|
4
4
|
import { isRecord } from "../shared/unknown.js";
|
|
5
|
-
import { Fault, log, wait } from "./bridge-protocol.js";
|
|
5
|
+
import { Fault, cancelled, log, wait } from "./bridge-protocol.js";
|
|
6
6
|
const FRAME_LIMIT = 4 * 1024 * 1024;
|
|
7
7
|
function launchEnvironment(packageRoot) {
|
|
8
8
|
const environment = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PI_SUBAGENT_")));
|
|
9
9
|
return { ...environment, PI_PACKAGE_DIR: packageRoot };
|
|
10
10
|
}
|
|
11
11
|
// Native RpcClient lacks public EOF/exit/UI control and retains/reprints unbounded stderr.
|
|
12
|
-
// This owner implements
|
|
12
|
+
// This owner implements state, owned-run cancellation and dialog dismissal, never a raw RPC proxy.
|
|
13
13
|
export class RpcProcess {
|
|
14
14
|
child;
|
|
15
15
|
exited;
|
|
@@ -23,8 +23,9 @@ export class RpcProcess {
|
|
|
23
23
|
stopping;
|
|
24
24
|
stderrBytes = 0;
|
|
25
25
|
exitObserved = false;
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
cancelling = false;
|
|
27
|
+
constructor(policy, cwd, sessionFile, resuming = false) {
|
|
28
|
+
this.child = spawn(process.execPath, this.arguments(policy, sessionFile, resuming), {
|
|
28
29
|
cwd,
|
|
29
30
|
detached: true,
|
|
30
31
|
env: launchEnvironment(policy.packageRoot),
|
|
@@ -65,19 +66,19 @@ export class RpcProcess {
|
|
|
65
66
|
get pid() {
|
|
66
67
|
return this.child.pid;
|
|
67
68
|
}
|
|
68
|
-
arguments(policy, sessionFile) {
|
|
69
|
+
arguments(policy, sessionFile, resuming) {
|
|
69
70
|
const args = [
|
|
70
71
|
policy.cli,
|
|
71
72
|
"--mode",
|
|
72
73
|
"rpc",
|
|
73
74
|
"--session",
|
|
74
75
|
sessionFile,
|
|
75
|
-
"--provider",
|
|
76
|
-
policy.provider,
|
|
77
|
-
"--model",
|
|
78
|
-
policy.model,
|
|
79
76
|
policy.trustProject ? "--approve" : "--no-approve",
|
|
80
77
|
];
|
|
78
|
+
// Resume leaves native model/thinking restoration authoritative; never force the launch default.
|
|
79
|
+
if (!resuming && policy.provider !== undefined && policy.model !== undefined) {
|
|
80
|
+
args.push("--provider", policy.provider, "--model", policy.model);
|
|
81
|
+
}
|
|
81
82
|
if (!policy.discoverResources) {
|
|
82
83
|
args.push("--no-extensions", "--no-skills", "--no-prompt-templates", "--no-themes", "--no-context-files");
|
|
83
84
|
}
|
|
@@ -175,8 +176,10 @@ export class RpcProcess {
|
|
|
175
176
|
response(data) {
|
|
176
177
|
const pending = this.pending;
|
|
177
178
|
this.pending = undefined;
|
|
178
|
-
if (data.success === true &&
|
|
179
|
-
pending?.
|
|
179
|
+
if (data.success === true &&
|
|
180
|
+
data.command === pending?.command &&
|
|
181
|
+
(pending?.command !== "get_state" || isRecord(data.data))) {
|
|
182
|
+
pending?.resolve(isRecord(data.data) ? data.data : {});
|
|
180
183
|
}
|
|
181
184
|
else {
|
|
182
185
|
pending?.reject(new Fault(503, "rpc_failed", "Pi RPC state request failed."));
|
|
@@ -200,14 +203,18 @@ export class RpcProcess {
|
|
|
200
203
|
this.cleanup();
|
|
201
204
|
}
|
|
202
205
|
}
|
|
203
|
-
async
|
|
206
|
+
async command(command, signal) {
|
|
207
|
+
cancelled(signal);
|
|
204
208
|
if (this.failure) {
|
|
205
209
|
throw this.failure;
|
|
206
210
|
}
|
|
207
|
-
|
|
211
|
+
if (this.pending) {
|
|
212
|
+
throw new Fault(409, "control_in_progress", "A native control operation is already in flight.");
|
|
213
|
+
}
|
|
214
|
+
const id = `control-${++this.sequence}`;
|
|
208
215
|
const result = new Promise((resolve, reject) => {
|
|
209
|
-
this.pending = { id, resolve, reject };
|
|
210
|
-
this.write({ type:
|
|
216
|
+
this.pending = { id, command, resolve, reject };
|
|
217
|
+
this.write({ type: command, id });
|
|
211
218
|
});
|
|
212
219
|
try {
|
|
213
220
|
return await wait(result, signal);
|
|
@@ -216,6 +223,27 @@ export class RpcProcess {
|
|
|
216
223
|
this.pending = undefined;
|
|
217
224
|
}
|
|
218
225
|
}
|
|
226
|
+
async state(signal) {
|
|
227
|
+
return this.command("get_state", signal);
|
|
228
|
+
}
|
|
229
|
+
async cancel(signal) {
|
|
230
|
+
if (this.cancelling) {
|
|
231
|
+
throw new Fault(409, "control_in_progress", "Native cancellation is already in flight.");
|
|
232
|
+
}
|
|
233
|
+
this.cancelling = true;
|
|
234
|
+
try {
|
|
235
|
+
await this.command("abort", signal);
|
|
236
|
+
const state = await this.state(signal);
|
|
237
|
+
if (state.isStreaming !== false) {
|
|
238
|
+
throw new Fault(409, "cancel_unconfirmed", "Native Pi has not reached idle after abort.");
|
|
239
|
+
}
|
|
240
|
+
this.status = "idle";
|
|
241
|
+
return { isStreaming: false, pendingMessageCount: state.pendingMessageCount };
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
this.cancelling = false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
219
247
|
ready() {
|
|
220
248
|
if (this.failure || this.exitObserved) {
|
|
221
249
|
throw this.failure ?? new Fault(503, "session_exited", "Pi exited during startup.");
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
3
|
import { IntercomClient } from "./broker/client.js";
|
|
3
|
-
import {
|
|
4
|
+
import { resolveSessionProjectId } from "./session-targets.js";
|
|
4
5
|
import { Fault, cancelled, log, wait } from "./bridge-protocol.js";
|
|
5
6
|
import { Mailbox } from "./bridge-mailbox.js";
|
|
7
|
+
import { resolveBridgeTarget, bridgeSessionList } from "./bridge-target.js";
|
|
8
|
+
import { ControlReplies } from "./bridge-control.js";
|
|
6
9
|
import { WaitingAsk } from "./bridge-ask.js";
|
|
7
10
|
const MAX_REQUESTS = 8;
|
|
8
11
|
const IDLE_TIMEOUT = 5 * 60 * 1000;
|
|
@@ -19,8 +22,10 @@ export class Session {
|
|
|
19
22
|
});
|
|
20
23
|
requests = new Set();
|
|
21
24
|
inbox;
|
|
25
|
+
inboxChanged = new AbortController();
|
|
22
26
|
replying = new Set();
|
|
23
27
|
pending;
|
|
28
|
+
controls = new ControlReplies();
|
|
24
29
|
closed = false;
|
|
25
30
|
connecting;
|
|
26
31
|
disconnecting;
|
|
@@ -45,12 +50,13 @@ export class Session {
|
|
|
45
50
|
this.touch();
|
|
46
51
|
}
|
|
47
52
|
guard(signal) {
|
|
48
|
-
|
|
49
|
-
cancelled(signal);
|
|
50
|
-
}
|
|
53
|
+
signal?.throwIfAborted();
|
|
51
54
|
if (this.closed) {
|
|
52
55
|
throw this.closeReason;
|
|
53
56
|
}
|
|
57
|
+
this.authorize();
|
|
58
|
+
}
|
|
59
|
+
authorize() {
|
|
54
60
|
if (!this.owner.allowed(this.identity)) {
|
|
55
61
|
throw new Fault(403, "revoked", "Identity is no longer authorized.");
|
|
56
62
|
}
|
|
@@ -143,27 +149,26 @@ export class Session {
|
|
|
143
149
|
this.owner.retired(this.identity.fingerprint256, this.generation);
|
|
144
150
|
log("session_closed", { ...this.fields(), result: this.closeReason.code });
|
|
145
151
|
}
|
|
146
|
-
close(
|
|
152
|
+
close(fault) {
|
|
147
153
|
if (this.closed) {
|
|
148
154
|
return;
|
|
149
155
|
}
|
|
150
156
|
this.closed = true;
|
|
151
|
-
this.closeReason =
|
|
157
|
+
this.closeReason = fault;
|
|
152
158
|
clearTimeout(this.lease);
|
|
153
|
-
this.pending?.reject(
|
|
159
|
+
this.pending?.reject(fault);
|
|
160
|
+
this.controls.close(fault);
|
|
154
161
|
for (const controller of this.requests) {
|
|
155
|
-
controller.abort(
|
|
162
|
+
controller.abort(fault);
|
|
156
163
|
}
|
|
157
164
|
// Retain the generation until acquisition/registration AND final disconnect settle.
|
|
158
|
-
this.startRetirement();
|
|
159
|
-
}
|
|
160
|
-
startRetirement() {
|
|
161
165
|
this.retirement = this.retire().catch((caught) => this.cleanupFailed(caught));
|
|
162
166
|
}
|
|
163
167
|
async settled() {
|
|
164
168
|
await this.retirement;
|
|
165
169
|
}
|
|
166
170
|
peerLeft(id) {
|
|
171
|
+
this.controls.peerLeft(id);
|
|
167
172
|
if (this.pending?.peer === id && !this.pending.settled) {
|
|
168
173
|
this.pending.reject(new Fault(409, "peer_offline", "Ask recipient disconnected.", { id: this.pending.id }));
|
|
169
174
|
}
|
|
@@ -182,6 +187,9 @@ export class Session {
|
|
|
182
187
|
: new Fault(403, "revoked", "Identity is no longer authorized."));
|
|
183
188
|
return;
|
|
184
189
|
}
|
|
190
|
+
if (this.controls.receive(from.id, message)) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
185
193
|
if (!this.inbox.persistent && this.resolveReply(from, message)) {
|
|
186
194
|
return;
|
|
187
195
|
}
|
|
@@ -203,12 +211,22 @@ export class Session {
|
|
|
203
211
|
this.close(new Fault(507, "mailbox_storage_failed", "Mailbox storage failed; inspect the metadata audit for unrecorded losses."));
|
|
204
212
|
return;
|
|
205
213
|
}
|
|
206
|
-
if (result
|
|
207
|
-
|
|
214
|
+
if (result !== "duplicate" || !this.inbox.persistent) {
|
|
215
|
+
if (result !== "stored") {
|
|
216
|
+
this.owner.lost(this.identity.fingerprint256);
|
|
217
|
+
}
|
|
218
|
+
this.inboxChanged.abort();
|
|
219
|
+
this.inboxChanged = new AbortController();
|
|
208
220
|
}
|
|
209
|
-
|
|
221
|
+
log(result === "lost" ? "inbox_overflow" : "received", {
|
|
222
|
+
...this.fields(),
|
|
223
|
+
peer: from.id,
|
|
224
|
+
messageId: message.id,
|
|
225
|
+
result: result === "lost" && this.inbox.persistent ? "storage_failed" : result,
|
|
226
|
+
});
|
|
210
227
|
if (result !== "lost") {
|
|
211
228
|
this.resolveReply(from, message);
|
|
229
|
+
this.owner.mailboxChanged?.();
|
|
212
230
|
}
|
|
213
231
|
this.presence();
|
|
214
232
|
}
|
|
@@ -220,14 +238,6 @@ export class Session {
|
|
|
220
238
|
pending.resolve({ from, message });
|
|
221
239
|
return true;
|
|
222
240
|
}
|
|
223
|
-
recordReceive(peer, messageId, result) {
|
|
224
|
-
log(result === "lost" ? "inbox_overflow" : "received", {
|
|
225
|
-
...this.fields(),
|
|
226
|
-
peer,
|
|
227
|
-
messageId,
|
|
228
|
-
result: result === "lost" && this.inbox.persistent ? "storage_failed" : result,
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
241
|
presence() {
|
|
232
242
|
if (this.closed) {
|
|
233
243
|
return;
|
|
@@ -254,20 +264,16 @@ export class Session {
|
|
|
254
264
|
await this.connect(signal);
|
|
255
265
|
return this.peers(signal);
|
|
256
266
|
}
|
|
267
|
+
async nativeControl(operation, certificate, context) {
|
|
268
|
+
await this.connect(context.signal);
|
|
269
|
+
const peer = await this.target(operation.to, context.signal);
|
|
270
|
+
return this.controls.request(this.client, { ...operation, peer: peer.id, certificate }, { ...context, guard: () => this.guard(context.signal) });
|
|
271
|
+
}
|
|
257
272
|
async target(to, signal) {
|
|
258
|
-
|
|
259
|
-
if (resolution.status === "none") {
|
|
260
|
-
throw new Fault(404, "peer_offline", "Peer is not connected.");
|
|
261
|
-
}
|
|
262
|
-
if (resolution.status !== "found" || !resolution.target) {
|
|
263
|
-
throw new Fault(409, "ambiguous_target", "Use an unambiguous peer name or session ID (prefixes need at least 8 characters).");
|
|
264
|
-
}
|
|
265
|
-
if (resolution.target.id === this.client.sessionId) {
|
|
266
|
-
throw new Fault(400, "self_target", "Choose another peer.");
|
|
267
|
-
}
|
|
268
|
-
return resolution.target;
|
|
273
|
+
return resolveBridgeTarget(await this.peers(signal), to, this.client.sessionId);
|
|
269
274
|
}
|
|
270
275
|
async send(peer, message, context, options = {}) {
|
|
276
|
+
await this.connect(context.signal);
|
|
271
277
|
const { signal, requestId } = context;
|
|
272
278
|
context.onPeer(peer);
|
|
273
279
|
this.guard(signal);
|
|
@@ -307,17 +313,6 @@ export class Session {
|
|
|
307
313
|
const waiting = new WaitingAsk();
|
|
308
314
|
this.pending = waiting;
|
|
309
315
|
const { signal } = context;
|
|
310
|
-
const abort = () => {
|
|
311
|
-
try {
|
|
312
|
-
cancelled(signal);
|
|
313
|
-
}
|
|
314
|
-
catch (error) {
|
|
315
|
-
waiting.reject(error instanceof Error
|
|
316
|
-
? error
|
|
317
|
-
: new Fault(499, "client_aborted", "HTTP client disconnected; delivery may be unknown."));
|
|
318
|
-
}
|
|
319
|
-
};
|
|
320
|
-
signal.addEventListener("abort", abort, { once: true });
|
|
321
316
|
try {
|
|
322
317
|
await this.connect(signal);
|
|
323
318
|
const peer = await this.target(operation.to, signal);
|
|
@@ -334,16 +329,37 @@ export class Session {
|
|
|
334
329
|
}
|
|
335
330
|
finally {
|
|
336
331
|
waiting.clear();
|
|
337
|
-
|
|
338
|
-
if (this.pending === waiting) {
|
|
339
|
-
this.pending = undefined;
|
|
340
|
-
}
|
|
332
|
+
this.pending = undefined;
|
|
341
333
|
}
|
|
342
334
|
}
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
335
|
+
async readInbox(operation, context) {
|
|
336
|
+
const read = () => ({
|
|
337
|
+
...this.inbox.page(operation.after, operation.limit),
|
|
338
|
+
...this.inbox.status(this.owner.losses(this.identity.fingerprint256)),
|
|
339
|
+
});
|
|
340
|
+
const deadline = performance.now() + operation.waitMs;
|
|
341
|
+
const losses = read().lostMessages;
|
|
342
|
+
for (;;) {
|
|
343
|
+
// Subscribe to the post-commit signal before rereading the authoritative cursor page.
|
|
344
|
+
const signal = AbortSignal.any([this.inboxChanged.signal, context.signal]);
|
|
345
|
+
const page = read();
|
|
346
|
+
const remaining = deadline - performance.now();
|
|
347
|
+
if (page.messages.length > 0 || page.lostMessages !== losses || remaining <= 0) {
|
|
348
|
+
return page;
|
|
349
|
+
}
|
|
350
|
+
log("inbox_wait", { ...this.fields(), requestId: context.requestId, after: operation.after });
|
|
351
|
+
try {
|
|
352
|
+
// A nonmatching commit must rearm the same cursor, not start parallel readers.
|
|
353
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
354
|
+
await delay(remaining, undefined, { signal });
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
if (!signal.aborted) {
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
this.guard(context.signal);
|
|
362
|
+
}
|
|
347
363
|
}
|
|
348
364
|
async answer(replyTo, message, context) {
|
|
349
365
|
const entry = this.inbox.get(replyTo);
|
|
@@ -367,6 +383,7 @@ export class Session {
|
|
|
367
383
|
}
|
|
368
384
|
const data = { ...(await this.send(peer, message, context, { replyTo })) };
|
|
369
385
|
this.inbox.remove(replyTo);
|
|
386
|
+
this.owner.mailboxChanged?.();
|
|
370
387
|
this.presence();
|
|
371
388
|
return data;
|
|
372
389
|
}
|
|
@@ -385,30 +402,30 @@ export class Session {
|
|
|
385
402
|
await this.connect(context.signal);
|
|
386
403
|
}
|
|
387
404
|
if (operation.route === "POST /v1/ack") {
|
|
388
|
-
|
|
405
|
+
const result = this.inbox.ack(operation.ids);
|
|
406
|
+
this.owner.mailboxChanged?.();
|
|
407
|
+
this.presence();
|
|
408
|
+
return result;
|
|
389
409
|
}
|
|
390
|
-
return
|
|
391
|
-
...this.inbox.page(operation.after, operation.limit),
|
|
392
|
-
...this.inbox.status(this.owner.losses(this.identity.fingerprint256)),
|
|
393
|
-
};
|
|
410
|
+
return this.readInbox(operation, context);
|
|
394
411
|
}
|
|
395
412
|
await this.connect(context.signal);
|
|
396
413
|
switch (operation.route) {
|
|
397
414
|
case "POST /v1/register":
|
|
398
415
|
return { sessionId: this.client.sessionId, name: this.identity.name };
|
|
399
|
-
case "GET /v1/list":
|
|
400
|
-
const sessions = await this.peers(context.signal);
|
|
416
|
+
case "GET /v1/list":
|
|
401
417
|
return {
|
|
402
418
|
sessionId: this.client.sessionId,
|
|
403
|
-
sessions:
|
|
404
|
-
return Object.assign(entry, { target: formatSessionTarget(entry, sessions) });
|
|
405
|
-
}),
|
|
419
|
+
sessions: bridgeSessionList(await this.peers(context.signal)),
|
|
406
420
|
};
|
|
421
|
+
case "POST /v1/send": {
|
|
422
|
+
const peer = await this.target(operation.to, context.signal);
|
|
423
|
+
const receipt = await this.send(peer.id, operation.message, context, {
|
|
424
|
+
messageId: operation.messageId,
|
|
425
|
+
delivery: operation.passive === true ? "passive" : "steer",
|
|
426
|
+
});
|
|
427
|
+
return { ...receipt };
|
|
407
428
|
}
|
|
408
|
-
case "POST /v1/send":
|
|
409
|
-
return {
|
|
410
|
-
...(await this.send((await this.target(operation.to, context.signal)).id, operation.message, context)),
|
|
411
|
-
};
|
|
412
429
|
case "POST /v1/reply":
|
|
413
430
|
return this.answer(operation.replyTo, operation.message, context);
|
|
414
431
|
}
|