@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.
Files changed (180) hide show
  1. package/CHANGELOG.md +63 -2
  2. package/README.md +36 -5
  3. package/dist/agent/child-runtime.js +80 -0
  4. package/dist/agent/contracts.js +15 -24
  5. package/dist/agent/host.js +3 -16
  6. package/dist/agent/index.js +212 -192
  7. package/dist/agent/journal-tail.js +115 -156
  8. package/dist/agent/launch.js +20 -8
  9. package/dist/agent/legacy.js +65 -23
  10. package/dist/agent/notifications.js +138 -47
  11. package/dist/agent/observation-state.js +246 -0
  12. package/dist/agent/observation-view.js +258 -0
  13. package/dist/agent/observation.js +350 -0
  14. package/dist/agent/observe-card.js +243 -0
  15. package/dist/agent/observe-chrome.js +85 -0
  16. package/dist/agent/observe-client.js +285 -0
  17. package/dist/agent/observe-layout.js +65 -0
  18. package/dist/agent/observe-reading.js +84 -0
  19. package/dist/agent/observe-screen.js +400 -96
  20. package/dist/agent/observe-transcript.js +382 -0
  21. package/dist/agent/observe.js +250 -43
  22. package/dist/agent/parking.js +253 -0
  23. package/dist/agent/process.js +228 -28
  24. package/dist/agent/receipts.js +92 -20
  25. package/dist/agent/runner.js +229 -126
  26. package/dist/agent/shell.js +2 -1
  27. package/dist/agent/storage.js +47 -12
  28. package/dist/agent/tool-schemas.js +20 -0
  29. package/dist/agent/typebox.js +10 -5
  30. package/dist/agent/usage.js +0 -115
  31. package/dist/agent/view.js +46 -16
  32. package/dist/agent/wake.js +99 -0
  33. package/dist/build-info.js +1 -1
  34. package/dist/intercom-client.d.ts +26 -0
  35. package/dist/intercom-client.js +45 -0
  36. package/dist/pi-intercom/agent-dir.d.ts +1 -0
  37. package/dist/pi-intercom/bridge-session.js +2 -1
  38. package/dist/pi-intercom/broker/broker.js +62 -43
  39. package/dist/pi-intercom/broker/client-last.d.ts +24 -0
  40. package/dist/pi-intercom/broker/client-last.js +42 -5
  41. package/dist/pi-intercom/broker/client-park.d.ts +9 -0
  42. package/dist/pi-intercom/broker/client-park.js +21 -0
  43. package/dist/pi-intercom/broker/client.d.ts +59 -0
  44. package/dist/pi-intercom/broker/client.js +55 -29
  45. package/dist/pi-intercom/broker/connection.d.ts +29 -0
  46. package/dist/pi-intercom/broker/framing.d.ts +17 -0
  47. package/dist/pi-intercom/broker/framing.js +10 -7
  48. package/dist/pi-intercom/broker/guard.js +23 -0
  49. package/dist/pi-intercom/broker/last.d.ts +30 -0
  50. package/dist/pi-intercom/broker/last.js +87 -40
  51. package/dist/pi-intercom/broker/parked.js +107 -0
  52. package/dist/pi-intercom/broker/paths.d.ts +13 -0
  53. package/dist/pi-intercom/broker/paths.js +35 -12
  54. package/dist/pi-intercom/broker/pid.js +5 -1
  55. package/dist/pi-intercom/broker/server-connection.js +20 -1
  56. package/dist/pi-intercom/broker/snapshot-reader.d.ts +9 -0
  57. package/dist/pi-intercom/broker/snapshots.d.ts +29 -0
  58. package/dist/pi-intercom/broker/targets.d.ts +9 -0
  59. package/dist/pi-intercom/connection.js +71 -20
  60. package/dist/pi-intercom/identity.js +6 -5
  61. package/dist/pi-intercom/inbound-delivery.js +16 -60
  62. package/dist/pi-intercom/inbound-journal.js +83 -6
  63. package/dist/pi-intercom/inbound-record.js +18 -1
  64. package/dist/pi-intercom/inbound.js +22 -37
  65. package/dist/pi-intercom/index.js +41 -0
  66. package/dist/pi-intercom/last-message.js +4 -16
  67. package/dist/pi-intercom/lifecycle.js +88 -3
  68. package/dist/pi-intercom/outbound.js +5 -15
  69. package/dist/pi-intercom/reply-tracker.js +7 -6
  70. package/dist/pi-intercom/reply-wait.js +2 -2
  71. package/dist/pi-intercom/session-targets.d.ts +24 -0
  72. package/dist/pi-intercom/tool-registration.js +4 -1
  73. package/dist/pi-intercom/tool-results.js +5 -7
  74. package/dist/pi-intercom/tool-schemas.js +12 -26
  75. package/dist/pi-intercom/types.d.ts +212 -0
  76. package/dist/pi-intercom/types.js +54 -3
  77. package/dist/pi-intercom/validation.d.ts +12 -0
  78. package/dist/pi-intercom/validation.js +3 -0
  79. package/dist/shared/bounded-json.js +151 -0
  80. package/dist/shared/compact-cards.js +66 -0
  81. package/dist/shared/journal-frames.js +3 -6
  82. package/dist/shared/json-projection.js +267 -129
  83. package/dist/shared/native-journal.js +123 -56
  84. package/dist/shared/parking.js +12 -0
  85. package/dist/shared/post-exit-stdio-guard.js +120 -7
  86. package/dist/shared/tui-action.js +29 -0
  87. package/dist/shared/unknown.d.ts +9 -0
  88. package/docs/code-quality.md +5 -3
  89. package/docs/development.md +42 -3
  90. package/docs/intercom-bridge.md +9 -4
  91. package/docs/intercom.md +228 -7
  92. package/docs/performance.md +118 -14
  93. package/docs/reference.md +168 -20
  94. package/docs/retired-history-maintenance.md +252 -0
  95. package/package.json +28 -15
  96. package/scripts/archive-retired-history.mjs +464 -0
  97. package/scripts/build.mjs +48 -28
  98. package/scripts/measure-size.mjs +1 -1
  99. package/scripts/native-package-smoke.mjs +153 -12
  100. package/scripts/package-smoke.mjs +80 -11
  101. package/scripts/prepare.mjs +2 -2
  102. package/scripts/real-pi-live.mjs +377 -0
  103. package/scripts/real-pi-processes.mjs +6 -1
  104. package/scripts/real-pi-smoke.mjs +6 -282
  105. package/scripts/run-tests.mjs +4 -2
  106. package/scripts/typescript.mjs +35 -0
  107. package/skills/pi-intercom/SKILL.md +2 -1
  108. package/skills/pi-subagents/SKILL.md +23 -5
  109. package/src/agent/child-runtime.ts +105 -0
  110. package/src/agent/contracts.ts +18 -34
  111. package/src/agent/host.ts +39 -20
  112. package/src/agent/index.ts +234 -207
  113. package/src/agent/journal-tail.ts +141 -153
  114. package/src/agent/launch.ts +33 -9
  115. package/src/agent/legacy.ts +85 -31
  116. package/src/agent/notifications.ts +192 -67
  117. package/src/agent/observation-state.ts +277 -0
  118. package/src/agent/observation-view.ts +267 -0
  119. package/src/agent/observation.ts +409 -0
  120. package/src/agent/observe-card.ts +304 -0
  121. package/src/agent/observe-chrome.ts +170 -0
  122. package/src/agent/observe-client.ts +318 -0
  123. package/src/agent/observe-layout.ts +79 -0
  124. package/src/agent/observe-reading.ts +97 -0
  125. package/src/agent/observe-screen.ts +443 -129
  126. package/src/agent/observe-transcript.ts +434 -0
  127. package/src/agent/observe.ts +304 -71
  128. package/src/agent/parking.ts +341 -0
  129. package/src/agent/process.ts +280 -37
  130. package/src/agent/receipts.ts +114 -24
  131. package/src/agent/runner.ts +227 -149
  132. package/src/agent/shell.ts +2 -1
  133. package/src/agent/storage.ts +44 -11
  134. package/src/agent/tool-schemas.ts +33 -0
  135. package/src/agent/typebox.ts +12 -7
  136. package/src/agent/usage.ts +0 -130
  137. package/src/agent/view.ts +70 -20
  138. package/src/agent/wake.ts +107 -0
  139. package/src/intercom-client.ts +108 -0
  140. package/src/pi-intercom/bridge-session.ts +2 -2
  141. package/src/pi-intercom/broker/broker.ts +64 -53
  142. package/src/pi-intercom/broker/client-last.ts +65 -12
  143. package/src/pi-intercom/broker/client-park.ts +23 -0
  144. package/src/pi-intercom/broker/client.ts +63 -45
  145. package/src/pi-intercom/broker/framing.ts +11 -8
  146. package/src/pi-intercom/broker/guard.ts +33 -0
  147. package/src/pi-intercom/broker/last.ts +131 -43
  148. package/src/pi-intercom/broker/parked.ts +130 -0
  149. package/src/pi-intercom/broker/paths.ts +39 -12
  150. package/src/pi-intercom/broker/pid.ts +6 -1
  151. package/src/pi-intercom/broker/server-connection.ts +27 -2
  152. package/src/pi-intercom/connection.ts +103 -39
  153. package/src/pi-intercom/identity.ts +8 -5
  154. package/src/pi-intercom/inbound-delivery.ts +20 -66
  155. package/src/pi-intercom/inbound-journal.ts +103 -7
  156. package/src/pi-intercom/inbound-record.ts +21 -4
  157. package/src/pi-intercom/inbound.ts +32 -49
  158. package/src/pi-intercom/index.ts +44 -0
  159. package/src/pi-intercom/last-message.ts +11 -20
  160. package/src/pi-intercom/lifecycle.ts +117 -14
  161. package/src/pi-intercom/outbound.ts +7 -16
  162. package/src/pi-intercom/reply-tracker.ts +8 -6
  163. package/src/pi-intercom/reply-wait.ts +3 -2
  164. package/src/pi-intercom/runtime-types.ts +0 -1
  165. package/src/pi-intercom/tool-registration.ts +4 -1
  166. package/src/pi-intercom/tool-results.ts +10 -9
  167. package/src/pi-intercom/tool-schemas.ts +12 -44
  168. package/src/pi-intercom/types.ts +115 -4
  169. package/src/pi-intercom/validation.ts +4 -0
  170. package/src/shared/bounded-json.ts +162 -0
  171. package/src/shared/compact-cards.ts +118 -0
  172. package/src/shared/journal-frames.ts +3 -19
  173. package/src/shared/json-projection.ts +267 -147
  174. package/src/shared/native-journal.ts +157 -75
  175. package/src/shared/parking.ts +22 -0
  176. package/src/shared/post-exit-stdio-guard.ts +165 -7
  177. package/src/shared/tui-action.ts +39 -0
  178. package/tsconfig.client.json +8 -0
  179. package/tsconfig.json +1 -0
  180. package/src/shared/stream-json.d.ts +0 -10
@@ -0,0 +1,267 @@
1
+ import type { Socket } from "node:net";
2
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
3
+ import { writeFrame } from "../pi-intercom/broker/framing.ts";
4
+ import { NAME_CHARS, type LiveState, type LiveTool } from "./observation-state.ts";
5
+
6
+ export const FRAME_BYTES = 256 * 1024 - 4;
7
+ const MAX_BUFFERED = 1024 * 1024;
8
+ const ENVELOPE_BYTES = 192;
9
+ /** Blocks past this index stay journal-only; `count` still reports the native length. */
10
+ const BLOCKS = 256;
11
+ type Block = AssistantMessage["content"][number];
12
+ export type Level = "detail" | "activity";
13
+
14
+ function short(value: string): string {
15
+ return JSON.stringify(value.length > NAME_CHARS ? value.slice(0, NAME_CHARS) : value);
16
+ }
17
+
18
+ /** Pre-serialized pieces keep arbitrary values bounded before the frame exists. */
19
+ class Frame {
20
+ readonly settled: string[] = [];
21
+ readonly tools: string[] = [];
22
+ readonly blocks: string[] = [];
23
+ full = false;
24
+ private bytes = ENVELOPE_BYTES;
25
+ room(): number {
26
+ return FRAME_BYTES - this.bytes;
27
+ }
28
+ take(json: string, bytes = Buffer.byteLength(json)): boolean {
29
+ if (this.bytes + bytes + 1 > FRAME_BYTES) {
30
+ this.full = true;
31
+ return false;
32
+ }
33
+ this.bytes += bytes + 1;
34
+ return true;
35
+ }
36
+ stop(): void {
37
+ this.full = true;
38
+ }
39
+ }
40
+
41
+ /** One viewer's position in one native content block. Text is sent as offset-addressed appends. */
42
+ class BlockCursor {
43
+ readonly ref: Block;
44
+ private type: Block["type"];
45
+ private sent = 0;
46
+ private announced = false;
47
+ private args?: unknown;
48
+ constructor(block: Block) {
49
+ this.ref = block;
50
+ this.type = block.type;
51
+ }
52
+ next(index: number, frame: Frame, state: LiveState): string | undefined {
53
+ const block = this.ref;
54
+ if (block.type !== this.type) {
55
+ // An in-place type replacement restarts the block from offset zero.
56
+ this.type = block.type;
57
+ this.sent = 0;
58
+ this.announced = false;
59
+ }
60
+ if (block.type === "toolCall") {
61
+ if (this.announced && this.args === block.arguments) {
62
+ return undefined;
63
+ }
64
+ const item = `[${index},"toolCall",${short(block.id)},${short(block.name)},${state.project(block.arguments)}]`;
65
+ this.announced = frame.take(item);
66
+ this.args = block.arguments;
67
+ return this.announced ? item : undefined;
68
+ }
69
+ const text = block.type === "text" ? block.text : block.thinking;
70
+ return this.text(`[${index},"${block.type}",`, text, frame);
71
+ }
72
+ private text(head: string, text: string, frame: Frame): string | undefined {
73
+ if (text.length < this.sent) {
74
+ this.sent = 0;
75
+ this.announced = false;
76
+ }
77
+ if (this.announced && text.length === this.sent) {
78
+ return undefined;
79
+ }
80
+ const prefix = `${head}${this.sent},`;
81
+ const budget = frame.room() - prefix.length - 2;
82
+ let end = Math.min(text.length, this.sent + Math.max(0, budget));
83
+ while (end >= this.sent) {
84
+ const json = JSON.stringify(text.slice(this.sent, end));
85
+ const bytes = Buffer.byteLength(json);
86
+ if (bytes <= budget) {
87
+ frame.take(json, prefix.length + bytes + 1);
88
+ if (end < text.length) {
89
+ // The rest of this block needs another frame; keep this viewer dirty.
90
+ frame.stop();
91
+ }
92
+ this.sent = end;
93
+ this.announced = true;
94
+ return `${prefix}${json}]`;
95
+ }
96
+ // Escapes and multibyte text exceed one byte per code unit; shrink proportionally.
97
+ end = this.sent + Math.floor(((end - this.sent) * budget) / bytes) - 8;
98
+ }
99
+ frame.stop();
100
+ return undefined;
101
+ }
102
+ }
103
+
104
+ /** Per-viewer cursors. Each accepted frame advances them exactly once; nothing is resent. */
105
+ export class Viewer {
106
+ blocked = false;
107
+ readonly socket: Socket;
108
+ readonly level: Level;
109
+ activitySeen = -1;
110
+ private activity = "";
111
+ private seen = -1;
112
+ private key = 0;
113
+ private count = 0;
114
+ private ended = false;
115
+ private blocks: BlockCursor[] = [];
116
+ private readonly tools = new Map<string, { readonly rev: number; readonly ended: boolean }>();
117
+ private settled = 0;
118
+ private evicted = 0;
119
+ constructor(socket: Socket, level: Level) {
120
+ this.socket = socket;
121
+ this.level = level;
122
+ }
123
+ /** A baseline: the journal through `head`'s leaf plus every current live item, resent. */
124
+ snapshot(state: LiveState, head: string, activity: string): string {
125
+ this.settled = state.settled;
126
+ this.evicted = state.evicted;
127
+ this.seen = -1;
128
+ this.key = 0;
129
+ this.blocks = [];
130
+ this.tools.clear();
131
+ this.activity = activity;
132
+ this.activitySeen = state.activityVersion;
133
+ return `${head},"activity":${this.activity},"seq":${state.settled}}`;
134
+ }
135
+ /** Missed settlements or evicted live items can only be recovered from a new baseline. */
136
+ stale(state: LiveState): boolean {
137
+ const oldest = state.settlements[0]?.seq ?? state.settled + 1;
138
+ return this.evicted !== state.evicted || this.settled + 1 < oldest;
139
+ }
140
+ wants(state: LiveState): boolean {
141
+ return this.level === "detail" && !this.blocked && this.seen !== state.version;
142
+ }
143
+ delta(state: LiveState): string | undefined {
144
+ const frame = new Frame();
145
+ this.settle(frame, state);
146
+ for (const tool of state.tools.values()) {
147
+ this.tool(tool, frame, state);
148
+ }
149
+ for (const id of this.tools.keys()) {
150
+ if (!state.tools.has(id)) {
151
+ this.tools.delete(id);
152
+ }
153
+ }
154
+ const message = this.message(frame, state);
155
+ if (!frame.full) {
156
+ this.seen = state.version;
157
+ }
158
+ if (frame.settled.length + frame.tools.length === 0 && message === undefined) {
159
+ return undefined;
160
+ }
161
+ const fields = [
162
+ `"t":"delta"`,
163
+ `"settled":[${frame.settled.join(",")}]`,
164
+ `"tools":[${frame.tools.join(",")}]`,
165
+ ];
166
+ return `{${[...fields, ...(message === undefined ? [] : [`"message":${message}`])].join(",")}}`;
167
+ }
168
+ private settle(frame: Frame, state: LiveState): void {
169
+ for (const settlement of state.settlements) {
170
+ if (settlement.seq <= this.settled) {
171
+ continue;
172
+ }
173
+ // Settlements stay in order: one that does not fit holds back every later one.
174
+ if (!frame.take(settlement.json)) {
175
+ return;
176
+ }
177
+ frame.settled.push(settlement.json);
178
+ this.settled = settlement.seq;
179
+ }
180
+ }
181
+ private tool(tool: LiveTool, frame: Frame, state: LiveState): void {
182
+ if (frame.full) {
183
+ return;
184
+ }
185
+ const fields = this.toolFields(tool, state);
186
+ const item = `{"id":${JSON.stringify(tool.id)},${fields.join(",")}}`;
187
+ if (fields.length > 0 && frame.take(item)) {
188
+ frame.tools.push(item);
189
+ this.tools.set(tool.id, { rev: tool.rev, ended: tool.ended !== undefined });
190
+ }
191
+ }
192
+ private toolFields(tool: LiveTool, state: LiveState): string[] {
193
+ const cursor = this.tools.get(tool.id);
194
+ const fields: string[] = [];
195
+ if (cursor === undefined) {
196
+ fields.push(`"name":${short(tool.name)}`, `"args":${tool.projectedArgs(state.project)}`);
197
+ }
198
+ if (tool.rev > (cursor?.rev ?? 0)) {
199
+ fields.push(`"partial":{"rev":${tool.rev},"value":${tool.projectedPartial(state.project)}}`);
200
+ }
201
+ if (tool.ended !== undefined && cursor?.ended !== true) {
202
+ const result = tool.projectedResult(state.project);
203
+ fields.push(`"end":{"isError":${tool.ended.isError},"result":${result}}`);
204
+ }
205
+ return fields;
206
+ }
207
+ private message(frame: Frame, state: LiveState): string | undefined {
208
+ const live = state.message;
209
+ if (live === undefined) {
210
+ return undefined;
211
+ }
212
+ const reset = this.key !== live.key;
213
+ if (reset) {
214
+ this.key = live.key;
215
+ this.blocks = [];
216
+ this.count = 0;
217
+ this.ended = false;
218
+ }
219
+ const content = live.message.content;
220
+ const changed = reset || content.length !== this.count || live.ended !== this.ended;
221
+ this.count = content.length;
222
+ this.ended = live.ended;
223
+ const limit = Math.min(content.length, BLOCKS);
224
+ this.blocks.length = Math.min(this.blocks.length, limit);
225
+ for (let index = 0; index < limit && !frame.full; index++) {
226
+ const item = this.cursor(index, content[index]).next(index, frame, state);
227
+ if (item !== undefined) {
228
+ frame.blocks.push(item);
229
+ }
230
+ }
231
+ if (!changed && frame.blocks.length === 0) {
232
+ return undefined;
233
+ }
234
+ // An ended message stays live until its session entry is found and handed over.
235
+ return `{"key":${live.key},"reset":${reset},"ended":${live.ended},"count":${content.length},"blocks":[${frame.blocks.join(",")}]}`;
236
+ }
237
+ private cursor(index: number, block: Block): BlockCursor {
238
+ const existing = this.blocks.at(index);
239
+ if (existing?.ref === block) {
240
+ return existing;
241
+ }
242
+ // A replaced native block is resent from offset zero.
243
+ const cursor = new BlockCursor(block);
244
+ this.blocks[index] = cursor;
245
+ return cursor;
246
+ }
247
+ activityFrame(state: LiveState, activity: string): string | undefined {
248
+ if (this.blocked) {
249
+ return undefined;
250
+ }
251
+ this.activitySeen = state.activityVersion;
252
+ if (this.activity === activity) {
253
+ return undefined;
254
+ }
255
+ this.activity = activity;
256
+ return `{"t":"activity","activity":${activity}}`;
257
+ }
258
+ /** `false` from Node means accepted but buffered: wait for drain, never resend. */
259
+ write(json: string): number {
260
+ this.blocked = !writeFrame(this.socket, json, FRAME_BYTES);
261
+ const buffered = this.socket.writableLength;
262
+ if (buffered > MAX_BUFFERED) {
263
+ this.socket.destroy();
264
+ }
265
+ return buffered;
266
+ }
267
+ }
@@ -0,0 +1,409 @@
1
+ import * as fs from "node:fs";
2
+ import * as net from "node:net";
3
+ import { once } from "node:events";
4
+ import { timingSafeEqual } from "node:crypto";
5
+ import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
6
+ import { createMessageReader } from "../pi-intercom/broker/framing.ts";
7
+ import {
8
+ agentObservationSocketPath,
9
+ preparePrivateSocketPath,
10
+ } from "../pi-intercom/broker/paths.ts";
11
+ import { errorMessage, isRecord } from "../shared/unknown.ts";
12
+ import { LiveState, type ToolEvent } from "./observation-state.ts";
13
+ import { Viewer, type Level } from "./observation-view.ts";
14
+
15
+ const CONTENT_MS = 250;
16
+ const ACTIVITY_MS = 1000;
17
+ const HELLO_MS = 2000;
18
+ const HELLO_BYTES = 4096;
19
+ const LIMITS: Readonly<Record<Level, number>> = { detail: 4, activity: 8 };
20
+
21
+ export interface ObservationIdentity {
22
+ readonly token: string;
23
+ readonly rootId: string;
24
+ readonly sessionId: string;
25
+ }
26
+ export interface ObservationStats {
27
+ error?: string;
28
+ viewers: number;
29
+ refused: number;
30
+ frames: number;
31
+ bytes: number;
32
+ largestFrame: number;
33
+ largestBuffered: number;
34
+ backpressured: number;
35
+ drains: number;
36
+ resyncs: number;
37
+ flushes: number;
38
+ flushMs: number;
39
+ slowestFlushMs: number;
40
+ listenerMs: number;
41
+ subscriptions: number;
42
+ events: number;
43
+ toolEvents: number;
44
+ progressUpdates: number;
45
+ projections: number;
46
+ unavailable: number;
47
+ settled: number;
48
+ evicted: number;
49
+ }
50
+
51
+ function sameSecret(actual: unknown, expected: string): boolean {
52
+ const left = Buffer.from(typeof actual === "string" ? actual : "");
53
+ const right = Buffer.from(expected);
54
+ return left.length === right.length && timingSafeEqual(left, right);
55
+ }
56
+
57
+ /**
58
+ * Actor-owned, read-only, push-only live view of the native child session. The actor is the sole
59
+ * publisher; viewers can only connect, say hello once and read. The journal stays authoritative.
60
+ */
61
+ export class ActorObservation {
62
+ private readonly state: LiveState;
63
+ private readonly viewers = new Set<Viewer>();
64
+ private readonly greeting = new Set<net.Socket>();
65
+ private server?: net.Server;
66
+ private socketPath?: string;
67
+ private inode?: number;
68
+ private unsubscribe?: () => void;
69
+ private timer?: NodeJS.Timeout;
70
+ private closed = false;
71
+ /** The actor's run is in progress, including native work before and after the agent loop. */
72
+ private running = false;
73
+ private lastContent = -Infinity;
74
+ private lastActivity = -Infinity;
75
+ private dueAt = 0;
76
+ private readonly stats: Omit<
77
+ ObservationStats,
78
+ | "events"
79
+ | "toolEvents"
80
+ | "progressUpdates"
81
+ | "projections"
82
+ | "unavailable"
83
+ | "settled"
84
+ | "evicted"
85
+ > = {
86
+ viewers: 0,
87
+ refused: 0,
88
+ frames: 0,
89
+ bytes: 0,
90
+ largestFrame: 0,
91
+ largestBuffered: 0,
92
+ backpressured: 0,
93
+ drains: 0,
94
+ resyncs: 0,
95
+ flushes: 0,
96
+ flushMs: 0,
97
+ slowestFlushMs: 0,
98
+ listenerMs: 0,
99
+ subscriptions: 0,
100
+ };
101
+ private readonly session: AgentSession;
102
+ private readonly identity: ObservationIdentity;
103
+ private constructor(session: AgentSession, identity: ObservationIdentity) {
104
+ this.session = session;
105
+ this.identity = identity;
106
+ this.state = new LiveState(
107
+ session,
108
+ () => this.schedule(),
109
+ (error) => this.fail(error),
110
+ );
111
+ }
112
+ /** Never rejects: observation faults disable observation, not native execution. */
113
+ static async open(
114
+ session: AgentSession,
115
+ identity: ObservationIdentity,
116
+ ): Promise<ActorObservation> {
117
+ const observation = new ActorObservation(session, identity);
118
+ try {
119
+ await observation.listen();
120
+ } catch (error) {
121
+ observation.fail(error);
122
+ }
123
+ return observation;
124
+ }
125
+ private async listen(): Promise<void> {
126
+ const socketPath = preparePrivateSocketPath(agentObservationSocketPath(this.identity.token));
127
+ if (fs.lstatSync(socketPath, { throwIfNoEntry: false }) !== undefined) {
128
+ throw new Error("Observation endpoint already exists; refusing to replace it.");
129
+ }
130
+ const server = net.createServer(this.accept);
131
+ this.server = server;
132
+ server.on("error", (error) => this.fail(error));
133
+ server.listen(socketPath);
134
+ await once(server, "listening");
135
+ this.socketPath = socketPath;
136
+ fs.chmodSync(socketPath, 0o600);
137
+ this.inode = fs.lstatSync(socketPath).ino;
138
+ }
139
+ /** Native callbacks run inline before persistence: assign references, never throw or await. */
140
+ private native(apply: () => void): void {
141
+ if (this.closed) {
142
+ return;
143
+ }
144
+ const started = performance.now();
145
+ try {
146
+ apply();
147
+ this.schedule();
148
+ } catch (error) {
149
+ this.fail(error);
150
+ }
151
+ this.stats.listenerMs += performance.now() - started;
152
+ }
153
+ private readonly listener = (event: AgentSessionEvent): void => {
154
+ this.native(() => this.state.apply(event));
155
+ };
156
+ /** Sparse tool lifecycle hook, registered by the actor's own extension. */
157
+ tool(event: ToolEvent): void {
158
+ this.native(() => this.state.tool(event));
159
+ }
160
+ /** Run lifecycle from the actor: compact activity is sampled from native state meanwhile. */
161
+ working(running: boolean): void {
162
+ this.native(() => {
163
+ this.running = running;
164
+ this.state.activityVersion++;
165
+ });
166
+ }
167
+ private busy(): boolean {
168
+ const { session } = this;
169
+ return this.running || session.isStreaming || session.isCompacting || session.isRetrying;
170
+ }
171
+ /** A live viewer keeps its actor resident; parking would end the native stream it reads. */
172
+ get watched(): boolean {
173
+ return this.viewers.size + this.greeting.size > 0;
174
+ }
175
+ private details(): number {
176
+ return [...this.viewers].filter((viewer) => viewer.level === "detail").length;
177
+ }
178
+ /** Token-rate native events are subscribed only while a validated detail viewer reads them. */
179
+ private observe(on: boolean): void {
180
+ this.unsubscribe?.();
181
+ this.unsubscribe = undefined;
182
+ clearTimeout(this.timer);
183
+ this.timer = undefined;
184
+ this.state.observe(on);
185
+ if (on) {
186
+ this.stats.subscriptions++;
187
+ this.unsubscribe = this.session.subscribe(this.listener);
188
+ }
189
+ }
190
+ private readonly accept = (socket: net.Socket): void => {
191
+ socket.on("error", () => {
192
+ socket.destroy();
193
+ });
194
+ if (this.closed || this.viewers.size + this.greeting.size >= LIMITS.detail + LIMITS.activity) {
195
+ this.stats.refused++;
196
+ socket.destroy();
197
+ return;
198
+ }
199
+ this.greeting.add(socket);
200
+ const timeout = setTimeout(() => {
201
+ socket.destroy();
202
+ }, HELLO_MS);
203
+ timeout.unref();
204
+ let viewer: Viewer | undefined;
205
+ let greeted = false;
206
+ const reader = createMessageReader(
207
+ (message) => {
208
+ clearTimeout(timeout);
209
+ // Observation is push-only: anything after the hello closes this viewer.
210
+ viewer = greeted ? viewer : this.hello(socket, message);
211
+ if (greeted || viewer === undefined) {
212
+ socket.destroy();
213
+ }
214
+ greeted = true;
215
+ },
216
+ () => {
217
+ socket.destroy();
218
+ },
219
+ HELLO_BYTES,
220
+ );
221
+ socket.on("data", reader);
222
+ socket.on("close", () => {
223
+ clearTimeout(timeout);
224
+ this.greeting.delete(socket);
225
+ const detail = viewer?.level === "detail";
226
+ if (viewer && this.viewers.delete(viewer) && detail && this.details() === 0 && !this.closed) {
227
+ this.observe(false);
228
+ this.schedule();
229
+ }
230
+ });
231
+ };
232
+ private hello(socket: net.Socket, message: unknown): Viewer | undefined {
233
+ this.greeting.delete(socket);
234
+ const level = this.helloLevel(message);
235
+ const count = [...this.viewers].filter((viewer) => viewer.level === level).length;
236
+ if (level === undefined || this.closed || count >= LIMITS[level]) {
237
+ this.stats.refused++;
238
+ return undefined;
239
+ }
240
+ const viewer = new Viewer(socket, level);
241
+ socket.on("drain", () => {
242
+ viewer.blocked = false;
243
+ this.stats.drains++;
244
+ this.schedule();
245
+ });
246
+ this.viewers.add(viewer);
247
+ this.stats.viewers++;
248
+ try {
249
+ if (level === "detail" && this.details() === 1) {
250
+ this.observe(true);
251
+ }
252
+ this.send(viewer, this.snapshot(viewer));
253
+ this.schedule();
254
+ } catch (error) {
255
+ this.fail(error);
256
+ }
257
+ return viewer;
258
+ }
259
+ private helloLevel(message: unknown): Level | undefined {
260
+ if (
261
+ !isRecord(message) ||
262
+ message.t !== "hello" ||
263
+ message.v !== 1 ||
264
+ !sameSecret(message.token, this.identity.token) ||
265
+ message.rootId !== this.identity.rootId ||
266
+ message.sessionId !== this.identity.sessionId
267
+ ) {
268
+ return undefined;
269
+ }
270
+ return message.level === "detail" || message.level === "activity" ? message.level : undefined;
271
+ }
272
+ /** Published entries are found first, so the leaf covers everything no longer live. */
273
+ private snapshot(viewer: Viewer): string {
274
+ this.state.resolve();
275
+ const { token, rootId, sessionId } = this.identity;
276
+ const head = JSON.stringify({ t: "snapshot", v: 1, generation: token, rootId, sessionId });
277
+ const leafId = JSON.stringify(this.session.sessionManager.getLeafId());
278
+ const activity = this.state.activity(this.running);
279
+ return viewer.snapshot(this.state, `${head.slice(0, -1)},"leafId":${leafId}`, activity);
280
+ }
281
+ private contentWork(): boolean {
282
+ return [...this.viewers].some((viewer) => viewer.wants(this.state));
283
+ }
284
+ /** Sampled once a second while native work runs; otherwise only after a sparse change. */
285
+ private activityWork(): boolean {
286
+ const version = this.state.activityVersion;
287
+ return (
288
+ this.busy() ||
289
+ [...this.viewers].some((viewer) => !viewer.blocked && viewer.activitySeen !== version)
290
+ );
291
+ }
292
+ /** One timer, armed only while a connected viewer has unsent changes or native work runs. */
293
+ private schedule(): void {
294
+ // An armed timer already covers anything due no earlier than the next content slot.
295
+ const armed = this.timer === undefined ? Infinity : this.dueAt;
296
+ if (armed <= this.lastContent + CONTENT_MS || this.closed || this.viewers.size === 0) {
297
+ return;
298
+ }
299
+ const content = this.contentWork() ? this.lastContent + CONTENT_MS : Infinity;
300
+ const activity = this.activityWork() ? this.lastActivity + ACTIVITY_MS : Infinity;
301
+ const due = Math.min(content, activity);
302
+ if (due < armed) {
303
+ clearTimeout(this.timer);
304
+ this.dueAt = due;
305
+ this.timer = setTimeout(this.flush, Math.max(0, due - performance.now()));
306
+ this.timer.unref();
307
+ }
308
+ }
309
+ private readonly flush = (): void => {
310
+ const started = performance.now();
311
+ // Node's cached loop clock can fire a timer just before performance.now() reaches it.
312
+ const now = Math.max(started, this.dueAt);
313
+ try {
314
+ // Retry unresolved publication without a timer of its own.
315
+ this.state.resolve();
316
+ if (now >= this.lastContent + CONTENT_MS && this.contentWork()) {
317
+ this.lastContent = now;
318
+ this.broadcastContent();
319
+ }
320
+ if (now >= this.lastActivity + ACTIVITY_MS && this.activityWork()) {
321
+ this.lastActivity = now;
322
+ this.broadcastActivity();
323
+ }
324
+ const elapsed = performance.now() - started;
325
+ this.stats.flushes++;
326
+ this.stats.flushMs += elapsed;
327
+ this.stats.slowestFlushMs = Math.max(this.stats.slowestFlushMs, elapsed);
328
+ this.timer = undefined;
329
+ this.schedule();
330
+ } catch (error) {
331
+ this.timer = undefined;
332
+ this.fail(error);
333
+ }
334
+ };
335
+ private broadcastContent(): void {
336
+ for (const viewer of this.viewers) {
337
+ if (viewer.wants(this.state) && viewer.stale(this.state)) {
338
+ this.stats.resyncs++;
339
+ this.send(viewer, this.snapshot(viewer));
340
+ }
341
+ const delta = viewer.wants(this.state) ? viewer.delta(this.state) : undefined;
342
+ if (delta !== undefined) {
343
+ this.send(viewer, delta);
344
+ }
345
+ }
346
+ }
347
+ private broadcastActivity(): void {
348
+ const activity = this.state.activity(this.running);
349
+ for (const viewer of this.viewers) {
350
+ const frame = viewer.activityFrame(this.state, activity);
351
+ if (frame !== undefined) {
352
+ this.send(viewer, frame);
353
+ }
354
+ }
355
+ }
356
+ private send(viewer: Viewer, json: string): void {
357
+ const bytes = Buffer.byteLength(json) + 4;
358
+ const buffered = viewer.write(json);
359
+ this.stats.frames++;
360
+ this.stats.bytes += bytes;
361
+ this.stats.largestFrame = Math.max(this.stats.largestFrame, bytes);
362
+ this.stats.largestBuffered = Math.max(this.stats.largestBuffered, buffered);
363
+ this.stats.backpressured += viewer.blocked ? 1 : 0;
364
+ }
365
+ private fail(error: unknown): void {
366
+ this.stats.error ??= errorMessage(error);
367
+ if (!this.closed) {
368
+ this.closed = true;
369
+ console.error(`Child observation disabled: ${this.stats.error}`);
370
+ // Teardown leaves the native callback that observed the fault.
371
+ setImmediate(() => this.release());
372
+ }
373
+ }
374
+ /** Observation-only teardown; its own failures are recorded, never thrown into the actor. */
375
+ private release(): void {
376
+ this.unsubscribe?.();
377
+ this.unsubscribe = undefined;
378
+ clearTimeout(this.timer);
379
+ this.timer = undefined;
380
+ this.state.dispose();
381
+ // The endpoint goes first: a disconnected viewer proves removal was already attempted.
382
+ const socketPath = this.socketPath;
383
+ this.socketPath = undefined;
384
+ try {
385
+ if (
386
+ socketPath !== undefined &&
387
+ fs.lstatSync(socketPath, { throwIfNoEntry: false })?.ino === this.inode
388
+ ) {
389
+ fs.unlinkSync(socketPath);
390
+ }
391
+ } catch (error) {
392
+ this.stats.error ??= `Observation endpoint removal failed: ${errorMessage(error)}`;
393
+ }
394
+ this.server?.close();
395
+ this.server = undefined;
396
+ for (const socket of [...this.greeting, ...[...this.viewers].map((viewer) => viewer.socket)]) {
397
+ socket.destroy();
398
+ }
399
+ }
400
+ /** Idempotent. Releases observation only; never stops or signals the actor's work. */
401
+ close(): ObservationStats {
402
+ this.closed = true;
403
+ this.release();
404
+ const { events, toolEvents, progressUpdates, projections, unavailable, settled, evicted } =
405
+ this.state;
406
+ const counts = { events, toolEvents, progressUpdates, projections, unavailable };
407
+ return { ...this.stats, ...counts, settled, evicted };
408
+ }
409
+ }