@fitchmultz/pi-subagents 0.50.0 → 0.52.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 +63 -2
- package/README.md +36 -5
- package/dist/agent/child-runtime.js +80 -0
- package/dist/agent/contracts.js +15 -24
- package/dist/agent/host.js +3 -16
- package/dist/agent/index.js +212 -192
- 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 +350 -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/parking.js +253 -0
- package/dist/agent/process.js +228 -28
- package/dist/agent/receipts.js +92 -20
- package/dist/agent/runner.js +229 -126
- package/dist/agent/shell.js +2 -1
- package/dist/agent/storage.js +47 -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 +62 -43
- package/dist/pi-intercom/broker/client-last.d.ts +24 -0
- package/dist/pi-intercom/broker/client-last.js +42 -5
- package/dist/pi-intercom/broker/client-park.d.ts +9 -0
- package/dist/pi-intercom/broker/client-park.js +21 -0
- package/dist/pi-intercom/broker/client.d.ts +59 -0
- package/dist/pi-intercom/broker/client.js +55 -29
- 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 +30 -0
- package/dist/pi-intercom/broker/last.js +87 -40
- package/dist/pi-intercom/broker/parked.js +107 -0
- 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 +20 -1
- 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 +71 -20
- 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 +22 -37
- package/dist/pi-intercom/index.js +41 -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/parking.js +12 -0
- 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 +5 -3
- package/docs/development.md +42 -3
- package/docs/intercom-bridge.md +9 -4
- package/docs/intercom.md +228 -7
- package/docs/performance.md +118 -14
- package/docs/reference.md +168 -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/child-runtime.ts +105 -0
- package/src/agent/contracts.ts +18 -34
- package/src/agent/host.ts +39 -20
- package/src/agent/index.ts +234 -207
- 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 +409 -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/parking.ts +341 -0
- package/src/agent/process.ts +280 -37
- package/src/agent/receipts.ts +114 -24
- package/src/agent/runner.ts +227 -149
- package/src/agent/shell.ts +2 -1
- package/src/agent/storage.ts +44 -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 +64 -53
- package/src/pi-intercom/broker/client-last.ts +65 -12
- package/src/pi-intercom/broker/client-park.ts +23 -0
- package/src/pi-intercom/broker/client.ts +63 -45
- 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 +131 -43
- package/src/pi-intercom/broker/parked.ts +130 -0
- 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 +27 -2
- package/src/pi-intercom/connection.ts +103 -39
- 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 +32 -49
- package/src/pi-intercom/index.ts +44 -0
- package/src/pi-intercom/last-message.ts +11 -20
- 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/parking.ts +22 -0
- 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
|
@@ -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
|
-
(nonNegativeInteger(value.limit) &&
|
|
29
|
-
value.limit > 0 &&
|
|
30
|
-
value.limit <= LAST_MESSAGE_PAGE_LIMIT))
|
|
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))
|
|
31
49
|
);
|
|
32
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))
|
|
59
|
+
);
|
|
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,38 @@ interface LastPeer {
|
|
|
65
99
|
readonly socket: net.Socket;
|
|
66
100
|
readonly info: SessionInfo;
|
|
67
101
|
readonly lastMessages: boolean;
|
|
102
|
+
readonly receiptReads: boolean;
|
|
103
|
+
readonly parked?: { readonly closed: boolean };
|
|
68
104
|
}
|
|
69
105
|
interface PendingLast {
|
|
70
106
|
readonly fromId: string;
|
|
71
107
|
readonly toId: string;
|
|
108
|
+
/** A parked child's main answers from the child's captured native history. */
|
|
109
|
+
readonly via?: string;
|
|
72
110
|
readonly requestId: string;
|
|
73
|
-
readonly query:
|
|
111
|
+
readonly query: NativeReadQuery;
|
|
74
112
|
readonly timer: NodeJS.Timeout;
|
|
75
113
|
}
|
|
114
|
+
/** An exited parked child answers nothing itself; only its main holds the captured history. */
|
|
115
|
+
function exitedRefusal(
|
|
116
|
+
target: LastPeer,
|
|
117
|
+
query: NativeReadQuery,
|
|
118
|
+
via?: LastPeer,
|
|
119
|
+
): string | undefined {
|
|
120
|
+
if (target.parked?.closed !== true) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
// No loaded journal owner answers receipts, with or without its main.
|
|
124
|
+
if ("messageId" in query) {
|
|
125
|
+
return "publication_unavailable";
|
|
126
|
+
}
|
|
127
|
+
return via === undefined ? "Parked session history is unavailable." : undefined;
|
|
128
|
+
}
|
|
76
129
|
function unavailable(
|
|
77
130
|
targets: readonly LastPeer[],
|
|
78
131
|
name: string,
|
|
79
132
|
pending: number,
|
|
133
|
+
{ query, via }: { readonly query: NativeReadQuery; readonly via?: LastPeer },
|
|
80
134
|
): string | undefined {
|
|
81
135
|
if (targets.length === 0) {
|
|
82
136
|
return "Session not found";
|
|
@@ -84,26 +138,65 @@ function unavailable(
|
|
|
84
138
|
if (targets.length > 1) {
|
|
85
139
|
return `Multiple sessions named "${name}" are connected. Use the session ID instead.`;
|
|
86
140
|
}
|
|
141
|
+
const exited = exitedRefusal(targets[0], query, via);
|
|
142
|
+
if (exited !== undefined) {
|
|
143
|
+
return exited;
|
|
144
|
+
}
|
|
87
145
|
if (!targets[0].lastMessages) {
|
|
88
146
|
return "Recipient does not support last-message reads; reload its extension.";
|
|
89
147
|
}
|
|
148
|
+
if (
|
|
149
|
+
"messageId" in query &&
|
|
150
|
+
(!targets[0].receiptReads || targets[0].info.rootSessionId !== query.rootSessionId)
|
|
151
|
+
) {
|
|
152
|
+
return "publication_unavailable";
|
|
153
|
+
}
|
|
90
154
|
if (pending >= 128) {
|
|
91
155
|
return "Too many pending last-message reads; retry after they settle.";
|
|
92
156
|
}
|
|
93
157
|
return undefined;
|
|
94
158
|
}
|
|
95
|
-
function matchesPage(result:
|
|
159
|
+
function matchesPage(result: NativeReadResult, pending: PendingLast, fromId: string): boolean {
|
|
96
160
|
if ("error" in result) {
|
|
97
161
|
return true;
|
|
98
162
|
}
|
|
163
|
+
if ("messageId" in pending.query) {
|
|
164
|
+
return (
|
|
165
|
+
"receipt" in result &&
|
|
166
|
+
result.receipt.messageId === pending.query.messageId &&
|
|
167
|
+
result.receipt.rootSessionId === pending.query.rootSessionId
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if ("receipt" in result) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
return matchesLastPage(result, pending.query, fromId);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function matchesLastPage(
|
|
177
|
+
result: Exclude<LastMessageResult, { readonly error: string }>,
|
|
178
|
+
query: LastMessageQuery,
|
|
179
|
+
fromId: string,
|
|
180
|
+
): boolean {
|
|
99
181
|
return (
|
|
100
182
|
result.session.id === fromId &&
|
|
101
|
-
result.offset === (
|
|
102
|
-
(
|
|
103
|
-
result.text.length <= (
|
|
183
|
+
result.offset === (query.offset ?? 0) &&
|
|
184
|
+
(query.entryId === undefined || result.entryId === query.entryId) &&
|
|
185
|
+
result.text.length <= (query.limit ?? LAST_MESSAGE_PAGE_LIMIT)
|
|
104
186
|
);
|
|
105
187
|
}
|
|
106
188
|
|
|
189
|
+
function lastRequest(message: UnknownRecord): {
|
|
190
|
+
readonly requestId: string;
|
|
191
|
+
readonly to: string;
|
|
192
|
+
readonly query: NativeReadQuery;
|
|
193
|
+
} {
|
|
194
|
+
const { requestId, to, query } = message;
|
|
195
|
+
if (typeof requestId !== "string" || typeof to !== "string" || !isNativeReadQuery(query)) {
|
|
196
|
+
throw new Error("Invalid last message request");
|
|
197
|
+
}
|
|
198
|
+
return { requestId, to, query };
|
|
199
|
+
}
|
|
107
200
|
/** Correlation belongs to the broker, not the requester or the recipient's model queue. */
|
|
108
201
|
export class LastMessageRelay {
|
|
109
202
|
private readonly pending = new Map<string, PendingLast>();
|
|
@@ -112,42 +205,36 @@ export class LastMessageRelay {
|
|
|
112
205
|
this.sessions = sessions;
|
|
113
206
|
}
|
|
114
207
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
208
|
+
/** `reader` names another peer that answers for an exited target from its captured history. */
|
|
209
|
+
request(
|
|
210
|
+
fromId: string,
|
|
211
|
+
input: UnknownRecord,
|
|
212
|
+
reader: (id: string) => LastPeer | undefined,
|
|
213
|
+
): void {
|
|
214
|
+
const message = lastRequest(input);
|
|
123
215
|
const targets = findSessions(this.sessions, message.to, fromId);
|
|
124
216
|
const target = targets[0];
|
|
125
217
|
const from = this.sessions.get(fromId);
|
|
126
218
|
if (!from) {
|
|
127
219
|
return;
|
|
128
220
|
}
|
|
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
221
|
const token = randomUUID();
|
|
222
|
+
const via = targets.length === 1 ? reader(target.info.id) : undefined;
|
|
139
223
|
const frame = {
|
|
140
224
|
type: "last_request" as const,
|
|
141
225
|
requestId: token,
|
|
142
226
|
from: messageSender(from.info),
|
|
143
227
|
query: message.query,
|
|
228
|
+
parked: via && messageSender(target.info),
|
|
144
229
|
};
|
|
145
|
-
const
|
|
146
|
-
|
|
230
|
+
const error =
|
|
231
|
+
unavailable(targets, message.to, this.pending.size, { query: message.query, via }) ??
|
|
232
|
+
validateIntercomMessageSize(frame)?.message;
|
|
233
|
+
if (error !== undefined) {
|
|
147
234
|
writeMessage(from.socket, {
|
|
148
235
|
type: "last_response",
|
|
149
236
|
requestId: message.requestId,
|
|
150
|
-
result: { error
|
|
237
|
+
result: { error },
|
|
151
238
|
});
|
|
152
239
|
return;
|
|
153
240
|
}
|
|
@@ -159,12 +246,13 @@ export class LastMessageRelay {
|
|
|
159
246
|
this.pending.set(token, {
|
|
160
247
|
fromId,
|
|
161
248
|
toId: target.info.id,
|
|
249
|
+
via: via?.info.id,
|
|
162
250
|
requestId: message.requestId,
|
|
163
251
|
query: message.query,
|
|
164
252
|
timer,
|
|
165
253
|
});
|
|
166
254
|
try {
|
|
167
|
-
writeMessage(target.socket, frame);
|
|
255
|
+
writeMessage((via ?? target).socket, frame);
|
|
168
256
|
} catch {
|
|
169
257
|
this.finish(token, { error: "Recipient disconnected before last-message read" });
|
|
170
258
|
}
|
|
@@ -175,13 +263,13 @@ export class LastMessageRelay {
|
|
|
175
263
|
throw new Error("Invalid last message response");
|
|
176
264
|
}
|
|
177
265
|
const pending = this.pending.get(message.requestId);
|
|
178
|
-
if (!pending || pending.toId !== fromId) {
|
|
266
|
+
if (!pending || (pending.via ?? pending.toId) !== fromId) {
|
|
179
267
|
return;
|
|
180
268
|
}
|
|
181
269
|
const result = message.result;
|
|
182
270
|
this.finish(
|
|
183
271
|
message.requestId,
|
|
184
|
-
|
|
272
|
+
isNativeReadResult(result) && matchesPage(result, pending, pending.toId)
|
|
185
273
|
? result
|
|
186
274
|
: { error: "Recipient returned an invalid last-message page" },
|
|
187
275
|
);
|
|
@@ -189,13 +277,13 @@ export class LastMessageRelay {
|
|
|
189
277
|
|
|
190
278
|
disconnected(sessionId: string): void {
|
|
191
279
|
for (const [token, pending] of this.pending) {
|
|
192
|
-
if (pending.fromId
|
|
280
|
+
if ([pending.fromId, pending.toId, pending.via].includes(sessionId)) {
|
|
193
281
|
this.finish(token, { error: "Peer disconnected during last-message read" });
|
|
194
282
|
}
|
|
195
283
|
}
|
|
196
284
|
}
|
|
197
285
|
|
|
198
|
-
private finish(token: string, result:
|
|
286
|
+
private finish(token: string, result: NativeReadResult): void {
|
|
199
287
|
const pending = this.pending.get(token);
|
|
200
288
|
if (!pending) {
|
|
201
289
|
return;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type net from "net";
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import type { Message, SessionInfo } from "../types.ts";
|
|
4
|
+
import { nativePeerId } from "../session-targets.ts";
|
|
5
|
+
import { writeMessage } from "./framing.ts";
|
|
6
|
+
|
|
7
|
+
type Retained = Array<{ readonly from: SessionInfo; readonly message: Message }>;
|
|
8
|
+
interface Parked {
|
|
9
|
+
readonly retained: Retained;
|
|
10
|
+
/** The fenced actor's socket closed: input waits for its restored replacement. */
|
|
11
|
+
closed: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface ParkablePeer {
|
|
14
|
+
readonly socket: net.Socket;
|
|
15
|
+
readonly info: SessionInfo;
|
|
16
|
+
/** This client can park itself, or wake and answer for its parked children. */
|
|
17
|
+
readonly parking: boolean;
|
|
18
|
+
parked?: Parked;
|
|
19
|
+
}
|
|
20
|
+
export function reachable<T extends ParkablePeer>(target?: T): target is T {
|
|
21
|
+
const socket = target?.socket;
|
|
22
|
+
return socket !== undefined && !socket.destroyed && socket.writable && !socket.writableEnded;
|
|
23
|
+
}
|
|
24
|
+
type Deliver = (id: string, from: SessionInfo, message: Message) => string | null;
|
|
25
|
+
function requested(id: unknown): string | undefined {
|
|
26
|
+
return typeof id === "string" && /^[a-zA-Z0-9_-]{8,80}$/.test(id) ? id : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A fenced child keeps its identity and queued input; it never departs while parked. */
|
|
30
|
+
export class ParkedSessions<T extends ParkablePeer> {
|
|
31
|
+
private readonly sessions: ReadonlyMap<string, T>;
|
|
32
|
+
private readonly deliver: Deliver;
|
|
33
|
+
constructor(sessions: ReadonlyMap<string, T>, deliver: Deliver) {
|
|
34
|
+
this.sessions = sessions;
|
|
35
|
+
this.deliver = deliver;
|
|
36
|
+
}
|
|
37
|
+
park(socket: net.Socket, id: string, on: boolean): void {
|
|
38
|
+
const session = this.sessions.get(id);
|
|
39
|
+
if (on && session?.parking === true) {
|
|
40
|
+
session.parked ??= { retained: [], closed: false };
|
|
41
|
+
// Frames already written to this socket precede the acknowledgement.
|
|
42
|
+
writeMessage(socket, { type: "parked" });
|
|
43
|
+
} else if (!on && session?.parked?.closed === false) {
|
|
44
|
+
const { retained } = session.parked;
|
|
45
|
+
session.parked = undefined;
|
|
46
|
+
this.flush(id, retained);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Delivers retained input in order. A transport that fails mid-flush keeps the rest for its
|
|
51
|
+
* next generation; delivery refusals on a live transport follow resident delivery.
|
|
52
|
+
*/
|
|
53
|
+
private flush(id: string, retained: Retained): void {
|
|
54
|
+
for (const [index, { from, message }] of retained.entries()) {
|
|
55
|
+
const session = this.sessions.get(id);
|
|
56
|
+
if (this.deliver(id, from, message) !== null && session?.socket.writable === false) {
|
|
57
|
+
session.parked = { retained: retained.slice(index), closed: false };
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** A free requested identity, or a parked one resumed by the same child lineage. */
|
|
63
|
+
claim(requestedId: unknown, next: Omit<SessionInfo, "id">): { id: string; resumed?: T } {
|
|
64
|
+
const id = requested(requestedId);
|
|
65
|
+
const previous = id === undefined ? undefined : this.sessions.get(id);
|
|
66
|
+
const resumes =
|
|
67
|
+
previous?.parked !== undefined &&
|
|
68
|
+
previous.info.rootSessionId === next.rootSessionId &&
|
|
69
|
+
previous.info.parentSessionId === next.parentSessionId;
|
|
70
|
+
if (id === undefined || (previous && !resumes)) {
|
|
71
|
+
return { id: randomUUID() };
|
|
72
|
+
}
|
|
73
|
+
previous?.socket.destroy();
|
|
74
|
+
return { id, resumed: previous };
|
|
75
|
+
}
|
|
76
|
+
/** Flushes a restored child's input and re-sends wakes owed to a (re)registered main. */
|
|
77
|
+
registered(id: string, previous?: T): void {
|
|
78
|
+
this.flush(id, previous?.parked?.retained ?? []);
|
|
79
|
+
for (const child of this.sessions.values()) {
|
|
80
|
+
if (nativePeerId(child.info.rootSessionId ?? "") === id) {
|
|
81
|
+
this.wake(child);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** A transport handoff, not a departure: no session_left and queued input stays. */
|
|
86
|
+
closed(session: T): boolean {
|
|
87
|
+
const { parked } = session;
|
|
88
|
+
if (parked) {
|
|
89
|
+
parked.closed = true;
|
|
90
|
+
this.wake(session);
|
|
91
|
+
}
|
|
92
|
+
return parked !== undefined;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Exited parked children keep identities while some live session remains. Input already
|
|
96
|
+
* admitted for one keeps the broker until a returning main wakes it.
|
|
97
|
+
*/
|
|
98
|
+
get exited(): boolean {
|
|
99
|
+
return [...this.sessions.values()].every(
|
|
100
|
+
({ parked }) => parked?.closed === true && parked.retained.length === 0,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
retain(target: T | undefined, from: SessionInfo, message: Message): boolean {
|
|
104
|
+
target?.parked?.retained.push({ from, message });
|
|
105
|
+
if (target) {
|
|
106
|
+
this.wake(target);
|
|
107
|
+
}
|
|
108
|
+
return target?.parked !== undefined;
|
|
109
|
+
}
|
|
110
|
+
/** Reads of an exited parked child go to its main, which answers from captured history. */
|
|
111
|
+
reader(id: string): T | undefined {
|
|
112
|
+
const child = this.sessions.get(id);
|
|
113
|
+
return child?.parked?.closed === true ? this.main(child) : undefined;
|
|
114
|
+
}
|
|
115
|
+
// Passive breadcrumbs and topic updates wait for a genuine wake; they never spawn the child.
|
|
116
|
+
private wake(child: T): void {
|
|
117
|
+
const main = this.main(child);
|
|
118
|
+
const wakes = child.parked?.retained.some(
|
|
119
|
+
({ message }) =>
|
|
120
|
+
message.passive !== true && message.delivery !== "passive" && message.topic === undefined,
|
|
121
|
+
);
|
|
122
|
+
if (main && child.parked?.closed === true && wakes === true) {
|
|
123
|
+
writeMessage(main.socket, { type: "wake", sessionId: child.info.id });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
private main(child: T): T | undefined {
|
|
127
|
+
const main = this.sessions.get(nativePeerId(child.info.rootSessionId ?? ""));
|
|
128
|
+
return main?.parking === true && main.parked === undefined ? main : undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -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,
|
|
@@ -12,7 +37,7 @@ type ClientMessageHandler = (
|
|
|
12
37
|
export function acceptBrokerConnection(
|
|
13
38
|
socket: net.Socket,
|
|
14
39
|
handleMessage: ClientMessageHandler,
|
|
15
|
-
disconnected: (sessionId: string) => void,
|
|
40
|
+
disconnected: (sessionId: string, socket: net.Socket) => void,
|
|
16
41
|
): void {
|
|
17
42
|
let sessionId: string | null = null;
|
|
18
43
|
socket.on(
|
|
@@ -29,7 +54,7 @@ export function acceptBrokerConnection(
|
|
|
29
54
|
);
|
|
30
55
|
socket.on("close", () => {
|
|
31
56
|
if (sessionId !== null) {
|
|
32
|
-
disconnected(sessionId);
|
|
57
|
+
disconnected(sessionId, socket);
|
|
33
58
|
}
|
|
34
59
|
});
|
|
35
60
|
socket.on("error", (error: Error) => {
|