@fitchmultz/pi-subagents 0.51.0 → 0.52.1
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 +18 -0
- package/dist/agent/child-runtime.js +80 -0
- package/dist/agent/contracts.js +9 -0
- package/dist/agent/index.js +6 -20
- package/dist/agent/observation.js +4 -0
- package/dist/agent/parking.js +253 -0
- package/dist/agent/process.js +31 -4
- package/dist/agent/runner.js +124 -126
- package/dist/agent/shell.js +2 -2
- package/dist/agent/storage.js +18 -0
- package/dist/build-info.js +1 -1
- package/dist/pi-intercom/broker/broker.js +42 -29
- package/dist/pi-intercom/broker/client-last.d.ts +12 -1
- package/dist/pi-intercom/broker/client-last.js +25 -0
- 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 +2 -3
- package/dist/pi-intercom/broker/client.js +23 -28
- package/dist/pi-intercom/broker/last.d.ts +5 -1
- package/dist/pi-intercom/broker/last.js +34 -12
- package/dist/pi-intercom/broker/parked.js +107 -0
- package/dist/pi-intercom/broker/server-connection.js +1 -1
- package/dist/pi-intercom/connection.js +65 -22
- package/dist/pi-intercom/inbound.js +11 -0
- package/dist/pi-intercom/index.js +38 -0
- package/dist/shared/parking.js +12 -0
- package/dist/shared/post-exit-stdio-guard.js +4 -2
- package/docs/code-quality.md +5 -5
- package/docs/development.md +3 -3
- package/docs/intercom.md +2 -1
- package/docs/performance.md +31 -3
- package/docs/reference.md +12 -1
- package/package.json +1 -1
- package/scripts/measure-size.mjs +1 -1
- package/src/agent/child-runtime.ts +105 -0
- package/src/agent/contracts.ts +10 -0
- package/src/agent/host.ts +22 -2
- package/src/agent/index.ts +10 -17
- package/src/agent/observation.ts +4 -0
- package/src/agent/parking.ts +341 -0
- package/src/agent/process.ts +41 -4
- package/src/agent/runner.ts +124 -144
- package/src/agent/shell.ts +6 -2
- package/src/agent/storage.ts +17 -0
- package/src/pi-intercom/broker/broker.ts +44 -37
- package/src/pi-intercom/broker/client-last.ts +41 -1
- package/src/pi-intercom/broker/client-park.ts +23 -0
- package/src/pi-intercom/broker/client.ts +24 -28
- package/src/pi-intercom/broker/last.ts +49 -14
- package/src/pi-intercom/broker/parked.ts +130 -0
- package/src/pi-intercom/broker/server-connection.ts +2 -2
- package/src/pi-intercom/connection.ts +87 -34
- package/src/pi-intercom/inbound.ts +14 -0
- package/src/pi-intercom/index.ts +41 -0
- package/src/pi-intercom/last-message.ts +1 -1
- package/src/shared/parking.ts +22 -0
- package/src/shared/post-exit-stdio-guard.ts +5 -2
|
@@ -3,8 +3,9 @@ import { randomUUID } from "crypto";
|
|
|
3
3
|
import { writeMessage } from "./framing.js";
|
|
4
4
|
import { BrokerConnection, connectBrokerSocket } from "./connection.js";
|
|
5
5
|
import { SnapshotReader } from "./snapshot-reader.js";
|
|
6
|
-
import { LastMessageRequests } from "./client-last.js";
|
|
7
|
-
import {
|
|
6
|
+
import { LastMessageRequests, lastRequest } from "./client-last.js";
|
|
7
|
+
import { ParkRequests } from "./client-park.js";
|
|
8
|
+
import { isNativeReadResult } from "./last.js";
|
|
8
9
|
import { errorMessage, isRecord, isUnknownArray, } from "../../shared/unknown.js";
|
|
9
10
|
import { compactTopicMessage, normalizeMessage, normalizeSessionInfo, } from "../types.js";
|
|
10
11
|
/** Default delivery-ack timeout for `send` (broker acknowledges quickly). */
|
|
@@ -30,8 +31,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
30
31
|
connection = null;
|
|
31
32
|
_sessionId = null;
|
|
32
33
|
_topicsSupported = false;
|
|
33
|
-
|
|
34
|
-
receiptReadsSupported = false;
|
|
34
|
+
parking = new ParkRequests();
|
|
35
35
|
guardedDeliverySupported = false;
|
|
36
36
|
lastRequests;
|
|
37
37
|
pendingSends = new Map();
|
|
@@ -104,8 +104,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
104
104
|
this.connection = null;
|
|
105
105
|
this._sessionId = null;
|
|
106
106
|
this._topicsSupported = false;
|
|
107
|
-
this.
|
|
108
|
-
this.receiptReadsSupported = false;
|
|
107
|
+
this.parking.settle(false);
|
|
109
108
|
this.guardedDeliverySupported = false;
|
|
110
109
|
this.disconnecting = false;
|
|
111
110
|
this.failPending(error);
|
|
@@ -123,6 +122,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
123
122
|
requestedId,
|
|
124
123
|
topicFrames: true,
|
|
125
124
|
lastMessages: true,
|
|
125
|
+
parking: true,
|
|
126
126
|
receiptReads: 1,
|
|
127
127
|
guardedDelivery: 1,
|
|
128
128
|
});
|
|
@@ -183,11 +183,15 @@ export class IntercomClient extends EventEmitter {
|
|
|
183
183
|
case "delivery_failed":
|
|
184
184
|
this.receiveDelivery(brokerMessage);
|
|
185
185
|
break;
|
|
186
|
+
case "parked":
|
|
187
|
+
this.parking.settle(true);
|
|
188
|
+
break;
|
|
189
|
+
case "wake":
|
|
186
190
|
case "session_left":
|
|
187
191
|
if (typeof brokerMessage.sessionId !== "string") {
|
|
188
|
-
throw new Error(
|
|
192
|
+
throw new Error(`Invalid ${type} message`);
|
|
189
193
|
}
|
|
190
|
-
this.emit(
|
|
194
|
+
this.emit(type, brokerMessage.sessionId);
|
|
191
195
|
break;
|
|
192
196
|
default:
|
|
193
197
|
throw new Error(`Unknown broker message type: ${type}`);
|
|
@@ -202,19 +206,17 @@ export class IntercomClient extends EventEmitter {
|
|
|
202
206
|
}
|
|
203
207
|
this._sessionId = msg.sessionId;
|
|
204
208
|
this._topicsSupported = msg.topicsSupported === true && msg.topicFrames === true;
|
|
205
|
-
this.
|
|
206
|
-
this.
|
|
209
|
+
this.lastRequests.lastMessages = msg.lastMessages === true;
|
|
210
|
+
this.parking.supported = msg.parking === true;
|
|
211
|
+
this.lastRequests.receiptReads = msg.receiptReads === 1;
|
|
207
212
|
this.guardedDeliverySupported = msg.guardedDelivery === 1;
|
|
208
213
|
this.connection?.confirmRegistration();
|
|
209
214
|
this.emit("_registered", { type: "registered", sessionId: msg.sessionId });
|
|
210
215
|
}
|
|
211
216
|
receiveLastRequest(msg) {
|
|
212
|
-
const
|
|
213
|
-
if (
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
if (!this.emit("last_request", { requestId: msg.requestId, from, query: msg.query })) {
|
|
217
|
-
this.respondLast(msg.requestId, { error: "Recipient has no last-message reader." });
|
|
217
|
+
const request = lastRequest(msg);
|
|
218
|
+
if (!this.emit("last_request", request)) {
|
|
219
|
+
this.respondLast(request.requestId, { error: "Recipient has no last-message reader." });
|
|
218
220
|
}
|
|
219
221
|
}
|
|
220
222
|
receiveMessage(msg) {
|
|
@@ -269,6 +271,9 @@ export class IntercomClient extends EventEmitter {
|
|
|
269
271
|
pending.resolve(pending.reader.finish());
|
|
270
272
|
}
|
|
271
273
|
}
|
|
274
|
+
park(on = true) {
|
|
275
|
+
return this.parking.request(() => this.requireActiveSocket(), on);
|
|
276
|
+
}
|
|
272
277
|
async disconnect() {
|
|
273
278
|
const connection = this.connection;
|
|
274
279
|
if (!connection) {
|
|
@@ -282,20 +287,10 @@ export class IntercomClient extends EventEmitter {
|
|
|
282
287
|
return this.requestSessions().then((snapshot) => [...snapshot.sessions]);
|
|
283
288
|
}
|
|
284
289
|
async last(to, query = {}) {
|
|
285
|
-
return await this.lastRequests.last(this.
|
|
290
|
+
return await this.lastRequests.last(this.requireActiveSocket(), to, query);
|
|
286
291
|
}
|
|
287
292
|
async receipt(to, query) {
|
|
288
|
-
return await this.lastRequests.receipt(this.
|
|
289
|
-
}
|
|
290
|
-
readSocket(query) {
|
|
291
|
-
const socket = this.requireActiveSocket();
|
|
292
|
-
if (!this.lastMessagesSupported) {
|
|
293
|
-
throw new Error("This broker does not support last-message reads; reload after it exits.");
|
|
294
|
-
}
|
|
295
|
-
if ("messageId" in query && !this.receiptReadsSupported) {
|
|
296
|
-
throw new Error("publication_unavailable");
|
|
297
|
-
}
|
|
298
|
-
return socket;
|
|
293
|
+
return await this.lastRequests.receipt(this.requireActiveSocket(), to, query);
|
|
299
294
|
}
|
|
300
295
|
respondLast(requestId, result) {
|
|
301
296
|
if (!isNativeReadResult(result)) {
|
|
@@ -12,13 +12,17 @@ interface LastPeer {
|
|
|
12
12
|
readonly info: SessionInfo;
|
|
13
13
|
readonly lastMessages: boolean;
|
|
14
14
|
readonly receiptReads: boolean;
|
|
15
|
+
readonly parked?: {
|
|
16
|
+
readonly closed: boolean;
|
|
17
|
+
};
|
|
15
18
|
}
|
|
16
19
|
/** Correlation belongs to the broker, not the requester or the recipient's model queue. */
|
|
17
20
|
export declare class LastMessageRelay {
|
|
18
21
|
private readonly pending;
|
|
19
22
|
private readonly sessions;
|
|
20
23
|
constructor(sessions: ReadonlyMap<string, LastPeer>);
|
|
21
|
-
|
|
24
|
+
/** `reader` names another peer that answers for an exited target from its captured history. */
|
|
25
|
+
request(fromId: string, input: UnknownRecord, reader: (id: string) => LastPeer | undefined): void;
|
|
22
26
|
respond(fromId: string, message: UnknownRecord): void;
|
|
23
27
|
disconnected(sessionId: string): void;
|
|
24
28
|
private finish;
|
|
@@ -63,13 +63,28 @@ export function isLastMessageResult(value) {
|
|
|
63
63
|
return (isRecord(value) &&
|
|
64
64
|
(typeof value.error === "string" || (pageIdentity(value) && pageOffsets(value))));
|
|
65
65
|
}
|
|
66
|
-
|
|
66
|
+
/** An exited parked child answers nothing itself; only its main holds the captured history. */
|
|
67
|
+
function exitedRefusal(target, query, via) {
|
|
68
|
+
if (target.parked?.closed !== true) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
// No loaded journal owner answers receipts, with or without its main.
|
|
72
|
+
if ("messageId" in query) {
|
|
73
|
+
return "publication_unavailable";
|
|
74
|
+
}
|
|
75
|
+
return via === undefined ? "Parked session history is unavailable." : undefined;
|
|
76
|
+
}
|
|
77
|
+
function unavailable(targets, name, pending, { query, via }) {
|
|
67
78
|
if (targets.length === 0) {
|
|
68
79
|
return "Session not found";
|
|
69
80
|
}
|
|
70
81
|
if (targets.length > 1) {
|
|
71
82
|
return `Multiple sessions named "${name}" are connected. Use the session ID instead.`;
|
|
72
83
|
}
|
|
84
|
+
const exited = exitedRefusal(targets[0], query, via);
|
|
85
|
+
if (exited !== undefined) {
|
|
86
|
+
return exited;
|
|
87
|
+
}
|
|
73
88
|
if (!targets[0].lastMessages) {
|
|
74
89
|
return "Recipient does not support last-message reads; reload its extension.";
|
|
75
90
|
}
|
|
@@ -102,6 +117,13 @@ function matchesLastPage(result, query, fromId) {
|
|
|
102
117
|
(query.entryId === undefined || result.entryId === query.entryId) &&
|
|
103
118
|
result.text.length <= (query.limit ?? LAST_MESSAGE_PAGE_LIMIT));
|
|
104
119
|
}
|
|
120
|
+
function lastRequest(message) {
|
|
121
|
+
const { requestId, to, query } = message;
|
|
122
|
+
if (typeof requestId !== "string" || typeof to !== "string" || !isNativeReadQuery(query)) {
|
|
123
|
+
throw new Error("Invalid last message request");
|
|
124
|
+
}
|
|
125
|
+
return { requestId, to, query };
|
|
126
|
+
}
|
|
105
127
|
/** Correlation belongs to the broker, not the requester or the recipient's model queue. */
|
|
106
128
|
export class LastMessageRelay {
|
|
107
129
|
pending = new Map();
|
|
@@ -109,12 +131,9 @@ export class LastMessageRelay {
|
|
|
109
131
|
constructor(sessions) {
|
|
110
132
|
this.sessions = sessions;
|
|
111
133
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
!isNativeReadQuery(message.query)) {
|
|
116
|
-
throw new Error("Invalid last message request");
|
|
117
|
-
}
|
|
134
|
+
/** `reader` names another peer that answers for an exited target from its captured history. */
|
|
135
|
+
request(fromId, input, reader) {
|
|
136
|
+
const message = lastRequest(input);
|
|
118
137
|
const targets = findSessions(this.sessions, message.to, fromId);
|
|
119
138
|
const target = targets[0];
|
|
120
139
|
const from = this.sessions.get(fromId);
|
|
@@ -122,13 +141,15 @@ export class LastMessageRelay {
|
|
|
122
141
|
return;
|
|
123
142
|
}
|
|
124
143
|
const token = randomUUID();
|
|
144
|
+
const via = targets.length === 1 ? reader(target.info.id) : undefined;
|
|
125
145
|
const frame = {
|
|
126
146
|
type: "last_request",
|
|
127
147
|
requestId: token,
|
|
128
148
|
from: messageSender(from.info),
|
|
129
149
|
query: message.query,
|
|
150
|
+
parked: via && messageSender(target.info),
|
|
130
151
|
};
|
|
131
|
-
const error = unavailable(targets, message.to, this.pending.size, message.query) ??
|
|
152
|
+
const error = unavailable(targets, message.to, this.pending.size, { query: message.query, via }) ??
|
|
132
153
|
validateIntercomMessageSize(frame)?.message;
|
|
133
154
|
if (error !== undefined) {
|
|
134
155
|
writeMessage(from.socket, {
|
|
@@ -143,12 +164,13 @@ export class LastMessageRelay {
|
|
|
143
164
|
this.pending.set(token, {
|
|
144
165
|
fromId,
|
|
145
166
|
toId: target.info.id,
|
|
167
|
+
via: via?.info.id,
|
|
146
168
|
requestId: message.requestId,
|
|
147
169
|
query: message.query,
|
|
148
170
|
timer,
|
|
149
171
|
});
|
|
150
172
|
try {
|
|
151
|
-
writeMessage(target.socket, frame);
|
|
173
|
+
writeMessage((via ?? target).socket, frame);
|
|
152
174
|
}
|
|
153
175
|
catch {
|
|
154
176
|
this.finish(token, { error: "Recipient disconnected before last-message read" });
|
|
@@ -159,17 +181,17 @@ export class LastMessageRelay {
|
|
|
159
181
|
throw new Error("Invalid last message response");
|
|
160
182
|
}
|
|
161
183
|
const pending = this.pending.get(message.requestId);
|
|
162
|
-
if (!pending || pending.toId !== fromId) {
|
|
184
|
+
if (!pending || (pending.via ?? pending.toId) !== fromId) {
|
|
163
185
|
return;
|
|
164
186
|
}
|
|
165
187
|
const result = message.result;
|
|
166
|
-
this.finish(message.requestId, isNativeReadResult(result) && matchesPage(result, pending,
|
|
188
|
+
this.finish(message.requestId, isNativeReadResult(result) && matchesPage(result, pending, pending.toId)
|
|
167
189
|
? result
|
|
168
190
|
: { error: "Recipient returned an invalid last-message page" });
|
|
169
191
|
}
|
|
170
192
|
disconnected(sessionId) {
|
|
171
193
|
for (const [token, pending] of this.pending) {
|
|
172
|
-
if (pending.fromId
|
|
194
|
+
if ([pending.fromId, pending.toId, pending.via].includes(sessionId)) {
|
|
173
195
|
this.finish(token, { error: "Peer disconnected during last-message read" });
|
|
174
196
|
}
|
|
175
197
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { nativePeerId } from "../session-targets.js";
|
|
3
|
+
import { writeMessage } from "./framing.js";
|
|
4
|
+
export function reachable(target) {
|
|
5
|
+
const socket = target?.socket;
|
|
6
|
+
return socket !== undefined && !socket.destroyed && socket.writable && !socket.writableEnded;
|
|
7
|
+
}
|
|
8
|
+
function requested(id) {
|
|
9
|
+
return typeof id === "string" && /^[a-zA-Z0-9_-]{8,80}$/.test(id) ? id : undefined;
|
|
10
|
+
}
|
|
11
|
+
/** A fenced child keeps its identity and queued input; it never departs while parked. */
|
|
12
|
+
export class ParkedSessions {
|
|
13
|
+
sessions;
|
|
14
|
+
deliver;
|
|
15
|
+
constructor(sessions, deliver) {
|
|
16
|
+
this.sessions = sessions;
|
|
17
|
+
this.deliver = deliver;
|
|
18
|
+
}
|
|
19
|
+
park(socket, id, on) {
|
|
20
|
+
const session = this.sessions.get(id);
|
|
21
|
+
if (on && session?.parking === true) {
|
|
22
|
+
session.parked ??= { retained: [], closed: false };
|
|
23
|
+
// Frames already written to this socket precede the acknowledgement.
|
|
24
|
+
writeMessage(socket, { type: "parked" });
|
|
25
|
+
}
|
|
26
|
+
else if (!on && session?.parked?.closed === false) {
|
|
27
|
+
const { retained } = session.parked;
|
|
28
|
+
session.parked = undefined;
|
|
29
|
+
this.flush(id, retained);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Delivers retained input in order. A transport that fails mid-flush keeps the rest for its
|
|
34
|
+
* next generation; delivery refusals on a live transport follow resident delivery.
|
|
35
|
+
*/
|
|
36
|
+
flush(id, retained) {
|
|
37
|
+
for (const [index, { from, message }] of retained.entries()) {
|
|
38
|
+
const session = this.sessions.get(id);
|
|
39
|
+
if (this.deliver(id, from, message) !== null && session?.socket.writable === false) {
|
|
40
|
+
session.parked = { retained: retained.slice(index), closed: false };
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** A free requested identity, or a parked one resumed by the same child lineage. */
|
|
46
|
+
claim(requestedId, next) {
|
|
47
|
+
const id = requested(requestedId);
|
|
48
|
+
const previous = id === undefined ? undefined : this.sessions.get(id);
|
|
49
|
+
const resumes = previous?.parked !== undefined &&
|
|
50
|
+
previous.info.rootSessionId === next.rootSessionId &&
|
|
51
|
+
previous.info.parentSessionId === next.parentSessionId;
|
|
52
|
+
if (id === undefined || (previous && !resumes)) {
|
|
53
|
+
return { id: randomUUID() };
|
|
54
|
+
}
|
|
55
|
+
previous?.socket.destroy();
|
|
56
|
+
return { id, resumed: previous };
|
|
57
|
+
}
|
|
58
|
+
/** Flushes a restored child's input and re-sends wakes owed to a (re)registered main. */
|
|
59
|
+
registered(id, previous) {
|
|
60
|
+
this.flush(id, previous?.parked?.retained ?? []);
|
|
61
|
+
for (const child of this.sessions.values()) {
|
|
62
|
+
if (nativePeerId(child.info.rootSessionId ?? "") === id) {
|
|
63
|
+
this.wake(child);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** A transport handoff, not a departure: no session_left and queued input stays. */
|
|
68
|
+
closed(session) {
|
|
69
|
+
const { parked } = session;
|
|
70
|
+
if (parked) {
|
|
71
|
+
parked.closed = true;
|
|
72
|
+
this.wake(session);
|
|
73
|
+
}
|
|
74
|
+
return parked !== undefined;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Exited parked children keep identities while some live session remains. Input already
|
|
78
|
+
* admitted for one keeps the broker until a returning main wakes it.
|
|
79
|
+
*/
|
|
80
|
+
get exited() {
|
|
81
|
+
return [...this.sessions.values()].every(({ parked }) => parked?.closed === true && parked.retained.length === 0);
|
|
82
|
+
}
|
|
83
|
+
retain(target, from, message) {
|
|
84
|
+
target?.parked?.retained.push({ from, message });
|
|
85
|
+
if (target) {
|
|
86
|
+
this.wake(target);
|
|
87
|
+
}
|
|
88
|
+
return target?.parked !== undefined;
|
|
89
|
+
}
|
|
90
|
+
/** Reads of an exited parked child go to its main, which answers from captured history. */
|
|
91
|
+
reader(id) {
|
|
92
|
+
const child = this.sessions.get(id);
|
|
93
|
+
return child?.parked?.closed === true ? this.main(child) : undefined;
|
|
94
|
+
}
|
|
95
|
+
// Passive breadcrumbs and topic updates wait for a genuine wake; they never spawn the child.
|
|
96
|
+
wake(child) {
|
|
97
|
+
const main = this.main(child);
|
|
98
|
+
const wakes = child.parked?.retained.some(({ message }) => message.passive !== true && message.delivery !== "passive" && message.topic === undefined);
|
|
99
|
+
if (main && child.parked?.closed === true && wakes === true) {
|
|
100
|
+
writeMessage(main.socket, { type: "wake", sessionId: child.info.id });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
main(child) {
|
|
104
|
+
const main = this.sessions.get(nativePeerId(child.info.rootSessionId ?? ""));
|
|
105
|
+
return main?.parking === true && main.parked === undefined ? main : undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -7,6 +7,8 @@ import { formatTargetOptions, resolveSessionTarget, nativePeerId } from "./sessi
|
|
|
7
7
|
/** Owns socket registration, coalesced connects and the reconnect/startup timers. */
|
|
8
8
|
export class IntercomConnection {
|
|
9
9
|
client = null;
|
|
10
|
+
/** The transport whose broker retains this actor's input while it parks. */
|
|
11
|
+
parked = null;
|
|
10
12
|
reconnectTimer = null;
|
|
11
13
|
startupTimer = null;
|
|
12
14
|
connecting = null;
|
|
@@ -23,6 +25,22 @@ export class IntercomConnection {
|
|
|
23
25
|
get active() {
|
|
24
26
|
return this.client;
|
|
25
27
|
}
|
|
28
|
+
async park() {
|
|
29
|
+
const client = this.client;
|
|
30
|
+
this.parked = client !== null && (await client.park().catch(() => false)) ? client : null;
|
|
31
|
+
return this.parked !== null && this.parked === this.client;
|
|
32
|
+
}
|
|
33
|
+
get parking() {
|
|
34
|
+
return this.parked !== null;
|
|
35
|
+
}
|
|
36
|
+
get reconnected() {
|
|
37
|
+
return this.client !== null && this.client !== this.parked;
|
|
38
|
+
}
|
|
39
|
+
unpark() {
|
|
40
|
+
const client = this.parked === this.client ? this.client : null;
|
|
41
|
+
this.parked = null;
|
|
42
|
+
return client?.park(false).catch(() => false) ?? Promise.resolve(false);
|
|
43
|
+
}
|
|
26
44
|
start(ctx, generation) {
|
|
27
45
|
this.attempt = 0;
|
|
28
46
|
this.clearTimers();
|
|
@@ -98,6 +116,47 @@ export class IntercomConnection {
|
|
|
98
116
|
});
|
|
99
117
|
}
|
|
100
118
|
}
|
|
119
|
+
answerLast(next, request) {
|
|
120
|
+
const ctx = this.lifecycle.live();
|
|
121
|
+
const id = next.sessionId;
|
|
122
|
+
if (this.client !== next || !ctx || id === null) {
|
|
123
|
+
next.respondLast(request.requestId, { error: "Intercom runtime no longer active" });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const { parked, query } = request;
|
|
127
|
+
if ("messageId" in query) {
|
|
128
|
+
// The broker refuses receipt reads of an exited parked child before relaying them.
|
|
129
|
+
next.respondLast(request.requestId, parked ? { error: "publication_unavailable" } : this.hooks.receipt(query));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (parked) {
|
|
133
|
+
this.hooks
|
|
134
|
+
.parkedHistory(parked)
|
|
135
|
+
.then((history) => history
|
|
136
|
+
? readLastMessage(history, parked, query)
|
|
137
|
+
: { error: "Parked session history is unavailable." })
|
|
138
|
+
.catch((error) => ({ error: asError(error).message }))
|
|
139
|
+
.then((result) => {
|
|
140
|
+
next.respondLast(request.requestId, result);
|
|
141
|
+
})
|
|
142
|
+
.catch(() => {
|
|
143
|
+
/* The requester's broker timeout reports a lost transport. */
|
|
144
|
+
});
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
next.respondLast(request.requestId, readLastMessage(ctx.sessionManager, {
|
|
149
|
+
id,
|
|
150
|
+
...this.lifecycle.identity(ctx.sessionManager.getSessionId()),
|
|
151
|
+
cwd: ctx.cwd,
|
|
152
|
+
model: ctx.model?.id ?? "unknown",
|
|
153
|
+
status: this.lifecycle.status(),
|
|
154
|
+
}, query));
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
next.respondLast(request.requestId, { error: asError(error).message });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
101
160
|
createClient() {
|
|
102
161
|
const next = new IntercomClient({
|
|
103
162
|
sendTimeoutMs: this.config.sendTimeoutMs,
|
|
@@ -105,28 +164,7 @@ export class IntercomConnection {
|
|
|
105
164
|
});
|
|
106
165
|
this.client = next;
|
|
107
166
|
next.on("last_request", (request) => {
|
|
108
|
-
|
|
109
|
-
const id = next.sessionId;
|
|
110
|
-
if (this.client !== next || !ctx || id === null) {
|
|
111
|
-
next.respondLast(request.requestId, { error: "Intercom runtime no longer active" });
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
try {
|
|
115
|
-
if ("messageId" in request.query) {
|
|
116
|
-
next.respondLast(request.requestId, this.hooks.receipt(request.query));
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
next.respondLast(request.requestId, readLastMessage(ctx.sessionManager, {
|
|
120
|
-
id,
|
|
121
|
-
...this.lifecycle.identity(ctx.sessionManager.getSessionId()),
|
|
122
|
-
cwd: ctx.cwd,
|
|
123
|
-
model: ctx.model?.id ?? "unknown",
|
|
124
|
-
status: this.lifecycle.status(),
|
|
125
|
-
}, request.query));
|
|
126
|
-
}
|
|
127
|
-
catch (error) {
|
|
128
|
-
next.respondLast(request.requestId, { error: asError(error).message });
|
|
129
|
-
}
|
|
167
|
+
this.answerLast(next, request);
|
|
130
168
|
});
|
|
131
169
|
next.on("message", (from, message) => {
|
|
132
170
|
const ctx = this.lifecycle.live();
|
|
@@ -137,6 +175,11 @@ export class IntercomConnection {
|
|
|
137
175
|
console.error("Intercom inbound delivery failed:", error);
|
|
138
176
|
});
|
|
139
177
|
});
|
|
178
|
+
next.on("wake", (peer) => {
|
|
179
|
+
if (this.client === next) {
|
|
180
|
+
this.hooks.wake(peer);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
140
183
|
next.on("session_left", (id) => {
|
|
141
184
|
if (this.client !== next) {
|
|
142
185
|
return;
|
|
@@ -70,7 +70,18 @@ export class IntercomInbound {
|
|
|
70
70
|
}
|
|
71
71
|
this.owners.delivery.queue(entry, "passive");
|
|
72
72
|
}
|
|
73
|
+
/** Counted synchronously so a parking fence observes frames received before its ack. */
|
|
74
|
+
receiving = 0;
|
|
73
75
|
async receive(ctx, from, input) {
|
|
76
|
+
this.receiving++;
|
|
77
|
+
try {
|
|
78
|
+
await this.receiveCounted(ctx, from, input);
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
this.receiving--;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async receiveCounted(ctx, from, input) {
|
|
74
85
|
const generation = this.lifecycle.generation;
|
|
75
86
|
const restore = this.owners.delivery.restoring;
|
|
76
87
|
if (restore) {
|
|
@@ -12,6 +12,18 @@ import { IntercomInspection } from "./inspection.js";
|
|
|
12
12
|
import { IntercomTools } from "./tools.js";
|
|
13
13
|
import { IntercomSessionEvents } from "./session-events.js";
|
|
14
14
|
import { registerIntercomTools } from "./tool-registration.js";
|
|
15
|
+
import { isParkRequest, PARK, PARKED_HISTORY, WAKE } from "../shared/parking.js";
|
|
16
|
+
/** The agent runtime answers for its exited parked child from the captured native session. */
|
|
17
|
+
function parkedHistory(pi, peer) {
|
|
18
|
+
let history;
|
|
19
|
+
pi.events.emit(PARKED_HISTORY, {
|
|
20
|
+
peer,
|
|
21
|
+
reply: (found) => {
|
|
22
|
+
history = found;
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
return history ?? Promise.reject(new Error("No agent runtime owns parked history."));
|
|
26
|
+
}
|
|
15
27
|
export default function piIntercomExtension(pi) {
|
|
16
28
|
const config = loadConfig();
|
|
17
29
|
const lifecycle = new IntercomLifecycle(pi, config, readIntercomLineage());
|
|
@@ -30,6 +42,10 @@ export default function piIntercomExtension(pi) {
|
|
|
30
42
|
disconnected: (error) => {
|
|
31
43
|
wait.reject(new Error(`Disconnected while waiting for reply: ${error.message}`, { cause: error }));
|
|
32
44
|
},
|
|
45
|
+
parkedHistory: (peer) => parkedHistory(pi, peer.id),
|
|
46
|
+
wake: (peer) => {
|
|
47
|
+
pi.events.emit(WAKE, peer);
|
|
48
|
+
},
|
|
33
49
|
});
|
|
34
50
|
const delivery = new InboundDeliveryOwner(pi, lifecycle, journal, {
|
|
35
51
|
replies,
|
|
@@ -55,4 +71,26 @@ export default function piIntercomExtension(pi) {
|
|
|
55
71
|
wait,
|
|
56
72
|
}).register();
|
|
57
73
|
registerIntercomTools(pi, tools);
|
|
74
|
+
pi.events.on(PARK, (request) => {
|
|
75
|
+
if (isParkRequest(request)) {
|
|
76
|
+
request.reply(request.park ? connection.park() : connection.unpark());
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
// Native fork sleep readiness; it vetoes only this package's own parking, never other saves.
|
|
80
|
+
Reflect.apply(pi.on.bind(pi), undefined, [
|
|
81
|
+
"working_session_save",
|
|
82
|
+
() => !connection.parking
|
|
83
|
+
? undefined
|
|
84
|
+
: {
|
|
85
|
+
blockers: [
|
|
86
|
+
inbound.receiving > 0 && "intercom is receiving a message",
|
|
87
|
+
journal.size > 0 && "intercom inbound messages await delivery",
|
|
88
|
+
replies.listPending().length > 0 && "intercom asks await replies",
|
|
89
|
+
wait.waiting && "intercom is waiting for a reply",
|
|
90
|
+
(delivery.restoring !== undefined || delivery.batching) &&
|
|
91
|
+
"intercom delivery is restoring",
|
|
92
|
+
connection.reconnected && "intercom reconnected while parking",
|
|
93
|
+
].filter((blocker) => typeof blocker === "string"),
|
|
94
|
+
},
|
|
95
|
+
]);
|
|
58
96
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { isRecord } from "./unknown.js";
|
|
2
|
+
/** A child actor asks its intercom to fence broker input before parking, or to release it. */
|
|
3
|
+
export const PARK = "pi-subagents:park";
|
|
4
|
+
/** A main intercom wakes or reads an exited parked child through the agent runtime. */
|
|
5
|
+
export const WAKE = "pi-subagents:wake";
|
|
6
|
+
export const PARKED_HISTORY = "pi-subagents:parked-history";
|
|
7
|
+
export function isParkRequest(value) {
|
|
8
|
+
return isRecord(value) && typeof value.park === "boolean" && typeof value.reply === "function";
|
|
9
|
+
}
|
|
10
|
+
export function isHistoryRequest(value) {
|
|
11
|
+
return isRecord(value) && typeof value.peer === "string" && typeof value.reply === "function";
|
|
12
|
+
}
|
|
@@ -3,6 +3,8 @@ import { readFileSync, readlinkSync } from "node:fs";
|
|
|
3
3
|
import { isLinuxProcessReplaced, readLinuxProcess } from "../pi-intercom/broker/pid.js";
|
|
4
4
|
import { hasErrorCode } from "./unknown.js";
|
|
5
5
|
const BIRTH_FIELDS = ["lstart=", "pgid="];
|
|
6
|
+
// macOS ships /bin/ps; resolving "ps" through PATH can spawn once per missing entry.
|
|
7
|
+
export const PS_COMMAND = process.platform === "darwin" ? "/bin/ps" : "ps";
|
|
6
8
|
/** Capture only a real, cross-process clock context; unavailable context proves nothing. */
|
|
7
9
|
export function readProcessClock() {
|
|
8
10
|
if (process.platform !== "android") {
|
|
@@ -45,7 +47,7 @@ function psOptions(zone) {
|
|
|
45
47
|
}
|
|
46
48
|
function readPsFields(pid, fields, zone) {
|
|
47
49
|
try {
|
|
48
|
-
return execFileSync(
|
|
50
|
+
return execFileSync(PS_COMMAND, psArguments(pid, fields), {
|
|
49
51
|
...psOptions(zone),
|
|
50
52
|
stdio: ["ignore", "pipe", "ignore"],
|
|
51
53
|
})
|
|
@@ -59,7 +61,7 @@ function readPsFields(pid, fields, zone) {
|
|
|
59
61
|
/** Nonblocking `readPsFields`; a failed run likewise observes nothing. */
|
|
60
62
|
function observePsFields(pid, fields, zone) {
|
|
61
63
|
return new Promise((resolve) => {
|
|
62
|
-
execFile(
|
|
64
|
+
execFile(PS_COMMAND, psArguments(pid, fields), psOptions(zone), (error, stdout) => {
|
|
63
65
|
resolve(error ? undefined : stdout.trim().replace(/\s+/g, " "));
|
|
64
66
|
});
|
|
65
67
|
});
|
package/docs/code-quality.md
CHANGED
|
@@ -34,8 +34,8 @@ tests. CI separately qualifies the latest official Pi on GitHub-hosted Linux and
|
|
|
34
34
|
the maintained fork on Linux and macOS. There is no custom checker build, probe
|
|
35
35
|
suite or self-hosted runner requirement.
|
|
36
36
|
|
|
37
|
-
CI's lint job also enforces the production
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
CI's lint job also enforces the production byte ceiling before qualification
|
|
38
|
+
substitutes the selected host graph. The retired workflow schema generator and
|
|
39
|
+
its patching dependency are not part of the new runtime. `npm run check:size`
|
|
40
|
+
still reports source and production line counts for visibility; no line count
|
|
41
|
+
is mandated. The 1 MiB byte ceiling and all lint limits stay unchanged.
|
package/docs/development.md
CHANGED
|
@@ -91,9 +91,9 @@ negative assertions. A sleep does not prove an owner processed its input.
|
|
|
91
91
|
|
|
92
92
|
`scripts/measure-size.mjs` counts source, production scripts, generated code and
|
|
93
93
|
schemas, including new untracked files. `--report` prints without enforcing.
|
|
94
|
-
The
|
|
95
|
-
|
|
96
|
-
into generated/minified output does not evade the gate.
|
|
94
|
+
The only enforced ceiling is 1 MiB total production bytes; source and production
|
|
95
|
+
line counts are reported for visibility, not mandated. Tests have no deletion
|
|
96
|
+
quota. Moving machinery into generated/minified output does not evade the gate.
|
|
97
97
|
|
|
98
98
|
[The rebuild evidence](single-agent-rebuild.md) records the baseline, confirmed
|
|
99
99
|
design, test-transfer obligations, and exact selected host identities. Update it
|
package/docs/intercom.md
CHANGED
|
@@ -342,7 +342,8 @@ the same runtime-root policy.
|
|
|
342
342
|
|
|
343
343
|
The runtime directory is owned by the OS user, nonsymlink, and mode 0700; the
|
|
344
344
|
socket is mode 0600. The broker starts on first use and exits after five seconds
|
|
345
|
-
without connected sessions
|
|
345
|
+
without connected sessions, unless a parked child still holds admitted input; that
|
|
346
|
+
input waits for its main to return and wake the child. Concurrent startup uses the shared spawn lock.
|
|
346
347
|
|
|
347
348
|
`PI_INTERCOM_RUNTIME_DIR` explicitly selects an isolated deployment. It must be
|
|
348
349
|
an absolute private directory with a socket path within 100 UTF-8 bytes. Invalid
|