@fitchmultz/pi-subagents 0.50.0 → 0.51.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 +51 -2
- package/README.md +36 -5
- package/dist/agent/contracts.js +6 -24
- package/dist/agent/host.js +3 -16
- package/dist/agent/index.js +207 -173
- 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 +346 -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/process.js +198 -25
- package/dist/agent/receipts.js +92 -20
- package/dist/agent/runner.js +125 -20
- package/dist/agent/shell.js +2 -1
- package/dist/agent/storage.js +29 -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 +27 -21
- package/dist/pi-intercom/broker/client-last.d.ts +13 -0
- package/dist/pi-intercom/broker/client-last.js +17 -5
- package/dist/pi-intercom/broker/client.d.ts +60 -0
- package/dist/pi-intercom/broker/client.js +47 -16
- 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 +26 -0
- package/dist/pi-intercom/broker/last.js +57 -32
- 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 +19 -0
- 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 +10 -2
- 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 +12 -38
- package/dist/pi-intercom/index.js +3 -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/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 +2 -0
- package/docs/development.md +40 -1
- package/docs/intercom-bridge.md +9 -4
- package/docs/intercom.md +226 -6
- package/docs/performance.md +87 -11
- package/docs/reference.md +157 -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/contracts.ts +8 -34
- package/src/agent/host.ts +19 -20
- package/src/agent/index.ts +224 -190
- 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 +405 -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/process.ts +240 -34
- package/src/agent/receipts.ts +114 -24
- package/src/agent/runner.ts +125 -27
- package/src/agent/shell.ts +2 -1
- package/src/agent/storage.ts +27 -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 +27 -23
- package/src/pi-intercom/broker/client-last.ts +25 -12
- package/src/pi-intercom/broker/client.ts +55 -33
- 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 +86 -33
- 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 +25 -0
- package/src/pi-intercom/connection.ts +21 -10
- 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 +18 -49
- package/src/pi-intercom/index.ts +3 -0
- package/src/pi-intercom/last-message.ts +10 -19
- 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/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
package/src/agent/view.ts
CHANGED
|
@@ -2,16 +2,16 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { readOutputPage } from "../shared/output-page.ts";
|
|
5
|
-
import { actor, alive } from "./process.ts";
|
|
5
|
+
import { actor, alive, observeAlive } from "./process.ts";
|
|
6
6
|
import {
|
|
7
7
|
admissionSchema,
|
|
8
8
|
commandSchema,
|
|
9
9
|
exitSchema,
|
|
10
10
|
successorSchema,
|
|
11
11
|
type Launch,
|
|
12
|
-
type RunsInput,
|
|
13
12
|
type Result,
|
|
14
13
|
} from "./contracts.ts";
|
|
14
|
+
import type { RunsInput } from "./tool-schemas.ts";
|
|
15
15
|
import { conversation, read, runDirectory } from "./storage.ts";
|
|
16
16
|
import { published } from "./notifications.ts";
|
|
17
17
|
import { readCanonicalResult, readResult } from "./usage.ts";
|
|
@@ -56,16 +56,37 @@ interface RunView extends Omit<RunSummary, "result"> {
|
|
|
56
56
|
result?: Result;
|
|
57
57
|
output: ReturnType<typeof outputView>;
|
|
58
58
|
}
|
|
59
|
-
|
|
59
|
+
type Receipt =
|
|
60
|
+
| { readonly receipt: "launch"; readonly note: string }
|
|
61
|
+
| { readonly receipt: "continuation" | "existing-successor" }
|
|
62
|
+
| { readonly receipt: "guidance"; readonly command: string; readonly note: string }
|
|
63
|
+
| { readonly stopRequest?: string; readonly stopAcknowledged: false; readonly note: string };
|
|
64
|
+
|
|
65
|
+
function toolResult(value: unknown, text: string): ToolReply {
|
|
60
66
|
return { content: [{ type: "text" as const, text }], details: value };
|
|
61
67
|
}
|
|
62
|
-
function
|
|
68
|
+
function receiptResult(result: Result | undefined) {
|
|
69
|
+
return result === undefined
|
|
70
|
+
? undefined
|
|
71
|
+
: {
|
|
72
|
+
id: result.id,
|
|
73
|
+
owner: result.owner,
|
|
74
|
+
status: result.status,
|
|
75
|
+
endedAt: result.endedAt,
|
|
76
|
+
terminalEntryId: result.terminalEntryId,
|
|
77
|
+
error: result.error,
|
|
78
|
+
accountingError: result.accountingError,
|
|
79
|
+
accountingPending: result.accountingPending,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** `observed` holds batched liveness by actor token; any other actor gets a fresh check. */
|
|
83
|
+
function processView(base: string, launch: Launch, observed?: ReadonlyMap<string, boolean>) {
|
|
63
84
|
const identity = actor(base, launch);
|
|
64
85
|
const exit = identity
|
|
65
86
|
? read(path.join(conversation(base, launch), `exit-${identity.token}.json`), exitSchema)
|
|
66
87
|
: undefined;
|
|
67
88
|
return {
|
|
68
|
-
live: identity !== undefined && alive(identity),
|
|
89
|
+
live: identity !== undefined && (observed?.get(identity.token) ?? alive(identity)),
|
|
69
90
|
exit: exit ?? null,
|
|
70
91
|
exitKnown: exit !== undefined,
|
|
71
92
|
};
|
|
@@ -120,10 +141,14 @@ function metadata(
|
|
|
120
141
|
base: string,
|
|
121
142
|
launch: Launch,
|
|
122
143
|
result: Result | undefined,
|
|
123
|
-
options: {
|
|
144
|
+
options: {
|
|
145
|
+
ctx?: ExtensionContext;
|
|
146
|
+
guidance: Guidance[];
|
|
147
|
+
observed?: ReadonlyMap<string, boolean>;
|
|
148
|
+
},
|
|
124
149
|
): Omit<RunView, "output"> {
|
|
125
150
|
const directory = runDirectory(base, launch.id);
|
|
126
|
-
const process = processView(base, launch);
|
|
151
|
+
const process = processView(base, launch, options.observed);
|
|
127
152
|
const outputFile = path.join(directory, "output.txt");
|
|
128
153
|
const admission = read(
|
|
129
154
|
path.join(conversation(base, launch), "admissions", `${launch.id}.json`),
|
|
@@ -156,16 +181,22 @@ function metadata(
|
|
|
156
181
|
guidance: options.guidance,
|
|
157
182
|
};
|
|
158
183
|
}
|
|
159
|
-
|
|
184
|
+
async function summaries(
|
|
160
185
|
base: string,
|
|
161
186
|
launches: readonly Launch[],
|
|
162
187
|
ctx?: ExtensionContext,
|
|
163
|
-
): RunSummary[] {
|
|
188
|
+
): Promise<RunSummary[]> {
|
|
164
189
|
const guidance = guidanceView(base, launches);
|
|
190
|
+
const identities = [
|
|
191
|
+
...new Map(launches.map((launch) => [launch.rootId, actor(base, launch)])).values(),
|
|
192
|
+
].filter((identity) => identity !== undefined);
|
|
193
|
+
const verdicts = await observeAlive(identities);
|
|
194
|
+
const observed = new Map(identities.map((identity, index) => [identity.token, verdicts[index]]));
|
|
165
195
|
return launches.map((launch) => {
|
|
166
196
|
const view = metadata(base, launch, readCanonicalResult(base, launch), {
|
|
167
197
|
ctx,
|
|
168
198
|
guidance: guidance.get(launch.id) ?? [],
|
|
199
|
+
observed,
|
|
169
200
|
});
|
|
170
201
|
return {
|
|
171
202
|
...view,
|
|
@@ -180,7 +211,7 @@ export function summaries(
|
|
|
180
211
|
};
|
|
181
212
|
});
|
|
182
213
|
}
|
|
183
|
-
|
|
214
|
+
function projection(
|
|
184
215
|
base: string,
|
|
185
216
|
launch: Launch,
|
|
186
217
|
ctx?: ExtensionContext,
|
|
@@ -195,6 +226,32 @@ export function projection(
|
|
|
195
226
|
output: outputView(view.outputFile, input),
|
|
196
227
|
};
|
|
197
228
|
}
|
|
229
|
+
export function renderReceipt(
|
|
230
|
+
base: string,
|
|
231
|
+
launch: Launch,
|
|
232
|
+
ctx: ExtensionContext | undefined,
|
|
233
|
+
receipt: Receipt,
|
|
234
|
+
): ToolReply {
|
|
235
|
+
const details = { ...projection(base, launch, ctx), ...receipt };
|
|
236
|
+
const model = { ...details, result: receiptResult(details.result) };
|
|
237
|
+
return toolResult(details, JSON.stringify(model, null, 2));
|
|
238
|
+
}
|
|
239
|
+
export async function renderInventory(
|
|
240
|
+
base: string,
|
|
241
|
+
launches: readonly Launch[],
|
|
242
|
+
ctx: ExtensionContext | undefined,
|
|
243
|
+
input: Pick<RunsInput, "offset">,
|
|
244
|
+
): Promise<ToolReply> {
|
|
245
|
+
const offset = input.offset ?? 0;
|
|
246
|
+
const runs = await summaries(base, launches.slice(offset, offset + 50), ctx);
|
|
247
|
+
const details = {
|
|
248
|
+
runs,
|
|
249
|
+
offset,
|
|
250
|
+
nextOffset: offset + runs.length < launches.length ? offset + runs.length : undefined,
|
|
251
|
+
total: launches.length,
|
|
252
|
+
};
|
|
253
|
+
return toolResult(details, JSON.stringify(details, null, 2));
|
|
254
|
+
}
|
|
198
255
|
function receiptText(details: RunView): string {
|
|
199
256
|
return details.nativeReceipt !== null
|
|
200
257
|
? `Native completion receipt: published (${details.nativeReceipt}); publication does not prove model consumption.`
|
|
@@ -225,20 +282,13 @@ function resultText(details: RunView): string {
|
|
|
225
282
|
`Native journal: ${details.sessionFile}`,
|
|
226
283
|
].join("\n");
|
|
227
284
|
}
|
|
228
|
-
export function
|
|
285
|
+
export function renderInspection(
|
|
229
286
|
base: string,
|
|
230
287
|
launch: Launch,
|
|
231
288
|
ctx?: ExtensionContext,
|
|
232
|
-
input: Pick<RunsInput, "offset" | "length">
|
|
289
|
+
input: Pick<RunsInput, "offset" | "length"> = {},
|
|
233
290
|
): ToolReply {
|
|
234
|
-
const
|
|
235
|
-
const details = {
|
|
236
|
-
...view,
|
|
237
|
-
receivedResult: input.receivedResult === true,
|
|
238
|
-
...(input.receivedResult === true && view.result?.contributions !== undefined
|
|
239
|
-
? { agentUsage: view.result.contributions }
|
|
240
|
-
: {}),
|
|
241
|
-
};
|
|
291
|
+
const details = projection(base, launch, ctx, input);
|
|
242
292
|
return {
|
|
243
293
|
...toolResult(details, resultText(details)),
|
|
244
294
|
isError: details.result?.status === "failed",
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { journalStamp } from "../shared/native-journal.ts";
|
|
3
|
+
import { missing } from "./storage.ts";
|
|
4
|
+
|
|
5
|
+
/** Identity and change stamp of a path; "" when absent. */
|
|
6
|
+
export function pathStamp(file: string): string {
|
|
7
|
+
try {
|
|
8
|
+
const stat = fs.statSync(file, { bigint: true, throwIfNoEntry: false });
|
|
9
|
+
return stat === undefined ? "" : journalStamp(stat);
|
|
10
|
+
} catch {
|
|
11
|
+
return "unreadable";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `armed`: hints start now, so reconcile anything published earlier.
|
|
17
|
+
* `changed`: the directory's own entries changed since the previous check, which also
|
|
18
|
+
* covers hints the OS dropped.
|
|
19
|
+
*/
|
|
20
|
+
export type WatchState = "unwatched" | "armed" | "changed" | "watching";
|
|
21
|
+
|
|
22
|
+
/** Native change hints for one directory identity; owners still reconcile canonical files. */
|
|
23
|
+
export class DirectoryWatch {
|
|
24
|
+
private watcher?: fs.FSWatcher;
|
|
25
|
+
private armed = "";
|
|
26
|
+
private checked = "";
|
|
27
|
+
private failed = false;
|
|
28
|
+
private readonly directory: string;
|
|
29
|
+
private readonly relevant: (file: string | null) => boolean;
|
|
30
|
+
private readonly wake: () => void;
|
|
31
|
+
constructor(directory: string, relevant: (file: string | null) => boolean, wake: () => void) {
|
|
32
|
+
this.directory = directory;
|
|
33
|
+
this.relevant = relevant;
|
|
34
|
+
this.wake = wake;
|
|
35
|
+
}
|
|
36
|
+
ensure(): WatchState {
|
|
37
|
+
const stat = fs.statSync(this.directory, { bigint: true, throwIfNoEntry: false });
|
|
38
|
+
const identity = stat?.isDirectory() === true ? `${stat.dev}:${stat.ino}` : "";
|
|
39
|
+
const checked = this.checked;
|
|
40
|
+
this.checked = stat === undefined ? "" : `${identity}:${stat.mtimeNs}:${stat.ctimeNs}`;
|
|
41
|
+
if (this.watcher !== undefined && identity === this.armed) {
|
|
42
|
+
return checked === this.checked ? "watching" : "changed";
|
|
43
|
+
}
|
|
44
|
+
// A replaced directory leaves an inode watch silent on Linux.
|
|
45
|
+
this.close();
|
|
46
|
+
return identity === "" || this.failed ? "unwatched" : this.arm(identity);
|
|
47
|
+
}
|
|
48
|
+
private arm(identity: string): WatchState {
|
|
49
|
+
try {
|
|
50
|
+
const watcher = fs.watch(this.directory, { persistent: false }, (_event, file) => {
|
|
51
|
+
if (watcher === this.watcher && this.relevant(file)) {
|
|
52
|
+
this.wake();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
watcher.on("error", () => {
|
|
56
|
+
if (watcher === this.watcher) {
|
|
57
|
+
this.failed = true;
|
|
58
|
+
this.close();
|
|
59
|
+
this.wake();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
this.watcher = watcher;
|
|
63
|
+
this.armed = identity;
|
|
64
|
+
return "armed";
|
|
65
|
+
} catch (error) {
|
|
66
|
+
// Unsupported platforms and watch limits keep their owner's polling fallback.
|
|
67
|
+
this.failed = !missing(error);
|
|
68
|
+
return "unwatched";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
close(): void {
|
|
72
|
+
this.watcher?.close();
|
|
73
|
+
this.watcher = undefined;
|
|
74
|
+
this.armed = "";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A coalesced wake for one owner loop; raised until that loop takes it. */
|
|
79
|
+
export class Wake {
|
|
80
|
+
private raised = true;
|
|
81
|
+
private resolve?: () => void;
|
|
82
|
+
readonly raise = (): void => {
|
|
83
|
+
this.raised = true;
|
|
84
|
+
this.resolve?.();
|
|
85
|
+
};
|
|
86
|
+
take(): boolean {
|
|
87
|
+
const raised = this.raised;
|
|
88
|
+
this.raised = false;
|
|
89
|
+
return raised;
|
|
90
|
+
}
|
|
91
|
+
/** Returns at once for a wake raised since the last take. */
|
|
92
|
+
async sleep(ms: number): Promise<void> {
|
|
93
|
+
if (this.raised) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const { promise, resolve } = Promise.withResolvers<"woken" | "elapsed">();
|
|
97
|
+
const timer = setTimeout(() => {
|
|
98
|
+
resolve("elapsed");
|
|
99
|
+
}, ms);
|
|
100
|
+
this.resolve = () => {
|
|
101
|
+
resolve("woken");
|
|
102
|
+
};
|
|
103
|
+
await promise;
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
this.resolve = undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
IntercomClient as BrokerClient,
|
|
3
|
+
type IntercomClientOptions as BrokerOptions,
|
|
4
|
+
} from "./pi-intercom/broker/client.ts";
|
|
5
|
+
import {
|
|
6
|
+
isDeliveryGuard,
|
|
7
|
+
type DeliveryGuard,
|
|
8
|
+
type LastMessageQuery,
|
|
9
|
+
type LastMessageResult,
|
|
10
|
+
type ReceiptQuery,
|
|
11
|
+
type ReceiptResult,
|
|
12
|
+
type SendOptions,
|
|
13
|
+
type SendResult,
|
|
14
|
+
type SessionInfo,
|
|
15
|
+
} from "./pi-intercom/types.ts";
|
|
16
|
+
import { isRecord } from "./shared/unknown.ts";
|
|
17
|
+
|
|
18
|
+
export type {
|
|
19
|
+
ContextTarget,
|
|
20
|
+
DeliveryGuard,
|
|
21
|
+
LastMessageQuery,
|
|
22
|
+
LastMessageResult,
|
|
23
|
+
MessageDelivery,
|
|
24
|
+
NativeReceipt,
|
|
25
|
+
PublicationReplyHint,
|
|
26
|
+
ReceiptQuery,
|
|
27
|
+
ReceiptResult,
|
|
28
|
+
SendResult,
|
|
29
|
+
SessionInfo,
|
|
30
|
+
} from "./pi-intercom/types.ts";
|
|
31
|
+
|
|
32
|
+
export type GuardedSendOptions = Omit<
|
|
33
|
+
SendOptions,
|
|
34
|
+
"messageId" | "guard" | "expectsReply" | "replyTo" | "human" | "topic" | "passive" | "delivery"
|
|
35
|
+
> & {
|
|
36
|
+
readonly messageId: string;
|
|
37
|
+
readonly guard: DeliveryGuard;
|
|
38
|
+
readonly expectsReply: false;
|
|
39
|
+
readonly delivery: "queue" | "steer" | "passive";
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export interface IntercomClientOptions extends BrokerOptions {
|
|
43
|
+
readonly spawn: false;
|
|
44
|
+
readonly session: Omit<
|
|
45
|
+
SessionInfo,
|
|
46
|
+
"id" | "guardedDelivery" | "applicationGuard" | "topics" | "subscriptions"
|
|
47
|
+
>;
|
|
48
|
+
readonly requestedId?: string;
|
|
49
|
+
readonly onError?: (error: Readonly<Error>) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface IntercomClient {
|
|
53
|
+
readonly sessionId: string | null;
|
|
54
|
+
readonly isConnected: () => boolean;
|
|
55
|
+
readonly listSessions: () => Promise<readonly SessionInfo[]>;
|
|
56
|
+
readonly send: (to: string, options: GuardedSendOptions) => Promise<SendResult>;
|
|
57
|
+
readonly last: (to: string, query?: LastMessageQuery) => Promise<LastMessageResult>;
|
|
58
|
+
readonly receipt: (to: string, query: ReceiptQuery) => Promise<ReceiptResult>;
|
|
59
|
+
readonly disconnect: () => Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function requireNoSpawn(value: unknown): void {
|
|
63
|
+
if (value !== false) {
|
|
64
|
+
throw new Error("The supported Intercom client requires spawn:false.");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function guardedSend(to: string, input: unknown): boolean {
|
|
69
|
+
return (
|
|
70
|
+
isRecord(input) &&
|
|
71
|
+
isDeliveryGuard(input.guard) &&
|
|
72
|
+
to === input.guard.peerId &&
|
|
73
|
+
typeof input.messageId === "string" &&
|
|
74
|
+
input.expectsReply === false &&
|
|
75
|
+
input.delivery !== undefined
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Connect to an existing native broker. No Pi runtime, broker spawn or automatic retry. */
|
|
80
|
+
export async function connectIntercom(options: IntercomClientOptions): Promise<IntercomClient> {
|
|
81
|
+
requireNoSpawn(options.spawn);
|
|
82
|
+
const client = new BrokerClient(options);
|
|
83
|
+
client.on("error", (error: Readonly<Error>) => options.onError?.(error));
|
|
84
|
+
try {
|
|
85
|
+
await client.connect(options.session, options.requestedId);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
await client.disconnect();
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
return Object.freeze({
|
|
91
|
+
get sessionId() {
|
|
92
|
+
return client.sessionId;
|
|
93
|
+
},
|
|
94
|
+
isConnected: () => client.isConnected(),
|
|
95
|
+
listSessions: () => client.listSessions(),
|
|
96
|
+
async send(to: string, input: GuardedSendOptions) {
|
|
97
|
+
if (!guardedSend(to, input)) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
"An exact guarded nonblocking send with an original message ID is required.",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return await client.send(to, input);
|
|
103
|
+
},
|
|
104
|
+
last: (to: string, query?: LastMessageQuery) => client.last(to, query),
|
|
105
|
+
receipt: (to: string, query: ReceiptQuery) => client.receipt(to, query),
|
|
106
|
+
disconnect: () => client.disconnect(),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -6,7 +6,7 @@ import type { Message, SendResult, SessionInfo } from "./types.ts";
|
|
|
6
6
|
import type { Identity } from "./bridge-config.ts";
|
|
7
7
|
import { Fault, cancelled, log, wait } from "./bridge-protocol.ts";
|
|
8
8
|
import { Mailbox } from "./bridge-mailbox.ts";
|
|
9
|
-
import type
|
|
9
|
+
import { hasErrorCode, type UnknownRecord } from "../shared/unknown.ts";
|
|
10
10
|
import type { Operation } from "./bridge-routes.ts";
|
|
11
11
|
import type { RequestContext } from "./bridge-http.ts";
|
|
12
12
|
import { resolveBridgeTarget, bridgeSessionList } from "./bridge-target.ts";
|
|
@@ -272,7 +272,7 @@ export class Session {
|
|
|
272
272
|
peers = await wait(this.client.listSessions(), signal);
|
|
273
273
|
} catch (error) {
|
|
274
274
|
cancelled(signal);
|
|
275
|
-
if (error
|
|
275
|
+
if (hasErrorCode(error, "list_timeout")) {
|
|
276
276
|
throw new Fault(504, "list_timeout", "Broker session list deadline exceeded.");
|
|
277
277
|
}
|
|
278
278
|
throw new Fault(503, "broker_unavailable", "Broker session list failed.");
|
|
@@ -7,11 +7,11 @@ import { getBrokerRuntimeDir, prepareBrokerSocketPath } from "./paths.ts";
|
|
|
7
7
|
import { brokerPidRecord } from "./pid.ts";
|
|
8
8
|
import { assertBrokerCanStart } from "./spawn.ts";
|
|
9
9
|
import { LastMessageRelay } from "./last.ts";
|
|
10
|
-
import {
|
|
11
|
-
import { acceptBrokerConnection } from "./server-connection.ts";
|
|
10
|
+
import { canSeeSession, findSessions } from "./targets.ts";
|
|
11
|
+
import { acceptBrokerConnection, registeredSessionInfo } from "./server-connection.ts";
|
|
12
|
+
import { guardedDeliveryError } from "./guard.ts";
|
|
12
13
|
import {
|
|
13
14
|
compactTopicMessage,
|
|
14
|
-
isSessionRegistration,
|
|
15
15
|
normalizeMessage,
|
|
16
16
|
normalizeSessionInfo,
|
|
17
17
|
type SessionInfo,
|
|
@@ -41,6 +41,7 @@ interface ConnectedSession {
|
|
|
41
41
|
topics: TopicSnapshot;
|
|
42
42
|
readonly topicFrames: boolean;
|
|
43
43
|
readonly lastMessages: boolean;
|
|
44
|
+
readonly receiptReads: boolean;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
interface PendingReplaceDelivery {
|
|
@@ -153,17 +154,8 @@ class IntercomBroker {
|
|
|
153
154
|
}
|
|
154
155
|
|
|
155
156
|
private register(socket: net.Socket, msg: UnknownRecord, setId: (id: string) => void): void {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
assertMainParent(msg.session, this.sessions);
|
|
160
|
-
const requestedId =
|
|
161
|
-
typeof msg.requestedId === "string" && /^[a-zA-Z0-9_-]{8,80}$/.test(msg.requestedId)
|
|
162
|
-
? msg.requestedId
|
|
163
|
-
: undefined;
|
|
164
|
-
const id =
|
|
165
|
-
requestedId !== undefined && !this.sessions.has(requestedId) ? requestedId : randomUUID();
|
|
166
|
-
const info: SessionInfo = { ...msg.session, id, lastSeen: msg.session.lastSeen ?? Date.now() };
|
|
157
|
+
const info = registeredSessionInfo(msg, this.sessions);
|
|
158
|
+
const id = info.id;
|
|
167
159
|
snapshotFrames(randomUUID(), [info], true);
|
|
168
160
|
const topicFrames = msg.topicFrames === true;
|
|
169
161
|
this.sessions.set(id, {
|
|
@@ -172,6 +164,7 @@ class IntercomBroker {
|
|
|
172
164
|
topics: new TopicSnapshot(info),
|
|
173
165
|
topicFrames,
|
|
174
166
|
lastMessages: msg.lastMessages === true,
|
|
167
|
+
receiptReads: msg.receiptReads === 1,
|
|
175
168
|
});
|
|
176
169
|
setId(id);
|
|
177
170
|
if (this.shutdownTimer) {
|
|
@@ -182,6 +175,8 @@ class IntercomBroker {
|
|
|
182
175
|
type: "registered",
|
|
183
176
|
sessionId: id,
|
|
184
177
|
lastMessages: true,
|
|
178
|
+
receiptReads: 1,
|
|
179
|
+
...(msg.guardedDelivery === 1 ? { guardedDelivery: 1 } : {}),
|
|
185
180
|
...(topicFrames ? { topicsSupported: true, topicFrames: true } : {}),
|
|
186
181
|
});
|
|
187
182
|
}
|
|
@@ -296,6 +291,15 @@ class IntercomBroker {
|
|
|
296
291
|
const targets = findSessions(this.sessions, msg.to, currentId);
|
|
297
292
|
const target = targets[0];
|
|
298
293
|
if (targets.length === 1) {
|
|
294
|
+
const guardError = guardedDeliveryError(this.sessions, msg.to, message);
|
|
295
|
+
if (guardError !== null) {
|
|
296
|
+
writeMessage(socket, {
|
|
297
|
+
type: "delivery_failed",
|
|
298
|
+
messageId: message.id,
|
|
299
|
+
reason: guardError,
|
|
300
|
+
});
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
299
303
|
const fromSession = this.sessions.get(currentId);
|
|
300
304
|
if (!fromSession) {
|
|
301
305
|
writeMessage(socket, {
|
|
@@ -343,11 +347,15 @@ class IntercomBroker {
|
|
|
343
347
|
"lastIntercomActivity",
|
|
344
348
|
"subscriptions",
|
|
345
349
|
"topics",
|
|
350
|
+
"applicationGuard",
|
|
346
351
|
]) {
|
|
347
352
|
if (msg[key] !== undefined) {
|
|
348
353
|
update[key] = msg[key];
|
|
349
354
|
}
|
|
350
355
|
}
|
|
356
|
+
if (msg.applicationGuard === null) {
|
|
357
|
+
delete update.applicationGuard;
|
|
358
|
+
}
|
|
351
359
|
const nextInfo = normalizeSessionInfo(update);
|
|
352
360
|
if (!nextInfo) {
|
|
353
361
|
throw new Error("Invalid presence update");
|
|
@@ -366,15 +374,7 @@ class IntercomBroker {
|
|
|
366
374
|
message: Message,
|
|
367
375
|
): SendResult {
|
|
368
376
|
const target = this.sessions.get(toId);
|
|
369
|
-
if (
|
|
370
|
-
return {
|
|
371
|
-
id: message.id,
|
|
372
|
-
accepted: false,
|
|
373
|
-
delivered: false,
|
|
374
|
-
reason: "Recipient disconnected before delivery",
|
|
375
|
-
};
|
|
376
|
-
}
|
|
377
|
-
if (shouldReplaceDelivery(message, target.info)) {
|
|
377
|
+
if (target && shouldReplaceDelivery(message, target.info)) {
|
|
378
378
|
return this.queueReplaceDelivery(fromId, toId, from, message);
|
|
379
379
|
}
|
|
380
380
|
const reason = this.deliverMessage(toId, from, message);
|
|
@@ -465,6 +465,10 @@ class IntercomBroker {
|
|
|
465
465
|
}
|
|
466
466
|
|
|
467
467
|
private deliverMessage(toId: string, from: SessionInfo, message: Message): string | null {
|
|
468
|
+
const guardError = guardedDeliveryError(this.sessions, toId, message);
|
|
469
|
+
if (guardError !== null) {
|
|
470
|
+
return guardError;
|
|
471
|
+
}
|
|
468
472
|
const target = this.sessions.get(toId);
|
|
469
473
|
if (
|
|
470
474
|
!target ||
|
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
import type net from "node:net";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { errorMessage, type UnknownRecord } from "../../shared/unknown.ts";
|
|
4
|
-
import
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
isNativeReadQuery,
|
|
6
|
+
isNativeReadResult,
|
|
7
|
+
isLastMessageResult,
|
|
8
|
+
isReceiptResult,
|
|
9
|
+
type NativeReadResult,
|
|
10
|
+
} from "./last.ts";
|
|
11
|
+
import type { LastMessageQuery, LastMessageResult, ReceiptQuery, ReceiptResult } from "../types.ts";
|
|
6
12
|
import { writeMessage } from "./framing.ts";
|
|
7
13
|
|
|
8
14
|
export class LastMessageRequests {
|
|
15
|
+
private readonly timeoutMs: number;
|
|
16
|
+
constructor(timeoutMs: number) {
|
|
17
|
+
this.timeoutMs = timeoutMs;
|
|
18
|
+
}
|
|
9
19
|
private readonly pending = new Map<
|
|
10
20
|
string,
|
|
11
21
|
{
|
|
12
|
-
readonly resolve: (result:
|
|
22
|
+
readonly resolve: (result: NativeReadResult) => void;
|
|
13
23
|
readonly reject: (error: Readonly<Error>) => void;
|
|
14
24
|
readonly timer: NodeJS.Timeout;
|
|
15
25
|
}
|
|
@@ -24,7 +34,7 @@ export class LastMessageRequests {
|
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
receive(message: UnknownRecord): void {
|
|
27
|
-
if (typeof message.requestId !== "string" || !
|
|
37
|
+
if (typeof message.requestId !== "string" || !isNativeReadResult(message.result)) {
|
|
28
38
|
throw new Error("Invalid last-message response");
|
|
29
39
|
}
|
|
30
40
|
const pending = this.pending.get(message.requestId);
|
|
@@ -36,13 +46,16 @@ export class LastMessageRequests {
|
|
|
36
46
|
pending.resolve(message.result);
|
|
37
47
|
}
|
|
38
48
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
async last(socket: net.Socket, to: string, query: LastMessageQuery): Promise<LastMessageResult> {
|
|
50
|
+
const result = await this.request(socket, to, query);
|
|
51
|
+
return isLastMessageResult(result) ? result : { error: "Invalid last-message response" };
|
|
52
|
+
}
|
|
53
|
+
async receipt(socket: net.Socket, to: string, query: ReceiptQuery): Promise<ReceiptResult> {
|
|
54
|
+
const result = await this.request(socket, to, query);
|
|
55
|
+
return isReceiptResult(result) ? result : { error: "Invalid receipt response" };
|
|
56
|
+
}
|
|
57
|
+
private request(socket: net.Socket, to: string, query: unknown): Promise<NativeReadResult> {
|
|
58
|
+
if (!isNativeReadQuery(query)) {
|
|
46
59
|
return Promise.reject(new Error("Invalid last-message query"));
|
|
47
60
|
}
|
|
48
61
|
return new Promise((resolve, reject) => {
|
|
@@ -50,7 +63,7 @@ export class LastMessageRequests {
|
|
|
50
63
|
const timer = setTimeout(() => {
|
|
51
64
|
this.pending.delete(requestId);
|
|
52
65
|
reject(new Error("Last-message read timeout"));
|
|
53
|
-
}, timeoutMs);
|
|
66
|
+
}, this.timeoutMs);
|
|
54
67
|
timer.unref();
|
|
55
68
|
this.pending.set(requestId, { resolve, reject, timer });
|
|
56
69
|
try {
|