@fitchmultz/pi-subagents 0.47.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -3
- package/dist/extension/build-info.js +1 -1
- package/dist/pi-intercom/bridge-broadcast.js +60 -0
- package/dist/pi-intercom/bridge-catalog.js +372 -0
- package/dist/pi-intercom/bridge-config.js +15 -3
- package/dist/pi-intercom/bridge-control.js +104 -0
- package/dist/pi-intercom/bridge-groups.js +135 -0
- package/dist/pi-intercom/bridge-http.js +8 -1
- package/dist/pi-intercom/bridge-launch-journal.js +84 -0
- package/dist/pi-intercom/bridge-launch-policy.js +71 -5
- package/dist/pi-intercom/bridge-launch.js +186 -76
- package/dist/pi-intercom/bridge-mailbox.js +3 -55
- package/dist/pi-intercom/bridge-operations.js +127 -0
- package/dist/pi-intercom/bridge-presence.js +5 -1
- package/dist/pi-intercom/bridge-protocol.js +7 -0
- package/dist/pi-intercom/bridge-routes.js +195 -27
- package/dist/pi-intercom/bridge-rpc-process.js +43 -15
- package/dist/pi-intercom/bridge-session.js +83 -66
- package/dist/pi-intercom/bridge-storage.js +73 -0
- package/dist/pi-intercom/bridge-target.js +22 -0
- package/dist/pi-intercom/bridge-wake-owner.js +57 -0
- package/dist/pi-intercom/bridge-wake-state.js +293 -0
- package/dist/pi-intercom/bridge-wake.js +190 -99
- package/dist/pi-intercom/bridge.js +39 -39
- package/dist/pi-intercom/broker/client.js +1 -0
- package/dist/pi-intercom/connection.js +2 -3
- package/dist/pi-intercom/inbound.js +2 -4
- package/dist/pi-intercom/index.js +7 -1
- package/dist/pi-intercom/native-control-auth.js +80 -0
- package/dist/pi-intercom/native-control-protocol.js +111 -0
- package/dist/pi-intercom/native-control.js +130 -0
- package/dist/pi-intercom/saved-transcript.js +135 -0
- package/dist/pi-intercom/session-targets.js +3 -0
- package/dist/pi-intercom/transcript.js +175 -0
- package/docs/intercom-bridge.md +315 -174
- package/docs/intercom.md +1 -1
- package/docs/reference.md +1 -1
- package/package.json +1 -1
- package/scripts/intercom-bridge-wake-preflight.mjs +112 -0
- package/scripts/intercom-remote.mjs +213 -38
- package/src/pi-intercom/bridge-broadcast.ts +70 -0
- package/src/pi-intercom/bridge-catalog.ts +489 -0
- package/src/pi-intercom/bridge-config.ts +20 -3
- package/src/pi-intercom/bridge-control.ts +179 -0
- package/src/pi-intercom/bridge-groups.ts +169 -0
- package/src/pi-intercom/bridge-http.ts +15 -1
- package/src/pi-intercom/bridge-launch-journal.ts +122 -0
- package/src/pi-intercom/bridge-launch-policy.ts +88 -7
- package/src/pi-intercom/bridge-launch.ts +296 -107
- package/src/pi-intercom/bridge-mailbox.ts +3 -59
- package/src/pi-intercom/bridge-operations.ts +169 -0
- package/src/pi-intercom/bridge-presence.ts +7 -1
- package/src/pi-intercom/bridge-protocol.ts +8 -0
- package/src/pi-intercom/bridge-routes.ts +291 -49
- package/src/pi-intercom/bridge-rpc-process.ts +53 -16
- package/src/pi-intercom/bridge-session.ts +98 -96
- package/src/pi-intercom/bridge-storage.ts +83 -0
- package/src/pi-intercom/bridge-target.ts +35 -0
- package/src/pi-intercom/bridge-wake-owner.ts +58 -0
- package/src/pi-intercom/bridge-wake-state.ts +321 -0
- package/src/pi-intercom/bridge-wake.ts +224 -130
- package/src/pi-intercom/bridge.ts +41 -49
- package/src/pi-intercom/broker/client.ts +3 -0
- package/src/pi-intercom/connection.ts +2 -3
- package/src/pi-intercom/inbound.ts +2 -4
- package/src/pi-intercom/index.ts +7 -1
- package/src/pi-intercom/native-control-auth.ts +89 -0
- package/src/pi-intercom/native-control-protocol.ts +180 -0
- package/src/pi-intercom/native-control.ts +160 -0
- package/src/pi-intercom/saved-transcript.ts +175 -0
- package/src/pi-intercom/session-targets.ts +4 -0
- package/src/pi-intercom/transcript.ts +226 -0
- package/src/pi-intercom/types.ts +2 -0
|
@@ -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,17 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
3
|
import { IntercomClient } from "./broker/client.ts";
|
|
3
|
-
import {
|
|
4
|
-
formatSessionTarget,
|
|
5
|
-
resolveSessionProjectId,
|
|
6
|
-
resolveSessionTarget,
|
|
7
|
-
} from "./session-targets.ts";
|
|
4
|
+
import { resolveSessionProjectId } from "./session-targets.ts";
|
|
8
5
|
import type { Message, SendResult, SessionInfo } from "./types.ts";
|
|
9
6
|
import type { Identity } from "./bridge-config.ts";
|
|
10
7
|
import { Fault, cancelled, log, wait } from "./bridge-protocol.ts";
|
|
11
8
|
import { Mailbox } from "./bridge-mailbox.ts";
|
|
12
9
|
import type { UnknownRecord } from "../shared/unknown.ts";
|
|
13
10
|
import type { MessagingOperation as Operation } from "./bridge-routes.ts";
|
|
11
|
+
import type { RequestContext } from "./bridge-http.ts";
|
|
12
|
+
import { resolveBridgeTarget, bridgeSessionList } from "./bridge-target.ts";
|
|
13
|
+
import { ControlReplies } from "./bridge-control.ts";
|
|
14
|
+
import type { NativeControlOperation } from "./native-control-protocol.ts";
|
|
14
15
|
|
|
15
16
|
import { WaitingAsk } from "./bridge-ask.ts";
|
|
16
17
|
const MAX_REQUESTS = 8;
|
|
@@ -18,19 +19,13 @@ const IDLE_TIMEOUT = 5 * 60 * 1000;
|
|
|
18
19
|
const SEND_TIMEOUT = 8000;
|
|
19
20
|
const LIST_TIMEOUT = 5000;
|
|
20
21
|
|
|
21
|
-
interface
|
|
22
|
-
readonly signal: AbortSignal;
|
|
23
|
-
readonly requestId: string;
|
|
24
|
-
readonly onPeer: (peer: string) => void;
|
|
25
|
-
}
|
|
26
|
-
interface SendOptions {
|
|
22
|
+
interface SendOptions extends Pick<Message, "replyTo" | "expectsReply" | "delivery"> {
|
|
27
23
|
readonly messageId?: string;
|
|
28
|
-
readonly replyTo?: string;
|
|
29
|
-
readonly expectsReply?: boolean;
|
|
30
24
|
}
|
|
31
25
|
export interface SessionOwner {
|
|
32
26
|
readonly stateDir?: string;
|
|
33
27
|
readonly notifications?: boolean;
|
|
28
|
+
readonly mailboxChanged?: () => void;
|
|
34
29
|
readonly allowed: (identity: Identity) => boolean;
|
|
35
30
|
readonly lost: (fp: string, count?: number) => void;
|
|
36
31
|
readonly retired: (fp: string, generation: string) => void;
|
|
@@ -48,8 +43,10 @@ export class Session {
|
|
|
48
43
|
});
|
|
49
44
|
private readonly requests = new Set<AbortController>();
|
|
50
45
|
private readonly inbox: Mailbox;
|
|
46
|
+
private inboxChanged = new AbortController();
|
|
51
47
|
private readonly replying = new Set<string>();
|
|
52
48
|
private pending?: WaitingAsk;
|
|
49
|
+
private readonly controls = new ControlReplies();
|
|
53
50
|
private closed = false;
|
|
54
51
|
private connecting?: Promise<void>;
|
|
55
52
|
private disconnecting?: Promise<void>;
|
|
@@ -80,12 +77,13 @@ export class Session {
|
|
|
80
77
|
}
|
|
81
78
|
|
|
82
79
|
guard(signal?: AbortSignal): void {
|
|
83
|
-
|
|
84
|
-
cancelled(signal);
|
|
85
|
-
}
|
|
80
|
+
signal?.throwIfAborted();
|
|
86
81
|
if (this.closed) {
|
|
87
82
|
throw this.closeReason;
|
|
88
83
|
}
|
|
84
|
+
this.authorize();
|
|
85
|
+
}
|
|
86
|
+
authorize(): void {
|
|
89
87
|
if (!this.owner.allowed(this.identity)) {
|
|
90
88
|
throw new Fault(403, "revoked", "Identity is no longer authorized.");
|
|
91
89
|
}
|
|
@@ -197,21 +195,19 @@ export class Session {
|
|
|
197
195
|
this.owner.retired(this.identity.fingerprint256, this.generation);
|
|
198
196
|
log("session_closed", { ...this.fields(), result: this.closeReason.code });
|
|
199
197
|
}
|
|
200
|
-
close(
|
|
198
|
+
close(fault: Readonly<Fault>): void {
|
|
201
199
|
if (this.closed) {
|
|
202
200
|
return;
|
|
203
201
|
}
|
|
204
202
|
this.closed = true;
|
|
205
|
-
this.closeReason =
|
|
203
|
+
this.closeReason = fault;
|
|
206
204
|
clearTimeout(this.lease);
|
|
207
|
-
this.pending?.reject(
|
|
205
|
+
this.pending?.reject(fault);
|
|
206
|
+
this.controls.close(fault);
|
|
208
207
|
for (const controller of this.requests) {
|
|
209
|
-
controller.abort(
|
|
208
|
+
controller.abort(fault);
|
|
210
209
|
}
|
|
211
210
|
// Retain the generation until acquisition/registration AND final disconnect settle.
|
|
212
|
-
this.startRetirement();
|
|
213
|
-
}
|
|
214
|
-
private startRetirement(): void {
|
|
215
211
|
this.retirement = this.retire().catch((caught: unknown) => this.cleanupFailed(caught));
|
|
216
212
|
}
|
|
217
213
|
async settled(): Promise<void> {
|
|
@@ -219,6 +215,7 @@ export class Session {
|
|
|
219
215
|
}
|
|
220
216
|
|
|
221
217
|
private peerLeft(id: string): void {
|
|
218
|
+
this.controls.peerLeft(id);
|
|
222
219
|
if (this.pending?.peer === id && !this.pending.settled) {
|
|
223
220
|
this.pending.reject(
|
|
224
221
|
new Fault(409, "peer_offline", "Ask recipient disconnected.", { id: this.pending.id }),
|
|
@@ -240,6 +237,9 @@ export class Session {
|
|
|
240
237
|
);
|
|
241
238
|
return;
|
|
242
239
|
}
|
|
240
|
+
if (this.controls.receive(from.id, message)) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
243
|
if (!this.inbox.persistent && this.resolveReply(from, message)) {
|
|
244
244
|
return;
|
|
245
245
|
}
|
|
@@ -266,12 +266,22 @@ export class Session {
|
|
|
266
266
|
);
|
|
267
267
|
return;
|
|
268
268
|
}
|
|
269
|
-
if (result
|
|
270
|
-
|
|
269
|
+
if (result !== "duplicate" || !this.inbox.persistent) {
|
|
270
|
+
if (result !== "stored") {
|
|
271
|
+
this.owner.lost(this.identity.fingerprint256);
|
|
272
|
+
}
|
|
273
|
+
this.inboxChanged.abort();
|
|
274
|
+
this.inboxChanged = new AbortController();
|
|
271
275
|
}
|
|
272
|
-
|
|
276
|
+
log(result === "lost" ? "inbox_overflow" : "received", {
|
|
277
|
+
...this.fields(),
|
|
278
|
+
peer: from.id,
|
|
279
|
+
messageId: message.id,
|
|
280
|
+
result: result === "lost" && this.inbox.persistent ? "storage_failed" : result,
|
|
281
|
+
});
|
|
273
282
|
if (result !== "lost") {
|
|
274
283
|
this.resolveReply(from, message);
|
|
284
|
+
this.owner.mailboxChanged?.();
|
|
275
285
|
}
|
|
276
286
|
this.presence();
|
|
277
287
|
}
|
|
@@ -283,18 +293,6 @@ export class Session {
|
|
|
283
293
|
pending.resolve({ from, message });
|
|
284
294
|
return true;
|
|
285
295
|
}
|
|
286
|
-
private recordReceive(
|
|
287
|
-
peer: string,
|
|
288
|
-
messageId: string,
|
|
289
|
-
result: "stored" | "duplicate" | "lost",
|
|
290
|
-
): void {
|
|
291
|
-
log(result === "lost" ? "inbox_overflow" : "received", {
|
|
292
|
-
...this.fields(),
|
|
293
|
-
peer,
|
|
294
|
-
messageId,
|
|
295
|
-
result: result === "lost" && this.inbox.persistent ? "storage_failed" : result,
|
|
296
|
-
});
|
|
297
|
-
}
|
|
298
296
|
private presence(): void {
|
|
299
297
|
if (this.closed) {
|
|
300
298
|
return;
|
|
@@ -320,29 +318,29 @@ export class Session {
|
|
|
320
318
|
await this.connect(signal);
|
|
321
319
|
return this.peers(signal);
|
|
322
320
|
}
|
|
321
|
+
async nativeControl(
|
|
322
|
+
operation: NativeControlOperation,
|
|
323
|
+
certificate: string,
|
|
324
|
+
context: RequestContext,
|
|
325
|
+
): Promise<UnknownRecord> {
|
|
326
|
+
await this.connect(context.signal);
|
|
327
|
+
const peer = await this.target(operation.to, context.signal);
|
|
328
|
+
return this.controls.request(
|
|
329
|
+
this.client,
|
|
330
|
+
{ ...operation, peer: peer.id, certificate },
|
|
331
|
+
{ ...context, guard: () => this.guard(context.signal) },
|
|
332
|
+
);
|
|
333
|
+
}
|
|
323
334
|
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;
|
|
335
|
+
return resolveBridgeTarget(await this.peers(signal), to, this.client.sessionId);
|
|
339
336
|
}
|
|
340
|
-
|
|
337
|
+
async send(
|
|
341
338
|
peer: string,
|
|
342
339
|
message: string,
|
|
343
340
|
context: RequestContext,
|
|
344
341
|
options: SendOptions = {},
|
|
345
342
|
): Promise<SendResult> {
|
|
343
|
+
await this.connect(context.signal);
|
|
346
344
|
const { signal, requestId } = context;
|
|
347
345
|
context.onPeer(peer);
|
|
348
346
|
this.guard(signal);
|
|
@@ -392,22 +390,6 @@ export class Session {
|
|
|
392
390
|
const waiting = new WaitingAsk();
|
|
393
391
|
this.pending = waiting;
|
|
394
392
|
const { signal } = context;
|
|
395
|
-
const abort = () => {
|
|
396
|
-
try {
|
|
397
|
-
cancelled(signal);
|
|
398
|
-
} catch (error) {
|
|
399
|
-
waiting.reject(
|
|
400
|
-
error instanceof Error
|
|
401
|
-
? error
|
|
402
|
-
: new Fault(
|
|
403
|
-
499,
|
|
404
|
-
"client_aborted",
|
|
405
|
-
"HTTP client disconnected; delivery may be unknown.",
|
|
406
|
-
),
|
|
407
|
-
);
|
|
408
|
-
}
|
|
409
|
-
};
|
|
410
|
-
signal.addEventListener("abort", abort, { once: true });
|
|
411
393
|
try {
|
|
412
394
|
await this.connect(signal);
|
|
413
395
|
const peer = await this.target(operation.to, signal);
|
|
@@ -423,16 +405,39 @@ export class Session {
|
|
|
423
405
|
return { ...waiting.receipt, replied: true, reply: answer };
|
|
424
406
|
} finally {
|
|
425
407
|
waiting.clear();
|
|
426
|
-
|
|
427
|
-
if (this.pending === waiting) {
|
|
428
|
-
this.pending = undefined;
|
|
429
|
-
}
|
|
408
|
+
this.pending = undefined;
|
|
430
409
|
}
|
|
431
410
|
}
|
|
432
|
-
private
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
411
|
+
private async readInbox(
|
|
412
|
+
operation: Extract<Operation, { readonly route: "GET /v1/inbox" }>,
|
|
413
|
+
context: RequestContext,
|
|
414
|
+
): Promise<UnknownRecord> {
|
|
415
|
+
const read = (): ReturnType<Mailbox["page"]> & UnknownRecord => ({
|
|
416
|
+
...this.inbox.page(operation.after, operation.limit),
|
|
417
|
+
...this.inbox.status(this.owner.losses(this.identity.fingerprint256)),
|
|
418
|
+
});
|
|
419
|
+
const deadline = performance.now() + operation.waitMs;
|
|
420
|
+
const losses = read().lostMessages;
|
|
421
|
+
for (;;) {
|
|
422
|
+
// Subscribe to the post-commit signal before rereading the authoritative cursor page.
|
|
423
|
+
const signal = AbortSignal.any([this.inboxChanged.signal, context.signal]);
|
|
424
|
+
const page = read();
|
|
425
|
+
const remaining = deadline - performance.now();
|
|
426
|
+
if (page.messages.length > 0 || page.lostMessages !== losses || remaining <= 0) {
|
|
427
|
+
return page;
|
|
428
|
+
}
|
|
429
|
+
log("inbox_wait", { ...this.fields(), requestId: context.requestId, after: operation.after });
|
|
430
|
+
try {
|
|
431
|
+
// A nonmatching commit must rearm the same cursor, not start parallel readers.
|
|
432
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
433
|
+
await delay(remaining, undefined, { signal });
|
|
434
|
+
} catch (error) {
|
|
435
|
+
if (!signal.aborted) {
|
|
436
|
+
throw error;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
this.guard(context.signal);
|
|
440
|
+
}
|
|
436
441
|
}
|
|
437
442
|
private async answer(
|
|
438
443
|
replyTo: string,
|
|
@@ -460,6 +465,7 @@ export class Session {
|
|
|
460
465
|
}
|
|
461
466
|
const data = { ...(await this.send(peer, message, context, { replyTo })) };
|
|
462
467
|
this.inbox.remove(replyTo);
|
|
468
|
+
this.owner.mailboxChanged?.();
|
|
463
469
|
this.presence();
|
|
464
470
|
return data;
|
|
465
471
|
} finally {
|
|
@@ -477,34 +483,30 @@ export class Session {
|
|
|
477
483
|
await this.connect(context.signal);
|
|
478
484
|
}
|
|
479
485
|
if (operation.route === "POST /v1/ack") {
|
|
480
|
-
|
|
486
|
+
const result = this.inbox.ack(operation.ids);
|
|
487
|
+
this.owner.mailboxChanged?.();
|
|
488
|
+
this.presence();
|
|
489
|
+
return result;
|
|
481
490
|
}
|
|
482
|
-
return
|
|
483
|
-
...this.inbox.page(operation.after, operation.limit),
|
|
484
|
-
...this.inbox.status(this.owner.losses(this.identity.fingerprint256)),
|
|
485
|
-
};
|
|
491
|
+
return this.readInbox(operation, context);
|
|
486
492
|
}
|
|
487
493
|
await this.connect(context.signal);
|
|
488
494
|
switch (operation.route) {
|
|
489
495
|
case "POST /v1/register":
|
|
490
496
|
return { sessionId: this.client.sessionId, name: this.identity.name };
|
|
491
|
-
case "GET /v1/list":
|
|
492
|
-
const sessions = await this.peers(context.signal);
|
|
497
|
+
case "GET /v1/list":
|
|
493
498
|
return {
|
|
494
499
|
sessionId: this.client.sessionId,
|
|
495
|
-
sessions:
|
|
496
|
-
return Object.assign(entry, { target: formatSessionTarget(entry, sessions) });
|
|
497
|
-
}),
|
|
500
|
+
sessions: bridgeSessionList(await this.peers(context.signal)),
|
|
498
501
|
};
|
|
502
|
+
case "POST /v1/send": {
|
|
503
|
+
const peer = await this.target(operation.to, context.signal);
|
|
504
|
+
const receipt = await this.send(peer.id, operation.message, context, {
|
|
505
|
+
messageId: operation.messageId,
|
|
506
|
+
delivery: operation.passive === true ? "passive" : "steer",
|
|
507
|
+
});
|
|
508
|
+
return { ...receipt };
|
|
499
509
|
}
|
|
500
|
-
case "POST /v1/send":
|
|
501
|
-
return {
|
|
502
|
-
...(await this.send(
|
|
503
|
-
(await this.target(operation.to, context.signal)).id,
|
|
504
|
-
operation.message,
|
|
505
|
-
context,
|
|
506
|
-
)),
|
|
507
|
-
};
|
|
508
510
|
case "POST /v1/reply":
|
|
509
511
|
return this.answer(operation.replyTo, operation.message, context);
|
|
510
512
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, realpathSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import { isRecord } from "../shared/unknown.ts";
|
|
5
|
+
import { label } from "./bridge-protocol.ts";
|
|
6
|
+
|
|
7
|
+
export function stateDirectory(value: unknown): string | undefined {
|
|
8
|
+
if (value === undefined) {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const path = label(value, "stateDir", 4096);
|
|
12
|
+
const stat = lstatSync(path);
|
|
13
|
+
if (
|
|
14
|
+
!isAbsolute(path) ||
|
|
15
|
+
!stat.isDirectory() ||
|
|
16
|
+
stat.uid !== process.getuid?.() ||
|
|
17
|
+
(stat.mode & 0o777) !== 0o700
|
|
18
|
+
) {
|
|
19
|
+
throw new Error("stateDir must be an absolute owned directory with mode 0700, not a symlink.");
|
|
20
|
+
}
|
|
21
|
+
return realpathSync(path);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function privateFile(path: string): void {
|
|
25
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
26
|
+
try {
|
|
27
|
+
const stat = fstatSync(fd);
|
|
28
|
+
if (
|
|
29
|
+
!stat.isFile() ||
|
|
30
|
+
stat.uid !== process.getuid?.() ||
|
|
31
|
+
(stat.mode & 0o777) !== 0o600 ||
|
|
32
|
+
stat.nlink !== 1
|
|
33
|
+
) {
|
|
34
|
+
throw new Error("Storage files must be owned regular files with mode 0600 and one link.");
|
|
35
|
+
}
|
|
36
|
+
} finally {
|
|
37
|
+
closeSync(fd);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Native SQLite FULL commits own durability; never replace an unreadable existing database. */
|
|
42
|
+
export function privateDatabase(
|
|
43
|
+
stateDir: string,
|
|
44
|
+
name: string,
|
|
45
|
+
): { readonly db: DatabaseSync; readonly created: boolean } {
|
|
46
|
+
stateDirectory(stateDir);
|
|
47
|
+
const path = join(stateDir, name);
|
|
48
|
+
let created = false;
|
|
49
|
+
try {
|
|
50
|
+
const fd = openSync(
|
|
51
|
+
path,
|
|
52
|
+
constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
|
53
|
+
0o600,
|
|
54
|
+
);
|
|
55
|
+
closeSync(fd);
|
|
56
|
+
created = true;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (!isRecord(error) || error.code !== "EEXIST") {
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
privateFile(path);
|
|
63
|
+
for (const suffix of ["-journal", "-wal", "-shm"]) {
|
|
64
|
+
try {
|
|
65
|
+
lstatSync(`${path}${suffix}`);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (isRecord(error) && error.code === "ENOENT") {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
privateFile(`${path}${suffix}`);
|
|
73
|
+
}
|
|
74
|
+
const db = new DatabaseSync(path, { enableDoubleQuotedStringLiterals: false });
|
|
75
|
+
try {
|
|
76
|
+
db.exec(`PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA secure_delete=ON;
|
|
77
|
+
PRAGMA cache_size=-1024; PRAGMA busy_timeout=1000;`);
|
|
78
|
+
return { db, created };
|
|
79
|
+
} catch (error) {
|
|
80
|
+
db.close();
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { privateDatabase, privateFile } from "./bridge-storage.ts";
|
|
6
|
+
import { isRecord } from "../shared/unknown.ts";
|
|
7
|
+
|
|
8
|
+
export const WAKE_OWNER_FILE = /^wake-owner-[0-9a-f-]{36}\.lock$/;
|
|
9
|
+
|
|
10
|
+
/** SQLite's OS lock survives pauses and is released on exit, without PID reuse or stale leases. */
|
|
11
|
+
export class WakeOwner {
|
|
12
|
+
readonly name = `wake-owner-${randomUUID()}.lock`;
|
|
13
|
+
private readonly db: DatabaseSync;
|
|
14
|
+
private readonly path: string;
|
|
15
|
+
|
|
16
|
+
constructor(stateDir: string) {
|
|
17
|
+
this.path = join(stateDir, this.name);
|
|
18
|
+
this.db = privateDatabase(stateDir, this.name).db;
|
|
19
|
+
try {
|
|
20
|
+
this.db.exec("BEGIN EXCLUSIVE");
|
|
21
|
+
} catch (error) {
|
|
22
|
+
this.db.close();
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
close(): void {
|
|
28
|
+
this.db.close();
|
|
29
|
+
rmSync(this.path, { force: true });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function stoppedWakeOwner(stateDir: string, name: string): boolean {
|
|
34
|
+
if (!WAKE_OWNER_FILE.test(name)) {
|
|
35
|
+
throw new Error("Invalid wake owner.");
|
|
36
|
+
}
|
|
37
|
+
const path = join(stateDir, name);
|
|
38
|
+
try {
|
|
39
|
+
privateFile(path);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (isRecord(error) && error.code === "ENOENT") {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
const db = new DatabaseSync(path);
|
|
47
|
+
try {
|
|
48
|
+
db.exec("PRAGMA busy_timeout=0; BEGIN EXCLUSIVE");
|
|
49
|
+
return true;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (isRecord(error) && error.errcode === 5) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
throw error;
|
|
55
|
+
} finally {
|
|
56
|
+
db.close();
|
|
57
|
+
}
|
|
58
|
+
}
|