@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
|
@@ -3,7 +3,7 @@ import { StringDecoder } from "node:string_decoder";
|
|
|
3
3
|
import type { RpcCommand, RpcExtensionUIResponse } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { trySignalChildTree } from "../shared/post-exit-stdio-guard.ts";
|
|
5
5
|
import { isRecord, type UnknownRecord } from "../shared/unknown.ts";
|
|
6
|
-
import { Fault, log, wait } from "./bridge-protocol.ts";
|
|
6
|
+
import { Fault, cancelled, log, wait } from "./bridge-protocol.ts";
|
|
7
7
|
import type { LaunchPolicy } from "./bridge-launch-policy.ts";
|
|
8
8
|
|
|
9
9
|
const FRAME_LIMIT = 4 * 1024 * 1024;
|
|
@@ -15,7 +15,7 @@ function launchEnvironment(packageRoot: string): NodeJS.ProcessEnv {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
// Native RpcClient lacks public EOF/exit/UI control and retains/reprints unbounded stderr.
|
|
18
|
-
// This owner implements
|
|
18
|
+
// This owner implements state, owned-run cancellation and dialog dismissal, never a raw RPC proxy.
|
|
19
19
|
export class RpcProcess {
|
|
20
20
|
private readonly child: ChildProcessWithoutNullStreams;
|
|
21
21
|
readonly exited: Promise<void>;
|
|
@@ -27,15 +27,17 @@ export class RpcProcess {
|
|
|
27
27
|
private sequence = 0;
|
|
28
28
|
private pending?: {
|
|
29
29
|
readonly id: string;
|
|
30
|
+
readonly command: "get_state" | "abort";
|
|
30
31
|
readonly resolve: (data: UnknownRecord) => void;
|
|
31
32
|
readonly reject: (error: Readonly<Error>) => void;
|
|
32
33
|
};
|
|
33
34
|
private stopping?: Promise<void>;
|
|
34
35
|
private stderrBytes = 0;
|
|
35
36
|
private exitObserved = false;
|
|
37
|
+
private cancelling = false;
|
|
36
38
|
|
|
37
|
-
constructor(policy: LaunchPolicy, cwd: string, sessionFile: string) {
|
|
38
|
-
this.child = spawn(process.execPath, this.arguments(policy, sessionFile), {
|
|
39
|
+
constructor(policy: LaunchPolicy, cwd: string, sessionFile: string, resuming = false) {
|
|
40
|
+
this.child = spawn(process.execPath, this.arguments(policy, sessionFile, resuming), {
|
|
39
41
|
cwd,
|
|
40
42
|
detached: true,
|
|
41
43
|
env: launchEnvironment(policy.packageRoot),
|
|
@@ -78,19 +80,19 @@ export class RpcProcess {
|
|
|
78
80
|
get pid(): number | undefined {
|
|
79
81
|
return this.child.pid;
|
|
80
82
|
}
|
|
81
|
-
private arguments(policy: LaunchPolicy, sessionFile: string): string[] {
|
|
83
|
+
private arguments(policy: LaunchPolicy, sessionFile: string, resuming: boolean): string[] {
|
|
82
84
|
const args = [
|
|
83
85
|
policy.cli,
|
|
84
86
|
"--mode",
|
|
85
87
|
"rpc",
|
|
86
88
|
"--session",
|
|
87
89
|
sessionFile,
|
|
88
|
-
"--provider",
|
|
89
|
-
policy.provider,
|
|
90
|
-
"--model",
|
|
91
|
-
policy.model,
|
|
92
90
|
policy.trustProject ? "--approve" : "--no-approve",
|
|
93
91
|
];
|
|
92
|
+
// Resume leaves native model/thinking restoration authoritative; never force the launch default.
|
|
93
|
+
if (!resuming && policy.provider !== undefined && policy.model !== undefined) {
|
|
94
|
+
args.push("--provider", policy.provider, "--model", policy.model);
|
|
95
|
+
}
|
|
94
96
|
if (!policy.discoverResources) {
|
|
95
97
|
args.push(
|
|
96
98
|
"--no-extensions",
|
|
@@ -191,8 +193,12 @@ export class RpcProcess {
|
|
|
191
193
|
private response(data: UnknownRecord): void {
|
|
192
194
|
const pending = this.pending;
|
|
193
195
|
this.pending = undefined;
|
|
194
|
-
if (
|
|
195
|
-
|
|
196
|
+
if (
|
|
197
|
+
data.success === true &&
|
|
198
|
+
data.command === pending?.command &&
|
|
199
|
+
(pending?.command !== "get_state" || isRecord(data.data))
|
|
200
|
+
) {
|
|
201
|
+
pending?.resolve(isRecord(data.data) ? data.data : {});
|
|
196
202
|
} else {
|
|
197
203
|
pending?.reject(new Fault(503, "rpc_failed", "Pi RPC state request failed."));
|
|
198
204
|
}
|
|
@@ -218,7 +224,7 @@ export class RpcProcess {
|
|
|
218
224
|
}
|
|
219
225
|
private write(
|
|
220
226
|
command: Readonly<
|
|
221
|
-
| Extract<RpcCommand, { type: "get_state" }>
|
|
227
|
+
| Extract<RpcCommand, { type: "get_state" | "abort" }>
|
|
222
228
|
| Extract<RpcExtensionUIResponse, { cancelled: true }>
|
|
223
229
|
>,
|
|
224
230
|
): void {
|
|
@@ -228,14 +234,25 @@ export class RpcProcess {
|
|
|
228
234
|
this.cleanup();
|
|
229
235
|
}
|
|
230
236
|
}
|
|
231
|
-
async
|
|
237
|
+
private async command(
|
|
238
|
+
command: "get_state" | "abort",
|
|
239
|
+
signal: AbortSignal,
|
|
240
|
+
): Promise<UnknownRecord> {
|
|
241
|
+
cancelled(signal);
|
|
232
242
|
if (this.failure) {
|
|
233
243
|
throw this.failure;
|
|
234
244
|
}
|
|
235
|
-
|
|
245
|
+
if (this.pending) {
|
|
246
|
+
throw new Fault(
|
|
247
|
+
409,
|
|
248
|
+
"control_in_progress",
|
|
249
|
+
"A native control operation is already in flight.",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
const id = `control-${++this.sequence}`;
|
|
236
253
|
const result = new Promise<UnknownRecord>((resolve, reject) => {
|
|
237
|
-
this.pending = { id, resolve, reject };
|
|
238
|
-
this.write({ type:
|
|
254
|
+
this.pending = { id, command, resolve, reject };
|
|
255
|
+
this.write({ type: command, id });
|
|
239
256
|
});
|
|
240
257
|
try {
|
|
241
258
|
return await wait(result, signal);
|
|
@@ -243,6 +260,26 @@ export class RpcProcess {
|
|
|
243
260
|
this.pending = undefined;
|
|
244
261
|
}
|
|
245
262
|
}
|
|
263
|
+
async state(signal: AbortSignal): Promise<UnknownRecord> {
|
|
264
|
+
return this.command("get_state", signal);
|
|
265
|
+
}
|
|
266
|
+
async cancel(signal: AbortSignal): Promise<UnknownRecord> {
|
|
267
|
+
if (this.cancelling) {
|
|
268
|
+
throw new Fault(409, "control_in_progress", "Native cancellation is already in flight.");
|
|
269
|
+
}
|
|
270
|
+
this.cancelling = true;
|
|
271
|
+
try {
|
|
272
|
+
await this.command("abort", signal);
|
|
273
|
+
const state = await this.state(signal);
|
|
274
|
+
if (state.isStreaming !== false) {
|
|
275
|
+
throw new Fault(409, "cancel_unconfirmed", "Native Pi has not reached idle after abort.");
|
|
276
|
+
}
|
|
277
|
+
this.status = "idle";
|
|
278
|
+
return { isStreaming: false, pendingMessageCount: state.pendingMessageCount };
|
|
279
|
+
} finally {
|
|
280
|
+
this.cancelling = false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
246
283
|
ready(): void {
|
|
247
284
|
if (this.failure || this.exitObserved) {
|
|
248
285
|
throw this.failure ?? new Fault(503, "session_exited", "Pi exited during startup.");
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { IntercomClient } from "./broker/client.ts";
|
|
3
|
-
import {
|
|
4
|
-
formatSessionTarget,
|
|
5
|
-
resolveSessionProjectId,
|
|
6
|
-
resolveSessionTarget,
|
|
7
|
-
} from "./session-targets.ts";
|
|
3
|
+
import { resolveSessionProjectId } from "./session-targets.ts";
|
|
8
4
|
import type { Message, SendResult, SessionInfo } from "./types.ts";
|
|
9
5
|
import type { Identity } from "./bridge-config.ts";
|
|
10
6
|
import { Fault, cancelled, log, wait } from "./bridge-protocol.ts";
|
|
11
7
|
import { Mailbox } from "./bridge-mailbox.ts";
|
|
12
8
|
import type { UnknownRecord } from "../shared/unknown.ts";
|
|
13
9
|
import type { MessagingOperation as Operation } from "./bridge-routes.ts";
|
|
10
|
+
import type { RequestContext } from "./bridge-http.ts";
|
|
11
|
+
import { resolveBridgeTarget, bridgeSessionList } from "./bridge-target.ts";
|
|
12
|
+
import { ControlReplies } from "./bridge-control.ts";
|
|
13
|
+
import type { NativeControlOperation } from "./native-control-protocol.ts";
|
|
14
14
|
|
|
15
15
|
import { WaitingAsk } from "./bridge-ask.ts";
|
|
16
16
|
const MAX_REQUESTS = 8;
|
|
@@ -18,11 +18,6 @@ const IDLE_TIMEOUT = 5 * 60 * 1000;
|
|
|
18
18
|
const SEND_TIMEOUT = 8000;
|
|
19
19
|
const LIST_TIMEOUT = 5000;
|
|
20
20
|
|
|
21
|
-
interface RequestContext {
|
|
22
|
-
readonly signal: AbortSignal;
|
|
23
|
-
readonly requestId: string;
|
|
24
|
-
readonly onPeer: (peer: string) => void;
|
|
25
|
-
}
|
|
26
21
|
interface SendOptions {
|
|
27
22
|
readonly messageId?: string;
|
|
28
23
|
readonly replyTo?: string;
|
|
@@ -50,6 +45,7 @@ export class Session {
|
|
|
50
45
|
private readonly inbox: Mailbox;
|
|
51
46
|
private readonly replying = new Set<string>();
|
|
52
47
|
private pending?: WaitingAsk;
|
|
48
|
+
private readonly controls = new ControlReplies();
|
|
53
49
|
private closed = false;
|
|
54
50
|
private connecting?: Promise<void>;
|
|
55
51
|
private disconnecting?: Promise<void>;
|
|
@@ -86,6 +82,9 @@ export class Session {
|
|
|
86
82
|
if (this.closed) {
|
|
87
83
|
throw this.closeReason;
|
|
88
84
|
}
|
|
85
|
+
this.authorize();
|
|
86
|
+
}
|
|
87
|
+
authorize(): void {
|
|
89
88
|
if (!this.owner.allowed(this.identity)) {
|
|
90
89
|
throw new Fault(403, "revoked", "Identity is no longer authorized.");
|
|
91
90
|
}
|
|
@@ -197,21 +196,19 @@ export class Session {
|
|
|
197
196
|
this.owner.retired(this.identity.fingerprint256, this.generation);
|
|
198
197
|
log("session_closed", { ...this.fields(), result: this.closeReason.code });
|
|
199
198
|
}
|
|
200
|
-
close(
|
|
199
|
+
close(fault: Readonly<Fault>): void {
|
|
201
200
|
if (this.closed) {
|
|
202
201
|
return;
|
|
203
202
|
}
|
|
204
203
|
this.closed = true;
|
|
205
|
-
this.closeReason =
|
|
204
|
+
this.closeReason = fault;
|
|
206
205
|
clearTimeout(this.lease);
|
|
207
|
-
this.pending?.reject(
|
|
206
|
+
this.pending?.reject(fault);
|
|
207
|
+
this.controls.close(fault);
|
|
208
208
|
for (const controller of this.requests) {
|
|
209
|
-
controller.abort(
|
|
209
|
+
controller.abort(fault);
|
|
210
210
|
}
|
|
211
211
|
// Retain the generation until acquisition/registration AND final disconnect settle.
|
|
212
|
-
this.startRetirement();
|
|
213
|
-
}
|
|
214
|
-
private startRetirement(): void {
|
|
215
212
|
this.retirement = this.retire().catch((caught: unknown) => this.cleanupFailed(caught));
|
|
216
213
|
}
|
|
217
214
|
async settled(): Promise<void> {
|
|
@@ -219,6 +216,7 @@ export class Session {
|
|
|
219
216
|
}
|
|
220
217
|
|
|
221
218
|
private peerLeft(id: string): void {
|
|
219
|
+
this.controls.peerLeft(id);
|
|
222
220
|
if (this.pending?.peer === id && !this.pending.settled) {
|
|
223
221
|
this.pending.reject(
|
|
224
222
|
new Fault(409, "peer_offline", "Ask recipient disconnected.", { id: this.pending.id }),
|
|
@@ -240,6 +238,9 @@ export class Session {
|
|
|
240
238
|
);
|
|
241
239
|
return;
|
|
242
240
|
}
|
|
241
|
+
if (this.controls.receive(from.id, message)) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
243
244
|
if (!this.inbox.persistent && this.resolveReply(from, message)) {
|
|
244
245
|
return;
|
|
245
246
|
}
|
|
@@ -320,29 +321,29 @@ export class Session {
|
|
|
320
321
|
await this.connect(signal);
|
|
321
322
|
return this.peers(signal);
|
|
322
323
|
}
|
|
324
|
+
async nativeControl(
|
|
325
|
+
operation: NativeControlOperation,
|
|
326
|
+
certificate: string,
|
|
327
|
+
context: RequestContext,
|
|
328
|
+
): Promise<UnknownRecord> {
|
|
329
|
+
await this.connect(context.signal);
|
|
330
|
+
const peer = await this.target(operation.to, context.signal);
|
|
331
|
+
return this.controls.request(
|
|
332
|
+
this.client,
|
|
333
|
+
{ ...operation, peer: peer.id, certificate },
|
|
334
|
+
{ ...context, guard: () => this.guard(context.signal) },
|
|
335
|
+
);
|
|
336
|
+
}
|
|
323
337
|
private async target(to: string, signal: AbortSignal): Promise<SessionInfo> {
|
|
324
|
-
|
|
325
|
-
if (resolution.status === "none") {
|
|
326
|
-
throw new Fault(404, "peer_offline", "Peer is not connected.");
|
|
327
|
-
}
|
|
328
|
-
if (resolution.status !== "found" || !resolution.target) {
|
|
329
|
-
throw new Fault(
|
|
330
|
-
409,
|
|
331
|
-
"ambiguous_target",
|
|
332
|
-
"Use an unambiguous peer name or session ID (prefixes need at least 8 characters).",
|
|
333
|
-
);
|
|
334
|
-
}
|
|
335
|
-
if (resolution.target.id === this.client.sessionId) {
|
|
336
|
-
throw new Fault(400, "self_target", "Choose another peer.");
|
|
337
|
-
}
|
|
338
|
-
return resolution.target;
|
|
338
|
+
return resolveBridgeTarget(await this.peers(signal), to, this.client.sessionId);
|
|
339
339
|
}
|
|
340
|
-
|
|
340
|
+
async send(
|
|
341
341
|
peer: string,
|
|
342
342
|
message: string,
|
|
343
343
|
context: RequestContext,
|
|
344
344
|
options: SendOptions = {},
|
|
345
345
|
): Promise<SendResult> {
|
|
346
|
+
await this.connect(context.signal);
|
|
346
347
|
const { signal, requestId } = context;
|
|
347
348
|
context.onPeer(peer);
|
|
348
349
|
this.guard(signal);
|
|
@@ -492,9 +493,7 @@ export class Session {
|
|
|
492
493
|
const sessions = await this.peers(context.signal);
|
|
493
494
|
return {
|
|
494
495
|
sessionId: this.client.sessionId,
|
|
495
|
-
sessions: sessions
|
|
496
|
-
return Object.assign(entry, { target: formatSessionTarget(entry, sessions) });
|
|
497
|
-
}),
|
|
496
|
+
sessions: bridgeSessionList(sessions),
|
|
498
497
|
};
|
|
499
498
|
}
|
|
500
499
|
case "POST /v1/send":
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { resolveSessionTarget, formatSessionTarget, nativePeerId } from "./session-targets.ts";
|
|
2
|
+
import { Fault } from "./bridge-protocol.ts";
|
|
3
|
+
import type { SessionInfo } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export function bridgeSessionList(
|
|
6
|
+
peers: readonly SessionInfo[],
|
|
7
|
+
): readonly (SessionInfo & { readonly target: string })[] {
|
|
8
|
+
return peers.map(({ topics: _topics, subscriptions: _subscriptions, ...entry }) => ({
|
|
9
|
+
...entry,
|
|
10
|
+
target: formatSessionTarget(entry, peers),
|
|
11
|
+
}));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function resolveBridgeTarget(
|
|
15
|
+
peers: readonly SessionInfo[],
|
|
16
|
+
to: string,
|
|
17
|
+
self: string | null,
|
|
18
|
+
): SessionInfo {
|
|
19
|
+
const exactNative = peers.find((peer) => peer.id === nativePeerId(to));
|
|
20
|
+
const resolution = resolveSessionTarget(peers, exactNative?.id ?? to);
|
|
21
|
+
if (resolution.status === "none") {
|
|
22
|
+
throw new Fault(404, "peer_offline", "Peer is not connected.");
|
|
23
|
+
}
|
|
24
|
+
if (resolution.status !== "found" || !resolution.target) {
|
|
25
|
+
throw new Fault(
|
|
26
|
+
409,
|
|
27
|
+
"ambiguous_target",
|
|
28
|
+
"Use an unambiguous peer name or session ID (prefixes need at least 8 characters).",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
if (resolution.target.id === self) {
|
|
32
|
+
throw new Fault(400, "self_target", "Choose another peer.");
|
|
33
|
+
}
|
|
34
|
+
return resolution.target;
|
|
35
|
+
}
|
|
@@ -8,14 +8,23 @@ import { getBrokerSocketPath } from "./broker/paths.ts";
|
|
|
8
8
|
import { loadConfig, sameListener, type Config, type Identity } from "./bridge-config.ts";
|
|
9
9
|
import { ASK_TIMEOUT, Fault, fingerprint, log } from "./bridge-protocol.ts";
|
|
10
10
|
import { Session } from "./bridge-session.ts";
|
|
11
|
-
import { operationFor, routeFor
|
|
12
|
-
import { reply } from "./bridge-http.ts";
|
|
13
|
-
import {
|
|
11
|
+
import { operationFor, routeFor } from "./bridge-routes.ts";
|
|
12
|
+
import { reply, replyFault } from "./bridge-http.ts";
|
|
13
|
+
import { sameIdentity } from "./bridge-launch.ts";
|
|
14
|
+
import { BridgeOperations } from "./bridge-operations.ts";
|
|
15
|
+
import { authenticatedCertificateChain } from "./native-control-auth.ts";
|
|
14
16
|
import { PersistentPresence } from "./bridge-presence.ts";
|
|
15
17
|
import { WakeDeliveries } from "./bridge-wake.ts";
|
|
16
18
|
import { spawnBrokerIfNeeded } from "./broker/spawn.ts";
|
|
17
19
|
|
|
18
20
|
const MAX_CONNECTIONS = 128;
|
|
21
|
+
const LONG_ROUTES = new Set([
|
|
22
|
+
"POST /v1/start",
|
|
23
|
+
"POST /v1/spawn",
|
|
24
|
+
"POST /v1/resume",
|
|
25
|
+
"GET /v1/transcript",
|
|
26
|
+
"POST /v1/transcript",
|
|
27
|
+
]);
|
|
19
28
|
|
|
20
29
|
function validCertificateDates(starts: number, expires: number): boolean {
|
|
21
30
|
return (
|
|
@@ -37,7 +46,7 @@ class Bridge {
|
|
|
37
46
|
private readonly configPath: string;
|
|
38
47
|
private readonly sessions = new Map<string, Session>();
|
|
39
48
|
private readonly losses = new Map<string, number>();
|
|
40
|
-
private readonly
|
|
49
|
+
private readonly operations: BridgeOperations;
|
|
41
50
|
private readonly sockets = new Set<Socket>();
|
|
42
51
|
private readonly connections = new Map<TLSSocket, Connection>();
|
|
43
52
|
private readonly server: Server;
|
|
@@ -49,6 +58,7 @@ class Bridge {
|
|
|
49
58
|
constructor(config: Config, configPath: string) {
|
|
50
59
|
this.config = config;
|
|
51
60
|
this.configPath = configPath;
|
|
61
|
+
this.operations = new BridgeOperations(config.stateDir);
|
|
52
62
|
this.deliveries = new WakeDeliveries(config.stateDir, (identity) => this.allowed(identity));
|
|
53
63
|
if (config.stateDir !== undefined) {
|
|
54
64
|
this.presence = new PersistentPresence({
|
|
@@ -245,12 +255,12 @@ class Bridge {
|
|
|
245
255
|
}
|
|
246
256
|
this.config = next;
|
|
247
257
|
this.revokeRemoved();
|
|
248
|
-
this.
|
|
258
|
+
this.operations.revoke(next.clients).catch(() => log("launch_cleanup", { result: "failed" }));
|
|
249
259
|
log("allowlist_reloaded", { result: "accepted", clients: next.clients.size });
|
|
250
260
|
} catch {
|
|
251
261
|
this.config = { ...this.config, clients: new Map() };
|
|
252
262
|
this.deliveries.reload();
|
|
253
|
-
this.
|
|
263
|
+
this.operations
|
|
254
264
|
.revoke(this.config.clients)
|
|
255
265
|
.catch(() => log("launch_cleanup", { result: "failed" }));
|
|
256
266
|
for (const session of this.sessions.values()) {
|
|
@@ -312,7 +322,7 @@ class Bridge {
|
|
|
312
322
|
await Promise.all([
|
|
313
323
|
...this.handlers,
|
|
314
324
|
...sessions.map((session) => session.settled()),
|
|
315
|
-
this.
|
|
325
|
+
this.operations.shutdown(),
|
|
316
326
|
this.presence?.stop(),
|
|
317
327
|
this.deliveries.stop(),
|
|
318
328
|
]);
|
|
@@ -321,6 +331,7 @@ class Bridge {
|
|
|
321
331
|
private admission(req: IncomingMessage): {
|
|
322
332
|
readonly identity: Identity;
|
|
323
333
|
readonly expires: number;
|
|
334
|
+
readonly certificate: string;
|
|
324
335
|
} {
|
|
325
336
|
const socket = req.socket;
|
|
326
337
|
const connection = socket instanceof TLSSocket ? this.connections.get(socket) : undefined;
|
|
@@ -339,7 +350,11 @@ class Bridge {
|
|
|
339
350
|
this.drop(connection.fingerprint, fault);
|
|
340
351
|
throw fault;
|
|
341
352
|
}
|
|
342
|
-
return {
|
|
353
|
+
return {
|
|
354
|
+
identity,
|
|
355
|
+
expires: connection.expires,
|
|
356
|
+
certificate: authenticatedCertificateChain(socket),
|
|
357
|
+
};
|
|
343
358
|
}
|
|
344
359
|
private async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
345
360
|
const requestId = randomUUID();
|
|
@@ -372,7 +387,7 @@ class Bridge {
|
|
|
372
387
|
);
|
|
373
388
|
};
|
|
374
389
|
try {
|
|
375
|
-
const { identity, expires } = this.admission(req);
|
|
390
|
+
const { identity, expires, certificate } = this.admission(req);
|
|
376
391
|
const route = routeFor(req);
|
|
377
392
|
session = this.session(identity, expires);
|
|
378
393
|
controller = session.begin();
|
|
@@ -380,15 +395,18 @@ class Bridge {
|
|
|
380
395
|
req.once("aborted", abort);
|
|
381
396
|
res.once("close", onClose);
|
|
382
397
|
const duration = route === "POST /v1/ask" ? ASK_TIMEOUT + 15000 : 15000;
|
|
383
|
-
setDeadline(route
|
|
398
|
+
setDeadline(LONG_ROUTES.has(route) ? 75000 : duration);
|
|
384
399
|
const operation = await operationFor(req, route, signal);
|
|
385
400
|
if (operation.route === "POST /v1/ask") {
|
|
386
401
|
setDeadline(operation.timeoutMs + 15000);
|
|
402
|
+
} else if (operation.route === "GET /v1/transcript") {
|
|
403
|
+
setDeadline(75000);
|
|
387
404
|
}
|
|
388
|
-
const data = await this.execute(operation, session, {
|
|
405
|
+
const data = await this.operations.execute(operation, session, {
|
|
389
406
|
signal,
|
|
390
407
|
requestId,
|
|
391
408
|
expires,
|
|
409
|
+
certificate,
|
|
392
410
|
onPeer: (id) => {
|
|
393
411
|
peer = id;
|
|
394
412
|
},
|
|
@@ -402,10 +420,7 @@ class Bridge {
|
|
|
402
420
|
? error
|
|
403
421
|
: new Fault(503, "broker_unavailable", "Local broker operation failed.");
|
|
404
422
|
log("request", { ...session?.fields(), requestId, peer, result: fault.code });
|
|
405
|
-
|
|
406
|
-
ok: false,
|
|
407
|
-
error: { code: fault.code, message: fault.message, ...fault.details },
|
|
408
|
-
});
|
|
423
|
+
replyFault(res, requestId, fault);
|
|
409
424
|
} finally {
|
|
410
425
|
clearTimeout(deadline);
|
|
411
426
|
req.off("aborted", abort);
|
|
@@ -415,38 +430,6 @@ class Bridge {
|
|
|
415
430
|
}
|
|
416
431
|
}
|
|
417
432
|
}
|
|
418
|
-
private async execute(
|
|
419
|
-
operation: Operation,
|
|
420
|
-
session: Readonly<Session>,
|
|
421
|
-
context: {
|
|
422
|
-
readonly signal: AbortSignal;
|
|
423
|
-
readonly requestId: string;
|
|
424
|
-
readonly expires: number;
|
|
425
|
-
readonly onPeer: (id: string) => void;
|
|
426
|
-
},
|
|
427
|
-
) {
|
|
428
|
-
switch (operation.route) {
|
|
429
|
-
case "POST /v1/start":
|
|
430
|
-
return this.launcher.start(operation.name, {
|
|
431
|
-
identity: session.identity,
|
|
432
|
-
expires: context.expires,
|
|
433
|
-
signal: context.signal,
|
|
434
|
-
peers: (signal) => session.connectedPeers(signal),
|
|
435
|
-
});
|
|
436
|
-
case "GET /v1/sessions":
|
|
437
|
-
return this.launcher.list(session.identity);
|
|
438
|
-
case "POST /v1/stop":
|
|
439
|
-
return this.launcher.stop(session.identity, operation.sessionId);
|
|
440
|
-
case "GET /v1/list":
|
|
441
|
-
case "GET /v1/inbox":
|
|
442
|
-
case "POST /v1/register":
|
|
443
|
-
case "POST /v1/ask":
|
|
444
|
-
case "POST /v1/send":
|
|
445
|
-
case "POST /v1/reply":
|
|
446
|
-
case "POST /v1/ack":
|
|
447
|
-
return session.execute(operation, context);
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
433
|
}
|
|
451
434
|
|
|
452
435
|
function main(): void {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { EventEmitter } from "events";
|
|
2
2
|
import type net from "net";
|
|
3
3
|
import { randomUUID } from "crypto";
|
|
4
|
+
import type { NativeControlEnvelope } from "../native-control-protocol.ts";
|
|
4
5
|
import { writeMessage } from "./framing.ts";
|
|
5
6
|
import { BrokerConnection, connectBrokerSocket } from "./connection.ts";
|
|
6
7
|
import { SnapshotReader } from "./snapshot-reader.ts";
|
|
@@ -51,6 +52,7 @@ interface SendOptions {
|
|
|
51
52
|
readonly messageId?: string;
|
|
52
53
|
readonly human?: HumanMessageOrigin;
|
|
53
54
|
readonly topic?: TopicUpdate;
|
|
55
|
+
readonly control?: NativeControlEnvelope;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
function toError(error: unknown): Error {
|
|
@@ -405,6 +407,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
405
407
|
expectsReply: options.expectsReply,
|
|
406
408
|
...(options.human ? { human: options.human } : {}),
|
|
407
409
|
...(options.topic ? { topic: options.topic } : {}),
|
|
410
|
+
...(options.control ? { control: options.control } : {}),
|
|
408
411
|
delivery: messageDelivery(options),
|
|
409
412
|
queueMode: options.queueMode,
|
|
410
413
|
threadId: options.threadId,
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
2
|
import { IntercomClient } from "./broker/client.ts";
|
|
4
3
|
import type { IntercomTransport } from "./transport.ts";
|
|
@@ -8,7 +7,7 @@ import type { IntercomLifecycle } from "./lifecycle.ts";
|
|
|
8
7
|
import type { IntercomTopics } from "./topics.ts";
|
|
9
8
|
import type { Message, SessionInfo, TopicChange } from "./types.ts";
|
|
10
9
|
import { asError, errorMessage } from "./validation.ts";
|
|
11
|
-
import { formatTargetOptions, resolveSessionTarget } from "./session-targets.ts";
|
|
10
|
+
import { formatTargetOptions, resolveSessionTarget, nativePeerId } from "./session-targets.ts";
|
|
12
11
|
|
|
13
12
|
export type ConnectionReason = "startup" | "background" | "tool" | "overlay" | "peer-awareness";
|
|
14
13
|
type Lifecycle = Readonly<
|
|
@@ -233,7 +232,7 @@ export class IntercomConnection {
|
|
|
233
232
|
}
|
|
234
233
|
await next.connect(
|
|
235
234
|
await this.lifecycle.registration(this.hooks.pendingAsks()),
|
|
236
|
-
|
|
235
|
+
nativePeerId(sessionId),
|
|
237
236
|
);
|
|
238
237
|
if (!this.lifecycle.live(ctx, generation)) {
|
|
239
238
|
await next.disconnect();
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
2
|
import type { IntercomConfig } from "./config.ts";
|
|
4
3
|
import type { IntercomLifecycle } from "./lifecycle.ts";
|
|
@@ -13,6 +12,7 @@ import type { Message, SessionInfo } from "./types.ts";
|
|
|
13
12
|
import { requestedDelivery, isBlockingSupervisorMessage } from "./inbound-record.ts";
|
|
14
13
|
import { formatAttachments } from "./message-format.ts";
|
|
15
14
|
import { readQuestionOwner } from "../runs/shared/supervisor-questions.ts";
|
|
15
|
+
import { nativePeerId } from "./session-targets.ts";
|
|
16
16
|
|
|
17
17
|
type Lifecycle = Readonly<
|
|
18
18
|
Pick<IntercomLifecycle, "generation" | "isIdle" | "live" | "markActivity">
|
|
@@ -53,9 +53,7 @@ export class IntercomInbound {
|
|
|
53
53
|
}
|
|
54
54
|
const owner = readQuestionOwner(origin.runId);
|
|
55
55
|
return (
|
|
56
|
-
owner?.sessionId === origin.ownerSessionId &&
|
|
57
|
-
from.id ===
|
|
58
|
-
`pi-${createHash("sha256").update(origin.ownerSessionId).digest("hex").slice(0, 32)}`
|
|
56
|
+
owner?.sessionId === origin.ownerSessionId && from.id === nativePeerId(origin.ownerSessionId)
|
|
59
57
|
);
|
|
60
58
|
}
|
|
61
59
|
private normalize(from: SessionInfo, input: Message): Message {
|
package/src/pi-intercom/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { InboundDeliveryOwner } from "./inbound-delivery.ts";
|
|
|
9
9
|
import { ReplyWait } from "./reply-wait.ts";
|
|
10
10
|
import { IntercomConnection } from "./connection.ts";
|
|
11
11
|
import { IntercomInbound } from "./inbound.ts";
|
|
12
|
+
import { NativeControls } from "./native-control.ts";
|
|
12
13
|
import { IntercomOutbound } from "./outbound.ts";
|
|
13
14
|
import { IntercomInspection } from "./inspection.ts";
|
|
14
15
|
import { IntercomTopicActions } from "./topic-actions.ts";
|
|
@@ -33,7 +34,11 @@ export default function piIntercomExtension(pi: ExtensionAPI): void {
|
|
|
33
34
|
const connection = new IntercomConnection(lifecycle, config, topics, {
|
|
34
35
|
restoring: () => delivery.restoring,
|
|
35
36
|
pendingAsks: () => replies.listPending().length,
|
|
36
|
-
incoming: (ctx, from, message) =>
|
|
37
|
+
incoming: async (ctx, from, message) => {
|
|
38
|
+
if (!(await controls.receive(ctx, from, message))) {
|
|
39
|
+
await inbound.receive(ctx, from, message);
|
|
40
|
+
}
|
|
41
|
+
},
|
|
37
42
|
peerLeft: (id) => {
|
|
38
43
|
wait.peerDisconnected(id);
|
|
39
44
|
journal.peerLeft(id);
|
|
@@ -46,6 +51,7 @@ export default function piIntercomExtension(pi: ExtensionAPI): void {
|
|
|
46
51
|
}
|
|
47
52
|
},
|
|
48
53
|
});
|
|
54
|
+
const controls = new NativeControls(lifecycle, connection);
|
|
49
55
|
const delivery = new InboundDeliveryOwner(pi, lifecycle, journal, {
|
|
50
56
|
replies,
|
|
51
57
|
syncStatus: () => connection.syncStatus(),
|