@fitchmultz/pi-subagents 0.52.20 → 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.
Files changed (44) hide show
  1. package/CHANGELOG.md +30 -2
  2. package/dist/agent/contracts.js +12 -3
  3. package/dist/agent/index.js +9 -9
  4. package/dist/agent/legacy.js +5 -5
  5. package/dist/agent/notifications.js +21 -14
  6. package/dist/agent/parent-check.js +38 -0
  7. package/dist/agent/process.js +45 -52
  8. package/dist/agent/runner.js +59 -64
  9. package/dist/agent/storage.js +16 -4
  10. package/dist/agent/view.js +1 -3
  11. package/dist/build-info.js +1 -1
  12. package/dist/pi-intercom/broker/broker.js +4 -26
  13. package/dist/pi-intercom/broker/client-park.d.ts +1 -1
  14. package/dist/pi-intercom/broker/client-park.js +22 -6
  15. package/dist/pi-intercom/broker/client.d.ts +3 -2
  16. package/dist/pi-intercom/broker/client.js +88 -62
  17. package/dist/pi-intercom/broker/connection.d.ts +3 -5
  18. package/dist/pi-intercom/broker/connection.js +48 -33
  19. package/dist/pi-intercom/broker/framing.d.ts +1 -1
  20. package/dist/pi-intercom/broker/framing.js +19 -12
  21. package/dist/pi-intercom/broker/replace-deliveries.js +38 -0
  22. package/dist/pi-intercom/connection.js +5 -8
  23. package/dist/pi-intercom/inbound-journal.js +5 -2
  24. package/dist/shared/journal-frames.js +19 -24
  25. package/docs/development.md +6 -0
  26. package/package.json +1 -1
  27. package/src/agent/contracts.ts +12 -3
  28. package/src/agent/index.ts +10 -10
  29. package/src/agent/legacy.ts +5 -5
  30. package/src/agent/notifications.ts +35 -15
  31. package/src/agent/parent-check.ts +40 -0
  32. package/src/agent/process.ts +53 -55
  33. package/src/agent/runner.ts +65 -65
  34. package/src/agent/storage.ts +18 -5
  35. package/src/agent/view.ts +1 -4
  36. package/src/pi-intercom/broker/broker.ts +5 -37
  37. package/src/pi-intercom/broker/client-park.ts +27 -6
  38. package/src/pi-intercom/broker/client.ts +91 -61
  39. package/src/pi-intercom/broker/connection.ts +58 -39
  40. package/src/pi-intercom/broker/framing.ts +24 -12
  41. package/src/pi-intercom/broker/replace-deliveries.ts +56 -0
  42. package/src/pi-intercom/connection.ts +9 -9
  43. package/src/pi-intercom/inbound-journal.ts +5 -2
  44. package/src/shared/journal-frames.ts +22 -23
@@ -43,16 +43,20 @@ interface ConnectionHandlers {
43
43
  readonly closed: (error: Readonly<Error>, expected: boolean) => void;
44
44
  }
45
45
 
46
+ type ConnectionPhase =
47
+ | { readonly kind: "acquired" | "active" | "failed" | "closed" }
48
+ | {
49
+ readonly kind: "registering";
50
+ readonly resolve: () => void;
51
+ readonly reject: (error: Readonly<Error>) => void;
52
+ readonly timer: NodeJS.Timeout;
53
+ }
54
+ | { readonly kind: "closing"; readonly task: Promise<void>; readonly registered: boolean };
55
+
46
56
  /** One socket owns registration, read/error listeners, and graceful shutdown. */
47
57
  export class BrokerConnection {
48
- private registered = false;
49
- private closing = false;
58
+ private phase: ConnectionPhase = { kind: "acquired" };
50
59
  private failure: Readonly<Error> | null = null;
51
- private registration: {
52
- readonly resolve: () => void;
53
- readonly reject: (error: Readonly<Error>) => void;
54
- } | null = null;
55
- private registrationTimer: NodeJS.Timeout | null = null;
56
60
  private readonly reader: (data: Buffer) => void;
57
61
 
58
62
  readonly socket: net.Socket;
@@ -69,12 +73,9 @@ export class BrokerConnection {
69
73
 
70
74
  start(registration: unknown): Promise<void> {
71
75
  return new Promise((resolve, reject) => {
72
- this.registration = { resolve, reject };
73
- this.registrationTimer = setTimeout(
74
- () => this.failRegistration(new Error("Connection timeout")),
75
- 10000,
76
- );
77
- this.registrationTimer.unref();
76
+ const timer = setTimeout(() => this.failRegistration(new Error("Connection timeout")), 10000);
77
+ timer.unref();
78
+ this.phase = { kind: "registering", resolve, reject, timer };
78
79
  try {
79
80
  writeMessage(this.socket, registration);
80
81
  } catch (error) {
@@ -84,23 +85,28 @@ export class BrokerConnection {
84
85
  }
85
86
 
86
87
  confirmRegistration(): void {
87
- this.registered = true;
88
- const pending = this.registration;
89
- this.clearRegistration();
90
- pending?.resolve();
88
+ const pending = this.takeRegistration();
89
+ if (pending !== undefined) {
90
+ this.phase = { kind: "active" };
91
+ pending.resolve();
92
+ }
91
93
  }
92
94
 
93
- private clearRegistration(): void {
94
- if (this.registrationTimer) {
95
- clearTimeout(this.registrationTimer);
95
+ private takeRegistration() {
96
+ const pending = this.phase;
97
+ if (pending.kind !== "registering") {
98
+ return;
96
99
  }
97
- this.registrationTimer = null;
98
- this.registration = null;
100
+ clearTimeout(pending.timer);
101
+ this.phase = { kind: "acquired" };
102
+ return pending;
99
103
  }
100
104
 
101
105
  private failRegistration(error: Readonly<Error>): void {
102
- const pending = this.registration;
103
- this.clearRegistration();
106
+ const pending = this.takeRegistration();
107
+ if (this.phase.kind !== "closing") {
108
+ this.phase = { kind: "failed" };
109
+ }
104
110
  this.failure = error;
105
111
  this.socket.destroy();
106
112
  pending?.reject(error);
@@ -127,19 +133,23 @@ export class BrokerConnection {
127
133
  };
128
134
 
129
135
  private readonly onClose = (): void => {
130
- const pending = this.registration;
131
- this.clearRegistration();
136
+ const closing = this.phase.kind === "closing";
137
+ const pending = this.takeRegistration();
138
+ this.phase = { kind: "closed" };
132
139
  this.socket.off("data", this.reader);
133
140
  this.socket.off("error", this.onError);
134
141
  this.socket.off("close", this.onClose);
135
142
  pending?.reject(new Error("Connection closed before registration"));
136
- this.handlers.closed(this.failure ?? new Error("Client disconnected"), this.closing);
143
+ this.handlers.closed(this.failure ?? new Error("Client disconnected"), closing);
137
144
  };
138
145
 
146
+ private get registered(): boolean {
147
+ return this.phase.kind === "active" || (this.phase.kind === "closing" && this.phase.registered);
148
+ }
149
+
139
150
  isActive(): boolean {
140
151
  return (
141
- this.registered &&
142
- !this.closing &&
152
+ this.phase.kind === "active" &&
143
153
  !this.socket.destroyed &&
144
154
  !this.socket.writableEnded &&
145
155
  this.socket.writable
@@ -147,15 +157,18 @@ export class BrokerConnection {
147
157
  }
148
158
 
149
159
  disconnect(): Promise<void> {
150
- this.closing = true;
160
+ const phase = this.phase;
161
+ if (phase.kind === "closing") {
162
+ return phase.task;
163
+ }
164
+ if (phase.kind === "closed") {
165
+ return Promise.resolve();
166
+ }
167
+ const pending = this.takeRegistration();
168
+ pending?.reject(new Error("Client disconnected before registration"));
151
169
  this.failure = null;
152
- return new Promise((resolve) => {
153
- let settled = false;
170
+ const task = new Promise<void>((resolve) => {
154
171
  const finish = () => {
155
- if (settled) {
156
- return;
157
- }
158
- settled = true;
159
172
  clearTimeout(timeout);
160
173
  this.socket.off("close", finish);
161
174
  this.socket.off("error", onError);
@@ -169,12 +182,18 @@ export class BrokerConnection {
169
182
  }, 2000);
170
183
  this.socket.once("close", finish);
171
184
  this.socket.once("error", onError);
172
- try {
185
+ });
186
+ this.phase = { kind: "closing", task, registered: phase.kind === "active" };
187
+ try {
188
+ if (phase.kind === "active" || phase.kind === "registering") {
173
189
  writeMessage(this.socket, { type: "unregister" });
174
190
  this.socket.end();
175
- } catch {
191
+ } else {
176
192
  this.socket.destroy();
177
193
  }
178
- });
194
+ } catch {
195
+ this.socket.destroy();
196
+ }
197
+ return task;
179
198
  }
180
199
  }
@@ -90,10 +90,15 @@ class MessageBuffer {
90
90
  }
91
91
  }
92
92
 
93
+ type FrameState =
94
+ | { readonly kind: "header" }
95
+ | { readonly kind: "payload"; readonly length: number }
96
+ | { readonly kind: "failed" };
97
+
93
98
  /**
94
99
  * Create a message reader that handles partial reads.
95
100
  * Calls onMessage for each complete message received.
96
- * Protocol or handler errors are reported to onError so the caller can close the socket.
101
+ * Protocol or handler errors end reading and are reported so the caller can close the socket.
97
102
  */
98
103
  export function createMessageReader(
99
104
  onMessage: (msg: unknown) => void,
@@ -101,32 +106,39 @@ export function createMessageReader(
101
106
  limit = MAX_FRAME_SIZE_BYTES,
102
107
  ): (data: Buffer) => void {
103
108
  const buffer = new MessageBuffer();
104
- let length: number | undefined;
109
+ let state: FrameState = { kind: "header" };
110
+ const fail = (error: Readonly<Error>) => {
111
+ state = { kind: "failed" };
112
+ buffer.clear();
113
+ onError(error);
114
+ };
105
115
 
106
116
  return (data: Buffer) => {
117
+ if (state.kind === "failed") {
118
+ return;
119
+ }
107
120
  buffer.append(data);
108
121
 
109
- while (buffer.length >= (length ?? 4)) {
110
- if (length === undefined) {
111
- length = buffer.read(4).readUInt32BE(0);
122
+ while (buffer.length >= (state.kind === "payload" ? state.length : 4)) {
123
+ if (state.kind === "header") {
124
+ const length = buffer.read(4).readUInt32BE(0);
112
125
  if (length > limit) {
113
- onError(new Error(`Intercom frame too large (${length} bytes; max ${limit})`));
114
- buffer.clear();
115
- length = undefined;
126
+ fail(new Error(`Intercom frame too large (${length} bytes; max ${limit})`));
116
127
  return;
117
128
  }
129
+ state = { kind: "payload", length };
118
130
  continue;
119
131
  }
120
132
 
121
- const payload = buffer.read(length);
122
- length = undefined;
133
+ const payload = buffer.read(state.length);
134
+ state = { kind: "header" };
123
135
 
124
136
  let msg: unknown;
125
137
  try {
126
138
  msg = JSON.parse(payload.toString("utf-8"));
127
139
  } catch (error) {
128
140
  const message = errorMessage(error);
129
- onError(new Error(`Failed to parse intercom message: ${message}`, { cause: error }));
141
+ fail(new Error(`Failed to parse intercom message: ${message}`, { cause: error }));
130
142
  return;
131
143
  }
132
144
 
@@ -134,7 +146,7 @@ export function createMessageReader(
134
146
  onMessage(msg);
135
147
  } catch (error) {
136
148
  const message = errorMessage(error);
137
- onError(new Error(`Failed to handle intercom message: ${message}`, { cause: error }));
149
+ fail(new Error(`Failed to handle intercom message: ${message}`, { cause: error }));
138
150
  return;
139
151
  }
140
152
  }
@@ -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
+ }
@@ -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: Promise<IntercomTransport> | null = null;
47
- private connectingGeneration: number | null = null;
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 && this.connectingGeneration === generation) {
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.consume(pending.message.id);
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 error: unknown;
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 (!this.nonblank && bytes.some((byte) => ![9, 10, 13, 32].includes(byte))) {
52
- this.nonblank = true;
53
+ if (this.state.kind === "invalid") {
54
+ return;
53
55
  }
54
- if (!this.failed) {
55
- try {
56
- this.projection.write(this.decoder.decode(bytes, { stream: true }));
57
- } catch (error) {
58
- this.failed = true;
59
- this.error = error;
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 (!this.nonblank || this.failed) {
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.failed = true;
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 failed = this.failed;
84
- const error = this.error;
85
- if (failed && this.policy !== "inspect") {
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.error = undefined;
94
- this.failed = false;
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.nonblank) {
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) {