@steerpane/sdk 0.1.0 → 0.2.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/client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { APIError } from "./errors.js";
2
- import type { AgentRun, Capture, Credential, CrawlJob, EmbedLink, FetchResult, HelpRequest, HumanResult, Json, Page, Profile, SearchResponse, Session, StoredFile, UploadUrl, Webhook } from "./types.js";
2
+ import type { ActionGuard, ActionGuardRule, ActionGuardUnlock, AgentRun, CaptchaSolveResult, Capture, Credential, CrawlJob, Device, DevicePairingCode, EmbedLink, EndUser, FetchResult, HelpRequest, HumanResult, Json, Page, Profile, SearchResponse, SecretFillResult, Session, StoredFile, UploadUrl, WaitForCodeOptions, WaitForCodeResult, Webhook, WebhookDelivery } from "./types.js";
3
3
  export declare const DEFAULT_BASE_URL = "https://api.steerpane.app";
4
4
  export interface SteerpaneOptions {
5
5
  /** Falls back to `STEERPANE_API_TOKEN`. */
@@ -12,6 +12,9 @@ export interface SteerpaneOptions {
12
12
  allowInsecureHttp?: boolean;
13
13
  /** Replace `fetch`, for tests. */
14
14
  fetch?: typeof fetch;
15
+ /** Who your program is, e.g. "Acme support bot" (falls back to `STEERPANE_AGENT_NAME`). The dashboard shows it on the sessions it
16
+ * drives, their activity and the agent cursor (sent as `X-Steerpane-Agent`). Without it the token's name is shown. */
17
+ agentName?: string;
15
18
  }
16
19
  interface RequestOptions {
17
20
  json?: unknown;
@@ -53,6 +56,24 @@ declare class Actions {
53
56
  extract(sessionId: string, instruction: string, args?: Json): Promise<Json>;
54
57
  download(sessionId: string, args?: Json): Promise<Json>;
55
58
  uploadFile(sessionId: string, args?: Json): Promise<Json>;
59
+ /** Type a value you hold (a password, an access code) into ONE field of the page, on the site you name. `POST /v1/sessions/{id}/secret-fill`; needs the `vault:use` scope.
60
+ * The runtime checks the origin of the frame holding the field against `site` in the same step that types (`exact: true`: the host must match exactly, no subdomains).
61
+ * `kind` is `password` (default; the field must be a password input) or `code`. `submit` and `exact` must be real booleans.
62
+ *
63
+ * The value is never logged, recorded, queued or returned (the result shows `value: "••••••"`). The SDK doesn't echo it either, and this call is NEVER retried: no
64
+ * automatic retry, no Idempotency-Key (the API refuses one). Treat every failure that isn't a definitive refusal before typing (`origin_mismatch`, `insecure_origin`,
65
+ * `field_type_mismatch`, `element_not_found`, ...) as "maybe typed": look at the page before you send it again.
66
+ * **`typed: true` in the result, or a 409 with `details.typed`, means the value is already in the page: do not send it again.** A human in control or a paused agent
67
+ * answers 409 `human_in_control` with nothing typed; a secret is never queued. */
68
+ secretFill(sessionId: string, input: {
69
+ site: string;
70
+ selector: string;
71
+ value: string;
72
+ kind?: "password" | "code";
73
+ submit?: boolean;
74
+ exact?: boolean;
75
+ frame?: string;
76
+ }): Promise<SecretFillResult>;
56
77
  /** Type a stored credential into the page. The secret never comes back. Refused off the credential's site. */
57
78
  vaultFill(sessionId: string, credentialId: string, args?: Json): Promise<Json>;
58
79
  }
@@ -64,15 +85,18 @@ declare class Sessions {
64
85
  profile_id?: string;
65
86
  q?: string;
66
87
  project_id?: string;
88
+ end_user_id?: string;
67
89
  limit?: number;
68
90
  cursor?: string | null;
69
91
  }): Promise<Page<Session>>;
70
- /** Start a browser. `config` takes the session fields in the API docs, such as `initial_url`, `profile_id`, `auto_close`, `timeout_min`. */
92
+ /** Start a browser. `config` takes the session fields in the API docs, such as `initial_url`, `profile_id`, `end_user_id`, `auto_close`, `timeout_min`.
93
+ * `profile_wait: { timeout_s: 300 }` queues behind the profile's current writer instead of failing with 409 `profile_locked`: the session comes back with
94
+ * `status: "queued"` and `queue_position`; stop it to cancel. Without `profile_id` you get a throwaway browser that saves nothing. */
71
95
  create(config?: Json, options?: {
72
96
  idempotencyKey?: string;
73
97
  }): Promise<Session>;
74
98
  get(sessionId: string): Promise<Session>;
75
- /** Wait for the browser to leave `starting`. Throws if it ends instead, or doesn't start in time. */
99
+ /** Wait for the browser to leave `starting` (and `queued`, for a session created with `profile_wait`: raise `timeoutS` to cover the wait). Throws if it ends instead, or doesn't start in time. */
76
100
  waitUntilReady(sessionId: string, options?: {
77
101
  timeoutS?: number;
78
102
  pollS?: number;
@@ -86,6 +110,28 @@ declare class Sessions {
86
110
  outcome?: "done" | "not_done";
87
111
  note?: string;
88
112
  }): Promise<Session>;
113
+ /** Name what the agent may not click or submit without approval. `rules` replaces the current ones; `[]` removes the guard. A blocked action fails with the
114
+ * `action_guard_blocked` error code until `unlockAction` is called. The service ships no rules of its own. */
115
+ setActionGuard(sessionId: string, rules: ActionGuardRule[]): Promise<ActionGuard>;
116
+ getActionGuard(sessionId: string): Promise<ActionGuard>;
117
+ removeActionGuard(sessionId: string): Promise<void>;
118
+ /** Approve ONE blocked gesture (the next matching click, Enter-submit or form submit, optionally only for `ruleId`) within `ttlS` seconds (1 to 120, default 30). Needs the `takeover` scope.
119
+ * The result's `late_targets` counts frames where a page script may have registered a listener ahead of the guard; if you need certainty, decline to rely on the guard when it is above 0.
120
+ * An action that spends the unlock returns `unlock_spent: true`, and an error it ends with carries `details.unlock_spent: true` (read it from `APIError.details`/`ToolError.details`): the act may have happened, so never click again, whatever the error.
121
+ * A session whose guard can't be installed refuses guarded actions with `action_guard_unavailable`. */
122
+ unlockAction(sessionId: string, options?: {
123
+ ruleId?: string;
124
+ ttlS?: number;
125
+ }): Promise<ActionGuardUnlock>;
126
+ /** Run the CAPTCHA solver on the session's page. One call waits about 45 s: while `status` is `in_progress`, call again (the worker keeps running, it never restarts).
127
+ * `onFailure` is `wait_for_human` (default), `request_help` or `return`. Needs a CAPTCHA model on the organization. */
128
+ solveCaptcha(sessionId: string, options?: {
129
+ onFailure?: "wait_for_human" | "request_help" | "return";
130
+ maxReloads?: number;
131
+ model?: string;
132
+ captchaImageSelector?: string;
133
+ captchaInputSelector?: string;
134
+ }): Promise<CaptchaSolveResult>;
89
135
  /** Ask a person for help. The agent goes on hold, and `agent.human_help_requested` fires. */
90
136
  requestHelp(sessionId: string, input: {
91
137
  reason: string;
@@ -110,10 +156,12 @@ declare class Profiles {
110
156
  list(params?: {
111
157
  tag?: string;
112
158
  q?: string;
159
+ end_user_id?: string;
113
160
  limit?: number;
114
161
  cursor?: string | null;
115
162
  }): Promise<Page<Profile>>;
116
163
  get(profileId: string): Promise<Profile>;
164
+ /** `fields` include `end_user_id` to give the profile to one of your end users. */
117
165
  create(name: string, fields?: Json): Promise<Profile>;
118
166
  update(profileId: string, fields: Json): Promise<Profile>;
119
167
  delete(profileId: string): Promise<void>;
@@ -127,6 +175,46 @@ declare class Profiles {
127
175
  imported: number;
128
176
  }>;
129
177
  }
178
+ /** Your own customers. Profiles and sessions can belong to one; a session only uses profiles of its own end user. */
179
+ declare class EndUsers {
180
+ private readonly c;
181
+ constructor(c: Steerpane);
182
+ /** Idempotent on `external_id`: calling it again returns the existing end user unchanged. */
183
+ create(externalId: string, fields?: {
184
+ name?: string;
185
+ metadata?: Record<string, string>;
186
+ }): Promise<EndUser>;
187
+ list(params?: {
188
+ external_id?: string;
189
+ limit?: number;
190
+ cursor?: string | null;
191
+ }): Promise<Page<EndUser>>;
192
+ get(endUserId: string): Promise<EndUser>;
193
+ update(endUserId: string, fields: {
194
+ name?: string;
195
+ metadata?: Record<string, string>;
196
+ disabled?: boolean;
197
+ }): Promise<EndUser>;
198
+ /** 409 while it owns profiles or live sessions; `cascade: true` deletes its profiles too (never while a session is live). */
199
+ delete(endUserId: string, options?: {
200
+ cascade?: boolean;
201
+ }): Promise<void>;
202
+ /** A single-use code (valid a few minutes) to hand to this end user so they can pair their own computer. Answers 404 while devices are off for the service. */
203
+ createDevicePairingCode(endUserId: string): Promise<DevicePairingCode>;
204
+ }
205
+ /** Computers that run sessions: yours, shared ones, and your end users'. Answers 404 while devices are off for the service. */
206
+ declare class Devices {
207
+ private readonly c;
208
+ constructor(c: Steerpane);
209
+ /** `scope` is `user`, `org` or `end_user`; `end_user_id` narrows to one end user's devices. Capped at 200, no cursor. */
210
+ list(params?: {
211
+ scope?: "user" | "org" | "end_user";
212
+ end_user_id?: string;
213
+ }): Promise<{
214
+ data: Device[];
215
+ }>;
216
+ get(deviceId: string): Promise<Device>;
217
+ }
130
218
  /** Hosted agent runs. These use your organization's own model key. */
131
219
  declare class Runs {
132
220
  private readonly c;
@@ -212,7 +300,7 @@ declare class Webhooks {
212
300
  delete(webhookId: string): Promise<void>;
213
301
  sendTest(webhookId: string, input?: {
214
302
  event?: string;
215
- }): Promise<Json>;
303
+ }): Promise<WebhookDelivery>;
216
304
  }
217
305
  /** Stored logins. Passwords and TOTP secrets go in and never come back to an API token. */
218
306
  declare class Vault {
@@ -260,18 +348,27 @@ declare class Mcp {
260
348
  call<T = Json>(tool: string, args?: Json, options?: {
261
349
  timeoutS?: number;
262
350
  }): Promise<T>;
351
+ /** Wait for an email or SMS verification code (the MCP tool `wait_for_code`). With `sessionId` the code is typed into the page and never reaches you
352
+ * (`status: "entered"`); with `returnCode: true` you get it back (`status: "received"`). The server answers in rounds of about 45 s with `status: "waiting"`:
353
+ * this keeps calling, with the `started_at` it was given, until the code arrives or `timeoutS` runs out (`status: "timeout"`), so the result is always final.
354
+ * Needs the `vault:use` scope and a code source set up on the organization. */
355
+ waitForCode(options?: WaitForCodeOptions): Promise<WaitForCodeResult>;
263
356
  }
264
357
  /** Client for the Steerpane API. */
265
358
  export declare class Steerpane {
266
359
  readonly baseUrl: string;
267
360
  readonly timeoutMs: number;
268
361
  readonly maxRetries: number;
362
+ /** Sent as X-Steerpane-Agent: the name the dashboard shows for this program. */
363
+ readonly agentName: string | undefined;
269
364
  /** The token, for helpers that need it. Not enumerable, so logging the client doesn't print it. */
270
365
  readonly token: string;
271
366
  private readonly fetchImpl;
272
367
  readonly sessions: Sessions;
273
368
  readonly actions: Actions;
274
369
  readonly profiles: Profiles;
370
+ readonly endUsers: EndUsers;
371
+ readonly devices: Devices;
275
372
  readonly runs: Runs;
276
373
  readonly files: Files;
277
374
  readonly embeds: Embeds;
package/dist/client.js CHANGED
@@ -51,6 +51,18 @@ class Actions {
51
51
  extract(sessionId, instruction, args = {}) { return this.run(sessionId, "extract", { instruction, ...args }); }
52
52
  download(sessionId, args = {}) { return this.run(sessionId, "download", args); }
53
53
  uploadFile(sessionId, args = {}) { return this.run(sessionId, "upload_file", args); }
54
+ /** Type a value you hold (a password, an access code) into ONE field of the page, on the site you name. `POST /v1/sessions/{id}/secret-fill`; needs the `vault:use` scope.
55
+ * The runtime checks the origin of the frame holding the field against `site` in the same step that types (`exact: true`: the host must match exactly, no subdomains).
56
+ * `kind` is `password` (default; the field must be a password input) or `code`. `submit` and `exact` must be real booleans.
57
+ *
58
+ * The value is never logged, recorded, queued or returned (the result shows `value: "••••••"`). The SDK doesn't echo it either, and this call is NEVER retried: no
59
+ * automatic retry, no Idempotency-Key (the API refuses one). Treat every failure that isn't a definitive refusal before typing (`origin_mismatch`, `insecure_origin`,
60
+ * `field_type_mismatch`, `element_not_found`, ...) as "maybe typed": look at the page before you send it again.
61
+ * **`typed: true` in the result, or a 409 with `details.typed`, means the value is already in the page: do not send it again.** A human in control or a paused agent
62
+ * answers 409 `human_in_control` with nothing typed; a secret is never queued. */
63
+ secretFill(sessionId, input) {
64
+ return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/secret-fill`, { json: clean(input) });
65
+ }
54
66
  /** Type a stored credential into the page. The secret never comes back. Refused off the credential's site. */
55
67
  vaultFill(sessionId, credentialId, args = {}) { return this.run(sessionId, "vault_fill", { credential_id: credentialId, ...args }); }
56
68
  }
@@ -62,12 +74,14 @@ class Sessions {
62
74
  list(params = {}) {
63
75
  return this.c.request("GET", "/v1/sessions", { params });
64
76
  }
65
- /** Start a browser. `config` takes the session fields in the API docs, such as `initial_url`, `profile_id`, `auto_close`, `timeout_min`. */
77
+ /** Start a browser. `config` takes the session fields in the API docs, such as `initial_url`, `profile_id`, `end_user_id`, `auto_close`, `timeout_min`.
78
+ * `profile_wait: { timeout_s: 300 }` queues behind the profile's current writer instead of failing with 409 `profile_locked`: the session comes back with
79
+ * `status: "queued"` and `queue_position`; stop it to cancel. Without `profile_id` you get a throwaway browser that saves nothing. */
66
80
  create(config = {}, options = {}) {
67
81
  return this.c.request("POST", "/v1/sessions", { json: clean(config), headers: { "Idempotency-Key": options.idempotencyKey ?? crypto.randomUUID() } });
68
82
  }
69
83
  get(sessionId) { return this.c.request("GET", `/v1/sessions/${seg(sessionId)}`); }
70
- /** Wait for the browser to leave `starting`. Throws if it ends instead, or doesn't start in time. */
84
+ /** Wait for the browser to leave `starting` (and `queued`, for a session created with `profile_wait`: raise `timeoutS` to cover the wait). Throws if it ends instead, or doesn't start in time. */
71
85
  async waitUntilReady(sessionId, options = {}) {
72
86
  const timeoutS = options.timeoutS ?? 90;
73
87
  const deadline = Date.now() + timeoutS * 1000;
@@ -75,10 +89,10 @@ class Sessions {
75
89
  const session = await this.get(sessionId);
76
90
  if (session.status === "stopped" || session.status === "error")
77
91
  throw new SteerpaneError(`Session ${sessionId} ended before it was ready: ${session.error_message ?? session.status}`);
78
- if (session.status !== "starting")
92
+ if (session.status !== "starting" && session.status !== "queued")
79
93
  return session;
80
94
  if (Date.now() >= deadline)
81
- throw new SteerpaneError(`Session ${sessionId} was still starting after ${timeoutS} s`);
95
+ throw new SteerpaneError(`Session ${sessionId} was still ${session.status} after ${timeoutS} s`);
82
96
  await sleep((options.pollS ?? 0.5) * 1000);
83
97
  }
84
98
  }
@@ -90,6 +104,26 @@ class Sessions {
90
104
  handback(sessionId, input = {}) {
91
105
  return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/handback`, { json: clean(input) });
92
106
  }
107
+ /** Name what the agent may not click or submit without approval. `rules` replaces the current ones; `[]` removes the guard. A blocked action fails with the
108
+ * `action_guard_blocked` error code until `unlockAction` is called. The service ships no rules of its own. */
109
+ setActionGuard(sessionId, rules) { return this.c.request("PUT", `/v1/sessions/${seg(sessionId)}/action-guard`, { json: { rules } }); }
110
+ getActionGuard(sessionId) { return this.c.request("GET", `/v1/sessions/${seg(sessionId)}/action-guard`); }
111
+ async removeActionGuard(sessionId) { await this.c.request("DELETE", `/v1/sessions/${seg(sessionId)}/action-guard`); }
112
+ /** Approve ONE blocked gesture (the next matching click, Enter-submit or form submit, optionally only for `ruleId`) within `ttlS` seconds (1 to 120, default 30). Needs the `takeover` scope.
113
+ * The result's `late_targets` counts frames where a page script may have registered a listener ahead of the guard; if you need certainty, decline to rely on the guard when it is above 0.
114
+ * An action that spends the unlock returns `unlock_spent: true`, and an error it ends with carries `details.unlock_spent: true` (read it from `APIError.details`/`ToolError.details`): the act may have happened, so never click again, whatever the error.
115
+ * A session whose guard can't be installed refuses guarded actions with `action_guard_unavailable`. */
116
+ unlockAction(sessionId, options = {}) {
117
+ return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/action-guard/unlock`, { json: clean({ rule_id: options.ruleId, ttl_s: options.ttlS }) });
118
+ }
119
+ /** Run the CAPTCHA solver on the session's page. One call waits about 45 s: while `status` is `in_progress`, call again (the worker keeps running, it never restarts).
120
+ * `onFailure` is `wait_for_human` (default), `request_help` or `return`. Needs a CAPTCHA model on the organization. */
121
+ solveCaptcha(sessionId, options = {}) {
122
+ return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/captcha/solve`, {
123
+ json: clean({ on_failure: options.onFailure, max_reloads: options.maxReloads, model: options.model, captcha_image_selector: options.captchaImageSelector, captcha_input_selector: options.captchaInputSelector }),
124
+ timeoutMs: Math.max(this.c.timeoutMs, 90_000),
125
+ });
126
+ }
93
127
  /** Ask a person for help. The agent goes on hold, and `agent.human_help_requested` fires. */
94
128
  requestHelp(sessionId, input) {
95
129
  return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/help-requests`, { json: { message: "", ...input } });
@@ -113,6 +147,7 @@ class Profiles {
113
147
  }
114
148
  list(params = {}) { return this.c.request("GET", "/v1/profiles", { params }); }
115
149
  get(profileId) { return this.c.request("GET", `/v1/profiles/${seg(profileId)}`); }
150
+ /** `fields` include `end_user_id` to give the profile to one of your end users. */
116
151
  create(name, fields = {}) { return this.c.request("POST", "/v1/profiles", { json: { name, ...clean(fields) } }); }
117
152
  update(profileId, fields) { return this.c.request("PATCH", `/v1/profiles/${seg(profileId)}`, { json: fields }); }
118
153
  async delete(profileId) { await this.c.request("DELETE", `/v1/profiles/${seg(profileId)}`); }
@@ -122,6 +157,38 @@ class Profiles {
122
157
  lock(profileId) { return this.c.request("GET", `/v1/profiles/${seg(profileId)}/lock`); }
123
158
  importCookies(profileId, cookies) { return this.c.request("POST", `/v1/profiles/${seg(profileId)}/cookies/import`, { json: { cookies } }); }
124
159
  }
160
+ /** Your own customers. Profiles and sessions can belong to one; a session only uses profiles of its own end user. */
161
+ class EndUsers {
162
+ c;
163
+ constructor(c) {
164
+ this.c = c;
165
+ }
166
+ /** Idempotent on `external_id`: calling it again returns the existing end user unchanged. */
167
+ create(externalId, fields = {}) {
168
+ return this.c.request("POST", "/v1/end-users", { json: clean({ external_id: externalId, ...fields }) });
169
+ }
170
+ list(params = {}) { return this.c.request("GET", "/v1/end-users", { params }); }
171
+ get(endUserId) { return this.c.request("GET", `/v1/end-users/${seg(endUserId)}`); }
172
+ update(endUserId, fields) {
173
+ return this.c.request("PATCH", `/v1/end-users/${seg(endUserId)}`, { json: clean(fields) });
174
+ }
175
+ /** 409 while it owns profiles or live sessions; `cascade: true` deletes its profiles too (never while a session is live). */
176
+ async delete(endUserId, options = {}) {
177
+ await this.c.request("DELETE", `/v1/end-users/${seg(endUserId)}`, { params: options.cascade ? { cascade: "true" } : {} });
178
+ }
179
+ /** A single-use code (valid a few minutes) to hand to this end user so they can pair their own computer. Answers 404 while devices are off for the service. */
180
+ createDevicePairingCode(endUserId) { return this.c.request("POST", `/v1/end-users/${seg(endUserId)}/device-pairing-codes`); }
181
+ }
182
+ /** Computers that run sessions: yours, shared ones, and your end users'. Answers 404 while devices are off for the service. */
183
+ class Devices {
184
+ c;
185
+ constructor(c) {
186
+ this.c = c;
187
+ }
188
+ /** `scope` is `user`, `org` or `end_user`; `end_user_id` narrows to one end user's devices. Capped at 200, no cursor. */
189
+ list(params = {}) { return this.c.request("GET", "/v1/devices", { params }); }
190
+ get(deviceId) { return this.c.request("GET", `/v1/devices/${seg(deviceId)}`); }
191
+ }
125
192
  /** Hosted agent runs. These use your organization's own model key. */
126
193
  class Runs {
127
194
  c;
@@ -301,16 +368,39 @@ class Mcp {
301
368
  }
302
369
  return payload;
303
370
  }
371
+ /** Wait for an email or SMS verification code (the MCP tool `wait_for_code`). With `sessionId` the code is typed into the page and never reaches you
372
+ * (`status: "entered"`); with `returnCode: true` you get it back (`status: "received"`). The server answers in rounds of about 45 s with `status: "waiting"`:
373
+ * this keeps calling, with the `started_at` it was given, until the code arrives or `timeoutS` runs out (`status: "timeout"`), so the result is always final.
374
+ * Needs the `vault:use` scope and a code source set up on the organization. */
375
+ async waitForCode(options = {}) {
376
+ const args = {
377
+ session_id: options.sessionId, profile_id: options.profileId, credential_id: options.credentialId, site: options.site, sender: options.sender, subject: options.subject,
378
+ timeout_s: options.timeoutS, max_age_s: options.maxAgeS, selector: options.selector, frame: options.frame, submit: options.submit, return_code: options.returnCode, code_id: options.codeId,
379
+ };
380
+ const timeoutS = options.timeoutS ?? 120;
381
+ for (let round = 0;; round++) {
382
+ const result = await this.call("wait_for_code", args, { timeoutS: 90 });
383
+ if (result.status !== "waiting")
384
+ return result;
385
+ if (round > timeoutS)
386
+ throw new SteerpaneError("wait_for_code kept answering 'waiting' past its own timeout");
387
+ args["started_at"] = result["started_at"];
388
+ }
389
+ }
304
390
  }
305
391
  /** Client for the Steerpane API. */
306
392
  export class Steerpane {
307
393
  baseUrl;
308
394
  timeoutMs;
309
395
  maxRetries;
396
+ /** Sent as X-Steerpane-Agent: the name the dashboard shows for this program. */
397
+ agentName;
310
398
  fetchImpl;
311
399
  sessions = new Sessions(this);
312
400
  actions = new Actions(this);
313
401
  profiles = new Profiles(this);
402
+ endUsers = new EndUsers(this);
403
+ devices = new Devices(this);
314
404
  runs = new Runs(this);
315
405
  files = new Files(this);
316
406
  embeds = new Embeds(this);
@@ -339,6 +429,7 @@ export class Steerpane {
339
429
  this.timeoutMs = options.timeoutMs ?? 60_000;
340
430
  this.maxRetries = options.maxRetries ?? 2;
341
431
  this.fetchImpl = options.fetch ?? fetch;
432
+ this.agentName = (options.agentName ?? process.env["STEERPANE_AGENT_NAME"] ?? "").trim().slice(0, 80) || undefined;
342
433
  }
343
434
  /** One HTTP call with your token and the retry rules. Doesn't throw on an error status. */
344
435
  async rawRequest(method, path, options = {}) {
@@ -347,25 +438,29 @@ export class Steerpane {
347
438
  if (v !== undefined && v !== null)
348
439
  query.set(k, String(v));
349
440
  const url = this.baseUrl + path + (query.size ? `?${query}` : "");
350
- const headers = { Authorization: `Bearer ${this.token}`, "User-Agent": `steerpane-sdk-node/${VERSION}`, ...options.headers };
441
+ const headers = {
442
+ Authorization: `Bearer ${this.token}`, "User-Agent": `steerpane-sdk-node/${VERSION}`,
443
+ ...(this.agentName ? { "X-Steerpane-Agent": this.agentName } : {}), ...options.headers,
444
+ };
351
445
  if (options.json !== undefined)
352
446
  headers["Content-Type"] = "application/json";
353
447
  const body = options.body ?? (options.json !== undefined ? JSON.stringify(options.json) : undefined);
354
448
  const keyed = "Idempotency-Key" in headers;
449
+ const noRetry = path.split("?")[0].replace(/\/+$/, "").endsWith("/secret-fill"); // typing a secret twice can't be undone: never retried, whatever the error
355
450
  const idempotent = method === "GET" || method === "HEAD" || method === "DELETE" || keyed;
356
451
  for (let attempt = 0;; attempt++) {
357
452
  let delay;
358
453
  try {
359
454
  const res = await this.fetchImpl(url, { method, headers, body, signal: AbortSignal.timeout(options.timeoutMs ?? this.timeoutMs) });
360
455
  // A stored 429 replays under an Idempotency-Key, so a keyed POST isn't retried on it. A rate-limited request never ran.
361
- const rateLimited = res.status === 429 && !keyed && (await isRateLimited(res));
362
- if (!((idempotent && RETRY_STATUSES.has(res.status)) || rateLimited) || attempt >= this.maxRetries)
456
+ const rateLimited = !noRetry && res.status === 429 && !keyed && (await isRateLimited(res));
457
+ if (!((!noRetry && idempotent && RETRY_STATUSES.has(res.status)) || rateLimited) || attempt >= this.maxRetries)
363
458
  return res;
364
459
  delay = retryAfterOf(res) ?? 0.5 * 2 ** attempt;
365
460
  }
366
461
  catch (e) {
367
462
  const timedOut = e instanceof Error && e.name === "TimeoutError";
368
- if (!idempotent || timedOut || attempt >= this.maxRetries)
463
+ if (noRetry || !idempotent || timedOut || attempt >= this.maxRetries)
369
464
  throw e;
370
465
  delay = 0.5 * 2 ** attempt;
371
466
  }