@clawrent/provider 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,881 @@
1
+ // src/api-client.ts
2
+ var ApiClient = class {
3
+ config;
4
+ /** When set (e.g. by ProviderAgent.start), overrides config.token for REST auth. */
5
+ agentTokenOverride = null;
6
+ constructor(config) {
7
+ this.config = config;
8
+ }
9
+ /**
10
+ * Set a token that overrides config.token for subsequent REST requests.
11
+ * Used by the in-process provider agent: once serving with an agentToken,
12
+ * provider REST calls (approve/list/end + internal autoApprove) authenticate
13
+ * as the agent owner via this token, so they work without a separate user
14
+ * JWT login. The backend's resolveAuth accepts the agt_clawrent_* prefix.
15
+ * Pass null to clear (e.g. on stop_serving).
16
+ */
17
+ setAgentToken(token) {
18
+ this.agentTokenOverride = token;
19
+ }
20
+ get apiUrl() {
21
+ return this.config.apiUrl;
22
+ }
23
+ get wsUrl() {
24
+ return this.config.wsUrl;
25
+ }
26
+ get userId() {
27
+ return this.config.userId;
28
+ }
29
+ // --- Auth (no auth needed) ---
30
+ async sendVerification(email) {
31
+ return this.request("POST", "/api/auth/send-verification", { email }, false);
32
+ }
33
+ async registerUser(input) {
34
+ return this.request("POST", "/api/auth/register", input, false);
35
+ }
36
+ async login(email, password) {
37
+ return this.request("POST", "/api/auth/login", { email, password }, false);
38
+ }
39
+ async getMe() {
40
+ return this.request("GET", "/api/auth/me");
41
+ }
42
+ // --- Marketplace ---
43
+ async browse(query) {
44
+ const params = new URLSearchParams();
45
+ if (query?.search) params.set("search", query.search);
46
+ if (query?.category) params.set("category", query.category);
47
+ if (query?.page) params.set("page", String(query.page));
48
+ if (query?.limit) params.set("limit", String(query.limit));
49
+ const qs = params.toString();
50
+ return this.request("GET", `/api/marketplace/browse${qs ? `?${qs}` : ""}`);
51
+ }
52
+ async getAgent(slug) {
53
+ return this.request("GET", `/api/marketplace/agents/${encodeURIComponent(slug)}`);
54
+ }
55
+ // --- Sessions ---
56
+ async rent(options) {
57
+ return this.request("POST", "/api/sessions", {
58
+ agentId: options.agentId,
59
+ taskDescription: options.taskDescription,
60
+ grantedPermissions: options.grantedPermissions ?? {}
61
+ });
62
+ }
63
+ async getSessions(query) {
64
+ const params = new URLSearchParams();
65
+ if (query?.role) params.set("role", query.role);
66
+ if (query?.status) params.set("status", query.status);
67
+ if (query?.page) params.set("page", String(query.page));
68
+ if (query?.limit) params.set("limit", String(query.limit));
69
+ const qs = params.toString();
70
+ return this.request("GET", `/api/sessions${qs ? `?${qs}` : ""}`);
71
+ }
72
+ async getSession(sessionId) {
73
+ return this.request("GET", `/api/sessions/${encodeURIComponent(sessionId)}`);
74
+ }
75
+ async getSessionMessages(sessionId, options) {
76
+ const params = new URLSearchParams();
77
+ if (options?.since) params.set("since", options.since);
78
+ if (options?.limit) params.set("limit", String(options.limit));
79
+ const qs = params.toString();
80
+ return this.request(
81
+ "GET",
82
+ `/api/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ""}`
83
+ );
84
+ }
85
+ /** POST a message via REST (WS fallback, e.g. session not attached after restart). */
86
+ async sendSessionMessage(sessionId, body) {
87
+ return this.request(
88
+ "POST",
89
+ `/api/sessions/${encodeURIComponent(sessionId)}/messages`,
90
+ body
91
+ );
92
+ }
93
+ async endSession(sessionId) {
94
+ return this.request("POST", `/api/sessions/${encodeURIComponent(sessionId)}/end`);
95
+ }
96
+ async approveSession(sessionId) {
97
+ return this.request("POST", `/api/sessions/${encodeURIComponent(sessionId)}/approve`);
98
+ }
99
+ // --- Billing ---
100
+ async getBalance() {
101
+ return this.request("GET", "/api/billing/wallet");
102
+ }
103
+ async topUp(amount) {
104
+ return this.request("POST", "/api/billing/wallet/topup", { amount });
105
+ }
106
+ // --- Provider: Agents ---
107
+ async registerAgent(data) {
108
+ return this.request("POST", "/api/agents", data);
109
+ }
110
+ async getMyAgents(query) {
111
+ const params = new URLSearchParams();
112
+ if (query?.page) params.set("page", String(query.page));
113
+ if (query?.limit) params.set("limit", String(query.limit));
114
+ if (query?.roles) params.set("roles", query.roles);
115
+ const qs = params.toString();
116
+ return this.request("GET", `/api/agents/my${qs ? `?${qs}` : ""}`);
117
+ }
118
+ /** Resolve the current agent from agentToken. Requires agt_clawrent_ token auth. */
119
+ async getMyAgent() {
120
+ return this.request("GET", "/api/agents/me/agent");
121
+ }
122
+ async applyProvider(agentId, data) {
123
+ return this.request("POST", `/api/agents/${encodeURIComponent(agentId)}/apply-provider`, data);
124
+ }
125
+ /** Publish agent — simplified apply-provider with defaults, submits for admin review */
126
+ async publishAgent(agentId, data) {
127
+ return this.request("POST", `/api/agents/${encodeURIComponent(agentId)}/publish`, data ?? {});
128
+ }
129
+ /** Activate agent — verify admin-approved + WebSocket connected, then go online */
130
+ async activateAgent(agentId) {
131
+ return this.request("POST", `/api/agents/${encodeURIComponent(agentId)}/activate`);
132
+ }
133
+ async setOnlineStatus(agentId, onlineStatus) {
134
+ return this.request("PATCH", `/api/agents/${encodeURIComponent(agentId)}/status`, { onlineStatus });
135
+ }
136
+ async generateAgentToken(agentId) {
137
+ return this.request("POST", `/api/agents/${encodeURIComponent(agentId)}/token`);
138
+ }
139
+ async revokeAgentToken(agentId) {
140
+ return this.request("DELETE", `/api/agents/${encodeURIComponent(agentId)}/token`);
141
+ }
142
+ // --- Orders ---
143
+ async createOrder(data) {
144
+ return this.request("POST", "/api/orders", data);
145
+ }
146
+ async getOrders(query) {
147
+ const params = new URLSearchParams();
148
+ if (query?.status) params.set("status", query.status);
149
+ if (query?.page) params.set("page", String(query.page));
150
+ if (query?.limit) params.set("limit", String(query.limit));
151
+ const qs = params.toString();
152
+ return this.request("GET", `/api/orders${qs ? `?${qs}` : ""}`);
153
+ }
154
+ async getOrder(orderId) {
155
+ return this.request("GET", `/api/orders/${encodeURIComponent(orderId)}`);
156
+ }
157
+ async cancelOrder(orderId) {
158
+ return this.request("POST", `/api/orders/${encodeURIComponent(orderId)}/cancel`);
159
+ }
160
+ // --- Cart ---
161
+ async getCart() {
162
+ return this.request("GET", "/api/cart");
163
+ }
164
+ async addToCart(data) {
165
+ return this.request("POST", "/api/cart", data);
166
+ }
167
+ async updateCartItem(itemId, data) {
168
+ return this.request("PATCH", `/api/cart/${encodeURIComponent(itemId)}`, data);
169
+ }
170
+ async removeFromCart(itemId) {
171
+ return this.request("DELETE", `/api/cart/${encodeURIComponent(itemId)}`);
172
+ }
173
+ async clearCart() {
174
+ return this.request("DELETE", "/api/cart");
175
+ }
176
+ // --- Favorites ---
177
+ async listFavorites(query) {
178
+ const params = new URLSearchParams();
179
+ if (query?.page) params.set("page", String(query.page));
180
+ if (query?.limit) params.set("limit", String(query.limit));
181
+ const qs = params.toString();
182
+ return this.request("GET", `/api/favorites${qs ? `?${qs}` : ""}`);
183
+ }
184
+ async addFavorite(agentId) {
185
+ return this.request("POST", `/api/favorites/${encodeURIComponent(agentId)}`);
186
+ }
187
+ async removeFavorite(agentId) {
188
+ return this.request("DELETE", `/api/favorites/${encodeURIComponent(agentId)}`);
189
+ }
190
+ // --- Health ---
191
+ async health() {
192
+ return this.request("GET", "/api/health", void 0, false);
193
+ }
194
+ // --- Docs (MCP interface) ---
195
+ async getDocsTree() {
196
+ return this.request("GET", "/api/mcp/docs/tree", void 0, false);
197
+ }
198
+ async getDocByPath(path) {
199
+ return this.request("GET", `/api/mcp/docs/by-path/${encodeURI(path)}`, void 0, false);
200
+ }
201
+ async searchDocs(query) {
202
+ const params = new URLSearchParams({ q: query });
203
+ return this.request("GET", `/api/mcp/docs/search?${params.toString()}`, void 0, false);
204
+ }
205
+ async getDoc(id) {
206
+ return this.request("GET", `/api/mcp/docs/${encodeURIComponent(id)}`, void 0, false);
207
+ }
208
+ async createDoc(data) {
209
+ return this.request("POST", "/api/mcp/docs", data);
210
+ }
211
+ async updateDoc(id, data) {
212
+ return this.request("PATCH", `/api/mcp/docs/${encodeURIComponent(id)}`, data);
213
+ }
214
+ async deleteDoc(id) {
215
+ return this.request("DELETE", `/api/mcp/docs/${encodeURIComponent(id)}`);
216
+ }
217
+ async publishDoc(id) {
218
+ return this.request("POST", `/api/mcp/docs/${encodeURIComponent(id)}/publish`);
219
+ }
220
+ async unpublishDoc(id) {
221
+ return this.request("POST", `/api/mcp/docs/${encodeURIComponent(id)}/unpublish`);
222
+ }
223
+ // --- Private ---
224
+ async request(method, path, body, requireAuth = true) {
225
+ const url = `${this.config.apiUrl}${path}`;
226
+ const headers = {};
227
+ if (body !== void 0) {
228
+ headers["Content-Type"] = "application/json";
229
+ }
230
+ if (requireAuth) {
231
+ if (this.agentTokenOverride) {
232
+ headers["Authorization"] = `Bearer ${this.agentTokenOverride}`;
233
+ } else if (this.config.token) {
234
+ headers["Authorization"] = `Bearer ${this.config.token}`;
235
+ } else if (this.config.apiKey) {
236
+ headers["x-api-key"] = this.config.apiKey;
237
+ } else {
238
+ throw new Error("Not authenticated. Run `clawrent auth login` first.");
239
+ }
240
+ }
241
+ const response = await fetch(url, {
242
+ method,
243
+ headers,
244
+ body: body ? JSON.stringify(body) : void 0
245
+ });
246
+ if (!response.ok) {
247
+ const errBody = await response.json().catch(() => ({ message: response.statusText }));
248
+ throw new Error(`API error ${response.status}: ${errBody["message"] ?? JSON.stringify(errBody)}`);
249
+ }
250
+ return response.json();
251
+ }
252
+ };
253
+
254
+ // src/config.ts
255
+ import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync } from "fs";
256
+ import { join } from "path";
257
+ import { homedir } from "os";
258
+ var DEFAULT_CONFIG = {
259
+ apiUrl: "https://clawrent.cloud",
260
+ wsUrl: "wss://clawrent.cloud"
261
+ };
262
+ function getConfigDir() {
263
+ return join(homedir(), ".clawrent");
264
+ }
265
+ function getConfigFilePath() {
266
+ return join(getConfigDir(), "config.json");
267
+ }
268
+ function getConfigPath() {
269
+ return getConfigFilePath();
270
+ }
271
+ function loadConfig() {
272
+ let fileConfig = {};
273
+ const configPath = getConfigFilePath();
274
+ if (existsSync(configPath)) {
275
+ try {
276
+ const raw = readFileSync(configPath, "utf-8");
277
+ fileConfig = JSON.parse(raw);
278
+ } catch {
279
+ }
280
+ }
281
+ if (fileConfig.apiUrl?.includes("localhost") || fileConfig.wsUrl?.includes("localhost")) {
282
+ delete fileConfig.apiUrl;
283
+ delete fileConfig.wsUrl;
284
+ try {
285
+ const cleaned = { ...fileConfig };
286
+ writeFileSync(configPath, JSON.stringify(cleaned, null, 2), "utf-8");
287
+ } catch {
288
+ }
289
+ }
290
+ const config = {
291
+ ...DEFAULT_CONFIG,
292
+ ...fileConfig
293
+ };
294
+ const envApiUrl = process.env["CLAWRENT_API_URL"];
295
+ const envWsUrl = process.env["CLAWRENT_WS_URL"];
296
+ const envToken = process.env["CLAWRENT_TOKEN"];
297
+ const envApiKey = process.env["CLAWRENT_API_KEY"];
298
+ const envUserId = process.env["CLAWRENT_USER_ID"];
299
+ if (envApiUrl) config.apiUrl = envApiUrl;
300
+ if (envWsUrl) config.wsUrl = envWsUrl;
301
+ if (envToken) config.token = envToken;
302
+ if (envApiKey) config.apiKey = envApiKey;
303
+ if (envUserId) config.userId = envUserId;
304
+ return config;
305
+ }
306
+ function saveConfig(partial) {
307
+ const configDir = getConfigDir();
308
+ if (!existsSync(configDir)) {
309
+ mkdirSync(configDir, { recursive: true });
310
+ }
311
+ let existing = {};
312
+ const configPath = getConfigFilePath();
313
+ if (existsSync(configPath)) {
314
+ try {
315
+ existing = JSON.parse(readFileSync(configPath, "utf-8"));
316
+ } catch {
317
+ }
318
+ }
319
+ const merged = { ...existing, ...partial };
320
+ writeFileSync(configPath, JSON.stringify(merged, null, 2), "utf-8");
321
+ }
322
+ function clearConfig() {
323
+ const configPath = getConfigFilePath();
324
+ if (existsSync(configPath)) {
325
+ unlinkSync(configPath);
326
+ }
327
+ }
328
+
329
+ // src/session-manager.ts
330
+ import { EventEmitter } from "events";
331
+ import WebSocket from "ws";
332
+ var TERMINAL_CLOSE_CODES = /* @__PURE__ */ new Set([4e3, 4001, 4002, 4003, 4004]);
333
+ var SessionManager = class extends EventEmitter {
334
+ sessions = /* @__PURE__ */ new Map();
335
+ wsUrl;
336
+ heartbeatInterval;
337
+ maxReconnectDelay;
338
+ maxReconnectAttempts;
339
+ agentId;
340
+ constructor(wsUrl, heartbeatInterval = 25e3, maxReconnectDelay = 3e4, maxReconnectAttempts = 5) {
341
+ super();
342
+ this.wsUrl = wsUrl;
343
+ this.heartbeatInterval = heartbeatInterval;
344
+ this.maxReconnectDelay = maxReconnectDelay;
345
+ this.maxReconnectAttempts = maxReconnectAttempts;
346
+ }
347
+ /** Connect to a session via WebSocket as provider */
348
+ connect(sessionId, sessionToken) {
349
+ if (this.sessions.has(sessionId)) {
350
+ return;
351
+ }
352
+ const url = `${this.wsUrl}/ws/session?sessionId=${sessionId}&token=${sessionToken}&role=provider`;
353
+ const ws = new WebSocket(url);
354
+ const conn = {
355
+ sessionId,
356
+ sessionToken,
357
+ ws,
358
+ heartbeatTimer: null,
359
+ reconnectAttempts: 0
360
+ };
361
+ this.sessions.set(sessionId, conn);
362
+ ws.on("open", () => {
363
+ conn.reconnectAttempts = 0;
364
+ conn.heartbeatTimer = setInterval(() => {
365
+ if (ws.readyState === WebSocket.OPEN) {
366
+ ws.send(JSON.stringify({ type: "system.heartbeat", payload: {} }));
367
+ }
368
+ }, this.heartbeatInterval);
369
+ this.emit("session:connected", sessionId);
370
+ });
371
+ ws.on("message", (raw) => {
372
+ try {
373
+ const message = JSON.parse(raw.toString());
374
+ if (message["type"] === "system.heartbeat_ack") return;
375
+ this.emit("session:message", sessionId, message);
376
+ } catch {
377
+ }
378
+ });
379
+ ws.on("close", (code, reason) => {
380
+ this.clearHeartbeat(conn);
381
+ if (TERMINAL_CLOSE_CODES.has(code)) {
382
+ this.sessions.delete(sessionId);
383
+ this.emit("session:dead", sessionId, `session rejected (code ${code}: ${reason.toString()})`);
384
+ return;
385
+ }
386
+ if (code !== 1e3) {
387
+ if (conn.reconnectAttempts >= this.maxReconnectAttempts) {
388
+ this.sessions.delete(sessionId);
389
+ this.emit("session:dead", sessionId, `max reconnect attempts (${this.maxReconnectAttempts}) reached`);
390
+ return;
391
+ }
392
+ const delay = Math.min(
393
+ 1e3 * Math.pow(2, conn.reconnectAttempts),
394
+ this.maxReconnectDelay
395
+ );
396
+ conn.reconnectAttempts++;
397
+ this.emit("session:reconnecting", sessionId, delay);
398
+ setTimeout(() => {
399
+ this.sessions.delete(sessionId);
400
+ this.connect(sessionId, sessionToken);
401
+ }, delay);
402
+ } else {
403
+ this.sessions.delete(sessionId);
404
+ this.emit("session:disconnected", sessionId, reason.toString());
405
+ }
406
+ });
407
+ ws.on("error", (err) => {
408
+ this.emit("session:error", sessionId, err);
409
+ });
410
+ }
411
+ /** Send a message to a specific session, auto-wrapping with protocol envelope */
412
+ send(sessionId, message) {
413
+ const conn = this.sessions.get(sessionId);
414
+ if (!conn || conn.ws.readyState !== WebSocket.OPEN) {
415
+ return false;
416
+ }
417
+ const envelope = {
418
+ id: message["id"] ?? `msg_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
419
+ sessionId: message["sessionId"] ?? sessionId,
420
+ timestamp: message["timestamp"] ?? Date.now(),
421
+ sender: message["sender"] ?? { role: "provider", agentId: this.agentId ?? "unknown" },
422
+ type: message["type"] ?? "result.success",
423
+ payload: message["payload"] ?? {}
424
+ };
425
+ conn.ws.send(JSON.stringify(envelope));
426
+ return true;
427
+ }
428
+ /** Disconnect a specific session */
429
+ disconnect(sessionId) {
430
+ const conn = this.sessions.get(sessionId);
431
+ if (!conn) return;
432
+ this.clearHeartbeat(conn);
433
+ if (conn.ws.readyState === WebSocket.OPEN || conn.ws.readyState === WebSocket.CONNECTING) {
434
+ conn.ws.close(1e3, "Provider disconnecting");
435
+ }
436
+ this.sessions.delete(sessionId);
437
+ }
438
+ /** Disconnect all sessions */
439
+ disconnectAll() {
440
+ for (const sessionId of this.sessions.keys()) {
441
+ this.disconnect(sessionId);
442
+ }
443
+ }
444
+ /** Get active session count */
445
+ get activeCount() {
446
+ return this.sessions.size;
447
+ }
448
+ /** Check if a session is connected */
449
+ isConnected(sessionId) {
450
+ const conn = this.sessions.get(sessionId);
451
+ return conn?.ws.readyState === WebSocket.OPEN;
452
+ }
453
+ clearHeartbeat(conn) {
454
+ if (conn.heartbeatTimer) {
455
+ clearInterval(conn.heartbeatTimer);
456
+ conn.heartbeatTimer = null;
457
+ }
458
+ }
459
+ };
460
+
461
+ // src/cursor.ts
462
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as existsSync2 } from "fs";
463
+ import { dirname } from "path";
464
+ var InMemoryCursorStore = class {
465
+ map = /* @__PURE__ */ new Map();
466
+ get(sessionId) {
467
+ return this.map.get(sessionId) ?? null;
468
+ }
469
+ set(sessionId, createdAt) {
470
+ const prev = this.map.get(sessionId);
471
+ if (prev === void 0 || createdAt > prev) this.map.set(sessionId, createdAt);
472
+ }
473
+ };
474
+ var FileCursorStore = class {
475
+ constructor(path) {
476
+ this.path = path;
477
+ }
478
+ path;
479
+ cache = null;
480
+ load() {
481
+ if (this.cache) return this.cache;
482
+ if (!existsSync2(this.path)) {
483
+ this.cache = /* @__PURE__ */ new Map();
484
+ return this.cache;
485
+ }
486
+ const raw = readFileSync2(this.path, "utf8");
487
+ try {
488
+ this.cache = new Map(Object.entries(JSON.parse(raw)));
489
+ } catch {
490
+ this.cache = /* @__PURE__ */ new Map();
491
+ }
492
+ return this.cache;
493
+ }
494
+ persist() {
495
+ if (!this.cache) return;
496
+ mkdirSync2(dirname(this.path), { recursive: true });
497
+ writeFileSync2(this.path, JSON.stringify(Object.fromEntries(this.cache), null, 2), "utf8");
498
+ }
499
+ get(sessionId) {
500
+ return this.load().get(sessionId) ?? null;
501
+ }
502
+ set(sessionId, createdAt) {
503
+ const m = this.load();
504
+ const prev = m.get(sessionId);
505
+ if (prev === void 0 || createdAt > prev) {
506
+ m.set(sessionId, createdAt);
507
+ this.persist();
508
+ }
509
+ }
510
+ };
511
+
512
+ // src/helpers.ts
513
+ async function resumeActiveSessions(client, sessionManager) {
514
+ const res = await client.getSessions({ role: "provider", status: "active" });
515
+ const list = res?.data ?? [];
516
+ const attached = [];
517
+ for (const s of list) {
518
+ const sessionId = s["sessionId"] ?? s["id"];
519
+ const sessionToken = s["sessionToken"];
520
+ if (typeof sessionId !== "string" || typeof sessionToken !== "string") continue;
521
+ attached.push({
522
+ sessionId,
523
+ sessionToken,
524
+ taskDescription: typeof s["taskDescription"] === "string" ? s["taskDescription"] : void 0,
525
+ consumerUserId: typeof s["consumerUserId"] === "string" ? s["consumerUserId"] : void 0,
526
+ slotIndex: typeof s["slotIndex"] === "number" ? s["slotIndex"] : void 0
527
+ });
528
+ sessionManager.connect(sessionId, sessionToken);
529
+ }
530
+ return attached;
531
+ }
532
+ function diffSessionStates(prev, curr) {
533
+ const prevMap = new Map(prev.map((s) => [s.sessionId, s]));
534
+ const currMap = new Map(curr.map((s) => [s.sessionId, s]));
535
+ const newPending = [];
536
+ const activated = [];
537
+ const ended = [];
538
+ for (const [id, c] of currMap) {
539
+ const p = prevMap.get(id);
540
+ if (!p) {
541
+ if (c.status === "pending_approval") newPending.push(c);
542
+ } else if (p.status === "pending_approval" && c.status === "active") {
543
+ activated.push(c);
544
+ }
545
+ }
546
+ for (const [id, p] of prevMap) {
547
+ if (!currMap.has(id)) ended.push(p);
548
+ }
549
+ return { newPending, activated, ended };
550
+ }
551
+
552
+ // src/provider-client.ts
553
+ import { EventEmitter as EventEmitter2 } from "events";
554
+ import WebSocket2 from "ws";
555
+ import { wsAgentControlEventSchema } from "@clawrent/protocol";
556
+ var ProviderClient = class extends EventEmitter2 {
557
+ client;
558
+ cursor;
559
+ heartbeatIntervalMs;
560
+ maxReconnectAttempts;
561
+ autoApprove;
562
+ agentToken;
563
+ agentId = null;
564
+ agentWs = null;
565
+ sessionManager = null;
566
+ heartbeatTimer = null;
567
+ _running = false;
568
+ activeSessions = /* @__PURE__ */ new Map();
569
+ /**
570
+ * Per-session in-flight promise chain: each `session:message` for the same
571
+ * sessionId is chained onto the previous one so calls serialize. Without this,
572
+ * two socket frames arriving in the same tick would both pass the cursor
573
+ * dedupe check (the first call's `cursor.set` is queued behind its `await`
574
+ * and hasn't run when the second call reads the cursor) and onMessage would
575
+ * fire twice. The Map entry is cleared when the tail settles so it doesn't
576
+ * grow unbounded.
577
+ */
578
+ inflight = /* @__PURE__ */ new Map();
579
+ /** Callbacks captured at start(); used by handleAgentMessage/handleSessionMessage. */
580
+ boundCallbacks = null;
581
+ constructor(opts) {
582
+ super();
583
+ const config = {
584
+ apiUrl: opts.apiUrl ?? "https://clawrent.cloud",
585
+ wsUrl: opts.wsUrl ?? "wss://clawrent.cloud"
586
+ };
587
+ this.client = new ApiClient(config);
588
+ this.client.setAgentToken(opts.agentToken);
589
+ this.agentToken = opts.agentToken;
590
+ this.cursor = opts.cursorStore ?? new InMemoryCursorStore();
591
+ this.heartbeatIntervalMs = opts.heartbeatIntervalMs ?? 25e3;
592
+ this.maxReconnectAttempts = opts.maxReconnectAttempts ?? 5;
593
+ this.autoApprove = opts.autoApprove ?? true;
594
+ }
595
+ get running() {
596
+ return this._running;
597
+ }
598
+ get currentAgentId() {
599
+ return this.agentId;
600
+ }
601
+ /** Cursor store used for per-session message dedupe (wired in Task 6b). */
602
+ get cursorStore() {
603
+ return this.cursor;
604
+ }
605
+ /** Whether sessions are auto-approved on arrival (wired in Task 6b). */
606
+ get isAutoApprove() {
607
+ return this.autoApprove;
608
+ }
609
+ async start(callbacks) {
610
+ if (this._running) throw new Error("ProviderClient already started");
611
+ if (callbacks.agentId) {
612
+ this.agentId = callbacks.agentId;
613
+ } else {
614
+ const me = await this.client.getMyAgent();
615
+ this.agentId = me["id"] ?? me["agentId"] ?? null;
616
+ }
617
+ if (!this.agentId) throw new Error("Could not resolve agentId (pass callbacks.agentId or ensure token is valid)");
618
+ this.sessionManager = new SessionManager(this.client.wsUrl, this.heartbeatIntervalMs, 3e4, this.maxReconnectAttempts);
619
+ this.sessionManager.agentId = this.agentId;
620
+ this.bindSessionManager(callbacks);
621
+ await this.connectAgent();
622
+ try {
623
+ await this.client.activateAgent(this.agentId);
624
+ } catch (err) {
625
+ this.emit("agent:warning", `activation failed: ${err.message}`);
626
+ }
627
+ this._running = true;
628
+ this.emit("agent:started", this.agentId);
629
+ void this.resumeActive(callbacks);
630
+ }
631
+ bindSessionManager(callbacks) {
632
+ this.boundCallbacks = callbacks;
633
+ const sm = this.sessionManager;
634
+ if (!sm) return;
635
+ sm.on("session:connected", (sid) => this.emit("session:connected", sid));
636
+ sm.on("session:disconnected", (sid, reason) => this.emit("session:disconnected", sid, reason));
637
+ sm.on("session:message", (sid, message) => {
638
+ const prev = this.inflight.get(sid) ?? Promise.resolve();
639
+ const next = prev.then(() => this.handleSessionMessage(sid, message)).catch((err) => {
640
+ this.emit("session:error", sid, err);
641
+ });
642
+ this.inflight.set(sid, next);
643
+ next.finally(() => {
644
+ if (this.inflight.get(sid) === next) this.inflight.delete(sid);
645
+ });
646
+ });
647
+ sm.on("session:dead", (sid, reason) => {
648
+ const active = this.activeSessions.get(sid) ?? { sessionId: sid, sessionToken: "" };
649
+ this.activeSessions.delete(sid);
650
+ callbacks.onSessionEnded?.(active, reason);
651
+ });
652
+ }
653
+ async resumeActive(callbacks) {
654
+ if (!this.sessionManager) return;
655
+ try {
656
+ const sessions = await resumeActiveSessions(this.client, this.sessionManager);
657
+ for (const s of sessions) {
658
+ this.activeSessions.set(s.sessionId, s);
659
+ callbacks.onSessionNew?.(s);
660
+ this.emit("session:reattached", s.sessionId);
661
+ }
662
+ } catch (err) {
663
+ this.emit("agent:warning", `resume failed: ${err.message}`);
664
+ }
665
+ }
666
+ connectAgent() {
667
+ return new Promise((resolve, reject) => {
668
+ const url = `${this.client.wsUrl}/ws/agent?token=${encodeURIComponent(this.agentToken)}`;
669
+ const ws = new WebSocket2(url);
670
+ this.agentWs = ws;
671
+ ws.on("open", () => {
672
+ this.heartbeatTimer = setInterval(() => {
673
+ if (ws.readyState === WebSocket2.OPEN) {
674
+ ws.send(JSON.stringify({ type: "system.heartbeat", payload: {} }));
675
+ }
676
+ }, this.heartbeatIntervalMs);
677
+ this.emit("agent:connected");
678
+ resolve();
679
+ });
680
+ ws.on("error", reject);
681
+ ws.on("close", (code, reason) => {
682
+ this.emit("agent:disconnected", code, reason.toString());
683
+ });
684
+ ws.on("message", (raw) => {
685
+ void this.handleAgentMessage(raw).catch((err) => {
686
+ this.emit("agent:warning", `agent message handler failed: ${err.message}`);
687
+ });
688
+ });
689
+ });
690
+ }
691
+ /**
692
+ * Parses an inbound /ws/agent frame and routes it.
693
+ *
694
+ * `session.new` / `session.approved` are validated against the protocol
695
+ * discriminated-union schema (fields under `payload`). Other /ws/agent frame
696
+ * types pushed by the backend (agent.connected / agent.status_updated /
697
+ * system.error / system.heartbeat_ack — see apps/platform-api ws-agent-handler.ts)
698
+ * are NOT in wsAgentControlEventSchema, so we route them by `type` directly
699
+ * instead of forcing them through the schema (which would reject them).
700
+ * Unknown types are ignored gracefully.
701
+ *
702
+ * Note: backend does NOT push `session.ended` on /ws/agent; session
703
+ * terminations arrive as `system.session_ended` on /ws/session.
704
+ */
705
+ async handleAgentMessage(raw) {
706
+ let frame;
707
+ try {
708
+ frame = JSON.parse(raw.toString());
709
+ } catch {
710
+ return;
711
+ }
712
+ if (typeof frame !== "object" || frame === null) return;
713
+ const type = frame["type"];
714
+ if (type === "session.new" || type === "session.approved") {
715
+ let parsed;
716
+ try {
717
+ parsed = wsAgentControlEventSchema.parse(frame);
718
+ } catch {
719
+ return;
720
+ }
721
+ if (parsed.type === "session.new") {
722
+ await this.onSessionNew(parsed.payload);
723
+ } else {
724
+ this.onSessionApproved(parsed.payload);
725
+ }
726
+ return;
727
+ }
728
+ const payload = frame["payload"];
729
+ switch (type) {
730
+ case "system.heartbeat_ack":
731
+ return;
732
+ // heartbeat acknowledgement — ignore
733
+ case "system.error":
734
+ this.emit("agent:warning", `server error: ${JSON.stringify(payload) ?? "no payload"}`);
735
+ return;
736
+ case "agent.connected":
737
+ this.emit("agent:connected", payload);
738
+ return;
739
+ case "agent.status_updated":
740
+ this.emit("agent:status", payload);
741
+ return;
742
+ default:
743
+ return;
744
+ }
745
+ }
746
+ /** session.new: register, notify host, optionally auto-approve + connect /ws/session. */
747
+ async onSessionNew(payload) {
748
+ const sessionId = payload.sessionId;
749
+ const sessionToken = payload.sessionToken;
750
+ const active = {
751
+ sessionId,
752
+ sessionToken: sessionToken ?? "",
753
+ taskDescription: payload.taskDescription,
754
+ consumerUserId: payload.consumerUserId
755
+ };
756
+ this.activeSessions.set(sessionId, active);
757
+ this.boundCallbacks?.onSessionNew?.(active);
758
+ this.emit("session:new", active);
759
+ const shouldApprove = this.autoApprove ? true : this.boundCallbacks?.onPendingApproval ? await this.boundCallbacks.onPendingApproval(active) : false;
760
+ if (!shouldApprove) return;
761
+ if (this.autoApprove) {
762
+ try {
763
+ await this.client.approveSession(sessionId);
764
+ } catch (err) {
765
+ this.emit("agent:warning", `approve failed for ${sessionId}: ${err.message}`);
766
+ return;
767
+ }
768
+ }
769
+ if (sessionToken) {
770
+ this.sessionManager?.connect(sessionId, sessionToken);
771
+ } else {
772
+ this.emit("agent:warning", `session.new for ${sessionId} carried no sessionToken; deferring /ws/session connect until session.approved`);
773
+ }
774
+ }
775
+ /** session.approved: ensure tracked + connect /ws/session (idempotent). */
776
+ onSessionApproved(payload) {
777
+ const sessionId = payload.sessionId;
778
+ const sessionToken = payload.sessionToken;
779
+ const existing = this.activeSessions.get(sessionId);
780
+ const active = existing ?? {
781
+ sessionId,
782
+ sessionToken: sessionToken ?? ""
783
+ };
784
+ if (sessionToken) active.sessionToken = sessionToken;
785
+ this.activeSessions.set(sessionId, active);
786
+ this.emit("session:approved", active);
787
+ if (sessionToken) {
788
+ this.sessionManager?.connect(sessionId, sessionToken);
789
+ }
790
+ }
791
+ /**
792
+ * Dedupes an inbound /ws/session message by per-session cursor, then invokes
793
+ * the host's onMessage callback. Public (no underscore prefix) so the
794
+ * integration test can drive the cursor/dedupe path directly as a hook.
795
+ *
796
+ * At-least-once invariant (Task 6b fix round 1): the cursor is advanced ONLY
797
+ * AFTER `await onMessage` completes successfully. If onMessage throws, the
798
+ * cursor is left un-advanced and `session:error` is emitted — so a future
799
+ * redelivery (e.g. backend replay on restart) will pass the dedupe check and
800
+ * re-process the message. onMessage MUST be idempotent precisely because of
801
+ * this redelivery semantics. The per-session in-flight chain in
802
+ * `bindSessionManager`'s `session:message` handler serializes concurrent
803
+ * frames for the same session so the cursor-after-success commit has a chance
804
+ * to run before the next frame's dedupe check reads the cursor.
805
+ *
806
+ * `createdAt` is canonical UTC ISO, taken from `_meta.timestamp` (backend
807
+ * pushes ISO `...Z`) with a `new Date(message.timestamp).toISOString()`
808
+ * fallback (ms epoch).
809
+ */
810
+ async handleSessionMessage(sessionId, message) {
811
+ const onMessage = this.boundCallbacks?.onMessage;
812
+ if (!onMessage) return;
813
+ const meta = message["_meta"];
814
+ const metaTs = meta?.timestamp;
815
+ const msgTs = message["timestamp"];
816
+ const createdAt = typeof metaTs === "string" ? metaTs : typeof msgTs === "number" ? new Date(msgTs).toISOString() : (/* @__PURE__ */ new Date()).toISOString();
817
+ const last = this.cursor.get(sessionId);
818
+ if (last && createdAt <= last) return;
819
+ const session = this.activeSessions.get(sessionId) ?? { sessionId, sessionToken: "" };
820
+ try {
821
+ await onMessage(session, message);
822
+ } catch (err) {
823
+ this.emit("session:error", sessionId, err);
824
+ return;
825
+ }
826
+ this.cursor.set(sessionId, createdAt);
827
+ }
828
+ /**
829
+ * Send an outbound message to a session. Prefers the /ws/session socket when
830
+ * it is OPEN (low latency, no HTTP overhead); falls back to the REST
831
+ * `POST /api/sessions/:id/messages` endpoint when the socket is absent or not
832
+ * yet open (e.g. before start(), during reconnect, or for sessions not yet
833
+ * attached). Returns the transport actually used so callers can observe it.
834
+ *
835
+ * Note: this method does NOT throw when WS is unavailable — it silently falls
836
+ * back to REST. REST errors propagate to the caller as-is.
837
+ */
838
+ async send(sessionId, message) {
839
+ const sm = this.sessionManager;
840
+ if (sm?.isConnected(sessionId)) {
841
+ const ok = sm.send(sessionId, message);
842
+ if (ok) return { via: "ws" };
843
+ }
844
+ await this.client.sendSessionMessage(sessionId, message);
845
+ return { via: "rest" };
846
+ }
847
+ stop() {
848
+ this._running = false;
849
+ if (this.heartbeatTimer) {
850
+ clearInterval(this.heartbeatTimer);
851
+ this.heartbeatTimer = null;
852
+ }
853
+ this.sessionManager?.disconnectAll();
854
+ if (this.agentWs) {
855
+ this.agentWs.close(1e3, "Provider stopping");
856
+ this.agentWs = null;
857
+ }
858
+ this.activeSessions.clear();
859
+ this.client.setAgentToken(null);
860
+ this.emit("stopped");
861
+ }
862
+ };
863
+
864
+ // src/index.ts
865
+ var PROVIDER_PACKAGE_VERSION = "0.1.0";
866
+ export {
867
+ ApiClient,
868
+ FileCursorStore,
869
+ InMemoryCursorStore,
870
+ PROVIDER_PACKAGE_VERSION,
871
+ ProviderClient,
872
+ SessionManager,
873
+ clearConfig,
874
+ diffSessionStates,
875
+ getConfigDir,
876
+ getConfigPath,
877
+ loadConfig,
878
+ resumeActiveSessions,
879
+ saveConfig
880
+ };
881
+ //# sourceMappingURL=index.js.map