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