@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +44 -0
  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/bridge-presence.js +30 -18
  13. package/dist/pi-intercom/bridge-wake.js +60 -37
  14. package/dist/pi-intercom/broker/broker.js +4 -26
  15. package/dist/pi-intercom/broker/client-last.js +6 -18
  16. package/dist/pi-intercom/broker/client-park.d.ts +1 -1
  17. package/dist/pi-intercom/broker/client-park.js +22 -6
  18. package/dist/pi-intercom/broker/client.d.ts +3 -2
  19. package/dist/pi-intercom/broker/client.js +104 -112
  20. package/dist/pi-intercom/broker/connection.d.ts +3 -5
  21. package/dist/pi-intercom/broker/connection.js +48 -33
  22. package/dist/pi-intercom/broker/framing.d.ts +1 -1
  23. package/dist/pi-intercom/broker/framing.js +19 -12
  24. package/dist/pi-intercom/broker/pending-requests.d.ts +12 -0
  25. package/dist/pi-intercom/broker/pending-requests.js +26 -0
  26. package/dist/pi-intercom/broker/replace-deliveries.js +38 -0
  27. package/dist/pi-intercom/broker/snapshot-reader.d.ts +3 -2
  28. package/dist/pi-intercom/broker/snapshot-reader.js +34 -15
  29. package/dist/pi-intercom/connection.js +5 -8
  30. package/dist/pi-intercom/inbound-journal.js +5 -2
  31. package/dist/shared/journal-frames.js +19 -24
  32. package/docs/development.md +6 -0
  33. package/package.json +1 -1
  34. package/src/agent/contracts.ts +12 -3
  35. package/src/agent/index.ts +10 -10
  36. package/src/agent/legacy.ts +5 -5
  37. package/src/agent/notifications.ts +35 -15
  38. package/src/agent/parent-check.ts +40 -0
  39. package/src/agent/process.ts +53 -55
  40. package/src/agent/runner.ts +65 -65
  41. package/src/agent/storage.ts +18 -5
  42. package/src/agent/view.ts +1 -4
  43. package/src/pi-intercom/bridge-presence.ts +37 -18
  44. package/src/pi-intercom/bridge-wake.ts +70 -38
  45. package/src/pi-intercom/broker/broker.ts +5 -37
  46. package/src/pi-intercom/broker/client-last.ts +14 -25
  47. package/src/pi-intercom/broker/client-park.ts +27 -6
  48. package/src/pi-intercom/broker/client.ts +127 -126
  49. package/src/pi-intercom/broker/connection.ts +58 -39
  50. package/src/pi-intercom/broker/framing.ts +24 -12
  51. package/src/pi-intercom/broker/pending-requests.ts +37 -0
  52. package/src/pi-intercom/broker/replace-deliveries.ts +56 -0
  53. package/src/pi-intercom/broker/snapshot-reader.ts +44 -16
  54. package/src/pi-intercom/connection.ts +9 -9
  55. package/src/pi-intercom/inbound-journal.ts +5 -2
  56. 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 readonly sessions = new Map<string, SessionParts>();
79
- private readonly receipts: Receipt[] = [];
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
- private addSession(row: unknown): void {
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 = this.sessions.get(row.id);
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
- this.sessions.set(info.id, {
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
- for (const row of sessions) {
107
- this.addSession(row);
108
- }
109
- if (receipts === undefined) {
110
- return;
111
- }
112
- if (!isUnknownArray(receipts)) {
113
- throw new Error("Invalid topic delivery receipts");
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 this.sessions.values()) {
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
- return { sessions, receipts: this.receipts };
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: 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) {