@fitchmultz/pi-subagents 0.46.2 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +1 -1
- package/dist/extension/build-info.js +1 -1
- package/dist/pi-intercom/bridge-broadcast.js +60 -0
- package/dist/pi-intercom/bridge-catalog.js +372 -0
- package/dist/pi-intercom/bridge-config.js +47 -26
- package/dist/pi-intercom/bridge-control.js +104 -0
- package/dist/pi-intercom/bridge-files.js +47 -0
- package/dist/pi-intercom/bridge-groups.js +135 -0
- package/dist/pi-intercom/bridge-http.js +8 -1
- package/dist/pi-intercom/bridge-launch-journal.js +84 -0
- package/dist/pi-intercom/bridge-launch-policy.js +71 -5
- package/dist/pi-intercom/bridge-launch.js +186 -76
- package/dist/pi-intercom/bridge-mailbox.js +324 -0
- package/dist/pi-intercom/bridge-operations.js +127 -0
- package/dist/pi-intercom/bridge-presence.js +100 -0
- package/dist/pi-intercom/bridge-protocol.js +7 -0
- package/dist/pi-intercom/bridge-routes.js +190 -17
- package/dist/pi-intercom/bridge-rpc-process.js +43 -15
- package/dist/pi-intercom/bridge-session.js +83 -85
- package/dist/pi-intercom/bridge-target.js +22 -0
- package/dist/pi-intercom/bridge-wake.js +158 -0
- package/dist/pi-intercom/bridge.js +84 -44
- package/dist/pi-intercom/broker/client.js +1 -0
- package/dist/pi-intercom/connection.js +2 -3
- package/dist/pi-intercom/inbound.js +2 -4
- package/dist/pi-intercom/index.js +7 -1
- package/dist/pi-intercom/native-control-auth.js +80 -0
- package/dist/pi-intercom/native-control-protocol.js +111 -0
- package/dist/pi-intercom/native-control.js +130 -0
- package/dist/pi-intercom/saved-transcript.js +135 -0
- package/dist/pi-intercom/session-targets.js +3 -0
- package/dist/pi-intercom/transcript.js +175 -0
- package/docs/intercom-bridge.md +293 -161
- package/docs/intercom.md +1 -1
- package/docs/reference.md +1 -1
- package/package.json +1 -1
- package/scripts/intercom-bridge-credentials.mjs +54 -12
- package/scripts/intercom-remote.mjs +196 -31
- package/scripts/package-smoke.mjs +1 -1
- package/src/pi-intercom/bridge-broadcast.ts +70 -0
- package/src/pi-intercom/bridge-catalog.ts +489 -0
- package/src/pi-intercom/bridge-config.ts +64 -37
- package/src/pi-intercom/bridge-control.ts +179 -0
- package/src/pi-intercom/bridge-files.ts +59 -0
- package/src/pi-intercom/bridge-groups.ts +169 -0
- package/src/pi-intercom/bridge-http.ts +15 -1
- package/src/pi-intercom/bridge-launch-journal.ts +122 -0
- package/src/pi-intercom/bridge-launch-policy.ts +88 -7
- package/src/pi-intercom/bridge-launch.ts +296 -107
- package/src/pi-intercom/bridge-mailbox.ts +395 -0
- package/src/pi-intercom/bridge-operations.ts +169 -0
- package/src/pi-intercom/bridge-presence.ts +128 -0
- package/src/pi-intercom/bridge-protocol.ts +8 -0
- package/src/pi-intercom/bridge-routes.ts +281 -32
- package/src/pi-intercom/bridge-rpc-process.ts +53 -16
- package/src/pi-intercom/bridge-session.ts +106 -105
- package/src/pi-intercom/bridge-target.ts +35 -0
- package/src/pi-intercom/bridge-wake.ts +188 -0
- package/src/pi-intercom/bridge.ts +88 -54
- package/src/pi-intercom/broker/client.ts +3 -0
- package/src/pi-intercom/connection.ts +2 -3
- package/src/pi-intercom/inbound.ts +2 -4
- package/src/pi-intercom/index.ts +7 -1
- package/src/pi-intercom/native-control-auth.ts +89 -0
- package/src/pi-intercom/native-control-protocol.ts +180 -0
- package/src/pi-intercom/native-control.ts +160 -0
- package/src/pi-intercom/saved-transcript.ts +175 -0
- package/src/pi-intercom/session-targets.ts +4 -0
- package/src/pi-intercom/transcript.ts +226 -0
- package/src/pi-intercom/types.ts +2 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { isRecord } from "../shared/unknown.js";
|
|
3
|
+
import { Launcher } from "./bridge-launch.js";
|
|
4
|
+
import { SessionCatalog, savedView, trustedJournal } from "./bridge-catalog.js";
|
|
5
|
+
import { Groups } from "./bridge-groups.js";
|
|
6
|
+
import { broadcastTargets } from "./bridge-broadcast.js";
|
|
7
|
+
import { getPiAgentDir } from "./agent-dir.js";
|
|
8
|
+
import { Fault } from "./bridge-protocol.js";
|
|
9
|
+
import { resolveBridgeTarget } from "./bridge-target.js";
|
|
10
|
+
import { readSavedTranscript } from "./saved-transcript.js";
|
|
11
|
+
/** Request operations own launch/catalog/group state; transport owns admission and cancellation. */
|
|
12
|
+
export class BridgeOperations {
|
|
13
|
+
catalog = new SessionCatalog();
|
|
14
|
+
launcher = new Launcher(this.catalog);
|
|
15
|
+
stateDir;
|
|
16
|
+
groups;
|
|
17
|
+
constructor(stateDir) {
|
|
18
|
+
this.stateDir = stateDir ?? join(getPiAgentDir(), "intercom", "bridge-state");
|
|
19
|
+
}
|
|
20
|
+
revoke(clients) {
|
|
21
|
+
return this.launcher.revoke(clients);
|
|
22
|
+
}
|
|
23
|
+
shutdown() {
|
|
24
|
+
return this.launcher.shutdown();
|
|
25
|
+
}
|
|
26
|
+
groupStore() {
|
|
27
|
+
this.groups ??= new Groups(this.stateDir);
|
|
28
|
+
return this.groups;
|
|
29
|
+
}
|
|
30
|
+
async execute(operation, session, context) {
|
|
31
|
+
const launchContext = {
|
|
32
|
+
identity: session.identity,
|
|
33
|
+
expires: context.expires,
|
|
34
|
+
signal: context.signal,
|
|
35
|
+
peers: (signal) => session.connectedPeers(signal),
|
|
36
|
+
submit: (peer, task) => session.send(peer, task, context),
|
|
37
|
+
};
|
|
38
|
+
switch (operation.route) {
|
|
39
|
+
case "POST /v1/start":
|
|
40
|
+
return this.launcher.start(operation.name, launchContext);
|
|
41
|
+
case "POST /v1/spawn":
|
|
42
|
+
return this.launcher.spawn(operation, launchContext);
|
|
43
|
+
case "POST /v1/resume":
|
|
44
|
+
return this.launcher.resume(operation, launchContext);
|
|
45
|
+
case "GET /v1/sessions":
|
|
46
|
+
return operation.query === undefined
|
|
47
|
+
? this.launcher.list(session.identity)
|
|
48
|
+
: this.launcher.browse(operation.query, launchContext);
|
|
49
|
+
case "POST /v1/stop":
|
|
50
|
+
case "POST /v1/cancel":
|
|
51
|
+
return this.stop(operation, session, context);
|
|
52
|
+
case "GET /v1/groups":
|
|
53
|
+
return { groups: this.groupStore().list(session.identity.fingerprint256) };
|
|
54
|
+
case "POST /v1/groups":
|
|
55
|
+
return {
|
|
56
|
+
groups: this.groupStore().upsert(session.identity.fingerprint256, operation.group),
|
|
57
|
+
};
|
|
58
|
+
case "POST /v1/broadcast":
|
|
59
|
+
return broadcastTargets(session, this.groupStore().targets(session.identity.fingerprint256, operation.group, await session.connectedPeers(context.signal)), operation.message, context);
|
|
60
|
+
case "POST /v1/control":
|
|
61
|
+
return session.nativeControl(operation, context.certificate, context);
|
|
62
|
+
case "GET /v1/transcript":
|
|
63
|
+
return this.transcript(operation, session, context);
|
|
64
|
+
case "GET /v1/inbox":
|
|
65
|
+
case "GET /v1/list":
|
|
66
|
+
case "POST /v1/ack":
|
|
67
|
+
case "POST /v1/ask":
|
|
68
|
+
case "POST /v1/register":
|
|
69
|
+
case "POST /v1/reply":
|
|
70
|
+
case "POST /v1/send":
|
|
71
|
+
return session.execute(operation, context);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async stop(operation, session, context) {
|
|
75
|
+
const cancel = operation.route === "POST /v1/cancel";
|
|
76
|
+
const control = {
|
|
77
|
+
signal: context.signal,
|
|
78
|
+
peers: (signal) => session.connectedPeers(signal),
|
|
79
|
+
};
|
|
80
|
+
try {
|
|
81
|
+
return cancel
|
|
82
|
+
? await this.launcher.cancel(session.identity, operation.sessionId, control)
|
|
83
|
+
: await this.launcher.stop(session.identity, operation.sessionId, control);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
// Only a pre-effect ownership miss may dispatch elsewhere; never retry an unknown outcome.
|
|
87
|
+
if (!(error instanceof Fault) || error.code !== "unknown_session") {
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return session.nativeControl({ to: operation.sessionId, verb: cancel ? "cancel" : "stop", timeoutMs: operation.timeoutMs }, context.certificate, context);
|
|
92
|
+
}
|
|
93
|
+
async transcript(operation, session, context) {
|
|
94
|
+
const peers = await session.connectedPeers(context.signal);
|
|
95
|
+
try {
|
|
96
|
+
const peer = resolveBridgeTarget(peers, operation.target, null);
|
|
97
|
+
const result = await session.nativeControl({
|
|
98
|
+
to: peer.id,
|
|
99
|
+
verb: "transcript",
|
|
100
|
+
options: operation.options,
|
|
101
|
+
timeoutMs: operation.timeoutMs,
|
|
102
|
+
}, context.certificate, context);
|
|
103
|
+
return {
|
|
104
|
+
...result,
|
|
105
|
+
source: "live",
|
|
106
|
+
transcript: isRecord(result.native) ? result.native.transcript : undefined,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (!(error instanceof Fault) || error.code !== "peer_offline") {
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const policy = session.identity.launch;
|
|
115
|
+
if (!policy) {
|
|
116
|
+
throw new Fault(403, "launch_disabled", "Saved transcripts require local resume policy.");
|
|
117
|
+
}
|
|
118
|
+
const saved = await this.catalog.resolve(policy, { target: operation.target }, context.signal);
|
|
119
|
+
trustedJournal(policy, saved.sessionFile);
|
|
120
|
+
const page = await readSavedTranscript(policy.packageRoot, saved.sessionFile, operation.options);
|
|
121
|
+
session.guard(context.signal);
|
|
122
|
+
if (page.sessionId !== saved.sessionId) {
|
|
123
|
+
throw new Fault(409, "session_changed", "Saved journal identity changed during reading.");
|
|
124
|
+
}
|
|
125
|
+
return { status: "ok", source: "saved", session: savedView(saved), transcript: page };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { isRecord } from "../shared/unknown.js";
|
|
3
|
+
import { readBridgeFile, writeBridgeJson } from "./bridge-files.js";
|
|
4
|
+
import { log } from "./bridge-protocol.js";
|
|
5
|
+
export class PersistentPresence {
|
|
6
|
+
registrations = new Map();
|
|
7
|
+
stopped = false;
|
|
8
|
+
timer;
|
|
9
|
+
running;
|
|
10
|
+
owner;
|
|
11
|
+
constructor(owner) {
|
|
12
|
+
this.owner = owner;
|
|
13
|
+
for (const identity of owner.clients().values()) {
|
|
14
|
+
this.restore(identity);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
path(fingerprint) {
|
|
18
|
+
return join(this.owner.stateDir, `presence-${fingerprint}.json`);
|
|
19
|
+
}
|
|
20
|
+
restore(identity) {
|
|
21
|
+
try {
|
|
22
|
+
const value = JSON.parse(readBridgeFile(this.path(identity.fingerprint256), true, 8192).toString("utf8"));
|
|
23
|
+
if (isRecord(value) &&
|
|
24
|
+
value.fingerprint === identity.fingerprint256 &&
|
|
25
|
+
value.name === identity.name &&
|
|
26
|
+
value.cwd === identity.cwd &&
|
|
27
|
+
typeof value.expires === "number" &&
|
|
28
|
+
Number.isFinite(value.expires) &&
|
|
29
|
+
value.expires > Date.now()) {
|
|
30
|
+
this.registrations.set(identity.fingerprint256, {
|
|
31
|
+
fingerprint: identity.fingerprint256,
|
|
32
|
+
name: identity.name,
|
|
33
|
+
cwd: identity.cwd,
|
|
34
|
+
expires: value.expires,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// An absent/invalid saved registration never grants a certificate identity.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
authenticated(identity, expires) {
|
|
43
|
+
const previous = this.registrations.get(identity.fingerprint256);
|
|
44
|
+
if (previous?.expires === expires &&
|
|
45
|
+
previous.name === identity.name &&
|
|
46
|
+
previous.cwd === identity.cwd) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const registration = {
|
|
50
|
+
fingerprint: identity.fingerprint256,
|
|
51
|
+
name: identity.name,
|
|
52
|
+
cwd: identity.cwd,
|
|
53
|
+
expires,
|
|
54
|
+
};
|
|
55
|
+
writeBridgeJson(this.path(identity.fingerprint256), registration);
|
|
56
|
+
this.registrations.set(identity.fingerprint256, registration);
|
|
57
|
+
this.start();
|
|
58
|
+
}
|
|
59
|
+
start() {
|
|
60
|
+
if (!this.stopped && !this.running) {
|
|
61
|
+
clearTimeout(this.timer);
|
|
62
|
+
this.timer = setTimeout(() => {
|
|
63
|
+
this.running = this.maintain().finally(() => {
|
|
64
|
+
this.running = undefined;
|
|
65
|
+
if (!this.stopped) {
|
|
66
|
+
this.timer = setTimeout(() => this.start(), 15000);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}, 0);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async maintain() {
|
|
73
|
+
for (const registration of this.registrations.values()) {
|
|
74
|
+
if (this.stopped) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const identity = this.owner.clients().get(registration.fingerprint);
|
|
78
|
+
if (!identity ||
|
|
79
|
+
identity.name !== registration.name ||
|
|
80
|
+
identity.cwd !== registration.cwd ||
|
|
81
|
+
registration.expires <= Date.now()) {
|
|
82
|
+
this.registrations.delete(registration.fingerprint);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
// Each identity has one connection attempt; preserve the native broker's spawn safeguards.
|
|
87
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
88
|
+
await this.owner.connect(identity, registration.expires);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
log("presence_retry", { name: identity.name, result: "broker_unavailable" });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async stop() {
|
|
96
|
+
this.stopped = true;
|
|
97
|
+
clearTimeout(this.timer);
|
|
98
|
+
await this.running;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -14,6 +14,13 @@ export class Fault extends Error {
|
|
|
14
14
|
this.details = details;
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
|
+
export class BroadcastFault extends Fault {
|
|
18
|
+
outcome;
|
|
19
|
+
constructor(fault, outcome) {
|
|
20
|
+
super(fault.status, fault.code, fault.message, fault.details);
|
|
21
|
+
this.outcome = { ...outcome, status: "partial" };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
17
24
|
export function object(value, fields) {
|
|
18
25
|
if (!isRecord(value)) {
|
|
19
26
|
throw new Fault(400, "invalid_input", "Expected a JSON object.");
|
|
@@ -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);
|
|
@@ -21,42 +34,154 @@ export function routeFor(req) {
|
|
|
21
34
|
Object.keys(req.headers).some((key) => key.startsWith("sec-fetch-"))) {
|
|
22
35
|
throw new Fault(403, "browser_forbidden", "Browser access is not supported.");
|
|
23
36
|
}
|
|
24
|
-
const route = `${req.method ?? ""} ${req.url ?? ""}`;
|
|
37
|
+
const route = `${req.method ?? ""} ${(req.url ?? "").split("?")[0]}`;
|
|
25
38
|
if (!isRoute(route)) {
|
|
26
39
|
throw new Fault(404, "not_found", "Unknown method or path.");
|
|
27
40
|
}
|
|
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.");
|
|
44
|
+
}
|
|
45
|
+
checkGetBody(req);
|
|
46
|
+
return route;
|
|
47
|
+
}
|
|
48
|
+
function checkGetBody(req) {
|
|
28
49
|
if (req.method === "GET" &&
|
|
29
50
|
(req.headers["transfer-encoding"] !== undefined ||
|
|
30
51
|
(req.headers["content-length"] !== undefined && req.headers["content-length"] !== "0"))) {
|
|
31
52
|
throw new Fault(400, "invalid_input", "GET requests must not have a body.");
|
|
32
53
|
}
|
|
33
|
-
return route;
|
|
34
54
|
}
|
|
35
|
-
function
|
|
36
|
-
const
|
|
37
|
-
if (
|
|
38
|
-
throw new Fault(400, "invalid_input", "
|
|
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.");
|
|
59
|
+
}
|
|
60
|
+
return query;
|
|
61
|
+
}
|
|
62
|
+
function inboxQuery(req) {
|
|
63
|
+
const query = queryFor(req, ["after", "limit"]);
|
|
64
|
+
const after = query.get("after") ?? "0";
|
|
65
|
+
const limit = query.get("limit") ?? String(INBOX_COUNT);
|
|
66
|
+
if (!/^(0|[1-9]\d{0,15})$/.test(after) ||
|
|
67
|
+
!Number.isSafeInteger(Number(after)) ||
|
|
68
|
+
!/^[1-9]\d{0,2}$/.test(limit) ||
|
|
69
|
+
Number(limit) > INBOX_COUNT) {
|
|
70
|
+
throw new Fault(400, "invalid_input", "after must be a nonnegative safe integer cursor; limit must be 1..256.");
|
|
71
|
+
}
|
|
72
|
+
return { route: "GET /v1/inbox", after: Number(after), limit: Number(limit) };
|
|
73
|
+
}
|
|
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}.`);
|
|
39
106
|
}
|
|
40
107
|
return ms;
|
|
41
108
|
}
|
|
42
|
-
|
|
43
|
-
|
|
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) {
|
|
44
171
|
switch (route) {
|
|
45
172
|
case "GET /v1/list":
|
|
46
|
-
case "GET /v1/inbox":
|
|
47
173
|
case "POST /v1/register":
|
|
48
|
-
case "GET /v1/
|
|
174
|
+
case "GET /v1/groups":
|
|
49
175
|
return { route };
|
|
50
|
-
case "
|
|
51
|
-
return
|
|
52
|
-
case "
|
|
53
|
-
return { route,
|
|
54
|
-
case "POST /v1/ack":
|
|
176
|
+
case "GET /v1/inbox":
|
|
177
|
+
return inboxQuery(req);
|
|
178
|
+
case "GET /v1/sessions":
|
|
179
|
+
return { route, query: catalogQuery(req) };
|
|
180
|
+
case "POST /v1/ack":
|
|
55
181
|
if (!isUnknownArray(input.ids) || input.ids.length > INBOX_COUNT) {
|
|
56
182
|
throw new Fault(400, "invalid_input", "ids must be an array of at most 256 message IDs.");
|
|
57
183
|
}
|
|
58
184
|
return { route, ids: [...new Set(input.ids.map((id) => label(id, "message ID")))] };
|
|
59
|
-
}
|
|
60
185
|
case "POST /v1/send":
|
|
61
186
|
return { route, message: text(input.message), to: label(input.to, "to").trim() };
|
|
62
187
|
case "POST /v1/ask":
|
|
@@ -68,5 +193,53 @@ export async function operationFor(req, route, signal) {
|
|
|
68
193
|
};
|
|
69
194
|
case "POST /v1/reply":
|
|
70
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);
|
|
71
244
|
}
|
|
72
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.");
|