@hyperdrive.bot/paseo-client 0.2.5
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/README.md +12 -0
- package/dist/compat/normalize-provider-models.d.ts +11 -0
- package/dist/compat/normalize-provider-models.js +42 -0
- package/dist/daemon-client-relay-e2ee-transport.d.ts +8 -0
- package/dist/daemon-client-relay-e2ee-transport.js +161 -0
- package/dist/daemon-client-runtime-metrics.d.ts +39 -0
- package/dist/daemon-client-runtime-metrics.js +173 -0
- package/dist/daemon-client-transport-types.d.ts +36 -0
- package/dist/daemon-client-transport-types.js +2 -0
- package/dist/daemon-client-transport-utils.d.ts +9 -0
- package/dist/daemon-client-transport-utils.js +121 -0
- package/dist/daemon-client-transport.d.ts +5 -0
- package/dist/daemon-client-transport.js +4 -0
- package/dist/daemon-client-websocket-transport.d.ts +8 -0
- package/dist/daemon-client-websocket-transport.js +120 -0
- package/dist/daemon-client.d.ts +1153 -0
- package/dist/daemon-client.js +4594 -0
- package/dist/index.d.ts +282 -0
- package/dist/index.js +177 -0
- package/dist/terminal-stream-router.d.ts +28 -0
- package/dist/terminal-stream-router.js +108 -0
- package/package.json +47 -0
|
@@ -0,0 +1,4594 @@
|
|
|
1
|
+
import { CLIENT_CAPS, } from "@hyperdrive.bot/paseo-protocol/client-capabilities";
|
|
2
|
+
import { AgentCreateFailedStatusPayloadSchema, AgentCreatedStatusPayloadSchema, AgentRefreshedStatusPayloadSchema, AgentResumedStatusPayloadSchema, parseServerInfoStatusPayload, RestartRequestedStatusPayloadSchema, ShutdownRequestedStatusPayloadSchema, DaemonUpdateResponseSchema, SessionInboundMessageSchema, } from "@hyperdrive.bot/paseo-protocol/messages";
|
|
3
|
+
import { validateWSOutboundMessage } from "@hyperdrive.bot/paseo-protocol/validation/ws-outbound";
|
|
4
|
+
import { isRelayClientWebSocketUrl } from "@hyperdrive.bot/paseo-protocol/daemon-endpoints";
|
|
5
|
+
import { terminalSubscriptionKey } from "@hyperdrive.bot/paseo-protocol/terminal-subscription-key";
|
|
6
|
+
import { asUint8Array, decodeFileTransferFrame, encodeFileTransferFrame, decodeTerminalStreamFrame, FileTransferOpcode, TerminalStreamOpcode, } from "@hyperdrive.bot/paseo-protocol/binary-frames/index";
|
|
7
|
+
import { UPLOAD_CHUNK_SIZE_BYTES } from "@hyperdrive.bot/paseo-protocol/binary-frames/chunk-size";
|
|
8
|
+
import { createRelayE2eeTransportFactory, createWebSocketTransportFactory, decodeMessageData, defaultWebSocketFactory, describeTransportClose, describeTransportError, } from "./daemon-client-transport.js";
|
|
9
|
+
import { DaemonClientRuntimeMetrics } from "./daemon-client-runtime-metrics.js";
|
|
10
|
+
import { normalizeListProviderModelsPayload, normalizeProviderSnapshotUpdateMessage, normalizeProvidersSnapshotPayload, } from "./compat/normalize-provider-models.js";
|
|
11
|
+
import { TerminalStreamRouter } from "./terminal-stream-router.js";
|
|
12
|
+
const consoleLogger = {
|
|
13
|
+
debug: () => { },
|
|
14
|
+
info: (obj, msg) => console.log(msg, obj),
|
|
15
|
+
warn: (obj, msg) => console.warn(msg, obj),
|
|
16
|
+
error: (obj, msg) => console.error(msg, obj),
|
|
17
|
+
};
|
|
18
|
+
const perfNow = typeof performance !== "undefined" && typeof performance.now === "function"
|
|
19
|
+
? () => performance.now()
|
|
20
|
+
: () => Date.now();
|
|
21
|
+
function normalizePassword(value) {
|
|
22
|
+
if (typeof value !== "string") {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return value.length > 0 ? value : null;
|
|
26
|
+
}
|
|
27
|
+
// COMPAT(daemon-client-object-options): added in v0.1.102; remove after
|
|
28
|
+
// 2026-12-29 once SDK callers have migrated to object parameters.
|
|
29
|
+
function normalizeFetchAgentOptions(input, legacyOptions) {
|
|
30
|
+
if (typeof input !== "string") {
|
|
31
|
+
return input;
|
|
32
|
+
}
|
|
33
|
+
if (typeof legacyOptions === "string") {
|
|
34
|
+
return { agentId: input, requestId: legacyOptions };
|
|
35
|
+
}
|
|
36
|
+
return { agentId: input, ...legacyOptions };
|
|
37
|
+
}
|
|
38
|
+
function normalizeListCommandsOptions(input, legacyOptions) {
|
|
39
|
+
if (typeof input !== "string") {
|
|
40
|
+
return input;
|
|
41
|
+
}
|
|
42
|
+
if (typeof legacyOptions === "string") {
|
|
43
|
+
return { agentId: input, requestId: legacyOptions };
|
|
44
|
+
}
|
|
45
|
+
return { agentId: input, ...legacyOptions };
|
|
46
|
+
}
|
|
47
|
+
class DaemonRpcError extends Error {
|
|
48
|
+
constructor(params) {
|
|
49
|
+
const parts = [params.error];
|
|
50
|
+
if (params.requestType)
|
|
51
|
+
parts.push(`requestType=${params.requestType}`);
|
|
52
|
+
if (params.code)
|
|
53
|
+
parts.push(`code=${params.code}`);
|
|
54
|
+
super(parts.join(" "));
|
|
55
|
+
this.name = "DaemonRpcError";
|
|
56
|
+
this.requestId = params.requestId;
|
|
57
|
+
this.requestType = params.requestType;
|
|
58
|
+
this.code = params.code;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Thrown when an in-flight `uploadFile()` is cancelled via its `AbortSignal`.
|
|
63
|
+
* Distinct from {@link UploadCapExceededError} and {@link UploadFailedError} so
|
|
64
|
+
* Epic 5's attachment-chip UI can render the "cancelled" state specifically.
|
|
65
|
+
*/
|
|
66
|
+
export class UploadCancelledError extends Error {
|
|
67
|
+
constructor(uploadId) {
|
|
68
|
+
super("Upload cancelled");
|
|
69
|
+
this.name = "UploadCancelledError";
|
|
70
|
+
this.uploadId = uploadId;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Thrown when the daemon rejects an upload because it exceeds the size cap —
|
|
75
|
+
* either the declared-size early reject at begin, or an incremental reject
|
|
76
|
+
* mid-stream (story 2.2). Carries the `too_large` code and any daemon detail.
|
|
77
|
+
*/
|
|
78
|
+
export class UploadCapExceededError extends Error {
|
|
79
|
+
constructor(uploadId, detail) {
|
|
80
|
+
super(detail ?? "Upload exceeds the size cap");
|
|
81
|
+
this.name = "UploadCapExceededError";
|
|
82
|
+
this.uploadId = uploadId;
|
|
83
|
+
this.code = "too_large";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Thrown for any other upload failure — transport error, `write_failed`,
|
|
88
|
+
* `checksum_mismatch`, or an unexpected daemon error. The third distinguishable
|
|
89
|
+
* reject path alongside cancelled and cap-exceeded.
|
|
90
|
+
*/
|
|
91
|
+
export class UploadFailedError extends Error {
|
|
92
|
+
constructor(uploadId, message, code) {
|
|
93
|
+
super(message);
|
|
94
|
+
this.name = "UploadFailedError";
|
|
95
|
+
this.uploadId = uploadId;
|
|
96
|
+
if (code) {
|
|
97
|
+
this.code = code;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Map a daemon upload error code to the matching typed client error.
|
|
103
|
+
* `too_large` → cap-exceeded; everything else → generic transfer failure.
|
|
104
|
+
*/
|
|
105
|
+
function mapUploadError(uploadId, code, message) {
|
|
106
|
+
if (code === "too_large") {
|
|
107
|
+
return new UploadCapExceededError(uploadId, message);
|
|
108
|
+
}
|
|
109
|
+
return new UploadFailedError(uploadId, message ?? `Upload failed: ${code}`, code);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Send-window target: the maximum number of unacked `FileChunk` frames in
|
|
113
|
+
* flight at once. Sits an order of magnitude below the relay's
|
|
114
|
+
* `MAX_PENDING_SENDS = 200` ceiling (`packages/relay/src/encrypted-channel.ts`),
|
|
115
|
+
* so a windowed upload can never burst past it.
|
|
116
|
+
*/
|
|
117
|
+
const UPLOAD_WINDOW_SIZE = 8;
|
|
118
|
+
class PingTimeoutError extends Error {
|
|
119
|
+
constructor(timeoutMs) {
|
|
120
|
+
super(`Ping timed out (${timeoutMs}ms)`);
|
|
121
|
+
this.timeoutMs = timeoutMs;
|
|
122
|
+
this.name = "PingTimeoutError";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function toTimeoutError(error, label, timeoutMs) {
|
|
126
|
+
if (error instanceof PingTimeoutError) {
|
|
127
|
+
return new Error(`${label} timed out (${timeoutMs}ms)`);
|
|
128
|
+
}
|
|
129
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
130
|
+
}
|
|
131
|
+
const DEFAULT_RECONNECT_BASE_DELAY_MS = 1500;
|
|
132
|
+
const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000;
|
|
133
|
+
const DEFAULT_SESSION_RPC_TIMEOUT_MS = 60000;
|
|
134
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 15000;
|
|
135
|
+
const DEFAULT_LIVENESS_TIMEOUT_MS = 5000;
|
|
136
|
+
const LIVENESS_HEARTBEAT_INTERVAL_MS = 10000;
|
|
137
|
+
const LIVENESS_HEARTBEAT_TIMEOUT_MS = 15000;
|
|
138
|
+
const LIVENESS_FAILURE_RECONNECT_THRESHOLD = 2;
|
|
139
|
+
/** Default timeout for waiting for connection before sending queued messages */
|
|
140
|
+
const DEFAULT_SEND_QUEUE_TIMEOUT_MS = DEFAULT_SESSION_RPC_TIMEOUT_MS;
|
|
141
|
+
const DEFAULT_DICTATION_FINISH_ACCEPT_TIMEOUT_MS = DEFAULT_SESSION_RPC_TIMEOUT_MS;
|
|
142
|
+
const DEFAULT_DICTATION_FINISH_FALLBACK_TIMEOUT_MS = 5 * 60 * 1000;
|
|
143
|
+
const DEFAULT_DICTATION_FINISH_TIMEOUT_GRACE_MS = 5000;
|
|
144
|
+
function isWaiterTimeoutError(error) {
|
|
145
|
+
return error instanceof Error && error.message.startsWith("Timeout waiting for message");
|
|
146
|
+
}
|
|
147
|
+
function normalizeClientId(value) {
|
|
148
|
+
if (typeof value !== "string") {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
const trimmed = value.trim();
|
|
152
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
153
|
+
}
|
|
154
|
+
function decodeBase64ToBytes(base64) {
|
|
155
|
+
const binary = globalThis.atob(base64);
|
|
156
|
+
const bytes = new Uint8Array(binary.length);
|
|
157
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
158
|
+
bytes[index] = binary.charCodeAt(index);
|
|
159
|
+
}
|
|
160
|
+
return bytes;
|
|
161
|
+
}
|
|
162
|
+
function legacyExplorerFileToBytes(file) {
|
|
163
|
+
let bytes;
|
|
164
|
+
if (file.encoding === "base64" && file.content) {
|
|
165
|
+
bytes = decodeBase64ToBytes(file.content);
|
|
166
|
+
}
|
|
167
|
+
else if (file.encoding === "utf-8" && file.content) {
|
|
168
|
+
bytes = new TextEncoder().encode(file.content);
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
bytes = new Uint8Array();
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
bytes,
|
|
175
|
+
mime: file.mimeType ?? "application/octet-stream",
|
|
176
|
+
size: file.size,
|
|
177
|
+
path: file.path,
|
|
178
|
+
kind: file.kind,
|
|
179
|
+
modifiedAt: file.modifiedAt,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function binaryFileKind(mime, encoding) {
|
|
183
|
+
if (mime.startsWith("image/")) {
|
|
184
|
+
return "image";
|
|
185
|
+
}
|
|
186
|
+
if (encoding === "utf-8" || mime.startsWith("text/") || mime === "application/json") {
|
|
187
|
+
return "text";
|
|
188
|
+
}
|
|
189
|
+
return "binary";
|
|
190
|
+
}
|
|
191
|
+
function concatByteChunks(chunks, size) {
|
|
192
|
+
const bytes = new Uint8Array(size);
|
|
193
|
+
let offset = 0;
|
|
194
|
+
for (const chunk of chunks) {
|
|
195
|
+
bytes.set(chunk, offset);
|
|
196
|
+
offset += chunk.byteLength;
|
|
197
|
+
}
|
|
198
|
+
return bytes;
|
|
199
|
+
}
|
|
200
|
+
function hashForLog(value) {
|
|
201
|
+
let hash = 0;
|
|
202
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
203
|
+
hash = (hash * 31 + value.charCodeAt(index)) | 0;
|
|
204
|
+
}
|
|
205
|
+
return `h_${Math.abs(hash).toString(16)}`;
|
|
206
|
+
}
|
|
207
|
+
function toReasonCode(reason) {
|
|
208
|
+
if (!reason) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
const normalized = reason.toLowerCase();
|
|
212
|
+
if (normalized.includes("timed out")) {
|
|
213
|
+
return "connect_timeout";
|
|
214
|
+
}
|
|
215
|
+
if (normalized.includes("disposed")) {
|
|
216
|
+
return "disposed";
|
|
217
|
+
}
|
|
218
|
+
if (normalized.includes("client closed")) {
|
|
219
|
+
return "client_closed";
|
|
220
|
+
}
|
|
221
|
+
if (normalized.includes("transport")) {
|
|
222
|
+
return "transport_error";
|
|
223
|
+
}
|
|
224
|
+
if (normalized.includes("failed to connect")) {
|
|
225
|
+
return "connect_failed";
|
|
226
|
+
}
|
|
227
|
+
return "unknown";
|
|
228
|
+
}
|
|
229
|
+
export class DaemonClient {
|
|
230
|
+
constructor(config) {
|
|
231
|
+
this.config = config;
|
|
232
|
+
this.transport = null;
|
|
233
|
+
this.transportCleanup = [];
|
|
234
|
+
this.rawMessageListeners = new Set();
|
|
235
|
+
this.messageHandlers = new Map();
|
|
236
|
+
this.eventListeners = new Set();
|
|
237
|
+
this.waiters = new Set();
|
|
238
|
+
this.checkoutStatusInFlight = new Map();
|
|
239
|
+
this.connectionListeners = new Set();
|
|
240
|
+
this.reconnectTimeout = null;
|
|
241
|
+
this.connectTimeout = null;
|
|
242
|
+
this.pendingGenericTransportErrorTimeout = null;
|
|
243
|
+
this.reconnectAttempt = 0;
|
|
244
|
+
this.shouldReconnect = true;
|
|
245
|
+
this.connectPromise = null;
|
|
246
|
+
this.connectResolve = null;
|
|
247
|
+
this.connectReject = null;
|
|
248
|
+
this.lastErrorValue = null;
|
|
249
|
+
this.connectionState = { status: "idle" };
|
|
250
|
+
this.checkoutDiffSubscriptions = new Map();
|
|
251
|
+
this.terminalDirectorySubscriptions = new Map();
|
|
252
|
+
this.terminalStreams = new TerminalStreamRouter();
|
|
253
|
+
this.pendingBinaryFileReads = new Map();
|
|
254
|
+
this.activeBinaryFileTransfers = new Map();
|
|
255
|
+
this.completedBinaryFileReads = new Map();
|
|
256
|
+
this.pendingSendQueue = [];
|
|
257
|
+
this.lastServerInfoMessage = null;
|
|
258
|
+
this.runtimeMetricsInterval = null;
|
|
259
|
+
this.runtimeMetrics = null;
|
|
260
|
+
this.pingProbe = null;
|
|
261
|
+
this.livenessHeartbeatTimer = null;
|
|
262
|
+
this.lastLivenessRttMs = null;
|
|
263
|
+
this.consecutiveLivenessFailures = 0;
|
|
264
|
+
this.logger = config.logger ?? consoleLogger;
|
|
265
|
+
this.logConnectionPath = isRelayClientWebSocketUrl(this.config.url) ? "relay" : "direct";
|
|
266
|
+
let parsedUrlForLog = null;
|
|
267
|
+
try {
|
|
268
|
+
parsedUrlForLog = new URL(this.config.url);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
parsedUrlForLog = null;
|
|
272
|
+
}
|
|
273
|
+
const parsedServerIdForLog = normalizeClientId(parsedUrlForLog?.searchParams.get("serverId"));
|
|
274
|
+
this.logServerId = parsedServerIdForLog ?? parsedUrlForLog?.host ?? null;
|
|
275
|
+
const resolvedClientId = normalizeClientId(this.config.clientId);
|
|
276
|
+
if (!resolvedClientId) {
|
|
277
|
+
throw new Error("Daemon client requires a non-empty clientId");
|
|
278
|
+
}
|
|
279
|
+
this.config.clientId = resolvedClientId;
|
|
280
|
+
this.logClientIdHash = hashForLog(resolvedClientId);
|
|
281
|
+
this.logGeneration =
|
|
282
|
+
typeof this.config.runtimeGeneration === "number" &&
|
|
283
|
+
Number.isFinite(this.config.runtimeGeneration)
|
|
284
|
+
? this.config.runtimeGeneration
|
|
285
|
+
: null;
|
|
286
|
+
const runtimeMetricsIntervalMs = typeof config.runtimeMetricsIntervalMs === "number" && config.runtimeMetricsIntervalMs > 0
|
|
287
|
+
? config.runtimeMetricsIntervalMs
|
|
288
|
+
: 0;
|
|
289
|
+
if (runtimeMetricsIntervalMs > 0) {
|
|
290
|
+
const runtimeMetricsWindowMs = typeof config.runtimeMetricsWindowMs === "number" && config.runtimeMetricsWindowMs > 0
|
|
291
|
+
? Math.max(config.runtimeMetricsWindowMs, runtimeMetricsIntervalMs)
|
|
292
|
+
: undefined;
|
|
293
|
+
this.runtimeMetrics = new DaemonClientRuntimeMetrics(this.logger, {
|
|
294
|
+
connectionPath: this.logConnectionPath,
|
|
295
|
+
serverId: this.logServerId,
|
|
296
|
+
getConnectionStatus: () => this.connectionState.status,
|
|
297
|
+
}, runtimeMetricsWindowMs ? { windowMs: runtimeMetricsWindowMs } : undefined);
|
|
298
|
+
this.runtimeMetricsInterval = setInterval(() => {
|
|
299
|
+
this.runtimeMetrics?.flush();
|
|
300
|
+
}, runtimeMetricsIntervalMs);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// ============================================================================
|
|
304
|
+
// Connection
|
|
305
|
+
// ============================================================================
|
|
306
|
+
async connect() {
|
|
307
|
+
if (this.connectionState.status === "disposed") {
|
|
308
|
+
throw new Error("Daemon client is disposed");
|
|
309
|
+
}
|
|
310
|
+
if (this.connectionState.status === "connected") {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (this.connectPromise) {
|
|
314
|
+
return this.connectPromise;
|
|
315
|
+
}
|
|
316
|
+
this.shouldReconnect = true;
|
|
317
|
+
this.connectPromise = new Promise((resolve, reject) => {
|
|
318
|
+
this.connectResolve = resolve;
|
|
319
|
+
this.connectReject = reject;
|
|
320
|
+
this.attemptConnect();
|
|
321
|
+
});
|
|
322
|
+
return this.connectPromise;
|
|
323
|
+
}
|
|
324
|
+
attemptConnect() {
|
|
325
|
+
if (this.connectionState.status === "disposed") {
|
|
326
|
+
this.rejectConnect(new Error("Daemon client is disposed"));
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (!this.shouldReconnect) {
|
|
330
|
+
this.rejectConnect(new Error("Daemon client is closed"));
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (this.connectionState.status === "connecting") {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const headers = {};
|
|
337
|
+
const password = normalizePassword(this.config.password);
|
|
338
|
+
if (password) {
|
|
339
|
+
headers.Authorization = `Bearer ${password}`;
|
|
340
|
+
}
|
|
341
|
+
else if (this.config.authHeader) {
|
|
342
|
+
headers.Authorization = this.config.authHeader;
|
|
343
|
+
}
|
|
344
|
+
const protocols = password ? [`paseo.bearer.${password}`] : undefined;
|
|
345
|
+
try {
|
|
346
|
+
// Reconnect can overlap with browser close/error delivery ordering.
|
|
347
|
+
// Always dispose previous transport before constructing the next one.
|
|
348
|
+
this.disposeTransport();
|
|
349
|
+
const baseTransportFactory = this.config.transportFactory ??
|
|
350
|
+
createWebSocketTransportFactory(this.config.webSocketFactory ?? defaultWebSocketFactory);
|
|
351
|
+
const shouldUseRelayE2ee = this.config.e2ee?.enabled === true && isRelayClientWebSocketUrl(this.config.url);
|
|
352
|
+
let transportFactory = baseTransportFactory;
|
|
353
|
+
if (shouldUseRelayE2ee) {
|
|
354
|
+
const daemonPublicKeyB64 = this.config.e2ee?.daemonPublicKeyB64;
|
|
355
|
+
if (!daemonPublicKeyB64) {
|
|
356
|
+
throw new Error("daemonPublicKeyB64 is required for relay E2EE");
|
|
357
|
+
}
|
|
358
|
+
transportFactory = createRelayE2eeTransportFactory({
|
|
359
|
+
baseFactory: baseTransportFactory,
|
|
360
|
+
daemonPublicKeyB64,
|
|
361
|
+
logger: this.logger,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
const transportUrl = this.resolveTransportUrlForAttempt();
|
|
365
|
+
const transport = transportFactory({
|
|
366
|
+
url: transportUrl,
|
|
367
|
+
headers,
|
|
368
|
+
...(protocols ? { protocols } : {}),
|
|
369
|
+
});
|
|
370
|
+
this.transport = transport;
|
|
371
|
+
this.lastServerInfoMessage = null;
|
|
372
|
+
this.updateConnectionState({
|
|
373
|
+
status: "connecting",
|
|
374
|
+
attempt: this.reconnectAttempt,
|
|
375
|
+
}, { event: "CONNECT_REQUEST" });
|
|
376
|
+
this.resetConnectTimeout();
|
|
377
|
+
const timeoutMs = Math.max(1, this.config.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);
|
|
378
|
+
this.connectTimeout = setTimeout(() => {
|
|
379
|
+
if (this.connectionState.status !== "connecting") {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
this.lastErrorValue = "Connection timed out";
|
|
383
|
+
this.disposeTransport(1001, "Connection timed out");
|
|
384
|
+
this.scheduleReconnect({
|
|
385
|
+
reason: "Connection timed out",
|
|
386
|
+
event: "CONNECT_TIMEOUT",
|
|
387
|
+
reasonCode: "connect_timeout",
|
|
388
|
+
});
|
|
389
|
+
}, timeoutMs);
|
|
390
|
+
this.transportCleanup = [
|
|
391
|
+
transport.onOpen(() => {
|
|
392
|
+
if (this.pendingGenericTransportErrorTimeout) {
|
|
393
|
+
clearTimeout(this.pendingGenericTransportErrorTimeout);
|
|
394
|
+
this.pendingGenericTransportErrorTimeout = null;
|
|
395
|
+
}
|
|
396
|
+
this.lastErrorValue = null;
|
|
397
|
+
this.sendHelloMessage();
|
|
398
|
+
}),
|
|
399
|
+
transport.onClose((event) => {
|
|
400
|
+
this.resetConnectTimeout();
|
|
401
|
+
if (this.pendingGenericTransportErrorTimeout) {
|
|
402
|
+
clearTimeout(this.pendingGenericTransportErrorTimeout);
|
|
403
|
+
this.pendingGenericTransportErrorTimeout = null;
|
|
404
|
+
}
|
|
405
|
+
const reason = describeTransportClose(event);
|
|
406
|
+
if (reason) {
|
|
407
|
+
this.lastErrorValue = reason;
|
|
408
|
+
}
|
|
409
|
+
this.scheduleReconnect({
|
|
410
|
+
reason,
|
|
411
|
+
event: "TRANSPORT_CLOSE",
|
|
412
|
+
reasonCode: "transport_closed",
|
|
413
|
+
});
|
|
414
|
+
}),
|
|
415
|
+
transport.onError((event) => {
|
|
416
|
+
this.resetConnectTimeout();
|
|
417
|
+
const reason = describeTransportError(event);
|
|
418
|
+
const isGeneric = reason === "Transport error";
|
|
419
|
+
// Browser WebSocket.onerror often provides no useful details and is followed
|
|
420
|
+
// by a close event (often with code 1006). Prefer surfacing the close details
|
|
421
|
+
// instead of immediately disconnecting with a generic "Transport error".
|
|
422
|
+
if (isGeneric) {
|
|
423
|
+
this.lastErrorValue ?? (this.lastErrorValue = reason);
|
|
424
|
+
if (!this.pendingGenericTransportErrorTimeout) {
|
|
425
|
+
this.pendingGenericTransportErrorTimeout = setTimeout(() => {
|
|
426
|
+
this.pendingGenericTransportErrorTimeout = null;
|
|
427
|
+
if (this.connectionState.status === "connected" ||
|
|
428
|
+
this.connectionState.status === "connecting") {
|
|
429
|
+
this.lastErrorValue = reason;
|
|
430
|
+
this.scheduleReconnect({
|
|
431
|
+
reason,
|
|
432
|
+
event: "TRANSPORT_ERROR",
|
|
433
|
+
reasonCode: "transport_error",
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}, 250);
|
|
437
|
+
}
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (this.pendingGenericTransportErrorTimeout) {
|
|
441
|
+
clearTimeout(this.pendingGenericTransportErrorTimeout);
|
|
442
|
+
this.pendingGenericTransportErrorTimeout = null;
|
|
443
|
+
}
|
|
444
|
+
this.lastErrorValue = reason;
|
|
445
|
+
this.scheduleReconnect({
|
|
446
|
+
reason,
|
|
447
|
+
event: "TRANSPORT_ERROR",
|
|
448
|
+
reasonCode: "transport_error",
|
|
449
|
+
});
|
|
450
|
+
}),
|
|
451
|
+
transport.onMessage((data) => this.handleTransportMessage(data)),
|
|
452
|
+
];
|
|
453
|
+
}
|
|
454
|
+
catch (error) {
|
|
455
|
+
this.resetConnectTimeout();
|
|
456
|
+
const message = error instanceof Error ? error.message : "Failed to connect";
|
|
457
|
+
this.lastErrorValue = message;
|
|
458
|
+
this.scheduleReconnect({
|
|
459
|
+
reason: message,
|
|
460
|
+
event: "CONNECT_FAILED",
|
|
461
|
+
reasonCode: "connect_failed",
|
|
462
|
+
});
|
|
463
|
+
this.rejectConnect(error instanceof Error ? error : new Error(message));
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
resolveConnect() {
|
|
467
|
+
if (this.connectResolve) {
|
|
468
|
+
this.connectResolve();
|
|
469
|
+
}
|
|
470
|
+
this.connectPromise = null;
|
|
471
|
+
this.connectResolve = null;
|
|
472
|
+
this.connectReject = null;
|
|
473
|
+
}
|
|
474
|
+
rejectConnect(error) {
|
|
475
|
+
if (this.connectReject) {
|
|
476
|
+
this.connectReject(error);
|
|
477
|
+
}
|
|
478
|
+
this.connectPromise = null;
|
|
479
|
+
this.connectResolve = null;
|
|
480
|
+
this.connectReject = null;
|
|
481
|
+
}
|
|
482
|
+
async close() {
|
|
483
|
+
if (this.connectionState.status === "disposed") {
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
this.shouldReconnect = false;
|
|
487
|
+
this.connectPromise = null;
|
|
488
|
+
this.connectResolve = null;
|
|
489
|
+
this.connectReject = null;
|
|
490
|
+
if (this.reconnectTimeout) {
|
|
491
|
+
clearTimeout(this.reconnectTimeout);
|
|
492
|
+
this.reconnectTimeout = null;
|
|
493
|
+
}
|
|
494
|
+
this.resetConnectTimeout();
|
|
495
|
+
this.disposeTransport(1000, "Client closed");
|
|
496
|
+
this.clearWaiters(new Error("Daemon client closed"));
|
|
497
|
+
this.rejectPendingSendQueue(new Error("Daemon client closed"));
|
|
498
|
+
this.rejectPingProbe(new Error("Daemon client closed"));
|
|
499
|
+
this.terminalStreams.clearSlots();
|
|
500
|
+
this.lastServerInfoMessage = null;
|
|
501
|
+
if (this.runtimeMetricsInterval) {
|
|
502
|
+
clearInterval(this.runtimeMetricsInterval);
|
|
503
|
+
this.runtimeMetricsInterval = null;
|
|
504
|
+
this.runtimeMetrics?.flush({ final: true });
|
|
505
|
+
this.runtimeMetrics = null;
|
|
506
|
+
}
|
|
507
|
+
this.updateConnectionState({ status: "disposed" }, { event: "DISPOSE", reason: "Client closed", reasonCode: "disposed" });
|
|
508
|
+
}
|
|
509
|
+
ensureConnected() {
|
|
510
|
+
if (this.connectionState.status === "disposed") {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (!this.shouldReconnect) {
|
|
514
|
+
this.shouldReconnect = true;
|
|
515
|
+
}
|
|
516
|
+
if (this.connectionState.status === "connected" ||
|
|
517
|
+
this.connectionState.status === "connecting") {
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
void this.connect();
|
|
521
|
+
}
|
|
522
|
+
getConnectionState() {
|
|
523
|
+
return this.connectionState;
|
|
524
|
+
}
|
|
525
|
+
subscribeConnectionStatus(listener) {
|
|
526
|
+
this.connectionListeners.add(listener);
|
|
527
|
+
listener(this.connectionState);
|
|
528
|
+
return () => {
|
|
529
|
+
this.connectionListeners.delete(listener);
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
get isConnected() {
|
|
533
|
+
return this.connectionState.status === "connected";
|
|
534
|
+
}
|
|
535
|
+
get isConnecting() {
|
|
536
|
+
return this.connectionState.status === "connecting";
|
|
537
|
+
}
|
|
538
|
+
get lastError() {
|
|
539
|
+
return this.lastErrorValue;
|
|
540
|
+
}
|
|
541
|
+
getLastLivenessRttMs() {
|
|
542
|
+
return this.lastLivenessRttMs;
|
|
543
|
+
}
|
|
544
|
+
// ============================================================================
|
|
545
|
+
// Message Subscription
|
|
546
|
+
// ============================================================================
|
|
547
|
+
subscribe(handler) {
|
|
548
|
+
this.eventListeners.add(handler);
|
|
549
|
+
return () => this.eventListeners.delete(handler);
|
|
550
|
+
}
|
|
551
|
+
subscribeRawMessages(handler) {
|
|
552
|
+
this.rawMessageListeners.add(handler);
|
|
553
|
+
return () => {
|
|
554
|
+
this.rawMessageListeners.delete(handler);
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
on(arg1, arg2) {
|
|
558
|
+
if (typeof arg1 === "function") {
|
|
559
|
+
return this.subscribe(arg1);
|
|
560
|
+
}
|
|
561
|
+
const type = arg1;
|
|
562
|
+
const handler = arg2;
|
|
563
|
+
if (!this.messageHandlers.has(type)) {
|
|
564
|
+
this.messageHandlers.set(type, new Set());
|
|
565
|
+
}
|
|
566
|
+
this.messageHandlers.get(type).add(handler);
|
|
567
|
+
return () => {
|
|
568
|
+
const handlers = this.messageHandlers.get(type);
|
|
569
|
+
if (!handlers) {
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
handlers.delete(handler);
|
|
573
|
+
if (handlers.size === 0) {
|
|
574
|
+
this.messageHandlers.delete(type);
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
// ============================================================================
|
|
579
|
+
// Core Send Helpers
|
|
580
|
+
// ============================================================================
|
|
581
|
+
/**
|
|
582
|
+
* Send a session message. For fire-and-forget messages (heartbeats, etc.),
|
|
583
|
+
* failures are suppressed if `suppressSendErrors` is configured.
|
|
584
|
+
* For RPC methods that wait for responses, use `sendSessionMessageOrThrow` instead.
|
|
585
|
+
*/
|
|
586
|
+
sendSessionMessage(message) {
|
|
587
|
+
if (!this.transport || this.connectionState.status !== "connected") {
|
|
588
|
+
if (this.config.suppressSendErrors) {
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
throw new Error(`Transport not connected (status: ${this.connectionState.status})`);
|
|
592
|
+
}
|
|
593
|
+
const payload = SessionInboundMessageSchema.parse(message);
|
|
594
|
+
try {
|
|
595
|
+
this.transport.send(JSON.stringify({ type: "session", message: payload }));
|
|
596
|
+
}
|
|
597
|
+
catch (error) {
|
|
598
|
+
if (this.config.suppressSendErrors) {
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
sendBinaryFrame(frame) {
|
|
605
|
+
if (!this.transport || this.connectionState.status !== "connected") {
|
|
606
|
+
if (this.config.suppressSendErrors) {
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
throw new Error(`Transport not connected (status: ${this.connectionState.status})`);
|
|
610
|
+
}
|
|
611
|
+
try {
|
|
612
|
+
this.transport.send(frame);
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
if (this.config.suppressSendErrors) {
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Send a session message for RPC methods that create waiters.
|
|
623
|
+
* If the connection is still being established ("connecting"), the message
|
|
624
|
+
* is queued and will be sent once connected (or rejected after timeout).
|
|
625
|
+
* This prevents waiters from hanging forever when called during connection.
|
|
626
|
+
*/
|
|
627
|
+
sendSessionMessageOrThrow(message) {
|
|
628
|
+
const status = this.connectionState.status;
|
|
629
|
+
// If connected, send immediately
|
|
630
|
+
if (this.transport && status === "connected") {
|
|
631
|
+
const payload = SessionInboundMessageSchema.parse(message);
|
|
632
|
+
this.transport.send(JSON.stringify({ type: "session", message: payload }));
|
|
633
|
+
return Promise.resolve();
|
|
634
|
+
}
|
|
635
|
+
// If connecting, queue the message to be sent once connected
|
|
636
|
+
if (status === "connecting") {
|
|
637
|
+
return new Promise((resolve, reject) => {
|
|
638
|
+
const timeoutHandle = setTimeout(() => {
|
|
639
|
+
// Remove from queue
|
|
640
|
+
const idx = this.pendingSendQueue.findIndex((p) => p.resolve === resolve);
|
|
641
|
+
if (idx !== -1) {
|
|
642
|
+
this.pendingSendQueue.splice(idx, 1);
|
|
643
|
+
}
|
|
644
|
+
reject(new Error(`Timed out waiting for connection to send message`));
|
|
645
|
+
}, this.config.defaultSendQueueTimeoutMs ?? DEFAULT_SEND_QUEUE_TIMEOUT_MS);
|
|
646
|
+
this.pendingSendQueue.push({ message, resolve, reject, timeoutHandle });
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
// Not connected and not connecting - fail immediately
|
|
650
|
+
return Promise.reject(new Error(`Transport not connected (status: ${status})`));
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Flush pending send queue - called when connection is established.
|
|
654
|
+
*/
|
|
655
|
+
flushPendingSendQueue() {
|
|
656
|
+
const queue = this.pendingSendQueue;
|
|
657
|
+
this.pendingSendQueue = [];
|
|
658
|
+
for (const pending of queue) {
|
|
659
|
+
clearTimeout(pending.timeoutHandle);
|
|
660
|
+
try {
|
|
661
|
+
if (this.transport && this.connectionState.status === "connected") {
|
|
662
|
+
const payload = SessionInboundMessageSchema.parse(pending.message);
|
|
663
|
+
this.transport.send(JSON.stringify({ type: "session", message: payload }));
|
|
664
|
+
pending.resolve();
|
|
665
|
+
}
|
|
666
|
+
else {
|
|
667
|
+
pending.reject(new Error("Connection lost before message could be sent"));
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
catch (error) {
|
|
671
|
+
pending.reject(error instanceof Error ? error : new Error(String(error)));
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Reject all pending sends - called when connection fails or is closed.
|
|
677
|
+
*/
|
|
678
|
+
rejectPendingSendQueue(error) {
|
|
679
|
+
const queue = this.pendingSendQueue;
|
|
680
|
+
this.pendingSendQueue = [];
|
|
681
|
+
for (const pending of queue) {
|
|
682
|
+
clearTimeout(pending.timeoutHandle);
|
|
683
|
+
pending.reject(error);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
// `protected` (not `private`) so test-only DaemonClient subclasses can issue
|
|
687
|
+
// typed RPCs the public client surface does not expose yet (e.g. the Story 1.3
|
|
688
|
+
// `workflow.get` RPC used by the kill-proof daemon-e2e smoke). No behavior change.
|
|
689
|
+
async sendRequest(params) {
|
|
690
|
+
const timeout = params.timeout ?? DEFAULT_SESSION_RPC_TIMEOUT_MS;
|
|
691
|
+
const { promise, cancel } = this.waitForWithCancel((msg) => {
|
|
692
|
+
if (msg.type === "rpc_error" && msg.payload.requestId === params.requestId) {
|
|
693
|
+
return {
|
|
694
|
+
kind: "error",
|
|
695
|
+
error: new DaemonRpcError({
|
|
696
|
+
requestId: msg.payload.requestId,
|
|
697
|
+
error: msg.payload.error,
|
|
698
|
+
requestType: msg.payload.requestType,
|
|
699
|
+
code: msg.payload.code,
|
|
700
|
+
}),
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
const value = params.select(msg);
|
|
704
|
+
if (value === null) {
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
return { kind: "ok", value };
|
|
708
|
+
}, timeout, params.options);
|
|
709
|
+
try {
|
|
710
|
+
await this.sendSessionMessageOrThrow(params.message);
|
|
711
|
+
}
|
|
712
|
+
catch (error) {
|
|
713
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
714
|
+
cancel(err);
|
|
715
|
+
void promise.catch(() => undefined);
|
|
716
|
+
throw err;
|
|
717
|
+
}
|
|
718
|
+
const result = await promise;
|
|
719
|
+
if (result.kind === "error") {
|
|
720
|
+
throw result.error;
|
|
721
|
+
}
|
|
722
|
+
return result.value;
|
|
723
|
+
}
|
|
724
|
+
async sendCorrelatedRequest(params) {
|
|
725
|
+
return this.sendRequest({
|
|
726
|
+
requestId: params.requestId,
|
|
727
|
+
message: params.message,
|
|
728
|
+
timeout: params.timeout,
|
|
729
|
+
options: params.options,
|
|
730
|
+
select: (msg) => {
|
|
731
|
+
const correlated = msg;
|
|
732
|
+
if (correlated.type !== params.responseType) {
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
const payload = correlated.payload;
|
|
736
|
+
if (payload.requestId !== params.requestId) {
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
if (!params.selectPayload) {
|
|
740
|
+
// Identity cast through `unknown`: TResult defaults to
|
|
741
|
+
// CorrelatedResponsePayload<TResponseType> but is a free type param, so a
|
|
742
|
+
// direct `as TResult` trips TS2352 once the correlated-response union grows
|
|
743
|
+
// (mirrors the bridge two lines up). COMPAT(workflows): surfaced when
|
|
744
|
+
// `workflow.start.response` joined the union (Story 3.3).
|
|
745
|
+
return payload;
|
|
746
|
+
}
|
|
747
|
+
return params.selectPayload(payload);
|
|
748
|
+
},
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
sendCorrelatedSessionRequest(params) {
|
|
752
|
+
const resolvedRequestId = this.createRequestId(params.requestId);
|
|
753
|
+
const message = SessionInboundMessageSchema.parse({
|
|
754
|
+
...params.message,
|
|
755
|
+
requestId: resolvedRequestId,
|
|
756
|
+
});
|
|
757
|
+
return this.sendCorrelatedRequest({
|
|
758
|
+
requestId: resolvedRequestId,
|
|
759
|
+
message,
|
|
760
|
+
responseType: params.responseType,
|
|
761
|
+
timeout: params.timeout,
|
|
762
|
+
options: { skipQueue: true },
|
|
763
|
+
...(params.selectPayload ? { selectPayload: params.selectPayload } : {}),
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
sendNamespacedCorrelatedSessionRequest(params) {
|
|
767
|
+
const responseType = params.message.type.replace(/\.request$/, ".response");
|
|
768
|
+
return this.sendCorrelatedSessionRequest({
|
|
769
|
+
...params,
|
|
770
|
+
responseType,
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
sendSessionMessageStrict(message) {
|
|
774
|
+
if (!this.transport || this.connectionState.status !== "connected") {
|
|
775
|
+
throw new Error("Transport not connected");
|
|
776
|
+
}
|
|
777
|
+
const payload = SessionInboundMessageSchema.parse(message);
|
|
778
|
+
try {
|
|
779
|
+
this.transport.send(JSON.stringify({ type: "session", message: payload }));
|
|
780
|
+
}
|
|
781
|
+
catch (error) {
|
|
782
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
async clearAgentAttention(agentId) {
|
|
786
|
+
const requestId = this.createRequestId();
|
|
787
|
+
const message = SessionInboundMessageSchema.parse({
|
|
788
|
+
type: "clear_agent_attention",
|
|
789
|
+
agentId,
|
|
790
|
+
requestId,
|
|
791
|
+
});
|
|
792
|
+
await this.sendRequest({
|
|
793
|
+
requestId,
|
|
794
|
+
message,
|
|
795
|
+
options: { skipQueue: true },
|
|
796
|
+
select: (msg) => {
|
|
797
|
+
if (msg.type !== "clear_agent_attention_response") {
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
if (msg.payload.requestId !== requestId) {
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
return msg.payload;
|
|
804
|
+
},
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
async clearWorkspaceAttention(workspaceId) {
|
|
808
|
+
const requestId = this.createRequestId();
|
|
809
|
+
const message = SessionInboundMessageSchema.parse({
|
|
810
|
+
type: "workspace.clear_attention.request",
|
|
811
|
+
workspaceId,
|
|
812
|
+
requestId,
|
|
813
|
+
});
|
|
814
|
+
const response = await this.sendRequest({
|
|
815
|
+
requestId,
|
|
816
|
+
message,
|
|
817
|
+
options: { skipQueue: true },
|
|
818
|
+
select: (msg) => {
|
|
819
|
+
if (msg.type !== "workspace.clear_attention.response") {
|
|
820
|
+
return null;
|
|
821
|
+
}
|
|
822
|
+
if (msg.payload.requestId !== requestId) {
|
|
823
|
+
return null;
|
|
824
|
+
}
|
|
825
|
+
return msg.payload;
|
|
826
|
+
},
|
|
827
|
+
});
|
|
828
|
+
if (!response.success) {
|
|
829
|
+
throw new Error(response.error ?? "Failed to clear workspace attention");
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
sendHeartbeat(params) {
|
|
833
|
+
this.sendSessionMessage({
|
|
834
|
+
type: "client_heartbeat",
|
|
835
|
+
deviceType: params.deviceType,
|
|
836
|
+
focusedAgentId: params.focusedAgentId,
|
|
837
|
+
focusedTerminalId: params.focusedTerminalId ?? null,
|
|
838
|
+
lastActivityAt: params.lastActivityAt,
|
|
839
|
+
appVisible: params.appVisible,
|
|
840
|
+
appVisibilityChangedAt: params.appVisibilityChangedAt,
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
registerPushToken(token) {
|
|
844
|
+
this.sendSessionMessage({
|
|
845
|
+
type: "register_push_token",
|
|
846
|
+
token,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Relay a watch-originated action (`watch.*`) over the existing WebSocket
|
|
851
|
+
* session transport. Fire-and-forget: the watch message types are already
|
|
852
|
+
* members of `SessionInboundMessageSchema`, so this forwards straight to the
|
|
853
|
+
* private `sendSessionMessage` path (`{ type: "session", message }`) — never
|
|
854
|
+
* an HTTP route, never a response-waiting RPC.
|
|
855
|
+
*/
|
|
856
|
+
sendWatchAction(message) {
|
|
857
|
+
this.sendSessionMessage(message);
|
|
858
|
+
}
|
|
859
|
+
async ping(params) {
|
|
860
|
+
const requestId = params?.requestId ?? `ping-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
861
|
+
const clientSentAt = Date.now();
|
|
862
|
+
const payload = await this.sendRequest({
|
|
863
|
+
requestId,
|
|
864
|
+
message: { type: "ping", requestId, clientSentAt },
|
|
865
|
+
timeout: params?.timeoutMs ?? 5000,
|
|
866
|
+
select: (msg) => {
|
|
867
|
+
if (msg.type !== "pong")
|
|
868
|
+
return null;
|
|
869
|
+
if (msg.payload.requestId !== requestId)
|
|
870
|
+
return null;
|
|
871
|
+
if (typeof msg.payload.serverReceivedAt !== "number")
|
|
872
|
+
return null;
|
|
873
|
+
if (typeof msg.payload.serverSentAt !== "number")
|
|
874
|
+
return null;
|
|
875
|
+
return msg.payload;
|
|
876
|
+
},
|
|
877
|
+
});
|
|
878
|
+
return {
|
|
879
|
+
requestId,
|
|
880
|
+
clientSentAt,
|
|
881
|
+
serverReceivedAt: payload.serverReceivedAt,
|
|
882
|
+
serverSentAt: payload.serverSentAt,
|
|
883
|
+
rttMs: Date.now() - clientSentAt,
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
measureLatency(params) {
|
|
887
|
+
const timeoutMs = Math.max(1, params?.timeoutMs ?? DEFAULT_LIVENESS_TIMEOUT_MS);
|
|
888
|
+
return this.sendPingAwaitRtt({ timeoutMs, drivesLivenessFailure: false }).catch((error) => {
|
|
889
|
+
throw toTimeoutError(error, "Latency measurement", timeoutMs);
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
async livenessPing(params) {
|
|
893
|
+
const timeoutMs = Math.max(1, params?.timeoutMs ?? DEFAULT_LIVENESS_TIMEOUT_MS);
|
|
894
|
+
try {
|
|
895
|
+
const rttMs = await this.sendPingAwaitRtt({ timeoutMs, drivesLivenessFailure: true });
|
|
896
|
+
this.lastLivenessRttMs = rttMs;
|
|
897
|
+
return rttMs;
|
|
898
|
+
}
|
|
899
|
+
catch (error) {
|
|
900
|
+
throw toTimeoutError(error, "Liveness check", timeoutMs);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
sendPingAwaitRtt(params) {
|
|
904
|
+
if (this.connectionState.status !== "connected" || !this.transport) {
|
|
905
|
+
return Promise.reject(new Error(`Transport not connected (status: ${this.connectionState.status})`));
|
|
906
|
+
}
|
|
907
|
+
if (this.pingProbe) {
|
|
908
|
+
return this.pingProbe.promise;
|
|
909
|
+
}
|
|
910
|
+
const startedAt = perfNow();
|
|
911
|
+
const timeoutMs = params.timeoutMs;
|
|
912
|
+
let resolveProbe = null;
|
|
913
|
+
let rejectProbe = null;
|
|
914
|
+
const promise = new Promise((resolve, reject) => {
|
|
915
|
+
resolveProbe = resolve;
|
|
916
|
+
rejectProbe = reject;
|
|
917
|
+
});
|
|
918
|
+
const probe = {
|
|
919
|
+
promise,
|
|
920
|
+
resolve: (value) => resolveProbe?.(value),
|
|
921
|
+
reject: (error) => rejectProbe?.(error),
|
|
922
|
+
timeoutHandle: setTimeout(() => {
|
|
923
|
+
if (this.pingProbe !== probe) {
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
this.pingProbe = null;
|
|
927
|
+
const error = new PingTimeoutError(timeoutMs);
|
|
928
|
+
probe.reject(error);
|
|
929
|
+
if (probe.drivesLivenessFailure) {
|
|
930
|
+
this.recordLivenessFailure(toTimeoutError(error, "Liveness check", timeoutMs));
|
|
931
|
+
}
|
|
932
|
+
}, timeoutMs),
|
|
933
|
+
startedAt,
|
|
934
|
+
drivesLivenessFailure: params.drivesLivenessFailure,
|
|
935
|
+
};
|
|
936
|
+
this.pingProbe = probe;
|
|
937
|
+
try {
|
|
938
|
+
this.transport.send(JSON.stringify({ type: "ping" }));
|
|
939
|
+
}
|
|
940
|
+
catch (error) {
|
|
941
|
+
this.clearPingProbe();
|
|
942
|
+
const sendError = error instanceof Error ? error : new Error(String(error));
|
|
943
|
+
if (probe.drivesLivenessFailure) {
|
|
944
|
+
this.recordLivenessFailure(sendError);
|
|
945
|
+
}
|
|
946
|
+
return Promise.reject(sendError);
|
|
947
|
+
}
|
|
948
|
+
return promise;
|
|
949
|
+
}
|
|
950
|
+
startLivenessHeartbeat() {
|
|
951
|
+
this.stopLivenessHeartbeat();
|
|
952
|
+
this.lastLivenessRttMs = null;
|
|
953
|
+
this.scheduleNextLivenessHeartbeat();
|
|
954
|
+
}
|
|
955
|
+
stopLivenessHeartbeat() {
|
|
956
|
+
if (!this.livenessHeartbeatTimer) {
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
clearTimeout(this.livenessHeartbeatTimer);
|
|
960
|
+
this.livenessHeartbeatTimer = null;
|
|
961
|
+
}
|
|
962
|
+
scheduleNextLivenessHeartbeat() {
|
|
963
|
+
if (this.connectionState.status !== "connected" || this.livenessHeartbeatTimer) {
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
this.livenessHeartbeatTimer = setTimeout(() => {
|
|
967
|
+
this.livenessHeartbeatTimer = null;
|
|
968
|
+
this.livenessPing({ timeoutMs: LIVENESS_HEARTBEAT_TIMEOUT_MS })
|
|
969
|
+
.catch(() => { })
|
|
970
|
+
.finally(() => {
|
|
971
|
+
this.scheduleNextLivenessHeartbeat();
|
|
972
|
+
});
|
|
973
|
+
}, LIVENESS_HEARTBEAT_INTERVAL_MS);
|
|
974
|
+
}
|
|
975
|
+
// ============================================================================
|
|
976
|
+
// Agent RPCs (requestId-correlated)
|
|
977
|
+
// ============================================================================
|
|
978
|
+
async fetchAgents(options) {
|
|
979
|
+
const resolvedRequestId = this.createRequestId(options?.requestId);
|
|
980
|
+
const message = SessionInboundMessageSchema.parse({
|
|
981
|
+
type: "fetch_agents_request",
|
|
982
|
+
requestId: resolvedRequestId,
|
|
983
|
+
...(options?.scope ? { scope: options.scope } : {}),
|
|
984
|
+
...(options?.filter ? { filter: options.filter } : {}),
|
|
985
|
+
...(options?.sort ? { sort: options.sort } : {}),
|
|
986
|
+
...(options?.page ? { page: options.page } : {}),
|
|
987
|
+
...(options?.subscribe ? { subscribe: options.subscribe } : {}),
|
|
988
|
+
});
|
|
989
|
+
return this.sendRequest({
|
|
990
|
+
requestId: resolvedRequestId,
|
|
991
|
+
message,
|
|
992
|
+
timeout: options?.timeout,
|
|
993
|
+
options: { skipQueue: true },
|
|
994
|
+
select: (msg) => {
|
|
995
|
+
if (msg.type !== "fetch_agents_response") {
|
|
996
|
+
return null;
|
|
997
|
+
}
|
|
998
|
+
if (msg.payload.requestId !== resolvedRequestId) {
|
|
999
|
+
return null;
|
|
1000
|
+
}
|
|
1001
|
+
return msg.payload;
|
|
1002
|
+
},
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
async fetchAgentHistory(options) {
|
|
1006
|
+
const resolvedRequestId = this.createRequestId(options?.requestId);
|
|
1007
|
+
const message = SessionInboundMessageSchema.parse({
|
|
1008
|
+
type: "fetch_agent_history_request",
|
|
1009
|
+
requestId: resolvedRequestId,
|
|
1010
|
+
...(options?.filter ? { filter: options.filter } : {}),
|
|
1011
|
+
...(options?.sort ? { sort: options.sort } : {}),
|
|
1012
|
+
...(options?.page ? { page: options.page } : {}),
|
|
1013
|
+
});
|
|
1014
|
+
return this.sendRequest({
|
|
1015
|
+
requestId: resolvedRequestId,
|
|
1016
|
+
message,
|
|
1017
|
+
options: { skipQueue: true },
|
|
1018
|
+
select: (msg) => {
|
|
1019
|
+
if (msg.type !== "fetch_agent_history_response") {
|
|
1020
|
+
return null;
|
|
1021
|
+
}
|
|
1022
|
+
if (msg.payload.requestId !== resolvedRequestId) {
|
|
1023
|
+
return null;
|
|
1024
|
+
}
|
|
1025
|
+
return msg.payload;
|
|
1026
|
+
},
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
async fetchRecentProviderSessions(options) {
|
|
1030
|
+
const resolvedRequestId = this.createRequestId(options?.requestId);
|
|
1031
|
+
const message = SessionInboundMessageSchema.parse({
|
|
1032
|
+
type: "fetch_recent_provider_sessions_request",
|
|
1033
|
+
requestId: resolvedRequestId,
|
|
1034
|
+
...(options?.cwd ? { cwd: options.cwd } : {}),
|
|
1035
|
+
...(options?.providers ? { providers: options.providers } : {}),
|
|
1036
|
+
...(options?.since ? { since: options.since } : {}),
|
|
1037
|
+
...(options?.limit ? { limit: options.limit } : {}),
|
|
1038
|
+
});
|
|
1039
|
+
return this.sendRequest({
|
|
1040
|
+
requestId: resolvedRequestId,
|
|
1041
|
+
message,
|
|
1042
|
+
options: { skipQueue: true },
|
|
1043
|
+
select: (msg) => {
|
|
1044
|
+
if (msg.type !== "fetch_recent_provider_sessions_response") {
|
|
1045
|
+
return null;
|
|
1046
|
+
}
|
|
1047
|
+
if (msg.payload.requestId !== resolvedRequestId) {
|
|
1048
|
+
return null;
|
|
1049
|
+
}
|
|
1050
|
+
return msg.payload;
|
|
1051
|
+
},
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
async fetchWorkspaces(options) {
|
|
1055
|
+
const resolvedRequestId = this.createRequestId(options?.requestId);
|
|
1056
|
+
const message = SessionInboundMessageSchema.parse({
|
|
1057
|
+
type: "fetch_workspaces_request",
|
|
1058
|
+
requestId: resolvedRequestId,
|
|
1059
|
+
...(options?.filter ? { filter: options.filter } : {}),
|
|
1060
|
+
...(options?.sort ? { sort: options.sort } : {}),
|
|
1061
|
+
...(options?.page ? { page: options.page } : {}),
|
|
1062
|
+
...(options?.subscribe ? { subscribe: options.subscribe } : {}),
|
|
1063
|
+
});
|
|
1064
|
+
return this.sendRequest({
|
|
1065
|
+
requestId: resolvedRequestId,
|
|
1066
|
+
message,
|
|
1067
|
+
options: { skipQueue: true },
|
|
1068
|
+
select: (msg) => {
|
|
1069
|
+
if (msg.type !== "fetch_workspaces_response") {
|
|
1070
|
+
return null;
|
|
1071
|
+
}
|
|
1072
|
+
if (msg.payload.requestId !== resolvedRequestId) {
|
|
1073
|
+
return null;
|
|
1074
|
+
}
|
|
1075
|
+
return msg.payload;
|
|
1076
|
+
},
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
async openProject(cwd, requestId) {
|
|
1080
|
+
return this.sendCorrelatedSessionRequest({
|
|
1081
|
+
requestId,
|
|
1082
|
+
message: {
|
|
1083
|
+
type: "open_project_request",
|
|
1084
|
+
cwd,
|
|
1085
|
+
},
|
|
1086
|
+
responseType: "open_project_response",
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
async addProject(cwd, requestId) {
|
|
1090
|
+
return this.sendCorrelatedSessionRequest({
|
|
1091
|
+
requestId,
|
|
1092
|
+
message: {
|
|
1093
|
+
type: "project.add.request",
|
|
1094
|
+
cwd,
|
|
1095
|
+
},
|
|
1096
|
+
responseType: "project.add.response",
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
async startWorkspaceScript(workspaceId, scriptName, requestId) {
|
|
1100
|
+
return this.sendCorrelatedSessionRequest({
|
|
1101
|
+
requestId,
|
|
1102
|
+
message: {
|
|
1103
|
+
type: "start_workspace_script_request",
|
|
1104
|
+
workspaceId,
|
|
1105
|
+
scriptName,
|
|
1106
|
+
},
|
|
1107
|
+
responseType: "start_workspace_script_response",
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
async archiveWorkspace(workspaceId, requestId) {
|
|
1111
|
+
return this.sendCorrelatedSessionRequest({
|
|
1112
|
+
requestId,
|
|
1113
|
+
message: {
|
|
1114
|
+
type: "archive_workspace_request",
|
|
1115
|
+
workspaceId,
|
|
1116
|
+
},
|
|
1117
|
+
responseType: "archive_workspace_response",
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Set or clear the user's custom project name override. Pass `null` (or an
|
|
1122
|
+
* empty/whitespace string, which the server normalizes to `null`) to clear
|
|
1123
|
+
* the override and fall back to the derived project name.
|
|
1124
|
+
*/
|
|
1125
|
+
async renameProject(projectId, customName, requestId) {
|
|
1126
|
+
return this.sendCorrelatedSessionRequest({
|
|
1127
|
+
requestId,
|
|
1128
|
+
message: {
|
|
1129
|
+
type: "project.rename.request",
|
|
1130
|
+
projectId,
|
|
1131
|
+
customName,
|
|
1132
|
+
},
|
|
1133
|
+
responseType: "project.rename.response",
|
|
1134
|
+
timeout: 10000,
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
async fetchWorkspaceSetupStatus(workspaceId, requestId) {
|
|
1138
|
+
return this.sendCorrelatedSessionRequest({
|
|
1139
|
+
requestId,
|
|
1140
|
+
message: {
|
|
1141
|
+
type: "workspace_setup_status_request",
|
|
1142
|
+
workspaceId,
|
|
1143
|
+
},
|
|
1144
|
+
responseType: "workspace_setup_status_response",
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
async fetchAgent(input, legacyOptions) {
|
|
1148
|
+
const options = normalizeFetchAgentOptions(input, legacyOptions);
|
|
1149
|
+
const resolvedRequestId = this.createRequestId(options.requestId);
|
|
1150
|
+
const message = SessionInboundMessageSchema.parse({
|
|
1151
|
+
type: "fetch_agent_request",
|
|
1152
|
+
requestId: resolvedRequestId,
|
|
1153
|
+
agentId: options.agentId,
|
|
1154
|
+
});
|
|
1155
|
+
const payload = await this.sendRequest({
|
|
1156
|
+
requestId: resolvedRequestId,
|
|
1157
|
+
message,
|
|
1158
|
+
timeout: options.timeout,
|
|
1159
|
+
options: { skipQueue: true },
|
|
1160
|
+
select: (msg) => {
|
|
1161
|
+
if (msg.type !== "fetch_agent_response") {
|
|
1162
|
+
return null;
|
|
1163
|
+
}
|
|
1164
|
+
if (msg.payload.requestId !== resolvedRequestId) {
|
|
1165
|
+
return null;
|
|
1166
|
+
}
|
|
1167
|
+
return msg.payload;
|
|
1168
|
+
},
|
|
1169
|
+
});
|
|
1170
|
+
if (payload.error) {
|
|
1171
|
+
throw new Error(payload.error);
|
|
1172
|
+
}
|
|
1173
|
+
if (!payload.agent) {
|
|
1174
|
+
return null;
|
|
1175
|
+
}
|
|
1176
|
+
return { agent: payload.agent, project: payload.project ?? null };
|
|
1177
|
+
}
|
|
1178
|
+
resubscribeCheckoutDiffSubscriptions() {
|
|
1179
|
+
if (this.checkoutDiffSubscriptions.size === 0) {
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
for (const [subscriptionId, subscription] of this.checkoutDiffSubscriptions) {
|
|
1183
|
+
const message = SessionInboundMessageSchema.parse({
|
|
1184
|
+
type: "subscribe_checkout_diff_request",
|
|
1185
|
+
subscriptionId,
|
|
1186
|
+
cwd: subscription.cwd,
|
|
1187
|
+
compare: subscription.compare,
|
|
1188
|
+
requestId: this.createRequestId(),
|
|
1189
|
+
});
|
|
1190
|
+
this.sendSessionMessage(message);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
resubscribeTerminalDirectorySubscriptions() {
|
|
1194
|
+
if (this.terminalDirectorySubscriptions.size === 0) {
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
for (const subscription of this.terminalDirectorySubscriptions.values()) {
|
|
1198
|
+
this.sendSessionMessage({
|
|
1199
|
+
type: "subscribe_terminals_request",
|
|
1200
|
+
cwd: subscription.cwd,
|
|
1201
|
+
...(subscription.workspaceId !== undefined
|
|
1202
|
+
? { workspaceId: subscription.workspaceId }
|
|
1203
|
+
: {}),
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
// ============================================================================
|
|
1208
|
+
// Agent Lifecycle
|
|
1209
|
+
// ============================================================================
|
|
1210
|
+
async createAgent(options) {
|
|
1211
|
+
const requestId = this.createRequestId(options.requestId);
|
|
1212
|
+
const config = resolveAgentConfig(options);
|
|
1213
|
+
const message = SessionInboundMessageSchema.parse({
|
|
1214
|
+
type: "create_agent_request",
|
|
1215
|
+
requestId,
|
|
1216
|
+
config,
|
|
1217
|
+
...(options.env ? { env: options.env } : {}),
|
|
1218
|
+
...(options.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}),
|
|
1219
|
+
...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}),
|
|
1220
|
+
...(options.clientMessageId ? { clientMessageId: options.clientMessageId } : {}),
|
|
1221
|
+
...(options.outputSchema ? { outputSchema: options.outputSchema } : {}),
|
|
1222
|
+
...(options.images && options.images.length > 0 ? { images: options.images } : {}),
|
|
1223
|
+
...(options.attachments && options.attachments.length > 0
|
|
1224
|
+
? { attachments: options.attachments }
|
|
1225
|
+
: {}),
|
|
1226
|
+
...(options.git ? { git: options.git } : {}),
|
|
1227
|
+
...(options.worktree ? { worktree: options.worktree } : {}),
|
|
1228
|
+
...(options.autoArchive !== undefined ? { autoArchive: options.autoArchive } : {}),
|
|
1229
|
+
...(options.worktreeName ? { worktreeName: options.worktreeName } : {}),
|
|
1230
|
+
...(options.labels && Object.keys(options.labels).length > 0
|
|
1231
|
+
? { labels: options.labels }
|
|
1232
|
+
: {}),
|
|
1233
|
+
...(options.resumeSessionId ? { resumeSessionId: options.resumeSessionId } : {}),
|
|
1234
|
+
});
|
|
1235
|
+
const status = await this.sendRequest({
|
|
1236
|
+
requestId,
|
|
1237
|
+
message,
|
|
1238
|
+
options: { skipQueue: true },
|
|
1239
|
+
select: (msg) => {
|
|
1240
|
+
if (msg.type !== "status") {
|
|
1241
|
+
return null;
|
|
1242
|
+
}
|
|
1243
|
+
const created = AgentCreatedStatusPayloadSchema.safeParse(msg.payload);
|
|
1244
|
+
if (created.success && created.data.requestId === requestId) {
|
|
1245
|
+
return created.data;
|
|
1246
|
+
}
|
|
1247
|
+
const failed = AgentCreateFailedStatusPayloadSchema.safeParse(msg.payload);
|
|
1248
|
+
if (failed.success && failed.data.requestId === requestId) {
|
|
1249
|
+
return failed.data;
|
|
1250
|
+
}
|
|
1251
|
+
return null;
|
|
1252
|
+
},
|
|
1253
|
+
});
|
|
1254
|
+
if (status.status === "agent_create_failed") {
|
|
1255
|
+
throw new Error(status.error);
|
|
1256
|
+
}
|
|
1257
|
+
return status.agent;
|
|
1258
|
+
}
|
|
1259
|
+
async deleteAgent(agentId) {
|
|
1260
|
+
const requestId = this.createRequestId();
|
|
1261
|
+
const message = SessionInboundMessageSchema.parse({
|
|
1262
|
+
type: "delete_agent_request",
|
|
1263
|
+
agentId,
|
|
1264
|
+
requestId,
|
|
1265
|
+
});
|
|
1266
|
+
await this.sendRequest({
|
|
1267
|
+
requestId,
|
|
1268
|
+
message,
|
|
1269
|
+
options: { skipQueue: true },
|
|
1270
|
+
select: (msg) => {
|
|
1271
|
+
if (msg.type !== "agent_deleted") {
|
|
1272
|
+
return null;
|
|
1273
|
+
}
|
|
1274
|
+
if (msg.payload.requestId !== requestId) {
|
|
1275
|
+
return null;
|
|
1276
|
+
}
|
|
1277
|
+
return msg.payload;
|
|
1278
|
+
},
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
async archiveAgent(agentId) {
|
|
1282
|
+
const requestId = this.createRequestId();
|
|
1283
|
+
const message = SessionInboundMessageSchema.parse({
|
|
1284
|
+
type: "archive_agent_request",
|
|
1285
|
+
agentId,
|
|
1286
|
+
requestId,
|
|
1287
|
+
});
|
|
1288
|
+
const result = await this.sendRequest({
|
|
1289
|
+
requestId,
|
|
1290
|
+
message,
|
|
1291
|
+
options: { skipQueue: true },
|
|
1292
|
+
select: (msg) => {
|
|
1293
|
+
if (msg.type !== "agent_archived") {
|
|
1294
|
+
return null;
|
|
1295
|
+
}
|
|
1296
|
+
if (msg.payload.requestId !== requestId) {
|
|
1297
|
+
return null;
|
|
1298
|
+
}
|
|
1299
|
+
return msg.payload;
|
|
1300
|
+
},
|
|
1301
|
+
});
|
|
1302
|
+
return { archivedAt: result.archivedAt };
|
|
1303
|
+
}
|
|
1304
|
+
/**
|
|
1305
|
+
* Dismiss a live background shell. For Claude Code agents this drives the
|
|
1306
|
+
* CLI's own `ctrl+x ctrl+k` kill chord over the PTY (kills it inside Claude
|
|
1307
|
+
* Code). Resolves to whether the dismiss was delivered.
|
|
1308
|
+
*/
|
|
1309
|
+
async dismissBackgroundTask(agentId, taskId) {
|
|
1310
|
+
const requestId = this.createRequestId();
|
|
1311
|
+
const message = SessionInboundMessageSchema.parse({
|
|
1312
|
+
type: "dismiss_background_task_request",
|
|
1313
|
+
agentId,
|
|
1314
|
+
taskId,
|
|
1315
|
+
requestId,
|
|
1316
|
+
});
|
|
1317
|
+
const result = await this.sendRequest({
|
|
1318
|
+
requestId,
|
|
1319
|
+
message,
|
|
1320
|
+
timeout: 10000,
|
|
1321
|
+
options: { skipQueue: true },
|
|
1322
|
+
select: (msg) => {
|
|
1323
|
+
if (msg.type !== "background_task_dismissed") {
|
|
1324
|
+
return null;
|
|
1325
|
+
}
|
|
1326
|
+
if (msg.payload.requestId !== requestId) {
|
|
1327
|
+
return null;
|
|
1328
|
+
}
|
|
1329
|
+
return msg.payload;
|
|
1330
|
+
},
|
|
1331
|
+
});
|
|
1332
|
+
return { dismissed: result.dismissed };
|
|
1333
|
+
}
|
|
1334
|
+
async detachAgent(agentId) {
|
|
1335
|
+
const payload = await this.sendNamespacedCorrelatedSessionRequest({
|
|
1336
|
+
message: {
|
|
1337
|
+
type: "agent.detach.request",
|
|
1338
|
+
agentId,
|
|
1339
|
+
},
|
|
1340
|
+
});
|
|
1341
|
+
if (!payload.accepted) {
|
|
1342
|
+
throw new Error(payload.error ?? "detachAgent rejected");
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
async updateAgent(agentId, updates) {
|
|
1346
|
+
const requestId = this.createRequestId();
|
|
1347
|
+
const message = SessionInboundMessageSchema.parse({
|
|
1348
|
+
type: "update_agent_request",
|
|
1349
|
+
agentId,
|
|
1350
|
+
...(updates.name !== undefined ? { name: updates.name } : {}),
|
|
1351
|
+
...(updates.labels && Object.keys(updates.labels).length > 0
|
|
1352
|
+
? { labels: updates.labels }
|
|
1353
|
+
: {}),
|
|
1354
|
+
requestId,
|
|
1355
|
+
});
|
|
1356
|
+
const payload = await this.sendRequest({
|
|
1357
|
+
requestId,
|
|
1358
|
+
message,
|
|
1359
|
+
options: { skipQueue: true },
|
|
1360
|
+
select: (msg) => {
|
|
1361
|
+
if (msg.type !== "update_agent_response") {
|
|
1362
|
+
return null;
|
|
1363
|
+
}
|
|
1364
|
+
if (msg.payload.requestId !== requestId) {
|
|
1365
|
+
return null;
|
|
1366
|
+
}
|
|
1367
|
+
return msg.payload;
|
|
1368
|
+
},
|
|
1369
|
+
});
|
|
1370
|
+
if (!payload.accepted) {
|
|
1371
|
+
throw new Error(payload.error ?? "updateAgent rejected");
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
// Story 2.2 — Rewind to here. Sends `rewind_session_request` over the
|
|
1375
|
+
// existing WebSocket so the request tunnels through the relay's E2EE channel
|
|
1376
|
+
// (the legacy HTTP route `/agents/:agentId/rewind` is unreachable over
|
|
1377
|
+
// relay). Server-side handler in `Session.handleRewindSessionRequest` calls
|
|
1378
|
+
// the same `truncateSessionAt` (Story 2.1) the HTTP route used.
|
|
1379
|
+
async rewindSession(agentId, turnId) {
|
|
1380
|
+
const requestId = this.createRequestId();
|
|
1381
|
+
const message = SessionInboundMessageSchema.parse({
|
|
1382
|
+
type: "rewind_session_request",
|
|
1383
|
+
requestId,
|
|
1384
|
+
agentId,
|
|
1385
|
+
turnId,
|
|
1386
|
+
});
|
|
1387
|
+
const payload = await this.sendRequest({
|
|
1388
|
+
requestId,
|
|
1389
|
+
message,
|
|
1390
|
+
timeout: 30000,
|
|
1391
|
+
options: { skipQueue: true },
|
|
1392
|
+
select: (msg) => {
|
|
1393
|
+
if (msg.type !== "rewind_session_response")
|
|
1394
|
+
return null;
|
|
1395
|
+
if (msg.payload.requestId !== requestId)
|
|
1396
|
+
return null;
|
|
1397
|
+
return msg.payload;
|
|
1398
|
+
},
|
|
1399
|
+
});
|
|
1400
|
+
if (payload.error || !payload.newSessionId) {
|
|
1401
|
+
throw new Error(payload.error ?? "rewind: daemon response missing newSessionId");
|
|
1402
|
+
}
|
|
1403
|
+
return { newSessionId: payload.newSessionId };
|
|
1404
|
+
}
|
|
1405
|
+
// Story 2.4 — Edit a user message. Sends `edit_message_request` over the
|
|
1406
|
+
// existing WebSocket so the request tunnels through the relay's E2EE channel
|
|
1407
|
+
// (the legacy HTTP route `/agents/:agentId/edit-message` is unreachable over
|
|
1408
|
+
// relay). The caller is responsible for spawning a fresh agent with
|
|
1409
|
+
// `resumeSessionId=<newSessionId>` and the new content as the initial prompt.
|
|
1410
|
+
async editMessage(agentId, turnId, newContent) {
|
|
1411
|
+
const requestId = this.createRequestId();
|
|
1412
|
+
const message = SessionInboundMessageSchema.parse({
|
|
1413
|
+
type: "edit_message_request",
|
|
1414
|
+
requestId,
|
|
1415
|
+
agentId,
|
|
1416
|
+
turnId,
|
|
1417
|
+
...(typeof newContent === "string" ? { newContent } : {}),
|
|
1418
|
+
});
|
|
1419
|
+
const payload = await this.sendRequest({
|
|
1420
|
+
requestId,
|
|
1421
|
+
message,
|
|
1422
|
+
timeout: 30000,
|
|
1423
|
+
options: { skipQueue: true },
|
|
1424
|
+
select: (msg) => {
|
|
1425
|
+
if (msg.type !== "edit_message_response")
|
|
1426
|
+
return null;
|
|
1427
|
+
if (msg.payload.requestId !== requestId)
|
|
1428
|
+
return null;
|
|
1429
|
+
return msg.payload;
|
|
1430
|
+
},
|
|
1431
|
+
});
|
|
1432
|
+
if (payload.error || !payload.newSessionId) {
|
|
1433
|
+
throw new Error(payload.error ?? "editMessage: daemon response missing newSessionId");
|
|
1434
|
+
}
|
|
1435
|
+
return { newSessionId: payload.newSessionId };
|
|
1436
|
+
}
|
|
1437
|
+
// Story 3.3 / 3.4 — Session history for a workspace. Sends
|
|
1438
|
+
// `list_workspace_sessions_request` over the existing WebSocket so the
|
|
1439
|
+
// request tunnels through the relay's E2EE channel (the legacy HTTP route
|
|
1440
|
+
// `/workspace/:id/sessions` is unreachable over relay). Server-side handler
|
|
1441
|
+
// in `Session.handleListWorkspaceSessionsRequest` scans the claude
|
|
1442
|
+
// transcript dir for the workspace's cwd.
|
|
1443
|
+
async listWorkspaceSessions(workspaceId, options) {
|
|
1444
|
+
void options;
|
|
1445
|
+
const requestId = this.createRequestId();
|
|
1446
|
+
const message = SessionInboundMessageSchema.parse({
|
|
1447
|
+
type: "list_workspace_sessions_request",
|
|
1448
|
+
requestId,
|
|
1449
|
+
workspaceId,
|
|
1450
|
+
});
|
|
1451
|
+
const payload = await this.sendRequest({
|
|
1452
|
+
requestId,
|
|
1453
|
+
message,
|
|
1454
|
+
timeout: 30000,
|
|
1455
|
+
options: { skipQueue: true },
|
|
1456
|
+
select: (msg) => {
|
|
1457
|
+
if (msg.type !== "list_workspace_sessions_response")
|
|
1458
|
+
return null;
|
|
1459
|
+
if (msg.payload.requestId !== requestId)
|
|
1460
|
+
return null;
|
|
1461
|
+
return msg.payload;
|
|
1462
|
+
},
|
|
1463
|
+
});
|
|
1464
|
+
if (payload.error) {
|
|
1465
|
+
throw new Error(payload.error);
|
|
1466
|
+
}
|
|
1467
|
+
return Array.isArray(payload.sessions) ? payload.sessions : [];
|
|
1468
|
+
}
|
|
1469
|
+
// Chat-actions push subscription bridge (2026-05). Sends
|
|
1470
|
+
// `subscribe_push_request` over the existing WebSocket so the request
|
|
1471
|
+
// tunnels through the relay's E2EE channel (the legacy HTTP route
|
|
1472
|
+
// `/push/subscribe` is unreachable over relay). Server-side handler in
|
|
1473
|
+
// `Session.handleSubscribePushRequest` persists into the same
|
|
1474
|
+
// PushSubscriptionStore the HTTP route writes to.
|
|
1475
|
+
async subscribePush(subscription) {
|
|
1476
|
+
const requestId = this.createRequestId();
|
|
1477
|
+
const message = SessionInboundMessageSchema.parse({
|
|
1478
|
+
type: "subscribe_push_request",
|
|
1479
|
+
requestId,
|
|
1480
|
+
subscription,
|
|
1481
|
+
});
|
|
1482
|
+
const payload = await this.sendRequest({
|
|
1483
|
+
requestId,
|
|
1484
|
+
message,
|
|
1485
|
+
timeout: 15000,
|
|
1486
|
+
options: { skipQueue: true },
|
|
1487
|
+
select: (msg) => {
|
|
1488
|
+
if (msg.type !== "subscribe_push_response")
|
|
1489
|
+
return null;
|
|
1490
|
+
if (msg.payload.requestId !== requestId)
|
|
1491
|
+
return null;
|
|
1492
|
+
return msg.payload;
|
|
1493
|
+
},
|
|
1494
|
+
});
|
|
1495
|
+
if (!payload.ok || payload.error) {
|
|
1496
|
+
throw new Error(payload.error ?? "subscribePush rejected");
|
|
1497
|
+
}
|
|
1498
|
+
return {
|
|
1499
|
+
ok: payload.ok,
|
|
1500
|
+
...(payload.vapidPublicKey ? { vapidPublicKey: payload.vapidPublicKey } : {}),
|
|
1501
|
+
};
|
|
1502
|
+
}
|
|
1503
|
+
async getVapidPublicKey() {
|
|
1504
|
+
const requestId = this.createRequestId();
|
|
1505
|
+
const message = SessionInboundMessageSchema.parse({
|
|
1506
|
+
type: "get_vapid_public_key_request",
|
|
1507
|
+
requestId,
|
|
1508
|
+
});
|
|
1509
|
+
const payload = await this.sendRequest({
|
|
1510
|
+
requestId,
|
|
1511
|
+
message,
|
|
1512
|
+
timeout: 15000,
|
|
1513
|
+
options: { skipQueue: true },
|
|
1514
|
+
select: (msg) => {
|
|
1515
|
+
if (msg.type !== "get_vapid_public_key_response")
|
|
1516
|
+
return null;
|
|
1517
|
+
if (msg.payload.requestId !== requestId)
|
|
1518
|
+
return null;
|
|
1519
|
+
return msg.payload;
|
|
1520
|
+
},
|
|
1521
|
+
});
|
|
1522
|
+
return payload.publicKey;
|
|
1523
|
+
}
|
|
1524
|
+
// MARRA CUSTOMIZATION — list ALL Claude sessions on disk, across every
|
|
1525
|
+
// workspace. Powers the command palette's "find any session" mode. Calls the
|
|
1526
|
+
// daemon's `/sessions` route (all-sessions-route.ts) which walks
|
|
1527
|
+
// `~/.claude/projects/`. Same entry shape as listWorkspaceSessions so
|
|
1528
|
+
// existing UI consumers can reuse rendering/restore logic.
|
|
1529
|
+
async listAllSessions(options) {
|
|
1530
|
+
// COMPAT(sessionsListRpc): prefer the WS path when the daemon advertises
|
|
1531
|
+
// it — HTTP routes throw over the relay transport, so the WS path is the
|
|
1532
|
+
// only way this works on mobile. Old daemons (no flag) keep the HTTP path,
|
|
1533
|
+
// which still works on direct (desktop/web) connections.
|
|
1534
|
+
//
|
|
1535
|
+
// On a RELAY connection, ALWAYS use WS even when the flag has not been
|
|
1536
|
+
// (re)seen yet: the HTTP fallback below can never succeed over the relay
|
|
1537
|
+
// (getHttpBaseUrl throws), and `lastServerInfoMessage` is transiently null
|
|
1538
|
+
// after every reconnect (see :1102/:5512) — searching in that window would
|
|
1539
|
+
// otherwise blank the command palette. The WS send queues across the
|
|
1540
|
+
// reconnect via sendSessionMessageOrThrow, so a transient drop self-heals.
|
|
1541
|
+
if (this.lastServerInfoMessage?.features?.sessionsListRpc ||
|
|
1542
|
+
isRelayClientWebSocketUrl(this.config.url)) {
|
|
1543
|
+
const requestId = this.createRequestId();
|
|
1544
|
+
const message = SessionInboundMessageSchema.parse({
|
|
1545
|
+
type: "list_all_sessions_request",
|
|
1546
|
+
requestId,
|
|
1547
|
+
});
|
|
1548
|
+
const payload = await this.sendRequest({
|
|
1549
|
+
requestId,
|
|
1550
|
+
message,
|
|
1551
|
+
timeout: 15000,
|
|
1552
|
+
options: { skipQueue: true },
|
|
1553
|
+
select: (msg) => {
|
|
1554
|
+
if (msg.type !== "list_all_sessions_response")
|
|
1555
|
+
return null;
|
|
1556
|
+
if (msg.payload.requestId !== requestId)
|
|
1557
|
+
return null;
|
|
1558
|
+
return msg.payload;
|
|
1559
|
+
},
|
|
1560
|
+
});
|
|
1561
|
+
if (payload.error) {
|
|
1562
|
+
throw new Error(`list_all_sessions failed: ${payload.error}`);
|
|
1563
|
+
}
|
|
1564
|
+
return Array.isArray(payload.sessions) ? payload.sessions : [];
|
|
1565
|
+
}
|
|
1566
|
+
const payload = await this.daemonHttpJson({
|
|
1567
|
+
method: "GET",
|
|
1568
|
+
path: "/sessions",
|
|
1569
|
+
signal: options?.signal,
|
|
1570
|
+
});
|
|
1571
|
+
return Array.isArray(payload.sessions) ? payload.sessions : [];
|
|
1572
|
+
}
|
|
1573
|
+
// COMPAT(workflows): added in v0.1.X, remove gate after 2026-12-17.
|
|
1574
|
+
// Story 2.2 — read every known workflow snapshot via the Epic 1
|
|
1575
|
+
// `workflow.list` RPC. Returns `[]` when the daemon does not advertise the
|
|
1576
|
+
// `workflows` capability (old daemon), so callers never hang on a request the
|
|
1577
|
+
// daemon will not answer.
|
|
1578
|
+
async listWorkflows() {
|
|
1579
|
+
if (this.lastServerInfoMessage?.features?.workflows !== true) {
|
|
1580
|
+
return [];
|
|
1581
|
+
}
|
|
1582
|
+
const requestId = this.createRequestId();
|
|
1583
|
+
const message = SessionInboundMessageSchema.parse({
|
|
1584
|
+
type: "workflow.list.request",
|
|
1585
|
+
requestId,
|
|
1586
|
+
});
|
|
1587
|
+
return this.sendRequest({
|
|
1588
|
+
requestId,
|
|
1589
|
+
message,
|
|
1590
|
+
timeout: 15000,
|
|
1591
|
+
options: { skipQueue: true },
|
|
1592
|
+
select: (msg) => {
|
|
1593
|
+
if (msg.type !== "workflow.list.response")
|
|
1594
|
+
return null;
|
|
1595
|
+
if (msg.payload.requestId !== requestId)
|
|
1596
|
+
return null;
|
|
1597
|
+
return msg.payload.workflows;
|
|
1598
|
+
},
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
// COMPAT(workflows): added in v0.1.X, remove gate after 2026-12-17.
|
|
1602
|
+
// Story 2.3 — read a single workflow snapshot via the Epic 1 `workflow.get`
|
|
1603
|
+
// RPC. Returns `null` for an unknown id (a valid, parseable response — absence
|
|
1604
|
+
// is not an error) AND when the daemon does not advertise the `workflows`
|
|
1605
|
+
// capability (old daemon), so callers never hang on a request the daemon will
|
|
1606
|
+
// not answer. The route gates on the capability before ever calling this.
|
|
1607
|
+
async getWorkflow(workflowId) {
|
|
1608
|
+
if (this.lastServerInfoMessage?.features?.workflows !== true) {
|
|
1609
|
+
return null;
|
|
1610
|
+
}
|
|
1611
|
+
const requestId = this.createRequestId();
|
|
1612
|
+
const message = SessionInboundMessageSchema.parse({
|
|
1613
|
+
type: "workflow.get.request",
|
|
1614
|
+
requestId,
|
|
1615
|
+
workflowId,
|
|
1616
|
+
});
|
|
1617
|
+
return this.sendRequest({
|
|
1618
|
+
requestId,
|
|
1619
|
+
message,
|
|
1620
|
+
timeout: 15000,
|
|
1621
|
+
options: { skipQueue: true },
|
|
1622
|
+
select: (msg) => {
|
|
1623
|
+
if (msg.type !== "workflow.get.response")
|
|
1624
|
+
return null;
|
|
1625
|
+
if (msg.payload.requestId !== requestId)
|
|
1626
|
+
return null;
|
|
1627
|
+
return msg.payload.workflow;
|
|
1628
|
+
},
|
|
1629
|
+
});
|
|
1630
|
+
}
|
|
1631
|
+
// COMPAT(workflows): added in v0.1.X, remove gate after 2026-12-17.
|
|
1632
|
+
// Story 2.3 — cancel a running workflow via the Epic 1 `workflow.cancel` RPC.
|
|
1633
|
+
// Resolves with the post-cancel snapshot. Unlike the read methods this throws
|
|
1634
|
+
// when the daemon lacks the `workflows` capability rather than silently
|
|
1635
|
+
// no-op'ing — a cancel must never appear to succeed when nothing happened. The
|
|
1636
|
+
// detail route gates on the capability, so this path is unreachable from an
|
|
1637
|
+
// old daemon in practice.
|
|
1638
|
+
async cancelWorkflow(workflowId) {
|
|
1639
|
+
if (this.lastServerInfoMessage?.features?.workflows !== true) {
|
|
1640
|
+
throw new Error("This host does not support workflows. Update the host to use this.");
|
|
1641
|
+
}
|
|
1642
|
+
const requestId = this.createRequestId();
|
|
1643
|
+
const message = SessionInboundMessageSchema.parse({
|
|
1644
|
+
type: "workflow.cancel.request",
|
|
1645
|
+
requestId,
|
|
1646
|
+
workflowId,
|
|
1647
|
+
});
|
|
1648
|
+
return this.sendRequest({
|
|
1649
|
+
requestId,
|
|
1650
|
+
message,
|
|
1651
|
+
timeout: 15000,
|
|
1652
|
+
options: { skipQueue: true },
|
|
1653
|
+
select: (msg) => {
|
|
1654
|
+
if (msg.type !== "workflow.cancel.response")
|
|
1655
|
+
return null;
|
|
1656
|
+
if (msg.payload.requestId !== requestId)
|
|
1657
|
+
return null;
|
|
1658
|
+
return msg.payload.workflow;
|
|
1659
|
+
},
|
|
1660
|
+
});
|
|
1661
|
+
}
|
|
1662
|
+
// COMPAT(workflows): added in v0.1.X, remove gate after 2026-12-17.
|
|
1663
|
+
// Story 3.3 — launch a daemon-owned workflow from a task-graph via the
|
|
1664
|
+
// `workflow.start` RPC (the client-facing twin of the `workflow_start` MCP
|
|
1665
|
+
// tool). The daemon spawns one child agent per task node and the run survives
|
|
1666
|
+
// this client being killed (G3/G8). Like `cancelWorkflow`, this THROWS when the
|
|
1667
|
+
// daemon lacks the `workflows` capability rather than silently no-op'ing — a
|
|
1668
|
+
// launch must never appear to succeed when nothing started. Returns the new
|
|
1669
|
+
// workflow id, the spawned child agent ids, and the workflow status.
|
|
1670
|
+
async startWorkflow(input) {
|
|
1671
|
+
if (this.lastServerInfoMessage?.features?.workflows !== true) {
|
|
1672
|
+
throw new Error("This host does not support workflows. Update the host to use this.");
|
|
1673
|
+
}
|
|
1674
|
+
const requestId = this.createRequestId();
|
|
1675
|
+
const message = SessionInboundMessageSchema.parse({
|
|
1676
|
+
type: "workflow.start.request",
|
|
1677
|
+
requestId,
|
|
1678
|
+
graph: input.graph,
|
|
1679
|
+
provider: input.provider,
|
|
1680
|
+
cwd: input.cwd,
|
|
1681
|
+
...(input.model ? { model: input.model } : {}),
|
|
1682
|
+
...(input.title ? { title: input.title } : {}),
|
|
1683
|
+
...(input.labels ? { labels: input.labels } : {}),
|
|
1684
|
+
});
|
|
1685
|
+
return this.sendRequest({
|
|
1686
|
+
requestId,
|
|
1687
|
+
message,
|
|
1688
|
+
timeout: 30000,
|
|
1689
|
+
options: { skipQueue: true },
|
|
1690
|
+
select: (msg) => {
|
|
1691
|
+
if (msg.type !== "workflow.start.response")
|
|
1692
|
+
return null;
|
|
1693
|
+
if (msg.payload.requestId !== requestId)
|
|
1694
|
+
return null;
|
|
1695
|
+
return {
|
|
1696
|
+
workflowId: msg.payload.workflowId,
|
|
1697
|
+
childAgentIds: msg.payload.childAgentIds,
|
|
1698
|
+
status: msg.payload.status,
|
|
1699
|
+
};
|
|
1700
|
+
},
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
// COMPAT(extensions): list installed extensions over WS so the renderer can
|
|
1704
|
+
// populate its contribution registries. Capability-gated — returns [] against
|
|
1705
|
+
// a daemon that doesn't advertise the plugin platform.
|
|
1706
|
+
async listExtensions() {
|
|
1707
|
+
if (!this.lastServerInfoMessage?.features?.extensions)
|
|
1708
|
+
return [];
|
|
1709
|
+
const requestId = this.createRequestId();
|
|
1710
|
+
const message = SessionInboundMessageSchema.parse({
|
|
1711
|
+
type: "extensions.list.request",
|
|
1712
|
+
requestId,
|
|
1713
|
+
});
|
|
1714
|
+
const payload = await this.sendRequest({
|
|
1715
|
+
requestId,
|
|
1716
|
+
message,
|
|
1717
|
+
timeout: 15000,
|
|
1718
|
+
options: { skipQueue: true },
|
|
1719
|
+
select: (msg) => {
|
|
1720
|
+
if (msg.type !== "extensions.list.response")
|
|
1721
|
+
return null;
|
|
1722
|
+
if (msg.payload.requestId !== requestId)
|
|
1723
|
+
return null;
|
|
1724
|
+
return msg.payload;
|
|
1725
|
+
},
|
|
1726
|
+
});
|
|
1727
|
+
if (payload.error)
|
|
1728
|
+
throw new Error(`extensions.list failed: ${payload.error}`);
|
|
1729
|
+
return payload.extensions;
|
|
1730
|
+
}
|
|
1731
|
+
// COMPAT(extensions): route a contributed command to its extension-host.
|
|
1732
|
+
async executeExtensionCommand(commandId, args) {
|
|
1733
|
+
const requestId = this.createRequestId();
|
|
1734
|
+
const message = SessionInboundMessageSchema.parse({
|
|
1735
|
+
type: "extensions.command.execute.request",
|
|
1736
|
+
requestId,
|
|
1737
|
+
commandId,
|
|
1738
|
+
args,
|
|
1739
|
+
});
|
|
1740
|
+
const payload = await this.sendRequest({
|
|
1741
|
+
requestId,
|
|
1742
|
+
message,
|
|
1743
|
+
timeout: 30000,
|
|
1744
|
+
options: { skipQueue: true },
|
|
1745
|
+
select: (msg) => {
|
|
1746
|
+
if (msg.type !== "extensions.command.execute.response")
|
|
1747
|
+
return null;
|
|
1748
|
+
if (msg.payload.requestId !== requestId)
|
|
1749
|
+
return null;
|
|
1750
|
+
return msg.payload;
|
|
1751
|
+
},
|
|
1752
|
+
});
|
|
1753
|
+
if (!payload.ok)
|
|
1754
|
+
throw new Error(payload.error ?? "extension command failed");
|
|
1755
|
+
return payload.result;
|
|
1756
|
+
}
|
|
1757
|
+
// MARRA CUSTOMIZATION — Full-text search across paseo + claude + codex +
|
|
1758
|
+
// factory + opencode session transcripts. Calls the daemon's
|
|
1759
|
+
// `/sessions/search` route (sessions-search-route.ts) which spawns the
|
|
1760
|
+
// `recall` CLI subprocess. Used by the command palette in FTS mode.
|
|
1761
|
+
//
|
|
1762
|
+
// COMPAT(recallSearch): added in v0.1.X. Callers must check
|
|
1763
|
+
// `serverInfo.features.recallSearch` before invoking — old daemons will
|
|
1764
|
+
// 404 on this route.
|
|
1765
|
+
async searchSessions(params) {
|
|
1766
|
+
// COMPAT(sessionsListRpc): WS path when advertised (works over the relay);
|
|
1767
|
+
// HTTP fallback for old daemons on direct connections. On failure both
|
|
1768
|
+
// paths throw — useRecallSearch's useQuery catch turns that into an empty
|
|
1769
|
+
// list, falling back to the title-only session browse.
|
|
1770
|
+
//
|
|
1771
|
+
// On a RELAY connection, ALWAYS use WS even when the flag has not been
|
|
1772
|
+
// (re)seen yet: the HTTP fallback below can never succeed over the relay
|
|
1773
|
+
// (getHttpBaseUrl throws), and `lastServerInfoMessage` is transiently null
|
|
1774
|
+
// after every reconnect (see :1102/:5512) — searching in that window would
|
|
1775
|
+
// otherwise blank the command palette ("search goes blank then opens the
|
|
1776
|
+
// session I was in"). The WS send queues across the reconnect via
|
|
1777
|
+
// sendSessionMessageOrThrow, so a transient drop self-heals.
|
|
1778
|
+
if (this.lastServerInfoMessage?.features?.sessionsListRpc ||
|
|
1779
|
+
isRelayClientWebSocketUrl(this.config.url)) {
|
|
1780
|
+
const requestId = this.createRequestId();
|
|
1781
|
+
const message = SessionInboundMessageSchema.parse({
|
|
1782
|
+
type: "search_sessions_request",
|
|
1783
|
+
requestId,
|
|
1784
|
+
query: params.query,
|
|
1785
|
+
...(typeof params.limit === "number" ? { limit: params.limit } : {}),
|
|
1786
|
+
...(params.sources && params.sources.length > 0 ? { sources: [...params.sources] } : {}),
|
|
1787
|
+
});
|
|
1788
|
+
const payload = await this.sendRequest({
|
|
1789
|
+
requestId,
|
|
1790
|
+
message,
|
|
1791
|
+
timeout: 15000,
|
|
1792
|
+
options: { skipQueue: true },
|
|
1793
|
+
select: (msg) => {
|
|
1794
|
+
if (msg.type !== "search_sessions_response")
|
|
1795
|
+
return null;
|
|
1796
|
+
if (msg.payload.requestId !== requestId)
|
|
1797
|
+
return null;
|
|
1798
|
+
return msg.payload;
|
|
1799
|
+
},
|
|
1800
|
+
});
|
|
1801
|
+
if (payload.error) {
|
|
1802
|
+
throw new Error(`search_sessions failed: ${payload.error}`);
|
|
1803
|
+
}
|
|
1804
|
+
return Array.isArray(payload.results) ? payload.results : [];
|
|
1805
|
+
}
|
|
1806
|
+
const search = new URLSearchParams();
|
|
1807
|
+
search.set("q", params.query);
|
|
1808
|
+
if (typeof params.limit === "number") {
|
|
1809
|
+
search.set("limit", String(params.limit));
|
|
1810
|
+
}
|
|
1811
|
+
if (params.sources && params.sources.length > 0) {
|
|
1812
|
+
search.set("source", params.sources.join(","));
|
|
1813
|
+
}
|
|
1814
|
+
const payload = await this.daemonHttpJson({
|
|
1815
|
+
method: "GET",
|
|
1816
|
+
path: `/sessions/search?${search.toString()}`,
|
|
1817
|
+
signal: params.signal,
|
|
1818
|
+
});
|
|
1819
|
+
return Array.isArray(payload.results) ? payload.results : [];
|
|
1820
|
+
}
|
|
1821
|
+
/**
|
|
1822
|
+
* Derive the daemon's HTTP base URL from the WebSocket URL we connected with.
|
|
1823
|
+
* Works for direct (ws://host:port/ws) and same-origin (web served by
|
|
1824
|
+
* daemon). For relay-mode (wss://relay.../ws), HTTP routes against the
|
|
1825
|
+
* daemon are not addressable — throws so callers fall back gracefully.
|
|
1826
|
+
*/
|
|
1827
|
+
getHttpBaseUrl() {
|
|
1828
|
+
const wsUrl = this.config.url;
|
|
1829
|
+
if (isRelayClientWebSocketUrl(wsUrl)) {
|
|
1830
|
+
throw new Error("HTTP routes are not available over relay transport");
|
|
1831
|
+
}
|
|
1832
|
+
let parsed;
|
|
1833
|
+
try {
|
|
1834
|
+
parsed = new URL(wsUrl);
|
|
1835
|
+
}
|
|
1836
|
+
catch {
|
|
1837
|
+
throw new Error(`Cannot derive HTTP base from WS url: ${wsUrl}`);
|
|
1838
|
+
}
|
|
1839
|
+
if (parsed.protocol === "ws:")
|
|
1840
|
+
parsed.protocol = "http:";
|
|
1841
|
+
else if (parsed.protocol === "wss:")
|
|
1842
|
+
parsed.protocol = "https:";
|
|
1843
|
+
parsed.username = "";
|
|
1844
|
+
parsed.password = "";
|
|
1845
|
+
parsed.pathname = parsed.pathname.replace(/\/ws\/?$/, "/");
|
|
1846
|
+
parsed.search = "";
|
|
1847
|
+
parsed.hash = "";
|
|
1848
|
+
return parsed.origin;
|
|
1849
|
+
}
|
|
1850
|
+
buildAuthHeader() {
|
|
1851
|
+
const password = normalizePassword(this.config.password);
|
|
1852
|
+
if (password)
|
|
1853
|
+
return `Bearer ${password}`;
|
|
1854
|
+
if (this.config.authHeader)
|
|
1855
|
+
return this.config.authHeader;
|
|
1856
|
+
return null;
|
|
1857
|
+
}
|
|
1858
|
+
async daemonHttpJson(input) {
|
|
1859
|
+
const base = this.getHttpBaseUrl();
|
|
1860
|
+
const url = new URL(input.path, base).toString();
|
|
1861
|
+
const headers = {};
|
|
1862
|
+
const auth = this.buildAuthHeader();
|
|
1863
|
+
if (auth)
|
|
1864
|
+
headers.Authorization = auth;
|
|
1865
|
+
if (input.body !== undefined)
|
|
1866
|
+
headers["Content-Type"] = "application/json";
|
|
1867
|
+
let res;
|
|
1868
|
+
try {
|
|
1869
|
+
res = await fetch(url, {
|
|
1870
|
+
method: input.method,
|
|
1871
|
+
headers,
|
|
1872
|
+
...(input.body !== undefined ? { body: JSON.stringify(input.body) } : {}),
|
|
1873
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
1874
|
+
});
|
|
1875
|
+
}
|
|
1876
|
+
catch (err) {
|
|
1877
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1878
|
+
throw new Error(`daemon HTTP ${input.method} ${input.path} failed: ${message}`, {
|
|
1879
|
+
cause: err,
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
1882
|
+
if (!res.ok) {
|
|
1883
|
+
let errBody = null;
|
|
1884
|
+
try {
|
|
1885
|
+
errBody = await res.json();
|
|
1886
|
+
}
|
|
1887
|
+
catch {
|
|
1888
|
+
errBody = null;
|
|
1889
|
+
}
|
|
1890
|
+
const code = errBody && typeof errBody === "object" && "error" in errBody
|
|
1891
|
+
? String(errBody.error)
|
|
1892
|
+
: `http_${res.status}`;
|
|
1893
|
+
throw new Error(`daemon HTTP ${input.method} ${input.path} ${res.status}: ${code}`);
|
|
1894
|
+
}
|
|
1895
|
+
return (await res.json());
|
|
1896
|
+
}
|
|
1897
|
+
async removeProject(projectId, requestId) {
|
|
1898
|
+
const payload = await this.sendNamespacedCorrelatedSessionRequest({
|
|
1899
|
+
requestId,
|
|
1900
|
+
message: {
|
|
1901
|
+
type: "project.remove.request",
|
|
1902
|
+
projectId,
|
|
1903
|
+
},
|
|
1904
|
+
});
|
|
1905
|
+
if (!payload.accepted) {
|
|
1906
|
+
throw new Error(payload.error ?? "removeProject rejected");
|
|
1907
|
+
}
|
|
1908
|
+
return { removedWorkspaceIds: payload.removedWorkspaceIds };
|
|
1909
|
+
}
|
|
1910
|
+
async setWorkspaceTitle(workspaceId, title, requestId) {
|
|
1911
|
+
const payload = await this.sendCorrelatedSessionRequest({
|
|
1912
|
+
requestId,
|
|
1913
|
+
message: {
|
|
1914
|
+
type: "workspace.title.set.request",
|
|
1915
|
+
workspaceId,
|
|
1916
|
+
title,
|
|
1917
|
+
},
|
|
1918
|
+
responseType: "workspace.title.set.response",
|
|
1919
|
+
});
|
|
1920
|
+
if (!payload.accepted) {
|
|
1921
|
+
throw new Error(payload.error ?? "setWorkspaceTitle rejected");
|
|
1922
|
+
}
|
|
1923
|
+
return { title: payload.title };
|
|
1924
|
+
}
|
|
1925
|
+
async resumeAgent(handle, overrides) {
|
|
1926
|
+
const requestId = this.createRequestId();
|
|
1927
|
+
const message = SessionInboundMessageSchema.parse({
|
|
1928
|
+
type: "resume_agent_request",
|
|
1929
|
+
requestId,
|
|
1930
|
+
handle,
|
|
1931
|
+
...(overrides ? { overrides } : {}),
|
|
1932
|
+
});
|
|
1933
|
+
const status = await this.sendRequest({
|
|
1934
|
+
requestId,
|
|
1935
|
+
message,
|
|
1936
|
+
options: { skipQueue: true },
|
|
1937
|
+
select: (msg) => {
|
|
1938
|
+
if (msg.type !== "status") {
|
|
1939
|
+
return null;
|
|
1940
|
+
}
|
|
1941
|
+
const resumed = AgentResumedStatusPayloadSchema.safeParse(msg.payload);
|
|
1942
|
+
if (resumed.success && resumed.data.requestId === requestId) {
|
|
1943
|
+
return resumed.data;
|
|
1944
|
+
}
|
|
1945
|
+
return null;
|
|
1946
|
+
},
|
|
1947
|
+
});
|
|
1948
|
+
return status.agent;
|
|
1949
|
+
}
|
|
1950
|
+
async importAgent(input) {
|
|
1951
|
+
const requestId = this.createRequestId();
|
|
1952
|
+
const message = SessionInboundMessageSchema.parse({
|
|
1953
|
+
type: "import_agent_request",
|
|
1954
|
+
requestId,
|
|
1955
|
+
...("providerId" in input
|
|
1956
|
+
? { providerId: input.providerId, providerHandleId: input.providerHandleId }
|
|
1957
|
+
: { provider: input.provider, sessionId: input.sessionId }),
|
|
1958
|
+
...(input.cwd ? { cwd: input.cwd } : {}),
|
|
1959
|
+
...(input.labels && Object.keys(input.labels).length > 0 ? { labels: input.labels } : {}),
|
|
1960
|
+
});
|
|
1961
|
+
const status = await this.sendRequest({
|
|
1962
|
+
requestId,
|
|
1963
|
+
message,
|
|
1964
|
+
options: { skipQueue: true },
|
|
1965
|
+
select: (msg) => {
|
|
1966
|
+
if (msg.type !== "status") {
|
|
1967
|
+
return null;
|
|
1968
|
+
}
|
|
1969
|
+
const resumed = AgentResumedStatusPayloadSchema.safeParse(msg.payload);
|
|
1970
|
+
if (resumed.success && resumed.data.requestId === requestId) {
|
|
1971
|
+
return resumed.data;
|
|
1972
|
+
}
|
|
1973
|
+
const failed = AgentCreateFailedStatusPayloadSchema.safeParse(msg.payload);
|
|
1974
|
+
if (failed.success && failed.data.requestId === requestId) {
|
|
1975
|
+
return failed.data;
|
|
1976
|
+
}
|
|
1977
|
+
return null;
|
|
1978
|
+
},
|
|
1979
|
+
});
|
|
1980
|
+
if (status.status === "agent_create_failed") {
|
|
1981
|
+
throw new Error(status.error);
|
|
1982
|
+
}
|
|
1983
|
+
return status.agent;
|
|
1984
|
+
}
|
|
1985
|
+
async refreshAgent(agentId, requestId) {
|
|
1986
|
+
const resolvedRequestId = this.createRequestId(requestId);
|
|
1987
|
+
const message = SessionInboundMessageSchema.parse({
|
|
1988
|
+
type: "refresh_agent_request",
|
|
1989
|
+
agentId,
|
|
1990
|
+
requestId: resolvedRequestId,
|
|
1991
|
+
});
|
|
1992
|
+
return this.sendRequest({
|
|
1993
|
+
requestId: resolvedRequestId,
|
|
1994
|
+
message,
|
|
1995
|
+
options: { skipQueue: true },
|
|
1996
|
+
select: (msg) => {
|
|
1997
|
+
if (msg.type !== "status") {
|
|
1998
|
+
return null;
|
|
1999
|
+
}
|
|
2000
|
+
const refreshed = AgentRefreshedStatusPayloadSchema.safeParse(msg.payload);
|
|
2001
|
+
if (refreshed.success && refreshed.data.requestId === resolvedRequestId) {
|
|
2002
|
+
return refreshed.data;
|
|
2003
|
+
}
|
|
2004
|
+
return null;
|
|
2005
|
+
},
|
|
2006
|
+
});
|
|
2007
|
+
}
|
|
2008
|
+
async fetchAgentTimeline(agentId, options = {}) {
|
|
2009
|
+
const resolvedRequestId = this.createRequestId(options.requestId);
|
|
2010
|
+
const message = SessionInboundMessageSchema.parse({
|
|
2011
|
+
type: "fetch_agent_timeline_request",
|
|
2012
|
+
agentId,
|
|
2013
|
+
requestId: resolvedRequestId,
|
|
2014
|
+
...(options.direction ? { direction: options.direction } : {}),
|
|
2015
|
+
...(options.cursor ? { cursor: options.cursor } : {}),
|
|
2016
|
+
...(typeof options.limit === "number" ? { limit: options.limit } : {}),
|
|
2017
|
+
...(options.projection ? { projection: options.projection } : {}),
|
|
2018
|
+
});
|
|
2019
|
+
const payload = await this.sendRequest({
|
|
2020
|
+
requestId: resolvedRequestId,
|
|
2021
|
+
message,
|
|
2022
|
+
timeout: options.timeout,
|
|
2023
|
+
options: { skipQueue: true },
|
|
2024
|
+
select: (msg) => {
|
|
2025
|
+
if (msg.type !== "fetch_agent_timeline_response") {
|
|
2026
|
+
return null;
|
|
2027
|
+
}
|
|
2028
|
+
if (msg.payload.requestId !== resolvedRequestId) {
|
|
2029
|
+
return null;
|
|
2030
|
+
}
|
|
2031
|
+
return msg.payload;
|
|
2032
|
+
},
|
|
2033
|
+
});
|
|
2034
|
+
if (payload.error) {
|
|
2035
|
+
throw new Error(payload.error);
|
|
2036
|
+
}
|
|
2037
|
+
return payload;
|
|
2038
|
+
}
|
|
2039
|
+
async buildAgentForkContext(agentId, options = {}) {
|
|
2040
|
+
const resolvedRequestId = this.createRequestId(options.requestId);
|
|
2041
|
+
const message = SessionInboundMessageSchema.parse({
|
|
2042
|
+
type: "agent.fork_context.request",
|
|
2043
|
+
agentId,
|
|
2044
|
+
requestId: resolvedRequestId,
|
|
2045
|
+
...(options.boundaryMessageId ? { boundaryMessageId: options.boundaryMessageId } : {}),
|
|
2046
|
+
});
|
|
2047
|
+
const payload = await this.sendRequest({
|
|
2048
|
+
requestId: resolvedRequestId,
|
|
2049
|
+
message,
|
|
2050
|
+
timeout: 15000,
|
|
2051
|
+
options: { skipQueue: true },
|
|
2052
|
+
select: (msg) => {
|
|
2053
|
+
if (msg.type !== "agent.fork_context.response") {
|
|
2054
|
+
return null;
|
|
2055
|
+
}
|
|
2056
|
+
if (msg.payload.requestId !== resolvedRequestId) {
|
|
2057
|
+
return null;
|
|
2058
|
+
}
|
|
2059
|
+
return msg.payload;
|
|
2060
|
+
},
|
|
2061
|
+
});
|
|
2062
|
+
if (payload.error) {
|
|
2063
|
+
throw new Error(payload.error);
|
|
2064
|
+
}
|
|
2065
|
+
return payload;
|
|
2066
|
+
}
|
|
2067
|
+
// ============================================================================
|
|
2068
|
+
// Agent Interaction
|
|
2069
|
+
// ============================================================================
|
|
2070
|
+
async sendAgentMessage(agentId, text, options) {
|
|
2071
|
+
const requestId = this.createRequestId();
|
|
2072
|
+
const messageId = options?.messageId ?? crypto.randomUUID();
|
|
2073
|
+
const message = SessionInboundMessageSchema.parse({
|
|
2074
|
+
type: "send_agent_message_request",
|
|
2075
|
+
requestId,
|
|
2076
|
+
agentId,
|
|
2077
|
+
text,
|
|
2078
|
+
...(messageId ? { messageId } : {}),
|
|
2079
|
+
...(options?.images ? { images: options.images } : {}),
|
|
2080
|
+
...(options?.attachments ? { attachments: options.attachments } : {}),
|
|
2081
|
+
});
|
|
2082
|
+
const payload = await this.sendRequest({
|
|
2083
|
+
requestId,
|
|
2084
|
+
message,
|
|
2085
|
+
options: { skipQueue: true },
|
|
2086
|
+
select: (msg) => {
|
|
2087
|
+
if (msg.type !== "send_agent_message_response") {
|
|
2088
|
+
return null;
|
|
2089
|
+
}
|
|
2090
|
+
if (msg.payload.requestId !== requestId) {
|
|
2091
|
+
return null;
|
|
2092
|
+
}
|
|
2093
|
+
return msg.payload;
|
|
2094
|
+
},
|
|
2095
|
+
});
|
|
2096
|
+
if (!payload.accepted) {
|
|
2097
|
+
throw new Error(payload.error ?? "sendAgentMessage rejected");
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
async sendMessage(agentId, text, options) {
|
|
2101
|
+
await this.sendAgentMessage(agentId, text, options);
|
|
2102
|
+
}
|
|
2103
|
+
async rewindAgent(agentId, messageId, mode) {
|
|
2104
|
+
const requestId = this.createRequestId();
|
|
2105
|
+
const message = SessionInboundMessageSchema.parse({
|
|
2106
|
+
type: "agent.rewind.request",
|
|
2107
|
+
requestId,
|
|
2108
|
+
agentId,
|
|
2109
|
+
messageId,
|
|
2110
|
+
mode,
|
|
2111
|
+
});
|
|
2112
|
+
const payload = await this.sendRequest({
|
|
2113
|
+
requestId,
|
|
2114
|
+
message,
|
|
2115
|
+
options: { skipQueue: true },
|
|
2116
|
+
select: (msg) => {
|
|
2117
|
+
if (msg.type !== "agent.rewind.response") {
|
|
2118
|
+
return null;
|
|
2119
|
+
}
|
|
2120
|
+
if (msg.payload.requestId !== requestId) {
|
|
2121
|
+
return null;
|
|
2122
|
+
}
|
|
2123
|
+
return msg.payload;
|
|
2124
|
+
},
|
|
2125
|
+
});
|
|
2126
|
+
if (!payload.ok) {
|
|
2127
|
+
throw new Error(payload.error ?? "Agent rewind failed");
|
|
2128
|
+
}
|
|
2129
|
+
return payload;
|
|
2130
|
+
}
|
|
2131
|
+
async cancelAgent(agentId) {
|
|
2132
|
+
const requestId = this.createRequestId();
|
|
2133
|
+
const message = SessionInboundMessageSchema.parse({
|
|
2134
|
+
type: "cancel_agent_request",
|
|
2135
|
+
agentId,
|
|
2136
|
+
requestId,
|
|
2137
|
+
});
|
|
2138
|
+
await this.sendRequest({
|
|
2139
|
+
requestId,
|
|
2140
|
+
message,
|
|
2141
|
+
options: { skipQueue: true },
|
|
2142
|
+
select: (msg) => {
|
|
2143
|
+
if (msg.type !== "cancel_agent_response") {
|
|
2144
|
+
return null;
|
|
2145
|
+
}
|
|
2146
|
+
if (msg.payload.requestId !== requestId) {
|
|
2147
|
+
return null;
|
|
2148
|
+
}
|
|
2149
|
+
return msg.payload;
|
|
2150
|
+
},
|
|
2151
|
+
});
|
|
2152
|
+
}
|
|
2153
|
+
async setAgentMode(agentId, modeId) {
|
|
2154
|
+
const requestId = this.createRequestId();
|
|
2155
|
+
const message = SessionInboundMessageSchema.parse({
|
|
2156
|
+
type: "set_agent_mode_request",
|
|
2157
|
+
agentId,
|
|
2158
|
+
modeId,
|
|
2159
|
+
requestId,
|
|
2160
|
+
});
|
|
2161
|
+
const payload = await this.sendRequest({
|
|
2162
|
+
requestId,
|
|
2163
|
+
message,
|
|
2164
|
+
options: { skipQueue: true },
|
|
2165
|
+
select: (msg) => {
|
|
2166
|
+
if (msg.type !== "set_agent_mode_response") {
|
|
2167
|
+
return null;
|
|
2168
|
+
}
|
|
2169
|
+
if (msg.payload.requestId !== requestId) {
|
|
2170
|
+
return null;
|
|
2171
|
+
}
|
|
2172
|
+
return msg.payload;
|
|
2173
|
+
},
|
|
2174
|
+
});
|
|
2175
|
+
if (!payload.accepted) {
|
|
2176
|
+
throw new Error(payload.error ?? "setAgentMode rejected");
|
|
2177
|
+
}
|
|
2178
|
+
return payload.notice ?? null;
|
|
2179
|
+
}
|
|
2180
|
+
async setAgentModel(agentId, modelId) {
|
|
2181
|
+
const requestId = this.createRequestId();
|
|
2182
|
+
const message = SessionInboundMessageSchema.parse({
|
|
2183
|
+
type: "set_agent_model_request",
|
|
2184
|
+
agentId,
|
|
2185
|
+
modelId,
|
|
2186
|
+
requestId,
|
|
2187
|
+
});
|
|
2188
|
+
const payload = await this.sendRequest({
|
|
2189
|
+
requestId,
|
|
2190
|
+
message,
|
|
2191
|
+
options: { skipQueue: true },
|
|
2192
|
+
select: (msg) => {
|
|
2193
|
+
if (msg.type !== "set_agent_model_response") {
|
|
2194
|
+
return null;
|
|
2195
|
+
}
|
|
2196
|
+
if (msg.payload.requestId !== requestId) {
|
|
2197
|
+
return null;
|
|
2198
|
+
}
|
|
2199
|
+
return msg.payload;
|
|
2200
|
+
},
|
|
2201
|
+
});
|
|
2202
|
+
if (!payload.accepted) {
|
|
2203
|
+
throw new Error(payload.error ?? "setAgentModel rejected");
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
/**
|
|
2207
|
+
* Hot-swap a running agent's provider to a compatible one (e.g. Claude →
|
|
2208
|
+
* Claude (Pool)). Throws if the swap is rejected by the daemon (incompatible
|
|
2209
|
+
* wireFamily, target not in compatibleProviders list, target unavailable).
|
|
2210
|
+
*/
|
|
2211
|
+
async swapAgentProvider(agentId, newProviderId, overrides) {
|
|
2212
|
+
const requestId = this.createRequestId();
|
|
2213
|
+
const message = SessionInboundMessageSchema.parse({
|
|
2214
|
+
type: "swap_agent_provider_request",
|
|
2215
|
+
agentId,
|
|
2216
|
+
newProviderId,
|
|
2217
|
+
requestId,
|
|
2218
|
+
...(overrides ? { overrides } : {}),
|
|
2219
|
+
});
|
|
2220
|
+
const payload = await this.sendRequest({
|
|
2221
|
+
requestId,
|
|
2222
|
+
message,
|
|
2223
|
+
// Swap involves session close + resume; allow longer than mode/model.
|
|
2224
|
+
timeout: 30000,
|
|
2225
|
+
options: { skipQueue: true },
|
|
2226
|
+
select: (msg) => {
|
|
2227
|
+
if (msg.type !== "swap_agent_provider_response") {
|
|
2228
|
+
return null;
|
|
2229
|
+
}
|
|
2230
|
+
if (msg.payload.requestId !== requestId) {
|
|
2231
|
+
return null;
|
|
2232
|
+
}
|
|
2233
|
+
return msg.payload;
|
|
2234
|
+
},
|
|
2235
|
+
});
|
|
2236
|
+
if (!payload.accepted) {
|
|
2237
|
+
throw new Error(payload.error ?? "swapAgentProvider rejected");
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
async setAgentFeature(agentId, featureId, value) {
|
|
2241
|
+
const requestId = this.createRequestId();
|
|
2242
|
+
const message = SessionInboundMessageSchema.parse({
|
|
2243
|
+
type: "set_agent_feature_request",
|
|
2244
|
+
agentId,
|
|
2245
|
+
featureId,
|
|
2246
|
+
value,
|
|
2247
|
+
requestId,
|
|
2248
|
+
});
|
|
2249
|
+
const payload = await this.sendRequest({
|
|
2250
|
+
requestId,
|
|
2251
|
+
message,
|
|
2252
|
+
options: { skipQueue: true },
|
|
2253
|
+
select: (msg) => {
|
|
2254
|
+
if (msg.type !== "set_agent_feature_response") {
|
|
2255
|
+
return null;
|
|
2256
|
+
}
|
|
2257
|
+
if (msg.payload.requestId !== requestId) {
|
|
2258
|
+
return null;
|
|
2259
|
+
}
|
|
2260
|
+
return msg.payload;
|
|
2261
|
+
},
|
|
2262
|
+
});
|
|
2263
|
+
if (!payload.accepted) {
|
|
2264
|
+
throw new Error(payload.error ?? "setAgentFeature rejected");
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
async setAgentThinkingOption(agentId, thinkingOptionId) {
|
|
2268
|
+
const requestId = this.createRequestId();
|
|
2269
|
+
const message = SessionInboundMessageSchema.parse({
|
|
2270
|
+
type: "set_agent_thinking_request",
|
|
2271
|
+
agentId,
|
|
2272
|
+
thinkingOptionId,
|
|
2273
|
+
requestId,
|
|
2274
|
+
});
|
|
2275
|
+
const payload = await this.sendRequest({
|
|
2276
|
+
requestId,
|
|
2277
|
+
message,
|
|
2278
|
+
options: { skipQueue: true },
|
|
2279
|
+
select: (msg) => {
|
|
2280
|
+
if (msg.type !== "set_agent_thinking_response") {
|
|
2281
|
+
return null;
|
|
2282
|
+
}
|
|
2283
|
+
if (msg.payload.requestId !== requestId) {
|
|
2284
|
+
return null;
|
|
2285
|
+
}
|
|
2286
|
+
return msg.payload;
|
|
2287
|
+
},
|
|
2288
|
+
});
|
|
2289
|
+
if (!payload.accepted) {
|
|
2290
|
+
throw new Error(payload.error ?? "setAgentThinkingOption rejected");
|
|
2291
|
+
}
|
|
2292
|
+
return payload.notice ?? null;
|
|
2293
|
+
}
|
|
2294
|
+
async restartServer(reason, requestId) {
|
|
2295
|
+
const resolvedRequestId = this.createRequestId(requestId);
|
|
2296
|
+
const message = SessionInboundMessageSchema.parse({
|
|
2297
|
+
type: "restart_server_request",
|
|
2298
|
+
...(reason && reason.trim().length > 0 ? { reason } : {}),
|
|
2299
|
+
requestId: resolvedRequestId,
|
|
2300
|
+
});
|
|
2301
|
+
return this.sendRequest({
|
|
2302
|
+
requestId: resolvedRequestId,
|
|
2303
|
+
message,
|
|
2304
|
+
options: { skipQueue: true },
|
|
2305
|
+
select: (msg) => {
|
|
2306
|
+
if (msg.type !== "status") {
|
|
2307
|
+
return null;
|
|
2308
|
+
}
|
|
2309
|
+
const restarted = RestartRequestedStatusPayloadSchema.safeParse(msg.payload);
|
|
2310
|
+
if (!restarted.success) {
|
|
2311
|
+
return null;
|
|
2312
|
+
}
|
|
2313
|
+
if (restarted.data.requestId !== resolvedRequestId) {
|
|
2314
|
+
return null;
|
|
2315
|
+
}
|
|
2316
|
+
return restarted.data;
|
|
2317
|
+
},
|
|
2318
|
+
});
|
|
2319
|
+
}
|
|
2320
|
+
async shutdownServer(options) {
|
|
2321
|
+
const resolvedRequestId = this.createRequestId(options?.requestId);
|
|
2322
|
+
const message = SessionInboundMessageSchema.parse({
|
|
2323
|
+
type: "shutdown_server_request",
|
|
2324
|
+
requestId: resolvedRequestId,
|
|
2325
|
+
});
|
|
2326
|
+
return this.sendRequest({
|
|
2327
|
+
requestId: resolvedRequestId,
|
|
2328
|
+
message,
|
|
2329
|
+
timeout: options?.timeout,
|
|
2330
|
+
options: { skipQueue: true },
|
|
2331
|
+
select: (msg) => {
|
|
2332
|
+
if (msg.type !== "status") {
|
|
2333
|
+
return null;
|
|
2334
|
+
}
|
|
2335
|
+
const shutdown = ShutdownRequestedStatusPayloadSchema.safeParse(msg.payload);
|
|
2336
|
+
if (!shutdown.success) {
|
|
2337
|
+
return null;
|
|
2338
|
+
}
|
|
2339
|
+
if (shutdown.data.requestId !== resolvedRequestId) {
|
|
2340
|
+
return null;
|
|
2341
|
+
}
|
|
2342
|
+
return shutdown.data;
|
|
2343
|
+
},
|
|
2344
|
+
});
|
|
2345
|
+
}
|
|
2346
|
+
async updateDaemon(requestId) {
|
|
2347
|
+
const resolvedRequestId = this.createRequestId(requestId);
|
|
2348
|
+
const message = SessionInboundMessageSchema.parse({
|
|
2349
|
+
type: "daemon.update.request",
|
|
2350
|
+
requestId: resolvedRequestId,
|
|
2351
|
+
});
|
|
2352
|
+
return this.sendRequest({
|
|
2353
|
+
requestId: resolvedRequestId,
|
|
2354
|
+
message,
|
|
2355
|
+
timeout: 300000, // 5 minutes — npm update can be slow on remote machines
|
|
2356
|
+
options: { skipQueue: true },
|
|
2357
|
+
select: (msg) => {
|
|
2358
|
+
const parsed = DaemonUpdateResponseSchema.safeParse(msg);
|
|
2359
|
+
if (!parsed.success) {
|
|
2360
|
+
return null;
|
|
2361
|
+
}
|
|
2362
|
+
if (parsed.data.payload.requestId !== resolvedRequestId) {
|
|
2363
|
+
return null;
|
|
2364
|
+
}
|
|
2365
|
+
return parsed.data.payload;
|
|
2366
|
+
},
|
|
2367
|
+
});
|
|
2368
|
+
}
|
|
2369
|
+
// ============================================================================
|
|
2370
|
+
// Audio / Voice
|
|
2371
|
+
// ============================================================================
|
|
2372
|
+
async setVoiceMode(enabled, agentId) {
|
|
2373
|
+
const requestId = this.createRequestId();
|
|
2374
|
+
const message = SessionInboundMessageSchema.parse({
|
|
2375
|
+
type: "set_voice_mode",
|
|
2376
|
+
enabled,
|
|
2377
|
+
...(agentId ? { agentId } : {}),
|
|
2378
|
+
requestId,
|
|
2379
|
+
});
|
|
2380
|
+
const response = await this.sendRequest({
|
|
2381
|
+
requestId,
|
|
2382
|
+
message,
|
|
2383
|
+
select: (msg) => {
|
|
2384
|
+
if (msg.type !== "set_voice_mode_response") {
|
|
2385
|
+
return null;
|
|
2386
|
+
}
|
|
2387
|
+
if (msg.payload.requestId !== requestId) {
|
|
2388
|
+
return null;
|
|
2389
|
+
}
|
|
2390
|
+
return msg.payload;
|
|
2391
|
+
},
|
|
2392
|
+
});
|
|
2393
|
+
if (!response.accepted) {
|
|
2394
|
+
const codeSuffix = typeof response.reasonCode === "string" && response.reasonCode.trim().length > 0
|
|
2395
|
+
? ` (${response.reasonCode})`
|
|
2396
|
+
: "";
|
|
2397
|
+
throw new Error((response.error ?? "Failed to set voice mode") + codeSuffix);
|
|
2398
|
+
}
|
|
2399
|
+
return response;
|
|
2400
|
+
}
|
|
2401
|
+
async sendVoiceAudioChunk(audio, format, isLast = false) {
|
|
2402
|
+
this.sendSessionMessage({ type: "voice_audio_chunk", audio, format, isLast });
|
|
2403
|
+
}
|
|
2404
|
+
async startDictationStream(dictationId, format) {
|
|
2405
|
+
const ack = this.waitForWithCancel((msg) => {
|
|
2406
|
+
if (msg.type !== "dictation_stream_ack") {
|
|
2407
|
+
return null;
|
|
2408
|
+
}
|
|
2409
|
+
if (msg.payload.dictationId !== dictationId) {
|
|
2410
|
+
return null;
|
|
2411
|
+
}
|
|
2412
|
+
if (msg.payload.ackSeq !== -1) {
|
|
2413
|
+
return null;
|
|
2414
|
+
}
|
|
2415
|
+
return msg.payload;
|
|
2416
|
+
}, 30000, { skipQueue: true });
|
|
2417
|
+
const ackPromise = ack.promise.then(() => undefined);
|
|
2418
|
+
const streamError = this.waitForWithCancel((msg) => {
|
|
2419
|
+
if (msg.type !== "dictation_stream_error") {
|
|
2420
|
+
return null;
|
|
2421
|
+
}
|
|
2422
|
+
if (msg.payload.dictationId !== dictationId) {
|
|
2423
|
+
return null;
|
|
2424
|
+
}
|
|
2425
|
+
return msg.payload;
|
|
2426
|
+
}, 30000, { skipQueue: true });
|
|
2427
|
+
const errorPromise = streamError.promise.then((payload) => {
|
|
2428
|
+
throw new Error(payload.error);
|
|
2429
|
+
});
|
|
2430
|
+
const cleanupError = new Error("Cancelled dictation start waiter");
|
|
2431
|
+
try {
|
|
2432
|
+
this.sendSessionMessageStrict({ type: "dictation_stream_start", dictationId, format });
|
|
2433
|
+
await Promise.race([ackPromise, errorPromise]);
|
|
2434
|
+
}
|
|
2435
|
+
finally {
|
|
2436
|
+
ack.cancel(cleanupError);
|
|
2437
|
+
streamError.cancel(cleanupError);
|
|
2438
|
+
void ackPromise.catch(() => undefined);
|
|
2439
|
+
void errorPromise.catch(() => undefined);
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
sendDictationStreamChunk(dictationId, seq, audio, format) {
|
|
2443
|
+
this.sendSessionMessageStrict({
|
|
2444
|
+
type: "dictation_stream_chunk",
|
|
2445
|
+
dictationId,
|
|
2446
|
+
seq,
|
|
2447
|
+
audio,
|
|
2448
|
+
format,
|
|
2449
|
+
});
|
|
2450
|
+
}
|
|
2451
|
+
async finishDictationStream(dictationId, finalSeq) {
|
|
2452
|
+
const final = this.waitForWithCancel((msg) => {
|
|
2453
|
+
if (msg.type !== "dictation_stream_final") {
|
|
2454
|
+
return null;
|
|
2455
|
+
}
|
|
2456
|
+
if (msg.payload.dictationId !== dictationId) {
|
|
2457
|
+
return null;
|
|
2458
|
+
}
|
|
2459
|
+
return msg.payload;
|
|
2460
|
+
}, 0, { skipQueue: true });
|
|
2461
|
+
const streamError = this.waitForWithCancel((msg) => {
|
|
2462
|
+
if (msg.type !== "dictation_stream_error") {
|
|
2463
|
+
return null;
|
|
2464
|
+
}
|
|
2465
|
+
if (msg.payload.dictationId !== dictationId) {
|
|
2466
|
+
return null;
|
|
2467
|
+
}
|
|
2468
|
+
return msg.payload;
|
|
2469
|
+
}, 0, { skipQueue: true });
|
|
2470
|
+
const finishAccepted = this.waitForWithCancel((msg) => {
|
|
2471
|
+
if (msg.type !== "dictation_stream_finish_accepted") {
|
|
2472
|
+
return null;
|
|
2473
|
+
}
|
|
2474
|
+
if (msg.payload.dictationId !== dictationId) {
|
|
2475
|
+
return null;
|
|
2476
|
+
}
|
|
2477
|
+
return msg.payload;
|
|
2478
|
+
}, DEFAULT_DICTATION_FINISH_ACCEPT_TIMEOUT_MS, { skipQueue: true });
|
|
2479
|
+
const finalPromise = final.promise;
|
|
2480
|
+
const errorPromise = streamError.promise.then((payload) => {
|
|
2481
|
+
throw new Error(payload.error);
|
|
2482
|
+
});
|
|
2483
|
+
const finishAcceptedPromise = finishAccepted.promise;
|
|
2484
|
+
const finalOutcomePromise = finalPromise.then((payload) => ({
|
|
2485
|
+
kind: "final",
|
|
2486
|
+
payload,
|
|
2487
|
+
}));
|
|
2488
|
+
const errorOutcomePromise = errorPromise.then(() => ({
|
|
2489
|
+
kind: "error",
|
|
2490
|
+
error: new Error("Unexpected dictation stream error state"),
|
|
2491
|
+
}), (error) => ({
|
|
2492
|
+
kind: "error",
|
|
2493
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
2494
|
+
}));
|
|
2495
|
+
const finishAcceptedOutcomePromise = finishAcceptedPromise.then((payload) => ({ kind: "accepted", payload }), (error) => {
|
|
2496
|
+
if (isWaiterTimeoutError(error)) {
|
|
2497
|
+
return { kind: "accepted_timeout" };
|
|
2498
|
+
}
|
|
2499
|
+
return {
|
|
2500
|
+
kind: "accepted_error",
|
|
2501
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
2502
|
+
};
|
|
2503
|
+
});
|
|
2504
|
+
const waitForFinalResult = async (timeoutMs) => {
|
|
2505
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
2506
|
+
const outcome = await Promise.race([finalOutcomePromise, errorOutcomePromise]);
|
|
2507
|
+
if (outcome.kind === "error") {
|
|
2508
|
+
throw outcome.error;
|
|
2509
|
+
}
|
|
2510
|
+
return outcome.payload;
|
|
2511
|
+
}
|
|
2512
|
+
let timeoutHandle = null;
|
|
2513
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
2514
|
+
timeoutHandle = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
|
|
2515
|
+
});
|
|
2516
|
+
const outcome = await Promise.race([
|
|
2517
|
+
finalOutcomePromise,
|
|
2518
|
+
errorOutcomePromise,
|
|
2519
|
+
timeoutPromise,
|
|
2520
|
+
]);
|
|
2521
|
+
if (timeoutHandle) {
|
|
2522
|
+
clearTimeout(timeoutHandle);
|
|
2523
|
+
}
|
|
2524
|
+
if (outcome.kind === "timeout") {
|
|
2525
|
+
throw new Error(`Timeout waiting for dictation finalization (${timeoutMs}ms)`);
|
|
2526
|
+
}
|
|
2527
|
+
if (outcome.kind === "error") {
|
|
2528
|
+
throw outcome.error;
|
|
2529
|
+
}
|
|
2530
|
+
return outcome.payload;
|
|
2531
|
+
};
|
|
2532
|
+
const cleanupError = new Error("Cancelled dictation finish waiter");
|
|
2533
|
+
try {
|
|
2534
|
+
this.sendSessionMessageStrict({ type: "dictation_stream_finish", dictationId, finalSeq });
|
|
2535
|
+
const firstOutcome = await Promise.race([
|
|
2536
|
+
finalOutcomePromise,
|
|
2537
|
+
errorOutcomePromise,
|
|
2538
|
+
finishAcceptedOutcomePromise,
|
|
2539
|
+
]);
|
|
2540
|
+
if (firstOutcome.kind === "final") {
|
|
2541
|
+
return firstOutcome.payload;
|
|
2542
|
+
}
|
|
2543
|
+
if (firstOutcome.kind === "error") {
|
|
2544
|
+
throw firstOutcome.error;
|
|
2545
|
+
}
|
|
2546
|
+
if (firstOutcome.kind === "accepted") {
|
|
2547
|
+
return await waitForFinalResult(firstOutcome.payload.timeoutMs + DEFAULT_DICTATION_FINISH_TIMEOUT_GRACE_MS);
|
|
2548
|
+
}
|
|
2549
|
+
return await waitForFinalResult(DEFAULT_DICTATION_FINISH_FALLBACK_TIMEOUT_MS);
|
|
2550
|
+
}
|
|
2551
|
+
finally {
|
|
2552
|
+
final.cancel(cleanupError);
|
|
2553
|
+
streamError.cancel(cleanupError);
|
|
2554
|
+
finishAccepted.cancel(cleanupError);
|
|
2555
|
+
void finalPromise.catch(() => undefined);
|
|
2556
|
+
void errorPromise.catch(() => undefined);
|
|
2557
|
+
void finishAcceptedPromise.catch(() => undefined);
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
cancelDictationStream(dictationId) {
|
|
2561
|
+
this.sendSessionMessageStrict({ type: "dictation_stream_cancel", dictationId });
|
|
2562
|
+
}
|
|
2563
|
+
async abortRequest() {
|
|
2564
|
+
this.sendSessionMessage({ type: "abort_request" });
|
|
2565
|
+
}
|
|
2566
|
+
async audioPlayed(id) {
|
|
2567
|
+
this.sendSessionMessage({ type: "audio_played", id });
|
|
2568
|
+
}
|
|
2569
|
+
// ============================================================================
|
|
2570
|
+
// Git Operations
|
|
2571
|
+
// ============================================================================
|
|
2572
|
+
async getCheckoutStatus(cwd, options) {
|
|
2573
|
+
const requestId = options?.requestId;
|
|
2574
|
+
if (!requestId) {
|
|
2575
|
+
const existing = this.checkoutStatusInFlight.get(cwd);
|
|
2576
|
+
if (existing) {
|
|
2577
|
+
return existing;
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2580
|
+
const resolvedRequestId = this.createRequestId(requestId);
|
|
2581
|
+
const message = SessionInboundMessageSchema.parse({
|
|
2582
|
+
type: "checkout_status_request",
|
|
2583
|
+
cwd,
|
|
2584
|
+
requestId: resolvedRequestId,
|
|
2585
|
+
});
|
|
2586
|
+
const responsePromise = this.sendRequest({
|
|
2587
|
+
requestId: resolvedRequestId,
|
|
2588
|
+
message,
|
|
2589
|
+
options: { skipQueue: true },
|
|
2590
|
+
select: (msg) => {
|
|
2591
|
+
if (msg.type !== "checkout_status_response") {
|
|
2592
|
+
return null;
|
|
2593
|
+
}
|
|
2594
|
+
if (msg.payload.requestId !== resolvedRequestId) {
|
|
2595
|
+
return null;
|
|
2596
|
+
}
|
|
2597
|
+
return msg.payload;
|
|
2598
|
+
},
|
|
2599
|
+
});
|
|
2600
|
+
if (!requestId) {
|
|
2601
|
+
this.checkoutStatusInFlight.set(cwd, responsePromise);
|
|
2602
|
+
void responsePromise
|
|
2603
|
+
.finally(() => {
|
|
2604
|
+
if (this.checkoutStatusInFlight.get(cwd) === responsePromise) {
|
|
2605
|
+
this.checkoutStatusInFlight.delete(cwd);
|
|
2606
|
+
}
|
|
2607
|
+
})
|
|
2608
|
+
.catch(() => undefined);
|
|
2609
|
+
}
|
|
2610
|
+
return responsePromise;
|
|
2611
|
+
}
|
|
2612
|
+
normalizeCheckoutDiffCompare(compare) {
|
|
2613
|
+
if (compare.mode === "uncommitted") {
|
|
2614
|
+
return compare.ignoreWhitespace === true
|
|
2615
|
+
? { mode: "uncommitted", ignoreWhitespace: true }
|
|
2616
|
+
: { mode: "uncommitted" };
|
|
2617
|
+
}
|
|
2618
|
+
const trimmedBaseRef = compare.baseRef?.trim();
|
|
2619
|
+
if (!trimmedBaseRef) {
|
|
2620
|
+
return compare.ignoreWhitespace === true
|
|
2621
|
+
? { mode: "base", ignoreWhitespace: true }
|
|
2622
|
+
: { mode: "base" };
|
|
2623
|
+
}
|
|
2624
|
+
return compare.ignoreWhitespace === true
|
|
2625
|
+
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace: true }
|
|
2626
|
+
: { mode: "base", baseRef: trimmedBaseRef };
|
|
2627
|
+
}
|
|
2628
|
+
async getCheckoutDiff(cwd, compare, requestId) {
|
|
2629
|
+
const oneShotSubscriptionId = `oneshot-checkout-diff:${crypto.randomUUID()}`;
|
|
2630
|
+
try {
|
|
2631
|
+
const payload = await this.subscribeCheckoutDiff(cwd, compare, {
|
|
2632
|
+
subscriptionId: oneShotSubscriptionId,
|
|
2633
|
+
requestId,
|
|
2634
|
+
});
|
|
2635
|
+
return {
|
|
2636
|
+
cwd: payload.cwd,
|
|
2637
|
+
files: payload.files,
|
|
2638
|
+
error: payload.error,
|
|
2639
|
+
requestId: payload.requestId,
|
|
2640
|
+
};
|
|
2641
|
+
}
|
|
2642
|
+
finally {
|
|
2643
|
+
try {
|
|
2644
|
+
this.unsubscribeCheckoutDiff(oneShotSubscriptionId);
|
|
2645
|
+
}
|
|
2646
|
+
catch {
|
|
2647
|
+
// Ignore disconnect races during one-shot cleanup.
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
async subscribeCheckoutDiff(cwd, compare, options) {
|
|
2652
|
+
const subscriptionId = options?.subscriptionId ?? crypto.randomUUID();
|
|
2653
|
+
const normalizedCompare = this.normalizeCheckoutDiffCompare(compare);
|
|
2654
|
+
const previousSubscription = this.checkoutDiffSubscriptions.get(subscriptionId) ?? null;
|
|
2655
|
+
this.checkoutDiffSubscriptions.set(subscriptionId, {
|
|
2656
|
+
cwd,
|
|
2657
|
+
compare: normalizedCompare,
|
|
2658
|
+
});
|
|
2659
|
+
const resolvedRequestId = this.createRequestId(options?.requestId);
|
|
2660
|
+
const message = SessionInboundMessageSchema.parse({
|
|
2661
|
+
type: "subscribe_checkout_diff_request",
|
|
2662
|
+
subscriptionId,
|
|
2663
|
+
cwd,
|
|
2664
|
+
compare: normalizedCompare,
|
|
2665
|
+
requestId: resolvedRequestId,
|
|
2666
|
+
});
|
|
2667
|
+
try {
|
|
2668
|
+
return await this.sendCorrelatedRequest({
|
|
2669
|
+
requestId: resolvedRequestId,
|
|
2670
|
+
message,
|
|
2671
|
+
responseType: "subscribe_checkout_diff_response",
|
|
2672
|
+
options: { skipQueue: true },
|
|
2673
|
+
selectPayload: (payload) => {
|
|
2674
|
+
if (payload.subscriptionId !== subscriptionId) {
|
|
2675
|
+
return null;
|
|
2676
|
+
}
|
|
2677
|
+
return payload;
|
|
2678
|
+
},
|
|
2679
|
+
});
|
|
2680
|
+
}
|
|
2681
|
+
catch (error) {
|
|
2682
|
+
if (previousSubscription) {
|
|
2683
|
+
this.checkoutDiffSubscriptions.set(subscriptionId, previousSubscription);
|
|
2684
|
+
}
|
|
2685
|
+
else {
|
|
2686
|
+
this.checkoutDiffSubscriptions.delete(subscriptionId);
|
|
2687
|
+
}
|
|
2688
|
+
throw error;
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
unsubscribeCheckoutDiff(subscriptionId) {
|
|
2692
|
+
this.checkoutDiffSubscriptions.delete(subscriptionId);
|
|
2693
|
+
this.sendSessionMessage({
|
|
2694
|
+
type: "unsubscribe_checkout_diff_request",
|
|
2695
|
+
subscriptionId,
|
|
2696
|
+
});
|
|
2697
|
+
}
|
|
2698
|
+
async checkoutCommit(cwd, input, requestId) {
|
|
2699
|
+
return this.sendCorrelatedSessionRequest({
|
|
2700
|
+
requestId,
|
|
2701
|
+
message: {
|
|
2702
|
+
type: "checkout_commit_request",
|
|
2703
|
+
cwd,
|
|
2704
|
+
message: input.message,
|
|
2705
|
+
addAll: input.addAll,
|
|
2706
|
+
},
|
|
2707
|
+
responseType: "checkout_commit_response",
|
|
2708
|
+
});
|
|
2709
|
+
}
|
|
2710
|
+
async checkoutMerge(cwd, input, requestId) {
|
|
2711
|
+
return this.sendCorrelatedSessionRequest({
|
|
2712
|
+
requestId,
|
|
2713
|
+
message: {
|
|
2714
|
+
type: "checkout_merge_request",
|
|
2715
|
+
cwd,
|
|
2716
|
+
baseRef: input.baseRef,
|
|
2717
|
+
strategy: input.strategy,
|
|
2718
|
+
requireCleanTarget: input.requireCleanTarget,
|
|
2719
|
+
},
|
|
2720
|
+
responseType: "checkout_merge_response",
|
|
2721
|
+
});
|
|
2722
|
+
}
|
|
2723
|
+
async checkoutMergeFromBase(cwd, input, requestId) {
|
|
2724
|
+
return this.sendCorrelatedSessionRequest({
|
|
2725
|
+
requestId,
|
|
2726
|
+
message: {
|
|
2727
|
+
type: "checkout_merge_from_base_request",
|
|
2728
|
+
cwd,
|
|
2729
|
+
baseRef: input.baseRef,
|
|
2730
|
+
requireCleanTarget: input.requireCleanTarget,
|
|
2731
|
+
},
|
|
2732
|
+
responseType: "checkout_merge_from_base_response",
|
|
2733
|
+
});
|
|
2734
|
+
}
|
|
2735
|
+
async checkoutPull(cwd, requestId) {
|
|
2736
|
+
return this.sendCorrelatedSessionRequest({
|
|
2737
|
+
requestId,
|
|
2738
|
+
message: {
|
|
2739
|
+
type: "checkout_pull_request",
|
|
2740
|
+
cwd,
|
|
2741
|
+
},
|
|
2742
|
+
responseType: "checkout_pull_response",
|
|
2743
|
+
});
|
|
2744
|
+
}
|
|
2745
|
+
async checkoutPush(cwd, requestId) {
|
|
2746
|
+
return this.sendCorrelatedSessionRequest({
|
|
2747
|
+
requestId,
|
|
2748
|
+
message: {
|
|
2749
|
+
type: "checkout_push_request",
|
|
2750
|
+
cwd,
|
|
2751
|
+
},
|
|
2752
|
+
responseType: "checkout_push_response",
|
|
2753
|
+
});
|
|
2754
|
+
}
|
|
2755
|
+
async checkoutRefresh(cwd, requestId) {
|
|
2756
|
+
return this.sendCorrelatedSessionRequest({
|
|
2757
|
+
requestId,
|
|
2758
|
+
message: {
|
|
2759
|
+
type: "checkout.refresh.request",
|
|
2760
|
+
cwd,
|
|
2761
|
+
},
|
|
2762
|
+
responseType: "checkout.refresh.response",
|
|
2763
|
+
});
|
|
2764
|
+
}
|
|
2765
|
+
async checkoutPrCreate(cwd, input, requestId) {
|
|
2766
|
+
return this.sendCorrelatedSessionRequest({
|
|
2767
|
+
requestId,
|
|
2768
|
+
message: {
|
|
2769
|
+
type: "checkout_pr_create_request",
|
|
2770
|
+
cwd,
|
|
2771
|
+
title: input.title,
|
|
2772
|
+
body: input.body,
|
|
2773
|
+
baseRef: input.baseRef,
|
|
2774
|
+
},
|
|
2775
|
+
responseType: "checkout_pr_create_response",
|
|
2776
|
+
});
|
|
2777
|
+
}
|
|
2778
|
+
async checkoutPrMerge(cwd, input, requestId) {
|
|
2779
|
+
return this.sendCorrelatedSessionRequest({
|
|
2780
|
+
requestId,
|
|
2781
|
+
message: {
|
|
2782
|
+
type: "checkout_pr_merge_request",
|
|
2783
|
+
cwd,
|
|
2784
|
+
mergeMethod: input.method,
|
|
2785
|
+
},
|
|
2786
|
+
responseType: "checkout_pr_merge_response",
|
|
2787
|
+
});
|
|
2788
|
+
}
|
|
2789
|
+
async checkoutGithubSetAutoMerge(cwd, input, requestId) {
|
|
2790
|
+
return this.sendNamespacedCorrelatedSessionRequest({
|
|
2791
|
+
requestId,
|
|
2792
|
+
message: {
|
|
2793
|
+
type: "checkout.github.set_auto_merge.request",
|
|
2794
|
+
cwd,
|
|
2795
|
+
enabled: input.enabled,
|
|
2796
|
+
...(input.enabled ? { mergeMethod: input.method } : {}),
|
|
2797
|
+
},
|
|
2798
|
+
});
|
|
2799
|
+
}
|
|
2800
|
+
async checkoutGithubGetCheckDetails(input, requestId) {
|
|
2801
|
+
return this.sendNamespacedCorrelatedSessionRequest({
|
|
2802
|
+
requestId,
|
|
2803
|
+
message: {
|
|
2804
|
+
type: "checkout.github.get_check_details.request",
|
|
2805
|
+
cwd: input.cwd,
|
|
2806
|
+
repoOwner: input.repoOwner,
|
|
2807
|
+
repoName: input.repoName,
|
|
2808
|
+
checkRunId: input.checkRunId,
|
|
2809
|
+
workflowRunId: input.workflowRunId,
|
|
2810
|
+
},
|
|
2811
|
+
});
|
|
2812
|
+
}
|
|
2813
|
+
async checkoutPrStatus(cwd, requestId) {
|
|
2814
|
+
return this.sendCorrelatedSessionRequest({
|
|
2815
|
+
requestId,
|
|
2816
|
+
message: {
|
|
2817
|
+
type: "checkout_pr_status_request",
|
|
2818
|
+
cwd,
|
|
2819
|
+
},
|
|
2820
|
+
responseType: "checkout_pr_status_response",
|
|
2821
|
+
});
|
|
2822
|
+
}
|
|
2823
|
+
async pullRequestTimeline(input, requestId) {
|
|
2824
|
+
return this.sendCorrelatedSessionRequest({
|
|
2825
|
+
requestId,
|
|
2826
|
+
message: {
|
|
2827
|
+
type: "pull_request_timeline_request",
|
|
2828
|
+
cwd: input.cwd,
|
|
2829
|
+
prNumber: input.prNumber,
|
|
2830
|
+
repoOwner: input.repoOwner,
|
|
2831
|
+
repoName: input.repoName,
|
|
2832
|
+
},
|
|
2833
|
+
responseType: "pull_request_timeline_response",
|
|
2834
|
+
});
|
|
2835
|
+
}
|
|
2836
|
+
async checkoutSwitchBranch(cwd, branch, requestId) {
|
|
2837
|
+
return this.sendCorrelatedSessionRequest({
|
|
2838
|
+
requestId,
|
|
2839
|
+
message: {
|
|
2840
|
+
type: "checkout_switch_branch_request",
|
|
2841
|
+
cwd,
|
|
2842
|
+
branch,
|
|
2843
|
+
},
|
|
2844
|
+
responseType: "checkout_switch_branch_response",
|
|
2845
|
+
});
|
|
2846
|
+
}
|
|
2847
|
+
async renameBranch(input) {
|
|
2848
|
+
return this.sendCorrelatedSessionRequest({
|
|
2849
|
+
requestId: input.requestId,
|
|
2850
|
+
message: {
|
|
2851
|
+
type: "checkout.rename_branch.request",
|
|
2852
|
+
cwd: input.cwd,
|
|
2853
|
+
branch: input.branch,
|
|
2854
|
+
},
|
|
2855
|
+
responseType: "checkout.rename_branch.response",
|
|
2856
|
+
});
|
|
2857
|
+
}
|
|
2858
|
+
async stashSave(cwd, options, requestId) {
|
|
2859
|
+
return this.sendCorrelatedSessionRequest({
|
|
2860
|
+
requestId,
|
|
2861
|
+
message: {
|
|
2862
|
+
type: "stash_save_request",
|
|
2863
|
+
cwd,
|
|
2864
|
+
branch: options?.branch,
|
|
2865
|
+
},
|
|
2866
|
+
responseType: "stash_save_response",
|
|
2867
|
+
});
|
|
2868
|
+
}
|
|
2869
|
+
async stashPop(cwd, stashIndex, requestId) {
|
|
2870
|
+
return this.sendCorrelatedSessionRequest({
|
|
2871
|
+
requestId,
|
|
2872
|
+
message: {
|
|
2873
|
+
type: "stash_pop_request",
|
|
2874
|
+
cwd,
|
|
2875
|
+
stashIndex,
|
|
2876
|
+
},
|
|
2877
|
+
responseType: "stash_pop_response",
|
|
2878
|
+
});
|
|
2879
|
+
}
|
|
2880
|
+
async stashList(cwd, options, requestId) {
|
|
2881
|
+
return this.sendCorrelatedSessionRequest({
|
|
2882
|
+
requestId,
|
|
2883
|
+
message: {
|
|
2884
|
+
type: "stash_list_request",
|
|
2885
|
+
cwd,
|
|
2886
|
+
paseoOnly: options?.paseoOnly,
|
|
2887
|
+
},
|
|
2888
|
+
responseType: "stash_list_response",
|
|
2889
|
+
});
|
|
2890
|
+
}
|
|
2891
|
+
async getPaseoWorktreeList(input, requestId) {
|
|
2892
|
+
return this.sendCorrelatedSessionRequest({
|
|
2893
|
+
requestId,
|
|
2894
|
+
message: {
|
|
2895
|
+
type: "paseo_worktree_list_request",
|
|
2896
|
+
cwd: input.cwd,
|
|
2897
|
+
repoRoot: input.repoRoot,
|
|
2898
|
+
},
|
|
2899
|
+
responseType: "paseo_worktree_list_response",
|
|
2900
|
+
});
|
|
2901
|
+
}
|
|
2902
|
+
async archivePaseoWorktree(input, requestId) {
|
|
2903
|
+
return this.sendCorrelatedSessionRequest({
|
|
2904
|
+
requestId,
|
|
2905
|
+
message: {
|
|
2906
|
+
type: "paseo_worktree_archive_request",
|
|
2907
|
+
worktreePath: input.worktreePath,
|
|
2908
|
+
repoRoot: input.repoRoot,
|
|
2909
|
+
branchName: input.branchName,
|
|
2910
|
+
...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}),
|
|
2911
|
+
...(input.scope !== undefined ? { scope: input.scope } : {}),
|
|
2912
|
+
},
|
|
2913
|
+
responseType: "paseo_worktree_archive_response",
|
|
2914
|
+
});
|
|
2915
|
+
}
|
|
2916
|
+
async createPaseoWorktree(input, requestId) {
|
|
2917
|
+
return this.sendCorrelatedSessionRequest({
|
|
2918
|
+
requestId,
|
|
2919
|
+
message: {
|
|
2920
|
+
type: "create_paseo_worktree_request",
|
|
2921
|
+
cwd: input.cwd,
|
|
2922
|
+
...(input.projectId !== undefined ? { projectId: input.projectId } : {}),
|
|
2923
|
+
worktreeSlug: input.worktreeSlug,
|
|
2924
|
+
...(input.firstAgentContext !== undefined
|
|
2925
|
+
? { firstAgentContext: input.firstAgentContext }
|
|
2926
|
+
: {}),
|
|
2927
|
+
...(input.refName !== undefined ? { refName: input.refName } : {}),
|
|
2928
|
+
...(input.action !== undefined ? { action: input.action } : {}),
|
|
2929
|
+
...(input.githubPrNumber !== undefined ? { githubPrNumber: input.githubPrNumber } : {}),
|
|
2930
|
+
},
|
|
2931
|
+
responseType: "create_paseo_worktree_response",
|
|
2932
|
+
});
|
|
2933
|
+
}
|
|
2934
|
+
async createWorkspace(input, requestId) {
|
|
2935
|
+
return this.sendCorrelatedSessionRequest({
|
|
2936
|
+
requestId,
|
|
2937
|
+
message: {
|
|
2938
|
+
type: "workspace.create.request",
|
|
2939
|
+
source: input.source,
|
|
2940
|
+
...(input.title !== undefined ? { title: input.title } : {}),
|
|
2941
|
+
...(input.firstAgentContext !== undefined
|
|
2942
|
+
? { firstAgentContext: input.firstAgentContext }
|
|
2943
|
+
: {}),
|
|
2944
|
+
},
|
|
2945
|
+
responseType: "workspace.create.response",
|
|
2946
|
+
});
|
|
2947
|
+
}
|
|
2948
|
+
async validateBranch(options, requestId) {
|
|
2949
|
+
return this.sendCorrelatedSessionRequest({
|
|
2950
|
+
requestId,
|
|
2951
|
+
message: {
|
|
2952
|
+
type: "validate_branch_request",
|
|
2953
|
+
cwd: options.cwd,
|
|
2954
|
+
branchName: options.branchName,
|
|
2955
|
+
},
|
|
2956
|
+
responseType: "validate_branch_response",
|
|
2957
|
+
});
|
|
2958
|
+
}
|
|
2959
|
+
async getBranchSuggestions(options, requestId) {
|
|
2960
|
+
return this.sendCorrelatedSessionRequest({
|
|
2961
|
+
requestId,
|
|
2962
|
+
message: {
|
|
2963
|
+
type: "branch_suggestions_request",
|
|
2964
|
+
cwd: options.cwd,
|
|
2965
|
+
query: options.query,
|
|
2966
|
+
limit: options.limit,
|
|
2967
|
+
},
|
|
2968
|
+
responseType: "branch_suggestions_response",
|
|
2969
|
+
});
|
|
2970
|
+
}
|
|
2971
|
+
async searchGitHub(options, requestId) {
|
|
2972
|
+
return this.sendCorrelatedSessionRequest({
|
|
2973
|
+
requestId,
|
|
2974
|
+
message: {
|
|
2975
|
+
type: "github_search_request",
|
|
2976
|
+
cwd: options.cwd,
|
|
2977
|
+
query: options.query,
|
|
2978
|
+
limit: options.limit,
|
|
2979
|
+
kinds: options.kinds,
|
|
2980
|
+
},
|
|
2981
|
+
responseType: "github_search_response",
|
|
2982
|
+
});
|
|
2983
|
+
}
|
|
2984
|
+
async getDirectorySuggestions(options, requestId) {
|
|
2985
|
+
return this.sendCorrelatedSessionRequest({
|
|
2986
|
+
requestId,
|
|
2987
|
+
message: {
|
|
2988
|
+
type: "directory_suggestions_request",
|
|
2989
|
+
query: options.query,
|
|
2990
|
+
cwd: options.cwd,
|
|
2991
|
+
includeFiles: options.includeFiles,
|
|
2992
|
+
includeDirectories: options.includeDirectories,
|
|
2993
|
+
matchMode: options.matchMode,
|
|
2994
|
+
limit: options.limit,
|
|
2995
|
+
},
|
|
2996
|
+
responseType: "directory_suggestions_response",
|
|
2997
|
+
// Home-tree scans on large home dirs can take several seconds; don't cut
|
|
2998
|
+
// the suggestion request off early (it would surface as an empty list).
|
|
2999
|
+
});
|
|
3000
|
+
}
|
|
3001
|
+
// ============================================================================
|
|
3002
|
+
// File Explorer
|
|
3003
|
+
// ============================================================================
|
|
3004
|
+
async requestFileExplorer(cwd, path, mode, requestId, acceptBinary = false) {
|
|
3005
|
+
return this.sendCorrelatedSessionRequest({
|
|
3006
|
+
requestId,
|
|
3007
|
+
message: {
|
|
3008
|
+
type: "file_explorer_request",
|
|
3009
|
+
cwd,
|
|
3010
|
+
path,
|
|
3011
|
+
mode,
|
|
3012
|
+
...(acceptBinary ? { acceptBinary: true } : {}),
|
|
3013
|
+
},
|
|
3014
|
+
responseType: "file_explorer_response",
|
|
3015
|
+
});
|
|
3016
|
+
}
|
|
3017
|
+
async listDirectory(cwd, path, requestId) {
|
|
3018
|
+
const payload = await this.requestFileExplorer(cwd, path, "list", requestId);
|
|
3019
|
+
if (payload.error) {
|
|
3020
|
+
throw new Error(payload.error);
|
|
3021
|
+
}
|
|
3022
|
+
if (!payload.directory) {
|
|
3023
|
+
throw new Error("Directory listing unavailable.");
|
|
3024
|
+
}
|
|
3025
|
+
return payload.directory;
|
|
3026
|
+
}
|
|
3027
|
+
async readFile(cwd, path, requestId) {
|
|
3028
|
+
const resolvedRequestId = this.createRequestId(requestId);
|
|
3029
|
+
this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path });
|
|
3030
|
+
try {
|
|
3031
|
+
const payload = await this.requestFileExplorer(cwd, path, "file", resolvedRequestId, true);
|
|
3032
|
+
if (payload.error) {
|
|
3033
|
+
throw new Error(payload.error);
|
|
3034
|
+
}
|
|
3035
|
+
const binaryResult = this.completedBinaryFileReads.get(resolvedRequestId);
|
|
3036
|
+
if (binaryResult) {
|
|
3037
|
+
this.completedBinaryFileReads.delete(resolvedRequestId);
|
|
3038
|
+
return binaryResult;
|
|
3039
|
+
}
|
|
3040
|
+
if (!payload.file) {
|
|
3041
|
+
throw new Error("File unavailable.");
|
|
3042
|
+
}
|
|
3043
|
+
return legacyExplorerFileToBytes(payload.file);
|
|
3044
|
+
}
|
|
3045
|
+
finally {
|
|
3046
|
+
this.pendingBinaryFileReads.delete(resolvedRequestId);
|
|
3047
|
+
this.activeBinaryFileTransfers.delete(resolvedRequestId);
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
async requestDownloadToken(cwd, path, requestId) {
|
|
3051
|
+
return this.sendCorrelatedSessionRequest({
|
|
3052
|
+
requestId,
|
|
3053
|
+
message: {
|
|
3054
|
+
type: "file_download_token_request",
|
|
3055
|
+
cwd,
|
|
3056
|
+
path,
|
|
3057
|
+
},
|
|
3058
|
+
responseType: "file_download_token_response",
|
|
3059
|
+
});
|
|
3060
|
+
}
|
|
3061
|
+
async requestProjectIcon(cwd, requestId) {
|
|
3062
|
+
return this.sendCorrelatedSessionRequest({
|
|
3063
|
+
requestId,
|
|
3064
|
+
message: {
|
|
3065
|
+
type: "project_icon_request",
|
|
3066
|
+
cwd,
|
|
3067
|
+
},
|
|
3068
|
+
responseType: "project_icon_response",
|
|
3069
|
+
});
|
|
3070
|
+
}
|
|
3071
|
+
// ============================================================================
|
|
3072
|
+
// Provider Models / Commands
|
|
3073
|
+
// ============================================================================
|
|
3074
|
+
async listProviderModels(provider, options) {
|
|
3075
|
+
const payload = await this.sendCorrelatedSessionRequest({
|
|
3076
|
+
requestId: options?.requestId,
|
|
3077
|
+
message: {
|
|
3078
|
+
type: "list_provider_models_request",
|
|
3079
|
+
provider,
|
|
3080
|
+
cwd: options?.cwd,
|
|
3081
|
+
},
|
|
3082
|
+
responseType: "list_provider_models_response",
|
|
3083
|
+
// Provider SDK cold starts (especially model discovery) can exceed 60s.
|
|
3084
|
+
timeout: 90000,
|
|
3085
|
+
});
|
|
3086
|
+
return normalizeListProviderModelsPayload(payload);
|
|
3087
|
+
}
|
|
3088
|
+
async listProviderModes(provider, options) {
|
|
3089
|
+
return this.sendCorrelatedSessionRequest({
|
|
3090
|
+
requestId: options?.requestId,
|
|
3091
|
+
message: {
|
|
3092
|
+
type: "list_provider_modes_request",
|
|
3093
|
+
provider,
|
|
3094
|
+
cwd: options?.cwd,
|
|
3095
|
+
},
|
|
3096
|
+
responseType: "list_provider_modes_response",
|
|
3097
|
+
timeout: 90000,
|
|
3098
|
+
});
|
|
3099
|
+
}
|
|
3100
|
+
async listProviderFeatures(draftConfig, options) {
|
|
3101
|
+
return this.sendCorrelatedSessionRequest({
|
|
3102
|
+
requestId: options?.requestId,
|
|
3103
|
+
message: {
|
|
3104
|
+
type: "list_provider_features_request",
|
|
3105
|
+
draftConfig,
|
|
3106
|
+
},
|
|
3107
|
+
responseType: "list_provider_features_response",
|
|
3108
|
+
timeout: 90000,
|
|
3109
|
+
});
|
|
3110
|
+
}
|
|
3111
|
+
async listAvailableProviders(options) {
|
|
3112
|
+
return this.sendCorrelatedSessionRequest({
|
|
3113
|
+
requestId: options?.requestId,
|
|
3114
|
+
message: {
|
|
3115
|
+
type: "list_available_providers_request",
|
|
3116
|
+
},
|
|
3117
|
+
responseType: "list_available_providers_response",
|
|
3118
|
+
});
|
|
3119
|
+
}
|
|
3120
|
+
async getProvidersSnapshot(options) {
|
|
3121
|
+
const payload = await this.sendCorrelatedSessionRequest({
|
|
3122
|
+
requestId: options?.requestId,
|
|
3123
|
+
message: {
|
|
3124
|
+
type: "get_providers_snapshot_request",
|
|
3125
|
+
cwd: options?.cwd,
|
|
3126
|
+
},
|
|
3127
|
+
responseType: "get_providers_snapshot_response",
|
|
3128
|
+
});
|
|
3129
|
+
return normalizeProvidersSnapshotPayload(payload);
|
|
3130
|
+
}
|
|
3131
|
+
async getDaemonConfig(requestId) {
|
|
3132
|
+
return this.sendCorrelatedSessionRequest({
|
|
3133
|
+
requestId,
|
|
3134
|
+
message: {
|
|
3135
|
+
type: "get_daemon_config_request",
|
|
3136
|
+
},
|
|
3137
|
+
responseType: "get_daemon_config_response",
|
|
3138
|
+
});
|
|
3139
|
+
}
|
|
3140
|
+
async getDaemonStatus(options) {
|
|
3141
|
+
return this.sendCorrelatedSessionRequest({
|
|
3142
|
+
requestId: options?.requestId,
|
|
3143
|
+
message: {
|
|
3144
|
+
type: "daemon.get_status.request",
|
|
3145
|
+
},
|
|
3146
|
+
responseType: "daemon.get_status.response",
|
|
3147
|
+
timeout: options?.timeout,
|
|
3148
|
+
});
|
|
3149
|
+
}
|
|
3150
|
+
async getDaemonPairingOffer(options) {
|
|
3151
|
+
return this.sendCorrelatedSessionRequest({
|
|
3152
|
+
requestId: options?.requestId,
|
|
3153
|
+
message: {
|
|
3154
|
+
type: "daemon.get_pairing_offer.request",
|
|
3155
|
+
},
|
|
3156
|
+
responseType: "daemon.get_pairing_offer.response",
|
|
3157
|
+
timeout: options?.timeout,
|
|
3158
|
+
});
|
|
3159
|
+
}
|
|
3160
|
+
async collectDiagnostics(requestId) {
|
|
3161
|
+
return this.sendNamespacedCorrelatedSessionRequest({
|
|
3162
|
+
requestId,
|
|
3163
|
+
message: {
|
|
3164
|
+
type: "diagnostics.request",
|
|
3165
|
+
},
|
|
3166
|
+
});
|
|
3167
|
+
}
|
|
3168
|
+
async patchDaemonConfig(config, requestId) {
|
|
3169
|
+
return this.sendCorrelatedSessionRequest({
|
|
3170
|
+
requestId,
|
|
3171
|
+
message: {
|
|
3172
|
+
type: "set_daemon_config_request",
|
|
3173
|
+
config,
|
|
3174
|
+
},
|
|
3175
|
+
responseType: "set_daemon_config_response",
|
|
3176
|
+
});
|
|
3177
|
+
}
|
|
3178
|
+
sendBrowserAutomationExecuteResponse(response) {
|
|
3179
|
+
this.sendSessionMessageStrict(response);
|
|
3180
|
+
}
|
|
3181
|
+
async readProjectConfig(repoRoot, requestId) {
|
|
3182
|
+
return this.sendCorrelatedSessionRequest({
|
|
3183
|
+
requestId,
|
|
3184
|
+
message: {
|
|
3185
|
+
type: "read_project_config_request",
|
|
3186
|
+
repoRoot,
|
|
3187
|
+
},
|
|
3188
|
+
responseType: "read_project_config_response",
|
|
3189
|
+
});
|
|
3190
|
+
}
|
|
3191
|
+
async writeProjectConfig(input) {
|
|
3192
|
+
return this.sendCorrelatedSessionRequest({
|
|
3193
|
+
requestId: input.requestId,
|
|
3194
|
+
message: {
|
|
3195
|
+
type: "write_project_config_request",
|
|
3196
|
+
repoRoot: input.repoRoot,
|
|
3197
|
+
config: input.config,
|
|
3198
|
+
expectedRevision: input.expectedRevision,
|
|
3199
|
+
},
|
|
3200
|
+
responseType: "write_project_config_response",
|
|
3201
|
+
});
|
|
3202
|
+
}
|
|
3203
|
+
async refreshProvidersSnapshot(options) {
|
|
3204
|
+
return this.sendCorrelatedSessionRequest({
|
|
3205
|
+
requestId: options?.requestId,
|
|
3206
|
+
message: {
|
|
3207
|
+
type: "refresh_providers_snapshot_request",
|
|
3208
|
+
cwd: options?.cwd,
|
|
3209
|
+
providers: options?.providers,
|
|
3210
|
+
},
|
|
3211
|
+
responseType: "refresh_providers_snapshot_response",
|
|
3212
|
+
timeout: 120000,
|
|
3213
|
+
});
|
|
3214
|
+
}
|
|
3215
|
+
async getProviderDiagnostic(provider, options) {
|
|
3216
|
+
return this.sendCorrelatedSessionRequest({
|
|
3217
|
+
requestId: options?.requestId,
|
|
3218
|
+
message: {
|
|
3219
|
+
type: "provider_diagnostic_request",
|
|
3220
|
+
provider,
|
|
3221
|
+
},
|
|
3222
|
+
responseType: "provider_diagnostic_response",
|
|
3223
|
+
timeout: 180000,
|
|
3224
|
+
});
|
|
3225
|
+
}
|
|
3226
|
+
async listProviderUsage(options) {
|
|
3227
|
+
return this.sendNamespacedCorrelatedSessionRequest({
|
|
3228
|
+
requestId: options?.requestId,
|
|
3229
|
+
message: {
|
|
3230
|
+
type: "provider.usage.list.request",
|
|
3231
|
+
},
|
|
3232
|
+
});
|
|
3233
|
+
}
|
|
3234
|
+
async listCommands(input, legacyOptions) {
|
|
3235
|
+
const options = normalizeListCommandsOptions(input, legacyOptions);
|
|
3236
|
+
return this.sendCorrelatedSessionRequest({
|
|
3237
|
+
requestId: options.requestId,
|
|
3238
|
+
message: {
|
|
3239
|
+
type: "list_commands_request",
|
|
3240
|
+
agentId: options.agentId,
|
|
3241
|
+
...(options.draftConfig ? { draftConfig: options.draftConfig } : {}),
|
|
3242
|
+
},
|
|
3243
|
+
responseType: "list_commands_response",
|
|
3244
|
+
});
|
|
3245
|
+
}
|
|
3246
|
+
// ============================================================================
|
|
3247
|
+
// Permissions
|
|
3248
|
+
// ============================================================================
|
|
3249
|
+
async respondToPermission(agentId, requestId, response) {
|
|
3250
|
+
this.sendSessionMessage({
|
|
3251
|
+
type: "agent_permission_response",
|
|
3252
|
+
agentId,
|
|
3253
|
+
requestId,
|
|
3254
|
+
response,
|
|
3255
|
+
});
|
|
3256
|
+
}
|
|
3257
|
+
async respondToPermissionAndWait(agentId, requestId, response, timeout = 15000) {
|
|
3258
|
+
const message = SessionInboundMessageSchema.parse({
|
|
3259
|
+
type: "agent_permission_response",
|
|
3260
|
+
agentId,
|
|
3261
|
+
requestId,
|
|
3262
|
+
response,
|
|
3263
|
+
});
|
|
3264
|
+
return this.sendRequest({
|
|
3265
|
+
requestId,
|
|
3266
|
+
message,
|
|
3267
|
+
timeout,
|
|
3268
|
+
options: { skipQueue: true },
|
|
3269
|
+
select: (msg) => {
|
|
3270
|
+
if (msg.type !== "agent_permission_resolved") {
|
|
3271
|
+
return null;
|
|
3272
|
+
}
|
|
3273
|
+
if (msg.payload.requestId !== requestId) {
|
|
3274
|
+
return null;
|
|
3275
|
+
}
|
|
3276
|
+
if (msg.payload.agentId !== agentId) {
|
|
3277
|
+
return null;
|
|
3278
|
+
}
|
|
3279
|
+
return msg.payload;
|
|
3280
|
+
},
|
|
3281
|
+
});
|
|
3282
|
+
}
|
|
3283
|
+
// ============================================================================
|
|
3284
|
+
// Waiting / Streaming Helpers
|
|
3285
|
+
// ============================================================================
|
|
3286
|
+
async waitForAgentUpsert(agentId, predicate, timeout = 60000) {
|
|
3287
|
+
const deadline = Date.now() + timeout;
|
|
3288
|
+
const remainingTimeoutMs = () => Math.max(1, deadline - Date.now());
|
|
3289
|
+
const timeoutError = () => new Error(`Timed out waiting for agent ${agentId}`);
|
|
3290
|
+
const fetchAgentWithinDeadline = () => this.fetchAgent({ agentId, timeout: remainingTimeoutMs() }).catch(() => null);
|
|
3291
|
+
const initialResult = await fetchAgentWithinDeadline();
|
|
3292
|
+
if (initialResult && predicate(initialResult.agent)) {
|
|
3293
|
+
return initialResult.agent;
|
|
3294
|
+
}
|
|
3295
|
+
if (Date.now() >= deadline) {
|
|
3296
|
+
throw timeoutError();
|
|
3297
|
+
}
|
|
3298
|
+
return await new Promise((resolve, reject) => {
|
|
3299
|
+
let settled = false;
|
|
3300
|
+
let pollInFlight = false;
|
|
3301
|
+
let pollTimer = null;
|
|
3302
|
+
let timeoutTimer = null;
|
|
3303
|
+
let unsubscribe = null;
|
|
3304
|
+
const finish = (result) => {
|
|
3305
|
+
if (settled) {
|
|
3306
|
+
return;
|
|
3307
|
+
}
|
|
3308
|
+
settled = true;
|
|
3309
|
+
if (timeoutTimer) {
|
|
3310
|
+
clearTimeout(timeoutTimer);
|
|
3311
|
+
timeoutTimer = null;
|
|
3312
|
+
}
|
|
3313
|
+
if (pollTimer) {
|
|
3314
|
+
clearInterval(pollTimer);
|
|
3315
|
+
pollTimer = null;
|
|
3316
|
+
}
|
|
3317
|
+
if (unsubscribe) {
|
|
3318
|
+
unsubscribe();
|
|
3319
|
+
unsubscribe = null;
|
|
3320
|
+
}
|
|
3321
|
+
if (result.kind === "ok") {
|
|
3322
|
+
resolve(result.snapshot);
|
|
3323
|
+
return;
|
|
3324
|
+
}
|
|
3325
|
+
reject(result.error);
|
|
3326
|
+
};
|
|
3327
|
+
const maybeResolve = (snapshot) => {
|
|
3328
|
+
if (!snapshot) {
|
|
3329
|
+
return false;
|
|
3330
|
+
}
|
|
3331
|
+
if (!predicate(snapshot)) {
|
|
3332
|
+
return false;
|
|
3333
|
+
}
|
|
3334
|
+
finish({ kind: "ok", snapshot });
|
|
3335
|
+
return true;
|
|
3336
|
+
};
|
|
3337
|
+
const poll = async () => {
|
|
3338
|
+
if (settled || pollInFlight) {
|
|
3339
|
+
return;
|
|
3340
|
+
}
|
|
3341
|
+
pollInFlight = true;
|
|
3342
|
+
try {
|
|
3343
|
+
const result = await fetchAgentWithinDeadline();
|
|
3344
|
+
maybeResolve(result?.agent ?? null);
|
|
3345
|
+
}
|
|
3346
|
+
finally {
|
|
3347
|
+
pollInFlight = false;
|
|
3348
|
+
}
|
|
3349
|
+
};
|
|
3350
|
+
unsubscribe = this.on("agent_update", (message) => {
|
|
3351
|
+
if (settled) {
|
|
3352
|
+
return;
|
|
3353
|
+
}
|
|
3354
|
+
if (message.payload.kind !== "upsert") {
|
|
3355
|
+
return;
|
|
3356
|
+
}
|
|
3357
|
+
const snapshot = message.payload.agent;
|
|
3358
|
+
if (snapshot.id !== agentId) {
|
|
3359
|
+
return;
|
|
3360
|
+
}
|
|
3361
|
+
maybeResolve(snapshot);
|
|
3362
|
+
});
|
|
3363
|
+
const remaining = Math.max(1, deadline - Date.now());
|
|
3364
|
+
timeoutTimer = setTimeout(() => {
|
|
3365
|
+
finish({
|
|
3366
|
+
kind: "error",
|
|
3367
|
+
error: timeoutError(),
|
|
3368
|
+
});
|
|
3369
|
+
}, remaining);
|
|
3370
|
+
pollTimer = setInterval(() => {
|
|
3371
|
+
void poll();
|
|
3372
|
+
}, 250);
|
|
3373
|
+
void poll();
|
|
3374
|
+
});
|
|
3375
|
+
}
|
|
3376
|
+
async waitForFinish(agentId, timeout = 60000) {
|
|
3377
|
+
const requestId = this.createRequestId();
|
|
3378
|
+
const hasTimeout = Number.isFinite(timeout) && timeout > 0;
|
|
3379
|
+
const message = SessionInboundMessageSchema.parse({
|
|
3380
|
+
type: "wait_for_finish_request",
|
|
3381
|
+
requestId,
|
|
3382
|
+
agentId,
|
|
3383
|
+
...(hasTimeout ? { timeoutMs: timeout } : {}),
|
|
3384
|
+
});
|
|
3385
|
+
const payload = await this.sendCorrelatedRequest({
|
|
3386
|
+
requestId,
|
|
3387
|
+
message,
|
|
3388
|
+
responseType: "wait_for_finish_response",
|
|
3389
|
+
timeout: hasTimeout ? timeout + 5000 : 0,
|
|
3390
|
+
options: { skipQueue: true },
|
|
3391
|
+
});
|
|
3392
|
+
return {
|
|
3393
|
+
status: payload.status,
|
|
3394
|
+
final: payload.final,
|
|
3395
|
+
error: payload.error,
|
|
3396
|
+
lastMessage: payload.lastMessage,
|
|
3397
|
+
};
|
|
3398
|
+
}
|
|
3399
|
+
// ============================================================================
|
|
3400
|
+
// Terminals
|
|
3401
|
+
// ============================================================================
|
|
3402
|
+
subscribeTerminals(input) {
|
|
3403
|
+
this.terminalDirectorySubscriptions.set(terminalSubscriptionKey(input.cwd, input.workspaceId), {
|
|
3404
|
+
cwd: input.cwd,
|
|
3405
|
+
workspaceId: input.workspaceId,
|
|
3406
|
+
});
|
|
3407
|
+
if (!this.transport || this.connectionState.status !== "connected") {
|
|
3408
|
+
return;
|
|
3409
|
+
}
|
|
3410
|
+
this.sendSessionMessage({
|
|
3411
|
+
type: "subscribe_terminals_request",
|
|
3412
|
+
cwd: input.cwd,
|
|
3413
|
+
...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}),
|
|
3414
|
+
});
|
|
3415
|
+
}
|
|
3416
|
+
unsubscribeTerminals(input) {
|
|
3417
|
+
this.terminalDirectorySubscriptions.delete(terminalSubscriptionKey(input.cwd, input.workspaceId));
|
|
3418
|
+
if (!this.transport || this.connectionState.status !== "connected") {
|
|
3419
|
+
return;
|
|
3420
|
+
}
|
|
3421
|
+
this.sendSessionMessage({
|
|
3422
|
+
type: "unsubscribe_terminals_request",
|
|
3423
|
+
cwd: input.cwd,
|
|
3424
|
+
...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}),
|
|
3425
|
+
});
|
|
3426
|
+
}
|
|
3427
|
+
async listTerminals(cwd, requestId, options) {
|
|
3428
|
+
const resolvedRequestId = this.createRequestId(requestId);
|
|
3429
|
+
const message = SessionInboundMessageSchema.parse({
|
|
3430
|
+
type: "list_terminals_request",
|
|
3431
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
3432
|
+
...(options?.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}),
|
|
3433
|
+
requestId: resolvedRequestId,
|
|
3434
|
+
});
|
|
3435
|
+
return this.sendCorrelatedRequest({
|
|
3436
|
+
requestId: resolvedRequestId,
|
|
3437
|
+
message,
|
|
3438
|
+
responseType: "list_terminals_response",
|
|
3439
|
+
options: { skipQueue: true },
|
|
3440
|
+
});
|
|
3441
|
+
}
|
|
3442
|
+
async createTerminal(cwd, name, requestId, options) {
|
|
3443
|
+
const resolvedRequestId = this.createRequestId(requestId);
|
|
3444
|
+
const message = SessionInboundMessageSchema.parse({
|
|
3445
|
+
type: "create_terminal_request",
|
|
3446
|
+
cwd,
|
|
3447
|
+
name,
|
|
3448
|
+
agentId: options?.agentId,
|
|
3449
|
+
command: options?.command,
|
|
3450
|
+
args: options?.args,
|
|
3451
|
+
...(options?.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}),
|
|
3452
|
+
requestId: resolvedRequestId,
|
|
3453
|
+
});
|
|
3454
|
+
return this.sendCorrelatedRequest({
|
|
3455
|
+
requestId: resolvedRequestId,
|
|
3456
|
+
message,
|
|
3457
|
+
responseType: "create_terminal_response",
|
|
3458
|
+
options: { skipQueue: true },
|
|
3459
|
+
});
|
|
3460
|
+
}
|
|
3461
|
+
async renameTerminal(input) {
|
|
3462
|
+
return this.sendCorrelatedSessionRequest({
|
|
3463
|
+
requestId: input.requestId,
|
|
3464
|
+
message: {
|
|
3465
|
+
type: "terminal.rename.request",
|
|
3466
|
+
terminalId: input.terminalId,
|
|
3467
|
+
title: input.title,
|
|
3468
|
+
},
|
|
3469
|
+
responseType: "terminal.rename.response",
|
|
3470
|
+
});
|
|
3471
|
+
}
|
|
3472
|
+
async subscribeTerminal(terminalId, optionsOrRequestId) {
|
|
3473
|
+
const restore = typeof optionsOrRequestId === "object" ? optionsOrRequestId.restore : undefined;
|
|
3474
|
+
const requestId = typeof optionsOrRequestId === "object" ? optionsOrRequestId.requestId : optionsOrRequestId;
|
|
3475
|
+
const resolvedRequestId = this.createRequestId(requestId);
|
|
3476
|
+
const message = SessionInboundMessageSchema.parse({
|
|
3477
|
+
type: "subscribe_terminal_request",
|
|
3478
|
+
terminalId,
|
|
3479
|
+
requestId: resolvedRequestId,
|
|
3480
|
+
...(restore ? { restore } : {}),
|
|
3481
|
+
});
|
|
3482
|
+
const payload = await this.sendCorrelatedRequest({
|
|
3483
|
+
requestId: resolvedRequestId,
|
|
3484
|
+
message,
|
|
3485
|
+
responseType: "subscribe_terminal_response",
|
|
3486
|
+
options: { skipQueue: true },
|
|
3487
|
+
});
|
|
3488
|
+
if (payload.error === null) {
|
|
3489
|
+
this.terminalStreams.setSlot(terminalId, payload.slot);
|
|
3490
|
+
}
|
|
3491
|
+
return payload;
|
|
3492
|
+
}
|
|
3493
|
+
unsubscribeTerminal(terminalId) {
|
|
3494
|
+
this.terminalStreams.removeTerminal(terminalId);
|
|
3495
|
+
this.sendSessionMessage({
|
|
3496
|
+
type: "unsubscribe_terminal_request",
|
|
3497
|
+
terminalId,
|
|
3498
|
+
});
|
|
3499
|
+
}
|
|
3500
|
+
sendTerminalInput(terminalId, message) {
|
|
3501
|
+
const frame = this.terminalStreams.encodeInput(terminalId, message);
|
|
3502
|
+
if (frame) {
|
|
3503
|
+
this.sendBinaryFrame(frame);
|
|
3504
|
+
return;
|
|
3505
|
+
}
|
|
3506
|
+
this.sendSessionMessage({
|
|
3507
|
+
type: "terminal_input",
|
|
3508
|
+
terminalId,
|
|
3509
|
+
message,
|
|
3510
|
+
});
|
|
3511
|
+
}
|
|
3512
|
+
async killTerminal(terminalId, requestId) {
|
|
3513
|
+
const resolvedRequestId = this.createRequestId(requestId);
|
|
3514
|
+
const message = SessionInboundMessageSchema.parse({
|
|
3515
|
+
type: "kill_terminal_request",
|
|
3516
|
+
terminalId,
|
|
3517
|
+
requestId: resolvedRequestId,
|
|
3518
|
+
});
|
|
3519
|
+
return this.sendCorrelatedRequest({
|
|
3520
|
+
requestId: resolvedRequestId,
|
|
3521
|
+
message,
|
|
3522
|
+
responseType: "kill_terminal_response",
|
|
3523
|
+
options: { skipQueue: true },
|
|
3524
|
+
});
|
|
3525
|
+
}
|
|
3526
|
+
async closeItems(input, requestId) {
|
|
3527
|
+
const resolvedRequestId = this.createRequestId(requestId);
|
|
3528
|
+
const message = SessionInboundMessageSchema.parse({
|
|
3529
|
+
type: "close_items_request",
|
|
3530
|
+
agentIds: input.agentIds ?? [],
|
|
3531
|
+
terminalIds: input.terminalIds ?? [],
|
|
3532
|
+
requestId: resolvedRequestId,
|
|
3533
|
+
});
|
|
3534
|
+
return this.sendCorrelatedRequest({
|
|
3535
|
+
requestId: resolvedRequestId,
|
|
3536
|
+
message,
|
|
3537
|
+
responseType: "close_items_response",
|
|
3538
|
+
options: { skipQueue: true },
|
|
3539
|
+
});
|
|
3540
|
+
}
|
|
3541
|
+
async captureTerminal(terminalId, options, requestId) {
|
|
3542
|
+
const resolvedRequestId = this.createRequestId(requestId);
|
|
3543
|
+
const message = SessionInboundMessageSchema.parse({
|
|
3544
|
+
type: "capture_terminal_request",
|
|
3545
|
+
terminalId,
|
|
3546
|
+
...(options?.start === undefined ? {} : { start: options.start }),
|
|
3547
|
+
...(options?.end === undefined ? {} : { end: options.end }),
|
|
3548
|
+
...(options?.stripAnsi === undefined ? {} : { stripAnsi: options.stripAnsi }),
|
|
3549
|
+
requestId: resolvedRequestId,
|
|
3550
|
+
});
|
|
3551
|
+
return this.sendCorrelatedRequest({
|
|
3552
|
+
requestId: resolvedRequestId,
|
|
3553
|
+
message,
|
|
3554
|
+
responseType: "capture_terminal_response",
|
|
3555
|
+
options: { skipQueue: true },
|
|
3556
|
+
});
|
|
3557
|
+
}
|
|
3558
|
+
async createChatRoom(options) {
|
|
3559
|
+
return this.sendCorrelatedSessionRequest({
|
|
3560
|
+
requestId: options.requestId,
|
|
3561
|
+
message: {
|
|
3562
|
+
type: "chat/create",
|
|
3563
|
+
name: options.name,
|
|
3564
|
+
...(options.purpose ? { purpose: options.purpose } : {}),
|
|
3565
|
+
},
|
|
3566
|
+
responseType: "chat/create/response",
|
|
3567
|
+
});
|
|
3568
|
+
}
|
|
3569
|
+
async listChatRooms(requestId) {
|
|
3570
|
+
return this.sendCorrelatedSessionRequest({
|
|
3571
|
+
requestId,
|
|
3572
|
+
message: {
|
|
3573
|
+
type: "chat/list",
|
|
3574
|
+
},
|
|
3575
|
+
responseType: "chat/list/response",
|
|
3576
|
+
});
|
|
3577
|
+
}
|
|
3578
|
+
async inspectChatRoom(options) {
|
|
3579
|
+
return this.sendCorrelatedSessionRequest({
|
|
3580
|
+
requestId: options.requestId,
|
|
3581
|
+
message: {
|
|
3582
|
+
type: "chat/inspect",
|
|
3583
|
+
room: options.room,
|
|
3584
|
+
},
|
|
3585
|
+
responseType: "chat/inspect/response",
|
|
3586
|
+
});
|
|
3587
|
+
}
|
|
3588
|
+
async deleteChatRoom(options) {
|
|
3589
|
+
return this.sendCorrelatedSessionRequest({
|
|
3590
|
+
requestId: options.requestId,
|
|
3591
|
+
message: {
|
|
3592
|
+
type: "chat/delete",
|
|
3593
|
+
room: options.room,
|
|
3594
|
+
},
|
|
3595
|
+
responseType: "chat/delete/response",
|
|
3596
|
+
});
|
|
3597
|
+
}
|
|
3598
|
+
async postChatMessage(options) {
|
|
3599
|
+
return this.sendCorrelatedSessionRequest({
|
|
3600
|
+
requestId: options.requestId,
|
|
3601
|
+
message: {
|
|
3602
|
+
type: "chat/post",
|
|
3603
|
+
room: options.room,
|
|
3604
|
+
body: options.body,
|
|
3605
|
+
...(options.authorAgentId ? { authorAgentId: options.authorAgentId } : {}),
|
|
3606
|
+
...(options.replyToMessageId ? { replyToMessageId: options.replyToMessageId } : {}),
|
|
3607
|
+
},
|
|
3608
|
+
responseType: "chat/post/response",
|
|
3609
|
+
});
|
|
3610
|
+
}
|
|
3611
|
+
async readChatMessages(options) {
|
|
3612
|
+
return this.sendCorrelatedSessionRequest({
|
|
3613
|
+
requestId: options.requestId,
|
|
3614
|
+
message: {
|
|
3615
|
+
type: "chat/read",
|
|
3616
|
+
room: options.room,
|
|
3617
|
+
...(typeof options.limit === "number" ? { limit: options.limit } : {}),
|
|
3618
|
+
...(options.since ? { since: options.since } : {}),
|
|
3619
|
+
...(options.authorAgentId ? { authorAgentId: options.authorAgentId } : {}),
|
|
3620
|
+
},
|
|
3621
|
+
responseType: "chat/read/response",
|
|
3622
|
+
timeout: options.timeout,
|
|
3623
|
+
});
|
|
3624
|
+
}
|
|
3625
|
+
async waitForChatMessages(options) {
|
|
3626
|
+
return this.sendCorrelatedSessionRequest({
|
|
3627
|
+
requestId: options.requestId,
|
|
3628
|
+
message: {
|
|
3629
|
+
type: "chat/wait",
|
|
3630
|
+
room: options.room,
|
|
3631
|
+
...(options.afterMessageId ? { afterMessageId: options.afterMessageId } : {}),
|
|
3632
|
+
...(typeof options.timeoutMs === "number" ? { timeoutMs: options.timeoutMs } : {}),
|
|
3633
|
+
},
|
|
3634
|
+
responseType: "chat/wait/response",
|
|
3635
|
+
timeout: (options.timeoutMs ?? 0) + 10000,
|
|
3636
|
+
});
|
|
3637
|
+
}
|
|
3638
|
+
async scheduleCreate(options) {
|
|
3639
|
+
return this.sendCorrelatedSessionRequest({
|
|
3640
|
+
requestId: options.requestId,
|
|
3641
|
+
message: {
|
|
3642
|
+
type: "schedule/create",
|
|
3643
|
+
prompt: options.prompt,
|
|
3644
|
+
cadence: options.cadence,
|
|
3645
|
+
target: options.target,
|
|
3646
|
+
...(options.name ? { name: options.name } : {}),
|
|
3647
|
+
...(typeof options.maxRuns === "number" ? { maxRuns: options.maxRuns } : {}),
|
|
3648
|
+
...(options.expiresAt ? { expiresAt: options.expiresAt } : {}),
|
|
3649
|
+
...(typeof options.runOnCreate === "boolean" ? { runOnCreate: options.runOnCreate } : {}),
|
|
3650
|
+
},
|
|
3651
|
+
responseType: "schedule/create/response",
|
|
3652
|
+
});
|
|
3653
|
+
}
|
|
3654
|
+
async scheduleList(requestId) {
|
|
3655
|
+
return this.sendCorrelatedSessionRequest({
|
|
3656
|
+
requestId,
|
|
3657
|
+
message: {
|
|
3658
|
+
type: "schedule/list",
|
|
3659
|
+
},
|
|
3660
|
+
responseType: "schedule/list/response",
|
|
3661
|
+
});
|
|
3662
|
+
}
|
|
3663
|
+
async scheduleInspect(options) {
|
|
3664
|
+
return this.sendCorrelatedSessionRequest({
|
|
3665
|
+
requestId: options.requestId,
|
|
3666
|
+
message: {
|
|
3667
|
+
type: "schedule/inspect",
|
|
3668
|
+
scheduleId: options.id,
|
|
3669
|
+
},
|
|
3670
|
+
responseType: "schedule/inspect/response",
|
|
3671
|
+
});
|
|
3672
|
+
}
|
|
3673
|
+
async scheduleLogs(options) {
|
|
3674
|
+
return this.sendCorrelatedSessionRequest({
|
|
3675
|
+
requestId: options.requestId,
|
|
3676
|
+
message: {
|
|
3677
|
+
type: "schedule/logs",
|
|
3678
|
+
scheduleId: options.id,
|
|
3679
|
+
},
|
|
3680
|
+
responseType: "schedule/logs/response",
|
|
3681
|
+
});
|
|
3682
|
+
}
|
|
3683
|
+
async schedulePause(options) {
|
|
3684
|
+
return this.sendCorrelatedSessionRequest({
|
|
3685
|
+
requestId: options.requestId,
|
|
3686
|
+
message: {
|
|
3687
|
+
type: "schedule/pause",
|
|
3688
|
+
scheduleId: options.id,
|
|
3689
|
+
},
|
|
3690
|
+
responseType: "schedule/pause/response",
|
|
3691
|
+
});
|
|
3692
|
+
}
|
|
3693
|
+
async scheduleResume(options) {
|
|
3694
|
+
return this.sendCorrelatedSessionRequest({
|
|
3695
|
+
requestId: options.requestId,
|
|
3696
|
+
message: {
|
|
3697
|
+
type: "schedule/resume",
|
|
3698
|
+
scheduleId: options.id,
|
|
3699
|
+
},
|
|
3700
|
+
responseType: "schedule/resume/response",
|
|
3701
|
+
});
|
|
3702
|
+
}
|
|
3703
|
+
async scheduleDelete(options) {
|
|
3704
|
+
return this.sendCorrelatedSessionRequest({
|
|
3705
|
+
requestId: options.requestId,
|
|
3706
|
+
message: {
|
|
3707
|
+
type: "schedule/delete",
|
|
3708
|
+
scheduleId: options.id,
|
|
3709
|
+
},
|
|
3710
|
+
responseType: "schedule/delete/response",
|
|
3711
|
+
});
|
|
3712
|
+
}
|
|
3713
|
+
async scheduleRunOnce(options) {
|
|
3714
|
+
return this.sendCorrelatedSessionRequest({
|
|
3715
|
+
requestId: options.requestId,
|
|
3716
|
+
message: {
|
|
3717
|
+
type: "schedule/run-once",
|
|
3718
|
+
scheduleId: options.id,
|
|
3719
|
+
},
|
|
3720
|
+
responseType: "schedule/run-once/response",
|
|
3721
|
+
});
|
|
3722
|
+
}
|
|
3723
|
+
async scheduleUpdate(options) {
|
|
3724
|
+
return this.sendCorrelatedSessionRequest({
|
|
3725
|
+
requestId: options.requestId,
|
|
3726
|
+
message: {
|
|
3727
|
+
type: "schedule/update",
|
|
3728
|
+
scheduleId: options.id,
|
|
3729
|
+
...(options.name !== undefined ? { name: options.name } : {}),
|
|
3730
|
+
...(options.prompt !== undefined ? { prompt: options.prompt } : {}),
|
|
3731
|
+
...(options.cadence !== undefined ? { cadence: options.cadence } : {}),
|
|
3732
|
+
...(options.newAgentConfig !== undefined ? { newAgentConfig: options.newAgentConfig } : {}),
|
|
3733
|
+
...(options.maxRuns !== undefined ? { maxRuns: options.maxRuns } : {}),
|
|
3734
|
+
...(options.expiresAt !== undefined ? { expiresAt: options.expiresAt } : {}),
|
|
3735
|
+
},
|
|
3736
|
+
responseType: "schedule/update/response",
|
|
3737
|
+
});
|
|
3738
|
+
}
|
|
3739
|
+
async loopRun(options) {
|
|
3740
|
+
return this.sendCorrelatedSessionRequest({
|
|
3741
|
+
requestId: options.requestId,
|
|
3742
|
+
message: {
|
|
3743
|
+
type: "loop/run",
|
|
3744
|
+
prompt: options.prompt,
|
|
3745
|
+
cwd: options.cwd,
|
|
3746
|
+
...(options.provider ? { provider: options.provider } : {}),
|
|
3747
|
+
...(options.model ? { model: options.model } : {}),
|
|
3748
|
+
...(options.modeId ? { modeId: options.modeId } : {}),
|
|
3749
|
+
...(options.verifierProvider ? { verifierProvider: options.verifierProvider } : {}),
|
|
3750
|
+
...(options.verifierModel ? { verifierModel: options.verifierModel } : {}),
|
|
3751
|
+
...(options.verifierModeId ? { verifierModeId: options.verifierModeId } : {}),
|
|
3752
|
+
...(options.verifyPrompt ? { verifyPrompt: options.verifyPrompt } : {}),
|
|
3753
|
+
...(options.verifyChecks && options.verifyChecks.length > 0
|
|
3754
|
+
? { verifyChecks: options.verifyChecks }
|
|
3755
|
+
: {}),
|
|
3756
|
+
...(options.name ? { name: options.name } : {}),
|
|
3757
|
+
...(typeof options.sleepMs === "number" ? { sleepMs: options.sleepMs } : {}),
|
|
3758
|
+
...(typeof options.maxIterations === "number"
|
|
3759
|
+
? { maxIterations: options.maxIterations }
|
|
3760
|
+
: {}),
|
|
3761
|
+
...(typeof options.maxTimeMs === "number" ? { maxTimeMs: options.maxTimeMs } : {}),
|
|
3762
|
+
},
|
|
3763
|
+
responseType: "loop/run/response",
|
|
3764
|
+
});
|
|
3765
|
+
}
|
|
3766
|
+
async loopList(requestId) {
|
|
3767
|
+
return this.sendCorrelatedSessionRequest({
|
|
3768
|
+
requestId,
|
|
3769
|
+
message: {
|
|
3770
|
+
type: "loop/list",
|
|
3771
|
+
},
|
|
3772
|
+
responseType: "loop/list/response",
|
|
3773
|
+
});
|
|
3774
|
+
}
|
|
3775
|
+
async loopInspect(options) {
|
|
3776
|
+
const normalized = typeof options === "string" ? { id: options } : options;
|
|
3777
|
+
return this.sendCorrelatedSessionRequest({
|
|
3778
|
+
requestId: normalized.requestId,
|
|
3779
|
+
message: {
|
|
3780
|
+
type: "loop/inspect",
|
|
3781
|
+
id: normalized.id,
|
|
3782
|
+
},
|
|
3783
|
+
responseType: "loop/inspect/response",
|
|
3784
|
+
});
|
|
3785
|
+
}
|
|
3786
|
+
async loopLogs(options, afterSeq) {
|
|
3787
|
+
const normalized = typeof options === "string" ? { id: options, afterSeq } : options;
|
|
3788
|
+
return this.sendCorrelatedSessionRequest({
|
|
3789
|
+
requestId: normalized.requestId,
|
|
3790
|
+
message: {
|
|
3791
|
+
type: "loop/logs",
|
|
3792
|
+
id: normalized.id,
|
|
3793
|
+
...(typeof normalized.afterSeq === "number" ? { afterSeq: normalized.afterSeq } : {}),
|
|
3794
|
+
},
|
|
3795
|
+
responseType: "loop/logs/response",
|
|
3796
|
+
});
|
|
3797
|
+
}
|
|
3798
|
+
async loopStop(options) {
|
|
3799
|
+
const normalized = typeof options === "string" ? { id: options } : options;
|
|
3800
|
+
return this.sendCorrelatedSessionRequest({
|
|
3801
|
+
requestId: normalized.requestId,
|
|
3802
|
+
message: {
|
|
3803
|
+
type: "loop/stop",
|
|
3804
|
+
id: normalized.id,
|
|
3805
|
+
},
|
|
3806
|
+
responseType: "loop/stop/response",
|
|
3807
|
+
});
|
|
3808
|
+
}
|
|
3809
|
+
onTerminalStreamEvent(handler) {
|
|
3810
|
+
return this.terminalStreams.onEvent(handler);
|
|
3811
|
+
}
|
|
3812
|
+
async waitForTerminalStreamEvent(predicate, timeout = 5000) {
|
|
3813
|
+
return new Promise((resolve, reject) => {
|
|
3814
|
+
const timeoutHandle = setTimeout(() => {
|
|
3815
|
+
unsubscribe();
|
|
3816
|
+
reject(new Error(`Timeout waiting for terminal stream event (${timeout}ms)`));
|
|
3817
|
+
}, timeout);
|
|
3818
|
+
const unsubscribe = this.onTerminalStreamEvent((event) => {
|
|
3819
|
+
if (!predicate(event)) {
|
|
3820
|
+
return;
|
|
3821
|
+
}
|
|
3822
|
+
clearTimeout(timeoutHandle);
|
|
3823
|
+
unsubscribe();
|
|
3824
|
+
resolve(event);
|
|
3825
|
+
});
|
|
3826
|
+
});
|
|
3827
|
+
}
|
|
3828
|
+
/**
|
|
3829
|
+
* Upload a file to an agent's workspace as a windowed, cancellable,
|
|
3830
|
+
* progress-reporting stream of {@link UPLOAD_CHUNK_SIZE_BYTES} `FileChunk`
|
|
3831
|
+
* frames with ack-based flow control. Resolves `{ fileId, path, size,
|
|
3832
|
+
* mimeType }` once the daemon finalizes the file.
|
|
3833
|
+
*
|
|
3834
|
+
* Purely additive over the receive path (`activeBinaryFileTransfers`): this
|
|
3835
|
+
* method only sends. It never buffers more than {@link UPLOAD_WINDOW_SIZE}
|
|
3836
|
+
* chunks in flight, so it cannot approach the relay's `MAX_PENDING_SENDS`
|
|
3837
|
+
* ceiling.
|
|
3838
|
+
*
|
|
3839
|
+
* @throws {UploadCancelledError} when `opts.signal` aborts
|
|
3840
|
+
* @throws {UploadCapExceededError} when the daemon rejects on the size cap
|
|
3841
|
+
* @throws {UploadFailedError} for any other transfer failure
|
|
3842
|
+
*/
|
|
3843
|
+
async uploadFile(file, opts) {
|
|
3844
|
+
const requestId = this.createRequestId();
|
|
3845
|
+
const uploadId = crypto.randomUUID();
|
|
3846
|
+
const totalBytes = file.bytes.byteLength;
|
|
3847
|
+
const signal = opts.signal;
|
|
3848
|
+
// Reject before sending anything if already aborted at call time.
|
|
3849
|
+
if (signal?.aborted) {
|
|
3850
|
+
throw new UploadCancelledError(uploadId);
|
|
3851
|
+
}
|
|
3852
|
+
// Split into fixed-size chunks using the single-source protocol constant
|
|
3853
|
+
// (UPLOAD_CHUNK_SIZE_BYTES) — never a local byte-count literal (AC 6).
|
|
3854
|
+
const chunks = [];
|
|
3855
|
+
for (let offset = 0; offset < totalBytes; offset += UPLOAD_CHUNK_SIZE_BYTES) {
|
|
3856
|
+
chunks.push(file.bytes.subarray(offset, Math.min(offset + UPLOAD_CHUNK_SIZE_BYTES, totalBytes)));
|
|
3857
|
+
}
|
|
3858
|
+
const totalChunks = chunks.length;
|
|
3859
|
+
// --- Control-plane begin: declare size so the daemon can early-reject
|
|
3860
|
+
// (story 2.2) before any chunk is sent. ---
|
|
3861
|
+
let beginResponse;
|
|
3862
|
+
try {
|
|
3863
|
+
beginResponse = await this.sendCorrelatedRequest({
|
|
3864
|
+
requestId,
|
|
3865
|
+
message: SessionInboundMessageSchema.parse({
|
|
3866
|
+
type: "files.upload.begin.request",
|
|
3867
|
+
requestId,
|
|
3868
|
+
agentId: opts.agentId,
|
|
3869
|
+
uploadId,
|
|
3870
|
+
metadata: {
|
|
3871
|
+
mime: file.mime,
|
|
3872
|
+
size: totalBytes,
|
|
3873
|
+
encoding: "binary",
|
|
3874
|
+
modifiedAt: file.modifiedAt,
|
|
3875
|
+
// Send the basename so the daemon stages `{uuid}-{sanitizedFilename}` and the
|
|
3876
|
+
// original name reaches the agent prompt — never the full path (the daemon strips
|
|
3877
|
+
// separators, which would concatenate path segments).
|
|
3878
|
+
fileName: file.path.split(/[\\/]/).at(-1) || file.path,
|
|
3879
|
+
},
|
|
3880
|
+
}),
|
|
3881
|
+
responseType: "files.upload.begin.response",
|
|
3882
|
+
timeout: 30000,
|
|
3883
|
+
options: { skipQueue: true },
|
|
3884
|
+
});
|
|
3885
|
+
}
|
|
3886
|
+
catch (error) {
|
|
3887
|
+
throw new UploadFailedError(uploadId, error instanceof Error ? error.message : String(error));
|
|
3888
|
+
}
|
|
3889
|
+
if (!beginResponse.accepted || beginResponse.error) {
|
|
3890
|
+
throw mapUploadError(uploadId, beginResponse.error ?? "write_failed");
|
|
3891
|
+
}
|
|
3892
|
+
// --- Windowed chunk streaming with ack-based flow control. ---
|
|
3893
|
+
// in-flight map keyed by chunkIndex → byte length (out-of-order-ack safe);
|
|
3894
|
+
// mirrors the Map-based bookkeeping shape of the receive path without ever
|
|
3895
|
+
// touching `activeBinaryFileTransfers` itself.
|
|
3896
|
+
const inFlight = new Map();
|
|
3897
|
+
let nextChunkToSend = 0;
|
|
3898
|
+
let ackedBytes = 0;
|
|
3899
|
+
let ackedCount = 0;
|
|
3900
|
+
let aborted = false;
|
|
3901
|
+
let settled = false;
|
|
3902
|
+
const cleanups = [];
|
|
3903
|
+
const cleanup = () => {
|
|
3904
|
+
for (const fn of cleanups.splice(0)) {
|
|
3905
|
+
try {
|
|
3906
|
+
fn();
|
|
3907
|
+
}
|
|
3908
|
+
catch {
|
|
3909
|
+
// no-op
|
|
3910
|
+
}
|
|
3911
|
+
}
|
|
3912
|
+
inFlight.clear();
|
|
3913
|
+
};
|
|
3914
|
+
await new Promise((resolve, reject) => {
|
|
3915
|
+
const settleResolve = () => {
|
|
3916
|
+
if (settled) {
|
|
3917
|
+
return;
|
|
3918
|
+
}
|
|
3919
|
+
settled = true;
|
|
3920
|
+
cleanup();
|
|
3921
|
+
resolve();
|
|
3922
|
+
};
|
|
3923
|
+
const settleReject = (error) => {
|
|
3924
|
+
if (settled) {
|
|
3925
|
+
return;
|
|
3926
|
+
}
|
|
3927
|
+
settled = true;
|
|
3928
|
+
cleanup();
|
|
3929
|
+
reject(error);
|
|
3930
|
+
};
|
|
3931
|
+
// Send as many chunks as the window allows. Driven purely off ack arrival
|
|
3932
|
+
// (no busy-wait) so a window stall simply pauses here until acks resume.
|
|
3933
|
+
const pump = () => {
|
|
3934
|
+
while (inFlight.size < UPLOAD_WINDOW_SIZE && nextChunkToSend < totalChunks) {
|
|
3935
|
+
// External guards (set by the ack/error/abort closures) — checked in
|
|
3936
|
+
// the body so the loop condition only references in-loop state.
|
|
3937
|
+
if (aborted || settled) {
|
|
3938
|
+
return;
|
|
3939
|
+
}
|
|
3940
|
+
const index = nextChunkToSend;
|
|
3941
|
+
const payload = chunks[index];
|
|
3942
|
+
nextChunkToSend += 1;
|
|
3943
|
+
inFlight.set(index, payload.byteLength);
|
|
3944
|
+
// Dev invariant: the window must never exceed UPLOAD_WINDOW_SIZE and
|
|
3945
|
+
// therefore never approaches MAX_PENDING_SENDS = 200.
|
|
3946
|
+
if (inFlight.size > UPLOAD_WINDOW_SIZE) {
|
|
3947
|
+
settleReject(new UploadFailedError(uploadId, `upload window invariant violated: ${inFlight.size} chunks in flight`));
|
|
3948
|
+
return;
|
|
3949
|
+
}
|
|
3950
|
+
try {
|
|
3951
|
+
this.sendBinaryFrame(encodeFileTransferFrame({ opcode: FileTransferOpcode.FileChunk, requestId, payload }));
|
|
3952
|
+
}
|
|
3953
|
+
catch (error) {
|
|
3954
|
+
settleReject(new UploadFailedError(uploadId, error instanceof Error ? error.message : String(error)));
|
|
3955
|
+
return;
|
|
3956
|
+
}
|
|
3957
|
+
}
|
|
3958
|
+
};
|
|
3959
|
+
// Per-chunk ack → confirm receipt, advance the window, report progress.
|
|
3960
|
+
cleanups.push(this.on("files.upload.chunk.ack", (msg) => {
|
|
3961
|
+
if (msg.payload.requestId !== requestId) {
|
|
3962
|
+
return;
|
|
3963
|
+
}
|
|
3964
|
+
const index = msg.payload.chunkIndex;
|
|
3965
|
+
const chunkLen = inFlight.get(index);
|
|
3966
|
+
if (chunkLen === undefined) {
|
|
3967
|
+
return; // duplicate or unknown ack — ignore
|
|
3968
|
+
}
|
|
3969
|
+
inFlight.delete(index);
|
|
3970
|
+
ackedCount += 1;
|
|
3971
|
+
ackedBytes += chunkLen;
|
|
3972
|
+
// Progress reflects CONFIRMED receipt, not sent-but-unacked bytes.
|
|
3973
|
+
opts.onProgress?.(ackedBytes, totalBytes);
|
|
3974
|
+
if (ackedCount >= totalChunks) {
|
|
3975
|
+
settleResolve();
|
|
3976
|
+
return;
|
|
3977
|
+
}
|
|
3978
|
+
pump();
|
|
3979
|
+
}));
|
|
3980
|
+
// Daemon-initiated abort/error (incremental cap reject, write failure…).
|
|
3981
|
+
cleanups.push(this.on("files.upload.error", (msg) => {
|
|
3982
|
+
if (msg.payload.requestId !== requestId && msg.payload.uploadId !== uploadId) {
|
|
3983
|
+
return;
|
|
3984
|
+
}
|
|
3985
|
+
settleReject(mapUploadError(uploadId, msg.payload.code, msg.payload.message));
|
|
3986
|
+
}));
|
|
3987
|
+
// Cancellation: stop sending, tell the daemon to discard the partial file
|
|
3988
|
+
// (story 2.1 owns the cleanup), and reject with the typed cancelled error.
|
|
3989
|
+
const onAbort = () => {
|
|
3990
|
+
aborted = true;
|
|
3991
|
+
try {
|
|
3992
|
+
this.sendSessionMessage({
|
|
3993
|
+
type: "files.upload.error",
|
|
3994
|
+
payload: { requestId, uploadId, code: "aborted" },
|
|
3995
|
+
});
|
|
3996
|
+
}
|
|
3997
|
+
catch {
|
|
3998
|
+
// best-effort — the promise still rejects below
|
|
3999
|
+
}
|
|
4000
|
+
settleReject(new UploadCancelledError(uploadId));
|
|
4001
|
+
};
|
|
4002
|
+
if (signal) {
|
|
4003
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
4004
|
+
cleanups.push(() => signal.removeEventListener("abort", onAbort));
|
|
4005
|
+
}
|
|
4006
|
+
// Zero-byte file: no chunks to stream, complete straight away.
|
|
4007
|
+
if (totalChunks === 0) {
|
|
4008
|
+
settleResolve();
|
|
4009
|
+
return;
|
|
4010
|
+
}
|
|
4011
|
+
// Re-check in case the signal aborted during the begin round-trip.
|
|
4012
|
+
if (signal?.aborted) {
|
|
4013
|
+
onAbort();
|
|
4014
|
+
return;
|
|
4015
|
+
}
|
|
4016
|
+
pump();
|
|
4017
|
+
});
|
|
4018
|
+
// --- Control-plane complete: finalize and collect the result. ---
|
|
4019
|
+
let completeResponse;
|
|
4020
|
+
try {
|
|
4021
|
+
completeResponse = await this.sendCorrelatedRequest({
|
|
4022
|
+
requestId,
|
|
4023
|
+
message: SessionInboundMessageSchema.parse({
|
|
4024
|
+
type: "files.upload.complete.request",
|
|
4025
|
+
requestId,
|
|
4026
|
+
uploadId,
|
|
4027
|
+
totalChunks,
|
|
4028
|
+
}),
|
|
4029
|
+
responseType: "files.upload.complete.response",
|
|
4030
|
+
timeout: 30000,
|
|
4031
|
+
options: { skipQueue: true },
|
|
4032
|
+
});
|
|
4033
|
+
}
|
|
4034
|
+
catch (error) {
|
|
4035
|
+
throw new UploadFailedError(uploadId, error instanceof Error ? error.message : String(error));
|
|
4036
|
+
}
|
|
4037
|
+
if (completeResponse.error) {
|
|
4038
|
+
throw mapUploadError(uploadId, completeResponse.error);
|
|
4039
|
+
}
|
|
4040
|
+
return {
|
|
4041
|
+
fileId: completeResponse.fileId,
|
|
4042
|
+
path: completeResponse.path,
|
|
4043
|
+
size: completeResponse.size,
|
|
4044
|
+
mimeType: completeResponse.mimeType,
|
|
4045
|
+
};
|
|
4046
|
+
}
|
|
4047
|
+
// ============================================================================
|
|
4048
|
+
// Internals
|
|
4049
|
+
// ============================================================================
|
|
4050
|
+
// `protected` so test-only subclasses can mint correlation ids for the typed
|
|
4051
|
+
// RPCs they issue via `sendRequest` (see the comment on `sendRequest`).
|
|
4052
|
+
createRequestId(requestId) {
|
|
4053
|
+
return requestId ?? crypto.randomUUID();
|
|
4054
|
+
}
|
|
4055
|
+
getLastServerInfoMessage() {
|
|
4056
|
+
return this.lastServerInfoMessage;
|
|
4057
|
+
}
|
|
4058
|
+
// COMPAT(fileUploads): added in v0.1.88, remove gate when daemon floor advertises features.fileUploads.
|
|
4059
|
+
getFileUploadsCapability() {
|
|
4060
|
+
return this.lastServerInfoMessage?.features?.fileUploads ?? false;
|
|
4061
|
+
}
|
|
4062
|
+
resolveTransportUrlForAttempt() {
|
|
4063
|
+
return this.config.url;
|
|
4064
|
+
}
|
|
4065
|
+
sendHelloMessage() {
|
|
4066
|
+
if (!this.transport) {
|
|
4067
|
+
this.scheduleReconnect({
|
|
4068
|
+
reason: "Transport unavailable before hello",
|
|
4069
|
+
event: "HELLO_TRANSPORT_MISSING",
|
|
4070
|
+
reasonCode: "transport_error",
|
|
4071
|
+
});
|
|
4072
|
+
return;
|
|
4073
|
+
}
|
|
4074
|
+
try {
|
|
4075
|
+
this.transport.send(JSON.stringify({
|
|
4076
|
+
type: "hello",
|
|
4077
|
+
clientId: this.config.clientId,
|
|
4078
|
+
clientType: this.config.clientType ?? "cli",
|
|
4079
|
+
protocolVersion: 1,
|
|
4080
|
+
capabilities: {
|
|
4081
|
+
[CLIENT_CAPS.customModeIcons]: true,
|
|
4082
|
+
[CLIENT_CAPS.reasoningMergeEnum]: true,
|
|
4083
|
+
[CLIENT_CAPS.terminalReflowableSnapshot]: true,
|
|
4084
|
+
...this.config.capabilities,
|
|
4085
|
+
},
|
|
4086
|
+
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
|
|
4087
|
+
}));
|
|
4088
|
+
}
|
|
4089
|
+
catch (error) {
|
|
4090
|
+
const message = error instanceof Error ? error.message : "Failed to send hello message";
|
|
4091
|
+
this.lastErrorValue = message;
|
|
4092
|
+
this.scheduleReconnect({
|
|
4093
|
+
reason: message,
|
|
4094
|
+
event: "HELLO_SEND_FAILED",
|
|
4095
|
+
reasonCode: "transport_error",
|
|
4096
|
+
});
|
|
4097
|
+
}
|
|
4098
|
+
}
|
|
4099
|
+
disposeTransport(code = 1001, reason = "Reconnecting") {
|
|
4100
|
+
this.stopLivenessHeartbeat();
|
|
4101
|
+
this.cleanupTransport();
|
|
4102
|
+
if (this.transport) {
|
|
4103
|
+
try {
|
|
4104
|
+
this.transport.close(code, reason);
|
|
4105
|
+
}
|
|
4106
|
+
catch {
|
|
4107
|
+
// no-op
|
|
4108
|
+
}
|
|
4109
|
+
this.transport = null;
|
|
4110
|
+
}
|
|
4111
|
+
}
|
|
4112
|
+
cleanupTransport() {
|
|
4113
|
+
this.resetConnectTimeout();
|
|
4114
|
+
if (this.pendingGenericTransportErrorTimeout) {
|
|
4115
|
+
clearTimeout(this.pendingGenericTransportErrorTimeout);
|
|
4116
|
+
this.pendingGenericTransportErrorTimeout = null;
|
|
4117
|
+
}
|
|
4118
|
+
for (const cleanup of this.transportCleanup) {
|
|
4119
|
+
try {
|
|
4120
|
+
cleanup();
|
|
4121
|
+
}
|
|
4122
|
+
catch {
|
|
4123
|
+
// no-op
|
|
4124
|
+
}
|
|
4125
|
+
}
|
|
4126
|
+
this.transportCleanup = [];
|
|
4127
|
+
}
|
|
4128
|
+
resetConnectTimeout() {
|
|
4129
|
+
if (!this.connectTimeout) {
|
|
4130
|
+
return;
|
|
4131
|
+
}
|
|
4132
|
+
clearTimeout(this.connectTimeout);
|
|
4133
|
+
this.connectTimeout = null;
|
|
4134
|
+
}
|
|
4135
|
+
handleTransportMessage(data) {
|
|
4136
|
+
const rawData = data && typeof data === "object" && "data" in data ? data.data : data;
|
|
4137
|
+
if (typeof Blob !== "undefined" &&
|
|
4138
|
+
rawData instanceof Blob &&
|
|
4139
|
+
typeof rawData.arrayBuffer === "function") {
|
|
4140
|
+
void rawData
|
|
4141
|
+
.arrayBuffer()
|
|
4142
|
+
.then((buffer) => {
|
|
4143
|
+
this.handleTransportMessage(buffer);
|
|
4144
|
+
return;
|
|
4145
|
+
})
|
|
4146
|
+
.catch(() => {
|
|
4147
|
+
// Ignore failed blob decoding and allow reconnect logic to recover.
|
|
4148
|
+
});
|
|
4149
|
+
return;
|
|
4150
|
+
}
|
|
4151
|
+
const rawBytes = asUint8Array(rawData);
|
|
4152
|
+
if (rawBytes && this.tryHandleBinaryFrame(rawBytes)) {
|
|
4153
|
+
return;
|
|
4154
|
+
}
|
|
4155
|
+
const payload = decodeMessageData(rawData);
|
|
4156
|
+
if (!payload) {
|
|
4157
|
+
return;
|
|
4158
|
+
}
|
|
4159
|
+
this.handleJsonPayload(payload, rawBytes?.byteLength);
|
|
4160
|
+
}
|
|
4161
|
+
handleJsonPayload(payload, rawBytesLength) {
|
|
4162
|
+
const bytes = rawBytesLength ?? payload.length;
|
|
4163
|
+
const startMs = perfNow();
|
|
4164
|
+
let parsedJson;
|
|
4165
|
+
try {
|
|
4166
|
+
parsedJson = JSON.parse(payload);
|
|
4167
|
+
}
|
|
4168
|
+
catch {
|
|
4169
|
+
return;
|
|
4170
|
+
}
|
|
4171
|
+
const parsed = validateWSOutboundMessage(parsedJson);
|
|
4172
|
+
if (!parsed.success) {
|
|
4173
|
+
const msgType = parsedJson != null &&
|
|
4174
|
+
typeof parsedJson === "object" &&
|
|
4175
|
+
"type" in parsedJson &&
|
|
4176
|
+
typeof parsedJson.type === "string"
|
|
4177
|
+
? parsedJson.type
|
|
4178
|
+
: "unknown";
|
|
4179
|
+
this.logger.warn({ msgType, error: parsed.error.message }, "Message validation failed");
|
|
4180
|
+
return;
|
|
4181
|
+
}
|
|
4182
|
+
this.consecutiveLivenessFailures = 0;
|
|
4183
|
+
if (parsed.data.type === "pong") {
|
|
4184
|
+
this.resolvePingProbe();
|
|
4185
|
+
this.runtimeMetrics?.recordMessage("pong", bytes, perfNow() - startMs);
|
|
4186
|
+
return;
|
|
4187
|
+
}
|
|
4188
|
+
this.handleSessionMessage(parsed.data.message);
|
|
4189
|
+
const msgType = parsed.data.message.type;
|
|
4190
|
+
this.runtimeMetrics?.recordMessage(msgType, bytes, perfNow() - startMs);
|
|
4191
|
+
if (parsed.data.message.type === "agent_stream") {
|
|
4192
|
+
this.runtimeMetrics?.recordAgentStream(parsed.data.message.payload);
|
|
4193
|
+
}
|
|
4194
|
+
}
|
|
4195
|
+
tryHandleBinaryFrame(rawBytes) {
|
|
4196
|
+
const fileFrame = decodeFileTransferFrame(rawBytes);
|
|
4197
|
+
if (fileFrame) {
|
|
4198
|
+
this.consecutiveLivenessFailures = 0;
|
|
4199
|
+
this.handleFileTransferFrame(fileFrame);
|
|
4200
|
+
this.runtimeMetrics?.recordBinaryFrame("other", rawBytes.byteLength, 0);
|
|
4201
|
+
return true;
|
|
4202
|
+
}
|
|
4203
|
+
const frame = decodeTerminalStreamFrame(rawBytes);
|
|
4204
|
+
if (!frame) {
|
|
4205
|
+
return false;
|
|
4206
|
+
}
|
|
4207
|
+
this.consecutiveLivenessFailures = 0;
|
|
4208
|
+
const binaryStartMs = perfNow();
|
|
4209
|
+
this.terminalStreams.handleFrame(frame);
|
|
4210
|
+
let frameKind = "other";
|
|
4211
|
+
if (frame.opcode === TerminalStreamOpcode.Output) {
|
|
4212
|
+
frameKind = "output";
|
|
4213
|
+
}
|
|
4214
|
+
else if (frame.opcode === TerminalStreamOpcode.Snapshot) {
|
|
4215
|
+
frameKind = "snapshot";
|
|
4216
|
+
}
|
|
4217
|
+
else if (frame.opcode === TerminalStreamOpcode.Restore) {
|
|
4218
|
+
frameKind = "output";
|
|
4219
|
+
}
|
|
4220
|
+
this.runtimeMetrics?.recordBinaryFrame(frameKind, rawBytes.byteLength, perfNow() - binaryStartMs);
|
|
4221
|
+
return true;
|
|
4222
|
+
}
|
|
4223
|
+
handleFileTransferFrame(frame) {
|
|
4224
|
+
if (frame.opcode === FileTransferOpcode.FileBegin) {
|
|
4225
|
+
const pending = this.pendingBinaryFileReads.get(frame.requestId);
|
|
4226
|
+
if (!pending) {
|
|
4227
|
+
return;
|
|
4228
|
+
}
|
|
4229
|
+
this.activeBinaryFileTransfers.set(frame.requestId, {
|
|
4230
|
+
...pending,
|
|
4231
|
+
mime: frame.metadata.mime,
|
|
4232
|
+
size: frame.metadata.size,
|
|
4233
|
+
encoding: frame.metadata.encoding,
|
|
4234
|
+
modifiedAt: frame.metadata.modifiedAt,
|
|
4235
|
+
chunks: [],
|
|
4236
|
+
});
|
|
4237
|
+
return;
|
|
4238
|
+
}
|
|
4239
|
+
const transfer = this.activeBinaryFileTransfers.get(frame.requestId);
|
|
4240
|
+
if (!transfer) {
|
|
4241
|
+
return;
|
|
4242
|
+
}
|
|
4243
|
+
if (frame.opcode === FileTransferOpcode.FileChunk) {
|
|
4244
|
+
transfer.chunks.push(frame.payload);
|
|
4245
|
+
return;
|
|
4246
|
+
}
|
|
4247
|
+
const bytes = concatByteChunks(transfer.chunks, transfer.size);
|
|
4248
|
+
this.activeBinaryFileTransfers.delete(frame.requestId);
|
|
4249
|
+
this.completedBinaryFileReads.set(frame.requestId, {
|
|
4250
|
+
bytes,
|
|
4251
|
+
mime: transfer.mime,
|
|
4252
|
+
size: transfer.size,
|
|
4253
|
+
path: transfer.path,
|
|
4254
|
+
kind: binaryFileKind(transfer.mime, transfer.encoding),
|
|
4255
|
+
modifiedAt: transfer.modifiedAt,
|
|
4256
|
+
});
|
|
4257
|
+
this.handleSessionMessage({
|
|
4258
|
+
type: "file_explorer_response",
|
|
4259
|
+
payload: {
|
|
4260
|
+
cwd: transfer.cwd,
|
|
4261
|
+
path: transfer.path,
|
|
4262
|
+
mode: "file",
|
|
4263
|
+
directory: null,
|
|
4264
|
+
file: null,
|
|
4265
|
+
error: null,
|
|
4266
|
+
requestId: frame.requestId,
|
|
4267
|
+
},
|
|
4268
|
+
});
|
|
4269
|
+
}
|
|
4270
|
+
updateConnectionState(next, metadata) {
|
|
4271
|
+
const previous = this.connectionState;
|
|
4272
|
+
this.connectionState = next;
|
|
4273
|
+
const reasonFromNext = next.status === "disconnected" && typeof next.reason === "string" ? next.reason : null;
|
|
4274
|
+
const reason = metadata?.reason ?? reasonFromNext;
|
|
4275
|
+
const reasonCode = metadata?.reasonCode ?? toReasonCode(reason);
|
|
4276
|
+
this.logger.debug({
|
|
4277
|
+
serverId: this.logServerId,
|
|
4278
|
+
clientIdHash: this.logClientIdHash,
|
|
4279
|
+
from: previous.status,
|
|
4280
|
+
to: next.status,
|
|
4281
|
+
event: metadata?.event ?? "STATE_UPDATE",
|
|
4282
|
+
connectionPath: this.logConnectionPath,
|
|
4283
|
+
generation: this.logGeneration,
|
|
4284
|
+
reasonCode,
|
|
4285
|
+
reason,
|
|
4286
|
+
}, "DaemonClientTransition");
|
|
4287
|
+
for (const listener of this.connectionListeners) {
|
|
4288
|
+
try {
|
|
4289
|
+
listener(next);
|
|
4290
|
+
}
|
|
4291
|
+
catch {
|
|
4292
|
+
// no-op
|
|
4293
|
+
}
|
|
4294
|
+
}
|
|
4295
|
+
}
|
|
4296
|
+
setReconnectEnabled(enabled) {
|
|
4297
|
+
this.config = { ...this.config, reconnect: { ...this.config.reconnect, enabled } };
|
|
4298
|
+
}
|
|
4299
|
+
scheduleReconnect(input) {
|
|
4300
|
+
if (this.reconnectTimeout) {
|
|
4301
|
+
clearTimeout(this.reconnectTimeout);
|
|
4302
|
+
this.reconnectTimeout = null;
|
|
4303
|
+
}
|
|
4304
|
+
const wasDisposed = this.connectionState.status === "disposed";
|
|
4305
|
+
const reason = input?.reason;
|
|
4306
|
+
if (typeof reason === "string" && reason.trim().length > 0) {
|
|
4307
|
+
this.lastErrorValue = reason.trim();
|
|
4308
|
+
}
|
|
4309
|
+
// Clear all pending waiters and queued sends since the connection was lost
|
|
4310
|
+
// and responses from the previous connection will never arrive.
|
|
4311
|
+
this.clearWaiters(new Error(reason ?? "Connection lost"));
|
|
4312
|
+
this.rejectPendingSendQueue(new Error(reason ?? "Connection lost"));
|
|
4313
|
+
this.rejectPingProbe(new Error(reason ?? "Connection lost"));
|
|
4314
|
+
this.terminalStreams.clearSlots();
|
|
4315
|
+
this.lastServerInfoMessage = null;
|
|
4316
|
+
if (wasDisposed) {
|
|
4317
|
+
this.rejectConnect(new Error(reason ?? "Daemon client is disposed"));
|
|
4318
|
+
return;
|
|
4319
|
+
}
|
|
4320
|
+
this.emitDisconnectedStateForReconnect(reason, input);
|
|
4321
|
+
if (!this.shouldReconnect || this.config.reconnect?.enabled === false) {
|
|
4322
|
+
this.rejectConnect(new Error(reason ?? "Transport disconnected before connect"));
|
|
4323
|
+
return;
|
|
4324
|
+
}
|
|
4325
|
+
this.armReconnectTimer();
|
|
4326
|
+
}
|
|
4327
|
+
emitDisconnectedStateForReconnect(reason, input) {
|
|
4328
|
+
this.updateConnectionState({
|
|
4329
|
+
status: "disconnected",
|
|
4330
|
+
...(reason ? { reason } : {}),
|
|
4331
|
+
}, {
|
|
4332
|
+
event: input?.event ?? "TRANSPORT_CLOSE",
|
|
4333
|
+
...(reason ? { reason } : {}),
|
|
4334
|
+
...(input?.reasonCode ? { reasonCode: input.reasonCode } : {}),
|
|
4335
|
+
});
|
|
4336
|
+
}
|
|
4337
|
+
armReconnectTimer() {
|
|
4338
|
+
const attempt = this.reconnectAttempt;
|
|
4339
|
+
const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS;
|
|
4340
|
+
const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS;
|
|
4341
|
+
const delay = Math.min(baseDelay * 2 ** attempt, maxDelay);
|
|
4342
|
+
this.reconnectAttempt = attempt + 1;
|
|
4343
|
+
this.reconnectTimeout = setTimeout(() => {
|
|
4344
|
+
this.reconnectTimeout = null;
|
|
4345
|
+
if (!this.shouldReconnect) {
|
|
4346
|
+
return;
|
|
4347
|
+
}
|
|
4348
|
+
this.attemptConnect();
|
|
4349
|
+
}, delay);
|
|
4350
|
+
}
|
|
4351
|
+
resolvePingProbe() {
|
|
4352
|
+
const probe = this.pingProbe;
|
|
4353
|
+
if (!probe) {
|
|
4354
|
+
return;
|
|
4355
|
+
}
|
|
4356
|
+
this.pingProbe = null;
|
|
4357
|
+
clearTimeout(probe.timeoutHandle);
|
|
4358
|
+
probe.resolve(perfNow() - probe.startedAt);
|
|
4359
|
+
}
|
|
4360
|
+
clearPingProbe() {
|
|
4361
|
+
const probe = this.pingProbe;
|
|
4362
|
+
if (!probe) {
|
|
4363
|
+
return;
|
|
4364
|
+
}
|
|
4365
|
+
this.pingProbe = null;
|
|
4366
|
+
clearTimeout(probe.timeoutHandle);
|
|
4367
|
+
}
|
|
4368
|
+
rejectPingProbe(error) {
|
|
4369
|
+
const probe = this.pingProbe;
|
|
4370
|
+
if (!probe) {
|
|
4371
|
+
return;
|
|
4372
|
+
}
|
|
4373
|
+
this.pingProbe = null;
|
|
4374
|
+
clearTimeout(probe.timeoutHandle);
|
|
4375
|
+
probe.reject(error);
|
|
4376
|
+
}
|
|
4377
|
+
recordLivenessFailure(error) {
|
|
4378
|
+
this.consecutiveLivenessFailures += 1;
|
|
4379
|
+
if (this.consecutiveLivenessFailures < LIVENESS_FAILURE_RECONNECT_THRESHOLD) {
|
|
4380
|
+
return;
|
|
4381
|
+
}
|
|
4382
|
+
this.consecutiveLivenessFailures = 0;
|
|
4383
|
+
this.lastErrorValue = error.message;
|
|
4384
|
+
this.disposeTransport(1001, "Liveness check timed out");
|
|
4385
|
+
this.scheduleReconnect({
|
|
4386
|
+
reason: error.message,
|
|
4387
|
+
event: "LIVENESS_TIMEOUT",
|
|
4388
|
+
reasonCode: "liveness_timeout",
|
|
4389
|
+
});
|
|
4390
|
+
}
|
|
4391
|
+
handleSessionMessage(msg) {
|
|
4392
|
+
const consumerMessage = normalizeProviderSnapshotUpdateMessage(msg);
|
|
4393
|
+
if (consumerMessage.type === "status") {
|
|
4394
|
+
const serverInfo = parseServerInfoStatusPayload(consumerMessage.payload);
|
|
4395
|
+
if (serverInfo) {
|
|
4396
|
+
this.lastServerInfoMessage = serverInfo;
|
|
4397
|
+
if (this.connectionState.status === "connecting") {
|
|
4398
|
+
this.resetConnectTimeout();
|
|
4399
|
+
this.reconnectAttempt = 0;
|
|
4400
|
+
this.updateConnectionState({ status: "connected" }, { event: "HELLO_SERVER_INFO" });
|
|
4401
|
+
this.startLivenessHeartbeat();
|
|
4402
|
+
this.resubscribeCheckoutDiffSubscriptions();
|
|
4403
|
+
this.resubscribeTerminalDirectorySubscriptions();
|
|
4404
|
+
this.flushPendingSendQueue();
|
|
4405
|
+
this.resolveConnect();
|
|
4406
|
+
}
|
|
4407
|
+
}
|
|
4408
|
+
}
|
|
4409
|
+
if (consumerMessage.type === "terminal_stream_exit") {
|
|
4410
|
+
this.terminalStreams.removeTerminal(consumerMessage.payload.terminalId);
|
|
4411
|
+
}
|
|
4412
|
+
if (this.rawMessageListeners.size > 0) {
|
|
4413
|
+
for (const handler of this.rawMessageListeners) {
|
|
4414
|
+
try {
|
|
4415
|
+
handler(consumerMessage);
|
|
4416
|
+
}
|
|
4417
|
+
catch {
|
|
4418
|
+
// no-op
|
|
4419
|
+
}
|
|
4420
|
+
}
|
|
4421
|
+
}
|
|
4422
|
+
const handlers = this.messageHandlers.get(consumerMessage.type);
|
|
4423
|
+
if (handlers) {
|
|
4424
|
+
for (const handler of handlers) {
|
|
4425
|
+
try {
|
|
4426
|
+
handler(consumerMessage);
|
|
4427
|
+
}
|
|
4428
|
+
catch {
|
|
4429
|
+
// no-op
|
|
4430
|
+
}
|
|
4431
|
+
}
|
|
4432
|
+
}
|
|
4433
|
+
const event = this.toEvent(consumerMessage);
|
|
4434
|
+
if (event) {
|
|
4435
|
+
for (const handler of this.eventListeners) {
|
|
4436
|
+
handler(event);
|
|
4437
|
+
}
|
|
4438
|
+
}
|
|
4439
|
+
this.resolveWaiters(consumerMessage);
|
|
4440
|
+
}
|
|
4441
|
+
resolveWaiters(msg) {
|
|
4442
|
+
for (const waiter of Array.from(this.waiters)) {
|
|
4443
|
+
const result = waiter.predicate(msg);
|
|
4444
|
+
if (result !== null) {
|
|
4445
|
+
this.waiters.delete(waiter);
|
|
4446
|
+
if (waiter.timeoutHandle) {
|
|
4447
|
+
clearTimeout(waiter.timeoutHandle);
|
|
4448
|
+
}
|
|
4449
|
+
waiter.resolve(result);
|
|
4450
|
+
}
|
|
4451
|
+
}
|
|
4452
|
+
}
|
|
4453
|
+
clearWaiters(error) {
|
|
4454
|
+
for (const waiter of Array.from(this.waiters)) {
|
|
4455
|
+
if (waiter.timeoutHandle) {
|
|
4456
|
+
clearTimeout(waiter.timeoutHandle);
|
|
4457
|
+
}
|
|
4458
|
+
waiter.reject(error);
|
|
4459
|
+
}
|
|
4460
|
+
this.waiters.clear();
|
|
4461
|
+
}
|
|
4462
|
+
toEvent(msg) {
|
|
4463
|
+
switch (msg.type) {
|
|
4464
|
+
case "agent_update":
|
|
4465
|
+
return {
|
|
4466
|
+
type: "agent_update",
|
|
4467
|
+
agentId: msg.payload.kind === "upsert" ? msg.payload.agent.id : msg.payload.agentId,
|
|
4468
|
+
payload: msg.payload,
|
|
4469
|
+
};
|
|
4470
|
+
case "workspace_update":
|
|
4471
|
+
return {
|
|
4472
|
+
type: "workspace_update",
|
|
4473
|
+
workspaceId: msg.payload.kind === "upsert" ? msg.payload.workspace.id : msg.payload.id,
|
|
4474
|
+
payload: msg.payload,
|
|
4475
|
+
};
|
|
4476
|
+
case "workspace_setup_progress":
|
|
4477
|
+
return {
|
|
4478
|
+
type: "workspace_setup_progress",
|
|
4479
|
+
workspaceId: msg.payload.workspaceId,
|
|
4480
|
+
payload: msg.payload,
|
|
4481
|
+
};
|
|
4482
|
+
case "agent_stream":
|
|
4483
|
+
return {
|
|
4484
|
+
type: "agent_stream",
|
|
4485
|
+
agentId: msg.payload.agentId,
|
|
4486
|
+
event: msg.payload.event,
|
|
4487
|
+
timestamp: msg.payload.timestamp,
|
|
4488
|
+
...(typeof msg.payload.seq === "number" ? { seq: msg.payload.seq } : {}),
|
|
4489
|
+
...(typeof msg.payload.epoch === "string" ? { epoch: msg.payload.epoch } : {}),
|
|
4490
|
+
};
|
|
4491
|
+
case "status":
|
|
4492
|
+
return { type: "status", payload: msg.payload };
|
|
4493
|
+
case "agent_deleted":
|
|
4494
|
+
return { type: "agent_deleted", agentId: msg.payload.agentId };
|
|
4495
|
+
case "agent_permission_request":
|
|
4496
|
+
return {
|
|
4497
|
+
type: "agent_permission_request",
|
|
4498
|
+
agentId: msg.payload.agentId,
|
|
4499
|
+
request: msg.payload.request,
|
|
4500
|
+
};
|
|
4501
|
+
case "agent_permission_resolved":
|
|
4502
|
+
return {
|
|
4503
|
+
type: "agent_permission_resolved",
|
|
4504
|
+
agentId: msg.payload.agentId,
|
|
4505
|
+
requestId: msg.payload.requestId,
|
|
4506
|
+
resolution: msg.payload.resolution,
|
|
4507
|
+
};
|
|
4508
|
+
case "providers_snapshot_update":
|
|
4509
|
+
return {
|
|
4510
|
+
type: "providers_snapshot_update",
|
|
4511
|
+
payload: msg.payload,
|
|
4512
|
+
};
|
|
4513
|
+
default:
|
|
4514
|
+
return null;
|
|
4515
|
+
}
|
|
4516
|
+
}
|
|
4517
|
+
waitForWithCancel(predicate, timeout = 30000, _options) {
|
|
4518
|
+
// Capture stack trace at call site, not inside setTimeout
|
|
4519
|
+
const timeoutError = new Error(`Timeout waiting for message (${timeout}ms)`);
|
|
4520
|
+
let waiter = null;
|
|
4521
|
+
let settled = false;
|
|
4522
|
+
let rejectFn = null;
|
|
4523
|
+
const promise = new Promise((resolve, reject) => {
|
|
4524
|
+
const wrappedResolve = (value) => {
|
|
4525
|
+
if (settled)
|
|
4526
|
+
return;
|
|
4527
|
+
settled = true;
|
|
4528
|
+
resolve(value);
|
|
4529
|
+
};
|
|
4530
|
+
const wrappedReject = (error) => {
|
|
4531
|
+
if (settled)
|
|
4532
|
+
return;
|
|
4533
|
+
settled = true;
|
|
4534
|
+
reject(error);
|
|
4535
|
+
};
|
|
4536
|
+
rejectFn = wrappedReject;
|
|
4537
|
+
const timeoutHandle = timeout > 0
|
|
4538
|
+
? setTimeout(() => {
|
|
4539
|
+
if (waiter) {
|
|
4540
|
+
this.waiters.delete(waiter);
|
|
4541
|
+
}
|
|
4542
|
+
wrappedReject(timeoutError);
|
|
4543
|
+
}, timeout)
|
|
4544
|
+
: null;
|
|
4545
|
+
waiter = {
|
|
4546
|
+
predicate,
|
|
4547
|
+
resolve: wrappedResolve,
|
|
4548
|
+
reject: wrappedReject,
|
|
4549
|
+
timeoutHandle,
|
|
4550
|
+
};
|
|
4551
|
+
this.waiters.add(waiter);
|
|
4552
|
+
});
|
|
4553
|
+
const cancel = (error) => {
|
|
4554
|
+
if (settled) {
|
|
4555
|
+
return;
|
|
4556
|
+
}
|
|
4557
|
+
if (waiter) {
|
|
4558
|
+
this.waiters.delete(waiter);
|
|
4559
|
+
if (waiter.timeoutHandle) {
|
|
4560
|
+
clearTimeout(waiter.timeoutHandle);
|
|
4561
|
+
}
|
|
4562
|
+
}
|
|
4563
|
+
if (rejectFn) {
|
|
4564
|
+
rejectFn(error);
|
|
4565
|
+
return;
|
|
4566
|
+
}
|
|
4567
|
+
// Extremely unlikely: cancel called before the Promise executor ran.
|
|
4568
|
+
queueMicrotask(() => {
|
|
4569
|
+
if (!settled && rejectFn) {
|
|
4570
|
+
rejectFn(error);
|
|
4571
|
+
}
|
|
4572
|
+
});
|
|
4573
|
+
};
|
|
4574
|
+
return { promise, cancel };
|
|
4575
|
+
}
|
|
4576
|
+
}
|
|
4577
|
+
function resolveAgentConfig(options) {
|
|
4578
|
+
const { config, provider, cwd, env: _env, workspaceId: _workspaceId, initialPrompt: _initialPrompt, images: _images, git: _git, worktreeName: _worktreeName, requestId: _requestId, labels: _labels, ...overrides } = options;
|
|
4579
|
+
const baseConfig = {
|
|
4580
|
+
...(provider ? { provider } : {}),
|
|
4581
|
+
...(cwd ? { cwd } : {}),
|
|
4582
|
+
...overrides,
|
|
4583
|
+
};
|
|
4584
|
+
const merged = config ? { ...baseConfig, ...config } : baseConfig;
|
|
4585
|
+
if (!merged.provider || !merged.cwd) {
|
|
4586
|
+
throw new Error("createAgent requires provider and cwd");
|
|
4587
|
+
}
|
|
4588
|
+
return {
|
|
4589
|
+
...merged,
|
|
4590
|
+
provider: merged.provider,
|
|
4591
|
+
cwd: merged.cwd,
|
|
4592
|
+
};
|
|
4593
|
+
}
|
|
4594
|
+
//# sourceMappingURL=daemon-client.js.map
|