@synqa/cli 1.1.0 → 1.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/README.md CHANGED
@@ -12,7 +12,7 @@ Blink 5.5, Sonata 6.0 and Aeon 6.5.
12
12
  - [Slash commands](#slash-commands)
13
13
  - [Welcome panel and turn footer](#welcome-panel-and-turn-footer)
14
14
  - [Diff preview](#diff-preview)
15
- - [Status bar](#status-bar)
15
+ - [Status line](#status-line)
16
16
  - [AGENTS.md and /init](#agentsmd-and-init)
17
17
  - [Undo](#undo)
18
18
  - [Settings and environment](#settings-and-environment)
@@ -22,7 +22,7 @@ Blink 5.5, Sonata 6.0 and Aeon 6.5.
22
22
 
23
23
  ## Install
24
24
 
25
- Needs Node.js 22.18 or newer (or 23.6 or newer) and npm. There are no other dependencies. **Not published to npm yet:** `npx` and the installer work only after the first release.
25
+ Needs Node.js 22.18 or newer (or 23.6 or newer) and npm. There are no other dependencies.
26
26
 
27
27
  Try it without installing:
28
28
 
@@ -59,72 +59,146 @@ 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
+
89
+ ## Hooks
90
+
91
+ `.synqa/hooks.json` in a project and `hooks.json` in the user config folder can run shell commands at
92
+ `SessionStart`, `PreToolUse`, `PostToolUse` and `Stop`:
93
+
94
+ ```json
95
+ { "hooks": { "PreToolUse": [ { "matcher": "write_file|edit_file", "command": "python check.py" } ] } }
96
+ ```
97
+
98
+ The hook gets the event as JSON on stdin. A `PreToolUse` hook blocks the tool by exiting with code 2
99
+ or printing `{"decision":"block","reason":"..."}`; other output is ignored. A command that starts
100
+ with `python` runs with `python3` (or `py` on Windows) when `python` is not on PATH. Project hooks
101
+ are repository content, so they start off: `/hooks` lists them and switches them on or off.
102
+
103
+ ### Hooks security
104
+
105
+ - Project hooks start disabled. An approval is tied to the project path and the exact entry
106
+ (command, matcher and timeout): a different folder or any edit to the entry needs a new approval.
107
+ - The hook payload includes the tool input and result (up to 4000 characters each), so a hook sees
108
+ file contents and command output.
109
+ - Never point a hook at a file the agent can write: the agent could rewrite the script that runs next.
110
+ - In plan only mode the `PostToolUse` and `Stop` hooks do not run.
111
+
112
+ ## Updates
113
+
114
+ On start `synqa` asks the npm registry once, with a 1.5 second limit and no error message, whether a
115
+ newer `@synqa/cli` exists, and shows a quiet hint in the footer. `/update` runs
116
+ `npm install -g @synqa/cli@latest`. Set `SYNQA_NO_UPDATE_CHECK=1` to skip the check.
63
117
 
64
118
  ## Use
65
119
 
66
120
  - `synqa` : interactive session in the current folder.
67
121
  - `synqa "fix the failing test"` : one task, then exit.
68
- - `synqa --yes "..."` : run unattended. **`--yes` runs shell commands without asking, so use it only in repositories you trust**
122
+ - `synqa --yes "..."` : approve ordinary file writes and edits inside the folder without asking. **Shell commands always ask, even with `--yes`** (any file the agent reads could carry an injected command), and `--yes` is not allowed in your home folder
69
123
  (text in a README or a web page can steer the model). Some things still ask, and are denied when nobody can answer:
70
124
  writes that can run code later (git hooks and other `.git` files, `package.json`, Makefiles, `.envrc`, `.vscode`
71
125
  settings, CI workflows, shell startup files, dotfiles, executables), writes to secrets files, every write and
72
126
  shell command while an `AGENTS.md` is loaded, and shell commands that name a secrets file or a glob that could.
73
127
  Diffs are still shown.
74
128
  - `synqa --model sonata` : start on another tier (`blink`, `sonata`, `aeon`).
75
- - `synqa --effort low` : start with a reasoning effort (`default`, `low`, `medium`).
129
+ - `synqa --effort low` : start with a reasoning effort (`default`, `low`, `medium`, `high`).
76
130
 
77
131
  ## Slash commands
78
132
 
79
133
  | Command | What it does |
80
134
  |---|---|
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. |
135
+ | `/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. |
136
+ | `/effort default\|low\|medium\|high` | Set the reasoning effort (arrow-key list with no argument). `default` sends no effort setting. |
137
+ | `/provider`, `/login` | Pick Synqa Cloud, OpenCode Go, OpenCode Zen, OpenRouter or a custom endpoint. |
138
+ | `/logout` | Leave the secondary provider, or log out fully and lock the session. |
139
+ | `/hooks` | List hooks and switch them on or off. |
140
+ | `/update` | Install the latest `@synqa/cli`. |
83
141
  | `/status` | Tier, routing, session tokens, context use of the last request, and your live credit and weekly pool balance. |
84
142
  | `/init` | Scan the project and write a starter `AGENTS.md`. |
85
143
  | `/compact` | Ask the model to summarize the conversation and continue from the summary, to save context. |
86
144
  | `/undo` | Revert the last file the agent wrote. Run it again to go further back. |
87
145
  | `/clear` | Start a fresh conversation (reloads `AGENTS.md`). |
88
- | `/help` | List the commands. |
146
+ | `/help` | List the commands, grouped, with the keys of the command menu. |
89
147
  | `/exit` | Leave (also `/quit`, or Ctrl-D). |
90
148
 
91
149
  `/status` reads your balance from the Synqa account service with your key. If the service does not
92
150
  answer, it says so and shows everything else; your balance is always on the
93
151
  [dashboard](https://synqa.ca/client-dashboard).
94
152
 
95
- ## Welcome panel and turn footer
153
+ ## Start screen, prompt and turn footer
96
154
 
97
- In an interactive terminal the session opens with a welcome panel: the CLI version, the tier id and
98
- name, the workspace folder (your home folder shown as `~`), whether `AGENTS.md` project notes were
99
- loaded, and your credit and weekly pool balance from the Synqa account service. The balance lookup
100
- waits at most 2 seconds; if it fails the panel says `balance unavailable`, and `balance not available yet`
101
- when the service does not offer it to API keys yet. Your key is never shown.
155
+ In an interactive terminal the session opens with the Synqa wordmark (on a window of 60 columns or
156
+ more; a one-line bold `Synqa.` below that), then dim lines with the CLI version, tier and effort, and
157
+ the workspace folder (your home folder shown as `~`), then short `⎿` notes: whether `AGENTS.md`
158
+ project notes were loaded, and your credit and weekly pool balance from the Synqa account service.
159
+ The balance lookup waits at most 2 seconds; if it fails the note says `balance unavailable`, and
160
+ `balance not available yet` when the service does not offer it to API keys yet. Your key is never shown.
102
161
 
103
162
  ```
104
- ╭─ Synqa. CLI v1.1.0 ───────────────────────────────────────────╮
105
- │ ╭────╮ Tier synqa-sonata-6.0 (Sonata 6.0) │
106
- │ │ S. │ Workspace ~/proj │
107
- │ ╰────╯ Notes AGENTS.md loaded (1,234 chars) │
108
- │ Balance $9.98 credit · weekly pool $4.00 of $10.00 │
109
- ╰───────────────────────────────────────────────────────────────╯
163
+ ▄▀▀▀
164
+ ▀▀▄ █ █ █▀▀▄ ▄▀▀█ ▄▀▀█
165
+ ▄▄▄▀ ▀▄▄█ █ █ ▀▄▄█ ▀▄▄█ ▄
166
+ ▄▄▄▀ █
167
+
168
+ v1.2.0 · Blink 5.5 · default effort
169
+ ~/synqa-test
170
+ ⎿ no AGENTS.md yet: /init writes one
171
+ ⎿ $9.98 credit · weekly pool $4.00 of $10.00
172
+ ───────────────────────────────────────────────────────────────────────────────
173
+ ❯ Try "explain this project", or type / for commands
174
+ ───────────────────────────────────────────────────────────────────────────────
175
+ Blink 5.5 · synqa-test · 0 tokens $9.98 credit
176
+ / commands · ↑↓ history · tab complete · ctrl+c quit
110
177
  ```
111
178
 
112
- On a terminal that is not UTF-8 (or `TERM=dumb`, or a very old Windows console) the panel is plain ASCII:
113
-
114
- ```
115
- [S.] Synqa. CLI v1.1.0
116
- Tier synqa-sonata-6.0 (Sonata 6.0)
117
- Workspace ~/proj
118
- Notes AGENTS.md loaded (1,234 chars)
119
- Balance $9.98 credit | weekly pool $4.00 of $10.00
120
- ```
121
-
122
- Colours: terracotta for the period, spinner, `❯` prompt and monogram; warm bone for the name and the
123
- tier; warm stone for borders, labels and the footer; sage for the balance and added diff lines
124
- (removed lines stay red). 24-bit colour is used when `COLORTERM` is `truecolor` or `24bit`, otherwise
125
- the nearest of the 256 terminal colours. Stone is a mid grey: row labels are bold to stay readable,
126
- but on a light terminal background borders and the footer are faint.
127
-
179
+ The letters are drawn in your terminal's own text colour, so they read as ink on a light theme and
180
+ light on a dark one; the full stop is terracotta. The input sits between two thin rules, with a dim
181
+ hint while it is empty. Under it the status line shows only what is known (see below) and a line of
182
+ key hints.
183
+
184
+ Type `/` and a menu of commands opens under the input, filtered as you type, with a short
185
+ description of each. Up and Down move the highlight, Tab or Enter completes it (Enter runs a command
186
+ that takes no argument), Esc closes the menu. An unknown command gets a one-line hint with the
187
+ nearest name. The menu is drawn with Node's own readline key events, no extra package.
188
+
189
+ On a terminal that is not UTF-8 (or `TERM=dumb`, or a very old Windows console) everything is plain
190
+ ASCII and there is no menu or frame: the status is printed above a plain `>` prompt.
191
+
192
+ Colours: terracotta for the full stop, spinner, `❯` prompt, tier name and the selected menu row;
193
+ the terminal's own text colour (bold) for names, or warm bone when the terminal reports a dark
194
+ background (`COLORFGBG`); warm stone for rules and labels; sage for the balance and added diff lines
195
+ (removed lines stay red). 24-bit colour is used only where it is known to work: `COLORTERM` is
196
+ `truecolor` or `24bit`, or `TERM_PROGRAM` is iTerm2, WezTerm, VS Code, Ghostty and similar. macOS
197
+ Terminal.app (`TERM_PROGRAM=Apple_Terminal`) and unknown terminals get the 256 standard colours, with
198
+ terracotta mapped to colour 166 so it stays burnt orange; Terminal.app gets them even when a
199
+ `COLORTERM` was copied into the shell profile, because it misdraws 24-bit escapes. Every line is
200
+ measured by its visible width (colour codes not counted, wide characters counted as two) and kept
201
+ one column short of the window, so nothing wraps.
128
202
 
129
203
  While the agent waits for the first token a spinner with the elapsed seconds is shown (`⠋ waiting 1.2s`)
130
204
  and is erased when output starts. Each tool call is drawn as a card: a header with the tool and its
@@ -143,10 +217,10 @@ file or command, the diff preview for edits inside it, and a one line result.
143
217
  After every turn a dim footer line stays in the scrollback: time to first token, output speed,
144
218
  routing (`local` or `hybrid`) and your credit left (only when the balance is known).
145
219
 
146
- The panel, spinner, cards and footer degrade on their own: with `NO_COLOR` they have no colour; when
220
+ The start screen, spinner, cards and footer degrade on their own: with `NO_COLOR` they have no colour; when
147
221
  output is not a terminal (a pipe, a file, a one-shot task) there is no spinner, no cursor control and
148
222
  the old plain output; when the terminal is not UTF-8, is `TERM=dumb`, or is the old Windows console
149
- (Windows Terminal is fine), the panel, cards and spinner use plain ASCII (`[S.]`, `+-`, `| / - \`). Long paths are cut with an
223
+ (Windows Terminal is fine), the start screen, cards and spinner use plain ASCII (`+-`, `| / - \`). Long paths are cut with an
150
224
  ellipsis to fit narrow windows. Text from the model or from tools never reaches the terminal as
151
225
  control sequences.
152
226
 
@@ -161,24 +235,26 @@ shell startup files, other dotfiles): those are always shown in full.
161
235
  Colour is off when output is not a terminal or when `NO_COLOR` is set; the diff is then plain `+`/`-`
162
236
  text. Control characters in file content are shown as `\x1b`-style escapes, never sent to your terminal.
163
237
 
164
- ## Status bar
238
+ ## Status line
165
239
 
166
- In an interactive terminal the last row of the screen stays pinned and shows:
240
+ In an interactive terminal the line under the input shows only values that are known:
167
241
 
168
242
  ```
169
- Blink 5.5 | local | TTFT 0.42s | 87.3 tok/s | 1,234 tokens
243
+ Blink 5.5 · low effort · synqa-test · ctx 10% · hybrid · 1,234 tokens $9.98 credit
170
244
  ```
171
245
 
172
- - Tier display name (`Blink 5.5`, `Sonata 6.0` or `Aeon 6.5`), plus `effort low` or `effort medium` when set.
246
+ - Tier display name (`Blink 5.5`, `Sonata 6.0` or `Aeon 6.5`), plus `low effort` or `medium effort` when set.
247
+ - The workspace folder name.
248
+ - `ctx`: how much of the context window the last request used (after the first request).
173
249
  - Routing: `local` or `hybrid`, read from the `x-synqa-routing` response header of the last request
174
- (`fallback` for a fallback route, `?` if the server did not say).
175
- - TTFT: seconds from sending the request to the first token. tok/s: output tokens per second of
176
- generation over the last turn. A dash means the number was not reported.
250
+ (`fallback` for a fallback route); left out when the server did not say.
177
251
  - Session tokens: input plus output so far.
252
+ - On the right, your credit when the balance is known.
178
253
 
179
- On a narrow terminal the least important parts drop off first. When the output is not a terminal
180
- (a pipe, a file, a very small window, `TERM=dumb`) there is no pinned bar: the same line is printed
181
- above each prompt, and after a one-shot task.
254
+ On a narrow terminal the least important parts drop off first. Time to first token and output speed
255
+ are in the footer after each turn. When the output is not a terminal (a pipe, a file, `TERM=dumb`)
256
+ a plain line (`Blink 5.5 | local | TTFT 0.42s | 87.3 tok/s | 1,234 tokens`, unknown parts left out)
257
+ is printed above each prompt, and after a one-shot task.
182
258
 
183
259
  ## AGENTS.md and /init
184
260
 
@@ -203,7 +279,7 @@ it. Use git for that.
203
279
 
204
280
  | Name | Meaning |
205
281
  |---|---|
206
- | `SYNQA_API_KEY` | Your key; wins over the saved one. Never passed to commands the agent runs. |
282
+ | `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. |
207
283
  | `NO_COLOR` | Any non-empty value turns colour off. |
208
284
  | `~/.config/synqa/config.json` | Saved key and default tier (mode 0600). |
209
285
 
@@ -211,7 +287,7 @@ it. Use git for that.
211
287
 
212
288
  - File tools only touch files inside the folder you started in (`..`, absolute paths and symlinks
213
289
  out of it are refused).
214
- - Writes, edits and shell commands ask `y/N` first, also in one-shot mode, unless `--yes`. Without a
290
+ - Writes and edits ask `y/N` first, also in one-shot mode, unless `--yes`; shell commands always ask. Every question shows the whole diff (only the display after a `--yes` approval is cut). Commands run without credential-looking environment variables (tokens, secrets, keys and passwords by name, `AWS_*` except region and profile, git askpass helpers, connection strings with a password in them, the API key, and `SSH_AUTH_SOCK`: run `git push` over SSH in your own terminal). `grep` stops a pattern that runs longer than 10 seconds. Without a
215
291
  terminal the answer is no.
216
292
  - Shell commands are not confined to the folder: read each one before you answer.
217
293
  - Writes to `.git/hooks`, `package.json`, shell startup files and other dotfiles carry a warning, because
@@ -220,21 +296,33 @@ it. Use git for that.
220
296
  delimited block as conventions only, you are told when it is loaded, and while it is loaded shell
221
297
  commands always ask first, even with `--yes`.
222
298
  - Files that hold secrets (`.env` and `.env.*` except examples, `.envrc`, `*.pem`, `*.key`, `*.tfvars`, `id_*`
223
- private keys, `keys.json`, `credentials.json`, `secrets.*`, `.ssh/`, `.aws/`, `.gnupg/`, `.kube/config`,
224
- `.docker/config.json`, `.netrc`, `.npmrc`, `.pypirc`, `.git-credentials`, `.dev.vars`, this CLI's own config)
225
- are opened (read or edit) only after you say yes, never with `--yes`, and are skipped by `grep` and `glob`.
299
+ private keys, `keys.json`, `credentials.json`, `secrets.*`, `.ssh/`, `.aws/`, `.gnupg/`, `.kube/`,
300
+ `.docker/config.json`, `.netrc`, `.npmrc`, `.pypirc`, `.git-credentials`, `.dev.vars`, `.config/gh`,
301
+ `.config/gcloud`, `.azure/`, `.password-store/`, macOS keychains (`Library/Keychains`, `*.keychain-db`),
302
+ browser profiles (Chrome, Chromium, Brave, Edge, Arc, Firefox, Safari, cookies and saved logins), this
303
+ CLI's own config) are never read, written or edited by the agent, with or without `--yes`, and are
304
+ skipped by `grep` and `glob`. A shell command that names one always asks first, even with `--yes`.
226
305
  Names match without regard to case or full-width letters, as on a case-insensitive Mac.
227
- - The CLI refuses to start in your home folder or the filesystem root (it could reach your keys and
228
- settings there). Pass `--allow-home` only if you mean it.
306
+ - The CLI starts in any folder, your home folder included, like other coding CLIs. Inside your home
307
+ folder everything under a dot entry at its top (`~/.zsh_history`, `~/.config/...`, `~/.cargo/...`,
308
+ any tool's settings) and everything under `~/Library` is protected as a rule, whether or not it is
309
+ named above; your projects' own dot files (`~/proj/.github`) are not affected. `--yes` is refused
310
+ in the home folder itself. Writes to launchd agents, autostart entries, systemd units, fish and
311
+ other shell startup files and `~/.local/bin` always ask. Only the filesystem root (`/`) is refused
312
+ as a start folder. `--allow-home` is still accepted and does nothing.
229
313
  - Your key is never printed, never put on a command line, and removed from the environment of every
230
314
  command the agent runs.
231
315
 
232
316
  ## Troubleshooting
233
317
 
234
318
  - `No API key`: run `synqa login` or set `SYNQA_API_KEY`.
235
- - `the key was rejected`: the key was revoked or mistyped; make a new one on the dashboard.
319
+ - `Your API key was rejected`: run `synqa login` again, or check on the dashboard that the key is
320
+ active. A key saved with `/login` is used before `SYNQA_API_KEY`; `/logout` then `Full logout` clears
321
+ both in the running session.
322
+ - `synqa login` checks the key against the same key check the chat uses, and refuses a key with no
323
+ chat access.
236
324
  - `request timed out`: a request is cut off after 10 minutes.
237
- - Status bar garbled after resizing: it repaints on resize.
325
+ - Stray characters or a broken layout in Terminal.app: update to 1.1.1, which uses 256 colours there.
238
326
  - No colour: check `NO_COLOR` and that stdout is a terminal.
239
327
 
240
328
  ## Develop
package/bin/synqa.js CHANGED
File without changes
package/dist/agent.js CHANGED
@@ -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,
@@ -52,6 +54,15 @@ export function systemPrompt(tier , cwd , notes ) {
52
54
 
53
55
 
54
56
 
57
+
58
+
59
+
60
+
61
+
62
+ // PreToolUse and PostToolUse hooks. pre returns a reason to block the tool, or undefined to run it.
63
+
64
+
65
+
55
66
 
56
67
 
57
68
 
@@ -78,7 +89,8 @@ export async function runAgent(o ) {
78
89
  o.events.onRequest?.();
79
90
  const r = await chat({
80
91
  apiKey: o.apiKey,
81
- model: o.tier.id,
92
+ model: o.modelId ?? o.tier.id,
93
+ baseUrl: o.baseUrl,
82
94
  messages: o.history,
83
95
  tools: TOOL_DEFS,
84
96
  fetchFn: o.fetchFn,
@@ -109,7 +121,9 @@ export async function runAgent(o ) {
109
121
  o.events.onTool(c.name, c.arguments);
110
122
  let result ;
111
123
  try {
112
- result = await runTool(o.ctx, c.name, parseToolArguments(c.arguments));
124
+ const blocked = await o.hooks?.pre(c.name, c.arguments);
125
+ result = blocked !== undefined ? `blocked by a hook: ${blocked}` : await runTool(o.ctx, c.name, parseToolArguments(c.arguments));
126
+ if (blocked === undefined) await o.hooks?.post(c.name, c.arguments, result);
113
127
  } catch (err) {
114
128
  result = `error: ${err instanceof Error ? err.message : String(err)}`;
115
129
  }
package/dist/api.js CHANGED
@@ -1,4 +1,5 @@
1
- import { API_BASE } from "./config.js";
1
+ import { API_BASE, DASHBOARD_URL } from "./config.js";
2
+ import { sanitize } from "./sanitize.js";
2
3
  import { REQUEST_TIMEOUT_MS, MAX_OUTPUT_TOKENS, usageFrom } from "./tiers.js";
3
4
 
4
5
 
@@ -90,9 +91,48 @@ export class ApiError extends Error {
90
91
  }
91
92
  }
92
93
 
94
+ export const KEY_REJECTED = `Your API key was rejected. Run \`synqa login\` again, or check the key is active in your Synqa dashboard (${DASHBOARD_URL}).`;
95
+
96
+ // The gateway's short reason ({"detail": "..."}), already white-labelled by the server; sanitized
97
+ // and cut here because it is network text.
98
+ async function detailOf(res ) {
99
+ try {
100
+ const text = (await res.text()).slice(0, 4000);
101
+ const v = JSON.parse(text) ;
102
+ const d = typeof v.detail === "string" ? v.detail : typeof v.error?.message === "string" ? v.error.message : "";
103
+ return sanitize(d).replace(/\s+/g, " ").trim().slice(0, 200);
104
+ } catch {
105
+ return "";
106
+ }
107
+ }
108
+
109
+ // A plain sentence for a failed request, by status. Never the raw body.
110
+ export function friendlyStatus(status , detail = "") {
111
+ const why = detail ? ` (${detail})` : "";
112
+ if (status === 401) return KEY_REJECTED;
113
+ if (status === 402) return `Your Synqa balance is used up or your plan is not active${why}. Top up in your Synqa dashboard (${DASHBOARD_URL}).`;
114
+ if (status === 403) return `This key is not allowed to do that${why}. Check the key's access in your Synqa dashboard (${DASHBOARD_URL}).`;
115
+ if (status === 404) return "The Synqa service did not recognise this request (404). Update the CLI: npm i -g @synqa/cli";
116
+ if (status === 413) return `The conversation is too large to send${why}. Run /compact or /clear and try again.`;
117
+ if (status === 429) return `Rate limit reached${why}. Wait a moment and try again.`;
118
+ if (status === 503) return `Synqa is busy or paused right now${why}. Try again in a minute.`;
119
+ if (status >= 500) return `The Synqa service had a problem (status ${status}). Try again in a minute.`;
120
+ return `The request failed with status ${status}${why}.`;
121
+ }
122
+
123
+ // Login check: GET /v1/models goes through the same key check as chat (the gateway's caller_key),
124
+ // and the list is empty for a key without the chat capability, so an empty list is refused too.
93
125
  export async function checkKey(apiKey , fetchFn = fetch) {
94
126
  const res = await fetchFn(`${API_BASE}/models`, { headers: { authorization: `Bearer ${apiKey}` } });
95
- if (!res.ok) throw new ApiError(res.status, res.status === 401 || res.status === 403 ? "the key was rejected" : `unexpected status ${res.status}`);
127
+ if (!res.ok) throw new ApiError(res.status, res.status === 401 ? "the key was rejected" : friendlyStatus(res.status, await detailOf(res)));
128
+ let ids = [];
129
+ try {
130
+ const body = (await res.json()) ;
131
+ ids = Array.isArray(body.data) ? body.data : [];
132
+ } catch {
133
+ throw new ApiError(res.status, "the service answer was not understood");
134
+ }
135
+ if (ids.length === 0) throw new ApiError(403, "this key has no chat access; check it in your Synqa dashboard");
96
136
  }
97
137
 
98
138
 
@@ -101,6 +141,7 @@ export async function checkKey(apiKey , fetchFn = fetch)
101
141
 
102
142
 
103
143
 
144
+
104
145
 
105
146
 
106
147
 
@@ -114,7 +155,7 @@ export async function chat(o ) {
114
155
  const ac = new AbortController();
115
156
  const timer = setTimeout(() => ac.abort(), o.timeoutMs ?? REQUEST_TIMEOUT_MS);
116
157
  try {
117
- const res = await fetchFn(`${API_BASE}/chat/completions`, {
158
+ const res = await fetchFn(`${(o.baseUrl ?? API_BASE).replace(/\/+$/, "")}/chat/completions`, {
118
159
  method: "POST",
119
160
  headers: { authorization: `Bearer ${o.apiKey}`, "content-type": "application/json" },
120
161
  body: JSON.stringify({
@@ -128,7 +169,8 @@ export async function chat(o ) {
128
169
  }),
129
170
  signal: ac.signal,
130
171
  });
131
- if (!res.ok || !res.body) throw new ApiError(res.status, `request failed with status ${res.status}`);
172
+ if (!res.ok) throw new ApiError(res.status, friendlyStatus(res.status, res.status === 401 ? "" : await detailOf(res)));
173
+ if (!res.body) throw new ApiError(res.status, "the service sent an empty answer");
132
174
  const acc = new StreamAccumulator();
133
175
  let firstAt ;
134
176
  for await (const ev of sseEvents(res.body )) {