@mirasoth/soothe-client 0.1.0 → 0.2.1
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/dist/chunk-AQZACDIC.js +1399 -0
- package/dist/chunk-AQZACDIC.js.map +1 -0
- package/dist/client-CB6WKQYW.js +7 -0
- package/dist/index.cjs +2383 -419
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1110 -280
- package/dist/index.d.ts +1110 -280
- package/dist/index.js +1265 -91
- package/dist/index.js.map +1 -1
- package/package.json +34 -8
- package/dist/chunk-OMAC7LA7.js +0 -647
- package/dist/chunk-OMAC7LA7.js.map +0 -1
- package/dist/client-QS23U6WX.js +0 -7
- /package/dist/{client-QS23U6WX.js.map → client-CB6WKQYW.js.map} +0 -0
|
@@ -0,0 +1,1399 @@
|
|
|
1
|
+
// src/client.ts
|
|
2
|
+
import { EventEmitter } from "events";
|
|
3
|
+
import WebSocket from "ws";
|
|
4
|
+
|
|
5
|
+
// src/config.ts
|
|
6
|
+
function defaultConfig() {
|
|
7
|
+
return {
|
|
8
|
+
daemonURL: "ws://localhost:8765",
|
|
9
|
+
verbosityLevel: "normal",
|
|
10
|
+
maxRetries: 5,
|
|
11
|
+
reconnectDelay: 2e3,
|
|
12
|
+
heartbeatInterval: 3e4,
|
|
13
|
+
daemonReadyTimeout: 2e4,
|
|
14
|
+
loopStatusTimeout: 6e4,
|
|
15
|
+
subscriptionTimeout: 1e4,
|
|
16
|
+
reconnectMaxAttempts: 10,
|
|
17
|
+
reconnectInitialDelay: 500,
|
|
18
|
+
reconnectMaxDelay: 1e4,
|
|
19
|
+
reattachProbeTimeout: 5e3
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function loadConfigFromEnv() {
|
|
23
|
+
const config = defaultConfig();
|
|
24
|
+
if (typeof process === "undefined") return config;
|
|
25
|
+
const url = process.env.SOOTHE_DAEMON_URL;
|
|
26
|
+
if (url) config.daemonURL = url;
|
|
27
|
+
const verbosity = process.env.SOOTHE_VERBOSITY;
|
|
28
|
+
if (verbosity) config.verbosityLevel = verbosity;
|
|
29
|
+
const retries = process.env.SOOTHE_MAX_RETRIES;
|
|
30
|
+
if (retries) {
|
|
31
|
+
const val = parseInt(retries, 10);
|
|
32
|
+
if (!isNaN(val)) config.maxRetries = val;
|
|
33
|
+
}
|
|
34
|
+
const readyTimeout = process.env.SOOTHE_DAEMON_READY_TIMEOUT_SEC;
|
|
35
|
+
if (readyTimeout) {
|
|
36
|
+
const val = parseInt(readyTimeout, 10);
|
|
37
|
+
if (val > 0) config.daemonReadyTimeout = val * 1e3;
|
|
38
|
+
}
|
|
39
|
+
const statusTimeout = process.env.SOOTHE_LOOP_STATUS_TIMEOUT_SEC;
|
|
40
|
+
if (statusTimeout) {
|
|
41
|
+
const val = parseInt(statusTimeout, 10);
|
|
42
|
+
if (val > 0) config.loopStatusTimeout = val * 1e3;
|
|
43
|
+
}
|
|
44
|
+
const subTimeout = process.env.SOOTHE_SUBSCRIPTION_TIMEOUT_SEC;
|
|
45
|
+
if (subTimeout) {
|
|
46
|
+
const val = parseInt(subTimeout, 10);
|
|
47
|
+
if (val > 0) config.subscriptionTimeout = val * 1e3;
|
|
48
|
+
}
|
|
49
|
+
return config;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/errors.ts
|
|
53
|
+
var ConnectionError = class extends Error {
|
|
54
|
+
url;
|
|
55
|
+
attempt;
|
|
56
|
+
cause;
|
|
57
|
+
constructor(url, attempt, cause) {
|
|
58
|
+
super(`connection error to ${url} (attempt ${attempt}): ${cause.message}`);
|
|
59
|
+
this.name = "ConnectionError";
|
|
60
|
+
this.url = url;
|
|
61
|
+
this.attempt = attempt;
|
|
62
|
+
this.cause = cause;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
var DaemonError = class extends Error {
|
|
66
|
+
/** Numeric error code from the RFC-450 §7.3 registry. */
|
|
67
|
+
code;
|
|
68
|
+
/** The daemon's error message text. */
|
|
69
|
+
daemonMessage;
|
|
70
|
+
/** Optional machine-parseable error details. */
|
|
71
|
+
data;
|
|
72
|
+
constructor(code, message, data) {
|
|
73
|
+
super(`daemon error [${code}]: ${message}`);
|
|
74
|
+
this.name = "DaemonError";
|
|
75
|
+
this.code = code;
|
|
76
|
+
this.daemonMessage = message;
|
|
77
|
+
this.data = data;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
var TimeoutError = class extends Error {
|
|
81
|
+
operation;
|
|
82
|
+
duration;
|
|
83
|
+
constructor(operation, duration) {
|
|
84
|
+
super(`timeout after ${duration} waiting for ${operation}`);
|
|
85
|
+
this.name = "TimeoutError";
|
|
86
|
+
this.operation = operation;
|
|
87
|
+
this.duration = duration;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
var DisconnectCause = /* @__PURE__ */ ((DisconnectCause2) => {
|
|
91
|
+
DisconnectCause2[DisconnectCause2["Unclean"] = 0] = "Unclean";
|
|
92
|
+
DisconnectCause2[DisconnectCause2["Clean"] = 1] = "Clean";
|
|
93
|
+
return DisconnectCause2;
|
|
94
|
+
})(DisconnectCause || {});
|
|
95
|
+
function disconnectCauseName(cause) {
|
|
96
|
+
return cause === 1 /* Clean */ ? "clean" : "unclean";
|
|
97
|
+
}
|
|
98
|
+
var ReconnectError = class extends Error {
|
|
99
|
+
url;
|
|
100
|
+
attempts;
|
|
101
|
+
cause;
|
|
102
|
+
constructor(url, attempts, cause) {
|
|
103
|
+
super(`reconnect to ${url} failed after ${attempts} attempts: ${cause.message}`);
|
|
104
|
+
this.name = "ReconnectError";
|
|
105
|
+
this.url = url;
|
|
106
|
+
this.attempts = attempts;
|
|
107
|
+
this.cause = cause;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
var StaleLoopError = class extends Error {
|
|
111
|
+
loopID;
|
|
112
|
+
cause;
|
|
113
|
+
constructor(loopID, cause) {
|
|
114
|
+
const detail = cause ? `: ${cause.message}` : "";
|
|
115
|
+
super(`stale loop ${loopID}: reattach accepted but liveness probe failed${detail}`);
|
|
116
|
+
this.name = "StaleLoopError";
|
|
117
|
+
this.loopID = loopID;
|
|
118
|
+
this.cause = cause;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// src/multiplexer.ts
|
|
123
|
+
var Multiplexer = class {
|
|
124
|
+
rpcs = /* @__PURE__ */ new Map();
|
|
125
|
+
subs = /* @__PURE__ */ new Map();
|
|
126
|
+
receipts = /* @__PURE__ */ new Map();
|
|
127
|
+
/**
|
|
128
|
+
* Installs a pending RPC wait keyed by `id`. Returns the pending call and an
|
|
129
|
+
* unregister function that MUST be called when the wait ends (success,
|
|
130
|
+
* timeout, or cancel) to avoid leaks. If a late response arrives after the
|
|
131
|
+
* caller has unregistered, it is dropped (log-and-drop) — no leak.
|
|
132
|
+
*/
|
|
133
|
+
registerRPC(id) {
|
|
134
|
+
let callResolve;
|
|
135
|
+
let callReject;
|
|
136
|
+
const call = new Promise((resolve, reject) => {
|
|
137
|
+
callResolve = resolve;
|
|
138
|
+
callReject = reject;
|
|
139
|
+
});
|
|
140
|
+
const pending = { resolve: callResolve, reject: callReject };
|
|
141
|
+
this.rpcs.set(id, pending);
|
|
142
|
+
const unregister = () => {
|
|
143
|
+
if (this.rpcs.get(id) === pending) {
|
|
144
|
+
this.rpcs.delete(id);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
return { call, unregister };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Installs a pending subscription stream keyed by `id`. Returns the stream
|
|
151
|
+
* channel (an async-iterable-like push sink), a `done` signal, and an
|
|
152
|
+
* unregister function. The Client pushes `next`/`complete` frames via
|
|
153
|
+
* `push`; the application reads from the channel.
|
|
154
|
+
*/
|
|
155
|
+
registerSubscription(id) {
|
|
156
|
+
let resolveDone;
|
|
157
|
+
const done = new Promise((resolve) => {
|
|
158
|
+
resolveDone = resolve;
|
|
159
|
+
});
|
|
160
|
+
const pending = {
|
|
161
|
+
push: () => {
|
|
162
|
+
},
|
|
163
|
+
done,
|
|
164
|
+
resolveDone,
|
|
165
|
+
settled: false
|
|
166
|
+
};
|
|
167
|
+
const push = (frame) => {
|
|
168
|
+
if (pending.settled) return;
|
|
169
|
+
pending.push(frame);
|
|
170
|
+
};
|
|
171
|
+
pending.push = () => {
|
|
172
|
+
};
|
|
173
|
+
this.subs.set(id, pending);
|
|
174
|
+
const unregister = () => {
|
|
175
|
+
if (this.subs.get(id) === pending) {
|
|
176
|
+
pending.settled = true;
|
|
177
|
+
this.subs.delete(id);
|
|
178
|
+
resolveDone();
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
return { push, done, unregister };
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Installs a pending receipt wait keyed by `receipt`. Returns an unregister
|
|
185
|
+
* function.
|
|
186
|
+
*/
|
|
187
|
+
registerReceipt(receipt) {
|
|
188
|
+
let resolveWait;
|
|
189
|
+
const wait = new Promise((resolve) => {
|
|
190
|
+
resolveWait = resolve;
|
|
191
|
+
});
|
|
192
|
+
this.receipts.set(receipt, resolveWait);
|
|
193
|
+
const unregister = () => {
|
|
194
|
+
this.receipts.delete(receipt);
|
|
195
|
+
};
|
|
196
|
+
return { wait, unregister };
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Wires a real sink for a registered subscription's `push`. Called by the
|
|
200
|
+
* Client right after `registerSubscription` to install the channel/queue the
|
|
201
|
+
* application reads from.
|
|
202
|
+
*/
|
|
203
|
+
setSubscriptionSink(id, sink) {
|
|
204
|
+
const sub = this.subs.get(id);
|
|
205
|
+
if (sub) sub.push = sink;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Inspects one decoded frame, delivers it to a matching waiter if one
|
|
209
|
+
* exists, and returns `true` (consumed). Returns `false` for frames with no
|
|
210
|
+
* matching waiter — these flow on to the resolver queue / event stream.
|
|
211
|
+
* Safe to call from the message handler.
|
|
212
|
+
*/
|
|
213
|
+
route(frame) {
|
|
214
|
+
if (!frame || typeof frame !== "object") return false;
|
|
215
|
+
const typ = frame.type;
|
|
216
|
+
const id = frame.id;
|
|
217
|
+
if (typ === "response" || typ === "error") {
|
|
218
|
+
if (!id) return false;
|
|
219
|
+
const pc = this.rpcs.get(id);
|
|
220
|
+
if (!pc) return false;
|
|
221
|
+
if (typ === "error") {
|
|
222
|
+
const errObj = frame.error ?? {};
|
|
223
|
+
const code = typeof errObj.code === "number" ? errObj.code : -32603;
|
|
224
|
+
const message = errObj.message ?? "daemon error";
|
|
225
|
+
pc.reject(new DaemonError(code, message, errObj.data));
|
|
226
|
+
} else {
|
|
227
|
+
const result = frame.result ?? frame;
|
|
228
|
+
pc.resolve(result);
|
|
229
|
+
}
|
|
230
|
+
this.rpcs.delete(id);
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
if (typ === "next" || typ === "complete") {
|
|
234
|
+
if (!id) return false;
|
|
235
|
+
const ps = this.subs.get(id);
|
|
236
|
+
if (!ps) return false;
|
|
237
|
+
if (ps.settled) return true;
|
|
238
|
+
ps.push(frame);
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
if (typ === "receipt_response") {
|
|
242
|
+
const rid = frame.receipt;
|
|
243
|
+
if (!rid) return false;
|
|
244
|
+
const ch = this.receipts.get(rid);
|
|
245
|
+
if (!ch) return false;
|
|
246
|
+
ch(frame);
|
|
247
|
+
this.receipts.delete(rid);
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
/** Reports whether an RPC waiter is registered for `id`. */
|
|
253
|
+
hasRPCWaiter(id) {
|
|
254
|
+
return this.rpcs.has(id);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// src/intent_hints.ts
|
|
259
|
+
var INTENT_HINT_TEXT_COMPLETION = "text_completion";
|
|
260
|
+
var INTENT_HINT_IMAGE_TO_TEXT = "image_to_text";
|
|
261
|
+
var INTENT_HINT_OCR = "ocr";
|
|
262
|
+
var INTENT_HINT_EMBED = "embed";
|
|
263
|
+
var REMOVED_INTENT_HINTS = ["direct_llm", "quiz"];
|
|
264
|
+
var REMOVED_INTENT_HINT_MESSAGES = {
|
|
265
|
+
direct_llm: "intent_hint direct_llm is removed; use text_completion (text-only) or image_to_text (with attachments)",
|
|
266
|
+
quiz: "intent_hint quiz is removed; omit intent_hint and let intake classify the turn"
|
|
267
|
+
};
|
|
268
|
+
function validateLoopInputIntentHint(hint) {
|
|
269
|
+
const key = hint.trim().toLowerCase();
|
|
270
|
+
if (key === "direct_llm" || key === "quiz") {
|
|
271
|
+
return REMOVED_INTENT_HINT_MESSAGES[key];
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
var LOOP_ASSISTANT_OUTPUT_PHASES = [
|
|
276
|
+
"goal_completion",
|
|
277
|
+
"quiz",
|
|
278
|
+
"autonomous_goal",
|
|
279
|
+
"direct_model",
|
|
280
|
+
"text_completion",
|
|
281
|
+
"image_to_text",
|
|
282
|
+
"ocr",
|
|
283
|
+
"embed",
|
|
284
|
+
"plan_direct"
|
|
285
|
+
];
|
|
286
|
+
var DEFAULT_DELIVERABLE_PHASES = /* @__PURE__ */ new Set([
|
|
287
|
+
"quiz",
|
|
288
|
+
"goal_completion",
|
|
289
|
+
"direct_model",
|
|
290
|
+
"text_completion",
|
|
291
|
+
"image_to_text",
|
|
292
|
+
"ocr",
|
|
293
|
+
"embed"
|
|
294
|
+
]);
|
|
295
|
+
|
|
296
|
+
// src/protocol.ts
|
|
297
|
+
import { randomUUID } from "crypto";
|
|
298
|
+
var PROTO_VERSION = "1";
|
|
299
|
+
var DEFAULT_CLIENT_CAPABILITIES = ["streaming", "batch", "heartbeat", "receipts"];
|
|
300
|
+
var CLIENT_VERSION = "0.1.0";
|
|
301
|
+
function encodeMessage(msg) {
|
|
302
|
+
return JSON.stringify(msg) + "\n";
|
|
303
|
+
}
|
|
304
|
+
function decodeMessage(data) {
|
|
305
|
+
if (!data || data.length === 0) return null;
|
|
306
|
+
let parsed;
|
|
307
|
+
try {
|
|
308
|
+
parsed = JSON.parse(data);
|
|
309
|
+
} catch {
|
|
310
|
+
throw new Error(`invalid JSON: ${data}`);
|
|
311
|
+
}
|
|
312
|
+
if (!parsed || typeof parsed !== "object") return parsed;
|
|
313
|
+
const type = parsed.type;
|
|
314
|
+
if (!type) return parsed;
|
|
315
|
+
switch (type) {
|
|
316
|
+
case "connection_init":
|
|
317
|
+
return { ...parsed };
|
|
318
|
+
case "connection_ack":
|
|
319
|
+
return { ...parsed };
|
|
320
|
+
case "request":
|
|
321
|
+
return { ...parsed };
|
|
322
|
+
case "response":
|
|
323
|
+
return { ...parsed };
|
|
324
|
+
case "notification":
|
|
325
|
+
return { ...parsed };
|
|
326
|
+
case "subscribe":
|
|
327
|
+
return { ...parsed };
|
|
328
|
+
case "next":
|
|
329
|
+
return { ...parsed };
|
|
330
|
+
case "error":
|
|
331
|
+
return { ...parsed };
|
|
332
|
+
case "complete":
|
|
333
|
+
return { ...parsed };
|
|
334
|
+
case "unsubscribe":
|
|
335
|
+
return { ...parsed };
|
|
336
|
+
case "ping":
|
|
337
|
+
return { ...parsed };
|
|
338
|
+
case "pong":
|
|
339
|
+
return { ...parsed };
|
|
340
|
+
case "receipt_response":
|
|
341
|
+
return { ...parsed };
|
|
342
|
+
case "disconnect":
|
|
343
|
+
return { ...parsed };
|
|
344
|
+
case "status":
|
|
345
|
+
return { ...parsed };
|
|
346
|
+
default:
|
|
347
|
+
return parsed;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function requestEnvelope(method, params, id) {
|
|
351
|
+
return {
|
|
352
|
+
proto: PROTO_VERSION,
|
|
353
|
+
type: "request",
|
|
354
|
+
method,
|
|
355
|
+
params,
|
|
356
|
+
id: id ?? newRequestID()
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
function notificationEnvelope(method, params) {
|
|
360
|
+
return { proto: PROTO_VERSION, type: "notification", method, params };
|
|
361
|
+
}
|
|
362
|
+
function subscribeEnvelope(method, params, id) {
|
|
363
|
+
return {
|
|
364
|
+
proto: PROTO_VERSION,
|
|
365
|
+
type: "subscribe",
|
|
366
|
+
method,
|
|
367
|
+
params,
|
|
368
|
+
id: id ?? newRequestID()
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
function unsubscribeEnvelope(id) {
|
|
372
|
+
return { proto: PROTO_VERSION, type: "unsubscribe", id };
|
|
373
|
+
}
|
|
374
|
+
function connectionInitEnvelope(opts) {
|
|
375
|
+
return {
|
|
376
|
+
proto: PROTO_VERSION,
|
|
377
|
+
type: "connection_init",
|
|
378
|
+
params: {
|
|
379
|
+
client_version: opts?.client_version ?? CLIENT_VERSION,
|
|
380
|
+
client_name: opts?.client_name ?? "soothe-client-ts",
|
|
381
|
+
accept_proto: opts?.accept_proto ?? [PROTO_VERSION],
|
|
382
|
+
capabilities: opts?.capabilities ?? DEFAULT_CLIENT_CAPABILITIES
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
function pingEnvelope() {
|
|
387
|
+
return { proto: PROTO_VERSION, type: "ping" };
|
|
388
|
+
}
|
|
389
|
+
function pongEnvelope() {
|
|
390
|
+
return { proto: PROTO_VERSION, type: "pong" };
|
|
391
|
+
}
|
|
392
|
+
function disconnectEnvelope() {
|
|
393
|
+
return { proto: PROTO_VERSION, type: "disconnect" };
|
|
394
|
+
}
|
|
395
|
+
function splitWirePayload(data) {
|
|
396
|
+
const trimmed = data.trim();
|
|
397
|
+
if (trimmed === "") return [];
|
|
398
|
+
const lines = trimmed.split("\n").map((l) => l.trim()).filter((l) => l !== "");
|
|
399
|
+
return lines.length > 0 ? lines : [data];
|
|
400
|
+
}
|
|
401
|
+
function extractSootheLoopID(msg) {
|
|
402
|
+
if (!msg || typeof msg !== "object") return ["", false];
|
|
403
|
+
const m = msg;
|
|
404
|
+
if (m.type === "next") {
|
|
405
|
+
const payload = m.payload;
|
|
406
|
+
if (payload && typeof payload === "object") {
|
|
407
|
+
const data = payload.data;
|
|
408
|
+
if (data && typeof data === "object") {
|
|
409
|
+
const id = data.loop_id;
|
|
410
|
+
if (id && id !== "") return [id, true];
|
|
411
|
+
}
|
|
412
|
+
const pid = payload.loop_id;
|
|
413
|
+
if (pid && pid !== "") return [pid, true];
|
|
414
|
+
}
|
|
415
|
+
return ["", false];
|
|
416
|
+
}
|
|
417
|
+
if (m.type === "status") {
|
|
418
|
+
const id = m.loop_id;
|
|
419
|
+
if (id && id !== "") return [id, true];
|
|
420
|
+
return ["", false];
|
|
421
|
+
}
|
|
422
|
+
const generic = m.loop_id;
|
|
423
|
+
if (generic && generic !== "") return [generic, true];
|
|
424
|
+
return ["", false];
|
|
425
|
+
}
|
|
426
|
+
function newRequestID() {
|
|
427
|
+
return randomUUID();
|
|
428
|
+
}
|
|
429
|
+
function newLoopInputMessage(loopID, content) {
|
|
430
|
+
return notificationEnvelope("loop_input", {
|
|
431
|
+
loop_id: loopID,
|
|
432
|
+
content,
|
|
433
|
+
autonomous: false
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
function newLoopNewMessage(opts) {
|
|
437
|
+
const options = typeof opts === "string" ? { client_workspace: opts } : opts ?? {};
|
|
438
|
+
const clientWorkspace = options.client_workspace ?? options.workspace;
|
|
439
|
+
const params = {};
|
|
440
|
+
if (clientWorkspace?.trim()) {
|
|
441
|
+
params.client_workspace = clientWorkspace.trim();
|
|
442
|
+
}
|
|
443
|
+
if (options.user_id?.trim()) {
|
|
444
|
+
params.user_id = options.user_id.trim();
|
|
445
|
+
}
|
|
446
|
+
if (options.client_workspace_id?.trim()) {
|
|
447
|
+
params.client_workspace_id = options.client_workspace_id.trim();
|
|
448
|
+
}
|
|
449
|
+
if (options.is_ephemeral) {
|
|
450
|
+
params.is_ephemeral = true;
|
|
451
|
+
}
|
|
452
|
+
return requestEnvelope("loop_new", params);
|
|
453
|
+
}
|
|
454
|
+
function newLoopSubscribeMessage(loopID, verbosity, streamDelivery) {
|
|
455
|
+
const params = { loop_id: loopID, verbosity };
|
|
456
|
+
if (streamDelivery) {
|
|
457
|
+
params.stream_delivery = streamDelivery;
|
|
458
|
+
}
|
|
459
|
+
return subscribeEnvelope("loop_events", params);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// src/client.ts
|
|
463
|
+
var Client = class extends EventEmitter {
|
|
464
|
+
url;
|
|
465
|
+
config;
|
|
466
|
+
ws = null;
|
|
467
|
+
messageBuffer = [];
|
|
468
|
+
resolvers = [];
|
|
469
|
+
// Protocol-1 handshake state (RFC-450 §8.2)
|
|
470
|
+
handshakeComplete = false;
|
|
471
|
+
negotiatedCapabilities = /* @__PURE__ */ new Set();
|
|
472
|
+
protocolVersion = null;
|
|
473
|
+
readinessState = null;
|
|
474
|
+
heartbeatIntervalMs = 0;
|
|
475
|
+
heartbeatTimer = null;
|
|
476
|
+
lastPongMonotonic = 0;
|
|
477
|
+
// Mid-session drop signal (RFC-450 §8.3). The 'disconnected' event is
|
|
478
|
+
// emitted exactly once when the connection drops, carrying a DisconnectCause
|
|
479
|
+
// that distinguishes clean (peer `disconnect`) from unclean (read/write
|
|
480
|
+
// error or missed pong). `disconnFired` guards the once-only delivery.
|
|
481
|
+
disconnFired = false;
|
|
482
|
+
// Pending-request/subscription multiplexer (RFC-629 constraint #1). Routes
|
|
483
|
+
// inbound frames by (type, id) instead of discarding non-matching events.
|
|
484
|
+
mux = new Multiplexer();
|
|
485
|
+
constructor(url, config) {
|
|
486
|
+
super();
|
|
487
|
+
this.url = url;
|
|
488
|
+
this.config = config ?? defaultConfig();
|
|
489
|
+
}
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
// Connection lifecycle
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
/**
|
|
494
|
+
* Dials the Soothe daemon WebSocket and completes the protocol-1 handshake
|
|
495
|
+
* (connection_init → connection_ack with readiness_state "ready").
|
|
496
|
+
*/
|
|
497
|
+
connect() {
|
|
498
|
+
return new Promise((resolve, reject) => {
|
|
499
|
+
const ws = new WebSocket(this.url, {
|
|
500
|
+
handshakeTimeout: 1e4
|
|
501
|
+
});
|
|
502
|
+
ws.on("open", () => {
|
|
503
|
+
this.ws = ws;
|
|
504
|
+
this.disconnFired = false;
|
|
505
|
+
this._lastCause = null;
|
|
506
|
+
this.mux = new Multiplexer();
|
|
507
|
+
this._performHandshake().then((ack) => {
|
|
508
|
+
this.handshakeComplete = true;
|
|
509
|
+
this.readinessState = ack.result?.readiness_state ?? "ready";
|
|
510
|
+
this._startHeartbeat();
|
|
511
|
+
resolve();
|
|
512
|
+
}).catch((err) => {
|
|
513
|
+
this._stopHeartbeat();
|
|
514
|
+
this.ws = null;
|
|
515
|
+
this.handshakeComplete = false;
|
|
516
|
+
try {
|
|
517
|
+
ws.close(1011, "handshake failed");
|
|
518
|
+
} catch {
|
|
519
|
+
}
|
|
520
|
+
reject(err);
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
ws.on("error", (err) => {
|
|
524
|
+
this._signalDisconnect(0 /* Unclean */);
|
|
525
|
+
if (!this.ws) {
|
|
526
|
+
reject(new Error(`soothe dial: ${err.message}`));
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
ws.on("message", (data) => {
|
|
530
|
+
const text = data.toString();
|
|
531
|
+
for (const frame of splitWirePayload(text)) {
|
|
532
|
+
let msg;
|
|
533
|
+
try {
|
|
534
|
+
msg = decodeMessage(frame);
|
|
535
|
+
} catch {
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
if (msg === null) continue;
|
|
539
|
+
const m = msg;
|
|
540
|
+
if (m.type === "ping") {
|
|
541
|
+
this._sendRaw(pongEnvelope());
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
if (m.type === "pong") {
|
|
545
|
+
this.lastPongMonotonic = Date.now();
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
if (m.type === "disconnect") {
|
|
549
|
+
this._signalDisconnect(1 /* Clean */);
|
|
550
|
+
}
|
|
551
|
+
if (this.mux.route(m)) {
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
const resolver = this.resolvers.shift();
|
|
555
|
+
if (resolver) {
|
|
556
|
+
resolver(msg);
|
|
557
|
+
} else {
|
|
558
|
+
this.messageBuffer.push(msg);
|
|
559
|
+
}
|
|
560
|
+
this.emit("message", msg);
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
ws.on("close", () => {
|
|
564
|
+
this.ws = null;
|
|
565
|
+
this._stopHeartbeat();
|
|
566
|
+
this.handshakeComplete = false;
|
|
567
|
+
this._signalDisconnect(0 /* Unclean */);
|
|
568
|
+
this.emit("close");
|
|
569
|
+
for (const resolver of this.resolvers) {
|
|
570
|
+
resolver(null);
|
|
571
|
+
}
|
|
572
|
+
this.resolvers = [];
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
/** Sends a `disconnect` notification and closes the WebSocket. */
|
|
577
|
+
close() {
|
|
578
|
+
this._stopHeartbeat();
|
|
579
|
+
if (!this.ws) return;
|
|
580
|
+
try {
|
|
581
|
+
if (this.ws.readyState === WebSocket.OPEN) {
|
|
582
|
+
this.ws.send(JSON.stringify(disconnectEnvelope()));
|
|
583
|
+
}
|
|
584
|
+
this.ws.close(1e3, "");
|
|
585
|
+
} catch {
|
|
586
|
+
}
|
|
587
|
+
this.ws = null;
|
|
588
|
+
this.handshakeComplete = false;
|
|
589
|
+
}
|
|
590
|
+
/** Returns whether the client has an active, handshaked WebSocket connection. */
|
|
591
|
+
isConnected() {
|
|
592
|
+
return this.ws !== null && this.ws.readyState === WebSocket.OPEN && this.handshakeComplete;
|
|
593
|
+
}
|
|
594
|
+
// ---------------------------------------------------------------------------
|
|
595
|
+
// Mid-session drop signal + reconnect/reattach (RFC-450 §8.3, RFC-629 L0)
|
|
596
|
+
// ---------------------------------------------------------------------------
|
|
597
|
+
/**
|
|
598
|
+
* Returns whether the connection has dropped (the `'disconnected'` event has
|
|
599
|
+
* fired). Pair with the `'disconnected'` event for the signal. Use
|
|
600
|
+
* `disconnectCause()` to read the cause.
|
|
601
|
+
*/
|
|
602
|
+
isDisconnected() {
|
|
603
|
+
return this.disconnFired;
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Returns the cause of the most recent drop, or `null` if the connection has
|
|
607
|
+
* not dropped. Clean follows a `disconnect` notification (loops keep running
|
|
608
|
+
* server-side); unclean is a read/write error or missed pong.
|
|
609
|
+
*/
|
|
610
|
+
disconnectCause() {
|
|
611
|
+
if (!this.disconnFired) return null;
|
|
612
|
+
return this._lastCause ?? 0 /* Unclean */;
|
|
613
|
+
}
|
|
614
|
+
_lastCause = null;
|
|
615
|
+
/**
|
|
616
|
+
* Delivers the disconnect cause exactly once via the `'disconnected'` event.
|
|
617
|
+
* Safe to call from any path; subsequent calls are no-ops. Listeners receive
|
|
618
|
+
* the cause as the event argument.
|
|
619
|
+
*/
|
|
620
|
+
_signalDisconnect(cause) {
|
|
621
|
+
if (this.disconnFired) return;
|
|
622
|
+
this.disconnFired = true;
|
|
623
|
+
this._lastCause = cause;
|
|
624
|
+
try {
|
|
625
|
+
this.emit("disconnected", cause);
|
|
626
|
+
} catch {
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Re-dials the daemon and re-handshakes after a connection drop (RFC-450
|
|
631
|
+
* §8.3). Does not re-establish loop subscriptions; follow with
|
|
632
|
+
* `reattachAndProbe()` to resume a loop session. The caller should invoke
|
|
633
|
+
* this after the `'disconnected'` event fires. Reuses the same Client,
|
|
634
|
+
* resetting the drop signal and multiplexer.
|
|
635
|
+
*
|
|
636
|
+
* Performs bounded-retry backoff using the configured reconnect knobs.
|
|
637
|
+
*/
|
|
638
|
+
async reconnect() {
|
|
639
|
+
const maxAttempts = this.config.reconnectMaxAttempts || 10;
|
|
640
|
+
const initialDelay = this.config.reconnectInitialDelay || 500;
|
|
641
|
+
const maxDelay = this.config.reconnectMaxDelay || 1e4;
|
|
642
|
+
let lastErr = null;
|
|
643
|
+
let delay = initialDelay;
|
|
644
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
645
|
+
try {
|
|
646
|
+
await this.connect();
|
|
647
|
+
return;
|
|
648
|
+
} catch (err) {
|
|
649
|
+
lastErr = err;
|
|
650
|
+
}
|
|
651
|
+
if (attempt < maxAttempts) {
|
|
652
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
653
|
+
delay = Math.min(delay * 2, maxDelay);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
throw new ReconnectError(this.url, maxAttempts, lastErr ?? new Error("unknown error"));
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Resumes an existing loop after a reconnect: issues `loop_reattach`,
|
|
660
|
+
* re-subscribes to `loop_events`, then runs a `loop_get` liveness probe to
|
|
661
|
+
* detect stale loops that accept the handshake but silently drop input.
|
|
662
|
+
* Returns a `StaleLoopError` when the probe fails; callers should fall back
|
|
663
|
+
* to a fresh `loop_new` bootstrap.
|
|
664
|
+
*
|
|
665
|
+
* Per RFC-629: connection-level readiness is the handshake's readiness_state
|
|
666
|
+
* (+ daemon_status); loop_get is a loop-scoped probe only, not a readiness
|
|
667
|
+
* probe.
|
|
668
|
+
*/
|
|
669
|
+
async reattachAndProbe(loopID) {
|
|
670
|
+
if (!loopID || !loopID.trim()) {
|
|
671
|
+
throw new Error("soothe: reattachAndProbe requires a loop id");
|
|
672
|
+
}
|
|
673
|
+
const lid = loopID.trim();
|
|
674
|
+
const reattachTimeout = this.config.loopStatusTimeout || 15e3;
|
|
675
|
+
try {
|
|
676
|
+
await this.requestResponse(
|
|
677
|
+
"loop_reattach",
|
|
678
|
+
{ loop_id: lid },
|
|
679
|
+
"loop_reattach",
|
|
680
|
+
reattachTimeout
|
|
681
|
+
);
|
|
682
|
+
} catch (err) {
|
|
683
|
+
throw new Error(`loop_reattach: ${err.message}`);
|
|
684
|
+
}
|
|
685
|
+
const subTimeout = this.config.subscriptionTimeout || 1e4;
|
|
686
|
+
try {
|
|
687
|
+
await this.subscribe(
|
|
688
|
+
"loop_events",
|
|
689
|
+
{ loop_id: lid, verbosity: this.config.verbosityLevel },
|
|
690
|
+
subTimeout
|
|
691
|
+
);
|
|
692
|
+
} catch (err) {
|
|
693
|
+
throw new Error(`loop events subscription failed: ${err.message}`);
|
|
694
|
+
}
|
|
695
|
+
const probeTimeout = this.config.reattachProbeTimeout || 5e3;
|
|
696
|
+
try {
|
|
697
|
+
await this.getLoop(lid, probeTimeout);
|
|
698
|
+
} catch (err) {
|
|
699
|
+
if (err instanceof DaemonError && err.code === -32200) {
|
|
700
|
+
throw new StaleLoopError(lid, err);
|
|
701
|
+
}
|
|
702
|
+
throw new StaleLoopError(lid, err);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
// ---------------------------------------------------------------------------
|
|
706
|
+
// Protocol-1 handshake (RFC-450 §8.2)
|
|
707
|
+
// ---------------------------------------------------------------------------
|
|
708
|
+
/** Send connection_init and wait for connection_ack with readiness "ready". */
|
|
709
|
+
async _performHandshake() {
|
|
710
|
+
const init = connectionInitEnvelope({
|
|
711
|
+
client_version: CLIENT_VERSION,
|
|
712
|
+
client_name: "soothe-client-ts",
|
|
713
|
+
accept_proto: [PROTO_VERSION],
|
|
714
|
+
capabilities: DEFAULT_CLIENT_CAPABILITIES
|
|
715
|
+
});
|
|
716
|
+
await this.sendMessage(init);
|
|
717
|
+
const deadline = Date.now() + this.config.daemonReadyTimeout;
|
|
718
|
+
while (Date.now() < deadline) {
|
|
719
|
+
const remaining = deadline - Date.now();
|
|
720
|
+
if (remaining <= 0) break;
|
|
721
|
+
const ev = await this.readEventWithTimeout(remaining);
|
|
722
|
+
if (ev === null) {
|
|
723
|
+
throw new Error("connection closed during handshake");
|
|
724
|
+
}
|
|
725
|
+
if (ev.type === "status") {
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
if (ev.type !== "connection_ack") {
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
const ack = ev;
|
|
732
|
+
const result = ack.result ?? {};
|
|
733
|
+
const state = result.readiness_state ?? "ready";
|
|
734
|
+
this.protocolVersion = result.protocol_version ?? PROTO_VERSION;
|
|
735
|
+
this.negotiatedCapabilities = new Set(result.capabilities ?? []);
|
|
736
|
+
this.heartbeatIntervalMs = result.heartbeat_interval_ms ?? 0;
|
|
737
|
+
if (state === "incompatible") {
|
|
738
|
+
throw new Error(`protocol version incompatible: daemon returned ${this.protocolVersion}`);
|
|
739
|
+
}
|
|
740
|
+
if (state === "ready") {
|
|
741
|
+
return ack;
|
|
742
|
+
}
|
|
743
|
+
if (state === "error") {
|
|
744
|
+
throw new Error("daemon startup failed");
|
|
745
|
+
}
|
|
746
|
+
if (state === "degraded") {
|
|
747
|
+
throw new Error("daemon is degraded");
|
|
748
|
+
}
|
|
749
|
+
await this._sleep(50);
|
|
750
|
+
await this.sendMessage(init);
|
|
751
|
+
}
|
|
752
|
+
throw new Error(`timeout after ${this.config.daemonReadyTimeout}ms waiting for connection_ack`);
|
|
753
|
+
}
|
|
754
|
+
// ---------------------------------------------------------------------------
|
|
755
|
+
// Heartbeat (RFC-450 §8.3)
|
|
756
|
+
// ---------------------------------------------------------------------------
|
|
757
|
+
_startHeartbeat() {
|
|
758
|
+
if (!this.negotiatedCapabilities.has("heartbeat")) return;
|
|
759
|
+
const interval = this.heartbeatIntervalMs;
|
|
760
|
+
if (interval <= 0) return;
|
|
761
|
+
this.lastPongMonotonic = Date.now();
|
|
762
|
+
this.heartbeatTimer = setInterval(() => this._heartbeatTick(interval), interval);
|
|
763
|
+
}
|
|
764
|
+
_stopHeartbeat() {
|
|
765
|
+
if (this.heartbeatTimer) {
|
|
766
|
+
clearInterval(this.heartbeatTimer);
|
|
767
|
+
this.heartbeatTimer = null;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
_heartbeatTick(intervalMs) {
|
|
771
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
772
|
+
const timeoutMs = Math.max(1e4, intervalMs * 2);
|
|
773
|
+
const now = Date.now();
|
|
774
|
+
if (now - (this.lastPongMonotonic || now) > intervalMs + timeoutMs) {
|
|
775
|
+
this._signalDisconnect(0 /* Unclean */);
|
|
776
|
+
try {
|
|
777
|
+
this.ws.close(1001, "heartbeat timeout");
|
|
778
|
+
} catch {
|
|
779
|
+
}
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
try {
|
|
783
|
+
this.ws.send(JSON.stringify(pingEnvelope()));
|
|
784
|
+
} catch {
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
_sleep(ms) {
|
|
788
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
789
|
+
}
|
|
790
|
+
// ---------------------------------------------------------------------------
|
|
791
|
+
// Core messaging
|
|
792
|
+
// ---------------------------------------------------------------------------
|
|
793
|
+
/** Serializes msg as JSON and sends it as a WebSocket text frame. */
|
|
794
|
+
sendMessage(msg) {
|
|
795
|
+
return new Promise((resolve, reject) => {
|
|
796
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
797
|
+
reject(new Error("soothe: not connected"));
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
const payload = JSON.stringify(msg);
|
|
801
|
+
this.ws.send(payload, (err) => {
|
|
802
|
+
if (err) {
|
|
803
|
+
this._signalDisconnect(0 /* Unclean */);
|
|
804
|
+
reject(err);
|
|
805
|
+
} else {
|
|
806
|
+
resolve();
|
|
807
|
+
}
|
|
808
|
+
});
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
/** Low-level send that does not reject on a missing connection (best-effort). */
|
|
812
|
+
_sendRaw(msg) {
|
|
813
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
814
|
+
try {
|
|
815
|
+
this.ws.send(JSON.stringify(msg));
|
|
816
|
+
} catch {
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
/** Returns an async iterable of decoded messages. Ends when connection closes. */
|
|
820
|
+
async *receiveMessages(signal) {
|
|
821
|
+
while (true) {
|
|
822
|
+
if (signal?.aborted) return;
|
|
823
|
+
while (this.messageBuffer.length > 0) {
|
|
824
|
+
const msg2 = this.messageBuffer.shift();
|
|
825
|
+
yield msg2;
|
|
826
|
+
}
|
|
827
|
+
const msg = await new Promise((resolve) => {
|
|
828
|
+
if (!this.ws) {
|
|
829
|
+
resolve(null);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
this.resolvers.push(resolve);
|
|
833
|
+
});
|
|
834
|
+
if (msg === null) return;
|
|
835
|
+
yield msg;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
/** Reads a single event from the daemon. Returns null on connection close. */
|
|
839
|
+
async readEvent() {
|
|
840
|
+
if (this.messageBuffer.length > 0) {
|
|
841
|
+
const msg2 = this.messageBuffer.shift();
|
|
842
|
+
return msg2;
|
|
843
|
+
}
|
|
844
|
+
if (!this.ws) return null;
|
|
845
|
+
const msg = await new Promise((resolve) => {
|
|
846
|
+
this.resolvers.push(resolve);
|
|
847
|
+
});
|
|
848
|
+
if (msg === null) return null;
|
|
849
|
+
return msg;
|
|
850
|
+
}
|
|
851
|
+
/** Reads a single event with a timeout. Returns null on timeout or close. */
|
|
852
|
+
readEventWithTimeout(timeout) {
|
|
853
|
+
if (this.messageBuffer.length > 0) {
|
|
854
|
+
const msg = this.messageBuffer.shift();
|
|
855
|
+
return Promise.resolve(msg);
|
|
856
|
+
}
|
|
857
|
+
if (!this.ws) return Promise.resolve(null);
|
|
858
|
+
return new Promise((resolve) => {
|
|
859
|
+
const timer = setTimeout(() => {
|
|
860
|
+
const idx = this.resolvers.indexOf(resolver);
|
|
861
|
+
if (idx >= 0) this.resolvers.splice(idx, 1);
|
|
862
|
+
resolve(null);
|
|
863
|
+
}, timeout);
|
|
864
|
+
const resolver = (val) => {
|
|
865
|
+
clearTimeout(timer);
|
|
866
|
+
resolve(val);
|
|
867
|
+
};
|
|
868
|
+
this.resolvers.push(resolver);
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
// ---------------------------------------------------------------------------
|
|
872
|
+
// Protocol-1 RPC primitives (RFC-450 §5/§9)
|
|
873
|
+
// ---------------------------------------------------------------------------
|
|
874
|
+
/**
|
|
875
|
+
* Reads the next frame directly from the live socket (via a resolver),
|
|
876
|
+
* bypassing `messageBuffer`. Used by RPC waits so that stream events
|
|
877
|
+
* previously buffered for `readEvent()`/`receiveMessages()` consumers are
|
|
878
|
+
* not re-cycled through the RPC wait loop (which would stall behind a
|
|
879
|
+
* continuous subscription stream). Non-RPC frames read here are pushed to
|
|
880
|
+
* `messageBuffer` for the stream readers.
|
|
881
|
+
*/
|
|
882
|
+
readLiveEventWithTimeout(timeout) {
|
|
883
|
+
if (!this.ws) return Promise.resolve(null);
|
|
884
|
+
return new Promise((resolve) => {
|
|
885
|
+
const timer = setTimeout(() => {
|
|
886
|
+
const idx = this.resolvers.indexOf(resolver);
|
|
887
|
+
if (idx >= 0) this.resolvers.splice(idx, 1);
|
|
888
|
+
resolve(null);
|
|
889
|
+
}, timeout);
|
|
890
|
+
const resolver = (val) => {
|
|
891
|
+
clearTimeout(timer);
|
|
892
|
+
resolve(val);
|
|
893
|
+
};
|
|
894
|
+
this.resolvers.push(resolver);
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Sends a `request` envelope and waits for the matching `response` (or
|
|
899
|
+
* `error`) correlated by `id` (RFC-450 §5/§9). Returns the `result` object.
|
|
900
|
+
*
|
|
901
|
+
* Multiplexer-aware (RFC-629 constraint #1): registers a pending RPC wait
|
|
902
|
+
* keyed by the request id so that, even when a `receiveMessages()` reader
|
|
903
|
+
* is concurrently active, the matching `response`/`error` is routed to
|
|
904
|
+
* this caller instead of being discarded or buffered behind a stream.
|
|
905
|
+
* Non-matching frames are routed to their own waiters by the multiplexer
|
|
906
|
+
* or flow on to the resolver queue for stream readers.
|
|
907
|
+
*/
|
|
908
|
+
async requestResponse(method, params, responseType, timeout = 15e3) {
|
|
909
|
+
const req = requestEnvelope(method, params);
|
|
910
|
+
const rid = req.id;
|
|
911
|
+
const { call, unregister } = this.mux.registerRPC(rid);
|
|
912
|
+
const label = responseType ?? method;
|
|
913
|
+
try {
|
|
914
|
+
await this.sendMessage(req);
|
|
915
|
+
const result = await this._raceRPC(call, timeout, label);
|
|
916
|
+
return result;
|
|
917
|
+
} finally {
|
|
918
|
+
unregister();
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Races the multiplexer's RPC promise against a timeout and the connection
|
|
923
|
+
* drop signal. Resolves with the `result` on `response`; rejects with a
|
|
924
|
+
* `DaemonError` on `error`; rejects with a timeout/close error otherwise.
|
|
925
|
+
* The disconnect listener is always removed to avoid accumulating handlers.
|
|
926
|
+
*/
|
|
927
|
+
async _raceRPC(call, timeout, label) {
|
|
928
|
+
let timer;
|
|
929
|
+
let cleanupDisconnect = () => {
|
|
930
|
+
};
|
|
931
|
+
const timeoutP = new Promise((_, reject) => {
|
|
932
|
+
timer = setTimeout(
|
|
933
|
+
() => reject(new Error(`timeout after ${timeout}ms waiting for ${label}`)),
|
|
934
|
+
timeout
|
|
935
|
+
);
|
|
936
|
+
});
|
|
937
|
+
const closedP = new Promise((_, reject) => {
|
|
938
|
+
if (this.disconnFired) {
|
|
939
|
+
reject(new Error(`connection closed waiting for ${label}`));
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
const onDisconnect = () => {
|
|
943
|
+
reject(new Error(`connection closed waiting for ${label}`));
|
|
944
|
+
};
|
|
945
|
+
this.once("disconnected", onDisconnect);
|
|
946
|
+
cleanupDisconnect = () => this.removeListener("disconnected", onDisconnect);
|
|
947
|
+
});
|
|
948
|
+
try {
|
|
949
|
+
return await Promise.race([call, timeoutP, closedP]);
|
|
950
|
+
} finally {
|
|
951
|
+
if (timer) clearTimeout(timer);
|
|
952
|
+
cleanupDisconnect();
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Sends a pre-built envelope (e.g. `unsubscribe`) that carries an `id` and
|
|
957
|
+
* waits for the matching `response`/`error`. Used for envelope types that
|
|
958
|
+
* are not `request` (e.g. `unsubscribe` → `autopilot_unsubscribe`) but still
|
|
959
|
+
* expect a correlated response from the daemon.
|
|
960
|
+
*/
|
|
961
|
+
async _requestResponseForEnvelope(env, label, timeout) {
|
|
962
|
+
const rid = env.id;
|
|
963
|
+
const { call, unregister } = this.mux.registerRPC(rid);
|
|
964
|
+
try {
|
|
965
|
+
await this.sendMessage(env);
|
|
966
|
+
return await this._raceRPC(call, timeout, label);
|
|
967
|
+
} finally {
|
|
968
|
+
unregister();
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
/** Sends a fire-and-forget `notification` envelope (no response expected). */
|
|
972
|
+
notify(method, params) {
|
|
973
|
+
return this.sendMessage(notificationEnvelope(method, params));
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* Starts a subscription stream. Returns the subscription `id` for later
|
|
977
|
+
* correlation and `unsubscribe()`. Stream events arrive as `next` frames
|
|
978
|
+
* carrying the same `id`.
|
|
979
|
+
*/
|
|
980
|
+
async subscribe(method, params, timeout = 5e3) {
|
|
981
|
+
const req = subscribeEnvelope(method, params);
|
|
982
|
+
const subId = req.id;
|
|
983
|
+
await this.sendMessage(req);
|
|
984
|
+
const deadline = Date.now() + timeout;
|
|
985
|
+
while (Date.now() < deadline) {
|
|
986
|
+
const remaining = deadline - Date.now();
|
|
987
|
+
if (remaining <= 0) break;
|
|
988
|
+
const ev = await this.readLiveEventWithTimeout(remaining);
|
|
989
|
+
if (ev === null) break;
|
|
990
|
+
const evId = ev.id;
|
|
991
|
+
if (evId !== subId) {
|
|
992
|
+
this.messageBuffer.push(ev);
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
const typ = ev.type;
|
|
996
|
+
if (typ === "error") {
|
|
997
|
+
const errObj = ev.error ?? {};
|
|
998
|
+
throw new DaemonError(
|
|
999
|
+
errObj.code ?? -32603,
|
|
1000
|
+
errObj.message ?? "subscription rejected",
|
|
1001
|
+
errObj.data
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
if (typ === "next" || typ === "complete") {
|
|
1005
|
+
this.messageBuffer.unshift(ev);
|
|
1006
|
+
break;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
return subId;
|
|
1010
|
+
}
|
|
1011
|
+
/** Cancels an active subscription by id. */
|
|
1012
|
+
unsubscribe(subscriptionId) {
|
|
1013
|
+
return this.sendMessage(unsubscribeEnvelope(subscriptionId));
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Reads the next stream event from a subscription. For `next` frames the
|
|
1017
|
+
* `payload` is returned; for `complete`/`error` the full envelope is
|
|
1018
|
+
* returned so the caller can inspect termination.
|
|
1019
|
+
*/
|
|
1020
|
+
async next() {
|
|
1021
|
+
const ev = await this.readEvent();
|
|
1022
|
+
if (ev === null) return null;
|
|
1023
|
+
if (ev.type === "next") {
|
|
1024
|
+
return ev.payload ?? {};
|
|
1025
|
+
}
|
|
1026
|
+
return ev;
|
|
1027
|
+
}
|
|
1028
|
+
// ---------------------------------------------------------------------------
|
|
1029
|
+
// High-level API methods (Loop-first, RFC-503)
|
|
1030
|
+
// ---------------------------------------------------------------------------
|
|
1031
|
+
/** Sends user input to the daemon (loop_input notification; requires loopID). */
|
|
1032
|
+
sendInput(text, options) {
|
|
1033
|
+
const loopId = (options?.loopID ?? "").trim();
|
|
1034
|
+
if (!loopId) {
|
|
1035
|
+
return Promise.reject(new Error("sendInput requires options.loopID"));
|
|
1036
|
+
}
|
|
1037
|
+
const params = {
|
|
1038
|
+
loop_id: loopId,
|
|
1039
|
+
content: text,
|
|
1040
|
+
autonomous: options?.autonomous ?? false
|
|
1041
|
+
};
|
|
1042
|
+
if (options?.maxIterations !== void 0) params.max_iterations = options.maxIterations;
|
|
1043
|
+
if (options?.subagent) params.preferred_subagent = options.subagent;
|
|
1044
|
+
if (options?.model) params.model = options.model;
|
|
1045
|
+
if (options?.modelParams) params.model_params = options.modelParams;
|
|
1046
|
+
if (options?.attachments) params.attachments = options.attachments;
|
|
1047
|
+
if (options?.intentHint) {
|
|
1048
|
+
const hintError = validateLoopInputIntentHint(options.intentHint);
|
|
1049
|
+
if (hintError) {
|
|
1050
|
+
return Promise.reject(new Error(hintError));
|
|
1051
|
+
}
|
|
1052
|
+
params.intent_hint = options.intentHint;
|
|
1053
|
+
}
|
|
1054
|
+
if (options?.responseSchema) params.response_schema = options.responseSchema;
|
|
1055
|
+
if (options?.responseSchemaName) params.response_schema_name = options.responseSchemaName;
|
|
1056
|
+
if (options?.responseSchemaStrict !== void 0)
|
|
1057
|
+
params.response_schema_strict = options.responseSchemaStrict;
|
|
1058
|
+
if (options?.clarificationMode) params.clarification_mode = options.clarificationMode;
|
|
1059
|
+
if (options?.clarificationAnswer) params.clarification_answer = true;
|
|
1060
|
+
if (options?.clarificationAnswers) params.clarification_answers = options.clarificationAnswers;
|
|
1061
|
+
return this.notify("loop_input", params);
|
|
1062
|
+
}
|
|
1063
|
+
/** Sends a slash command to the daemon (slash_command notification). */
|
|
1064
|
+
sendCommand(cmd) {
|
|
1065
|
+
return this.notify("slash_command", { cmd });
|
|
1066
|
+
}
|
|
1067
|
+
// ---------------------------------------------------------------------------
|
|
1068
|
+
// Loop lifecycle methods (RFC-503)
|
|
1069
|
+
// ---------------------------------------------------------------------------
|
|
1070
|
+
/** Requests the daemon to create a new StrangeLoop and waits for the response. */
|
|
1071
|
+
sendLoopNew(opts) {
|
|
1072
|
+
return this.sendMessage(newLoopNewMessage(opts));
|
|
1073
|
+
}
|
|
1074
|
+
/** Subscribes to events for a loop (subscribe → loop_events). */
|
|
1075
|
+
async sendLoopSubscribe(loopID, verbosity, streamDelivery) {
|
|
1076
|
+
await this.subscribe("loop_events", {
|
|
1077
|
+
loop_id: loopID,
|
|
1078
|
+
verbosity,
|
|
1079
|
+
stream_delivery: streamDelivery
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
/** Detaches from a loop (unsubscribe by subscription id). */
|
|
1083
|
+
sendLoopDetach(loopID) {
|
|
1084
|
+
return this.sendMessage(unsubscribeEnvelope(loopID));
|
|
1085
|
+
}
|
|
1086
|
+
/** Notifies the daemon that this client is leaving (disconnect notification). */
|
|
1087
|
+
sendDetach() {
|
|
1088
|
+
return this.sendMessage(disconnectEnvelope());
|
|
1089
|
+
}
|
|
1090
|
+
/** Requests daemon status check. */
|
|
1091
|
+
sendDaemonStatus() {
|
|
1092
|
+
return this.sendMessage(requestEnvelope("daemon_status", {}));
|
|
1093
|
+
}
|
|
1094
|
+
/** Requests daemon shutdown. */
|
|
1095
|
+
sendDaemonShutdown() {
|
|
1096
|
+
return this.sendMessage(requestEnvelope("daemon_shutdown", {}));
|
|
1097
|
+
}
|
|
1098
|
+
/** Requests a config section from the daemon. */
|
|
1099
|
+
sendConfigGet(section) {
|
|
1100
|
+
return this.sendMessage(requestEnvelope("config_get", { section }));
|
|
1101
|
+
}
|
|
1102
|
+
// ---------------------------------------------------------------------------
|
|
1103
|
+
// Convenience RPC methods (blocking request/response)
|
|
1104
|
+
// ---------------------------------------------------------------------------
|
|
1105
|
+
/** Requests the skills catalog and waits for the response. */
|
|
1106
|
+
listSkills(timeout) {
|
|
1107
|
+
return this.requestResponse("skills_list", {}, "skills_list", timeout ?? 15e3);
|
|
1108
|
+
}
|
|
1109
|
+
/** Requests the models catalog and waits for the response. */
|
|
1110
|
+
listModels(timeout) {
|
|
1111
|
+
return this.requestResponse("models_list", {}, "models_list", timeout ?? 15e3);
|
|
1112
|
+
}
|
|
1113
|
+
/** Invokes a skill on the daemon host and receives echo. */
|
|
1114
|
+
invokeSkill(skill, args, timeout) {
|
|
1115
|
+
const params = { skill, args: args ?? "" };
|
|
1116
|
+
return this.requestResponse("invoke_skill", params, "invoke_skill", timeout ?? 12e4);
|
|
1117
|
+
}
|
|
1118
|
+
/** Requests loop list and waits for response. */
|
|
1119
|
+
listLoops(timeout, workspace) {
|
|
1120
|
+
const params = {};
|
|
1121
|
+
if (workspace) params.filter = { workspace };
|
|
1122
|
+
return this.requestResponse("loop_list", params, "loop_list", timeout ?? 15e3);
|
|
1123
|
+
}
|
|
1124
|
+
/** Requests loop details and waits for response. */
|
|
1125
|
+
getLoop(loopID, timeout) {
|
|
1126
|
+
return this.requestResponse("loop_get", { loop_id: loopID }, "loop_get", timeout ?? 15e3);
|
|
1127
|
+
}
|
|
1128
|
+
/** Requests loop tree and waits for response. */
|
|
1129
|
+
getLoopTree(loopID, timeout) {
|
|
1130
|
+
return this.requestResponse("loop_tree", { loop_id: loopID }, "loop_tree", timeout ?? 15e3);
|
|
1131
|
+
}
|
|
1132
|
+
/** Requests loop deletion and waits for response. */
|
|
1133
|
+
deleteLoop(loopID, timeout) {
|
|
1134
|
+
return this.requestResponse(
|
|
1135
|
+
"loop_delete",
|
|
1136
|
+
{ loop_id: loopID },
|
|
1137
|
+
"loop_delete",
|
|
1138
|
+
timeout ?? 15e3
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
/** Requests persisted conversation/activity rows. */
|
|
1142
|
+
sendLoopMessages(loopID, limit, offset, includeEvents) {
|
|
1143
|
+
const params = { loop_id: loopID };
|
|
1144
|
+
if (limit !== void 0) params.limit = limit;
|
|
1145
|
+
if (offset !== void 0) params.offset = offset;
|
|
1146
|
+
if (includeEvents) params.include_events = true;
|
|
1147
|
+
return this.sendMessage(requestEnvelope("loop_messages", params));
|
|
1148
|
+
}
|
|
1149
|
+
/** Requests LangGraph checkpoint channel values. */
|
|
1150
|
+
sendLoopStateGet(loopID) {
|
|
1151
|
+
return this.sendMessage(requestEnvelope("loop_state_get", { loop_id: loopID }));
|
|
1152
|
+
}
|
|
1153
|
+
/** Applies partial checkpoint values. */
|
|
1154
|
+
sendLoopStateUpdate(loopID, values, asNode) {
|
|
1155
|
+
const params = { loop_id: loopID, values };
|
|
1156
|
+
if (asNode) params.as_node = asNode;
|
|
1157
|
+
return this.sendMessage(requestEnvelope("loop_state_update", params));
|
|
1158
|
+
}
|
|
1159
|
+
/** Requests display card ledger snapshot. */
|
|
1160
|
+
sendLoopCardsFetch(loopID) {
|
|
1161
|
+
return this.sendMessage(requestEnvelope("loop_cards_fetch", { loop_id: loopID }));
|
|
1162
|
+
}
|
|
1163
|
+
/** Requests the full loop history (RFC-631). */
|
|
1164
|
+
sendLoopHistoryFetch(loopID) {
|
|
1165
|
+
return this.sendMessage(requestEnvelope("loop_history_fetch", { loop_id: loopID }));
|
|
1166
|
+
}
|
|
1167
|
+
/** Requests MCP server status. */
|
|
1168
|
+
sendMCPStatus() {
|
|
1169
|
+
return this.sendMessage(requestEnvelope("mcp_status", {}));
|
|
1170
|
+
}
|
|
1171
|
+
/** Requests daemon config reload. */
|
|
1172
|
+
sendConfigReload() {
|
|
1173
|
+
return this.sendMessage(requestEnvelope("config_reload", {}));
|
|
1174
|
+
}
|
|
1175
|
+
/** Submits credentials for daemon-side authentication. */
|
|
1176
|
+
sendAuth(accessKey, secretKey) {
|
|
1177
|
+
return this.sendMessage(
|
|
1178
|
+
requestEnvelope("auth", { access_key: accessKey, secret_key: secretKey })
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
/** Refreshes the daemon-side auth token. */
|
|
1182
|
+
sendAuthRefresh(refreshToken) {
|
|
1183
|
+
return this.sendMessage(requestEnvelope("auth_refresh", { refresh_token: refreshToken }));
|
|
1184
|
+
}
|
|
1185
|
+
/** Requests persisted messages and waits for response. */
|
|
1186
|
+
getLoopMessages(loopID, limit, offset, includeEvents, timeout) {
|
|
1187
|
+
const params = { loop_id: loopID };
|
|
1188
|
+
if (limit !== void 0) params.limit = limit;
|
|
1189
|
+
if (offset !== void 0) params.offset = offset;
|
|
1190
|
+
if (includeEvents) params.include_events = true;
|
|
1191
|
+
return this.requestResponse("loop_messages", params, "loop_messages", timeout ?? 15e3);
|
|
1192
|
+
}
|
|
1193
|
+
/** Requests loop state and waits for response. */
|
|
1194
|
+
getLoopState(loopID, timeout) {
|
|
1195
|
+
return this.requestResponse(
|
|
1196
|
+
"loop_state_get",
|
|
1197
|
+
{ loop_id: loopID },
|
|
1198
|
+
"loop_state_get",
|
|
1199
|
+
timeout ?? 15e3
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
/** Updates loop state and waits for response. */
|
|
1203
|
+
updateLoopState(loopID, values, asNode, timeout) {
|
|
1204
|
+
const params = { loop_id: loopID, values };
|
|
1205
|
+
if (asNode) params.as_node = asNode;
|
|
1206
|
+
return this.requestResponse(
|
|
1207
|
+
"loop_state_update",
|
|
1208
|
+
params,
|
|
1209
|
+
"loop_state_update",
|
|
1210
|
+
timeout ?? 15e3
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
/** Requests display cards and waits for response. */
|
|
1214
|
+
fetchLoopCards(loopID, timeout) {
|
|
1215
|
+
return this.requestResponse(
|
|
1216
|
+
"loop_cards_fetch",
|
|
1217
|
+
{ loop_id: loopID },
|
|
1218
|
+
"loop_cards_fetch",
|
|
1219
|
+
timeout ?? 15e3
|
|
1220
|
+
);
|
|
1221
|
+
}
|
|
1222
|
+
/** Requests MCP status and waits for response. */
|
|
1223
|
+
getMCPStatus(timeout) {
|
|
1224
|
+
return this.requestResponse("mcp_status", {}, "mcp_status", timeout ?? 15e3);
|
|
1225
|
+
}
|
|
1226
|
+
/** Requests loop history and waits for response. */
|
|
1227
|
+
fetchLoopHistory(loopID, timeout) {
|
|
1228
|
+
return this.requestResponse(
|
|
1229
|
+
"loop_history_fetch",
|
|
1230
|
+
{ loop_id: loopID },
|
|
1231
|
+
"loop_history_fetch",
|
|
1232
|
+
timeout ?? 15e3
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
/** Requests daemon config reload and waits for response. */
|
|
1236
|
+
reloadConfig(timeout) {
|
|
1237
|
+
return this.requestResponse("config_reload", {}, "config_reload", timeout ?? 15e3);
|
|
1238
|
+
}
|
|
1239
|
+
/** Submits credentials for daemon-side authentication and waits for response. */
|
|
1240
|
+
authenticate(accessKey, secretKey, timeout) {
|
|
1241
|
+
return this.requestResponse(
|
|
1242
|
+
"auth",
|
|
1243
|
+
{ access_key: accessKey, secret_key: secretKey },
|
|
1244
|
+
"auth",
|
|
1245
|
+
timeout ?? 15e3
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
1248
|
+
/** Refreshes the daemon-side auth token and waits for response. */
|
|
1249
|
+
refreshAuthToken(refreshToken, timeout) {
|
|
1250
|
+
return this.requestResponse(
|
|
1251
|
+
"auth_refresh",
|
|
1252
|
+
{ refresh_token: refreshToken },
|
|
1253
|
+
"auth_refresh",
|
|
1254
|
+
timeout ?? 15e3
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
// ---------------------------------------------------------------------------
|
|
1258
|
+
// RFC-228 Job IPC methods
|
|
1259
|
+
// ---------------------------------------------------------------------------
|
|
1260
|
+
/** Creates an autopilot job and waits for the response. */
|
|
1261
|
+
createJob(goal, verificationRules, workspace, timeout) {
|
|
1262
|
+
const params = { goal };
|
|
1263
|
+
if (verificationRules) params.verification_rules = verificationRules;
|
|
1264
|
+
if (workspace) params.workspace = workspace;
|
|
1265
|
+
return this.requestResponse("job_create", params, "job_create", timeout ?? 15e3);
|
|
1266
|
+
}
|
|
1267
|
+
/** Queries job status and waits for the response. */
|
|
1268
|
+
getJobStatus(jobId, timeout) {
|
|
1269
|
+
return this.requestResponse("job_status", { job_id: jobId }, "job_status", timeout ?? 15e3);
|
|
1270
|
+
}
|
|
1271
|
+
/** Pauses a running job. */
|
|
1272
|
+
pauseJob(jobId, timeout) {
|
|
1273
|
+
return this.requestResponse("job_pause", { job_id: jobId }, "job_pause", timeout ?? 15e3);
|
|
1274
|
+
}
|
|
1275
|
+
/** Resumes a paused job. */
|
|
1276
|
+
resumeJob(jobId, timeout) {
|
|
1277
|
+
return this.requestResponse("job_resume", { job_id: jobId }, "job_resume", timeout ?? 15e3);
|
|
1278
|
+
}
|
|
1279
|
+
/** Cancels a job. */
|
|
1280
|
+
cancelJob(jobId, timeout) {
|
|
1281
|
+
return this.requestResponse("job_cancel", { job_id: jobId }, "job_cancel", timeout ?? 15e3);
|
|
1282
|
+
}
|
|
1283
|
+
/** Requests the DAG visualization for a job. */
|
|
1284
|
+
getJobDag(jobId, timeout) {
|
|
1285
|
+
return this.requestResponse("job_dag", { job_id: jobId }, "job_dag", timeout ?? 15e3);
|
|
1286
|
+
}
|
|
1287
|
+
/** Sends guidance to a job or specific goal. */
|
|
1288
|
+
sendJobGuidance(jobId, text, goalId, timeout) {
|
|
1289
|
+
const params = { job_id: jobId, content: text };
|
|
1290
|
+
if (goalId) params.goal_id = goalId;
|
|
1291
|
+
return this.requestResponse("job_guidance", params, "job_guidance", timeout ?? 3e4);
|
|
1292
|
+
}
|
|
1293
|
+
/** Subscribes to autopilot worker events. */
|
|
1294
|
+
autopilotSubscribe(timeout) {
|
|
1295
|
+
return this.subscribe("autopilot_events", {}, timeout ?? 15e3);
|
|
1296
|
+
}
|
|
1297
|
+
/** Unsubscribes from autopilot worker events. */
|
|
1298
|
+
autopilotUnsubscribe(timeout) {
|
|
1299
|
+
const req = unsubscribeEnvelope(newRequestID());
|
|
1300
|
+
return this._requestResponseForEnvelope(req, "autopilot_unsubscribe", timeout ?? 15e3);
|
|
1301
|
+
}
|
|
1302
|
+
// ---------------------------------------------------------------------------
|
|
1303
|
+
// RFC-229 Cron IPC methods
|
|
1304
|
+
// ---------------------------------------------------------------------------
|
|
1305
|
+
/** Creates a scheduled job from natural language. */
|
|
1306
|
+
cronAdd(text, priority, timeout) {
|
|
1307
|
+
const params = { text };
|
|
1308
|
+
if (priority !== void 0) params.priority = priority;
|
|
1309
|
+
return this.requestResponse(
|
|
1310
|
+
"cron_add",
|
|
1311
|
+
params,
|
|
1312
|
+
"cron_add",
|
|
1313
|
+
timeout ?? 3e4
|
|
1314
|
+
// Longer timeout for NL extraction
|
|
1315
|
+
);
|
|
1316
|
+
}
|
|
1317
|
+
/** Lists scheduled jobs. */
|
|
1318
|
+
cronList(status, timeout) {
|
|
1319
|
+
const params = {};
|
|
1320
|
+
if (status !== void 0) params.status = status;
|
|
1321
|
+
return this.requestResponse("cron_list", params, "cron_list", timeout ?? 15e3);
|
|
1322
|
+
}
|
|
1323
|
+
/** Shows a specific scheduled job. */
|
|
1324
|
+
cronShow(jobId, timeout) {
|
|
1325
|
+
return this.requestResponse("cron_show", { job_id: jobId }, "cron_show", timeout ?? 15e3);
|
|
1326
|
+
}
|
|
1327
|
+
/** Cancels a scheduled job. */
|
|
1328
|
+
cronCancel(jobId, timeout) {
|
|
1329
|
+
return this.requestResponse("cron_cancel", { job_id: jobId }, "cron_cancel", timeout ?? 15e3);
|
|
1330
|
+
}
|
|
1331
|
+
// ---------------------------------------------------------------------------
|
|
1332
|
+
// Wait helpers
|
|
1333
|
+
/**
|
|
1334
|
+
* Waits for the connection_ack to report readiness (already done in
|
|
1335
|
+
* connect(); kept for callers that reconnect manually). Resolves
|
|
1336
|
+
* immediately if the handshake is already complete.
|
|
1337
|
+
*/
|
|
1338
|
+
async waitForDaemonReady(timeout) {
|
|
1339
|
+
if (this.handshakeComplete) {
|
|
1340
|
+
return { readiness_state: this.readinessState ?? "ready" };
|
|
1341
|
+
}
|
|
1342
|
+
const t = timeout ?? 1e4;
|
|
1343
|
+
const deadline = Date.now() + t;
|
|
1344
|
+
while (Date.now() < deadline) {
|
|
1345
|
+
const remaining = deadline - Date.now();
|
|
1346
|
+
if (remaining <= 0) break;
|
|
1347
|
+
const ev = await this.readEventWithTimeout(remaining);
|
|
1348
|
+
if (ev === null) break;
|
|
1349
|
+
if (ev.type !== "connection_ack") continue;
|
|
1350
|
+
const result = ev.result ?? {};
|
|
1351
|
+
const state = result.readiness_state;
|
|
1352
|
+
if (state === "ready") return ev;
|
|
1353
|
+
throw new Error(`daemon not ready: state=${state ?? "unknown"}`);
|
|
1354
|
+
}
|
|
1355
|
+
throw new Error(`timeout after ${t}ms waiting for connection_ack`);
|
|
1356
|
+
}
|
|
1357
|
+
};
|
|
1358
|
+
|
|
1359
|
+
export {
|
|
1360
|
+
ConnectionError,
|
|
1361
|
+
DaemonError,
|
|
1362
|
+
TimeoutError,
|
|
1363
|
+
DisconnectCause,
|
|
1364
|
+
disconnectCauseName,
|
|
1365
|
+
ReconnectError,
|
|
1366
|
+
StaleLoopError,
|
|
1367
|
+
defaultConfig,
|
|
1368
|
+
loadConfigFromEnv,
|
|
1369
|
+
PROTO_VERSION,
|
|
1370
|
+
DEFAULT_CLIENT_CAPABILITIES,
|
|
1371
|
+
CLIENT_VERSION,
|
|
1372
|
+
encodeMessage,
|
|
1373
|
+
decodeMessage,
|
|
1374
|
+
requestEnvelope,
|
|
1375
|
+
notificationEnvelope,
|
|
1376
|
+
subscribeEnvelope,
|
|
1377
|
+
unsubscribeEnvelope,
|
|
1378
|
+
connectionInitEnvelope,
|
|
1379
|
+
pingEnvelope,
|
|
1380
|
+
pongEnvelope,
|
|
1381
|
+
disconnectEnvelope,
|
|
1382
|
+
splitWirePayload,
|
|
1383
|
+
extractSootheLoopID,
|
|
1384
|
+
newRequestID,
|
|
1385
|
+
newLoopInputMessage,
|
|
1386
|
+
newLoopNewMessage,
|
|
1387
|
+
newLoopSubscribeMessage,
|
|
1388
|
+
INTENT_HINT_TEXT_COMPLETION,
|
|
1389
|
+
INTENT_HINT_IMAGE_TO_TEXT,
|
|
1390
|
+
INTENT_HINT_OCR,
|
|
1391
|
+
INTENT_HINT_EMBED,
|
|
1392
|
+
REMOVED_INTENT_HINTS,
|
|
1393
|
+
validateLoopInputIntentHint,
|
|
1394
|
+
LOOP_ASSISTANT_OUTPUT_PHASES,
|
|
1395
|
+
DEFAULT_DELIVERABLE_PHASES,
|
|
1396
|
+
Multiplexer,
|
|
1397
|
+
Client
|
|
1398
|
+
};
|
|
1399
|
+
//# sourceMappingURL=chunk-AQZACDIC.js.map
|