@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/dist/prompt.js CHANGED
@@ -11,18 +11,24 @@ import { emitKeypressEvents } from "node:readline";
11
11
 
12
12
  import { bold, dim, stone, terracottaBold } from "./theme.js";
13
13
  import { fit, oneLine, pad, PLACEHOLDER, rule, styleOf, textWidth } from "./ui.js";
14
+
14
15
 
15
16
 
16
17
 
17
18
 
18
19
  export const SLASH_COMMANDS = [
19
- { name: "/model", args: "blink|sonata|aeon", desc: "Switch tier", group: "Session" },
20
- { name: "/effort", args: "default|low|medium", desc: "Set reasoning effort", group: "Session" },
20
+ { name: "/model", args: "blink|sonata|aeon", desc: "Choose the model (arrow keys)", group: "Session" },
21
+ { name: "/effort", args: "default|low|medium|high", desc: "Set reasoning effort (arrow keys)", group: "Session" },
22
+ { name: "/provider", desc: "Switch provider: Synqa Cloud, OpenCode, ...", group: "Session" },
23
+ { name: "/login", desc: "Sign in to a provider", group: "Session" },
24
+ { name: "/logout", desc: "Sign out of a provider or fully", group: "Session" },
21
25
  { name: "/status", desc: "Tier, routing, tokens, credit and weekly pool", group: "Session" },
22
26
  { name: "/compact", desc: "Summarize the conversation to save context", group: "Session" },
23
27
  { name: "/clear", desc: "Start a fresh conversation", group: "Session" },
24
28
  { name: "/init", desc: "Write a starter AGENTS.md for this project", group: "Project" },
25
29
  { name: "/undo", desc: "Revert the last file the agent wrote", group: "Project" },
30
+ { name: "/hooks", desc: "List hooks and switch them on or off", group: "General" },
31
+ { name: "/update", desc: "Install the latest @synqa/cli", group: "General" },
26
32
  { name: "/help", desc: "Show commands and keys", group: "General" },
27
33
  { name: "/exit", desc: "Leave Synqa", group: "General" },
28
34
  ];
@@ -110,7 +116,7 @@ export function renderMenu(items , selected , ui
110
116
  const ESC = "\x1b";
111
117
 
112
118
  // Draws below the input line and routes keys. One per session, TTY only.
113
- export class PromptFrame {
119
+ export class PromptFrame {
114
120
  input ;
115
121
  rl ;
116
122
  shown = 0; // rows drawn below the input line
@@ -122,6 +128,13 @@ export class PromptFrame {
122
128
  started = false;
123
129
  pasting = false; // between the terminal's paste-start and paste-end marks
124
130
  ctrlC = false; // Ctrl+C was pressed (typed, not pasted)
131
+ capture ; // a selector or secret prompt owns the keys
132
+ lastCols = 0;
133
+ promptStr = "";
134
+ turnAbort ; // set while a response streams: keys are locked
135
+ holds = 0; // open questions during a turn (y/N): keys go to readline again meanwhile
136
+ // Shift+Tab at the prompt (cycles the permission mode); the footer is redrawn after it.
137
+ onShiftTab ;
125
138
 
126
139
  // True once the person pressed Ctrl+C. An end of input is not an interrupt.
127
140
  get interrupted() {
@@ -165,8 +178,29 @@ export class PromptFrame {
165
178
  rl.on("close", () => this.stop());
166
179
  }
167
180
 
181
+ // While a turn runs, typed characters are not echoed or kept: Esc or Ctrl+C calls onAbort and
182
+ // every other key is dropped. Pasted text was already dropped outside the prompt.
183
+ beginTurn(onAbort ) {
184
+ this.turnAbort = onAbort;
185
+ }
186
+
187
+ endTurn() {
188
+ this.turnAbort = undefined;
189
+ }
190
+
191
+ // Runs a question that needs the keyboard (the y/N approval) inside a locked turn.
192
+ async withKeys (fn ) {
193
+ this.holds++;
194
+ try {
195
+ return await fn();
196
+ } finally {
197
+ this.holds--;
198
+ }
199
+ }
200
+
168
201
  // Gives the terminal back: raw mode and bracketed paste off. Safe to call any number of times.
169
202
  stop() {
203
+ this.turnAbort = undefined;
170
204
  if (!this.started) return;
171
205
  this.started = false;
172
206
  this.stdin.off("keypress", this.onKey);
@@ -176,6 +210,35 @@ export class PromptFrame {
176
210
  this.input.end();
177
211
  }
178
212
 
213
+ // KeySource: gives every key to fn and none to readline until the returned function is called.
214
+ // Pasted text reaches fn only when asked for (a secret), with its line breaks dropped.
215
+ subscribe(fn , opts = {}) {
216
+ const mine = { fn, paste: opts.paste === true };
217
+ this.capture = mine;
218
+ return () => { if (this.capture === mine) this.capture = undefined; };
219
+ }
220
+
221
+ // The window changed: when the columns differ, redraw the whole frame (see redrawFrame).
222
+ onResize() {
223
+ const cols = this.cols();
224
+ const prev = this.lastCols || cols;
225
+ this.lastCols = cols;
226
+ if (this.active && !this.capture && cols !== prev) this.redrawFrame(prev);
227
+ }
228
+
229
+ // Rebuilds the framed prompt at the current width: the old top rule is erased (it may have
230
+ // reflowed onto several rows if the window got narrower), then the top rule, the prompt with the
231
+ // typed text, the bottom rule and the footer are drawn again, so no half-width border is left.
232
+ redrawFrame(prevCols ) {
233
+ if (!this.active) return;
234
+ const cols = this.cols();
235
+ const ruleRows = Math.max(1, Math.ceil(Math.max(1, prevCols - 1) / Math.max(1, cols)));
236
+ this.out.write(`\r${ESC}[${ruleRows}A${ESC}[J` + rule(this.ui) + "\n" + this.promptStr + this.line());
237
+ this.shown = 0;
238
+ this.placeholder = false;
239
+ this.draw();
240
+ }
241
+
179
242
  line() {
180
243
  return (this.rl )?.line ?? "";
181
244
  }
@@ -189,6 +252,8 @@ export class PromptFrame {
189
252
  // The rule above the prompt; then the prompt itself is asked by the caller.
190
253
  async ask(promptText ) {
191
254
  const rl = this.rl ;
255
+ this.promptStr = promptText;
256
+ this.lastCols = this.cols();
192
257
  this.out.write(rule(this.ui) + "\n");
193
258
  this.active = true;
194
259
  this.pasteOk = true;
@@ -276,7 +341,21 @@ export class PromptFrame {
276
341
  key(s , key ) {
277
342
  const seq = key?.sequence ?? s ?? "";
278
343
  if (key?.name === "paste-start") { this.pasting = true; return; }
279
- if (key?.name === "paste-end") { this.pasting = false; this.after(); return; }
344
+ if (key?.name === "paste-end") { this.pasting = false; if (!this.capture) this.after(); return; }
345
+ if (this.capture) {
346
+ if (this.pasting) {
347
+ const k = key?.name;
348
+ if (this.capture.paste && k !== "return" && k !== "enter" && seq !== "\r" && seq !== "\n") this.capture.fn(s, key);
349
+ return;
350
+ }
351
+ this.capture.fn(s, key);
352
+ return;
353
+ }
354
+ if (this.turnAbort && this.holds === 0) {
355
+ // A turn is streaming: nothing typed reaches readline, so nothing is echoed.
356
+ if (!this.pasting && (seq === "\x03" || key?.name === "escape")) this.turnAbort();
357
+ return;
358
+ }
280
359
  if (!this.pasting && seq === "\x03") this.ctrlC = true;
281
360
  if (this.pasting) {
282
361
  // Pasted text is never a key press. While a y/N question (or a turn) holds the input it is
@@ -290,6 +369,7 @@ export class PromptFrame {
290
369
  if (!this.active) { this.forward(seq); return; }
291
370
  const items = this.menuItems();
292
371
  const name = key?.name;
372
+ if (name === "tab" && key?.shift) { this.onShiftTab?.(); this.draw(); return; }
293
373
  // A lone Esc closes the menu (node reports it with meta set on some versions).
294
374
  if (items.length && name === "escape") { this.dismissed = true; this.draw(); return; }
295
375
  if (items.length && !key?.ctrl && !key?.meta) {
@@ -301,7 +381,6 @@ export class PromptFrame {
301
381
  const pick = items[this.selected] ;
302
382
  if (name === "tab") { this.replaceLine(pick.args ? pick.name + " " : pick.name); this.after(); return; }
303
383
  if (name === "return" || name === "enter") {
304
- if (pick.args && this.line().toLowerCase() !== pick.name) { this.replaceLine(pick.name + " "); this.after(); return; }
305
384
  this.clear();
306
385
  this.replaceLine(pick.name);
307
386
  this.forward("\r");
@@ -0,0 +1,234 @@
1
+ // Providers: Synqa Cloud is the primary one and unlocks first; the others are the user's own
2
+ // accounts (OpenCode Go and Zen, a bring-your-own router key, any OpenAI-compatible endpoint).
3
+ // Their credentials are stored in the private config file and sent only to that provider's URL.
4
+ import { checkKey } from "./api.js";
5
+
6
+ import { fetchAccount } from "./account.js";
7
+ import { API_BASE, looksLikeKey, readConfig, resolveApiKey, resolveProviderKey, saveProvider, writeConfig } from "./config.js";
8
+ import { sanitize, sanitizeModelId } from "./sanitize.js";
9
+ import { TIERS } from "./tiers.js";
10
+
11
+
12
+
13
+
14
+
15
+
16
+ export const PROVIDERS = [
17
+ { id: "synqa", name: "Synqa Cloud", label: "Synqa Cloud (Blink 5.5 · Sonata 6.0 · Aeon 6.5) [Primary]", baseUrl: API_BASE },
18
+ { id: "opencode-go", name: "OpenCode Go", label: "OpenCode Go (Subscription models via OpenCode)", baseUrl: "https://opencode.ai/zen/v1" },
19
+ { id: "opencode-zen", name: "OpenCode Zen", label: "OpenCode Zen (Pay-as-you-go via OpenCode)", baseUrl: "https://opencode.ai/zen/v1" },
20
+ { id: "openrouter", name: "OpenRouter", label: "OpenRouter (Bring your own sk-or-... key)", baseUrl: "https://openrouter.ai/api/v1" },
21
+ { id: "custom", name: "Custom endpoint", label: "Custom OpenAI-Compatible Endpoint", baseUrl: "" },
22
+ ];
23
+
24
+ export const providerDef = (id ) => PROVIDERS.find((p) => p.id === id);
25
+
26
+ // Shown only when the provider's own /v1/models list cannot be fetched.
27
+ export const CURATED_MODELS = {
28
+ "opencode-go": ["kimi-k2", "grok-code"],
29
+ "opencode-zen": ["grok-code", "kimi-k2"],
30
+ openrouter: ["openrouter/auto", "moonshotai/kimi-k2", "x-ai/grok-code-fast-1"],
31
+ custom: [],
32
+ };
33
+
34
+ // What /model offers. On Synqa Cloud it is the three tiers (the short id after the name); on the
35
+ // others it is the fetched list, or the curated defaults when nothing could be fetched.
36
+ export function modelItems(provider , fetched ) {
37
+ if (provider === "synqa") {
38
+ return TIERS.map((t) => ({ label: `${t.display} (${t.id.replace(/-\d+(\.\d+)*$/, "")})`, value: t.key }));
39
+ }
40
+ const ids = fetched && fetched.length ? fetched : CURATED_MODELS[provider] ?? [];
41
+ return ids.slice(0, 200).map((id) => ({ label: id, value: id }));
42
+ }
43
+
44
+ const trimSlash = (u ) => u.replace(/\/+$/, "");
45
+
46
+ // GET {base}/models. Returns the model ids; throws a short, sanitized message on failure.
47
+ export async function fetchModelIds(baseUrl , secret , fetchFn = fetch, timeoutMs = 8000) {
48
+ const ac = new AbortController();
49
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
50
+ try {
51
+ const res = await fetchFn(`${trimSlash(baseUrl)}/models`, { headers: { authorization: `Bearer ${secret}` }, signal: ac.signal });
52
+ if (res.status === 401 || res.status === 403) throw new Error("the key was rejected");
53
+ if (!res.ok) throw new Error(`the provider answered with status ${res.status}`);
54
+ const body = (await res.json()) ;
55
+ const data = Array.isArray(body.data) ? body.data : [];
56
+ return data
57
+ .map((m) => (m && typeof m === "object" ? (m ).id : undefined))
58
+ .filter((id) => typeof id === "string" && id.length > 0 && id.length <= 200)
59
+ .map((id) => sanitize(id));
60
+ } catch (err) {
61
+ if (ac.signal.aborted) throw new Error("the provider did not answer in time");
62
+ throw new Error(sanitize(err instanceof Error ? err.message : String(err)));
63
+ } finally {
64
+ clearTimeout(timer);
65
+ }
66
+ }
67
+
68
+ // A base URL a person typed: http(s) only, no credentials in it, no query or fragment.
69
+ export function normalizeBaseUrl(raw ) {
70
+ try {
71
+ const u = new URL(raw.trim());
72
+ if (u.protocol !== "https:" && u.protocol !== "http:") return undefined;
73
+ // The key travels in a header: plain http only to this machine.
74
+ if (u.protocol === "http:" && !["localhost", "127.0.0.1", "[::1]"].includes(u.hostname)) return undefined;
75
+ if (u.username || u.password || u.search || u.hash) return undefined;
76
+ return trimSlash(u.toString());
77
+ } catch {
78
+ return undefined;
79
+ }
80
+ }
81
+
82
+ // Verifies a Synqa key against GET /v1/account (the models list when the account route is not
83
+ // available to keys yet). Returns undefined when the key is good, else a short reason.
84
+ export async function verifySynqaKey(candidate , fetchFn ) {
85
+ if (!looksLikeKey(candidate)) return "That does not look like a Synqa key (expected synqa_live_...).";
86
+ const r = await fetchAccount(candidate, fetchFn, 8000);
87
+ if (r.ok) return undefined;
88
+ if (r.notFound) {
89
+ try {
90
+ await checkKey(candidate, fetchFn);
91
+ return undefined;
92
+ } catch (err) {
93
+ return `Could not verify the key: ${sanitize(err instanceof Error ? err.message : String(err))}`;
94
+ }
95
+ }
96
+ return `Could not verify the key: ${sanitize(r.message)}`;
97
+ }
98
+
99
+
100
+
101
+ // The synqa-first gate: nothing else unlocks until a valid Synqa Cloud key is saved. `secret`
102
+ // reads masked input (undefined = cancelled). Returns the key, or undefined when cancelled or
103
+ // out of attempts (the TUI stays locked).
104
+ export async function unlockGate(o
105
+
106
+
107
+
108
+
109
+
110
+ ) {
111
+ const env = o.env ?? process.env;
112
+ for (let i = 0; i < (o.attempts ?? 3); i++) {
113
+ const entered = await o.secret("Synqa Cloud API key (synqa_live_...): ");
114
+ if (entered === undefined) return undefined;
115
+ const why = await verifySynqaKey(entered, o.fetchFn);
116
+ if (why) { o.print(why); continue; }
117
+ writeConfig({ ...readConfig(env), apiKey: entered }, env);
118
+ return entered;
119
+ }
120
+ o.print("Too many attempts. Run synqa again to sign in.");
121
+ return undefined;
122
+ }
123
+
124
+
125
+
126
+
127
+
128
+
129
+
130
+
131
+
132
+
133
+
134
+
135
+
136
+
137
+
138
+
139
+
140
+
141
+ // Makes `id` the active provider. For a secondary one it asks for the key when none is saved,
142
+ // verifies it with GET /models, stores it and picks a first model. Returns false when cancelled.
143
+ export async function activateProvider(st , id , io ) {
144
+ const env = io.env ?? process.env;
145
+ const def = providerDef(id) ;
146
+ if (id === "synqa") {
147
+ const saved = resolveApiKey(env, { envKey: io.envKey });
148
+ if (!saved) { io.print("Not signed in to Synqa Cloud."); return false; }
149
+ st.apiKey = saved; st.providerId = "synqa"; st.baseUrl = API_BASE; st.modelId = undefined;
150
+ writeConfig({ ...readConfig(env), activeProvider: "synqa" }, env);
151
+ io.print("Using Synqa Cloud.");
152
+ return true;
153
+ }
154
+ const entry = readConfig(env).providers?.[id];
155
+ let baseUrl = def.baseUrl;
156
+ let model = entry?.model;
157
+ if (id === "custom") {
158
+ const raw = entry?.baseUrl ?? (await io.ask("Base URL (for example https://host/v1): "));
159
+ if (raw === undefined) return false;
160
+ const norm = normalizeBaseUrl(raw);
161
+ if (!norm) { io.print("That is not a usable base URL (https, or http for localhost only)."); return false; }
162
+ baseUrl = norm;
163
+ }
164
+ let credential = resolveProviderKey(id, env, { envKey: io.envKey });
165
+ if (!credential) {
166
+ credential = await io.secret(`${def.name} API key: `);
167
+ if (!credential) { io.print("Cancelled."); return false; }
168
+ }
169
+ let ids = [];
170
+ try {
171
+ ids = await fetchModelIds(baseUrl, credential, io.fetchFn);
172
+ } catch (err) {
173
+ const msg = err instanceof Error ? err.message : String(err);
174
+ // A custom endpoint may not serve /models at all; a rejected key is always final.
175
+ if (id !== "custom" || /rejected/.test(msg)) { io.print(`Could not verify the key: ${msg}`); return false; }
176
+ io.print(`Could not list models (${msg}); continuing.`);
177
+ }
178
+ if (id === "custom" && !model) {
179
+ const m = await io.ask("Model ID: ");
180
+ if (!m) { io.print("Cancelled."); return false; }
181
+ model = sanitizeModelId(m);
182
+ if (!model) { io.print("That is not a usable model id."); return false; }
183
+ }
184
+ if (!model) model = modelItems(id, ids)[0]?.value;
185
+ saveProvider(id, { apiKey: credential, ...(id === "custom" ? { baseUrl } : {}), ...(model ? { model } : {}) }, env);
186
+ writeConfig({ ...readConfig(env), activeProvider: id }, env);
187
+ st.apiKey = credential; st.providerId = id; st.baseUrl = baseUrl; st.modelId = model;
188
+ io.print(`Using ${def.name}${model ? ` with ${model}` : ""}.`);
189
+ return true;
190
+ }
191
+
192
+ // /provider and /login: the arrow-key menu.
193
+ export async function providerMenu(st , io , arg = "") {
194
+ const wanted = arg.trim().toLowerCase();
195
+ let id = PROVIDERS.find((p) => p.id === wanted)?.id;
196
+ if (!id) {
197
+ const items = PROVIDERS.map((p) => ({ label: p.label, value: p.id, hint: p.id === st.providerId ? "(active)" : undefined }));
198
+ id = await io.pick(items, "Choose a provider");
199
+ }
200
+ if (!id) { io.print("Cancelled."); return false; }
201
+ return activateProvider(st, id, io);
202
+ }
203
+
204
+
205
+
206
+ // /logout. Returns "locked" after a full logout (the caller returns to the sign-in prompt),
207
+ // "secondary" after leaving a secondary provider, or undefined when nothing changed.
208
+ export async function logout(st , io ) {
209
+ const env = io.env ?? process.env;
210
+ const choice = await io.pick ([
211
+ { label: "Log out of active secondary provider", value: "secondary" },
212
+ { label: "Full logout (clear Synqa key and lock TUI)", value: "full" },
213
+ ], "Log out");
214
+ if (!choice) { io.print("Cancelled."); return undefined; }
215
+ const cfg = readConfig(env);
216
+ if (choice === "secondary") {
217
+ if (st.providerId === "synqa") { io.print("No secondary provider is active."); return undefined; }
218
+ const providers = { ...cfg.providers };
219
+ delete providers[st.providerId];
220
+ const synqaKey = cfg.apiKey?.trim() || env.SYNQA_API_KEY?.trim() || "";
221
+ writeConfig({ ...cfg, providers, activeProvider: "synqa" }, env);
222
+ st.providerId = "synqa"; st.baseUrl = API_BASE; st.modelId = undefined; st.apiKey = synqaKey;
223
+ io.print("Logged out of the secondary provider. Using Synqa Cloud.");
224
+ return "secondary";
225
+ }
226
+ const next = { ...cfg, activeProvider: "synqa" };
227
+ delete next.apiKey;
228
+ writeConfig(next, env);
229
+ // The variable is cleared in this process only, so nobody has to unset it in their shell.
230
+ delete env.SYNQA_API_KEY;
231
+ st.apiKey = ""; st.providerId = "synqa"; st.baseUrl = API_BASE; st.modelId = undefined;
232
+ io.print("Logged out of Synqa Cloud. The session is locked until you sign in again.");
233
+ return "locked";
234
+ }
package/dist/sanitize.js CHANGED
@@ -51,3 +51,9 @@ export function fenceText(s , max ) {
51
51
  .slice(0, max)
52
52
  .replace(/</g, "\u2039");
53
53
  }
54
+
55
+ // A model id a person typed or a provider listed: letters, digits and . _ : / @ + - only, 200 at most.
56
+ export function sanitizeModelId(s ) {
57
+ const t = s.trim();
58
+ return /^[A-Za-z0-9._:\/@+-]{1,200}$/.test(t) ? t : undefined;
59
+ }
package/dist/select.js ADDED
@@ -0,0 +1,131 @@
1
+ // Inline terminal controls that take over the keyboard for a moment: an Up/Down selector (pickOne)
2
+ // and a masked secret prompt (readMasked). Both read keys from a KeySource and write to a plain
3
+ // { write } sink, so they are tested with a fake source and a string buffer. The only control
4
+ // sequences written are the fixed ones in this file (cursor up, erase to end of screen).
5
+ import { emitKeypressEvents } from "node:readline";
6
+ import { dim, terracottaBold } from "./theme.js";
7
+
8
+
9
+
10
+
11
+ // Something that delivers key presses and can be listened to exclusively: subscribe returns the
12
+ // function that gives the keyboard back.
13
+
14
+
15
+
16
+ const ESC = "\x1b";
17
+
18
+ // A KeySource over a raw stdin stream (used where no PromptFrame owns the keyboard, and in tests
19
+ // that drive a PassThrough).
20
+ export function streamKeys(stdin ) {
21
+ emitKeypressEvents(stdin);
22
+ return {
23
+ subscribe(fn) {
24
+ const was = (stdin ).isRaw;
25
+ stdin.setRawMode?.(true);
26
+ stdin.on("keypress", fn);
27
+ stdin.resume();
28
+ return () => {
29
+ stdin.off("keypress", fn);
30
+ if (!was) { stdin.setRawMode?.(false); stdin.pause(); }
31
+ };
32
+ },
33
+ };
34
+ }
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
45
+
46
+
47
+ const oneLine = (s ) => s.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim();
48
+
49
+ // Draws the list and returns the number of rows written. Text comes from our own tables or from
50
+ // a provider's model list, so it is cut to one plain line first.
51
+ export function renderPick (items , active , o ) {
52
+ const s = o.style ?? { color: false, depth: 8 };
53
+ const pointer = o.unicode === false ? ">" : "❯";
54
+ const max = Math.max(1, o.max ?? 10);
55
+ const start = Math.max(0, Math.min(active - max + 1, items.length - max));
56
+ return items.slice(start, start + max).map((it, i) => {
57
+ const on = start + i === active;
58
+ const label = oneLine(it.label);
59
+ const hint = it.hint ? " " + dim(s, oneLine(it.hint)) : "";
60
+ return on ? `${terracottaBold(s, `${pointer} ${label}`)}${hint}` : ` ${label}${hint}`;
61
+ });
62
+ }
63
+
64
+ // Up/Down (or k/j) move, Enter confirms, Esc or Ctrl+C cancels. Resolves to the chosen value, or
65
+ // undefined when cancelled. The list is erased on the way out so the transcript stays clean.
66
+ export function pickOne (items , o ) {
67
+ if (items.length === 0) return Promise.resolve(undefined);
68
+ return new Promise((resolve) => {
69
+ let active = Math.min(Math.max(0, o.initial ?? 0), items.length - 1);
70
+ let rows = 0;
71
+ const s = o.style ?? { color: false, depth: 8 };
72
+ const header = o.title ? [dim(s, oneLine(o.title) + (o.unicode === false ? " (up/down, enter, esc)" : " (↑↓ move, enter select, esc cancel)"))] : [];
73
+ const draw = () => {
74
+ const lines = [...header, ...renderPick(items, active, o)];
75
+ let out = rows > 0 ? `\r${ESC}[${rows}A${ESC}[J` : "";
76
+ out += lines.join("\n") + "\n";
77
+ rows = lines.length;
78
+ o.out.write(out);
79
+ };
80
+ const finish = (v ) => {
81
+ off();
82
+ o.out.write(`\r${ESC}[${rows}A${ESC}[J`);
83
+ resolve(v);
84
+ };
85
+ const off = o.keys.subscribe((str, key) => {
86
+ const name = key?.name;
87
+ const seq = key?.sequence ?? str ?? "";
88
+ if (name === "up" || (name === undefined && seq === "k")) active = (active - 1 + items.length) % items.length;
89
+ else if (name === "down" || (name === undefined && seq === "j") || name === "tab") active = (active + 1) % items.length;
90
+ else if (name === "return" || name === "enter") { finish((items[active] ).value); return; }
91
+ else if (name === "escape" || (key?.ctrl && name === "c") || seq === "\x03") { finish(undefined); return; }
92
+ else return;
93
+ draw();
94
+ });
95
+ draw();
96
+ });
97
+ }
98
+
99
+
100
+
101
+ // A secret read one key at a time; each character shows as a bullet and nothing typed is echoed.
102
+ // Pasted text arrives as characters and is accepted; Enter ends it, Esc or Ctrl+C cancels
103
+ // (undefined). Backspace removes one character and one bullet.
104
+ export function readMasked(prompt , o ) {
105
+ const bullet = o.unicode === false ? "*" : "•";
106
+ return new Promise((resolve) => {
107
+ let value = "";
108
+ const finish = (v ) => {
109
+ off();
110
+ o.out.write("\n");
111
+ resolve(v);
112
+ };
113
+ o.out.write(prompt);
114
+ const off = o.keys.subscribe((str, key) => {
115
+ const name = key?.name;
116
+ const seq = key?.sequence ?? str ?? "";
117
+ if (name === "return" || name === "enter" || seq === "\r" || seq === "\n") { finish(value.trim()); return; }
118
+ if (name === "escape" || (key?.ctrl && name === "c") || seq === "\x03") { finish(undefined); return; }
119
+ if (name === "backspace" || seq === "\x7f" || seq === "\b") {
120
+ if (value.length > 0) { value = Array.from(value).slice(0, -1).join(""); o.out.write("\b \b"); }
121
+ return;
122
+ }
123
+ if (key?.ctrl || key?.meta) return;
124
+ // Printable text only (a paste may carry several characters; control characters are dropped).
125
+ const text = Array.from(seq).filter((ch) => ch >= " " && ch !== "\x7f" && !/[\u0080-\u009f]/.test(ch)).join("");
126
+ if (!text) return;
127
+ value += text;
128
+ o.out.write(bullet.repeat(Array.from(text).length));
129
+ }, { paste: true });
130
+ });
131
+ }
package/dist/tiers.js CHANGED
@@ -9,7 +9,7 @@ export const TIERS = [
9
9
  export const DEFAULT_TIER = "blink";
10
10
 
11
11
  // Reasoning effort the user can pick. "default" sends no reasoning_effort at all.
12
- export const EFFORTS = ["default", "low", "medium"] ;
12
+ export const EFFORTS = ["default", "low", "medium", "high"] ;
13
13
 
14
14
 
15
15
  export function findEffort(name ) {
package/dist/tools.js CHANGED
@@ -5,6 +5,8 @@ import { dirname, isAbsolute, join, relative, sep } from "node:path";
5
5
  import { MAX_DIFF_LINES, renderDiff, unifiedDiff } from "./diff.js";
6
6
  import { snapshot } from "./journal.js";
7
7
 
8
+ import { MUTATING_TOOLS } from "./modes.js";
9
+
8
10
  import { commandTouchesSecrets, confine, fold, isHomeDir, isSecretStemSource, isSensitivePath, writeConfined } from "./paths.js";
9
11
  import { visibleEscapes } from "./sanitize.js";
10
12
 
@@ -73,6 +75,7 @@ export function bashEnv(env )
73
75
 
74
76
 
75
77
 
78
+
76
79
 
77
80
 
78
81
  const MAX_OUT = 20_000;
@@ -174,6 +177,8 @@ export function riskReasons(root , abs ) {
174
177
  if (/\/(\.config|\.local\/share)\/systemd\//.test(full)) reasons.push("a systemd unit (runs as a service)");
175
178
  if (full.includes("/.config/fish/")) reasons.push("a fish startup file (runs in every new shell)");
176
179
  if (full.includes("/.local/bin/")) reasons.push("a program on your PATH");
180
+ if (full.includes("/start menu/programs/startup/")) reasons.push("a Windows startup entry (runs at login)");
181
+ if (dirs.includes(".synqa") && base === "hooks.json") reasons.push("Synqa hooks (commands that run at session start and around tools)");
177
182
  if (rel.startsWith(".git/hooks/") || rel.includes("/.git/hooks/")) reasons.push("a git hook (runs on git actions)");
178
183
  else if (dirs.includes(".git")) reasons.push("inside .git");
179
184
  if (base === "package.json") reasons.push("package.json (scripts can run on install or test)");
@@ -396,7 +401,10 @@ function str(a , k ) {
396
401
  // Never throws: failures come back as text so the model can react.
397
402
  export async function runTool(ctx0 , name , args ) {
398
403
  // --yes never applies when the workspace is the home folder: too much there can run code later.
399
- const ctx = ctx0.yes && isHomeDir(ctx0.cwd, ctx0.home) ? { ...ctx0, yes: false } : ctx0;
404
+ // The auto-accept mode is --yes for file edits only, with the same limits.
405
+ if (ctx0.mode === "plan" && MUTATING_TOOLS.includes(name)) return "error: plan only mode is on, so nothing was changed or run. Describe the plan instead; the user leaves this mode with Shift+Tab.";
406
+ const yes = (ctx0.yes || ctx0.mode === "auto") && !isHomeDir(ctx0.cwd, ctx0.home);
407
+ const ctx = yes === ctx0.yes ? ctx0 : { ...ctx0, yes };
400
408
  try {
401
409
  switch (name) {
402
410
  case "read_file": return await toolRead(ctx, args);