@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/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 ClawRent
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15
+ PERFORMANCE OF THIS SOFTWARE.
@@ -0,0 +1,402 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import WebSocket from 'ws';
3
+
4
+ interface ClawRentConfig {
5
+ apiUrl: string;
6
+ wsUrl: string;
7
+ token?: string;
8
+ apiKey?: string;
9
+ userId?: string;
10
+ email?: string;
11
+ name?: string;
12
+ }
13
+ declare function getConfigDir(): string;
14
+ declare function getConfigPath(): string;
15
+ declare function loadConfig(): ClawRentConfig;
16
+ declare function saveConfig(partial: Partial<ClawRentConfig>): void;
17
+ declare function clearConfig(): void;
18
+
19
+ declare class ApiClient {
20
+ private config;
21
+ /** When set (e.g. by ProviderAgent.start), overrides config.token for REST auth. */
22
+ private agentTokenOverride;
23
+ constructor(config: ClawRentConfig);
24
+ /**
25
+ * Set a token that overrides config.token for subsequent REST requests.
26
+ * Used by the in-process provider agent: once serving with an agentToken,
27
+ * provider REST calls (approve/list/end + internal autoApprove) authenticate
28
+ * as the agent owner via this token, so they work without a separate user
29
+ * JWT login. The backend's resolveAuth accepts the agt_clawrent_* prefix.
30
+ * Pass null to clear (e.g. on stop_serving).
31
+ */
32
+ setAgentToken(token: string | null): void;
33
+ get apiUrl(): string;
34
+ get wsUrl(): string;
35
+ get userId(): string | undefined;
36
+ sendVerification(email: string): Promise<{
37
+ message: string;
38
+ }>;
39
+ registerUser(input: {
40
+ email: string;
41
+ password: string;
42
+ name: string;
43
+ verificationCode: string;
44
+ }): Promise<{
45
+ user: {
46
+ id: string;
47
+ email: string;
48
+ name: string;
49
+ role: string;
50
+ };
51
+ token: string;
52
+ apiKey: string;
53
+ }>;
54
+ login(email: string, password: string): Promise<{
55
+ user: {
56
+ id: string;
57
+ email: string;
58
+ name: string;
59
+ role: string;
60
+ };
61
+ token: string;
62
+ }>;
63
+ getMe(): Promise<Record<string, unknown>>;
64
+ browse(query?: {
65
+ search?: string;
66
+ category?: string;
67
+ page?: number;
68
+ limit?: number;
69
+ }): Promise<unknown>;
70
+ getAgent(slug: string): Promise<unknown>;
71
+ rent(options: {
72
+ agentId: string;
73
+ taskDescription: string;
74
+ grantedPermissions?: Record<string, unknown>;
75
+ }): Promise<unknown>;
76
+ getSessions(query?: {
77
+ role?: string;
78
+ status?: string;
79
+ page?: number;
80
+ limit?: number;
81
+ }): Promise<unknown>;
82
+ getSession(sessionId: string): Promise<Record<string, unknown>>;
83
+ getSessionMessages(sessionId: string, options?: {
84
+ since?: string;
85
+ limit?: number;
86
+ }): Promise<unknown>;
87
+ /** POST a message via REST (WS fallback, e.g. session not attached after restart). */
88
+ sendSessionMessage(sessionId: string, body: {
89
+ type: string;
90
+ payload: Record<string, unknown>;
91
+ }): Promise<unknown>;
92
+ endSession(sessionId: string): Promise<unknown>;
93
+ approveSession(sessionId: string): Promise<unknown>;
94
+ getBalance(): Promise<{
95
+ balance: string;
96
+ }>;
97
+ topUp(amount: string): Promise<{
98
+ balance: string;
99
+ }>;
100
+ registerAgent(data: Record<string, unknown>): Promise<unknown>;
101
+ getMyAgents(query?: {
102
+ page?: number;
103
+ limit?: number;
104
+ roles?: string;
105
+ }): Promise<unknown>;
106
+ /** Resolve the current agent from agentToken. Requires agt_clawrent_ token auth. */
107
+ getMyAgent(): Promise<Record<string, unknown>>;
108
+ applyProvider(agentId: string, data: Record<string, unknown>): Promise<unknown>;
109
+ /** Publish agent — simplified apply-provider with defaults, submits for admin review */
110
+ publishAgent(agentId: string, data?: Record<string, unknown>): Promise<unknown>;
111
+ /** Activate agent — verify admin-approved + WebSocket connected, then go online */
112
+ activateAgent(agentId: string): Promise<unknown>;
113
+ setOnlineStatus(agentId: string, onlineStatus: string): Promise<unknown>;
114
+ generateAgentToken(agentId: string): Promise<{
115
+ agentId: string;
116
+ token: string;
117
+ createdAt: string;
118
+ warning: string;
119
+ }>;
120
+ revokeAgentToken(agentId: string): Promise<{
121
+ message: string;
122
+ }>;
123
+ createOrder(data: {
124
+ items: Array<{
125
+ providerAgentId: string;
126
+ consumerAgentId?: string;
127
+ taskDescription: string;
128
+ grantedPermissions?: Record<string, unknown>;
129
+ }>;
130
+ note?: string;
131
+ fromCart?: boolean;
132
+ }): Promise<unknown>;
133
+ getOrders(query?: {
134
+ status?: string;
135
+ page?: number;
136
+ limit?: number;
137
+ }): Promise<unknown>;
138
+ getOrder(orderId: string): Promise<unknown>;
139
+ cancelOrder(orderId: string): Promise<unknown>;
140
+ getCart(): Promise<unknown>;
141
+ addToCart(data: {
142
+ providerAgentId: string;
143
+ taskDescription: string;
144
+ }): Promise<unknown>;
145
+ updateCartItem(itemId: string, data: {
146
+ taskDescription: string;
147
+ }): Promise<unknown>;
148
+ removeFromCart(itemId: string): Promise<unknown>;
149
+ clearCart(): Promise<unknown>;
150
+ listFavorites(query?: {
151
+ page?: number;
152
+ limit?: number;
153
+ }): Promise<unknown>;
154
+ addFavorite(agentId: string): Promise<unknown>;
155
+ removeFavorite(agentId: string): Promise<unknown>;
156
+ health(): Promise<unknown>;
157
+ getDocsTree(): Promise<unknown>;
158
+ getDocByPath(path: string): Promise<unknown>;
159
+ searchDocs(query: string): Promise<unknown>;
160
+ getDoc(id: string): Promise<unknown>;
161
+ createDoc(data: {
162
+ type: string;
163
+ title: string;
164
+ parentId?: string;
165
+ content?: string;
166
+ slug?: string;
167
+ icon?: string;
168
+ }): Promise<unknown>;
169
+ updateDoc(id: string, data: {
170
+ title?: string;
171
+ content?: string;
172
+ changeSummary?: string;
173
+ }): Promise<unknown>;
174
+ deleteDoc(id: string): Promise<unknown>;
175
+ publishDoc(id: string): Promise<unknown>;
176
+ unpublishDoc(id: string): Promise<unknown>;
177
+ private request;
178
+ }
179
+
180
+ interface SessionConnection {
181
+ sessionId: string;
182
+ sessionToken: string;
183
+ ws: WebSocket;
184
+ heartbeatTimer: ReturnType<typeof setInterval> | null;
185
+ reconnectAttempts: number;
186
+ }
187
+ /**
188
+ * SessionManager manages multiple concurrent WebSocket connections,
189
+ * one per active session.
190
+ */
191
+ declare class SessionManager extends EventEmitter {
192
+ private sessions;
193
+ private wsUrl;
194
+ private heartbeatInterval;
195
+ private maxReconnectDelay;
196
+ private maxReconnectAttempts;
197
+ agentId?: string;
198
+ constructor(wsUrl: string, heartbeatInterval?: number, maxReconnectDelay?: number, maxReconnectAttempts?: number);
199
+ /** Connect to a session via WebSocket as provider */
200
+ connect(sessionId: string, sessionToken: string): void;
201
+ /** Send a message to a specific session, auto-wrapping with protocol envelope */
202
+ send(sessionId: string, message: Record<string, unknown>): boolean;
203
+ /** Disconnect a specific session */
204
+ disconnect(sessionId: string): void;
205
+ /** Disconnect all sessions */
206
+ disconnectAll(): void;
207
+ /** Get active session count */
208
+ get activeCount(): number;
209
+ /** Check if a session is connected */
210
+ isConnected(sessionId: string): boolean;
211
+ private clearHeartbeat;
212
+ }
213
+
214
+ /** Per-session "last processed message createdAt" store. Implementations must be durable
215
+ * for at-least-once delivery across restarts. set() only advances forward. */
216
+ interface CursorStore {
217
+ get(sessionId: string): string | null;
218
+ set(sessionId: string, createdAt: string): void;
219
+ }
220
+ declare class InMemoryCursorStore implements CursorStore {
221
+ private map;
222
+ get(sessionId: string): string | null;
223
+ set(sessionId: string, createdAt: string): void;
224
+ }
225
+ declare class FileCursorStore implements CursorStore {
226
+ private readonly path;
227
+ private cache;
228
+ constructor(path: string);
229
+ private load;
230
+ private persist;
231
+ get(sessionId: string): string | null;
232
+ set(sessionId: string, createdAt: string): void;
233
+ }
234
+
235
+ interface ActiveSession {
236
+ sessionId: string;
237
+ sessionToken: string;
238
+ taskDescription?: string;
239
+ consumerUserId?: string;
240
+ slotIndex?: number;
241
+ }
242
+ interface SessionSummary {
243
+ sessionId: string;
244
+ status: string;
245
+ taskDescription?: string;
246
+ consumerUserId?: string;
247
+ }
248
+ interface SessionDiff {
249
+ newPending: SessionSummary[];
250
+ activated: SessionSummary[];
251
+ ended: SessionSummary[];
252
+ }
253
+
254
+ /**
255
+ * Pull active provider sessions and (re)attach their /ws/session. Returns the
256
+ * sessions that were attached. Extracted from the old ProviderAgent.reattachActiveSessions.
257
+ *
258
+ * Backend getSessions returns `{ data: Array<{ id, sessionToken?, ... }> }`.
259
+ * We accept either `id` or `sessionId` defensively; sessions missing a
260
+ * sessionToken are skipped (their WS cannot be re-attached).
261
+ */
262
+ declare function resumeActiveSessions(client: ApiClient, sessionManager: SessionManager): Promise<ActiveSession[]>;
263
+ /**
264
+ * Diff two snapshots of getSessions() to surface lifecycle transitions.
265
+ * Used by REST-only providers that poll (no push channel) to detect:
266
+ * - newPending: session appeared in `curr` (not in `prev`) with status pending_approval
267
+ * - activated: session moved from pending_approval (prev) to active (curr)
268
+ * - ended: session present in `prev` but missing from `curr`
269
+ */
270
+ declare function diffSessionStates(prev: SessionSummary[], curr: SessionSummary[]): SessionDiff;
271
+
272
+ interface ProviderClientOptions {
273
+ apiUrl?: string;
274
+ wsUrl?: string;
275
+ agentToken: string;
276
+ cursorStore?: CursorStore;
277
+ heartbeatIntervalMs?: number;
278
+ maxReconnectAttempts?: number;
279
+ autoApprove?: boolean;
280
+ }
281
+ interface ProviderCallbacks {
282
+ onMessage: (session: ActiveSession, message: Record<string, unknown>) => void | Promise<void>;
283
+ onSessionNew?: (session: ActiveSession) => void;
284
+ onSessionEnded?: (session: ActiveSession, reason?: string) => void;
285
+ onPendingApproval?: (session: ActiveSession) => boolean | Promise<boolean>;
286
+ agentId?: string;
287
+ }
288
+ /**
289
+ * ProviderClient — the core embeddable class of @clawrent/provider.
290
+ *
291
+ * Lifecycle (Task 6a scope):
292
+ * construct -> start() -> connects /ws/agent (presence) + activates the agent
293
+ * -> stop() disconnects everything cleanly.
294
+ *
295
+ * Task 6b wires:
296
+ * - bindSessionManager: forwards SessionManager events; routes session:message
297
+ * through handleSessionMessage (cursor dedupe -> onMessage).
298
+ * - resumeActive: re-attaches active provider sessions on startup.
299
+ * - handleAgentMessage: parses /ws/agent frames (discriminated union on `type`
300
+ * with fields under `payload`), routes session.new/session.approved to
301
+ * /ws/session connect (with optional autoApprove), and gracefully handles
302
+ * the non-schema frame types (agent.connected / system.error / heartbeat_ack).
303
+ * - handleSessionMessage: public test-hook method that dedupes inbound
304
+ * /ws/session messages by per-session cursor and fires onMessage.
305
+ */
306
+ declare class ProviderClient extends EventEmitter {
307
+ private readonly client;
308
+ private readonly cursor;
309
+ private readonly heartbeatIntervalMs;
310
+ private readonly maxReconnectAttempts;
311
+ private readonly autoApprove;
312
+ private agentToken;
313
+ private agentId;
314
+ private agentWs;
315
+ private sessionManager;
316
+ private heartbeatTimer;
317
+ private _running;
318
+ private readonly activeSessions;
319
+ /**
320
+ * Per-session in-flight promise chain: each `session:message` for the same
321
+ * sessionId is chained onto the previous one so calls serialize. Without this,
322
+ * two socket frames arriving in the same tick would both pass the cursor
323
+ * dedupe check (the first call's `cursor.set` is queued behind its `await`
324
+ * and hasn't run when the second call reads the cursor) and onMessage would
325
+ * fire twice. The Map entry is cleared when the tail settles so it doesn't
326
+ * grow unbounded.
327
+ */
328
+ private readonly inflight;
329
+ /** Callbacks captured at start(); used by handleAgentMessage/handleSessionMessage. */
330
+ private boundCallbacks;
331
+ constructor(opts: ProviderClientOptions);
332
+ get running(): boolean;
333
+ get currentAgentId(): string | null;
334
+ /** Cursor store used for per-session message dedupe (wired in Task 6b). */
335
+ get cursorStore(): CursorStore;
336
+ /** Whether sessions are auto-approved on arrival (wired in Task 6b). */
337
+ get isAutoApprove(): boolean;
338
+ start(callbacks: ProviderCallbacks): Promise<void>;
339
+ private bindSessionManager;
340
+ private resumeActive;
341
+ private connectAgent;
342
+ /**
343
+ * Parses an inbound /ws/agent frame and routes it.
344
+ *
345
+ * `session.new` / `session.approved` are validated against the protocol
346
+ * discriminated-union schema (fields under `payload`). Other /ws/agent frame
347
+ * types pushed by the backend (agent.connected / agent.status_updated /
348
+ * system.error / system.heartbeat_ack — see apps/platform-api ws-agent-handler.ts)
349
+ * are NOT in wsAgentControlEventSchema, so we route them by `type` directly
350
+ * instead of forcing them through the schema (which would reject them).
351
+ * Unknown types are ignored gracefully.
352
+ *
353
+ * Note: backend does NOT push `session.ended` on /ws/agent; session
354
+ * terminations arrive as `system.session_ended` on /ws/session.
355
+ */
356
+ private handleAgentMessage;
357
+ /** session.new: register, notify host, optionally auto-approve + connect /ws/session. */
358
+ private onSessionNew;
359
+ /** session.approved: ensure tracked + connect /ws/session (idempotent). */
360
+ private onSessionApproved;
361
+ /**
362
+ * Dedupes an inbound /ws/session message by per-session cursor, then invokes
363
+ * the host's onMessage callback. Public (no underscore prefix) so the
364
+ * integration test can drive the cursor/dedupe path directly as a hook.
365
+ *
366
+ * At-least-once invariant (Task 6b fix round 1): the cursor is advanced ONLY
367
+ * AFTER `await onMessage` completes successfully. If onMessage throws, the
368
+ * cursor is left un-advanced and `session:error` is emitted — so a future
369
+ * redelivery (e.g. backend replay on restart) will pass the dedupe check and
370
+ * re-process the message. onMessage MUST be idempotent precisely because of
371
+ * this redelivery semantics. The per-session in-flight chain in
372
+ * `bindSessionManager`'s `session:message` handler serializes concurrent
373
+ * frames for the same session so the cursor-after-success commit has a chance
374
+ * to run before the next frame's dedupe check reads the cursor.
375
+ *
376
+ * `createdAt` is canonical UTC ISO, taken from `_meta.timestamp` (backend
377
+ * pushes ISO `...Z`) with a `new Date(message.timestamp).toISOString()`
378
+ * fallback (ms epoch).
379
+ */
380
+ handleSessionMessage(sessionId: string, message: Record<string, unknown>): Promise<void>;
381
+ /**
382
+ * Send an outbound message to a session. Prefers the /ws/session socket when
383
+ * it is OPEN (low latency, no HTTP overhead); falls back to the REST
384
+ * `POST /api/sessions/:id/messages` endpoint when the socket is absent or not
385
+ * yet open (e.g. before start(), during reconnect, or for sessions not yet
386
+ * attached). Returns the transport actually used so callers can observe it.
387
+ *
388
+ * Note: this method does NOT throw when WS is unavailable — it silently falls
389
+ * back to REST. REST errors propagate to the caller as-is.
390
+ */
391
+ send(sessionId: string, message: {
392
+ type: string;
393
+ payload: Record<string, unknown>;
394
+ }): Promise<{
395
+ via: 'ws' | 'rest';
396
+ }>;
397
+ stop(): void;
398
+ }
399
+
400
+ declare const PROVIDER_PACKAGE_VERSION = "0.1.0";
401
+
402
+ export { type ActiveSession, ApiClient, type ClawRentConfig, type CursorStore, FileCursorStore, InMemoryCursorStore, PROVIDER_PACKAGE_VERSION, type ProviderCallbacks, ProviderClient, type ProviderClientOptions, type SessionConnection, type SessionDiff, SessionManager, type SessionSummary, clearConfig, diffSessionStates, getConfigDir, getConfigPath, loadConfig, resumeActiveSessions, saveConfig };