@fitchmultz/pi-subagents 0.50.0 → 0.51.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 +51 -2
- package/README.md +36 -5
- package/dist/agent/contracts.js +6 -24
- package/dist/agent/host.js +3 -16
- package/dist/agent/index.js +207 -173
- package/dist/agent/journal-tail.js +115 -156
- package/dist/agent/launch.js +20 -8
- package/dist/agent/legacy.js +65 -23
- package/dist/agent/notifications.js +138 -47
- package/dist/agent/observation-state.js +246 -0
- package/dist/agent/observation-view.js +258 -0
- package/dist/agent/observation.js +346 -0
- package/dist/agent/observe-card.js +243 -0
- package/dist/agent/observe-chrome.js +85 -0
- package/dist/agent/observe-client.js +285 -0
- package/dist/agent/observe-layout.js +65 -0
- package/dist/agent/observe-reading.js +84 -0
- package/dist/agent/observe-screen.js +400 -96
- package/dist/agent/observe-transcript.js +382 -0
- package/dist/agent/observe.js +250 -43
- package/dist/agent/process.js +198 -25
- package/dist/agent/receipts.js +92 -20
- package/dist/agent/runner.js +125 -20
- package/dist/agent/shell.js +2 -1
- package/dist/agent/storage.js +29 -12
- package/dist/agent/tool-schemas.js +20 -0
- package/dist/agent/typebox.js +10 -5
- package/dist/agent/usage.js +0 -115
- package/dist/agent/view.js +46 -16
- package/dist/agent/wake.js +99 -0
- package/dist/build-info.js +1 -1
- package/dist/intercom-client.d.ts +26 -0
- package/dist/intercom-client.js +45 -0
- package/dist/pi-intercom/agent-dir.d.ts +1 -0
- package/dist/pi-intercom/bridge-session.js +2 -1
- package/dist/pi-intercom/broker/broker.js +27 -21
- package/dist/pi-intercom/broker/client-last.d.ts +13 -0
- package/dist/pi-intercom/broker/client-last.js +17 -5
- package/dist/pi-intercom/broker/client.d.ts +60 -0
- package/dist/pi-intercom/broker/client.js +47 -16
- package/dist/pi-intercom/broker/connection.d.ts +29 -0
- package/dist/pi-intercom/broker/framing.d.ts +17 -0
- package/dist/pi-intercom/broker/framing.js +10 -7
- package/dist/pi-intercom/broker/guard.js +23 -0
- package/dist/pi-intercom/broker/last.d.ts +26 -0
- package/dist/pi-intercom/broker/last.js +57 -32
- package/dist/pi-intercom/broker/paths.d.ts +13 -0
- package/dist/pi-intercom/broker/paths.js +35 -12
- package/dist/pi-intercom/broker/pid.js +5 -1
- package/dist/pi-intercom/broker/server-connection.js +19 -0
- package/dist/pi-intercom/broker/snapshot-reader.d.ts +9 -0
- package/dist/pi-intercom/broker/snapshots.d.ts +29 -0
- package/dist/pi-intercom/broker/targets.d.ts +9 -0
- package/dist/pi-intercom/connection.js +10 -2
- package/dist/pi-intercom/identity.js +6 -5
- package/dist/pi-intercom/inbound-delivery.js +16 -60
- package/dist/pi-intercom/inbound-journal.js +83 -6
- package/dist/pi-intercom/inbound-record.js +18 -1
- package/dist/pi-intercom/inbound.js +12 -38
- package/dist/pi-intercom/index.js +3 -0
- package/dist/pi-intercom/last-message.js +4 -16
- package/dist/pi-intercom/lifecycle.js +88 -3
- package/dist/pi-intercom/outbound.js +5 -15
- package/dist/pi-intercom/reply-tracker.js +7 -6
- package/dist/pi-intercom/reply-wait.js +2 -2
- package/dist/pi-intercom/session-targets.d.ts +24 -0
- package/dist/pi-intercom/tool-registration.js +4 -1
- package/dist/pi-intercom/tool-results.js +5 -7
- package/dist/pi-intercom/tool-schemas.js +12 -26
- package/dist/pi-intercom/types.d.ts +212 -0
- package/dist/pi-intercom/types.js +54 -3
- package/dist/pi-intercom/validation.d.ts +12 -0
- package/dist/pi-intercom/validation.js +3 -0
- package/dist/shared/bounded-json.js +151 -0
- package/dist/shared/compact-cards.js +66 -0
- package/dist/shared/journal-frames.js +3 -6
- package/dist/shared/json-projection.js +267 -129
- package/dist/shared/native-journal.js +123 -56
- package/dist/shared/post-exit-stdio-guard.js +120 -7
- package/dist/shared/tui-action.js +29 -0
- package/dist/shared/unknown.d.ts +9 -0
- package/docs/code-quality.md +2 -0
- package/docs/development.md +40 -1
- package/docs/intercom-bridge.md +9 -4
- package/docs/intercom.md +226 -6
- package/docs/performance.md +87 -11
- package/docs/reference.md +157 -20
- package/docs/retired-history-maintenance.md +252 -0
- package/package.json +28 -15
- package/scripts/archive-retired-history.mjs +464 -0
- package/scripts/build.mjs +48 -28
- package/scripts/measure-size.mjs +1 -1
- package/scripts/native-package-smoke.mjs +153 -12
- package/scripts/package-smoke.mjs +80 -11
- package/scripts/prepare.mjs +2 -2
- package/scripts/real-pi-live.mjs +377 -0
- package/scripts/real-pi-processes.mjs +6 -1
- package/scripts/real-pi-smoke.mjs +6 -282
- package/scripts/run-tests.mjs +4 -2
- package/scripts/typescript.mjs +35 -0
- package/skills/pi-intercom/SKILL.md +2 -1
- package/skills/pi-subagents/SKILL.md +23 -5
- package/src/agent/contracts.ts +8 -34
- package/src/agent/host.ts +19 -20
- package/src/agent/index.ts +224 -190
- package/src/agent/journal-tail.ts +141 -153
- package/src/agent/launch.ts +33 -9
- package/src/agent/legacy.ts +85 -31
- package/src/agent/notifications.ts +192 -67
- package/src/agent/observation-state.ts +277 -0
- package/src/agent/observation-view.ts +267 -0
- package/src/agent/observation.ts +405 -0
- package/src/agent/observe-card.ts +304 -0
- package/src/agent/observe-chrome.ts +170 -0
- package/src/agent/observe-client.ts +318 -0
- package/src/agent/observe-layout.ts +79 -0
- package/src/agent/observe-reading.ts +97 -0
- package/src/agent/observe-screen.ts +443 -129
- package/src/agent/observe-transcript.ts +434 -0
- package/src/agent/observe.ts +304 -71
- package/src/agent/process.ts +240 -34
- package/src/agent/receipts.ts +114 -24
- package/src/agent/runner.ts +125 -27
- package/src/agent/shell.ts +2 -1
- package/src/agent/storage.ts +27 -11
- package/src/agent/tool-schemas.ts +33 -0
- package/src/agent/typebox.ts +12 -7
- package/src/agent/usage.ts +0 -130
- package/src/agent/view.ts +70 -20
- package/src/agent/wake.ts +107 -0
- package/src/intercom-client.ts +108 -0
- package/src/pi-intercom/bridge-session.ts +2 -2
- package/src/pi-intercom/broker/broker.ts +27 -23
- package/src/pi-intercom/broker/client-last.ts +25 -12
- package/src/pi-intercom/broker/client.ts +55 -33
- package/src/pi-intercom/broker/framing.ts +11 -8
- package/src/pi-intercom/broker/guard.ts +33 -0
- package/src/pi-intercom/broker/last.ts +86 -33
- package/src/pi-intercom/broker/paths.ts +39 -12
- package/src/pi-intercom/broker/pid.ts +6 -1
- package/src/pi-intercom/broker/server-connection.ts +25 -0
- package/src/pi-intercom/connection.ts +21 -10
- package/src/pi-intercom/identity.ts +8 -5
- package/src/pi-intercom/inbound-delivery.ts +20 -66
- package/src/pi-intercom/inbound-journal.ts +103 -7
- package/src/pi-intercom/inbound-record.ts +21 -4
- package/src/pi-intercom/inbound.ts +18 -49
- package/src/pi-intercom/index.ts +3 -0
- package/src/pi-intercom/last-message.ts +10 -19
- package/src/pi-intercom/lifecycle.ts +117 -14
- package/src/pi-intercom/outbound.ts +7 -16
- package/src/pi-intercom/reply-tracker.ts +8 -6
- package/src/pi-intercom/reply-wait.ts +3 -2
- package/src/pi-intercom/runtime-types.ts +0 -1
- package/src/pi-intercom/tool-registration.ts +4 -1
- package/src/pi-intercom/tool-results.ts +10 -9
- package/src/pi-intercom/tool-schemas.ts +12 -44
- package/src/pi-intercom/types.ts +115 -4
- package/src/pi-intercom/validation.ts +4 -0
- package/src/shared/bounded-json.ts +162 -0
- package/src/shared/compact-cards.ts +118 -0
- package/src/shared/journal-frames.ts +3 -19
- package/src/shared/json-projection.ts +267 -147
- package/src/shared/native-journal.ts +157 -75
- package/src/shared/post-exit-stdio-guard.ts +165 -7
- package/src/shared/tui-action.ts +39 -0
- package/tsconfig.client.json +8 -0
- package/tsconfig.json +1 -0
- package/src/shared/stream-json.d.ts +0 -10
|
@@ -5,7 +5,7 @@ import { writeMessage } from "./framing.ts";
|
|
|
5
5
|
import { BrokerConnection, connectBrokerSocket } from "./connection.ts";
|
|
6
6
|
import { SnapshotReader } from "./snapshot-reader.ts";
|
|
7
7
|
import { LastMessageRequests } from "./client-last.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { isNativeReadQuery, isNativeReadResult, type NativeReadQuery } from "./last.ts";
|
|
9
9
|
import {
|
|
10
10
|
errorMessage,
|
|
11
11
|
isRecord,
|
|
@@ -18,16 +18,16 @@ import {
|
|
|
18
18
|
normalizeSessionInfo,
|
|
19
19
|
type SessionInfo,
|
|
20
20
|
type Message,
|
|
21
|
-
type Attachment,
|
|
22
21
|
type MessageDelivery,
|
|
23
|
-
type QueueMode,
|
|
24
|
-
type HumanMessageOrigin,
|
|
25
22
|
type TopicUpdate,
|
|
26
23
|
type TopicChange,
|
|
27
24
|
type SessionSnapshot,
|
|
28
25
|
type SendResult,
|
|
29
26
|
type LastMessageQuery,
|
|
30
27
|
type LastMessageResult,
|
|
28
|
+
type SendOptions,
|
|
29
|
+
type ReceiptQuery,
|
|
30
|
+
type ReceiptResult,
|
|
31
31
|
} from "../types.ts";
|
|
32
32
|
export type { SendResult } from "../types.ts";
|
|
33
33
|
|
|
@@ -43,20 +43,6 @@ export interface IntercomClientOptions {
|
|
|
43
43
|
readonly listTimeoutMs?: number;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
interface SendOptions {
|
|
47
|
-
readonly text: string;
|
|
48
|
-
readonly attachments?: readonly Attachment[];
|
|
49
|
-
readonly replyTo?: string;
|
|
50
|
-
readonly expectsReply?: boolean;
|
|
51
|
-
readonly delivery?: MessageDelivery;
|
|
52
|
-
readonly queueMode?: QueueMode;
|
|
53
|
-
readonly threadId?: string;
|
|
54
|
-
readonly passive?: boolean;
|
|
55
|
-
readonly messageId?: string;
|
|
56
|
-
readonly human?: HumanMessageOrigin;
|
|
57
|
-
readonly topic?: TopicUpdate;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
46
|
function toError(error: unknown): Error {
|
|
61
47
|
return error instanceof Error ? error : new Error(errorMessage(error));
|
|
62
48
|
}
|
|
@@ -79,7 +65,9 @@ export class IntercomClient extends EventEmitter {
|
|
|
79
65
|
private _sessionId: string | null = null;
|
|
80
66
|
private _topicsSupported = false;
|
|
81
67
|
private lastMessagesSupported = false;
|
|
82
|
-
private
|
|
68
|
+
private receiptReadsSupported = false;
|
|
69
|
+
private guardedDeliverySupported = false;
|
|
70
|
+
private readonly lastRequests: LastMessageRequests;
|
|
83
71
|
private readonly pendingSends = new Map<
|
|
84
72
|
string,
|
|
85
73
|
{ readonly resolve: (r: SendResult) => void; readonly reject: (e: Readonly<Error>) => void }
|
|
@@ -101,6 +89,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
101
89
|
super();
|
|
102
90
|
this.sendTimeoutMs = options.sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS;
|
|
103
91
|
this.listTimeoutMs = options.listTimeoutMs ?? DEFAULT_LIST_TIMEOUT_MS;
|
|
92
|
+
this.lastRequests = new LastMessageRequests(this.listTimeoutMs);
|
|
104
93
|
}
|
|
105
94
|
|
|
106
95
|
private failPending(error: Readonly<Error>): void {
|
|
@@ -122,6 +111,9 @@ export class IntercomClient extends EventEmitter {
|
|
|
122
111
|
get supportsTopics(): boolean {
|
|
123
112
|
return this.isConnected() && this._topicsSupported;
|
|
124
113
|
}
|
|
114
|
+
get supportsGuardedDelivery(): boolean {
|
|
115
|
+
return this.isConnected() && this.guardedDeliverySupported;
|
|
116
|
+
}
|
|
125
117
|
isConnected(): boolean {
|
|
126
118
|
return (
|
|
127
119
|
(this._sessionId ?? "").length > 0 &&
|
|
@@ -165,6 +157,8 @@ export class IntercomClient extends EventEmitter {
|
|
|
165
157
|
this._sessionId = null;
|
|
166
158
|
this._topicsSupported = false;
|
|
167
159
|
this.lastMessagesSupported = false;
|
|
160
|
+
this.receiptReadsSupported = false;
|
|
161
|
+
this.guardedDeliverySupported = false;
|
|
168
162
|
this.disconnecting = false;
|
|
169
163
|
this.failPending(error);
|
|
170
164
|
if (established && !expected) {
|
|
@@ -181,6 +175,8 @@ export class IntercomClient extends EventEmitter {
|
|
|
181
175
|
requestedId,
|
|
182
176
|
topicFrames: true,
|
|
183
177
|
lastMessages: true,
|
|
178
|
+
receiptReads: 1,
|
|
179
|
+
guardedDelivery: 1,
|
|
184
180
|
});
|
|
185
181
|
} catch (error) {
|
|
186
182
|
if (this.connection === connection) {
|
|
@@ -266,13 +262,15 @@ export class IntercomClient extends EventEmitter {
|
|
|
266
262
|
this._sessionId = msg.sessionId;
|
|
267
263
|
this._topicsSupported = msg.topicsSupported === true && msg.topicFrames === true;
|
|
268
264
|
this.lastMessagesSupported = msg.lastMessages === true;
|
|
265
|
+
this.receiptReadsSupported = msg.receiptReads === 1;
|
|
266
|
+
this.guardedDeliverySupported = msg.guardedDelivery === 1;
|
|
269
267
|
this.connection?.confirmRegistration();
|
|
270
268
|
this.emit("_registered", { type: "registered", sessionId: msg.sessionId });
|
|
271
269
|
}
|
|
272
270
|
|
|
273
271
|
private receiveLastRequest(msg: UnknownRecord): void {
|
|
274
272
|
const from = normalizeSessionInfo(msg.from);
|
|
275
|
-
if (typeof msg.requestId !== "string" || !from || !
|
|
273
|
+
if (typeof msg.requestId !== "string" || !from || !isNativeReadQuery(msg.query)) {
|
|
276
274
|
throw new Error("Invalid last-message request");
|
|
277
275
|
}
|
|
278
276
|
if (!this.emit("last_request", { requestId: msg.requestId, from, query: msg.query })) {
|
|
@@ -351,20 +349,25 @@ export class IntercomClient extends EventEmitter {
|
|
|
351
349
|
return this.requestSessions().then((snapshot) => [...snapshot.sessions]);
|
|
352
350
|
}
|
|
353
351
|
|
|
354
|
-
last(to: string, query: LastMessageQuery = {}): Promise<LastMessageResult> {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
352
|
+
async last(to: string, query: LastMessageQuery = {}): Promise<LastMessageResult> {
|
|
353
|
+
return await this.lastRequests.last(this.readSocket(query), to, query);
|
|
354
|
+
}
|
|
355
|
+
async receipt(to: string, query: ReceiptQuery): Promise<ReceiptResult> {
|
|
356
|
+
return await this.lastRequests.receipt(this.readSocket(query), to, query);
|
|
357
|
+
}
|
|
358
|
+
private readSocket(query: NativeReadQuery): net.Socket {
|
|
359
|
+
const socket = this.requireActiveSocket();
|
|
360
|
+
if (!this.lastMessagesSupported) {
|
|
361
|
+
throw new Error("This broker does not support last-message reads; reload after it exits.");
|
|
362
|
+
}
|
|
363
|
+
if ("messageId" in query && !this.receiptReadsSupported) {
|
|
364
|
+
throw new Error("publication_unavailable");
|
|
363
365
|
}
|
|
366
|
+
return socket;
|
|
364
367
|
}
|
|
365
368
|
|
|
366
369
|
respondLast(requestId: string, result: unknown): void {
|
|
367
|
-
if (!
|
|
370
|
+
if (!isNativeReadResult(result)) {
|
|
368
371
|
throw new Error("Invalid last-message result");
|
|
369
372
|
}
|
|
370
373
|
writeMessage(this.requireActiveSocket(), { type: "last_response", requestId, result });
|
|
@@ -410,7 +413,9 @@ export class IntercomClient extends EventEmitter {
|
|
|
410
413
|
const timeout = setTimeout(() => {
|
|
411
414
|
if (this.pendingLists.has(requestId)) {
|
|
412
415
|
this.pendingLists.delete(requestId);
|
|
413
|
-
wrappedReject(
|
|
416
|
+
wrappedReject(
|
|
417
|
+
Object.assign(new Error("List sessions timeout"), { code: "list_timeout" }),
|
|
418
|
+
);
|
|
414
419
|
}
|
|
415
420
|
}, this.listTimeoutMs);
|
|
416
421
|
timeout.unref();
|
|
@@ -434,20 +439,33 @@ export class IntercomClient extends EventEmitter {
|
|
|
434
439
|
});
|
|
435
440
|
}
|
|
436
441
|
|
|
442
|
+
private requireSendSocket(options: SendOptions): net.Socket {
|
|
443
|
+
const socket = this.requireActiveSocket();
|
|
444
|
+
if (options.guard && !this.guardedDeliverySupported) {
|
|
445
|
+
throw new Error("This broker does not support guarded delivery; no message sent.");
|
|
446
|
+
}
|
|
447
|
+
return socket;
|
|
448
|
+
}
|
|
449
|
+
|
|
437
450
|
send(to: string, options: SendOptions): Promise<SendResult> {
|
|
438
451
|
let socket: net.Socket;
|
|
439
452
|
try {
|
|
440
|
-
socket = this.
|
|
453
|
+
socket = this.requireSendSocket(options);
|
|
441
454
|
} catch (error) {
|
|
442
455
|
return Promise.reject(toError(error));
|
|
443
456
|
}
|
|
444
457
|
|
|
445
|
-
const messageId = options.messageId ?? randomUUID();
|
|
458
|
+
const messageId = options.messageId ?? (options.guard ? "" : randomUUID());
|
|
459
|
+
if (this.pendingSends.has(messageId)) {
|
|
460
|
+
return Promise.reject(new Error("Message ID already has a pending send"));
|
|
461
|
+
}
|
|
446
462
|
const message: Message = {
|
|
447
463
|
id: messageId,
|
|
448
464
|
timestamp: Date.now(),
|
|
449
465
|
replyTo: options.replyTo,
|
|
450
466
|
expectsReply: options.expectsReply,
|
|
467
|
+
guard: options.guard,
|
|
468
|
+
replyHint: options.replyHint,
|
|
451
469
|
...(options.human ? { human: options.human } : {}),
|
|
452
470
|
...(options.topic ? { topic: options.topic } : {}),
|
|
453
471
|
delivery: messageDelivery(options),
|
|
@@ -460,6 +478,9 @@ export class IntercomClient extends EventEmitter {
|
|
|
460
478
|
},
|
|
461
479
|
};
|
|
462
480
|
|
|
481
|
+
if ((options.guard || options.replyHint) && normalizeMessage(message) === null) {
|
|
482
|
+
return Promise.reject(new Error("Invalid message format"));
|
|
483
|
+
}
|
|
463
484
|
return new Promise((resolve, reject) => {
|
|
464
485
|
const wrappedResolve = (result: SendResult) => {
|
|
465
486
|
clearTimeout(timeout);
|
|
@@ -493,6 +514,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
493
514
|
}
|
|
494
515
|
|
|
495
516
|
updatePresence(updates: {
|
|
517
|
+
readonly applicationGuard?: string | null;
|
|
496
518
|
readonly name?: string;
|
|
497
519
|
readonly status?: string;
|
|
498
520
|
readonly model?: string;
|
|
@@ -19,15 +19,19 @@ export function validateIntercomMessageSize(msg: unknown): Error | null {
|
|
|
19
19
|
* Format: 4-byte big-endian length + JSON payload
|
|
20
20
|
*/
|
|
21
21
|
export function writeMessage(socket: Socket, msg: unknown): void {
|
|
22
|
-
|
|
22
|
+
writeFrame(socket, JSON.stringify(msg));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Write serialized JSON; false means Node accepted it but the caller should wait for drain. */
|
|
26
|
+
export function writeFrame(socket: Socket, json: string, limit = MAX_FRAME_SIZE_BYTES): boolean {
|
|
23
27
|
const size = Buffer.byteLength(json, "utf-8");
|
|
24
|
-
if (size >
|
|
25
|
-
throw new Error(`Intercom message too large (${size} bytes; max ${
|
|
28
|
+
if (size > limit) {
|
|
29
|
+
throw new Error(`Intercom message too large (${size} bytes; max ${limit})`);
|
|
26
30
|
}
|
|
27
31
|
const payload = Buffer.from(json, "utf-8");
|
|
28
32
|
const header = Buffer.alloc(4);
|
|
29
33
|
header.writeUInt32BE(payload.length, 0);
|
|
30
|
-
socket.write(Buffer.concat([header, payload]));
|
|
34
|
+
return socket.write(Buffer.concat([header, payload]));
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
/** Retain fragments until a header or payload is complete; copy each fragmented value once. */
|
|
@@ -88,6 +92,7 @@ class MessageBuffer {
|
|
|
88
92
|
export function createMessageReader(
|
|
89
93
|
onMessage: (msg: unknown) => void,
|
|
90
94
|
onError: (error: Readonly<Error>) => void,
|
|
95
|
+
limit = MAX_FRAME_SIZE_BYTES,
|
|
91
96
|
): (data: Buffer) => void {
|
|
92
97
|
const buffer = new MessageBuffer();
|
|
93
98
|
let length: number | undefined;
|
|
@@ -98,10 +103,8 @@ export function createMessageReader(
|
|
|
98
103
|
while (buffer.length >= (length ?? 4)) {
|
|
99
104
|
if (length === undefined) {
|
|
100
105
|
length = buffer.read(4).readUInt32BE(0);
|
|
101
|
-
if (length >
|
|
102
|
-
onError(
|
|
103
|
-
new Error(`Intercom frame too large (${length} bytes; max ${MAX_FRAME_SIZE_BYTES})`),
|
|
104
|
-
);
|
|
106
|
+
if (length > limit) {
|
|
107
|
+
onError(new Error(`Intercom frame too large (${length} bytes; max ${limit})`));
|
|
105
108
|
buffer.clear();
|
|
106
109
|
length = undefined;
|
|
107
110
|
return;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Message, SessionInfo } from "../types.ts";
|
|
2
|
+
|
|
3
|
+
/** Guarded commands use an exact main, never convenience names or a replacement context. */
|
|
4
|
+
export function guardedDeliveryError(
|
|
5
|
+
sessions: ReadonlyMap<string, { readonly info: SessionInfo }>,
|
|
6
|
+
to: string,
|
|
7
|
+
message: Message,
|
|
8
|
+
): string | null {
|
|
9
|
+
const guard = message.guard;
|
|
10
|
+
if (!guard) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const registered = sessions.get(to);
|
|
14
|
+
if (!registered || to !== guard.peerId) {
|
|
15
|
+
return "Guarded target not found";
|
|
16
|
+
}
|
|
17
|
+
const target = registered.info;
|
|
18
|
+
if (target.guardedDelivery !== 1) {
|
|
19
|
+
return "Recipient does not support guarded delivery";
|
|
20
|
+
}
|
|
21
|
+
if (
|
|
22
|
+
target.parentSessionId !== undefined ||
|
|
23
|
+
target.rootSessionId !== guard.rootSessionId ||
|
|
24
|
+
target.incarnation !== guard.incarnation ||
|
|
25
|
+
target.applicationGuard !== guard.applicationGuard
|
|
26
|
+
) {
|
|
27
|
+
return "Guarded target changed";
|
|
28
|
+
}
|
|
29
|
+
const mains = [...sessions.values()].filter(
|
|
30
|
+
({ info }) => info.rootSessionId === guard.rootSessionId && info.parentSessionId === undefined,
|
|
31
|
+
);
|
|
32
|
+
return mains.length === 1 ? null : "Guarded root is ambiguous";
|
|
33
|
+
}
|
|
@@ -2,34 +2,68 @@ import type net from "node:net";
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { isRecord, type UnknownRecord } from "../../shared/unknown.ts";
|
|
4
4
|
import {
|
|
5
|
+
isDeliveryGuard,
|
|
6
|
+
isPublicationReplyHint,
|
|
7
|
+
isLastMessageQuery,
|
|
8
|
+
isReceiptQuery,
|
|
9
|
+
LAST_MESSAGE_PAGE_LIMIT,
|
|
5
10
|
normalizeSessionInfo,
|
|
6
11
|
type LastMessageQuery,
|
|
7
12
|
type LastMessageResult,
|
|
8
13
|
type SessionInfo,
|
|
14
|
+
type NativeReceipt,
|
|
15
|
+
type ReceiptQuery,
|
|
16
|
+
type ReceiptResult,
|
|
9
17
|
} from "../types.ts";
|
|
18
|
+
import { finiteNumber, nonNegativeInteger, optionalLabel, safeLabel } from "../validation.ts";
|
|
10
19
|
import { validateIntercomMessageSize, writeMessage } from "./framing.ts";
|
|
11
20
|
import { messageSender } from "./snapshots.ts";
|
|
12
21
|
import { findSessions } from "./targets.ts";
|
|
13
22
|
|
|
14
|
-
export
|
|
23
|
+
export type NativeReadQuery = LastMessageQuery | ReceiptQuery;
|
|
24
|
+
export type NativeReadResult = LastMessageResult | ReceiptResult;
|
|
15
25
|
|
|
16
|
-
function
|
|
17
|
-
return
|
|
26
|
+
export function isNativeReadQuery(value: unknown): value is NativeReadQuery {
|
|
27
|
+
return isReceiptQuery(value) || isLastMessageQuery(value);
|
|
18
28
|
}
|
|
19
|
-
function
|
|
20
|
-
|
|
29
|
+
function isNativeReceipt(value: unknown): value is NativeReceipt {
|
|
30
|
+
if (!isReceiptQuery(value) || !isRecord(value) || !isRecord(value.sender)) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
return (
|
|
34
|
+
value.version === 1 &&
|
|
35
|
+
safeLabel(value.sender.peerId) &&
|
|
36
|
+
[value.sender.rootSessionId, value.sender.incarnation].every(optionalLabel) &&
|
|
37
|
+
typeof value.textSha256 === "string" &&
|
|
38
|
+
/^[a-f0-9]{64}$/.test(value.textSha256) &&
|
|
39
|
+
receiptPublication(value) &&
|
|
40
|
+
receiptFacts(value)
|
|
41
|
+
);
|
|
21
42
|
}
|
|
22
|
-
|
|
43
|
+
function receiptPublication(value: UnknownRecord): boolean {
|
|
23
44
|
return (
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
(value.
|
|
27
|
-
(value.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
45
|
+
typeof value.delivery === "string" &&
|
|
46
|
+
["queue", "steer", "passive"].includes(value.delivery) &&
|
|
47
|
+
(value.guard === undefined || isDeliveryGuard(value.guard)) &&
|
|
48
|
+
(value.replyHint === undefined || isPublicationReplyHint(value.replyHint))
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
function receiptFacts(value: UnknownRecord): boolean {
|
|
52
|
+
return (
|
|
53
|
+
safeLabel(value.checkpointEntryId) &&
|
|
54
|
+
finiteNumber(value.receivedAt) &&
|
|
55
|
+
[value.queued, value.nativeHandoff, value.discarded].every(
|
|
56
|
+
(field) => typeof field === "boolean",
|
|
57
|
+
) &&
|
|
58
|
+
(value.placementEntryId === null || safeLabel(value.placementEntryId))
|
|
31
59
|
);
|
|
32
60
|
}
|
|
61
|
+
export function isReceiptResult(value: unknown): value is ReceiptResult {
|
|
62
|
+
return isRecord(value) && (typeof value.error === "string" || isNativeReceipt(value.receipt));
|
|
63
|
+
}
|
|
64
|
+
export function isNativeReadResult(value: unknown): value is NativeReadResult {
|
|
65
|
+
return isReceiptResult(value) || isLastMessageResult(value);
|
|
66
|
+
}
|
|
33
67
|
function pageIdentity(value: UnknownRecord): boolean {
|
|
34
68
|
return (
|
|
35
69
|
normalizeSessionInfo(value.session) !== null &&
|
|
@@ -65,18 +99,20 @@ interface LastPeer {
|
|
|
65
99
|
readonly socket: net.Socket;
|
|
66
100
|
readonly info: SessionInfo;
|
|
67
101
|
readonly lastMessages: boolean;
|
|
102
|
+
readonly receiptReads: boolean;
|
|
68
103
|
}
|
|
69
104
|
interface PendingLast {
|
|
70
105
|
readonly fromId: string;
|
|
71
106
|
readonly toId: string;
|
|
72
107
|
readonly requestId: string;
|
|
73
|
-
readonly query:
|
|
108
|
+
readonly query: NativeReadQuery;
|
|
74
109
|
readonly timer: NodeJS.Timeout;
|
|
75
110
|
}
|
|
76
111
|
function unavailable(
|
|
77
112
|
targets: readonly LastPeer[],
|
|
78
113
|
name: string,
|
|
79
114
|
pending: number,
|
|
115
|
+
query: NativeReadQuery,
|
|
80
116
|
): string | undefined {
|
|
81
117
|
if (targets.length === 0) {
|
|
82
118
|
return "Session not found";
|
|
@@ -87,20 +123,44 @@ function unavailable(
|
|
|
87
123
|
if (!targets[0].lastMessages) {
|
|
88
124
|
return "Recipient does not support last-message reads; reload its extension.";
|
|
89
125
|
}
|
|
126
|
+
if (
|
|
127
|
+
"messageId" in query &&
|
|
128
|
+
(!targets[0].receiptReads || targets[0].info.rootSessionId !== query.rootSessionId)
|
|
129
|
+
) {
|
|
130
|
+
return "publication_unavailable";
|
|
131
|
+
}
|
|
90
132
|
if (pending >= 128) {
|
|
91
133
|
return "Too many pending last-message reads; retry after they settle.";
|
|
92
134
|
}
|
|
93
135
|
return undefined;
|
|
94
136
|
}
|
|
95
|
-
function matchesPage(result:
|
|
137
|
+
function matchesPage(result: NativeReadResult, pending: PendingLast, fromId: string): boolean {
|
|
96
138
|
if ("error" in result) {
|
|
97
139
|
return true;
|
|
98
140
|
}
|
|
141
|
+
if ("messageId" in pending.query) {
|
|
142
|
+
return (
|
|
143
|
+
"receipt" in result &&
|
|
144
|
+
result.receipt.messageId === pending.query.messageId &&
|
|
145
|
+
result.receipt.rootSessionId === pending.query.rootSessionId
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if ("receipt" in result) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
return matchesLastPage(result, pending.query, fromId);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function matchesLastPage(
|
|
155
|
+
result: Exclude<LastMessageResult, { readonly error: string }>,
|
|
156
|
+
query: LastMessageQuery,
|
|
157
|
+
fromId: string,
|
|
158
|
+
): boolean {
|
|
99
159
|
return (
|
|
100
160
|
result.session.id === fromId &&
|
|
101
|
-
result.offset === (
|
|
102
|
-
(
|
|
103
|
-
result.text.length <= (
|
|
161
|
+
result.offset === (query.offset ?? 0) &&
|
|
162
|
+
(query.entryId === undefined || result.entryId === query.entryId) &&
|
|
163
|
+
result.text.length <= (query.limit ?? LAST_MESSAGE_PAGE_LIMIT)
|
|
104
164
|
);
|
|
105
165
|
}
|
|
106
166
|
|
|
@@ -116,7 +176,7 @@ export class LastMessageRelay {
|
|
|
116
176
|
if (
|
|
117
177
|
typeof message.requestId !== "string" ||
|
|
118
178
|
typeof message.to !== "string" ||
|
|
119
|
-
!
|
|
179
|
+
!isNativeReadQuery(message.query)
|
|
120
180
|
) {
|
|
121
181
|
throw new Error("Invalid last message request");
|
|
122
182
|
}
|
|
@@ -126,15 +186,6 @@ export class LastMessageRelay {
|
|
|
126
186
|
if (!from) {
|
|
127
187
|
return;
|
|
128
188
|
}
|
|
129
|
-
const error = unavailable(targets, message.to, this.pending.size);
|
|
130
|
-
if (error !== undefined) {
|
|
131
|
-
writeMessage(from.socket, {
|
|
132
|
-
type: "last_response",
|
|
133
|
-
requestId: message.requestId,
|
|
134
|
-
result: { error },
|
|
135
|
-
});
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
189
|
const token = randomUUID();
|
|
139
190
|
const frame = {
|
|
140
191
|
type: "last_request" as const,
|
|
@@ -142,12 +193,14 @@ export class LastMessageRelay {
|
|
|
142
193
|
from: messageSender(from.info),
|
|
143
194
|
query: message.query,
|
|
144
195
|
};
|
|
145
|
-
const
|
|
146
|
-
|
|
196
|
+
const error =
|
|
197
|
+
unavailable(targets, message.to, this.pending.size, message.query) ??
|
|
198
|
+
validateIntercomMessageSize(frame)?.message;
|
|
199
|
+
if (error !== undefined) {
|
|
147
200
|
writeMessage(from.socket, {
|
|
148
201
|
type: "last_response",
|
|
149
202
|
requestId: message.requestId,
|
|
150
|
-
result: { error
|
|
203
|
+
result: { error },
|
|
151
204
|
});
|
|
152
205
|
return;
|
|
153
206
|
}
|
|
@@ -181,7 +234,7 @@ export class LastMessageRelay {
|
|
|
181
234
|
const result = message.result;
|
|
182
235
|
this.finish(
|
|
183
236
|
message.requestId,
|
|
184
|
-
|
|
237
|
+
isNativeReadResult(result) && matchesPage(result, pending, fromId)
|
|
185
238
|
? result
|
|
186
239
|
: { error: "Recipient returned an invalid last-message page" },
|
|
187
240
|
);
|
|
@@ -195,7 +248,7 @@ export class LastMessageRelay {
|
|
|
195
248
|
}
|
|
196
249
|
}
|
|
197
250
|
|
|
198
|
-
private finish(token: string, result:
|
|
251
|
+
private finish(token: string, result: NativeReadResult): void {
|
|
199
252
|
const pending = this.pending.get(token);
|
|
200
253
|
if (!pending) {
|
|
201
254
|
return;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { dirname, isAbsolute, join } from "node:path";
|
|
4
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
5
|
import { getPiAgentDir } from "../agent-dir.ts";
|
|
6
6
|
|
|
7
7
|
const MAX_UNIX_SOCKET_PATH_BYTES = 100;
|
|
@@ -36,37 +36,64 @@ export function isOwnedBrokerSocket(socketPath: string): boolean {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
function defaultRuntimeRoot(): string {
|
|
40
|
+
if (process.platform !== "android") {
|
|
41
|
+
return "/tmp";
|
|
42
|
+
}
|
|
43
|
+
return process.env.PREFIX === undefined ? tmpdir() : join(process.env.PREFIX, "tmp");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function runtimeDirectory(socketName: string, sibling = ""): string {
|
|
41
47
|
const configured = process.env.PI_INTERCOM_RUNTIME_DIR;
|
|
42
48
|
const uid = process.getuid?.();
|
|
43
49
|
if (uid === undefined) {
|
|
44
50
|
throw new Error("Intercom requires Unix user IDs and Unix domain sockets.");
|
|
45
51
|
}
|
|
46
|
-
const
|
|
52
|
+
const base = configured ?? join(defaultRuntimeRoot(), `pi-intercom-${uid}`);
|
|
53
|
+
const directory = isAbsolute(base) && sibling !== "" ? `${resolve(base)}${sibling}` : base;
|
|
47
54
|
if (
|
|
48
55
|
!isAbsolute(directory) ||
|
|
49
56
|
directory.includes("\0") ||
|
|
50
|
-
Buffer.byteLength(join(directory,
|
|
57
|
+
Buffer.byteLength(join(directory, socketName)) > MAX_UNIX_SOCKET_PATH_BYTES
|
|
51
58
|
) {
|
|
52
59
|
throw new Error("PI_INTERCOM_RUNTIME_DIR must be an absolute short Unix socket directory.");
|
|
53
60
|
}
|
|
54
61
|
return directory;
|
|
55
62
|
}
|
|
56
63
|
|
|
64
|
+
/** A deployment can explicitly opt out of same-user discovery with a private runtime directory. */
|
|
65
|
+
export function getBrokerRuntimeDir(): string {
|
|
66
|
+
return runtimeDirectory("broker.sock");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Read-only child observation endpoints share the runtime policy, not the broker namespace.
|
|
70
|
+
* The endpoint name is a short token digest because the private runtime directory also counts
|
|
71
|
+
* toward the Unix-socket path limit; a full token made nested test/deployment roots overflow. */
|
|
72
|
+
export function agentObservationSocketPath(token: string): string {
|
|
73
|
+
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(token)) {
|
|
74
|
+
throw new Error("Invalid actor token.");
|
|
75
|
+
}
|
|
76
|
+
const name = `obs-${createHash("sha256").update(token).digest("hex").slice(0, 16)}.sock`;
|
|
77
|
+
return join(runtimeDirectory(name, "-agents"), name);
|
|
78
|
+
}
|
|
79
|
+
|
|
57
80
|
export function getBrokerSocketPath(): string {
|
|
58
81
|
return join(getBrokerRuntimeDir(), "broker.sock");
|
|
59
82
|
}
|
|
60
83
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
fs.mkdirSync(
|
|
65
|
-
const stat = fs.lstatSync(
|
|
84
|
+
/** Create or verify an owned, non-symlink, private socket directory. */
|
|
85
|
+
export function preparePrivateSocketPath(socketPath: string): string {
|
|
86
|
+
const directory = dirname(socketPath);
|
|
87
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
88
|
+
const stat = fs.lstatSync(directory);
|
|
66
89
|
const uid = process.getuid?.();
|
|
67
90
|
if (!stat.isDirectory() || stat.isSymbolicLink() || (uid !== undefined && stat.uid !== uid)) {
|
|
68
|
-
throw new Error(`Unsafe intercom socket directory: ${
|
|
91
|
+
throw new Error(`Unsafe intercom socket directory: ${directory}`);
|
|
69
92
|
}
|
|
70
|
-
fs.chmodSync(
|
|
93
|
+
fs.chmodSync(directory, 0o700);
|
|
71
94
|
return socketPath;
|
|
72
95
|
}
|
|
96
|
+
|
|
97
|
+
export function prepareBrokerSocketPath(): string {
|
|
98
|
+
return preparePrivateSocketPath(getBrokerSocketPath());
|
|
99
|
+
}
|
|
@@ -80,6 +80,11 @@ export function brokerPidRecord(): string {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
export function isBrokerPidReused(pid: number, record: string): boolean {
|
|
83
|
+
return isLinuxProcessReplaced(pid, record.slice(record.indexOf("\n") + 1));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** True only when a live Linux PID provably names a different process than the identity. */
|
|
87
|
+
export function isLinuxProcessReplaced(pid: number, identity: string): boolean {
|
|
83
88
|
const current = readLinuxProcess(pid);
|
|
84
89
|
if (!current) {
|
|
85
90
|
return false;
|
|
@@ -87,7 +92,7 @@ export function isBrokerPidReused(pid: number, record: string): boolean {
|
|
|
87
92
|
if (current.tgid !== pid) {
|
|
88
93
|
return true;
|
|
89
94
|
}
|
|
90
|
-
const saved = IDENTITY.exec(
|
|
95
|
+
const saved = IDENTITY.exec(identity.trim());
|
|
91
96
|
const actual = IDENTITY.exec(current.identity ?? "");
|
|
92
97
|
if (!saved || !actual) {
|
|
93
98
|
return false;
|
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
import type net from "node:net";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { createMessageReader } from "./framing.ts";
|
|
4
|
+
import { assertMainParent } from "./targets.ts";
|
|
5
|
+
import { isSessionRegistration, type SessionInfo } from "../types.ts";
|
|
6
|
+
import type { UnknownRecord } from "../../shared/unknown.ts";
|
|
7
|
+
|
|
8
|
+
export function registeredSessionInfo(
|
|
9
|
+
msg: UnknownRecord,
|
|
10
|
+
sessions: ReadonlyMap<string, { readonly info: SessionInfo }>,
|
|
11
|
+
): SessionInfo {
|
|
12
|
+
if (!isSessionRegistration(msg.session)) {
|
|
13
|
+
throw new Error("Invalid register message");
|
|
14
|
+
}
|
|
15
|
+
assertMainParent(msg.session, sessions);
|
|
16
|
+
const requestedId =
|
|
17
|
+
typeof msg.requestedId === "string" && /^[a-zA-Z0-9_-]{8,80}$/.test(msg.requestedId)
|
|
18
|
+
? msg.requestedId
|
|
19
|
+
: undefined;
|
|
20
|
+
const id = requestedId !== undefined && !sessions.has(requestedId) ? requestedId : randomUUID();
|
|
21
|
+
return {
|
|
22
|
+
...msg.session,
|
|
23
|
+
id,
|
|
24
|
+
guardedDelivery: msg.guardedDelivery === 1 ? msg.session.guardedDelivery : undefined,
|
|
25
|
+
lastSeen: msg.session.lastSeen ?? Date.now(),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
3
28
|
|
|
4
29
|
type ClientMessageHandler = (
|
|
5
30
|
socket: net.Socket,
|