@fitchmultz/pi-subagents 0.52.7 → 0.52.9
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 +26 -0
- package/dist/agent/launch.js +1 -1
- package/dist/agent/observation-view.js +15 -7
- package/dist/agent/observe-card.js +2 -2
- package/dist/agent/observe-client.js +1 -1
- package/dist/agent/observe.js +4 -1
- package/dist/agent/process.js +5 -3
- package/dist/agent/runner.js +9 -4
- package/dist/agent/saved.js +16 -6
- package/dist/agent/view.js +5 -2
- package/dist/build-info.js +1 -1
- package/dist/pi-intercom/bridge-routes.js +1 -1
- package/dist/pi-intercom/bridge.js +4 -3
- package/dist/pi-intercom/broker/broker.js +1 -1
- package/dist/pi-intercom/broker/client-last.d.ts +1 -1
- package/dist/pi-intercom/broker/client.d.ts +9 -9
- package/dist/pi-intercom/broker/framing.js +6 -0
- package/dist/pi-intercom/broker/last.d.ts +5 -1
- package/dist/pi-intercom/broker/last.js +33 -15
- package/dist/pi-intercom/broker/parked.js +1 -1
- package/dist/pi-intercom/outbound.js +1 -0
- package/dist/pi-intercom/reply-tracker.js +11 -5
- package/dist/pi-intercom/session-targets.d.ts +2 -2
- package/dist/pi-intercom/session-targets.js +3 -2
- package/dist/pi-intercom/tool-registration.js +1 -0
- package/dist/pi-intercom/types.d.ts +37 -37
- package/dist/pi-intercom/types.js +9 -7
- package/dist/shared/output-page.js +1 -1
- package/package.json +5 -4
- package/scripts/archive-retired-history.mjs +3 -1
- package/src/agent/index.ts +3 -3
- package/src/agent/journal-tail.ts +2 -2
- package/src/agent/launch.ts +1 -1
- package/src/agent/notifications.ts +3 -3
- package/src/agent/observation-state.ts +2 -2
- package/src/agent/observation-view.ts +20 -7
- package/src/agent/observation.ts +4 -4
- package/src/agent/observe-card.ts +7 -3
- package/src/agent/observe-chrome.ts +2 -2
- package/src/agent/observe-client.ts +5 -5
- package/src/agent/observe-reading.ts +1 -1
- package/src/agent/observe-screen.ts +4 -4
- package/src/agent/observe-transcript.ts +4 -4
- package/src/agent/observe.ts +13 -8
- package/src/agent/parking.ts +1 -1
- package/src/agent/process.ts +6 -4
- package/src/agent/runner.ts +15 -7
- package/src/agent/saved.ts +17 -7
- package/src/agent/usage.ts +1 -1
- package/src/agent/view.ts +18 -9
- package/src/agent/wake.ts +2 -2
- package/src/pi-intercom/bridge-config.ts +2 -2
- package/src/pi-intercom/bridge-mailbox.ts +1 -1
- package/src/pi-intercom/bridge-presence.ts +1 -1
- package/src/pi-intercom/bridge-routes.ts +5 -5
- package/src/pi-intercom/bridge-session.ts +5 -5
- package/src/pi-intercom/bridge-wake.ts +2 -2
- package/src/pi-intercom/bridge.ts +4 -3
- package/src/pi-intercom/broker/broker.ts +1 -1
- package/src/pi-intercom/broker/client-last.ts +1 -1
- package/src/pi-intercom/broker/client-park.ts +1 -1
- package/src/pi-intercom/broker/client.ts +9 -9
- package/src/pi-intercom/broker/framing.ts +6 -0
- package/src/pi-intercom/broker/last.ts +62 -18
- package/src/pi-intercom/broker/parked.ts +8 -5
- package/src/pi-intercom/config.ts +1 -1
- package/src/pi-intercom/outbound.ts +1 -0
- package/src/pi-intercom/reply-tracker.ts +13 -6
- package/src/pi-intercom/reply-wait.ts +3 -3
- package/src/pi-intercom/runtime-types.ts +1 -1
- package/src/pi-intercom/session-targets.ts +5 -4
- package/src/pi-intercom/tool-registration.ts +1 -0
- package/src/pi-intercom/tool-results.ts +1 -1
- package/src/pi-intercom/types.ts +48 -43
- package/src/shared/journal-frames.ts +1 -1
- package/src/shared/json-projection.ts +2 -2
- package/src/shared/native-journal.ts +2 -2
- package/src/shared/output-page.ts +3 -3
- package/src/shared/post-exit-stdio-guard.ts +4 -4
- package/tsconfig.json +8 -1
package/src/agent/saved.ts
CHANGED
|
@@ -47,10 +47,20 @@ function ownedRun(context: ExtensionContext, id: string) {
|
|
|
47
47
|
throw new Error("Legacy continuation requires a published native owner.");
|
|
48
48
|
}
|
|
49
49
|
const journal = new NativeJournal(file, "live");
|
|
50
|
-
|
|
50
|
+
const [opening] = journal.records;
|
|
51
|
+
if (opening === undefined || opening.value.id !== context.sessionManager.getSessionId()) {
|
|
51
52
|
throw new Error("Native owner journal identity changed; nothing was launched.");
|
|
52
53
|
}
|
|
53
|
-
|
|
54
|
+
const owned = findOwnedRun(journal, id);
|
|
55
|
+
if (owned?.ownerSessionId !== context.sessionManager.getSessionId()) {
|
|
56
|
+
throw new Error("Missing saved native owner or foreign legacy run; nothing was launched.");
|
|
57
|
+
}
|
|
58
|
+
Assert(singleSchema, owned);
|
|
59
|
+
return owned;
|
|
60
|
+
}
|
|
61
|
+
/** The most recent saved subagent-run record for this run id, if one exists. */
|
|
62
|
+
function findOwnedRun(journal: NativeJournal, id: string): Record<string, unknown> | undefined {
|
|
63
|
+
let owned: Record<string, unknown> | undefined;
|
|
54
64
|
for (const record of journal.records) {
|
|
55
65
|
if (record.value.type === "custom" && record.value.customType === "subagent-run") {
|
|
56
66
|
const { data } = journal.body(record);
|
|
@@ -59,10 +69,6 @@ function ownedRun(context: ExtensionContext, id: string) {
|
|
|
59
69
|
}
|
|
60
70
|
}
|
|
61
71
|
}
|
|
62
|
-
if (owned?.ownerSessionId !== context.sessionManager.getSessionId()) {
|
|
63
|
-
throw new Error("Missing saved native owner or foreign legacy run; nothing was launched.");
|
|
64
|
-
}
|
|
65
|
-
Assert(singleSchema, owned);
|
|
66
72
|
return owned;
|
|
67
73
|
}
|
|
68
74
|
function requireExited(pid: unknown): void {
|
|
@@ -101,7 +107,11 @@ function nativeSession(contract: Contract) {
|
|
|
101
107
|
throw new Error("Legacy continuation requires the exact saved native journal.");
|
|
102
108
|
}
|
|
103
109
|
const journal = new NativeJournal(contract.sessionFile, "live");
|
|
104
|
-
const
|
|
110
|
+
const [opening] = journal.records;
|
|
111
|
+
if (opening === undefined) {
|
|
112
|
+
throw new Error("Not a readable native Pi session.");
|
|
113
|
+
}
|
|
114
|
+
const sessionId = journal.body(opening).id;
|
|
105
115
|
if (
|
|
106
116
|
typeof sessionId !== "string" ||
|
|
107
117
|
sessionId.length === 0 ||
|
package/src/agent/usage.ts
CHANGED
|
@@ -74,7 +74,7 @@ function reusable(cached: Prefix | undefined, stat: fs.BigIntStats): Prefix {
|
|
|
74
74
|
* accepted LF boundary. Baselines, required usage and attribution apply to each read.
|
|
75
75
|
*/
|
|
76
76
|
export class UsageIndex {
|
|
77
|
-
private prefix?: Prefix;
|
|
77
|
+
private prefix?: Prefix | undefined;
|
|
78
78
|
read(launch: Launch, baseline: ReadonlySet<string>, terminal?: string): Contribution[] {
|
|
79
79
|
const values: unknown[] = [];
|
|
80
80
|
const reachedTerminal = new Error("Native terminal boundary reached.");
|
package/src/agent/view.ts
CHANGED
|
@@ -36,8 +36,8 @@ interface RunSummary {
|
|
|
36
36
|
id: string;
|
|
37
37
|
owner: string;
|
|
38
38
|
rootId: string;
|
|
39
|
-
predecessor?: string;
|
|
40
|
-
name?: string;
|
|
39
|
+
predecessor?: string | undefined;
|
|
40
|
+
name?: string | undefined;
|
|
41
41
|
cwd: string;
|
|
42
42
|
model: string;
|
|
43
43
|
thinking: string;
|
|
@@ -45,24 +45,28 @@ interface RunSummary {
|
|
|
45
45
|
admission: string;
|
|
46
46
|
status: string;
|
|
47
47
|
completed: boolean;
|
|
48
|
-
result?: Pick<Result, "status" | "endedAt" | "error"
|
|
48
|
+
result?: Pick<Result, "status" | "endedAt" | "error"> | undefined;
|
|
49
49
|
nativeReceipt: string | null;
|
|
50
50
|
process: ReturnType<typeof processView>;
|
|
51
|
-
lastWakeFailure?: { at: number; reason: string };
|
|
51
|
+
lastWakeFailure?: { at: number; reason: string } | undefined;
|
|
52
52
|
sessionId: string;
|
|
53
53
|
sessionFile: string;
|
|
54
54
|
outputFile: string;
|
|
55
55
|
guidance: Guidance[];
|
|
56
56
|
}
|
|
57
57
|
interface RunView extends Omit<RunSummary, "result"> {
|
|
58
|
-
result?: Result;
|
|
58
|
+
result?: Result | undefined;
|
|
59
59
|
output: ReturnType<typeof outputView>;
|
|
60
60
|
}
|
|
61
61
|
type Receipt =
|
|
62
62
|
| { readonly receipt: "launch"; readonly note: string }
|
|
63
63
|
| { readonly receipt: "continuation" | "existing-successor" }
|
|
64
64
|
| { readonly receipt: "guidance"; readonly command: string; readonly note: string }
|
|
65
|
-
| {
|
|
65
|
+
| {
|
|
66
|
+
readonly stopRequest?: string | undefined;
|
|
67
|
+
readonly stopAcknowledged: false;
|
|
68
|
+
readonly note: string;
|
|
69
|
+
};
|
|
66
70
|
|
|
67
71
|
function toolResult(value: unknown, text: string): ToolReply {
|
|
68
72
|
return { content: [{ type: "text" as const, text }], details: value };
|
|
@@ -144,7 +148,7 @@ function metadata(
|
|
|
144
148
|
launch: Launch,
|
|
145
149
|
result: Result | undefined,
|
|
146
150
|
options: {
|
|
147
|
-
ctx?: ExtensionContext;
|
|
151
|
+
ctx?: ExtensionContext | undefined;
|
|
148
152
|
guidance: Guidance[];
|
|
149
153
|
observed?: ReadonlyMap<string, boolean>;
|
|
150
154
|
},
|
|
@@ -197,7 +201,12 @@ async function summaries(
|
|
|
197
201
|
...new Map(launches.map((launch) => [launch.rootId, actor(base, launch)])).values(),
|
|
198
202
|
].filter((identity) => identity !== undefined);
|
|
199
203
|
const verdicts = await observeAlive(identities);
|
|
200
|
-
const observed = new Map(
|
|
204
|
+
const observed = new Map(
|
|
205
|
+
identities.flatMap((identity, index) => {
|
|
206
|
+
const verdict = verdicts[index];
|
|
207
|
+
return verdict === undefined ? [] : ([[identity.token, verdict]] as const);
|
|
208
|
+
}),
|
|
209
|
+
);
|
|
201
210
|
return launches.map((launch) => {
|
|
202
211
|
const view = metadata(base, launch, readCanonicalResult(base, launch), {
|
|
203
212
|
ctx,
|
|
@@ -211,7 +220,7 @@ async function summaries(
|
|
|
211
220
|
? {
|
|
212
221
|
status: view.result.status,
|
|
213
222
|
endedAt: view.result.endedAt,
|
|
214
|
-
error: view.result.error
|
|
223
|
+
...(view.result.error !== undefined ? { error: view.result.error.slice(0, 2048) } : {}),
|
|
215
224
|
}
|
|
216
225
|
: undefined,
|
|
217
226
|
};
|
package/src/agent/wake.ts
CHANGED
|
@@ -21,7 +21,7 @@ export type WatchState = "unwatched" | "armed" | "changed" | "watching";
|
|
|
21
21
|
|
|
22
22
|
/** Native change hints for one directory identity; owners still reconcile canonical files. */
|
|
23
23
|
export class DirectoryWatch {
|
|
24
|
-
private watcher?: fs.FSWatcher;
|
|
24
|
+
private watcher?: fs.FSWatcher | undefined;
|
|
25
25
|
private armed = "";
|
|
26
26
|
private checked = "";
|
|
27
27
|
private failed = false;
|
|
@@ -78,7 +78,7 @@ export class DirectoryWatch {
|
|
|
78
78
|
/** A coalesced wake for one owner loop; raised until that loop takes it. */
|
|
79
79
|
export class Wake {
|
|
80
80
|
private raised = true;
|
|
81
|
-
private resolve?: () => void;
|
|
81
|
+
private resolve?: (() => void) | undefined;
|
|
82
82
|
readonly raise = (): void => {
|
|
83
83
|
this.raised = true;
|
|
84
84
|
this.resolve?.();
|
|
@@ -39,10 +39,10 @@ export interface Identity {
|
|
|
39
39
|
readonly fingerprint256: string;
|
|
40
40
|
readonly name: string;
|
|
41
41
|
readonly cwd: string;
|
|
42
|
-
readonly wake?: WakePolicy;
|
|
42
|
+
readonly wake?: WakePolicy | undefined;
|
|
43
43
|
}
|
|
44
44
|
export interface Config {
|
|
45
|
-
readonly stateDir?: string;
|
|
45
|
+
readonly stateDir?: string | undefined;
|
|
46
46
|
readonly port: number;
|
|
47
47
|
readonly ca: string;
|
|
48
48
|
readonly cert: string;
|
|
@@ -76,7 +76,7 @@ function decode(value: unknown): Envelope {
|
|
|
76
76
|
|
|
77
77
|
/** Persistent payloads leave only ID tombstones after acknowledgement, preventing replay resurrection. */
|
|
78
78
|
export class Mailbox {
|
|
79
|
-
private readonly db?: DatabaseSync;
|
|
79
|
+
private readonly db?: DatabaseSync | undefined;
|
|
80
80
|
private readonly entries = new Map<string, Entry>();
|
|
81
81
|
private bytes = 0;
|
|
82
82
|
private sequence = 0;
|
|
@@ -22,7 +22,7 @@ export class PersistentPresence {
|
|
|
22
22
|
private readonly registrations = new Map<string, Registration>();
|
|
23
23
|
private stopped = false;
|
|
24
24
|
private timer?: NodeJS.Timeout;
|
|
25
|
-
private running?: Promise<void
|
|
25
|
+
private running?: Promise<void> | undefined;
|
|
26
26
|
private readonly owner: PresenceOwner;
|
|
27
27
|
|
|
28
28
|
constructor(owner: PresenceOwner) {
|
|
@@ -16,10 +16,10 @@ export type Operation =
|
|
|
16
16
|
readonly route: "POST /v1/send";
|
|
17
17
|
readonly to: string;
|
|
18
18
|
readonly message: string;
|
|
19
|
-
readonly passive?: boolean;
|
|
20
|
-
readonly messageId?: string;
|
|
21
|
-
readonly replyTo?: string;
|
|
22
|
-
readonly expectsReply?: boolean;
|
|
19
|
+
readonly passive?: boolean | undefined;
|
|
20
|
+
readonly messageId?: string | undefined;
|
|
21
|
+
readonly replyTo?: string | undefined;
|
|
22
|
+
readonly expectsReply?: boolean | undefined;
|
|
23
23
|
};
|
|
24
24
|
type Route = Operation["route"];
|
|
25
25
|
const fields = {
|
|
@@ -39,7 +39,7 @@ export function routeFor(req: IncomingMessage): Route {
|
|
|
39
39
|
) {
|
|
40
40
|
throw new Fault(403, "browser_forbidden", "Browser access is not supported.");
|
|
41
41
|
}
|
|
42
|
-
const route = `${req.method ?? ""} ${(req.url ?? "").split("?")[0]}`;
|
|
42
|
+
const route = `${req.method ?? ""} ${(req.url ?? "").split("?")[0] ?? ""}`;
|
|
43
43
|
if (!isRoute(route)) {
|
|
44
44
|
throw new Fault(404, "not_found", "Unknown method or path.");
|
|
45
45
|
}
|
|
@@ -16,12 +16,12 @@ const SEND_TIMEOUT = 8000;
|
|
|
16
16
|
const LIST_TIMEOUT = 5000;
|
|
17
17
|
|
|
18
18
|
interface SendOptions extends Pick<Message, "replyTo" | "expectsReply" | "delivery"> {
|
|
19
|
-
readonly messageId?: string;
|
|
19
|
+
readonly messageId?: string | undefined;
|
|
20
20
|
}
|
|
21
21
|
export interface SessionOwner {
|
|
22
|
-
readonly stateDir?: string;
|
|
23
|
-
readonly notifications?: boolean;
|
|
24
|
-
readonly mailboxChanged?: () => void;
|
|
22
|
+
readonly stateDir?: string | undefined;
|
|
23
|
+
readonly notifications?: boolean | undefined;
|
|
24
|
+
readonly mailboxChanged?: (() => void) | undefined;
|
|
25
25
|
readonly allowed: (identity: Identity) => boolean;
|
|
26
26
|
readonly lost: (fp: string, count?: number) => void;
|
|
27
27
|
readonly retired: (fp: string, generation: string) => void;
|
|
@@ -42,7 +42,7 @@ export class Session {
|
|
|
42
42
|
private inboxChanged = new AbortController();
|
|
43
43
|
private closed = false;
|
|
44
44
|
private connecting?: Promise<void>;
|
|
45
|
-
private disconnecting?: Promise<void
|
|
45
|
+
private disconnecting?: Promise<void> | undefined;
|
|
46
46
|
private retirement?: Promise<void>;
|
|
47
47
|
private lease?: NodeJS.Timeout;
|
|
48
48
|
private closeReason: Fault = new Fault(503, "broker_unavailable", "Broker connection failed.");
|
|
@@ -35,12 +35,12 @@ function bucket(identity: Identity): string {
|
|
|
35
35
|
/** Mailbox commits and durable retry/quota deadlines drive wakes; there is no intake scan timer. */
|
|
36
36
|
export class WakeDeliveries {
|
|
37
37
|
private readonly stateDir: string | undefined;
|
|
38
|
-
private readonly state?: WakeState;
|
|
38
|
+
private readonly state?: WakeState | undefined;
|
|
39
39
|
private readonly allowed: (identity: Identity) => boolean;
|
|
40
40
|
private readonly deliveries = new Map<string, Delivery>();
|
|
41
41
|
private readonly controller = new AbortController();
|
|
42
42
|
private timer?: NodeJS.Timeout;
|
|
43
|
-
private running?: Promise<void
|
|
43
|
+
private running?: Promise<void> | undefined;
|
|
44
44
|
private dirty = false;
|
|
45
45
|
private storageBackoff = 15000;
|
|
46
46
|
|
|
@@ -417,19 +417,20 @@ class Bridge {
|
|
|
417
417
|
|
|
418
418
|
function main(): void {
|
|
419
419
|
const args = process.argv.slice(2);
|
|
420
|
-
|
|
420
|
+
const [flag, configPath] = args;
|
|
421
|
+
if (args.length === 1 && (flag === "-h" || flag === "--help")) {
|
|
421
422
|
process.stdout.write(
|
|
422
423
|
"Usage: node dist/pi-intercom/bridge.js --config PATH\n\nOptional loopback HTTPS/mTLS bridge for the local pi-intercom broker.\nConfig and TLS key must be owned by you with mode 0600.\nWithout stateDir, messaging never starts the broker. Durable presence safely starts\none only when none is running; it never restarts or replaces a live broker.\nSIGHUP reloads the client allowlist (invalid reload denies all clients).\nRestart this helper for TLS/listener rotation. SIGINT/SIGTERM disconnect peers.\n\nExample: node dist/pi-intercom/bridge.js --config ~/.pi/agent/intercom/bridge.json\n",
|
|
423
424
|
);
|
|
424
425
|
return;
|
|
425
426
|
}
|
|
426
|
-
if (args.length !== 2 ||
|
|
427
|
+
if (args.length !== 2 || flag !== "--config" || configPath === undefined || configPath === "") {
|
|
427
428
|
process.stderr.write("Usage: node dist/pi-intercom/bridge.js --config PATH (or --help)\n");
|
|
428
429
|
process.exitCode = 2;
|
|
429
430
|
return;
|
|
430
431
|
}
|
|
431
432
|
try {
|
|
432
|
-
const path = resolve(
|
|
433
|
+
const path = resolve(configPath);
|
|
433
434
|
new Bridge(loadConfig(path), path).start();
|
|
434
435
|
} catch (error) {
|
|
435
436
|
if (error instanceof Fault) {
|
|
@@ -303,7 +303,7 @@ class IntercomBroker {
|
|
|
303
303
|
}
|
|
304
304
|
const targets = findSessions(this.sessions, msg.to, currentId);
|
|
305
305
|
const target = targets[0];
|
|
306
|
-
if (targets.length === 1) {
|
|
306
|
+
if (targets.length === 1 && target !== undefined) {
|
|
307
307
|
const guardError = guardedDeliveryError(this.sessions, msg.to, message);
|
|
308
308
|
if (guardError !== null) {
|
|
309
309
|
writeMessage(socket, {
|
|
@@ -24,7 +24,7 @@ export function lastRequest(msg: UnknownRecord): {
|
|
|
24
24
|
readonly requestId: string;
|
|
25
25
|
readonly from: SessionInfo;
|
|
26
26
|
readonly query: NativeReadQuery;
|
|
27
|
-
readonly parked?: SessionInfo;
|
|
27
|
+
readonly parked?: SessionInfo | undefined;
|
|
28
28
|
} {
|
|
29
29
|
const from = normalizeSessionInfo(msg.from);
|
|
30
30
|
if (typeof msg.requestId !== "string" || !from || !isNativeReadQuery(msg.query)) {
|
|
@@ -4,7 +4,7 @@ import { writeMessage } from "./framing.ts";
|
|
|
4
4
|
/** Parking negotiation and its single outstanding broker acknowledgement. */
|
|
5
5
|
export class ParkRequests {
|
|
6
6
|
supported = false;
|
|
7
|
-
private acknowledged?: (parked: boolean) => void;
|
|
7
|
+
private acknowledged?: ((parked: boolean) => void) | undefined;
|
|
8
8
|
settle(parked: boolean): void {
|
|
9
9
|
this.acknowledged?.(parked);
|
|
10
10
|
}
|
|
@@ -516,15 +516,15 @@ export class IntercomClient extends EventEmitter {
|
|
|
516
516
|
}
|
|
517
517
|
|
|
518
518
|
updatePresence(updates: {
|
|
519
|
-
readonly applicationGuard?: string | null;
|
|
520
|
-
readonly name?: string;
|
|
521
|
-
readonly status?: string;
|
|
522
|
-
readonly model?: string;
|
|
523
|
-
readonly pendingAsks?: number;
|
|
524
|
-
readonly acceptsAsks?: boolean;
|
|
525
|
-
readonly lastIntercomActivity?: number;
|
|
526
|
-
readonly subscriptions?: SessionInfo["subscriptions"];
|
|
527
|
-
readonly topics?: readonly TopicUpdate[];
|
|
519
|
+
readonly applicationGuard?: string | null | undefined;
|
|
520
|
+
readonly name?: string | undefined;
|
|
521
|
+
readonly status?: string | undefined;
|
|
522
|
+
readonly model?: string | undefined;
|
|
523
|
+
readonly pendingAsks?: number | undefined;
|
|
524
|
+
readonly acceptsAsks?: boolean | undefined;
|
|
525
|
+
readonly lastIntercomActivity?: number | undefined;
|
|
526
|
+
readonly subscriptions?: SessionInfo["subscriptions"] | undefined;
|
|
527
|
+
readonly topics?: readonly TopicUpdate[] | undefined;
|
|
528
528
|
}): void {
|
|
529
529
|
if (this.disconnecting) {
|
|
530
530
|
return;
|
|
@@ -58,10 +58,16 @@ class MessageBuffer {
|
|
|
58
58
|
if (size === 0) {
|
|
59
59
|
return Buffer.alloc(0);
|
|
60
60
|
}
|
|
61
|
+
if (first === undefined) {
|
|
62
|
+
throw new Error("Frame reader has no buffered chunk for a non-empty read.");
|
|
63
|
+
}
|
|
61
64
|
const result = first.length >= size ? first.subarray(0, size) : Buffer.allocUnsafe(size);
|
|
62
65
|
let written = 0;
|
|
63
66
|
while (written < size) {
|
|
64
67
|
const chunk = this.chunks[this.index];
|
|
68
|
+
if (chunk === undefined) {
|
|
69
|
+
throw new Error("Frame reader ran out of buffered chunks mid-read.");
|
|
70
|
+
}
|
|
65
71
|
const count = Math.min(chunk.length, size - written);
|
|
66
72
|
if (first.length < size) {
|
|
67
73
|
chunk.copy(result, written, 0, count);
|
|
@@ -100,13 +100,13 @@ interface LastPeer {
|
|
|
100
100
|
readonly info: SessionInfo;
|
|
101
101
|
readonly lastMessages: boolean;
|
|
102
102
|
readonly receiptReads: boolean;
|
|
103
|
-
readonly parked?: { readonly closed: boolean };
|
|
103
|
+
readonly parked?: { readonly closed: boolean } | undefined;
|
|
104
104
|
}
|
|
105
105
|
interface PendingLast {
|
|
106
106
|
readonly fromId: string;
|
|
107
107
|
readonly toId: string;
|
|
108
108
|
/** A parked child's main answers from the child's captured native history. */
|
|
109
|
-
readonly via?: string;
|
|
109
|
+
readonly via?: string | undefined;
|
|
110
110
|
readonly requestId: string;
|
|
111
111
|
readonly query: NativeReadQuery;
|
|
112
112
|
readonly timer: NodeJS.Timeout;
|
|
@@ -130,24 +130,25 @@ function unavailable(
|
|
|
130
130
|
targets: readonly LastPeer[],
|
|
131
131
|
name: string,
|
|
132
132
|
pending: number,
|
|
133
|
-
{ query, via }: { readonly query: NativeReadQuery; readonly via?: LastPeer },
|
|
133
|
+
{ query, via }: { readonly query: NativeReadQuery; readonly via?: LastPeer | undefined },
|
|
134
134
|
): string | undefined {
|
|
135
|
-
|
|
135
|
+
const [target] = targets;
|
|
136
|
+
if (target === undefined) {
|
|
136
137
|
return "Session not found";
|
|
137
138
|
}
|
|
138
139
|
if (targets.length > 1) {
|
|
139
140
|
return `Multiple sessions named "${name}" are connected. Use the session ID instead.`;
|
|
140
141
|
}
|
|
141
|
-
const exited = exitedRefusal(
|
|
142
|
+
const exited = exitedRefusal(target, query, via);
|
|
142
143
|
if (exited !== undefined) {
|
|
143
144
|
return exited;
|
|
144
145
|
}
|
|
145
|
-
if (!
|
|
146
|
+
if (!target.lastMessages) {
|
|
146
147
|
return "Recipient does not support last-message reads; reload its extension.";
|
|
147
148
|
}
|
|
148
149
|
if (
|
|
149
150
|
"messageId" in query &&
|
|
150
|
-
(!
|
|
151
|
+
(!target.receiptReads || target.info.rootSessionId !== query.rootSessionId)
|
|
151
152
|
) {
|
|
152
153
|
return "publication_unavailable";
|
|
153
154
|
}
|
|
@@ -213,19 +214,19 @@ export class LastMessageRelay {
|
|
|
213
214
|
): void {
|
|
214
215
|
const message = lastRequest(input);
|
|
215
216
|
const targets = findSessions(this.sessions, message.to, fromId);
|
|
216
|
-
const target = targets[0];
|
|
217
217
|
const from = this.sessions.get(fromId);
|
|
218
218
|
if (!from) {
|
|
219
219
|
return;
|
|
220
220
|
}
|
|
221
221
|
const token = randomUUID();
|
|
222
|
-
const
|
|
222
|
+
const delegated = this.delegatedReader(targets, reader);
|
|
223
|
+
const via = delegated?.via;
|
|
223
224
|
const frame = {
|
|
224
225
|
type: "last_request" as const,
|
|
225
226
|
requestId: token,
|
|
226
227
|
from: messageSender(from.info),
|
|
227
228
|
query: message.query,
|
|
228
|
-
parked:
|
|
229
|
+
parked: delegated === undefined ? undefined : messageSender(delegated.target.info),
|
|
229
230
|
};
|
|
230
231
|
const error =
|
|
231
232
|
unavailable(targets, message.to, this.pending.size, { query: message.query, via }) ??
|
|
@@ -238,24 +239,67 @@ export class LastMessageRelay {
|
|
|
238
239
|
});
|
|
239
240
|
return;
|
|
240
241
|
}
|
|
242
|
+
const [recipient] = targets;
|
|
243
|
+
if (recipient === undefined) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
this.forward(
|
|
247
|
+
{ token, fromId, requestId: message.requestId, query: message.query },
|
|
248
|
+
recipient,
|
|
249
|
+
via,
|
|
250
|
+
frame,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Register the correlated wait, then forward the read to the reader or the recipient. */
|
|
255
|
+
private forward(
|
|
256
|
+
wait: {
|
|
257
|
+
readonly token: string;
|
|
258
|
+
readonly fromId: string;
|
|
259
|
+
readonly requestId: string;
|
|
260
|
+
readonly query: NativeReadQuery;
|
|
261
|
+
},
|
|
262
|
+
recipient: LastPeer,
|
|
263
|
+
via: LastPeer | undefined,
|
|
264
|
+
frame: {
|
|
265
|
+
readonly type: "last_request";
|
|
266
|
+
readonly requestId: string;
|
|
267
|
+
readonly from: SessionInfo;
|
|
268
|
+
readonly query: NativeReadQuery;
|
|
269
|
+
readonly parked?: SessionInfo | undefined;
|
|
270
|
+
},
|
|
271
|
+
): void {
|
|
241
272
|
const timer = setTimeout(
|
|
242
|
-
() => this.finish(token, { error: "Last-message read timed out" }),
|
|
273
|
+
() => this.finish(wait.token, { error: "Last-message read timed out" }),
|
|
243
274
|
5000,
|
|
244
275
|
);
|
|
245
276
|
timer.unref();
|
|
246
|
-
this.pending.set(token, {
|
|
247
|
-
fromId,
|
|
248
|
-
toId:
|
|
277
|
+
this.pending.set(wait.token, {
|
|
278
|
+
fromId: wait.fromId,
|
|
279
|
+
toId: recipient.info.id,
|
|
249
280
|
via: via?.info.id,
|
|
250
|
-
requestId:
|
|
251
|
-
query:
|
|
281
|
+
requestId: wait.requestId,
|
|
282
|
+
query: wait.query,
|
|
252
283
|
timer,
|
|
253
284
|
});
|
|
254
285
|
try {
|
|
255
|
-
writeMessage((via ??
|
|
286
|
+
writeMessage((via ?? recipient).socket, frame);
|
|
256
287
|
} catch {
|
|
257
|
-
this.finish(token, { error: "Recipient disconnected before last-message read" });
|
|
288
|
+
this.finish(wait.token, { error: "Recipient disconnected before last-message read" });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** A sole matched target may delegate the read to another peer's captured history. */
|
|
293
|
+
private delegatedReader(
|
|
294
|
+
targets: readonly LastPeer[],
|
|
295
|
+
reader: (id: string) => LastPeer | undefined,
|
|
296
|
+
): { readonly target: LastPeer; readonly via: LastPeer } | undefined {
|
|
297
|
+
const [target] = targets;
|
|
298
|
+
if (targets.length !== 1 || target === undefined) {
|
|
299
|
+
return undefined;
|
|
258
300
|
}
|
|
301
|
+
const via = reader(target.info.id);
|
|
302
|
+
return via === undefined ? undefined : { target, via };
|
|
259
303
|
}
|
|
260
304
|
|
|
261
305
|
respond(fromId: string, message: UnknownRecord): void {
|
|
@@ -21,8 +21,8 @@ interface Parked {
|
|
|
21
21
|
readonly retained: Retained;
|
|
22
22
|
/** The fenced actor's socket closed: input waits for its restored replacement. */
|
|
23
23
|
closed: boolean;
|
|
24
|
-
attempt?: WakeAttempt;
|
|
25
|
-
lastFailure?: string;
|
|
24
|
+
attempt?: WakeAttempt | undefined;
|
|
25
|
+
lastFailure?: string | undefined;
|
|
26
26
|
}
|
|
27
27
|
export interface ParkablePeer {
|
|
28
28
|
readonly socket: net.Socket;
|
|
@@ -31,7 +31,7 @@ export interface ParkablePeer {
|
|
|
31
31
|
readonly parking: boolean;
|
|
32
32
|
readonly wakeResults: boolean;
|
|
33
33
|
readonly wakeNotices: boolean;
|
|
34
|
-
parked?: Parked;
|
|
34
|
+
parked?: Parked | undefined;
|
|
35
35
|
}
|
|
36
36
|
export function reachable<T extends ParkablePeer>(target?: T): target is T {
|
|
37
37
|
const socket = target?.socket;
|
|
@@ -101,7 +101,10 @@ export class ParkedSessions<T extends ParkablePeer> {
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
/** A free requested identity, or a parked one resumed by the same child lineage. */
|
|
104
|
-
claim(
|
|
104
|
+
claim(
|
|
105
|
+
requestedId: unknown,
|
|
106
|
+
next: Omit<SessionInfo, "id">,
|
|
107
|
+
): { id: string; resumed?: T | undefined } {
|
|
105
108
|
const id = requested(requestedId);
|
|
106
109
|
const previous = id === undefined ? undefined : this.sessions.get(id);
|
|
107
110
|
const resumes =
|
|
@@ -112,7 +115,7 @@ export class ParkedSessions<T extends ParkablePeer> {
|
|
|
112
115
|
return { id: randomUUID() };
|
|
113
116
|
}
|
|
114
117
|
previous?.socket.destroy();
|
|
115
|
-
return { id, resumed: previous };
|
|
118
|
+
return { id, ...(previous !== undefined ? { resumed: previous } : {}) };
|
|
116
119
|
}
|
|
117
120
|
/** Flushes a restored child's input and re-sends wakes owed to a (re)registered main. */
|
|
118
121
|
registered(id: string, previous?: T): void {
|
|
@@ -12,7 +12,7 @@ export interface IntercomConfig {
|
|
|
12
12
|
/** Require confirmation before non-reply sends from interactive sessions. */
|
|
13
13
|
readonly confirmSend: boolean;
|
|
14
14
|
/** Optional custom status suffix shown after automatic lifecycle status. */
|
|
15
|
-
readonly status?: string;
|
|
15
|
+
readonly status?: string | undefined;
|
|
16
16
|
/** Show reply hint in incoming messages (default: true). */
|
|
17
17
|
readonly replyHint: boolean;
|
|
18
18
|
/** Ordinary ask reply timeout, in milliseconds (default: 120000 = 2 minutes). */
|
|
@@ -93,6 +93,7 @@ export class IntercomOutbound {
|
|
|
93
93
|
case "queue":
|
|
94
94
|
return " (intentionally deferred behind active recipient work)";
|
|
95
95
|
case undefined:
|
|
96
|
+
default:
|
|
96
97
|
return " (defaults to steer; wakes idle recipients and steers active recipients after the current tool call)";
|
|
97
98
|
}
|
|
98
99
|
}
|
|
@@ -79,8 +79,9 @@ function explicitReply(
|
|
|
79
79
|
}
|
|
80
80
|
function replyBySender(contexts: readonly IntercomContext[], to: string): IntercomContext {
|
|
81
81
|
const matches = matchingSender(contexts, to);
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
const [onlyMatch] = matches;
|
|
83
|
+
if (matches.length === 1 && onlyMatch !== undefined) {
|
|
84
|
+
return onlyMatch;
|
|
84
85
|
}
|
|
85
86
|
if (matches.length > 1) {
|
|
86
87
|
throw new RecoveryError(
|
|
@@ -164,7 +165,7 @@ export class ReplyTracker {
|
|
|
164
165
|
const beforeQueued = this.pendingTurnContexts.length;
|
|
165
166
|
for (let index = this.pendingTurnContexts.length - 1; index >= 0; index -= 1) {
|
|
166
167
|
const context = this.pendingTurnContexts[index];
|
|
167
|
-
if (expires(context)) {
|
|
168
|
+
if (context !== undefined && expires(context)) {
|
|
168
169
|
this.pendingTurnContexts.splice(index, 1);
|
|
169
170
|
}
|
|
170
171
|
}
|
|
@@ -181,7 +182,7 @@ export class ReplyTracker {
|
|
|
181
182
|
}
|
|
182
183
|
|
|
183
184
|
resolveReplyTarget(
|
|
184
|
-
options: { readonly to?: string; readonly replyTo?: string },
|
|
185
|
+
options: { readonly to?: string | undefined; readonly replyTo?: string | undefined },
|
|
185
186
|
now = Date.now(),
|
|
186
187
|
): IntercomContext {
|
|
187
188
|
this.pruneExpired(now);
|
|
@@ -204,11 +205,17 @@ export class ReplyTracker {
|
|
|
204
205
|
if (to !== "") {
|
|
205
206
|
return replyBySender(contexts, to);
|
|
206
207
|
}
|
|
208
|
+
return this.implicitReplyTarget(pending);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Without an explicit target, the turn context wins, then a sole pending ask. */
|
|
212
|
+
private implicitReplyTarget(pending: readonly IntercomContext[]): IntercomContext {
|
|
207
213
|
if (this.currentTurnContext) {
|
|
208
214
|
return this.currentTurnContext;
|
|
209
215
|
}
|
|
210
|
-
|
|
211
|
-
|
|
216
|
+
const [solePending] = pending;
|
|
217
|
+
if (pending.length === 1 && solePending !== undefined) {
|
|
218
|
+
return solePending;
|
|
212
219
|
}
|
|
213
220
|
if (pending.length === 0) {
|
|
214
221
|
throw new RecoveryError("no_pending_reply", "No active intercom context to reply to");
|
|
@@ -15,13 +15,13 @@ interface ReplyWaiter {
|
|
|
15
15
|
export interface ReplyWaitOptions {
|
|
16
16
|
readonly from: string;
|
|
17
17
|
readonly replyTo: string;
|
|
18
|
-
readonly signal?: AbortSignal;
|
|
18
|
+
readonly signal?: AbortSignal | undefined;
|
|
19
19
|
}
|
|
20
20
|
export interface AskTransaction {
|
|
21
21
|
readonly to: string;
|
|
22
22
|
readonly questionId: string;
|
|
23
23
|
readonly options: Parameters<IntercomClient["send"]>[1];
|
|
24
|
-
readonly signal?: AbortSignal;
|
|
24
|
+
readonly signal?: AbortSignal | undefined;
|
|
25
25
|
readonly onSent: (result: SendResult) => void;
|
|
26
26
|
}
|
|
27
27
|
/** One correlated blocking wait; admitted before send so a fast reply cannot race it. */
|
|
@@ -53,7 +53,7 @@ export class ReplyWait {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
receive(
|
|
56
|
-
sender: { readonly id: string; readonly name?: string },
|
|
56
|
+
sender: { readonly id: string; readonly name?: string | undefined },
|
|
57
57
|
message: Message,
|
|
58
58
|
onMatch: () => void,
|
|
59
59
|
): boolean {
|
|
@@ -3,7 +3,7 @@ import type { Attachment, Message, MessageDelivery, QueueMode, SessionInfo } fro
|
|
|
3
3
|
export interface InboundMessageEntry {
|
|
4
4
|
readonly from: SessionInfo;
|
|
5
5
|
readonly message: Message;
|
|
6
|
-
readonly replyCommand?: string;
|
|
6
|
+
readonly replyCommand?: string | undefined;
|
|
7
7
|
readonly bodyText: string;
|
|
8
8
|
}
|
|
9
9
|
export interface PendingInboundMessage extends InboundMessageEntry {
|