@synqa/cli 1.1.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -59,7 +59,66 @@ From a checkout of this repo (for development): `npm link ./cli`.
59
59
  2. Paste your `synqa_live_...` key (typing is hidden). It is checked against the Synqa API and
60
60
  saved to `~/.config/synqa/config.json` (file mode 0600).
61
61
 
62
- Or skip login and set `SYNQA_API_KEY` in your shell. The variable wins over the saved key.
62
+ Or set `SYNQA_API_KEY` in your shell. A key saved by `synqa login` or `/login` wins over the variable,
63
+ so you never need to unset it; pass `--env-key` to make the variable win for one run.
64
+
65
+ Starting `synqa` in a terminal with no valid Synqa key shows the welcome screen and asks for the key
66
+ first (typing shows `••••`, the key is checked at `https://api.synqa.ca/v1/account`). Chat and every
67
+ other provider stay locked until you are signed in to Synqa Cloud.
68
+
69
+ ## Providers
70
+
71
+ After you sign in, `/provider` (or `/login`) opens an arrow-key menu: Synqa Cloud (primary),
72
+ OpenCode Go, OpenCode Zen, OpenRouter (bring your own `sk-or-...` key) and a custom
73
+ OpenAI-compatible endpoint. Keys for the others are typed masked, checked with `GET /v1/models` and
74
+ saved in the same private config file (mode 0600 on macOS and Linux; Windows ignores the mode). The
75
+ active provider and model show in the header and the footer. `/logout` offers to leave the secondary
76
+ provider or to log out fully, which clears the Synqa key (and `SYNQA_API_KEY` in this process) and
77
+ returns to the sign-in prompt. Use `OPENCODE_API_KEY` or the router's key variable as a fallback when
78
+ nothing is saved.
79
+
80
+ ## Keys and modes
81
+
82
+ - `/` opens the command list under the prompt: Up/Down move, Tab fills, Enter runs, Esc closes.
83
+ - `/model`, `/effort`, `/provider`, `/login`, `/logout` and `/hooks` with no argument open an inline
84
+ Up/Down list (Enter selects, Esc cancels).
85
+ - Shift+Tab cycles the permission mode: `ask before edits`, `auto-accept edits` (file edits in the
86
+ workspace apply without a question, like `--yes`; shell commands still ask) and `plan only` (no
87
+ file is written and no command is run). The mode shows in the footer.
88
+ - While an answer streams, the keyboard is locked: what you type is not echoed or kept. A tool call
89
+ that is still arriving (for example a large `write_file`) shows a live line such as
90
+ `⠋ write_file index.html · streaming 18.4 KB…`. Esc or Ctrl+C stops the request and returns to the
91
+ prompt; if a shell command is still running, press it a second time to leave. A tool call cut off
92
+ by the output limit is never run: the model is told to split the work into smaller files.
93
+
94
+ ## Hooks
95
+
96
+ `.synqa/hooks.json` in a project and `hooks.json` in the user config folder can run shell commands at
97
+ `SessionStart`, `PreToolUse`, `PostToolUse` and `Stop`:
98
+
99
+ ```json
100
+ { "hooks": { "PreToolUse": [ { "matcher": "write_file|edit_file", "command": "python check.py" } ] } }
101
+ ```
102
+
103
+ The hook gets the event as JSON on stdin. A `PreToolUse` hook blocks the tool by exiting with code 2
104
+ or printing `{"decision":"block","reason":"..."}`; other output is ignored. A command that starts
105
+ with `python` runs with `python3` (or `py` on Windows) when `python` is not on PATH. Project hooks
106
+ are repository content, so they start off: `/hooks` lists them and switches them on or off.
107
+
108
+ ### Hooks security
109
+
110
+ - Project hooks start disabled. An approval is tied to the project path and the exact entry
111
+ (command, matcher and timeout): a different folder or any edit to the entry needs a new approval.
112
+ - The hook payload includes the tool input and result (up to 4000 characters each), so a hook sees
113
+ file contents and command output.
114
+ - Never point a hook at a file the agent can write: the agent could rewrite the script that runs next.
115
+ - In plan only mode the `PostToolUse` and `Stop` hooks do not run.
116
+
117
+ ## Updates
118
+
119
+ On start `synqa` asks the npm registry once, with a 1.5 second limit and no error message, whether a
120
+ newer `@synqa/cli` exists, and shows a quiet hint in the footer. `/update` runs
121
+ `npm install -g @synqa/cli@latest`. Set `SYNQA_NO_UPDATE_CHECK=1` to skip the check.
63
122
 
64
123
  ## Use
65
124
 
@@ -72,14 +131,18 @@ Or skip login and set `SYNQA_API_KEY` in your shell. The variable wins over the
72
131
  shell command while an `AGENTS.md` is loaded, and shell commands that name a secrets file or a glob that could.
73
132
  Diffs are still shown.
74
133
  - `synqa --model sonata` : start on another tier (`blink`, `sonata`, `aeon`).
75
- - `synqa --effort low` : start with a reasoning effort (`default`, `low`, `medium`).
134
+ - `synqa --effort low` : start with a reasoning effort (`default`, `low`, `medium`, `high`).
76
135
 
77
136
  ## Slash commands
78
137
 
79
138
  | Command | What it does |
80
139
  |---|---|
81
- | `/model blink\|sonata\|aeon` | Switch tier. With no argument it shows the current one. |
82
- | `/effort default\|low\|medium` | Set the reasoning effort. `default` sends no effort setting. |
140
+ | `/model blink\|sonata\|aeon` | Choose the model. With no argument an arrow-key list opens: Blink 5.5, Sonata 6.0, Aeon 6.5 on Synqa Cloud, the provider's own model list otherwise. |
141
+ | `/effort default\|low\|medium\|high` | Set the reasoning effort (arrow-key list with no argument). `default` sends no effort setting. |
142
+ | `/provider`, `/login` | Pick Synqa Cloud, OpenCode Go, OpenCode Zen, OpenRouter or a custom endpoint. |
143
+ | `/logout` | Leave the secondary provider, or log out fully and lock the session. |
144
+ | `/hooks` | List hooks and switch them on or off. |
145
+ | `/update` | Install the latest `@synqa/cli`. |
83
146
  | `/status` | Tier, routing, session tokens, context use of the last request, and your live credit and weekly pool balance. |
84
147
  | `/init` | Scan the project and write a starter `AGENTS.md`. |
85
148
  | `/compact` | Ask the model to summarize the conversation and continue from the summary, to save context. |
@@ -107,7 +170,7 @@ The balance lookup waits at most 2 seconds; if it fails the note says `balance u
107
170
  ▄▄▄▀ ▀▄▄█ █ █ ▀▄▄█ ▀▄▄█ ▄
108
171
  ▄▄▄▀ █
109
172
 
110
- v1.1.1 · Blink 5.5 · default effort
173
+ v1.2.0 · Blink 5.5 · default effort
111
174
  ~/synqa-test
112
175
  ⎿ no AGENTS.md yet: /init writes one
113
176
  ⎿ $9.98 credit · weekly pool $4.00 of $10.00
@@ -221,7 +284,7 @@ it. Use git for that.
221
284
 
222
285
  | Name | Meaning |
223
286
  |---|---|
224
- | `SYNQA_API_KEY` | Your key; wins over the saved one (the start screen says so, and `synqa login` warns when it differs from the key you just saved). Never passed to commands the agent runs. |
287
+ | `SYNQA_API_KEY` | Your key. A key saved by `synqa login` or `/login` wins over it unless you pass `--env-key`. Never passed to commands the agent runs. |
225
288
  | `NO_COLOR` | Any non-empty value turns colour off. |
226
289
  | `~/.config/synqa/config.json` | Saved key and default tier (mode 0600). |
227
290
 
@@ -259,8 +322,8 @@ it. Use git for that.
259
322
 
260
323
  - `No API key`: run `synqa login` or set `SYNQA_API_KEY`.
261
324
  - `Your API key was rejected`: run `synqa login` again, or check on the dashboard that the key is
262
- active. If `SYNQA_API_KEY` is set in your shell, that key is used instead of the saved one: remove it
263
- (`unset SYNQA_API_KEY`, and the line in `~/.zshrc`).
325
+ active. A key saved with `/login` is used before `SYNQA_API_KEY`; `/logout` then `Full logout` clears
326
+ both in the running session.
264
327
  - `synqa login` checks the key against the same key check the chat uses, and refuses a key with no
265
328
  chat access.
266
329
  - `request timed out`: a request is cut off after 10 minutes.
@@ -270,7 +333,9 @@ it. Use git for that.
270
333
  ## Develop
271
334
 
272
335
  From the repo root: `node --test 'cli/test/*.test.ts'` (or `npm test` inside `cli/`). Tests use fake
273
- `fetch`, fake terminals and temporary folders and never call the live API.
336
+ `fetch`, fake terminals and temporary folders and never call the live API. `test/pty.test.ts` also
337
+ runs the real CLI in a pseudo-terminal through a small Python driver (standard library only) with a
338
+ throwaway home folder and no network; it skips with a message where Python or a PTY is missing.
274
339
 
275
340
  Sources are TypeScript run directly by Node (type stripping); only erasable syntax is allowed. Node
276
341
  will not strip types inside `node_modules`, so the published package ships plain JS: `npm run build`
package/bin/synqa.js CHANGED
File without changes
package/dist/agent.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { fenceText } from "./sanitize.js";
2
- import { chat, parseToolArguments } from "./api.js";
3
-
2
+ import { AbortedError, chat } from "./api.js";
3
+
4
4
  import { runTool, TOOL_DEFS } from "./tools.js";
5
5
 
6
6
 
@@ -13,7 +13,7 @@ export const MAX_NOTES_CHARS = 8000;
13
13
  // from a third party (a cloned repo), so it goes in a delimited block as data: conventions only,
14
14
  // never an instruction source. fenceText removes invisible characters and neutralizes every "<", so
15
15
  // nothing inside can open or close the block.
16
- export function systemPrompt(tier , cwd , notes ) {
16
+ export function systemPrompt(tier , cwd , notes , external = false) {
17
17
  const body = fenceText(notes ?? "", MAX_NOTES_CHARS);
18
18
  const extra = body
19
19
  ? [
@@ -26,8 +26,10 @@ export function systemPrompt(tier , cwd , notes ) {
26
26
  ]
27
27
  : [];
28
28
  return [
29
- `You are Synqa-${tier.display}, a terminal coding agent made by Synqa.`,
30
- "If asked what model you are or who created you, say you are Synqa and name your tier; say nothing else about what runs inside.",
29
+ external
30
+ ? "You are the Synqa terminal coding agent, running on a model provider the user chose. If asked who created this tool, say Synqa."
31
+ : `You are Synqa-${tier.display}, a terminal coding agent made by Synqa.`,
32
+ ...(external ? [] : ["If asked what model you are or who created you, say you are Synqa and name your tier; say nothing else about what runs inside."]),
31
33
  `Working directory: ${cwd}. File tools cannot leave it.`,
32
34
  "Use the tools to read, search and change files and to run commands. Make small, correct changes and say briefly what you did.",
33
35
  ...extra,
@@ -40,6 +42,8 @@ export function systemPrompt(tier , cwd , notes ) {
40
42
 
41
43
 
42
44
 
45
+
46
+
43
47
 
44
48
 
45
49
 
@@ -52,6 +56,16 @@ export function systemPrompt(tier , cwd , notes ) {
52
56
 
53
57
 
54
58
 
59
+
60
+
61
+
62
+
63
+
64
+
65
+ // PreToolUse and PostToolUse hooks. pre returns a reason to block the tool, or undefined to run it.
66
+
67
+
68
+
55
69
 
56
70
 
57
71
 
@@ -62,6 +76,44 @@ export function systemPrompt(tier , cwd , notes ) {
62
76
 
63
77
 
64
78
 
79
+ // What the model is told when a tool call was cut off by the output limit. No provider or model
80
+ // name, and no file content: only what to do next.
81
+ export const CUT_OFF_RESULT =
82
+ "error: this tool call was cut off before its arguments were complete (the output limit was reached), so nothing was run and nothing was written. Split the work into smaller modular files (for example styles.css, app.js and index.html), one short tool call each, and do not repeat the large call.";
83
+
84
+ // A different failure, a different message: the stream ended early (a dropped connection), not the
85
+ // output limit. The call may be fine to try again as it was.
86
+ export const CONNECTION_LOST_RESULT =
87
+ "error: the connection ended before this tool call was complete, so nothing was run and nothing was written. This is not a size problem: try the same call again.";
88
+
89
+ export const CONNECTION_LOST_NOTICE = "connection lost before the response finished; no tool was run";
90
+
91
+
92
+
93
+ // Decides whether a streamed tool call can run. Its arguments must be complete JSON (an object).
94
+ // Policy for finish_reason "length": the cap can only cut the last call, and a call whose JSON
95
+ // parsed is complete (a string closed with its quote cannot hold a truncated file), so it runs
96
+ // as usual; a call with no arguments at all is treated as cut off, because a tool that needs
97
+ // arguments was begun and never filled. Anything that does not parse is never run, and its
98
+ // arguments are stored as "{}" so the next request stays valid.
99
+ // complete: false when the stream ended without [DONE] and without a finish_reason (a dropped
100
+ // connection): then no call is trusted, whatever its arguments look like. Whatever the outcome,
101
+ // `stored` is always a valid JSON object text, so the next request cannot be refused for it.
102
+ export function checkCall(raw , finishReason , complete = true) {
103
+ if (!complete) return { ok: false, stored: "{}", result: CONNECTION_LOST_RESULT };
104
+ if (raw.trim() === "") {
105
+ return finishReason === "length" ? { ok: false, stored: "{}", result: CUT_OFF_RESULT } : { ok: true, args: {}, stored: "{}" };
106
+ }
107
+ let v ;
108
+ try {
109
+ v = JSON.parse(raw);
110
+ } catch {
111
+ return { ok: false, stored: "{}", result: CUT_OFF_RESULT };
112
+ }
113
+ if (!v || typeof v !== "object" || Array.isArray(v)) return { ok: false, stored: "{}", result: "error: tool arguments must be a JSON object" };
114
+ return { ok: true, args: v , stored: raw };
115
+ }
116
+
65
117
  // Runs model -> tools -> model until the model answers without tool calls. Mutates history.
66
118
  export async function runAgent(o ) {
67
119
  const total = { prompt: 0, completion: 0 };
@@ -75,14 +127,18 @@ export async function runAgent(o ) {
75
127
  let routing = null;
76
128
  let lastUsage = { prompt: 0, completion: 0 };
77
129
  for (let step = 0; step < MAX_STEPS; step++) {
130
+ if (o.signal?.aborted) throw new AbortedError();
78
131
  o.events.onRequest?.();
79
132
  const r = await chat({
80
133
  apiKey: o.apiKey,
81
- model: o.tier.id,
134
+ model: o.modelId ?? o.tier.id,
135
+ baseUrl: o.baseUrl,
82
136
  messages: o.history,
83
137
  tools: TOOL_DEFS,
84
138
  fetchFn: o.fetchFn,
85
139
  onText: o.events.onText,
140
+ onToolProgress: o.events.onToolProgress,
141
+ signal: o.signal,
86
142
  timeoutMs: o.timeoutMs,
87
143
  reasoningEffort: o.effort && o.effort !== "default" ? o.effort : undefined,
88
144
  now: o.now,
@@ -99,23 +155,37 @@ export async function runAgent(o ) {
99
155
  lastUsage = r.usage;
100
156
  }
101
157
  o.events.onTurn(r);
158
+ if (r.complete === false && r.toolCalls.length) o.events.onNotice?.(CONNECTION_LOST_NOTICE);
159
+ const checks = r.toolCalls.map((c) => checkCall(c.arguments, r.finishReason, r.complete !== false));
102
160
  const assistant = { role: "assistant", content: r.content || null };
103
161
  if (r.toolCalls.length) {
104
- assistant.tool_calls = r.toolCalls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.arguments } }));
162
+ assistant.tool_calls = r.toolCalls.map((c, i) => ({ id: c.id, type: "function", function: { name: c.name, arguments: (checks[i] ).stored } }));
105
163
  }
106
164
  o.history.push(assistant);
107
165
  if (!r.toolCalls.length) return result();
108
- for (const c of r.toolCalls) {
109
- o.events.onTool(c.name, c.arguments);
166
+ // Every call gets exactly one result, also when the person stops the run between calls.
167
+ for (const [i, c] of r.toolCalls.entries()) {
168
+ const check = checks[i] ;
169
+ const shownArgs = check.stored;
170
+ o.events.onTool(c.name, shownArgs);
110
171
  let result ;
111
- try {
112
- result = await runTool(o.ctx, c.name, parseToolArguments(c.arguments));
113
- } catch (err) {
114
- result = `error: ${err instanceof Error ? err.message : String(err)}`;
172
+ if (o.signal?.aborted) {
173
+ result = "error: stopped by the user before this tool ran";
174
+ } else if (!check.ok) {
175
+ result = check.result;
176
+ } else {
177
+ try {
178
+ const blocked = await o.hooks?.pre(c.name, c.arguments);
179
+ result = blocked !== undefined ? `blocked by a hook: ${blocked}` : await runTool(o.ctx, c.name, check.args);
180
+ if (blocked === undefined) await o.hooks?.post(c.name, c.arguments, result);
181
+ } catch (err) {
182
+ result = `error: ${err instanceof Error ? err.message : String(err)}`;
183
+ }
115
184
  }
116
- o.events.onToolResult?.(c.name, c.arguments, result);
185
+ o.events.onToolResult?.(c.name, shownArgs, result);
117
186
  o.history.push({ role: "tool", tool_call_id: c.id, content: result });
118
187
  }
188
+ if (o.signal?.aborted) throw new AbortedError();
119
189
  }
120
190
  o.events.onText("\n[stopped: step limit reached]\n");
121
191
  return result();
package/dist/api.js CHANGED
@@ -14,14 +14,42 @@ import { REQUEST_TIMEOUT_MS, MAX_OUTPUT_TOKENS, usageFrom } from "./tiers.js";
14
14
 
15
15
 
16
16
 
17
+
18
+
17
19
 
18
20
 
19
-
21
+
22
+
23
+ // What the terminal shows while a tool call is still streaming in.
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+ const PATH_SCAN_CHARS = 4096;
32
+
33
+ // The "path" argument out of a possibly cut-off JSON object. Only a string whose closing quote has
34
+ // arrived counts, so a half-received path is never shown. A "path" key inside another string value
35
+ // is escaped there (\"path\"), so it cannot match.
36
+ export function partialPath(raw ) {
37
+ const m = /"path"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(raw);
38
+ if (!m) return undefined;
39
+ try {
40
+ const v = JSON.parse(`"${m[1]}"`);
41
+ return typeof v === "string" && v !== "" ? v : undefined;
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ }
20
46
 
21
47
  // Folds streamed chunks into text and complete tool calls.
22
48
  export class StreamAccumulator {
23
49
  content = "";
24
50
  usage ;
51
+ finishReason ;
52
+ onToolProgress ;
25
53
  calls = new Map ();
26
54
 
27
55
  push(chunk ) {
@@ -32,14 +60,27 @@ export class StreamAccumulator {
32
60
  text = delta.content;
33
61
  this.content += text;
34
62
  }
63
+ if (typeof choice?.finish_reason === "string") this.finishReason = choice.finish_reason;
35
64
  if (delta && Array.isArray(delta.tool_calls)) {
36
65
  for (const tc of delta.tool_calls) {
37
66
  const idx = typeof tc.index === "number" ? tc.index : 0;
38
- const cur = this.calls.get(idx) ?? { id: "", name: "", arguments: "" };
67
+ const cur = this.calls.get(idx) ?? { id: "", name: "", arguments: "", bytes: 0 };
39
68
  if (tc.id) cur.id = tc.id;
40
69
  if (tc.function?.name) cur.name += tc.function.name;
41
- if (typeof tc.function?.arguments === "string") cur.arguments += tc.function.arguments;
70
+ if (typeof tc.function?.arguments === "string") {
71
+ cur.arguments += tc.function.arguments;
72
+ cur.bytes += Buffer.byteLength(tc.function.arguments);
73
+ }
42
74
  this.calls.set(idx, cur);
75
+ if (this.onToolProgress && cur.name) {
76
+ // The path is looked for only near the start and only until it is found, so a 60 KB
77
+ // argument string is not rescanned on every chunk.
78
+ if (cur.path === undefined && !cur.scanned) {
79
+ cur.path = partialPath(cur.arguments.slice(0, PATH_SCAN_CHARS));
80
+ if (cur.arguments.length >= PATH_SCAN_CHARS) cur.scanned = true;
81
+ }
82
+ this.onToolProgress({ index: idx, name: cur.name, bytes: cur.bytes, path: cur.path });
83
+ }
43
84
  }
44
85
  }
45
86
  const u = usageFrom(chunk?.usage);
@@ -66,7 +107,7 @@ export function parseToolArguments(raw ) {
66
107
  }
67
108
 
68
109
  // Parses an SSE byte stream into JSON events; "[DONE]" ends it.
69
- export async function* sseEvents(body ) {
110
+ export async function* sseEvents(body , state ) {
70
111
  const dec = new TextDecoder();
71
112
  let buf = "";
72
113
  for await (const part of body) {
@@ -77,7 +118,7 @@ export async function* sseEvents(body )
77
118
  buf = buf.slice(nl + 1);
78
119
  if (!line.startsWith("data:")) continue;
79
120
  const data = line.slice(5).trim();
80
- if (data === "[DONE]") return;
121
+ if (data === "[DONE]") { if (state) state.done = true; return; }
81
122
  if (data) yield JSON.parse(data);
82
123
  }
83
124
  }
@@ -141,20 +182,34 @@ export async function checkKey(apiKey , fetchFn = fetch)
141
182
 
142
183
 
143
184
 
185
+
144
186
 
187
+
188
+
145
189
 
146
190
 
147
191
 
148
192
 
149
193
 
194
+ // Thrown when the person aborts a request; not a failure to report as an error.
195
+ export class AbortedError extends Error {
196
+ constructor() {
197
+ super("interrupted");
198
+ this.name = "AbortedError";
199
+ }
200
+ }
201
+
150
202
  export async function chat(o ) {
151
203
  const fetchFn = o.fetchFn ?? fetch;
152
204
  const now = o.now ?? (() => performance.now());
153
205
  const t0 = now();
154
206
  const ac = new AbortController();
155
207
  const timer = setTimeout(() => ac.abort(), o.timeoutMs ?? REQUEST_TIMEOUT_MS);
208
+ const onUserAbort = () => ac.abort();
209
+ if (o.signal?.aborted) { clearTimeout(timer); throw new AbortedError(); }
210
+ o.signal?.addEventListener("abort", onUserAbort, { once: true });
156
211
  try {
157
- const res = await fetchFn(`${API_BASE}/chat/completions`, {
212
+ const res = await fetchFn(`${(o.baseUrl ?? API_BASE).replace(/\/+$/, "")}/chat/completions`, {
158
213
  method: "POST",
159
214
  headers: { authorization: `Bearer ${o.apiKey}`, "content-type": "application/json" },
160
215
  body: JSON.stringify({
@@ -171,8 +226,10 @@ export async function chat(o ) {
171
226
  if (!res.ok) throw new ApiError(res.status, friendlyStatus(res.status, res.status === 401 ? "" : await detailOf(res)));
172
227
  if (!res.body) throw new ApiError(res.status, "the service sent an empty answer");
173
228
  const acc = new StreamAccumulator();
229
+ acc.onToolProgress = o.onToolProgress;
174
230
  let firstAt ;
175
- for await (const ev of sseEvents(res.body )) {
231
+ const sseState = { done: false };
232
+ for await (const ev of sseEvents(res.body , sseState)) {
176
233
  const t = acc.push(ev);
177
234
  if (firstAt === undefined && acc.hasOutput()) firstAt = now();
178
235
  if (t && o.onText) o.onText(t);
@@ -183,14 +240,18 @@ export async function chat(o ) {
183
240
  toolCalls: acc.toolCalls(),
184
241
  usage: acc.usage,
185
242
  routing: res.headers.get("x-synqa-routing"),
243
+ finishReason: acc.finishReason,
244
+ complete: sseState.done || acc.finishReason !== undefined,
186
245
  ttftMs: firstAt === undefined ? undefined : firstAt - t0,
187
246
  genMs: firstAt === undefined ? undefined : end - firstAt,
188
247
  };
189
248
  } catch (err) {
249
+ if (o.signal?.aborted) throw new AbortedError();
190
250
  if (ac.signal.aborted) throw new Error("request timed out");
191
251
  throw err;
192
252
  } finally {
193
253
  clearTimeout(timer);
254
+ o.signal?.removeEventListener("abort", onUserAbort);
194
255
  }
195
256
  }
196
257