@fitchmultz/pi-subagents 0.52.19 → 0.52.21
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 +44 -0
- package/dist/agent/contracts.js +12 -3
- package/dist/agent/index.js +9 -9
- package/dist/agent/legacy.js +5 -5
- package/dist/agent/notifications.js +21 -14
- package/dist/agent/parent-check.js +38 -0
- package/dist/agent/process.js +45 -52
- package/dist/agent/runner.js +59 -64
- package/dist/agent/storage.js +16 -4
- package/dist/agent/view.js +1 -3
- package/dist/build-info.js +1 -1
- package/dist/pi-intercom/bridge-presence.js +30 -18
- package/dist/pi-intercom/bridge-wake.js +60 -37
- package/dist/pi-intercom/broker/broker.js +4 -26
- package/dist/pi-intercom/broker/client-last.js +6 -18
- package/dist/pi-intercom/broker/client-park.d.ts +1 -1
- package/dist/pi-intercom/broker/client-park.js +22 -6
- package/dist/pi-intercom/broker/client.d.ts +3 -2
- package/dist/pi-intercom/broker/client.js +104 -112
- package/dist/pi-intercom/broker/connection.d.ts +3 -5
- package/dist/pi-intercom/broker/connection.js +48 -33
- package/dist/pi-intercom/broker/framing.d.ts +1 -1
- package/dist/pi-intercom/broker/framing.js +19 -12
- package/dist/pi-intercom/broker/pending-requests.d.ts +12 -0
- package/dist/pi-intercom/broker/pending-requests.js +26 -0
- package/dist/pi-intercom/broker/replace-deliveries.js +38 -0
- package/dist/pi-intercom/broker/snapshot-reader.d.ts +3 -2
- package/dist/pi-intercom/broker/snapshot-reader.js +34 -15
- package/dist/pi-intercom/connection.js +5 -8
- package/dist/pi-intercom/inbound-journal.js +5 -2
- package/dist/shared/journal-frames.js +19 -24
- package/docs/development.md +6 -0
- package/package.json +1 -1
- package/src/agent/contracts.ts +12 -3
- package/src/agent/index.ts +10 -10
- package/src/agent/legacy.ts +5 -5
- package/src/agent/notifications.ts +35 -15
- package/src/agent/parent-check.ts +40 -0
- package/src/agent/process.ts +53 -55
- package/src/agent/runner.ts +65 -65
- package/src/agent/storage.ts +18 -5
- package/src/agent/view.ts +1 -4
- package/src/pi-intercom/bridge-presence.ts +37 -18
- package/src/pi-intercom/bridge-wake.ts +70 -38
- package/src/pi-intercom/broker/broker.ts +5 -37
- package/src/pi-intercom/broker/client-last.ts +14 -25
- package/src/pi-intercom/broker/client-park.ts +27 -6
- package/src/pi-intercom/broker/client.ts +127 -126
- package/src/pi-intercom/broker/connection.ts +58 -39
- package/src/pi-intercom/broker/framing.ts +24 -12
- package/src/pi-intercom/broker/pending-requests.ts +37 -0
- package/src/pi-intercom/broker/replace-deliveries.ts +56 -0
- package/src/pi-intercom/broker/snapshot-reader.ts +44 -16
- package/src/pi-intercom/connection.ts +9 -9
- package/src/pi-intercom/inbound-journal.ts +5 -2
- package/src/shared/journal-frames.ts +22 -23
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
interface Request {
|
|
2
|
+
readonly reject: (error: Readonly<Error>) => void;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/** Map membership owns settlement; taking a request also releases its deadline. */
|
|
6
|
+
export class PendingRequests<T extends Request> {
|
|
7
|
+
private readonly pending = new Map<
|
|
8
|
+
string,
|
|
9
|
+
{ readonly value: T; readonly timer: NodeJS.Timeout }
|
|
10
|
+
>();
|
|
11
|
+
|
|
12
|
+
get(id: string): T | undefined {
|
|
13
|
+
return this.pending.get(id)?.value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
set(id: string, value: T, timeoutMs: number, timeoutError: () => Error): void {
|
|
17
|
+
const timer = setTimeout(() => this.take(id)?.reject(timeoutError()), timeoutMs);
|
|
18
|
+
timer.unref();
|
|
19
|
+
this.pending.set(id, { value, timer });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
take(id: string): T | undefined {
|
|
23
|
+
const pending = this.pending.get(id);
|
|
24
|
+
if (pending === undefined) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
this.pending.delete(id);
|
|
28
|
+
clearTimeout(pending.timer);
|
|
29
|
+
return pending.value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
fail(error: Readonly<Error>): void {
|
|
33
|
+
for (const id of this.pending.keys()) {
|
|
34
|
+
this.take(id)?.reject(error);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Message, SessionInfo } from "../types.ts";
|
|
2
|
+
|
|
3
|
+
const REPLACE_DELIVERY_DELAY_MS = 1500;
|
|
4
|
+
|
|
5
|
+
interface StagedDelivery {
|
|
6
|
+
readonly fromId: string;
|
|
7
|
+
readonly toId: string;
|
|
8
|
+
readonly from: SessionInfo;
|
|
9
|
+
readonly message: Message;
|
|
10
|
+
readonly timer: NodeJS.Timeout;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Only broker-staged messages remain replaceable; forwarding ends this owner's authority. */
|
|
14
|
+
export class ReplaceDeliveries {
|
|
15
|
+
private readonly pending = new Map<string, StagedDelivery>();
|
|
16
|
+
private readonly deliver: (toId: string, from: SessionInfo, message: Message) => string | null;
|
|
17
|
+
|
|
18
|
+
constructor(deliver: (toId: string, from: SessionInfo, message: Message) => string | null) {
|
|
19
|
+
this.deliver = deliver;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
stage(fromId: string, toId: string, from: SessionInfo, message: Message): void {
|
|
23
|
+
const key = `${fromId}\0${toId}\0${message.threadId ?? ""}`;
|
|
24
|
+
this.take(key);
|
|
25
|
+
const timer = setTimeout(() => this.flush(key), REPLACE_DELIVERY_DELAY_MS);
|
|
26
|
+
timer.unref();
|
|
27
|
+
this.pending.set(key, { fromId, toId, from, message, timer });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private take(key: string): StagedDelivery | undefined {
|
|
31
|
+
const pending = this.pending.get(key);
|
|
32
|
+
if (pending !== undefined) {
|
|
33
|
+
this.pending.delete(key);
|
|
34
|
+
clearTimeout(pending.timer);
|
|
35
|
+
}
|
|
36
|
+
return pending;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private flush(key: string): void {
|
|
40
|
+
const pending = this.take(key);
|
|
41
|
+
if (pending !== undefined) {
|
|
42
|
+
this.deliver(pending.toId, pending.from, pending.message);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
disconnected(sessionId: string): void {
|
|
47
|
+
for (const [key, pending] of this.pending) {
|
|
48
|
+
if (
|
|
49
|
+
pending.toId === sessionId ||
|
|
50
|
+
(pending.fromId === sessionId && pending.message.expectsReply === true)
|
|
51
|
+
) {
|
|
52
|
+
this.take(key);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -58,6 +58,14 @@ interface SessionParts {
|
|
|
58
58
|
readonly subscriptions: TopicSubscription[] | undefined;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
type SnapshotPhase =
|
|
62
|
+
| {
|
|
63
|
+
readonly kind: "open";
|
|
64
|
+
readonly sessions: Map<string, SessionParts>;
|
|
65
|
+
readonly receipts: Receipt[];
|
|
66
|
+
}
|
|
67
|
+
| { readonly kind: "finished" | "failed" };
|
|
68
|
+
|
|
61
69
|
function appendRows<T>(
|
|
62
70
|
previous: T[] | undefined,
|
|
63
71
|
rows: readonly T[] | undefined,
|
|
@@ -75,16 +83,28 @@ function appendRows<T>(
|
|
|
75
83
|
|
|
76
84
|
/** Multipart frames build a private cache; only a complete validated snapshot escapes. */
|
|
77
85
|
export class SnapshotReader {
|
|
78
|
-
private
|
|
79
|
-
|
|
86
|
+
private phase: SnapshotPhase = { kind: "open", sessions: new Map(), receipts: [] };
|
|
87
|
+
|
|
88
|
+
private open(): Extract<SnapshotPhase, { readonly kind: "open" }> {
|
|
89
|
+
if (this.phase.kind !== "open") {
|
|
90
|
+
throw new Error(`Session snapshot is ${this.phase.kind}`);
|
|
91
|
+
}
|
|
92
|
+
return this.phase;
|
|
93
|
+
}
|
|
80
94
|
|
|
81
|
-
|
|
95
|
+
fail(): void {
|
|
96
|
+
if (this.phase.kind === "open") {
|
|
97
|
+
this.phase = { kind: "failed" };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private addSession(state: Extract<SnapshotPhase, { readonly kind: "open" }>, row: unknown): void {
|
|
82
102
|
if (!isRecord(row) || typeof row.id !== "string") {
|
|
83
103
|
throw new Error("Invalid session snapshot row");
|
|
84
104
|
}
|
|
85
105
|
const topics = optionalArray(row.topics);
|
|
86
106
|
const subscriptions = optionalArray(row.subscriptions);
|
|
87
|
-
const previous =
|
|
107
|
+
const previous = state.sessions.get(row.id);
|
|
88
108
|
const info = normalizeSessionInfo({
|
|
89
109
|
...previous?.info,
|
|
90
110
|
...row,
|
|
@@ -95,7 +115,7 @@ export class SnapshotReader {
|
|
|
95
115
|
throw new Error("Invalid session snapshot");
|
|
96
116
|
}
|
|
97
117
|
const { topics: newTopics, subscriptions: newSubscriptions, ...identity } = info;
|
|
98
|
-
|
|
118
|
+
state.sessions.set(info.id, {
|
|
99
119
|
info: identity,
|
|
100
120
|
topics: appendRows(previous?.topics, newTopics, "topics" in row),
|
|
101
121
|
subscriptions: appendRows(previous?.subscriptions, newSubscriptions, "subscriptions" in row),
|
|
@@ -103,27 +123,35 @@ export class SnapshotReader {
|
|
|
103
123
|
}
|
|
104
124
|
|
|
105
125
|
append(sessions: readonly unknown[], receipts: unknown): void {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
126
|
+
const state = this.open();
|
|
127
|
+
try {
|
|
128
|
+
for (const row of sessions) {
|
|
129
|
+
this.addSession(state, row);
|
|
130
|
+
}
|
|
131
|
+
if (receipts === undefined) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (!isUnknownArray(receipts)) {
|
|
135
|
+
throw new Error("Invalid topic delivery receipts");
|
|
136
|
+
}
|
|
137
|
+
state.receipts.push(...receipts.map(receipt));
|
|
138
|
+
} catch (error) {
|
|
139
|
+
this.fail();
|
|
140
|
+
throw error;
|
|
114
141
|
}
|
|
115
|
-
this.receipts.push(...receipts.map(receipt));
|
|
116
142
|
}
|
|
117
143
|
|
|
118
144
|
finish(): SessionSnapshot {
|
|
145
|
+
const state = this.open();
|
|
119
146
|
const sessions: SessionInfo[] = [];
|
|
120
|
-
for (const { info, topics, subscriptions } of
|
|
147
|
+
for (const { info, topics, subscriptions } of state.sessions.values()) {
|
|
121
148
|
sessions.push({
|
|
122
149
|
...info,
|
|
123
150
|
...(topics !== undefined ? { topics } : {}),
|
|
124
151
|
...(subscriptions !== undefined ? { subscriptions } : {}),
|
|
125
152
|
});
|
|
126
153
|
}
|
|
127
|
-
|
|
154
|
+
this.phase = { kind: "finished" };
|
|
155
|
+
return { sessions, receipts: state.receipts };
|
|
128
156
|
}
|
|
129
157
|
}
|
|
@@ -43,8 +43,10 @@ export class IntercomConnection {
|
|
|
43
43
|
private parked: IntercomClient | null = null;
|
|
44
44
|
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
45
45
|
private startupTimer: NodeJS.Timeout | null = null;
|
|
46
|
-
private connecting:
|
|
47
|
-
|
|
46
|
+
private connecting: {
|
|
47
|
+
readonly task: Promise<IntercomTransport>;
|
|
48
|
+
readonly generation: number;
|
|
49
|
+
} | null = null;
|
|
48
50
|
private attempt = 0;
|
|
49
51
|
private readonly lifecycle: Lifecycle;
|
|
50
52
|
private readonly config: IntercomConfig;
|
|
@@ -352,8 +354,8 @@ export class IntercomConnection {
|
|
|
352
354
|
if (!ctx || generation !== this.lifecycle.generation) {
|
|
353
355
|
throw new Error("Intercom shutting down");
|
|
354
356
|
}
|
|
355
|
-
if (this.connecting
|
|
356
|
-
return this.connecting;
|
|
357
|
+
if (this.connecting?.generation === generation) {
|
|
358
|
+
return this.connecting.task;
|
|
357
359
|
}
|
|
358
360
|
if (this.client?.isConnected() === true) {
|
|
359
361
|
return this.client;
|
|
@@ -365,8 +367,7 @@ export class IntercomConnection {
|
|
|
365
367
|
this.clearReconnect();
|
|
366
368
|
// Publish ownership before registration, which can synchronously invoke subscribers.
|
|
367
369
|
const attempt = Promise.resolve().then(() => this.connect(ctx, generation, sessionId));
|
|
368
|
-
this.connecting = attempt;
|
|
369
|
-
this.connectingGeneration = generation;
|
|
370
|
+
this.connecting = { task: attempt, generation };
|
|
370
371
|
return await this.finishAttempt(attempt, ctx, generation);
|
|
371
372
|
}
|
|
372
373
|
private async finishAttempt(
|
|
@@ -381,9 +382,8 @@ export class IntercomConnection {
|
|
|
381
382
|
retry = this.lifecycle.live(ctx, generation) !== null;
|
|
382
383
|
throw error;
|
|
383
384
|
} finally {
|
|
384
|
-
if (this.connecting === attempt) {
|
|
385
|
+
if (this.connecting?.task === attempt) {
|
|
385
386
|
this.connecting = null;
|
|
386
|
-
this.connectingGeneration = null;
|
|
387
387
|
}
|
|
388
388
|
if (retry) {
|
|
389
389
|
this.scheduleReconnect();
|
|
@@ -394,7 +394,7 @@ export class IntercomConnection {
|
|
|
394
394
|
this.clearTimers();
|
|
395
395
|
this.cancelWakes();
|
|
396
396
|
// The bounded launcher attempt is joined before socket shutdown.
|
|
397
|
-
await Promise.allSettled([this.connecting]);
|
|
397
|
+
await Promise.allSettled([this.connecting?.task]);
|
|
398
398
|
if (this.client) {
|
|
399
399
|
await this.client.disconnect();
|
|
400
400
|
this.client = null;
|
|
@@ -191,13 +191,16 @@ export class InboundJournal {
|
|
|
191
191
|
pending.from.id === entry.from.id &&
|
|
192
192
|
pending.message.threadId === entry.message.threadId
|
|
193
193
|
) {
|
|
194
|
-
this.
|
|
195
|
-
this.replies.markReplied(pending.message.id);
|
|
194
|
+
this.supersede(pending.message.id);
|
|
196
195
|
}
|
|
197
196
|
}
|
|
198
197
|
}
|
|
199
198
|
this.pending.set(entry.message.id, entry);
|
|
200
199
|
}
|
|
200
|
+
private supersede(id: string): void {
|
|
201
|
+
this.consume(id);
|
|
202
|
+
this.replies.markReplied(id);
|
|
203
|
+
}
|
|
201
204
|
setStage(entry: PendingInboundMessage, stage: PendingInboundMessage["stage"]): void {
|
|
202
205
|
if (entry.stage === stage) {
|
|
203
206
|
return;
|
|
@@ -16,13 +16,15 @@ export interface JournalOptions {
|
|
|
16
16
|
readonly stringChunk?: StringChunk | undefined;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
type RecordState =
|
|
20
|
+
| { readonly kind: "blank" | "reading" }
|
|
21
|
+
| { readonly kind: "invalid"; readonly error: unknown };
|
|
22
|
+
|
|
19
23
|
/** Byte-framed JSONL. Publication framing is independent of malformed-record tolerance. */
|
|
20
24
|
export class JournalFrames {
|
|
21
25
|
private projection: JsonProjection;
|
|
22
26
|
private decoder = new TextDecoder("utf8", { fatal: true });
|
|
23
|
-
private
|
|
24
|
-
private failed = false;
|
|
25
|
-
private nonblank = false;
|
|
27
|
+
private state: RecordState = { kind: "blank" };
|
|
26
28
|
private start: number;
|
|
27
29
|
offset: number;
|
|
28
30
|
private readonly policy: JournalPolicy;
|
|
@@ -48,20 +50,20 @@ export class JournalFrames {
|
|
|
48
50
|
return new JsonProjection(this.select, this.options.stringChunk);
|
|
49
51
|
}
|
|
50
52
|
private part(bytes: Buffer): void {
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
+
if (this.state.kind === "invalid") {
|
|
54
|
+
return;
|
|
53
55
|
}
|
|
54
|
-
if (
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
56
|
+
if (this.state.kind === "blank" && bytes.some((byte) => ![9, 10, 13, 32].includes(byte))) {
|
|
57
|
+
this.state = { kind: "reading" };
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
this.projection.write(this.decoder.decode(bytes, { stream: true }));
|
|
61
|
+
} catch (error) {
|
|
62
|
+
this.state = { kind: "invalid", error };
|
|
61
63
|
}
|
|
62
64
|
}
|
|
63
65
|
private finishRecord(end: number): JournalRecord | undefined {
|
|
64
|
-
if (
|
|
66
|
+
if (this.state.kind !== "reading") {
|
|
65
67
|
return undefined;
|
|
66
68
|
}
|
|
67
69
|
try {
|
|
@@ -72,17 +74,16 @@ export class JournalFrames {
|
|
|
72
74
|
}
|
|
73
75
|
return { value, start: this.start, end };
|
|
74
76
|
} catch (error) {
|
|
75
|
-
this.
|
|
76
|
-
this.error = error;
|
|
77
|
+
this.state = { kind: "invalid", error };
|
|
77
78
|
return undefined;
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
81
|
private commit(end: number): void {
|
|
81
82
|
const start = this.start;
|
|
82
83
|
const record = this.finishRecord(end);
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
const state = this.state;
|
|
85
|
+
if (state.kind === "invalid" && this.policy !== "inspect") {
|
|
86
|
+
const { error } = state;
|
|
86
87
|
throw new SyntaxError(`Invalid JSONL record at byte ${start}: ${errorDescription(error)}`, {
|
|
87
88
|
cause: error,
|
|
88
89
|
});
|
|
@@ -90,10 +91,8 @@ export class JournalFrames {
|
|
|
90
91
|
this.start = end;
|
|
91
92
|
this.projection = this.newProjection();
|
|
92
93
|
this.decoder = new TextDecoder("utf8", { fatal: true });
|
|
93
|
-
this.
|
|
94
|
-
|
|
95
|
-
this.nonblank = false;
|
|
96
|
-
if (!failed && record) {
|
|
94
|
+
this.state = { kind: "blank" };
|
|
95
|
+
if (record) {
|
|
97
96
|
this.record(record);
|
|
98
97
|
}
|
|
99
98
|
}
|
|
@@ -110,7 +109,7 @@ export class JournalFrames {
|
|
|
110
109
|
}
|
|
111
110
|
finish(): number {
|
|
112
111
|
if (this.requireNewline) {
|
|
113
|
-
if (this.policy === "strict" && this.
|
|
112
|
+
if (this.policy === "strict" && this.state.kind !== "blank") {
|
|
114
113
|
throw new SyntaxError(`Unpublished JSONL record at byte ${this.start}: newline required`);
|
|
115
114
|
}
|
|
116
115
|
} else if (this.offset > this.start) {
|