@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
|
@@ -1,24 +1,42 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { isRecord } from "../../shared/unknown.js";
|
|
3
|
-
import { normalizeSessionInfo, } from "../types.js";
|
|
3
|
+
import { isDeliveryGuard, isPublicationReplyHint, isLastMessageQuery, isReceiptQuery, LAST_MESSAGE_PAGE_LIMIT, normalizeSessionInfo, } from "../types.js";
|
|
4
|
+
import { finiteNumber, nonNegativeInteger, optionalLabel, safeLabel } from "../validation.js";
|
|
4
5
|
import { validateIntercomMessageSize, writeMessage } from "./framing.js";
|
|
5
6
|
import { messageSender } from "./snapshots.js";
|
|
6
7
|
import { findSessions } from "./targets.js";
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
8
|
+
export function isNativeReadQuery(value) {
|
|
9
|
+
return isReceiptQuery(value) || isLastMessageQuery(value);
|
|
10
10
|
}
|
|
11
|
-
function
|
|
12
|
-
|
|
11
|
+
function isNativeReceipt(value) {
|
|
12
|
+
if (!isReceiptQuery(value) || !isRecord(value) || !isRecord(value.sender)) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
return (value.version === 1 &&
|
|
16
|
+
safeLabel(value.sender.peerId) &&
|
|
17
|
+
[value.sender.rootSessionId, value.sender.incarnation].every(optionalLabel) &&
|
|
18
|
+
typeof value.textSha256 === "string" &&
|
|
19
|
+
/^[a-f0-9]{64}$/.test(value.textSha256) &&
|
|
20
|
+
receiptPublication(value) &&
|
|
21
|
+
receiptFacts(value));
|
|
13
22
|
}
|
|
14
|
-
|
|
15
|
-
return (
|
|
16
|
-
|
|
17
|
-
(value.
|
|
18
|
-
(value.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
23
|
+
function receiptPublication(value) {
|
|
24
|
+
return (typeof value.delivery === "string" &&
|
|
25
|
+
["queue", "steer", "passive"].includes(value.delivery) &&
|
|
26
|
+
(value.guard === undefined || isDeliveryGuard(value.guard)) &&
|
|
27
|
+
(value.replyHint === undefined || isPublicationReplyHint(value.replyHint)));
|
|
28
|
+
}
|
|
29
|
+
function receiptFacts(value) {
|
|
30
|
+
return (safeLabel(value.checkpointEntryId) &&
|
|
31
|
+
finiteNumber(value.receivedAt) &&
|
|
32
|
+
[value.queued, value.nativeHandoff, value.discarded].every((field) => typeof field === "boolean") &&
|
|
33
|
+
(value.placementEntryId === null || safeLabel(value.placementEntryId)));
|
|
34
|
+
}
|
|
35
|
+
export function isReceiptResult(value) {
|
|
36
|
+
return isRecord(value) && (typeof value.error === "string" || isNativeReceipt(value.receipt));
|
|
37
|
+
}
|
|
38
|
+
export function isNativeReadResult(value) {
|
|
39
|
+
return isReceiptResult(value) || isLastMessageResult(value);
|
|
22
40
|
}
|
|
23
41
|
function pageIdentity(value) {
|
|
24
42
|
return (normalizeSessionInfo(value.session) !== null &&
|
|
@@ -45,16 +63,35 @@ export function isLastMessageResult(value) {
|
|
|
45
63
|
return (isRecord(value) &&
|
|
46
64
|
(typeof value.error === "string" || (pageIdentity(value) && pageOffsets(value))));
|
|
47
65
|
}
|
|
48
|
-
|
|
66
|
+
/** An exited parked child answers nothing itself; only its main holds the captured history. */
|
|
67
|
+
function exitedRefusal(target, query, via) {
|
|
68
|
+
if (target.parked?.closed !== true) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
// No loaded journal owner answers receipts, with or without its main.
|
|
72
|
+
if ("messageId" in query) {
|
|
73
|
+
return "publication_unavailable";
|
|
74
|
+
}
|
|
75
|
+
return via === undefined ? "Parked session history is unavailable." : undefined;
|
|
76
|
+
}
|
|
77
|
+
function unavailable(targets, name, pending, { query, via }) {
|
|
49
78
|
if (targets.length === 0) {
|
|
50
79
|
return "Session not found";
|
|
51
80
|
}
|
|
52
81
|
if (targets.length > 1) {
|
|
53
82
|
return `Multiple sessions named "${name}" are connected. Use the session ID instead.`;
|
|
54
83
|
}
|
|
84
|
+
const exited = exitedRefusal(targets[0], query, via);
|
|
85
|
+
if (exited !== undefined) {
|
|
86
|
+
return exited;
|
|
87
|
+
}
|
|
55
88
|
if (!targets[0].lastMessages) {
|
|
56
89
|
return "Recipient does not support last-message reads; reload its extension.";
|
|
57
90
|
}
|
|
91
|
+
if ("messageId" in query &&
|
|
92
|
+
(!targets[0].receiptReads || targets[0].info.rootSessionId !== query.rootSessionId)) {
|
|
93
|
+
return "publication_unavailable";
|
|
94
|
+
}
|
|
58
95
|
if (pending >= 128) {
|
|
59
96
|
return "Too many pending last-message reads; retry after they settle.";
|
|
60
97
|
}
|
|
@@ -64,10 +101,28 @@ function matchesPage(result, pending, fromId) {
|
|
|
64
101
|
if ("error" in result) {
|
|
65
102
|
return true;
|
|
66
103
|
}
|
|
104
|
+
if ("messageId" in pending.query) {
|
|
105
|
+
return ("receipt" in result &&
|
|
106
|
+
result.receipt.messageId === pending.query.messageId &&
|
|
107
|
+
result.receipt.rootSessionId === pending.query.rootSessionId);
|
|
108
|
+
}
|
|
109
|
+
if ("receipt" in result) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
return matchesLastPage(result, pending.query, fromId);
|
|
113
|
+
}
|
|
114
|
+
function matchesLastPage(result, query, fromId) {
|
|
67
115
|
return (result.session.id === fromId &&
|
|
68
|
-
result.offset === (
|
|
69
|
-
(
|
|
70
|
-
result.text.length <= (
|
|
116
|
+
result.offset === (query.offset ?? 0) &&
|
|
117
|
+
(query.entryId === undefined || result.entryId === query.entryId) &&
|
|
118
|
+
result.text.length <= (query.limit ?? LAST_MESSAGE_PAGE_LIMIT));
|
|
119
|
+
}
|
|
120
|
+
function lastRequest(message) {
|
|
121
|
+
const { requestId, to, query } = message;
|
|
122
|
+
if (typeof requestId !== "string" || typeof to !== "string" || !isNativeReadQuery(query)) {
|
|
123
|
+
throw new Error("Invalid last message request");
|
|
124
|
+
}
|
|
125
|
+
return { requestId, to, query };
|
|
71
126
|
}
|
|
72
127
|
/** Correlation belongs to the broker, not the requester or the recipient's model queue. */
|
|
73
128
|
export class LastMessageRelay {
|
|
@@ -76,40 +131,31 @@ export class LastMessageRelay {
|
|
|
76
131
|
constructor(sessions) {
|
|
77
132
|
this.sessions = sessions;
|
|
78
133
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
!isLastMessageQuery(message.query)) {
|
|
83
|
-
throw new Error("Invalid last message request");
|
|
84
|
-
}
|
|
134
|
+
/** `reader` names another peer that answers for an exited target from its captured history. */
|
|
135
|
+
request(fromId, input, reader) {
|
|
136
|
+
const message = lastRequest(input);
|
|
85
137
|
const targets = findSessions(this.sessions, message.to, fromId);
|
|
86
138
|
const target = targets[0];
|
|
87
139
|
const from = this.sessions.get(fromId);
|
|
88
140
|
if (!from) {
|
|
89
141
|
return;
|
|
90
142
|
}
|
|
91
|
-
const error = unavailable(targets, message.to, this.pending.size);
|
|
92
|
-
if (error !== undefined) {
|
|
93
|
-
writeMessage(from.socket, {
|
|
94
|
-
type: "last_response",
|
|
95
|
-
requestId: message.requestId,
|
|
96
|
-
result: { error },
|
|
97
|
-
});
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
143
|
const token = randomUUID();
|
|
144
|
+
const via = targets.length === 1 ? reader(target.info.id) : undefined;
|
|
101
145
|
const frame = {
|
|
102
146
|
type: "last_request",
|
|
103
147
|
requestId: token,
|
|
104
148
|
from: messageSender(from.info),
|
|
105
149
|
query: message.query,
|
|
150
|
+
parked: via && messageSender(target.info),
|
|
106
151
|
};
|
|
107
|
-
const
|
|
108
|
-
|
|
152
|
+
const error = unavailable(targets, message.to, this.pending.size, { query: message.query, via }) ??
|
|
153
|
+
validateIntercomMessageSize(frame)?.message;
|
|
154
|
+
if (error !== undefined) {
|
|
109
155
|
writeMessage(from.socket, {
|
|
110
156
|
type: "last_response",
|
|
111
157
|
requestId: message.requestId,
|
|
112
|
-
result: { error
|
|
158
|
+
result: { error },
|
|
113
159
|
});
|
|
114
160
|
return;
|
|
115
161
|
}
|
|
@@ -118,12 +164,13 @@ export class LastMessageRelay {
|
|
|
118
164
|
this.pending.set(token, {
|
|
119
165
|
fromId,
|
|
120
166
|
toId: target.info.id,
|
|
167
|
+
via: via?.info.id,
|
|
121
168
|
requestId: message.requestId,
|
|
122
169
|
query: message.query,
|
|
123
170
|
timer,
|
|
124
171
|
});
|
|
125
172
|
try {
|
|
126
|
-
writeMessage(target.socket, frame);
|
|
173
|
+
writeMessage((via ?? target).socket, frame);
|
|
127
174
|
}
|
|
128
175
|
catch {
|
|
129
176
|
this.finish(token, { error: "Recipient disconnected before last-message read" });
|
|
@@ -134,17 +181,17 @@ export class LastMessageRelay {
|
|
|
134
181
|
throw new Error("Invalid last message response");
|
|
135
182
|
}
|
|
136
183
|
const pending = this.pending.get(message.requestId);
|
|
137
|
-
if (!pending || pending.toId !== fromId) {
|
|
184
|
+
if (!pending || (pending.via ?? pending.toId) !== fromId) {
|
|
138
185
|
return;
|
|
139
186
|
}
|
|
140
187
|
const result = message.result;
|
|
141
|
-
this.finish(message.requestId,
|
|
188
|
+
this.finish(message.requestId, isNativeReadResult(result) && matchesPage(result, pending, pending.toId)
|
|
142
189
|
? result
|
|
143
190
|
: { error: "Recipient returned an invalid last-message page" });
|
|
144
191
|
}
|
|
145
192
|
disconnected(sessionId) {
|
|
146
193
|
for (const [token, pending] of this.pending) {
|
|
147
|
-
if (pending.fromId
|
|
194
|
+
if ([pending.fromId, pending.toId, pending.via].includes(sessionId)) {
|
|
148
195
|
this.finish(token, { error: "Peer disconnected during last-message read" });
|
|
149
196
|
}
|
|
150
197
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { nativePeerId } from "../session-targets.js";
|
|
3
|
+
import { writeMessage } from "./framing.js";
|
|
4
|
+
export function reachable(target) {
|
|
5
|
+
const socket = target?.socket;
|
|
6
|
+
return socket !== undefined && !socket.destroyed && socket.writable && !socket.writableEnded;
|
|
7
|
+
}
|
|
8
|
+
function requested(id) {
|
|
9
|
+
return typeof id === "string" && /^[a-zA-Z0-9_-]{8,80}$/.test(id) ? id : undefined;
|
|
10
|
+
}
|
|
11
|
+
/** A fenced child keeps its identity and queued input; it never departs while parked. */
|
|
12
|
+
export class ParkedSessions {
|
|
13
|
+
sessions;
|
|
14
|
+
deliver;
|
|
15
|
+
constructor(sessions, deliver) {
|
|
16
|
+
this.sessions = sessions;
|
|
17
|
+
this.deliver = deliver;
|
|
18
|
+
}
|
|
19
|
+
park(socket, id, on) {
|
|
20
|
+
const session = this.sessions.get(id);
|
|
21
|
+
if (on && session?.parking === true) {
|
|
22
|
+
session.parked ??= { retained: [], closed: false };
|
|
23
|
+
// Frames already written to this socket precede the acknowledgement.
|
|
24
|
+
writeMessage(socket, { type: "parked" });
|
|
25
|
+
}
|
|
26
|
+
else if (!on && session?.parked?.closed === false) {
|
|
27
|
+
const { retained } = session.parked;
|
|
28
|
+
session.parked = undefined;
|
|
29
|
+
this.flush(id, retained);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Delivers retained input in order. A transport that fails mid-flush keeps the rest for its
|
|
34
|
+
* next generation; delivery refusals on a live transport follow resident delivery.
|
|
35
|
+
*/
|
|
36
|
+
flush(id, retained) {
|
|
37
|
+
for (const [index, { from, message }] of retained.entries()) {
|
|
38
|
+
const session = this.sessions.get(id);
|
|
39
|
+
if (this.deliver(id, from, message) !== null && session?.socket.writable === false) {
|
|
40
|
+
session.parked = { retained: retained.slice(index), closed: false };
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** A free requested identity, or a parked one resumed by the same child lineage. */
|
|
46
|
+
claim(requestedId, next) {
|
|
47
|
+
const id = requested(requestedId);
|
|
48
|
+
const previous = id === undefined ? undefined : this.sessions.get(id);
|
|
49
|
+
const resumes = previous?.parked !== undefined &&
|
|
50
|
+
previous.info.rootSessionId === next.rootSessionId &&
|
|
51
|
+
previous.info.parentSessionId === next.parentSessionId;
|
|
52
|
+
if (id === undefined || (previous && !resumes)) {
|
|
53
|
+
return { id: randomUUID() };
|
|
54
|
+
}
|
|
55
|
+
previous?.socket.destroy();
|
|
56
|
+
return { id, resumed: previous };
|
|
57
|
+
}
|
|
58
|
+
/** Flushes a restored child's input and re-sends wakes owed to a (re)registered main. */
|
|
59
|
+
registered(id, previous) {
|
|
60
|
+
this.flush(id, previous?.parked?.retained ?? []);
|
|
61
|
+
for (const child of this.sessions.values()) {
|
|
62
|
+
if (nativePeerId(child.info.rootSessionId ?? "") === id) {
|
|
63
|
+
this.wake(child);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** A transport handoff, not a departure: no session_left and queued input stays. */
|
|
68
|
+
closed(session) {
|
|
69
|
+
const { parked } = session;
|
|
70
|
+
if (parked) {
|
|
71
|
+
parked.closed = true;
|
|
72
|
+
this.wake(session);
|
|
73
|
+
}
|
|
74
|
+
return parked !== undefined;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Exited parked children keep identities while some live session remains. Input already
|
|
78
|
+
* admitted for one keeps the broker until a returning main wakes it.
|
|
79
|
+
*/
|
|
80
|
+
get exited() {
|
|
81
|
+
return [...this.sessions.values()].every(({ parked }) => parked?.closed === true && parked.retained.length === 0);
|
|
82
|
+
}
|
|
83
|
+
retain(target, from, message) {
|
|
84
|
+
target?.parked?.retained.push({ from, message });
|
|
85
|
+
if (target) {
|
|
86
|
+
this.wake(target);
|
|
87
|
+
}
|
|
88
|
+
return target?.parked !== undefined;
|
|
89
|
+
}
|
|
90
|
+
/** Reads of an exited parked child go to its main, which answers from captured history. */
|
|
91
|
+
reader(id) {
|
|
92
|
+
const child = this.sessions.get(id);
|
|
93
|
+
return child?.parked?.closed === true ? this.main(child) : undefined;
|
|
94
|
+
}
|
|
95
|
+
// Passive breadcrumbs and topic updates wait for a genuine wake; they never spawn the child.
|
|
96
|
+
wake(child) {
|
|
97
|
+
const main = this.main(child);
|
|
98
|
+
const wakes = child.parked?.retained.some(({ message }) => message.passive !== true && message.delivery !== "passive" && message.topic === undefined);
|
|
99
|
+
if (main && child.parked?.closed === true && wakes === true) {
|
|
100
|
+
writeMessage(main.socket, { type: "wake", sessionId: child.info.id });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
main(child) {
|
|
104
|
+
const main = this.sessions.get(nativePeerId(child.info.rootSessionId ?? ""));
|
|
105
|
+
return main?.parking === true && main.parked === undefined ? main : undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Old agent-directory namespaces are inspected for migration, never used for routing. */
|
|
2
|
+
export declare function getPreviousBrokerSocketPaths(): readonly string[];
|
|
3
|
+
export declare function isOwnedBrokerSocket(socketPath: string): boolean;
|
|
4
|
+
/** A deployment can explicitly opt out of same-user discovery with a private runtime directory. */
|
|
5
|
+
export declare function getBrokerRuntimeDir(): string;
|
|
6
|
+
/** Read-only child observation endpoints share the runtime policy, not the broker namespace.
|
|
7
|
+
* The endpoint name is a short token digest because the private runtime directory also counts
|
|
8
|
+
* toward the Unix-socket path limit; a full token made nested test/deployment roots overflow. */
|
|
9
|
+
export declare function agentObservationSocketPath(token: string): string;
|
|
10
|
+
export declare function getBrokerSocketPath(): string;
|
|
11
|
+
/** Create or verify an owned, non-symlink, private socket directory. */
|
|
12
|
+
export declare function preparePrivateSocketPath(socketPath: string): string;
|
|
13
|
+
export declare function prepareBrokerSocketPath(): string;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { dirname, isAbsolute, join } from "node:path";
|
|
4
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
5
|
import { getPiAgentDir } from "../agent-dir.js";
|
|
6
6
|
const MAX_UNIX_SOCKET_PATH_BYTES = 100;
|
|
7
7
|
function agentDigest(agentDir, uid) {
|
|
@@ -32,33 +32,56 @@ export function isOwnedBrokerSocket(socketPath) {
|
|
|
32
32
|
return false;
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
function defaultRuntimeRoot() {
|
|
36
|
+
if (process.platform !== "android") {
|
|
37
|
+
return "/tmp";
|
|
38
|
+
}
|
|
39
|
+
return process.env.PREFIX === undefined ? tmpdir() : join(process.env.PREFIX, "tmp");
|
|
40
|
+
}
|
|
41
|
+
function runtimeDirectory(socketName, sibling = "") {
|
|
37
42
|
const configured = process.env.PI_INTERCOM_RUNTIME_DIR;
|
|
38
43
|
const uid = process.getuid?.();
|
|
39
44
|
if (uid === undefined) {
|
|
40
45
|
throw new Error("Intercom requires Unix user IDs and Unix domain sockets.");
|
|
41
46
|
}
|
|
42
|
-
const
|
|
47
|
+
const base = configured ?? join(defaultRuntimeRoot(), `pi-intercom-${uid}`);
|
|
48
|
+
const directory = isAbsolute(base) && sibling !== "" ? `${resolve(base)}${sibling}` : base;
|
|
43
49
|
if (!isAbsolute(directory) ||
|
|
44
50
|
directory.includes("\0") ||
|
|
45
|
-
Buffer.byteLength(join(directory,
|
|
51
|
+
Buffer.byteLength(join(directory, socketName)) > MAX_UNIX_SOCKET_PATH_BYTES) {
|
|
46
52
|
throw new Error("PI_INTERCOM_RUNTIME_DIR must be an absolute short Unix socket directory.");
|
|
47
53
|
}
|
|
48
54
|
return directory;
|
|
49
55
|
}
|
|
56
|
+
/** A deployment can explicitly opt out of same-user discovery with a private runtime directory. */
|
|
57
|
+
export function getBrokerRuntimeDir() {
|
|
58
|
+
return runtimeDirectory("broker.sock");
|
|
59
|
+
}
|
|
60
|
+
/** Read-only child observation endpoints share the runtime policy, not the broker namespace.
|
|
61
|
+
* The endpoint name is a short token digest because the private runtime directory also counts
|
|
62
|
+
* toward the Unix-socket path limit; a full token made nested test/deployment roots overflow. */
|
|
63
|
+
export function agentObservationSocketPath(token) {
|
|
64
|
+
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(token)) {
|
|
65
|
+
throw new Error("Invalid actor token.");
|
|
66
|
+
}
|
|
67
|
+
const name = `obs-${createHash("sha256").update(token).digest("hex").slice(0, 16)}.sock`;
|
|
68
|
+
return join(runtimeDirectory(name, "-agents"), name);
|
|
69
|
+
}
|
|
50
70
|
export function getBrokerSocketPath() {
|
|
51
71
|
return join(getBrokerRuntimeDir(), "broker.sock");
|
|
52
72
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
fs.mkdirSync(
|
|
57
|
-
const stat = fs.lstatSync(
|
|
73
|
+
/** Create or verify an owned, non-symlink, private socket directory. */
|
|
74
|
+
export function preparePrivateSocketPath(socketPath) {
|
|
75
|
+
const directory = dirname(socketPath);
|
|
76
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
77
|
+
const stat = fs.lstatSync(directory);
|
|
58
78
|
const uid = process.getuid?.();
|
|
59
79
|
if (!stat.isDirectory() || stat.isSymbolicLink() || (uid !== undefined && stat.uid !== uid)) {
|
|
60
|
-
throw new Error(`Unsafe intercom socket directory: ${
|
|
80
|
+
throw new Error(`Unsafe intercom socket directory: ${directory}`);
|
|
61
81
|
}
|
|
62
|
-
fs.chmodSync(
|
|
82
|
+
fs.chmodSync(directory, 0o700);
|
|
63
83
|
return socketPath;
|
|
64
84
|
}
|
|
85
|
+
export function prepareBrokerSocketPath() {
|
|
86
|
+
return preparePrivateSocketPath(getBrokerSocketPath());
|
|
87
|
+
}
|
|
@@ -73,6 +73,10 @@ export function brokerPidRecord() {
|
|
|
73
73
|
return `${process.pid}\n${identity !== undefined ? `${identity}\n` : ""}`;
|
|
74
74
|
}
|
|
75
75
|
export function isBrokerPidReused(pid, record) {
|
|
76
|
+
return isLinuxProcessReplaced(pid, record.slice(record.indexOf("\n") + 1));
|
|
77
|
+
}
|
|
78
|
+
/** True only when a live Linux PID provably names a different process than the identity. */
|
|
79
|
+
export function isLinuxProcessReplaced(pid, identity) {
|
|
76
80
|
const current = readLinuxProcess(pid);
|
|
77
81
|
if (!current) {
|
|
78
82
|
return false;
|
|
@@ -80,7 +84,7 @@ export function isBrokerPidReused(pid, record) {
|
|
|
80
84
|
if (current.tgid !== pid) {
|
|
81
85
|
return true;
|
|
82
86
|
}
|
|
83
|
-
const saved = IDENTITY.exec(
|
|
87
|
+
const saved = IDENTITY.exec(identity.trim());
|
|
84
88
|
const actual = IDENTITY.exec(current.identity ?? "");
|
|
85
89
|
if (!saved || !actual) {
|
|
86
90
|
return false;
|
|
@@ -1,4 +1,23 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { createMessageReader } from "./framing.js";
|
|
3
|
+
import { assertMainParent } from "./targets.js";
|
|
4
|
+
import { isSessionRegistration } from "../types.js";
|
|
5
|
+
export function registeredSessionInfo(msg, sessions) {
|
|
6
|
+
if (!isSessionRegistration(msg.session)) {
|
|
7
|
+
throw new Error("Invalid register message");
|
|
8
|
+
}
|
|
9
|
+
assertMainParent(msg.session, sessions);
|
|
10
|
+
const requestedId = typeof msg.requestedId === "string" && /^[a-zA-Z0-9_-]{8,80}$/.test(msg.requestedId)
|
|
11
|
+
? msg.requestedId
|
|
12
|
+
: undefined;
|
|
13
|
+
const id = requestedId !== undefined && !sessions.has(requestedId) ? requestedId : randomUUID();
|
|
14
|
+
return {
|
|
15
|
+
...msg.session,
|
|
16
|
+
id,
|
|
17
|
+
guardedDelivery: msg.guardedDelivery === 1 ? msg.session.guardedDelivery : undefined,
|
|
18
|
+
lastSeen: msg.session.lastSeen ?? Date.now(),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
2
21
|
/** A server connection owns its registration identity until the socket closes. */
|
|
3
22
|
export function acceptBrokerConnection(socket, handleMessage, disconnected) {
|
|
4
23
|
let sessionId = null;
|
|
@@ -9,7 +28,7 @@ export function acceptBrokerConnection(socket, handleMessage, disconnected) {
|
|
|
9
28
|
}));
|
|
10
29
|
socket.on("close", () => {
|
|
11
30
|
if (sessionId !== null) {
|
|
12
|
-
disconnected(sessionId);
|
|
31
|
+
disconnected(sessionId, socket);
|
|
13
32
|
}
|
|
14
33
|
});
|
|
15
34
|
socket.on("error", (error) => {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type SessionSnapshot } from "../types.ts";
|
|
2
|
+
/** Multipart frames build a private cache; only a complete validated snapshot escapes. */
|
|
3
|
+
export declare class SnapshotReader {
|
|
4
|
+
private readonly sessions;
|
|
5
|
+
private readonly receipts;
|
|
6
|
+
private addSession;
|
|
7
|
+
append(sessions: readonly unknown[], receipts: unknown): void;
|
|
8
|
+
finish(): SessionSnapshot;
|
|
9
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type SessionInfo, type TopicUpdate, type Message, type BrokerMessage, type TopicSubscription, type SendResult } from "../types.ts";
|
|
2
|
+
/** Direct messages and old clients receive identity, not potentially large topic state. */
|
|
3
|
+
export declare function messageSender(info: SessionInfo): SessionInfo;
|
|
4
|
+
export declare function snapshotFrames(requestId: string, infos: readonly SessionInfo[], stream: boolean): BrokerMessage[];
|
|
5
|
+
interface PreparedTopicChange {
|
|
6
|
+
readonly topic?: TopicUpdate;
|
|
7
|
+
readonly subscription?: TopicSubscription;
|
|
8
|
+
readonly unsubscribe?: string;
|
|
9
|
+
readonly publication?: TopicUpdate;
|
|
10
|
+
readonly restore: boolean;
|
|
11
|
+
readonly changed: boolean;
|
|
12
|
+
readonly bytes: number;
|
|
13
|
+
}
|
|
14
|
+
/** Keep committed records indexed; restores validate only new bytes and cached envelope maxima. */
|
|
15
|
+
export declare class TopicSnapshot {
|
|
16
|
+
private readonly topics;
|
|
17
|
+
private readonly subscriptions;
|
|
18
|
+
constructor(info: SessionInfo);
|
|
19
|
+
subscription(topic: string): TopicSubscription | undefined;
|
|
20
|
+
prepare(change: unknown, supported: boolean): PreparedTopicChange;
|
|
21
|
+
private preparePublication;
|
|
22
|
+
snapshot(info: SessionInfo, prepared?: PreparedTopicChange): SessionInfo;
|
|
23
|
+
validateRestore(info: SessionInfo, prepared: PreparedTopicChange, requestId: string): void;
|
|
24
|
+
commit(prepared: PreparedTopicChange): void;
|
|
25
|
+
}
|
|
26
|
+
export declare function deliveryAcknowledgement(receipt: SendResult): BrokerMessage;
|
|
27
|
+
export declare function shouldReplaceDelivery(message: Message, info: SessionInfo): boolean;
|
|
28
|
+
export declare function publicationMessage(publication: TopicUpdate, awaitRelease: boolean): Message;
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SessionInfo } from "../types.ts";
|
|
2
|
+
export declare function canSeeSession(from: SessionInfo, target: SessionInfo): boolean;
|
|
3
|
+
export declare function assertMainParent(session: Omit<SessionInfo, "id">, sessions: ReadonlyMap<string, {
|
|
4
|
+
readonly info: SessionInfo;
|
|
5
|
+
}>): void;
|
|
6
|
+
/** Preserve exact-ID precedence and case-insensitive duplicate-name ambiguity. */
|
|
7
|
+
export declare function findSessions<T extends {
|
|
8
|
+
readonly info: SessionInfo;
|
|
9
|
+
}>(sessions: ReadonlyMap<string, T>, nameOrId: string, fromId: string): T[];
|
|
@@ -2,10 +2,13 @@ import { IntercomClient } from "./broker/client.js";
|
|
|
2
2
|
import { spawnBrokerIfNeeded } from "./broker/spawn.js";
|
|
3
3
|
import { readLastMessage } from "./last-message.js";
|
|
4
4
|
import { asError } from "./validation.js";
|
|
5
|
+
import { RecoveryError } from "./tool-results.js";
|
|
5
6
|
import { formatTargetOptions, resolveSessionTarget, nativePeerId } from "./session-targets.js";
|
|
6
7
|
/** Owns socket registration, coalesced connects and the reconnect/startup timers. */
|
|
7
8
|
export class IntercomConnection {
|
|
8
9
|
client = null;
|
|
10
|
+
/** The transport whose broker retains this actor's input while it parks. */
|
|
11
|
+
parked = null;
|
|
9
12
|
reconnectTimer = null;
|
|
10
13
|
startupTimer = null;
|
|
11
14
|
connecting = null;
|
|
@@ -22,6 +25,22 @@ export class IntercomConnection {
|
|
|
22
25
|
get active() {
|
|
23
26
|
return this.client;
|
|
24
27
|
}
|
|
28
|
+
async park() {
|
|
29
|
+
const client = this.client;
|
|
30
|
+
this.parked = client !== null && (await client.park().catch(() => false)) ? client : null;
|
|
31
|
+
return this.parked !== null && this.parked === this.client;
|
|
32
|
+
}
|
|
33
|
+
get parking() {
|
|
34
|
+
return this.parked !== null;
|
|
35
|
+
}
|
|
36
|
+
get reconnected() {
|
|
37
|
+
return this.client !== null && this.client !== this.parked;
|
|
38
|
+
}
|
|
39
|
+
unpark() {
|
|
40
|
+
const client = this.parked === this.client ? this.client : null;
|
|
41
|
+
this.parked = null;
|
|
42
|
+
return client?.park(false).catch(() => false) ?? Promise.resolve(false);
|
|
43
|
+
}
|
|
25
44
|
start(ctx, generation) {
|
|
26
45
|
this.attempt = 0;
|
|
27
46
|
this.clearTimers();
|
|
@@ -93,9 +112,51 @@ export class IntercomConnection {
|
|
|
93
112
|
this.client.updatePresence({
|
|
94
113
|
status: this.lifecycle.status(),
|
|
95
114
|
...this.lifecycle.health(this.hooks.pendingAsks()),
|
|
115
|
+
applicationGuard: this.lifecycle.snapshot().applicationGuard ?? null,
|
|
96
116
|
});
|
|
97
117
|
}
|
|
98
118
|
}
|
|
119
|
+
answerLast(next, request) {
|
|
120
|
+
const ctx = this.lifecycle.live();
|
|
121
|
+
const id = next.sessionId;
|
|
122
|
+
if (this.client !== next || !ctx || id === null) {
|
|
123
|
+
next.respondLast(request.requestId, { error: "Intercom runtime no longer active" });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const { parked, query } = request;
|
|
127
|
+
if ("messageId" in query) {
|
|
128
|
+
// The broker refuses receipt reads of an exited parked child before relaying them.
|
|
129
|
+
next.respondLast(request.requestId, parked ? { error: "publication_unavailable" } : this.hooks.receipt(query));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (parked) {
|
|
133
|
+
this.hooks
|
|
134
|
+
.parkedHistory(parked)
|
|
135
|
+
.then((history) => history
|
|
136
|
+
? readLastMessage(history, parked, query)
|
|
137
|
+
: { error: "Parked session history is unavailable." })
|
|
138
|
+
.catch((error) => ({ error: asError(error).message }))
|
|
139
|
+
.then((result) => {
|
|
140
|
+
next.respondLast(request.requestId, result);
|
|
141
|
+
})
|
|
142
|
+
.catch(() => {
|
|
143
|
+
/* The requester's broker timeout reports a lost transport. */
|
|
144
|
+
});
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
next.respondLast(request.requestId, readLastMessage(ctx.sessionManager, {
|
|
149
|
+
id,
|
|
150
|
+
...this.lifecycle.identity(ctx.sessionManager.getSessionId()),
|
|
151
|
+
cwd: ctx.cwd,
|
|
152
|
+
model: ctx.model?.id ?? "unknown",
|
|
153
|
+
status: this.lifecycle.status(),
|
|
154
|
+
}, query));
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
next.respondLast(request.requestId, { error: asError(error).message });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
99
160
|
createClient() {
|
|
100
161
|
const next = new IntercomClient({
|
|
101
162
|
sendTimeoutMs: this.config.sendTimeoutMs,
|
|
@@ -103,24 +164,7 @@ export class IntercomConnection {
|
|
|
103
164
|
});
|
|
104
165
|
this.client = next;
|
|
105
166
|
next.on("last_request", (request) => {
|
|
106
|
-
|
|
107
|
-
const id = next.sessionId;
|
|
108
|
-
if (this.client !== next || !ctx || id === null) {
|
|
109
|
-
next.respondLast(request.requestId, { error: "Intercom runtime no longer active" });
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
try {
|
|
113
|
-
next.respondLast(request.requestId, readLastMessage(ctx.sessionManager, {
|
|
114
|
-
id,
|
|
115
|
-
...this.lifecycle.identity(ctx.sessionManager.getSessionId()),
|
|
116
|
-
cwd: ctx.cwd,
|
|
117
|
-
model: ctx.model?.id ?? "unknown",
|
|
118
|
-
status: this.lifecycle.status(),
|
|
119
|
-
}, request.query));
|
|
120
|
-
}
|
|
121
|
-
catch (error) {
|
|
122
|
-
next.respondLast(request.requestId, { error: asError(error).message });
|
|
123
|
-
}
|
|
167
|
+
this.answerLast(next, request);
|
|
124
168
|
});
|
|
125
169
|
next.on("message", (from, message) => {
|
|
126
170
|
const ctx = this.lifecycle.live();
|
|
@@ -131,6 +175,11 @@ export class IntercomConnection {
|
|
|
131
175
|
console.error("Intercom inbound delivery failed:", error);
|
|
132
176
|
});
|
|
133
177
|
});
|
|
178
|
+
next.on("wake", (peer) => {
|
|
179
|
+
if (this.client === next) {
|
|
180
|
+
this.hooks.wake(peer);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
134
183
|
next.on("session_left", (id) => {
|
|
135
184
|
if (this.client !== next) {
|
|
136
185
|
return;
|
|
@@ -143,6 +192,7 @@ export class IntercomConnection {
|
|
|
143
192
|
}
|
|
144
193
|
this.hooks.disconnected(error);
|
|
145
194
|
this.client = null;
|
|
195
|
+
this.lifecycle.transport(null);
|
|
146
196
|
this.clearReconnect();
|
|
147
197
|
this.scheduleReconnect();
|
|
148
198
|
});
|
|
@@ -161,6 +211,7 @@ export class IntercomConnection {
|
|
|
161
211
|
throw new Error("Intercom runtime no longer active");
|
|
162
212
|
}
|
|
163
213
|
this.client = next;
|
|
214
|
+
this.lifecycle.transport(next.sessionId, next.supportsGuardedDelivery);
|
|
164
215
|
this.syncIdentity(ctx.sessionManager.getSessionId());
|
|
165
216
|
this.attempt = 0;
|
|
166
217
|
return next;
|
|
@@ -239,10 +290,10 @@ export async function resolveConnectedTarget(active, name) {
|
|
|
239
290
|
return resolution.target.id;
|
|
240
291
|
}
|
|
241
292
|
if (resolution.status === "ambiguous") {
|
|
242
|
-
throw new
|
|
293
|
+
throw new RecoveryError("ambiguous_target", `Target "${name}" matches multiple sessions. Use one of these targets: ${formatTargetOptions(resolution.matches, sessions)}.`);
|
|
243
294
|
}
|
|
244
295
|
if (resolution.status === "prefix_too_short") {
|
|
245
|
-
throw new
|
|
296
|
+
throw new RecoveryError("ambiguous_target", `Target "${name}" is too short. Use the displayed target from intercom list, such as ${formatTargetOptions(resolution.matches, sessions)}.`);
|
|
246
297
|
}
|
|
247
298
|
return null;
|
|
248
299
|
}
|