@fitchmultz/pi-subagents 0.47.0 → 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 +15 -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 +14 -2
- 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-operations.js +127 -0
- package/dist/pi-intercom/bridge-protocol.js +7 -0
- package/dist/pi-intercom/bridge-routes.js +171 -22
- package/dist/pi-intercom/bridge-rpc-process.js +43 -15
- package/dist/pi-intercom/bridge-session.js +24 -22
- package/dist/pi-intercom/bridge-target.js +22 -0
- package/dist/pi-intercom/bridge.js +29 -37
- 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 +285 -175
- package/docs/intercom.md +1 -1
- package/docs/reference.md +1 -1
- package/package.json +1 -1
- package/scripts/intercom-remote.mjs +176 -35
- 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 +19 -2
- 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-operations.ts +169 -0
- package/src/pi-intercom/bridge-protocol.ts +8 -0
- package/src/pi-intercom/bridge-routes.ts +254 -42
- package/src/pi-intercom/bridge-rpc-process.ts +53 -16
- package/src/pi-intercom/bridge-session.ts +35 -36
- package/src/pi-intercom/bridge-target.ts +35 -0
- package/src/pi-intercom/bridge.ts +30 -47
- 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,6 +1,9 @@
|
|
|
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": [],
|
|
@@ -10,8 +13,18 @@ const fields = {
|
|
|
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,13 +52,15 @@ 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"]);
|
|
48
64
|
const after = query.get("after") ?? "0";
|
|
49
65
|
const limit = query.get("limit") ?? String(INBOX_COUNT);
|
|
50
66
|
if (!/^(0|[1-9]\d{0,15})$/.test(after) ||
|
|
@@ -55,32 +71,117 @@ function inboxQuery(req) {
|
|
|
55
71
|
}
|
|
56
72
|
return { route: "GET /v1/inbox", after: Number(after), limit: Number(limit) };
|
|
57
73
|
}
|
|
58
|
-
function
|
|
59
|
-
const
|
|
60
|
-
if (
|
|
61
|
-
throw new Fault(400, "invalid_input", "
|
|
74
|
+
function pageNumber(value, fallback, min, max) {
|
|
75
|
+
const number = value === null ? fallback : Number(value);
|
|
76
|
+
if (value === "" || !Number.isSafeInteger(number) || number < min || number > max) {
|
|
77
|
+
throw new Fault(400, "invalid_input", "Invalid catalog pagination.");
|
|
78
|
+
}
|
|
79
|
+
return number;
|
|
80
|
+
}
|
|
81
|
+
function queryBoolean(value) {
|
|
82
|
+
if (value !== null && value !== "true" && value !== "false") {
|
|
83
|
+
throw new Fault(400, "invalid_input", "Catalog switches must be true or false.");
|
|
84
|
+
}
|
|
85
|
+
return value === "true";
|
|
86
|
+
}
|
|
87
|
+
function catalogQuery(req) {
|
|
88
|
+
if (req.url?.includes("?") !== true) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
const params = queryFor(req, ["live", "saved", "q", "offset", "limit"]);
|
|
92
|
+
return {
|
|
93
|
+
live: queryBoolean(params.get("live")),
|
|
94
|
+
saved: queryBoolean(params.get("saved")),
|
|
95
|
+
q: params.has("q") ? label(params.get("q"), "catalog filter", 128) : undefined,
|
|
96
|
+
offset: pageNumber(params.get("offset"), 0, 0, Number.MAX_SAFE_INTEGER),
|
|
97
|
+
limit: pageNumber(params.get("limit"), 50, 1, 100),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function timeout(value, control = false) {
|
|
101
|
+
const defaultMs = control ? 3000 : ASK_TIMEOUT;
|
|
102
|
+
const ms = value === undefined ? defaultMs : value;
|
|
103
|
+
const max = control ? 10000 : ASK_TIMEOUT;
|
|
104
|
+
if (typeof ms !== "number" || !Number.isInteger(ms) || ms < 100 || ms > max) {
|
|
105
|
+
throw new Fault(400, "invalid_input", `timeoutMs must be an integer from 100 to ${max}.`);
|
|
62
106
|
}
|
|
63
107
|
return ms;
|
|
64
108
|
}
|
|
65
|
-
|
|
66
|
-
|
|
109
|
+
function resume(input) {
|
|
110
|
+
if (["sessionId", "name", "target"].filter((key) => input[key] !== undefined).length !== 1) {
|
|
111
|
+
throw new Fault(400, "invalid_input", "Resume requires exactly one of sessionId, name or target.");
|
|
112
|
+
}
|
|
113
|
+
const sessionId = input.sessionId === undefined ? undefined : label(input.sessionId, "native session ID", 128);
|
|
114
|
+
if (sessionId !== undefined && !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(sessionId)) {
|
|
115
|
+
throw new Fault(400, "invalid_input", "Resume takes a native ID, never a journal path.");
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
route: "POST /v1/resume",
|
|
119
|
+
sessionId,
|
|
120
|
+
target: input.target === undefined ? undefined : label(input.target, "resume target", 256).trim(),
|
|
121
|
+
name: input.name === undefined ? undefined : label(input.name, "saved session name", 256).trim(),
|
|
122
|
+
task: input.task === undefined ? undefined : text(input.task),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function stop(route, input) {
|
|
126
|
+
if (["sessionId", "target", "to"].filter((key) => input[key] !== undefined).length !== 1) {
|
|
127
|
+
throw new Fault(400, "invalid_input", "Control requires exactly one sessionId, target or to.");
|
|
128
|
+
}
|
|
129
|
+
const mode = input.mode ?? "kill";
|
|
130
|
+
if (mode !== "kill" && mode !== "cancel") {
|
|
131
|
+
throw new Fault(400, "invalid_input", "Stop mode must be cancel or kill.");
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
route: mode === "cancel" ? "POST /v1/cancel" : route,
|
|
135
|
+
sessionId: label(input.sessionId ?? input.target ?? input.to, "session target", 256).trim(),
|
|
136
|
+
timeoutMs: timeout(input.timeoutMs, true),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function nativeControl(route, input) {
|
|
140
|
+
const verb = route === "POST /v1/control" ? input.verb : route.split("/").at(-1);
|
|
141
|
+
if (!isControlVerb(verb)) {
|
|
142
|
+
throw new Fault(400, "invalid_input", "Unknown native control verb.");
|
|
143
|
+
}
|
|
144
|
+
const timeoutMs = timeout(input.timeoutMs, true);
|
|
145
|
+
const to = label(input.to ?? input.target, "session target").trim();
|
|
146
|
+
if (verb === "transcript") {
|
|
147
|
+
const options = transcriptOptions(route === "POST /v1/control"
|
|
148
|
+
? input.options
|
|
149
|
+
: { limit: input.limit, maxBytes: input.maxBytes, cursor: input.cursor });
|
|
150
|
+
return { route: "GET /v1/transcript", target: to, options, timeoutMs };
|
|
151
|
+
}
|
|
152
|
+
if (input.options !== undefined) {
|
|
153
|
+
throw new Fault(400, "invalid_input", "Only transcript accepts options.");
|
|
154
|
+
}
|
|
155
|
+
return { route: "POST /v1/control", verb, to, timeoutMs };
|
|
156
|
+
}
|
|
157
|
+
async function operationInput(req, route, signal) {
|
|
158
|
+
if (req.method === "POST") {
|
|
159
|
+
return object(await body(req, signal), fields[route]);
|
|
160
|
+
}
|
|
161
|
+
if (route !== "GET /v1/transcript") {
|
|
162
|
+
return {};
|
|
163
|
+
}
|
|
164
|
+
const input = {};
|
|
165
|
+
for (const [key, value] of queryFor(req, fields[route])) {
|
|
166
|
+
input[key] = ["limit", "maxBytes", "timeoutMs"].includes(key) ? Number(value) : value;
|
|
167
|
+
}
|
|
168
|
+
return input;
|
|
169
|
+
}
|
|
170
|
+
function messagingOperation(route, input, req) {
|
|
67
171
|
switch (route) {
|
|
68
172
|
case "GET /v1/list":
|
|
69
173
|
case "POST /v1/register":
|
|
70
|
-
case "GET /v1/
|
|
174
|
+
case "GET /v1/groups":
|
|
71
175
|
return { route };
|
|
72
176
|
case "GET /v1/inbox":
|
|
73
177
|
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": {
|
|
178
|
+
case "GET /v1/sessions":
|
|
179
|
+
return { route, query: catalogQuery(req) };
|
|
180
|
+
case "POST /v1/ack":
|
|
79
181
|
if (!isUnknownArray(input.ids) || input.ids.length > INBOX_COUNT) {
|
|
80
182
|
throw new Fault(400, "invalid_input", "ids must be an array of at most 256 message IDs.");
|
|
81
183
|
}
|
|
82
184
|
return { route, ids: [...new Set(input.ids.map((id) => label(id, "message ID")))] };
|
|
83
|
-
}
|
|
84
185
|
case "POST /v1/send":
|
|
85
186
|
return { route, message: text(input.message), to: label(input.to, "to").trim() };
|
|
86
187
|
case "POST /v1/ask":
|
|
@@ -92,5 +193,53 @@ export async function operationFor(req, route, signal) {
|
|
|
92
193
|
};
|
|
93
194
|
case "POST /v1/reply":
|
|
94
195
|
return { route, message: text(input.message), replyTo: label(input.replyTo, "replyTo") };
|
|
196
|
+
case "GET /v1/transcript":
|
|
197
|
+
case "POST /v1/control":
|
|
198
|
+
case "POST /v1/transcript":
|
|
199
|
+
case "POST /v1/capabilities":
|
|
200
|
+
return nativeControl(route, input);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
export async function operationFor(req, route, signal) {
|
|
204
|
+
const input = await operationInput(req, route, signal);
|
|
205
|
+
switch (route) {
|
|
206
|
+
case "POST /v1/start":
|
|
207
|
+
return { route, name: label(input.name, "session name", 64).trim() };
|
|
208
|
+
case "POST /v1/spawn":
|
|
209
|
+
return {
|
|
210
|
+
route,
|
|
211
|
+
name: label(input.name, "session name", 64).trim(),
|
|
212
|
+
cwd: input.cwd === undefined ? undefined : label(input.cwd, "cwd", 4096),
|
|
213
|
+
provider: input.provider === undefined ? undefined : label(input.provider, "provider", 128),
|
|
214
|
+
model: input.model === undefined ? undefined : label(input.model, "model reference", 512),
|
|
215
|
+
task: input.task === undefined ? undefined : text(input.task),
|
|
216
|
+
};
|
|
217
|
+
case "POST /v1/resume":
|
|
218
|
+
return resume(input);
|
|
219
|
+
case "POST /v1/stop":
|
|
220
|
+
case "POST /v1/cancel":
|
|
221
|
+
return stop(route, input);
|
|
222
|
+
case "POST /v1/groups":
|
|
223
|
+
return { route, group: groupDefinition(input) };
|
|
224
|
+
case "POST /v1/broadcast":
|
|
225
|
+
return {
|
|
226
|
+
route,
|
|
227
|
+
message: text(input.message),
|
|
228
|
+
group: input.group === undefined ? undefined : label(input.group, "group", 64).trim(),
|
|
229
|
+
};
|
|
230
|
+
case "GET /v1/groups":
|
|
231
|
+
case "GET /v1/inbox":
|
|
232
|
+
case "GET /v1/list":
|
|
233
|
+
case "GET /v1/sessions":
|
|
234
|
+
case "GET /v1/transcript":
|
|
235
|
+
case "POST /v1/ack":
|
|
236
|
+
case "POST /v1/ask":
|
|
237
|
+
case "POST /v1/capabilities":
|
|
238
|
+
case "POST /v1/control":
|
|
239
|
+
case "POST /v1/register":
|
|
240
|
+
case "POST /v1/reply":
|
|
241
|
+
case "POST /v1/send":
|
|
242
|
+
case "POST /v1/transcript":
|
|
243
|
+
return messagingOperation(route, input, req);
|
|
95
244
|
}
|
|
96
245
|
}
|
|
@@ -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,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { IntercomClient } from "./broker/client.js";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveSessionProjectId } from "./session-targets.js";
|
|
4
4
|
import { Fault, cancelled, log, wait } from "./bridge-protocol.js";
|
|
5
5
|
import { Mailbox } from "./bridge-mailbox.js";
|
|
6
|
+
import { resolveBridgeTarget, bridgeSessionList } from "./bridge-target.js";
|
|
7
|
+
import { ControlReplies } from "./bridge-control.js";
|
|
6
8
|
import { WaitingAsk } from "./bridge-ask.js";
|
|
7
9
|
const MAX_REQUESTS = 8;
|
|
8
10
|
const IDLE_TIMEOUT = 5 * 60 * 1000;
|
|
@@ -21,6 +23,7 @@ export class Session {
|
|
|
21
23
|
inbox;
|
|
22
24
|
replying = new Set();
|
|
23
25
|
pending;
|
|
26
|
+
controls = new ControlReplies();
|
|
24
27
|
closed = false;
|
|
25
28
|
connecting;
|
|
26
29
|
disconnecting;
|
|
@@ -51,6 +54,9 @@ export class Session {
|
|
|
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
|
}
|
|
@@ -254,20 +262,16 @@ export class Session {
|
|
|
254
262
|
await this.connect(signal);
|
|
255
263
|
return this.peers(signal);
|
|
256
264
|
}
|
|
265
|
+
async nativeControl(operation, certificate, context) {
|
|
266
|
+
await this.connect(context.signal);
|
|
267
|
+
const peer = await this.target(operation.to, context.signal);
|
|
268
|
+
return this.controls.request(this.client, { ...operation, peer: peer.id, certificate }, { ...context, guard: () => this.guard(context.signal) });
|
|
269
|
+
}
|
|
257
270
|
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;
|
|
271
|
+
return resolveBridgeTarget(await this.peers(signal), to, this.client.sessionId);
|
|
269
272
|
}
|
|
270
273
|
async send(peer, message, context, options = {}) {
|
|
274
|
+
await this.connect(context.signal);
|
|
271
275
|
const { signal, requestId } = context;
|
|
272
276
|
context.onPeer(peer);
|
|
273
277
|
this.guard(signal);
|
|
@@ -400,9 +404,7 @@ export class Session {
|
|
|
400
404
|
const sessions = await this.peers(context.signal);
|
|
401
405
|
return {
|
|
402
406
|
sessionId: this.client.sessionId,
|
|
403
|
-
sessions: sessions
|
|
404
|
-
return Object.assign(entry, { target: formatSessionTarget(entry, sessions) });
|
|
405
|
-
}),
|
|
407
|
+
sessions: bridgeSessionList(sessions),
|
|
406
408
|
};
|
|
407
409
|
}
|
|
408
410
|
case "POST /v1/send":
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { resolveSessionTarget, formatSessionTarget, nativePeerId } from "./session-targets.js";
|
|
2
|
+
import { Fault } from "./bridge-protocol.js";
|
|
3
|
+
export function bridgeSessionList(peers) {
|
|
4
|
+
return peers.map(({ topics: _topics, subscriptions: _subscriptions, ...entry }) => ({
|
|
5
|
+
...entry,
|
|
6
|
+
target: formatSessionTarget(entry, peers),
|
|
7
|
+
}));
|
|
8
|
+
}
|
|
9
|
+
export function resolveBridgeTarget(peers, to, self) {
|
|
10
|
+
const exactNative = peers.find((peer) => peer.id === nativePeerId(to));
|
|
11
|
+
const resolution = resolveSessionTarget(peers, exactNative?.id ?? to);
|
|
12
|
+
if (resolution.status === "none") {
|
|
13
|
+
throw new Fault(404, "peer_offline", "Peer is not connected.");
|
|
14
|
+
}
|
|
15
|
+
if (resolution.status !== "found" || !resolution.target) {
|
|
16
|
+
throw new Fault(409, "ambiguous_target", "Use an unambiguous peer name or session ID (prefixes need at least 8 characters).");
|
|
17
|
+
}
|
|
18
|
+
if (resolution.target.id === self) {
|
|
19
|
+
throw new Fault(400, "self_target", "Choose another peer.");
|
|
20
|
+
}
|
|
21
|
+
return resolution.target;
|
|
22
|
+
}
|
|
@@ -7,12 +7,21 @@ import { loadConfig, sameListener } from "./bridge-config.js";
|
|
|
7
7
|
import { ASK_TIMEOUT, Fault, fingerprint, log } from "./bridge-protocol.js";
|
|
8
8
|
import { Session } from "./bridge-session.js";
|
|
9
9
|
import { operationFor, routeFor } from "./bridge-routes.js";
|
|
10
|
-
import { reply } from "./bridge-http.js";
|
|
11
|
-
import {
|
|
10
|
+
import { reply, replyFault } from "./bridge-http.js";
|
|
11
|
+
import { sameIdentity } from "./bridge-launch.js";
|
|
12
|
+
import { BridgeOperations } from "./bridge-operations.js";
|
|
13
|
+
import { authenticatedCertificateChain } from "./native-control-auth.js";
|
|
12
14
|
import { PersistentPresence } from "./bridge-presence.js";
|
|
13
15
|
import { WakeDeliveries } from "./bridge-wake.js";
|
|
14
16
|
import { spawnBrokerIfNeeded } from "./broker/spawn.js";
|
|
15
17
|
const MAX_CONNECTIONS = 128;
|
|
18
|
+
const LONG_ROUTES = new Set([
|
|
19
|
+
"POST /v1/start",
|
|
20
|
+
"POST /v1/spawn",
|
|
21
|
+
"POST /v1/resume",
|
|
22
|
+
"GET /v1/transcript",
|
|
23
|
+
"POST /v1/transcript",
|
|
24
|
+
]);
|
|
16
25
|
function validCertificateDates(starts, expires) {
|
|
17
26
|
return (Number.isFinite(starts) &&
|
|
18
27
|
Number.isFinite(expires) &&
|
|
@@ -24,7 +33,7 @@ class Bridge {
|
|
|
24
33
|
configPath;
|
|
25
34
|
sessions = new Map();
|
|
26
35
|
losses = new Map();
|
|
27
|
-
|
|
36
|
+
operations;
|
|
28
37
|
sockets = new Set();
|
|
29
38
|
connections = new Map();
|
|
30
39
|
server;
|
|
@@ -35,6 +44,7 @@ class Bridge {
|
|
|
35
44
|
constructor(config, configPath) {
|
|
36
45
|
this.config = config;
|
|
37
46
|
this.configPath = configPath;
|
|
47
|
+
this.operations = new BridgeOperations(config.stateDir);
|
|
38
48
|
this.deliveries = new WakeDeliveries(config.stateDir, (identity) => this.allowed(identity));
|
|
39
49
|
if (config.stateDir !== undefined) {
|
|
40
50
|
this.presence = new PersistentPresence({
|
|
@@ -221,13 +231,13 @@ class Bridge {
|
|
|
221
231
|
}
|
|
222
232
|
this.config = next;
|
|
223
233
|
this.revokeRemoved();
|
|
224
|
-
this.
|
|
234
|
+
this.operations.revoke(next.clients).catch(() => log("launch_cleanup", { result: "failed" }));
|
|
225
235
|
log("allowlist_reloaded", { result: "accepted", clients: next.clients.size });
|
|
226
236
|
}
|
|
227
237
|
catch {
|
|
228
238
|
this.config = { ...this.config, clients: new Map() };
|
|
229
239
|
this.deliveries.reload();
|
|
230
|
-
this.
|
|
240
|
+
this.operations
|
|
231
241
|
.revoke(this.config.clients)
|
|
232
242
|
.catch(() => log("launch_cleanup", { result: "failed" }));
|
|
233
243
|
for (const session of this.sessions.values()) {
|
|
@@ -280,7 +290,7 @@ class Bridge {
|
|
|
280
290
|
await Promise.all([
|
|
281
291
|
...this.handlers,
|
|
282
292
|
...sessions.map((session) => session.settled()),
|
|
283
|
-
this.
|
|
293
|
+
this.operations.shutdown(),
|
|
284
294
|
this.presence?.stop(),
|
|
285
295
|
this.deliveries.stop(),
|
|
286
296
|
]);
|
|
@@ -302,7 +312,11 @@ class Bridge {
|
|
|
302
312
|
this.drop(connection.fingerprint, fault);
|
|
303
313
|
throw fault;
|
|
304
314
|
}
|
|
305
|
-
return {
|
|
315
|
+
return {
|
|
316
|
+
identity,
|
|
317
|
+
expires: connection.expires,
|
|
318
|
+
certificate: authenticatedCertificateChain(socket),
|
|
319
|
+
};
|
|
306
320
|
}
|
|
307
321
|
async handle(req, res) {
|
|
308
322
|
const requestId = randomUUID();
|
|
@@ -322,7 +336,7 @@ class Bridge {
|
|
|
322
336
|
deadline = setTimeout(() => session?.close(new Fault(504, "request_timeout", "HTTP operation deadline exceeded; delivery may be unknown.")), Math.max(1, ms - (Date.now() - started)));
|
|
323
337
|
};
|
|
324
338
|
try {
|
|
325
|
-
const { identity, expires } = this.admission(req);
|
|
339
|
+
const { identity, expires, certificate } = this.admission(req);
|
|
326
340
|
const route = routeFor(req);
|
|
327
341
|
session = this.session(identity, expires);
|
|
328
342
|
controller = session.begin();
|
|
@@ -330,15 +344,19 @@ class Bridge {
|
|
|
330
344
|
req.once("aborted", abort);
|
|
331
345
|
res.once("close", onClose);
|
|
332
346
|
const duration = route === "POST /v1/ask" ? ASK_TIMEOUT + 15000 : 15000;
|
|
333
|
-
setDeadline(route
|
|
347
|
+
setDeadline(LONG_ROUTES.has(route) ? 75000 : duration);
|
|
334
348
|
const operation = await operationFor(req, route, signal);
|
|
335
349
|
if (operation.route === "POST /v1/ask") {
|
|
336
350
|
setDeadline(operation.timeoutMs + 15000);
|
|
337
351
|
}
|
|
338
|
-
|
|
352
|
+
else if (operation.route === "GET /v1/transcript") {
|
|
353
|
+
setDeadline(75000);
|
|
354
|
+
}
|
|
355
|
+
const data = await this.operations.execute(operation, session, {
|
|
339
356
|
signal,
|
|
340
357
|
requestId,
|
|
341
358
|
expires,
|
|
359
|
+
certificate,
|
|
342
360
|
onPeer: (id) => {
|
|
343
361
|
peer = id;
|
|
344
362
|
},
|
|
@@ -352,10 +370,7 @@ class Bridge {
|
|
|
352
370
|
? error
|
|
353
371
|
: new Fault(503, "broker_unavailable", "Local broker operation failed.");
|
|
354
372
|
log("request", { ...session?.fields(), requestId, peer, result: fault.code });
|
|
355
|
-
|
|
356
|
-
ok: false,
|
|
357
|
-
error: { code: fault.code, message: fault.message, ...fault.details },
|
|
358
|
-
});
|
|
373
|
+
replyFault(res, requestId, fault);
|
|
359
374
|
}
|
|
360
375
|
finally {
|
|
361
376
|
clearTimeout(deadline);
|
|
@@ -366,29 +381,6 @@ class Bridge {
|
|
|
366
381
|
}
|
|
367
382
|
}
|
|
368
383
|
}
|
|
369
|
-
async execute(operation, session, context) {
|
|
370
|
-
switch (operation.route) {
|
|
371
|
-
case "POST /v1/start":
|
|
372
|
-
return this.launcher.start(operation.name, {
|
|
373
|
-
identity: session.identity,
|
|
374
|
-
expires: context.expires,
|
|
375
|
-
signal: context.signal,
|
|
376
|
-
peers: (signal) => session.connectedPeers(signal),
|
|
377
|
-
});
|
|
378
|
-
case "GET /v1/sessions":
|
|
379
|
-
return this.launcher.list(session.identity);
|
|
380
|
-
case "POST /v1/stop":
|
|
381
|
-
return this.launcher.stop(session.identity, operation.sessionId);
|
|
382
|
-
case "GET /v1/list":
|
|
383
|
-
case "GET /v1/inbox":
|
|
384
|
-
case "POST /v1/register":
|
|
385
|
-
case "POST /v1/ask":
|
|
386
|
-
case "POST /v1/send":
|
|
387
|
-
case "POST /v1/reply":
|
|
388
|
-
case "POST /v1/ack":
|
|
389
|
-
return session.execute(operation, context);
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
384
|
}
|
|
393
385
|
function main() {
|
|
394
386
|
const args = process.argv.slice(2);
|
|
@@ -320,6 +320,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
320
320
|
expectsReply: options.expectsReply,
|
|
321
321
|
...(options.human ? { human: options.human } : {}),
|
|
322
322
|
...(options.topic ? { topic: options.topic } : {}),
|
|
323
|
+
...(options.control ? { control: options.control } : {}),
|
|
323
324
|
delivery: messageDelivery(options),
|
|
324
325
|
queueMode: options.queueMode,
|
|
325
326
|
threadId: options.threadId,
|