@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.
@@ -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